diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f4baaa3..5244db1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,15 +14,18 @@ jobs: id-token: write # required for trusted publishing steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: 3.11 - cache: 'pip' + python-version: 3.12 - name: Install dependencies - run: pip install .[build] + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.local/bin" >> $GITHUB_PATH + uv venv + uv sync --extra=dev - name: Build package - run: hatch build + run: uv build - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78446a2..d0035c9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,19 +25,28 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - uses: actions/setup-python@v4 with: python-version: ${{env.PYTHON_LATEST}} - - uses: pre-commit/action@v3.0.0 + - name: Install UV + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.local/bin" >> $GITHUB_PATH + uv venv + uv sync --all-extras + . .venv/bin/activate + - name: Run ruff + run: | + . .venv/bin/activate + ruff check . + ruff format --check . test: runs-on: ubuntu-latest needs: lint strategy: matrix: - python: ['3.11', '3.12'] + python: ['3.12', '3.13'] steps: - uses: actions/checkout@v4 @@ -45,9 +54,17 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} + - name: Install UV + run: | + curl -LsSf https://astral.sh/uv/install.sh | sh + echo "$HOME/.local/bin" >> $GITHUB_PATH + - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install tox tox-gh-actions + uv venv + . .venv/bin/activate + uv pip install -e '.[dev,hy]' - name: Test with tox - run: tox -- -v + run: | + . .venv/bin/activate + tox -- -v diff --git a/.gitignore b/.gitignore index 9c4d81d..873f7ed 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,4 @@ prof profiling.py sudoku.py .ccls-cache +.aider* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb44b21..d4753ae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,9 +18,15 @@ repos: - id: blacken-docs additional_dependencies: [black==23.10.0] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.1.2' + rev: 'v0.7.1' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format args: [--check] + - repo: https://github.com/astral-sh/uv-pre-commit + # uv version. + rev: 0.4.30 + hooks: + # Update the uv lockfile + - id: uv-lock diff --git a/README.md b/README.md index 9eb5be4..9c346fc 100644 --- a/README.md +++ b/README.md @@ -1,168 +1,204 @@ # microkanren -`microkanren` is an implementation of a miniKanren style relational programming language, embedded in Python. The solver is implemented in the style of μKanren[^1]. It provides a framework for extending the language with constraints, as well as a basic implementation of disequality and finite domain constraints, in the style of cKanren[^2]. - -Due to the differences between Python and the reference implementation languages (Scheme, Racket), some divergences from the typical miniKanren API are necessary. It is a goal to capture the spirit of the miniKanren language family, but not the exact API. - -* [Installation](#installation) -* [Usage](#usage) - + [Basic usage](#basic-usage) - + [Conjunction and disjunction](#conjunction-and-disjunction) - + [The result type and multiple top-level variables](#the-result-type-and-multiple-top-level-variables) - + [Defining goal constructors](#defining-goal-constructors) - - [Recursive goal constructors and `snooze` (Zzz)](#recursive-goal-constructors-and--snooze---zzz-) -* [Developing microkanren](#developing-microkanren) +`microkanren` is an implementation of a miniKanren style relational programming language. The solver is implemented in the style of μKanren[^1], with a Hy-based interface that closely follows the traditional miniKanren API. The core implementation is in Python and can be used directly for advanced use cases. ## Installation -``` bash -pip install microkanren +```bash +pip install microkanren[hy] # For the Hy interface +pip install microkanren # For just the Python core ``` -## Usage +## Usage with Hy + +The main interface is provided through Hy macros and functions in `microkanren.lang`. Here's a basic example: + +```clojure +(require microkanren.lang *) +(import microkanren.lang *) + +;; Basic unification +(run* [q] + (== q 5)) ; Returns [#(5)] + +;; Conjunction with fresh variables +(fresh [x y] + (== x 1) + (== y 2) + (== q [x y])) + +;; Disjunction with conde +(conde + [(== q 'apple)] + [(== q 'orange)]) + +;; Basic pattern-matching support with defne +(defne ancestor° [x y] + ([x y] + (parent° x y)) + ([x y] + (fresh [z] + (parent° x z) + (ancestor° z y)))) +``` -### Basic usage +### Tabling Support -The basic goal constructor is `eq`. `eq` takes two terms as arguments, and returns a goal that will succeed if the terms can be unified, and fails otherwise. +Relations can be tabled, using the `tabled` decorator to improve performance, and in some cases avoid divergence: -``` python-console ->>> from microkanren import eq ->>> eq("🍕", "🍕") - +```clojure +(defn [core.tabled] path° [x y] + (conde + [(arc° x y)] + [(fresh [z] + (arc° x z) + (path° z y))])) ``` -To run a goal, use one of the provided interfaces: `run`, `run_all`, or `irun`. `run` takes two arguments: +### Tutorial: Building a Pizza Recommendation System + +Let's build a simple pizza recommendation system to demonstrate how relational programming works: + +```clojure +(require microkanren.lang *) +(import microkanren.lang *) + +;; Define our pizza database +(defn likes° [person topping] + (conde + [(== person 'alice) (== topping 'mushroom)] + [(== person 'alice) (== topping 'olive)] + [(== person 'bob) (== topping 'pepper)] + [(== person 'bob) (== topping 'mushroom)])) + +;; Find toppings that both people like +(defn common-topping° [p1 p2 topping] + (fresh [] + (likes° p1 topping) + (likes° p2 topping))) + +;; Query examples: +;; What does Alice like? +(run* [q] + (likes° 'alice q)) +;; => [#(mushroom) #(olive)] + +;; Who likes mushrooms? +(run* [q] + (likes° q 'mushroom)) +;; => [#(alice) #(bob)] + +;; What toppings do Alice and Bob have in common? +(run* [q] + (common-topping° 'alice 'bob q)) +;; => [#(mushroom)] +``` -1. an integer, the maximum number of results to return; and -2. a callable with positional-only arguments, each of which will receive a fresh logic variable. +## Python Core API -`run_all` and `irun` take a single argument, the fresh-var-receiver. +The core implementation is available in `microkanren.core` for direct use from Python: -``` python-console ->>> from microkanren import run ->>> run(1, lambda x: eq(x, "🍕")) -['🍕'] -``` +```python +from microkanren.core import eq, State, empty_state, reify -The return type of `run` and `run_all` is a (possibly-empty) list of results. If the list is empty, there are no solutions that satisfy the goal. `irun` returns a generator that yields single results. - -``` python-console ->>> from microkanren import irun ->>> rs = irun(lambda x: eq(x, "😁")) ->>> next(rs) -'😁' ->>> next(rs) -Traceback (most recent call last): - File "", line 1, in -StopIteration -``` +# Create a goal that unifies two terms +goal = eq("x", "x") -### Conjunction and disjunction +# Run the goal with an empty state +stream = goal(empty_state()) -Conjunction and disjunction are provided by the vararg `conj` and `disj` functions. `Goal` objects support combination using `|` and `&` operators, which map to `conj` and `disj`. - -``` python-console ->>> from microkanren import run_all ->>> run_all(lambda x: disj(eq(x, "α"), eq(x, "β"), eq(x, "δ"))) -['α', 'β', 'δ'] ->>> run_all(lambda x: eq(x, "α") | eq(x, "β") | eq(x, "δ")) -['α', 'β', 'δ'] ->>> run_all(lambda x: eq(x, "ω") & eq(x, "ω")) -['ω'] ->>> run_all(lambda x: conj(eq(x, "ω"), eq(x, "ω"))) -['ω'] +# Process results +five_states = take(5, stream) ``` -### The result type and multiple top-level variables +## Developing microkanren + +Requirements: +- Python 3.12 +- Hy 1.0+ + +1. `git clone git@github.com:jams2/microkanren.git` +2. `pip install -e '.[dev,hy]'` -If the fresh-var-receiver provided to an interface has arity 1, results will be single elements. If it has arity > 1, the results will be a tuple of values, each mapping position-wise to the receiver's arguments. +Run tests with `pytest`. -``` python-console ->>> run_all(lambda x, y: eq(x, "foo") & eq(y, "bar") | eq(x, "hello") & eq(y, "world")) -[('foo', 'bar'), ('hello', 'world')] +Format code: +```bash +ruff check --fix src tests +ruff format src tests ``` -### Defining goal constructors +## API Reference + +### Core Operators -Calling goal constructors in your top-level program quickly becomes unwieldy. To mitigate this, you can define your own goal constructors. +#### == (unification) -A goal constructor is a function that takes zero or more arguments, and returns a `Goal` (or some object that implements the `GoalProto`). +```clojure +(== term1 term2) +``` -A `Goal` is a callable that takes a `State` and returns a `Stream` of `State` objects. +Unifies two terms, succeeding if they can be made equal. Basic building block for relations. -A `Stream` is either: -- empty (`mzero`); -- a callable of no arguments that returns a `Stream` (a thunk); or -- a tuple, `(State, Stream)`. +#### fresh (variable introduction) -``` python-console ->>> def likes_pizza(person, out): -... return eq(out, (person, "likes 🍕")) -... ->>> run_all(lambda q: likes_pizza("Jane", q) | likes_pizza("Bill", q)) -[('Jane', 'likes 🍕'), ('Bill', 'likes 🍕')] +```clojure +(fresh [x y z ...] goal ...) ``` -As shown in the above example, it can be convenient to define goals in terms of the combination of other goals. However, if you require access to the current state, you can define the goal returned by your goal constructor explicitly. +Introduces new logic variables into scope. Returns conjunction of all goals. -``` python -def my_constructor(x): - def _my_constructor(state): - if there_is_something_about(x): - return unit(state) - return mzero +#### conde (disjunction) - return Goal(_my_constructor) +```clojure +(conde + [goal1 ...] + [goal3 ...]) ``` -Wrapping your goal with `Goal` means it will be combinable with other goals using `|` and `&`. +Returns disjunction of conjunctions. Each clause represents an "or" branch. -#### Recursive goal constructors and `snooze` (Zzz) +### Running Queries -If your goal constructor is directly recursive, it will never terminate. +#### run* (unlimited results) -``` python-console ->>> def always_pizza(x): -... return eq(x, "🍕") | always_pizza(x) -... ->>> run(1, lambda x: always_pizza(x)) -... -RecursionError: maximum recursion depth exceeded while calling a Python object +```clojure +(run* [q ...] goal) ``` -We provide `snooze` to delay the construction of a goal until it is needed. Using `snooze` we can fix `always_pizza` to return an infinite stream of pizza[^3]. - -``` python-console ->>> def always_pizza(x): -... return eq(x, "🍕") | snooze(always_pizza, x) -... ->>> rs = irun(lambda x: always_pizza(x)) ->>> next(rs) -'🍕' ->>> next(rs) -'🍕' ->>> next(rs) -'🍕' ->>> next(rs) -'🍕' +Returns all results that satisfy the goals. + +#### run (limited results) + +```clojure +(run n [q ...] goal) ``` -## Developing microkanren +Returns at most n results that satisfy the goals. -`microkanren` currently requires Python 3.11. +### Relation Definition -1. `git clone git@github.com:jams2/microkanren.git` -2. `pip install -e .[dev,testing]` +#### defne (pattern matching) +```clojure +(defne relation° [args] + ([pattern1] goal1 ...) + ([pattern2] goal2 ...) + ...) -Run the tests with `pytest`. +``` -Format code with `black` and `ruff`: +Defines a relation using pattern matching. Patterns are unified with args. +- Use `_` for anonymous variables +- Variables in patterns become fresh variables, unless they are bound by the parameters +- Multiple clauses create disjunctions -``` bash -black . -ruff check --fix src tests +#### core.tabled (tabling decorator) + +```clojure +(defn [core.tabled] relation° [x y] + goals ...) ``` +Tables the defined relation. + [^1]: [μKanren: A Minimal Functional Core for Relational Programming (Hemann & Friedman, 2013)](http://webyrd.net/scheme-2013/papers/HemannMuKanren2013.pdf) -[^2]: [cKanren: miniKanren with constraints (Alvis et al, 2011)](http://www.schemeworkshop.org/2011/papers/Alvis2011.pdf) -[^3]: original example `fives` from the μKanren paper altered here to provide more pizza diff --git a/pyproject.toml b/pyproject.toml index 77bb6e5..eaf5950 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [ ] description = "A Python implementation of microkanren extended with constraints" readme = "README.md" -requires-python = ">=3.11,<3.13" +requires-python = ">=3.12,<3.13" classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", @@ -17,7 +17,7 @@ classifiers = [ ] dependencies = [ "pyrsistent >= 0.19, < 1", - "fastcons == 0.4.1", + "fastcons == 0.5.0", "immutables >= 0.19, < 1", ] license = {file = "LICENSE"} @@ -27,9 +27,19 @@ license = {file = "LICENSE"} "Bug Tracker" = "https://github.com/jams2/microkanren/issues" [project.optional-dependencies] -dev = ["ruff == 0.1.2"] -testing = ["tox == 4.11.3", "pytest == 7.2.2", "pytest-profiling == 1.7.0"] -build = ["hatch == 1.7.0"] +hy = [ + "hy>=1.0.0,<2.0.0", + "hyrule>=0.7.0,<1.0.0", +] +dev = [ + "hatch == 1.7.0", + "ruff>=0.7.1", + "pyright>=1.1.377", + "tox>=4.23.2,<5", + "tox-uv>=1.16.0", + "pytest==7.2.2", + "debugpy>=1.8.7", +] [build-system] requires = ["hatchling"] @@ -38,11 +48,27 @@ build-backend = "hatchling.build" [tool.pytest.ini_options] pythonpath = ["src"] minversion = 7.0 +python_functions=[ + "test_*", + "hyx_test_*", +] +filterwarnings = [ + "ignore:ast.NameConstant is deprecated:DeprecationWarning", + "ignore:ast.Str is deprecated:DeprecationWarning", + "ignore::pytest.PytestReturnNotNoneWarning", +] [tool.ruff] line-length = 88 src = ["src", "tests"] target-version = "py311" + +[tool.ruff.lint] +ignore = [ + "S101", # Use of `assert` + "E501", # line-too-long (conflicts with formatter) + "W191", # tab-indentation (conflicts with formatter) +] select = [ "B", # flake8-bugbear "BLE", # flake8-blind-except @@ -64,11 +90,6 @@ select = [ unfixable = [ "ARG", # unused arguments ] -ignore = [ - "S101", # Use of `assert` - "E501", # line-too-long (conflicts with formatter) - "W191", # tab-indentation (conflicts with formatter) -] [isort] known-first-party = ["microkanren"] @@ -76,5 +97,9 @@ lines-between-types = 1 lines-after-imports = 2 [tool.pyright] -pythonVersion = "3.11" +pythonVersion = "3.12" stubPath = "" +reportFunctionMemberAccess = false + +[tool.uv] +dev-dependencies = [] diff --git a/src/microkanren/core.py b/src/microkanren/core.py index d924464..5ec6588 100644 --- a/src/microkanren/core.py +++ b/src/microkanren/core.py @@ -1,630 +1,524 @@ -from collections.abc import Callable, Generator -from dataclasses import dataclass -from functools import partial, reduce, wraps -from typing import Any, Optional, Protocol, TypeAlias, TypeVar - -import immutables -from fastcons import cons, nil -from pyrsistent import PClass, field - -from microkanren.utils import foldr, identity - -NOT_FOUND = object() - - -@dataclass(slots=True, frozen=True) -class Var: - i: int - - -@dataclass(slots=True, frozen=True) -class ReifiedVar: - i: int - - def __repr__(self): - return f"_.{self.i}" - - -Value: TypeAlias = ( - Var | int | str | bool | tuple["Value", ...] | list["Value"] | cons | nil +import itertools +from collections.abc import Callable +from functools import reduce +from typing import ( + Any, + ClassVar, + NamedTuple, + Self, + SupportsIndex, + cast, + overload, ) -Substitution: TypeAlias = immutables.Map[Var, Value] -NeqStore: TypeAlias = list[list[tuple[Var, Value]]] -DomainStore: TypeAlias = immutables.Map[Var, set[int]] -ConstraintFunction: TypeAlias = Callable[["State"], Optional["State"]] -ConstraintStore: TypeAlias = list["Constraint"] - - -def empty_sub() -> Substitution: - return immutables.Map() - - -def empty_domain_store() -> DomainStore: - return immutables.Map() - - -def empty_constraint_store() -> ConstraintStore: - return [] +import immutables +from fastcons import cons -class State(PClass): - sub = field(mandatory=True) - domains = field(mandatory=True) - constraints = field(mandatory=True) - var_count = field(mandatory=True) +# We could rely on Var object identity, but this might make tests +# simpler. +_COUNTER = itertools.count() - @staticmethod - def empty(): - return State( - sub=empty_sub(), - domains=empty_domain_store(), - constraints=empty_constraint_store(), - var_count=0, - ) - def get_domain(self, x: Var) -> set[int] | None: - return self.domains.get(x, None) +class OccursError(Exception): ... -class ConstraintProto(Protocol): - def __call__(self, *args: Any) -> ConstraintFunction: - ... +class Sentinel: ... -@dataclass(slots=True, frozen=True) -class Constraint: - func: ConstraintProto - operands: tuple[Value] +SENTINEL = Sentinel() - def __call__(self, state: State) -> State | None: - return self.func(*self.operands)(state) +class Var: + name: str + _id: int + __match_args__ = ("name",) -Stream: TypeAlias = tuple[()] | Callable[[], "Stream"] | tuple[State, "Stream"] + def __init__(self, name: str | int, _id: int | None = None): + self.name = str(name) + self._id = next(_COUNTER) if _id is None else _id + def __repr__(self) -> str: + return f"Var({self.name})" -class GoalProto(Protocol): - def __call__(self, state: State) -> Stream: - ... + def __str__(self) -> str: + return f"?{self.name}" + def __hash__(self): + return hash((self.__class__, self._id)) -class GoalConstructorProto(Protocol): - def __call__(self, *args: Value) -> GoalProto: - ... + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + return self._id == other._id -class Goal: - def __init__(self, goal: GoalProto): - self.goal = goal +class Symbol: + name: str + _cache: ClassVar[dict[str, Self] | None] = None - def __call__(self, state: State) -> Stream: - # Inverse η-delay happens here - return lambda: self.goal(state) + def __new__(cls, name: str) -> Self: + if (cache := cls._cache) is None: + cache = {} + cls._cache = cache - def __or__(self, other): - return disj(self, other) + if name in cache: + return cache[name] - def __and__(self, other): - return conj(self, other) + instance = super().__new__(cls) + cache[name] = instance + return instance + def __init__(self, name: str): + self.name = name -def goal_from_constraint(constraint: ConstraintFunction) -> GoalProto: - def _goal(state: State) -> Stream: - match constraint(state): - case None: - return mzero - case _ as next_state: - return unit(next_state) + def __repr__(self) -> str: + return str(self) - return Goal(_goal) + def __str__(self) -> str: + return self.name + def __hash__(self): + return hash((self.__class__, self.name)) -class InvalidStream(Exception): - pass + def __eq__(self, other): + return other is self -mzero = () +type Substitution = immutables.Map[Var, Any] -def unit(state: State) -> Stream: - return (state, mzero) +def empty_sub() -> Substitution: + return immutables.Map() -def mplus(s1: Stream, s2: Stream) -> Stream: - match s1: - case (): - return lambda: s2 - case f if callable(f): - return lambda: mplus(s2, f()) - case (head, tail): - return lambda: (head, mplus(s2, tail)) - case _: - raise InvalidStream +class State(NamedTuple): + sub: Substitution -def bind(stream: Stream, g: GoalProto) -> Stream: - match stream: - case (): - return mzero - case f if callable(f): - return lambda: bind(f(), g) - case (s1, s2): - return lambda: mplus(g(s1), bind(s2, g)) - case _: - raise InvalidStream +type TableCache = list -def walk(u: Value, s: Substitution) -> Value: - if isinstance(u, Var): - bound = s.get(u, NOT_FOUND) - if bound is NOT_FOUND: - return u - return walk(bound, s) - return u +class SuspendedStream(NamedTuple): + cache: TableCache + # A suffix of the tabled goal's cached answers. Indicates which of the + # primary call's answer terms this stream has already processed. + answer_terms: list -def extend_substitution(x: Var, v: Value, s: Substitution) -> Substitution: - return s.set(x, v) + # Produces the remainder of the stream. + thunk: Callable[[], "Stream"] -def extend_domain_store(x: Var, fd: set[int], d: DomainStore) -> DomainStore: - return d.set(x, fd) +def ready_stream(ss: SuspendedStream) -> bool: + """ + Does the cache contain new answer terms not yet consumed by the stream? + """ + return ss.cache != ss.answer_terms -def extend_constraint_store( - constraint: Constraint, c: ConstraintStore -) -> ConstraintStore: - return [constraint, *c] +class WaitingStream(list[SuspendedStream]): + @overload + def __getitem__(self, key: SupportsIndex) -> SuspendedStream: ... + @overload + def __getitem__(self, key: slice) -> Self: ... -def occurs(x, v, s): - v = walk(v, s) - match v: - case Var(_) as var: - return var == x - case (a, d) | cons(a, d): - return occurs(x, a, s) or occurs(x, d, s) - case _: - return False - + def __getitem__(self, key): + if isinstance(key, slice): + return self.__class__(super().__getitem__(key)) + else: + return super().__getitem__(key) -def unify(u: Value, v: Value, s: Substitution) -> Substitution | None: - match walk(u, s), walk(v, s): - case (Var(_) as vi, Var(_) as vj) if vi == vj: - return s - case (Var(_) as var, val): - return extend_substitution(var, val, s) - case (val, Var(_) as var): - return extend_substitution(var, val, s) - case cons(x, xs), cons(y, ys): - s1 = unify(x, y, s) - return unify(xs, ys, s1) if s1 is not None else None - case (_, *_) as xs, (_, *_) as ys if ( - len(xs) == len(ys) and type(xs) == type(ys) - ): - for x, y in zip(xs, ys): # NOQA: B905 - s = unify(x, y, s) - if s is None: - return None - return s - case x, y if x == y: - return s - case _: - return None + def __add__(self, value) -> "WaitingStream": + if not isinstance(value, self.__class__): + raise TypeError( + "WaitingStream can only be concatenated with another WaitingStream instance" + ) + return WaitingStream([*self, *value]) + def __repr__(self) -> str: + return f"WaitingStream({super().__repr__()})" -def succeed() -> GoalProto: - def _succeed(state): - return eq(True, True)(state) + def __str__(self) -> str: + return f"WaitingStream({super().__str__()})" - return Goal(_succeed) +type EmptyStream = tuple[()] +type ReadyStream = tuple[State, "Stream"] +type ThunkStream = Callable[[], "Stream"] +type Stream = EmptyStream | ReadyStream | ThunkStream | WaitingStream +type Goal = Callable[[State], Stream] +type GoalConstructor = Callable[..., Goal] -def fail() -> GoalProto: - def _fail(state): - return eq(False, True)(state) - return Goal(_fail) +def empty(stream: Stream): + return stream == () -def snooze(g: GoalConstructorProto, *args: Value) -> GoalProto: - def delayed_goal(state) -> Stream: - return g(*args)(state) +def empty_state(): + return State(empty_sub()) - return Goal(delayed_goal) +def walk(candidate: Any, sub: Substitution) -> Any: + while isinstance(candidate, Var): + result = sub.get(candidate, SENTINEL) + if result is SENTINEL: + return candidate + candidate = result + return candidate -def delay(g: GoalProto) -> GoalProto: - return Goal(lambda state: g(state)) +def deep_walk(candidate: Any, sub: Substitution) -> Any: + """ + Like `walk', but reify elements of lists/tuples. + """ + candidate = walk(candidate, sub) + if isinstance(candidate, list | tuple): + container = type(candidate) + return container(deep_walk(x, sub) for x in candidate) + elif isinstance(candidate, cons): + return cons(deep_walk(candidate.head, sub), deep_walk(candidate.tail, sub)) + else: + return candidate -def disj(*goals: GoalProto) -> GoalProto: - return foldr(_disj, fail(), goals) +def extend_substitution( + x: Var, v: Any, sub: Substitution, occurs_check: bool = True +) -> Substitution: + if occurs_check and occurs(x, v, sub): + raise OccursError(f"occurs_check failed ({x=}, {v=}, {sub=})") + return sub.set(x, v) -def _disj(g1: GoalProto, g2: GoalProto) -> GoalProto: - def __disj(state: State) -> Stream: - return mplus(g1(state), g2(state)) - return Goal(__disj) +def occurs(x: Var, v: Any, sub: Substitution) -> bool: + """ + Does `x' occur in `v' with regards to `sub'? + """ + v = walk(v, sub) + if isinstance(v, Var): + return v == x + elif isinstance(v, cons): + return occurs(x, v.head, sub) or occurs(x, v.tail, sub) + elif isinstance(v, list | tuple): + return any(occurs(x, term, sub) for term in v) + else: + return False -def conj(*goals: GoalProto) -> GoalProto: - return foldr(_conj, succeed(), goals) +def unit(state: State) -> ReadyStream: + return (state, mzero()) -def _conj(g1: GoalProto, g2: GoalProto) -> GoalProto: - def __conj(state: State) -> Stream: - return bind(g1(state), g2) +def succeed(state: State) -> ReadyStream: + return unit(state) - return Goal(__conj) +def mzero() -> EmptyStream: + return () -def eq(u: Value, v: Value) -> GoalProto: - def _eqc(state: State) -> State | None: - new_sub = unify(u, v, state.sub) - if new_sub is None: - # Unification failed - return None - elif new_sub == state.sub: - # Unification succeeded without new associations - return state - else: - prefix = get_sub_prefix(new_sub, state.sub) - return Hooks.process_prefix(prefix, state.constraints)( - state.set(sub=new_sub) - ) - return goal_from_constraint(_eqc) +def fail(_: State) -> EmptyStream: + return mzero() -def unify_all( - constraint: list[tuple[Var, Value]], sub: Substitution -) -> Substitution | None: - match constraint: - case []: - return sub - case [(a, d), *rest]: - s = unify(a, d, sub) - if s is not None: - return unify_all(rest, s) - return None - case _: - return None - - -def pairs(xs): - _xs = iter(xs) - while True: - try: - a = next(_xs) - except StopIteration: - break - try: - b = next(_xs) - yield (a, b) - except StopIteration as err: - raise ValueError("got sequence with uneven length") from err - - -def unpairs(xs): - for a, b in xs: - yield a - yield b - - -def maybe_unify( - pair: tuple[Value, Value], sub: Substitution | None -) -> Substitution | None: - if sub is None: - return None - u, v = pair - return unify(u, v, sub) - - -def flip(f): - @wraps(f) - def _flipped(x, y): - return f(y, x) - - return _flipped - - -def neq(u, v, /, *rest) -> GoalProto: - return goal_from_constraint(neqc(u, v, *rest)) - - -def neqc(u, v, *rest) -> ConstraintFunction: - def _neqc(state: State) -> State | None: - new_sub = reduce(flip(maybe_unify), pairs(rest), unify(u, v, state.sub)) - if new_sub is None: - return state - elif new_sub == state.sub: - return None - prefix = get_sub_prefix(new_sub, state.sub) - remaining_pairs = tuple(prefix.items()) - return state.set( - constraints=extend_constraint_store( - Constraint(neqc, tuple(unpairs(remaining_pairs))), state.constraints - ) - ) +def eq(u: Any, v: Any) -> Goal: + def _eq(state: State) -> EmptyStream | ReadyStream: + maybe_sub: Substitution | Sentinel = unify(u, v, state.sub) + if isinstance(maybe_sub, Sentinel): + return mzero() + return unit(State(maybe_sub)) - return _neqc + return _eq -def any_relevant_vars(operands, values): - match operands: - case Var(_) as v: - return v in values - case [first, *rest]: - return any_relevant_vars(first, values) or any_relevant_vars(rest, values) +def unify(u: Any, v: Any, s: Substitution) -> Substitution | Sentinel: + """ + Unify u and v in the Substitution s. + """ + match walk(u, s), walk(v, s): + case Var(_) as x, Var(_) as y if x is y: + return s + case Var(_) as x, y: + return extend_substitution(x, y, s) + case x, Var(_) as y: + return extend_substitution(y, x, s) + case cons(x, xs), cons(y, ys): + s1 = unify(x, y, s) + return SENTINEL if isinstance(s1, Sentinel) else unify(xs, ys, s1) + case (x, *xs) as m, (y, *ys) as n if len(m) == len(n) and type(m) is type(n): + result = unify(x, y, s) + if isinstance(result, Sentinel): + return SENTINEL + return unify(xs, ys, result) + case x, y if x == y: + return s case _: - return False - + return SENTINEL -def bind_constraints(f, g) -> ConstraintFunction: - def _bind_constraints(state: State) -> State | None: - maybe_state = f(state) - return g(maybe_state) if maybe_state is not None else None - return _bind_constraints +def call_fresh(f: Callable[[Var], Goal]) -> Goal: + """ + Return a goal that calls `f' (a goal constructor) with a fresh logic variable. + """ - -def compose_constraints(f, g) -> ConstraintFunction: - return bind_constraints(f, g) - - -def run_constraints( - xs: list[Var] | set[Var], constraints: ConstraintStore -) -> ConstraintFunction: - match constraints: - case []: - return identity - case [first, *rest]: - if any_relevant_vars(first.operands, xs): - return compose_constraints( - remove_and_run(first), - run_constraints(xs, rest), + def _goal(state: State) -> Stream: + # If the goal constructor was tabled, get the actual goal + # constructor function so the var name is relevant. + actual_gc = getattr(f, "__wrapped_goal_constructor__", f) + return f(Var(actual_gc.__code__.co_varnames[0]))(state) + + return _goal + + +def bind(stream: Stream, g: Goal) -> Stream: + if empty(stream): + return mzero() + elif callable(stream): + return lambda: bind(stream(), g) + elif isinstance(stream, WaitingStream): + return w_check( + stream, + # Success: the WaitingStream can contribute new answers. + lambda x: lambda: bind(x(), g), + # Failure: the WaitingStream cannot contribute new answers. + lambda: WaitingStream( + SuspendedStream( + x.cache, + x.answer_terms, + lambda x=x: bind(x.thunk(), g), ) - else: - return run_constraints(xs, rest) - case _: - raise ValueError("Invalid constraint store") - - -def remove_and_run(constraint: Constraint) -> ConstraintFunction: - def _remove_and_run(state: State) -> State | None: - if constraint in state.constraints: - constraints = [x for x in state.constraints if x != constraint] - return constraint(state.set(constraints=constraints)) - else: - return state - - return _remove_and_run - - -def process_prefix_neq( - prefix: Substitution, constraints: ConstraintStore -) -> ConstraintFunction: - prefix_vars = {x for x in prefix.keys() if isinstance(x, Var)} - prefix_vars.update({x for x in prefix.values() if isinstance(x, Var)}) - return run_constraints(prefix_vars, constraints) - - -def enforce_constraints_neq(_: Var) -> GoalProto: - return lambda state: unit(state) - - -def reify_constraints_neq(_: Var, __: Substitution) -> ConstraintFunction: - return identity - - -class UnboundVariables(Exception): - pass - - -def force_answer(x: Var | list[Var]) -> GoalProto: - def _force_answer(state: State) -> Stream: - match walk(x, state.sub): - case Var(_) as var if (d := state.get_domain(var)) is not None: - return map_sum(lambda val: eq(x, val), d)(state) - case (first, *rest) | cons(first, rest): - return conj(force_answer(first), force_answer(rest))(state) - case _: - return succeed()(state) - - return _force_answer - - -A = TypeVar("A") - - -def map_sum(goal_constructor: Callable[[A], GoalProto], xs: list[A]) -> GoalProto: - return reduce(lambda accum, x: disj(accum, goal_constructor(x)), xs, fail()) - - -def get_sub_prefix(new_sub: Substitution, old_sub: Substitution) -> Substitution: - mutation = new_sub.mutate() - for k in new_sub: - if k in old_sub: - del mutation[k] - return mutation.finish() - + for x in stream + ), + ) + else: + head, tail = cast(tuple[State, Stream], stream) + return mplus(g(head), bind(tail, g)) + + +def mplus(left: Stream, right: Stream) -> Stream: + if empty(left): + return right + elif callable(left): + return lambda: mplus(right, left()) + elif isinstance(left, WaitingStream): + return w_check( + left, + # Success: the WaitingStream can contribute new answers. + lambda x: lambda: mplus(right, x), + # Failure: the WaitingStream cannot contribute new answers. + lambda: right + left + if isinstance(right, WaitingStream) + else mplus(right, lambda: left), + ) + else: + head, tail = cast(tuple[State, Stream], left) + return (head, mplus(tail, right)) -def fresh(fp: Callable) -> GoalProto: - n = fp.__code__.co_argcount - def _fresh(state: State) -> Stream: - i = state.var_count - vs = (Var(j) for j in range(i, i + n)) - return fp(*vs)(state.set(var_count=i + n)) +def disj(g1: Goal, g2: Goal) -> Goal: + def _disj(state: State): + return mplus(g1(state), g2(state)) - return Goal(_fresh) + return _disj -def freshn(n: int, fp: Callable) -> GoalProto: - def _fresh(state: State) -> Stream: - i = state.var_count - vs = (Var(j) for j in range(i, i + n)) - return fp(*vs)(state.set(var_count=i + n)) +def conj(g1: Goal, g2: Goal) -> Goal: + def _conj(state: State): + return bind(g1(state), g2) - return Goal(_fresh) + return _conj -def pull(s: Stream): - while callable(s): - s = s() - return s +### Reification -def take_all(s: Stream) -> list[State]: - result = [] - rest = s - while (s := pull(rest)) != (): - first, rest = s - result.append(first) - return result +def pull(x): + while callable(x): + x = x() + return x -def take(n, s: Stream) -> list[State]: - i = 0 - result = [] - rest = s - while i < n and (s := pull(rest)) != (): - first, rest = s - result.append(first) - i += 1 - return result +def raise_(exc): + raise exc -def itake(s: Stream) -> list[State]: - rest = s - while (s := pull(rest)) != (): - first, rest = s - yield first +def take(n: int, stream: Stream): + if n == 0: + return [] + s = pull(stream) + if empty(s): + return [] + elif isinstance(s, WaitingStream): + return take( + n, + w_check( + s, + lambda x: x, + lambda: mzero(), + ), + ) + else: + a, d = cast(ReadyStream, s) + return [a, *take(n - 1, d)] -def reify(states: list[State], var: Var): - return [reify_state(s, var) for s in states] +def reify_symbol(i: int) -> Symbol: + return Symbol(f"_.{i}") -def ireify(states: Generator[State, None, None], var: Var): - yield from (reify_state(s, var) for s in states) +def make_reify(representation): + def reify(v: Any, s: Substitution): + v = deep_walk(v, s) + return deep_walk(v, reify_sub(representation, v, empty_sub())) + return reify -def reify_state(state: State, v: Var) -> Value: - v = walk_all(v, state.sub) - reified_sub = reify_sub(v, empty_sub()) - v = walk_all(v, reified_sub) - return Hooks.reify_value(Hooks.reify_constraints(v, reified_sub, state)) +def reify_sub(representation: Callable, v: Any, sub: Substitution) -> Substitution: + v = walk(v, sub) + if isinstance(v, Var): + return extend_substitution(v, representation(len(sub)), sub, occurs_check=False) + elif isinstance(v, list | tuple): + return reduce(lambda s, x: reify_sub(representation, x, s), v, sub) + else: + return sub -def walk_all(v: Value, s: Substitution) -> Value: - v = walk(v, s) - match v: - case _ if isinstance(v, Var): - return v - case cons(a, d): - return cons(walk_all(a, s), walk_all(d, s)) - case (first, *rest) as xs: - if isinstance(xs, list): - return [walk_all(first, s), *walk_all(rest, s)] - return (walk_all(first, s), *walk_all(rest, s)) - case _: - return v +# Reify unbound logic variables as Symbols. +reify = make_reify(reify_symbol) -def reify_sub(v: Value, s: Substitution) -> Substitution: - # Allows us to control how fresh Vars are reified - v = walk(v, s) - match v: - case _ if isinstance(v, Var): - return extend_substitution(v, Hooks.reify_var(v, s), s) - case (a, *d) | cons(a, d): - return reify_sub(d, reify_sub(a, s)) - case _: - return s +# Reify unbound logic variables as fresh logic variables. +reify_var = make_reify(Var) +# Apparently like Prolog's copy_term/2. +reify_tabled_var = make_reify(lambda i: Var(str(i))) -def call_with_empty_state(g: GoalProto) -> Stream: - return g(State.empty()) +### Tabling -def run(n: int, f_fresh_vars: Callable[..., GoalProto]): - return _run(f_fresh_vars, partial(take, n), reify) +class Table(dict[tuple, TableCache]): ... -def run_all(f_fresh_vars: Callable[..., GoalProto]): - return _run(f_fresh_vars, take_all, reify) +def tabled(gc: GoalConstructor) -> GoalConstructor: + table = Table() -def irun(f_fresh_vars: Callable[..., GoalProto]): - return _run(f_fresh_vars, itake, ireify) + def tabled_gc(*args): + def tabled_goal(state: State) -> Stream: + # Reify `args' in the current substitution, use this as + # the cache key for a master call. + key = reify(args, state.sub) + if key not in table: + table[key] = [] + return conj( + gc(*args), + primary_tabled_call(args, table[key]), + )(state) + # reuse_tabled_results gets a pointer to the cache, not a + # copy, so the suspended streams have access to newly + # cached results. + return reuse_tabled_results(args, table[key], state) -def _run(f_fresh_vars, take, reify): - n_vars = f_fresh_vars.__code__.co_argcount - fresh_vars = tuple(Var(i) for i in range(1, n_vars + 1)) - state = State.empty().set(var_count=n_vars + 1) + return tabled_goal - # We set up `q' and associate it with `fresh_vars' as the first goal - # so all of the top-level vars requested by the user are reified wrt - # the same substitution, giving accurate variable "numbers". Without - # this, we may get incorrect multiple appearances of `_.0', for - # instance. - q = Var(0) - goal = conj( - eq(q, fresh_vars if len(fresh_vars) > 1 else fresh_vars[0]), - f_fresh_vars(*fresh_vars), - *map(Hooks.enforce_constraints, fresh_vars), - ) - return reify(take(goal(state)), q) + tabled_gc._table = table + tabled_gc.__wrapped_goal_constructor__ = gc + return tabled_gc -def default_process_prefix(*_): - return identity +def primary_tabled_call(args: tuple, cache: TableCache) -> Goal: + def _goal(state: State) -> Stream: + (sub,) = state + reified_args = reify(args, sub) + # Check if the result is alpha-equivalent to any previous cached result. + if any(reified_args == reify(result, sub) for result in cache): + # If so, contribute no state. + return mzero() -def default_enforce_constraints(*_): - return succeed() + # Otherwise, we have a new state to cache and contribute to the result. + cache.append(reify_tabled_var(args, sub)) + return unit(state) + return _goal -def default_reify_constraints(value, *_): - return value +def alpha_equivalent(x: Any, y: Any, s: Substitution) -> bool: + return reify(x, s) == reify(y, s) -def default_reify_var(_, substitution): - return ReifiedVar(len(substitution)) +def reuse_tabled_results(args: tuple, cache: TableCache, state: State) -> Stream: + # Fix is called at the beginning of the reuse call, with the whole cache. + # Fix is called as a SS's thunk in w_check, in the success continuation. + def fix(start, end) -> Stream: + def loop(cached_results): + if cached_results == end: + # This will run on the first iteration of `loop'. + return WaitingStream( + # TODO: Should `cache' be a copy of the tabled + # cache, or a pointer to it? + [SuspendedStream(cache, start, lambda: fix(cache, start))] + ) + else: + head, *tail = cached_results + (sub,) = state + + # Produce a new state that is the result of unifying + # the secondary call's args with the first cached + # result. + next_state = State( + subunify(args, reify_tabled_var(head, sub), sub), + ) -def default_reify_value(value): - return value + # Concat the new state with the result of unifying the + # secondary call's args with the rest of the cached + # results. + return mplus( + unit(next_state), + lambda: loop(tail), + ) + return loop(start) + + return fix(cache, []) + + +def subunify(args, cached_result, sub: Substitution) -> Substitution: + args = walk(args, sub) + if args == cached_result: + return sub + elif isinstance(args, Var): + return extend_substitution(args, cached_result, sub, occurs_check=False) + elif isinstance(args, list | tuple): + a, *d = args + b, *e = cached_result + return subunify(d, e, subunify(a, b, sub)) + else: + return sub + + +def w_check( + w: WaitingStream, sk: Callable[[Any], Stream], fk: Callable[[], Stream] +) -> Stream: + # Find the first suspended stream in `w' whose cache contains new + # answer terms. + + def loop(w: WaitingStream, a: WaitingStream) -> Stream: + if not w: + return fk() + elif ready_stream(w[0]): + # The first suspended is can contribute results. Invoke + # its thunk, followed by any remaining suspended streams. + head, *tail = w + _, _, thunk = head + rest_suspended_streams: WaitingStream = a[::-1] + tail + return sk( + lambda: thunk() + if not w + else mplus(thunk(), lambda: rest_suspended_streams) + ) + else: + return loop(w[1:], WaitingStream([w[0], *a])) -class HooksMeta(type): - def _set_hook(cls, name): - def update_hooks(hook_function): - if not hasattr(cls, name): - raise AttributeError( - f"Cannot set unknown hook '{name}' on registry '{cls.__name__}'" - ) - setattr(cls, name, hook_function) - - return update_hooks - - def __getattribute__(cls, attr): - if attr.startswith("set_"): - return cls._set_hook(attr[4:]) - return super().__getattribute__(attr) - - -class Hooks(metaclass=HooksMeta): - process_prefix: Callable[ - [Substitution, ConstraintStore], ConstraintFunction - ] = default_process_prefix - enforce_constraints: Callable[[Var], GoalProto] = default_enforce_constraints - reify_constraints: Callable[ - [Value, Substitution, State], Any - ] = default_reify_constraints - reify_var: Callable[[Var, Substitution], Any] = default_reify_var - reify_value: Callable[[Any], Any] = default_reify_value + return loop(w, WaitingStream()) diff --git a/src/microkanren/fd.py b/src/microkanren/fd.py deleted file mode 100644 index 433e3fa..0000000 --- a/src/microkanren/fd.py +++ /dev/null @@ -1,347 +0,0 @@ -from functools import reduce - -from microkanren.core import ( - Constraint, - ConstraintFunction, - ConstraintStore, - Goal, - GoalProto, - State, - Stream, - Substitution, - Value, - Var, - compose_constraints, - conj, - extend_constraint_store, - extend_domain_store, - extend_substitution, - force_answer, - goal_from_constraint, - run_constraints, - walk, -) -from microkanren.goals import onceo -from microkanren.utils import identity, partition - - -class UnboundConstrainedVariable(Exception): - pass - - -def make_domain(*values: int) -> set[int]: - return set(values) - - -def mkrange(start: int, end: int) -> set[int]: - return make_domain(*range(start, end + 1)) - - -def domfd(x: Value, domain: set[int]) -> GoalProto: - return goal_from_constraint(domfdc(x, domain)) - - -def domfdc(x: Value, domain: set[int]) -> ConstraintFunction: - def _domfdc(state: State) -> State | None: - process_domain_goal = process_domain(x, domain) - return process_domain_goal(state) - - return _domfdc - - -def infd(values: tuple[Value], domain, /) -> GoalProto: - infdc = reduce( - lambda c, v: compose_constraints(c, domfdc(v, domain)), - values, - identity, - ) - return goal_from_constraint(infdc) - - -def ltfd(u: Value, v: Value) -> GoalProto: - return goal_from_constraint(ltfdc(u, v)) - - -def ltfdc(u: Value, v: Value) -> ConstraintFunction: - def _ltfdc(state: State) -> State | None: - _u = walk(u, state.sub) - _v = walk(v, state.sub) - dom_u = state.get_domain(_u) if isinstance(_u, Var) else make_domain(_u) - dom_v = state.get_domain(_v) if isinstance(_v, Var) else make_domain(_v) - - next_state = state.set( - constraints=extend_constraint_store( - Constraint(ltfdc, (_u, _v)), state.constraints - ) - ) - if not dom_u or not dom_v: - return next_state - - max_v = max(dom_v) - min_u = min(dom_u) - return compose_constraints( - process_domain(_u, make_domain(*(i for i in dom_u if i < max_v))), - process_domain(_v, make_domain(*(i for i in dom_v if i > min_u))), - )(next_state) - - return _ltfdc - - -def ltefd(u: Value, v: Value) -> GoalProto: - return goal_from_constraint(ltefdc(u, v)) - - -def ltefdc(u: Value, v: Value) -> ConstraintFunction: - def _ltefdc(state: State) -> State | None: - _u = walk(u, state.sub) - _v = walk(v, state.sub) - dom_u = state.get_domain(_u) if isinstance(_u, Var) else make_domain(_u) - dom_v = state.get_domain(_v) if isinstance(_v, Var) else make_domain(_v) - - next_state = state.set( - constraints=extend_constraint_store( - Constraint(ltefdc, (_u, _v)), state.constraints - ) - ) - if not dom_u or not dom_v: - return next_state - - max_v = max(dom_v) - min_u = min(dom_u) - return compose_constraints( - process_domain(_u, make_domain(*(i for i in dom_u if i <= max_v))), - process_domain(_v, make_domain(*(i for i in dom_v if i >= min_u))), - )(next_state) - - return _ltefdc - - -def plusfd(u: Value, v: Value, w: Value): - return goal_from_constraint(plusfdc(u, v, w)) - - -def plusfdc(u: Value, v: Value, w: Value) -> ConstraintFunction: - def _plusfdc(state: State) -> State | None: - _u = walk(u, state.sub) - _v = walk(v, state.sub) - _w = walk(w, state.sub) - dom_u = state.get_domain(_u) if isinstance(_u, Var) else make_domain(_u) - dom_v = state.get_domain(_v) if isinstance(_v, Var) else make_domain(_v) - dom_w = state.get_domain(_w) if isinstance(_w, Var) else make_domain(_w) - - next_state = state.set( - constraints=extend_constraint_store( - Constraint(plusfdc, (_u, _v, _w)), state.constraints - ) - ) - if not all((dom_u, dom_v, dom_w)): - return next_state - - min_u = min(dom_u) - max_u = max(dom_u) - min_v = min(dom_v) - max_v = max(dom_v) - min_w = min(dom_w) - max_w = max(dom_w) - return compose_constraints( - process_domain(_w, mkrange(min_u + min_v, max_u + max_v)), - compose_constraints( - process_domain(_u, mkrange(min_w - max_v, max_w - min_v)), - process_domain(_v, mkrange(min_w - max_u, max_w - min_u)), - ), - )(next_state) - - return _plusfdc - - -def neqfd(u: Value, v: Value) -> GoalProto: - return goal_from_constraint(neqfdc(u, v)) - - -def neqfdc(u: Value, v: Value) -> ConstraintFunction: - def _neqfdc(state: State) -> State | None: - _u = walk(u, state.sub) - _v = walk(v, state.sub) - dom_u = state.get_domain(_u) if isinstance(_u, Var) else make_domain(_u) - dom_v = state.get_domain(_v) if isinstance(_v, Var) else make_domain(_v) - if dom_u is None or dom_v is None: - return state.set( - constraints=extend_constraint_store( - Constraint(neqfdc, (_u, _v)), state.constraints - ) - ) - elif len(dom_u) == 1 and len(dom_v) == 1 and dom_u == dom_v: - return None - elif dom_u.isdisjoint(dom_v): - return state - - next_state = state.set( - constraints=extend_constraint_store( - Constraint(neqfdc, (_u, _v)), state.constraints - ) - ) - if len(dom_u) == 1: - return process_domain(_v, dom_v - dom_u)(next_state) - elif len(dom_v) == 1: - return process_domain(_u, dom_u - dom_v)(next_state) - else: - return next_state - - return _neqfdc - - -def alldifffd(*vs: Value) -> GoalProto: - return goal_from_constraint(alldifffdc(*vs)) - - -def alldifffdc(*vs: Value) -> ConstraintFunction: - def _alldifffdc(state: State) -> State | None: - unresolved, values = partition(lambda v: isinstance(v, Var), vs) - unresolved = tuple(unresolved) - values = tuple(values) - values_domain = make_domain(*values) - if len(values) == len(values_domain): - return alldifffdc_resolve(unresolved, values_domain)(state) - return None - - return _alldifffdc - - -def alldifffdc_resolve( - unresolved: tuple[Var], values: set[Value] -) -> ConstraintFunction: - def _alldifffdc_resolve(state: State) -> State | None: - nonlocal values - values = set(values) - remains_unresolved = [] - for var in unresolved: - v = walk(var, state.sub) - if isinstance(v, Var): - remains_unresolved.append(v) - elif is_domain_member(v, values): - return None - else: - values.add(v) - - next_state = state.set( - constraints=extend_constraint_store( - Constraint( - alldifffdc_resolve, (tuple(remains_unresolved), tuple(values)) - ), - state.constraints, - ) - ) - return exclude_from_domains(remains_unresolved, values)(next_state) - - return _alldifffdc_resolve - - -def exclude_from_domains(vs: list[Var], values: set[Value]) -> ConstraintFunction: - """ - For each Var in vs, remove all values in values from its domain. - """ - - def _exclude_from_domains(state: State) -> State | None: - with_domains = ( - (var, dom) for var in vs if (dom := state.get_domain(var)) is not None - ) - constraint = reduce( - compose_constraints, - (process_domain(var, dom - values) for var, dom in with_domains), - identity, - ) - return constraint(state) - - return _exclude_from_domains - - -def is_domain_member(v: Value, dom: set[int]) -> bool: - return isinstance(v, int) and v in dom - - -def process_domain(x: Value, domain: set[int]) -> ConstraintFunction: - def _process_domain(state): - match walk(x, state.sub): - case Var(_): - # x is not associated with any concrete value, update its domain - return update_var_domain(x, domain, state) - case val if val in domain: - # We already have a concrete value, check if it's in the domain - return state - case _: - return None - - return _process_domain - - -def update_var_domain(x: Var, domain: set[int], state: State) -> State | None: - match state.get_domain(x): - case fd if isinstance(fd, set): - i = domain & fd - if i: - return resolve_storable_domain(i, x, state) - else: - return None - case _: - return resolve_storable_domain(domain, x, state) - - -def resolve_storable_domain(domain: set[int], x: Var, state: State) -> State | None: - if len(domain) == 1: - n = domain.copy().pop() - next_state = state.set(sub=extend_substitution(x, n, state.sub)) - return run_constraints([x], state.constraints)(next_state) - return state.set(domains=extend_domain_store(x, domain, state.domains)) - - -def process_prefix_fd( - prefix: Substitution, constraints: ConstraintStore -) -> ConstraintFunction: - if not prefix: - return identity - (x, v), *_ = prefix.items() - t = compose_constraints( - run_constraints([x], constraints), - process_prefix_fd(prefix.delete(x), constraints), - ) - - def _process_prefix_fd(state: State): - domain_x = state.get_domain(x) - if domain_x is not None: - # We have a new association for x (as x is in prefix), and we found an - # existing domain for x. Check that the new association does not violate - # the fd constraint - return compose_constraints(process_domain(v, domain_x), t)(state) - return t(state) - - return _process_prefix_fd - - -def enforce_constraints_fd(x: Var) -> GoalProto: - def _enforce_constraints(state: State) -> Stream: - bound_vars = state.domains.keys() - verify_all_bound(state.constraints, bound_vars) - return onceo(force_answer(bound_vars))(state) - - return conj(force_answer(x), Goal(_enforce_constraints)) - - -def verify_all_bound(constraints: ConstraintStore, bound_vars: list[Var]): - if len(constraints) > 0: - first, *rest = constraints - var_operands = [x for x in first.operands if isinstance(x, Var)] - for var in var_operands: - if var not in bound_vars: - raise UnboundConstrainedVariable( - f"Constrained variable {var} has no domain" - ) - verify_all_bound(rest, bound_vars) - - -# TODO -def reify_constraints_fd(_: Var, __: Substitution) -> ConstraintFunction: - def _reify_constraints_fd(state: State) -> State | None: - return state - # raise UnboundVariables() - - return _reify_constraints_fd diff --git a/src/microkanren/goals.py b/src/microkanren/goals.py deleted file mode 100644 index 1af7946..0000000 --- a/src/microkanren/goals.py +++ /dev/null @@ -1,191 +0,0 @@ -from fastcons import cons, nil - -from microkanren.core import ( - Goal, - GoalProto, - State, - Stream, - Var, - bind, - conj, - disj, - eq, - fail, - fresh, - mzero, - neq, - succeed, - unit, -) - - -def conda(*cases) -> GoalProto: - _cases = [] - for case in cases: - if isinstance(case, list | tuple): - _cases.append((case[0], succeed) if len(case) < 2 else case) - else: - _cases.append((case, succeed())) - - def _conda(state: State) -> Stream: - return starfoldr(ifte, _cases, fail())(state) - - return Goal(_conda) - - -def starfoldr(f, xs, initial): - sentinel = object() - accum = sentinel - for args in reversed(xs): - if accum is sentinel: - _args = (*args, initial) - else: - _args = (*args, accum) - accum = f(*_args) - return accum - - -def ifte(g1, g2, g3=None) -> GoalProto: - g3 = g3 or fail() - - def _ifte(state: State) -> Stream: - # TODO: rewrite iteratively - def ifte_loop(stream: Stream) -> Stream: - match stream: - case (): - return g3(state) - case (_, _): - return bind(stream, g2) - case _: - return lambda: ifte_loop(stream()) - - return ifte_loop(g1(state)) - - return Goal(_ifte) - - -def onceo(g: GoalProto) -> GoalProto: - def _onceo(state: State): - stream = g(state) - while stream: - match stream: - case (s1, _): - return unit(s1) - case _: - stream = stream() - return mzero - - return _onceo - - -def appendo(xs: cons | Var, ys: cons | Var, zs: cons | Var) -> Goal: - return disj( - nullo(xs) & eq(ys, zs), - fresh( - lambda a, d, res: conj( - conso(a, d, xs), - conso(a, res, zs), - appendo(d, ys, res), - ) - ), - ) - - -def membero(x, xs): - return fresh( - lambda a, d: conj( - conso(a, d, xs), - disj(eq(a, x), membero(x, d)), - ) - ) - - -def nullo(x): - return eq(x, nil()) - - -def notnullo(x): - return neq(x, nil()) - - -def conso(a, d, ls): - return eq(cons(a, d), ls) - - -def caro(a, xs): - return fresh(lambda d: conso(a, d, xs)) - - -def cdro(d, xs): - return fresh(lambda a: conso(a, d, xs)) - - -def listo(xs): - return nullo(xs) | fresh(lambda d: cdro(d, xs) & listo(d)) - - -def inserto(x, ys, zs): - def _inserto(state: State) -> Stream: - return disj( - appendo(cons(x, nil()), ys, zs), - fresh( - lambda a, d, res: conj( - eq((a, d), ys), - eq((a, res), zs), - inserto(x, d, res), - ), - ), - )(state) - - return Goal(_inserto) - - -def assoco(x, xs, y): - return ifte( - eq(xs, nil()), - fail(), - fresh( - lambda key, val, rest: disj( - conj( - conso((key, val), rest, xs), - disj( - eq(key, x) & eq((key, val), y), - assoco(x, rest, y), - ), - ) - ), - ), - ) - - -def rembero(x, xs, out): - def _rembero(state: State) -> Stream: - return disj( - nullo(xs) & nullo(out), - fresh( - lambda a, d, res: disj( - conso(x, d, xs) & eq(d, out), - conj( - neq(a, x), - conso(a, d, xs), - conso(a, res, out), - rembero(x, d, res), - ), - ) - ), - )(state) - - return Goal(_rembero) - - -def joino(prefix, suffix, sep, out): - return disj( - nullo(prefix) & eq(suffix, out), - nullo(suffix) & eq(prefix, out), - fresh( - lambda tmp: notnullo(prefix) - & notnullo(suffix) - & appendo(prefix, sep, tmp) - & appendo(tmp, suffix, out) - ), - ) diff --git a/src/microkanren/lang.hy b/src/microkanren/lang.hy new file mode 100644 index 0000000..e898a48 --- /dev/null +++ b/src/microkanren/lang.hy @@ -0,0 +1,74 @@ +(import microkanren [core]) +(import hy) +(import hyrule.collections [prewalk]) +(require hyrule.argmove *) + +(setv call/fresh core.call-fresh) +(setv == core.eq) + +(defmacro Zzz [g] + `(fn [s/c] (fn [] (~g s/c)))) + +(defmacro conj+ [goal #* goals] + (if (not goals) + `(Zzz ~goal) + `(core.conj (Zzz ~goal) (conj+ ~@goals)))) + +(defmacro disj+ [goal #* goals] + (if (not goals) + `(Zzz ~goal) + `(core.disj (Zzz ~goal) (disj+ ~@goals)))) + +(defmacro conde [#* goals] + (match goals + [] 'core.fail + [gs] `(conj+ ~@gs) + [gs #* gs^] `(disj+ (conj+ ~@gs) ~@(map (fn [xs] `(conj+ ~@xs)) gs^)))) + +(defmacro fresh [lvars #* goals] + (match lvars + [] (if goals `(conj+ ~@goals) 'core.succeed) + [v #* vs] `(call/fresh (fn [~v] (fresh ~vs ~@goals))))) + +(defmacro exist [lvars #* goals] + `(fresh ~lvars (conj+ ~@goals))) + +(defmacro run [n lvars #* goals] + `(lfor + x + (core.take ~n ((fresh ~lvars ~@goals (fn [s] (core.unit (core.reify ~(tuple lvars) s.sub)))) + (core.empty-state))) + x)) + +(defmacro run* [lvars / #* goals] + `(run -1 ~lvars ~@goals)) + +(defmacro unless [test / #* consequents] + `(when (not ~test) ~@consequents)) + +(defmacro defne [name subject / #* cases] + (unless (isinstance name hy.models.Symbol) + (raise + (ValueError + "First positional argument to defne must be an identifier, the name of the defined relation"))) + (unless (isinstance subject hy.models.List) + (raise + (ValueError + "Second positional argument to defne must be a list, the parameter list of the defined relation"))) + + (defn -collect-free-vars [head bound] + (match head + [first #* rest] [#* (-collect-free-vars first bound) #* (-collect-free-vars rest bound)] + x :if (and (isinstance x hy.models.Symbol) (not-in x bound)) [x] + _ [])) + + (defn -replace-anons [head] + (prewalk (fn [x] (if (= x '_) (hy.gensym) x)) head)) + + `(defn ~name ~subject + (disj+ + ~@(lfor [head #* rest] cases + (let [-head (-replace-anons head)] + `(fresh ~(-collect-free-vars -head subject) + (== ~-head ~subject) + ~@rest)))))) diff --git a/src/microkanren/utils.py b/src/microkanren/utils.py deleted file mode 100644 index 776c75c..0000000 --- a/src/microkanren/utils.py +++ /dev/null @@ -1,23 +0,0 @@ -from itertools import filterfalse, tee - -from fastcons import cons - - -def identity(x): - return x - - -def partition(pred, iterable): - t1, t2 = tee(iterable) - return filter(pred, t1), filterfalse(pred, t2) - - -def foldr(f, b, xs): - if not xs: - return b - x, *xs = xs - return f(x, foldr(f, b, xs)) - - -def _(*xs): - return cons.from_xs(xs) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ef47d01 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import hy # noqa: F401 +import pytest + + +def pytest_collect_file(file_path, parent): + if file_path.name.startswith("test_") and file_path.suffix == ".hy": + return pytest.Module.from_parent(parent, path=file_path) diff --git a/tests/test_core.py b/tests/test_core.py index 689ae95..28eb74d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,115 +1,180 @@ +import pytest from fastcons import cons, nil -from pyrsistent import pmap from microkanren import ( - ReifiedVar, + SENTINEL, + OccursError, + State, + Symbol, Var, - disj, + empty_state, empty_sub, - eq, extend_substitution, - fresh, - get_sub_prefix, - run, - run_all, - snooze, + mplus, + mzero, + pull, + take, + unify, + unit, walk, ) -from microkanren.goals import appendo -from microkanren.utils import _ -class TestSubstitution: - def test_extend_substitution(self): - val = object() - s = extend_substitution(Var(0), val, empty_sub()) - assert walk(Var(0), s) is val - - def test_walk_self(self): - assert walk(Var(0), empty_sub()) == Var(0) - - def test_walk_constant(self): - assert walk("foo", empty_sub()) == "foo" - - def test_recursive_walk(self): - val = object() - s = extend_substitution( - Var(0), Var(1), extend_substitution(Var(1), val, empty_sub()) - ) - assert walk(Var(0), s) is val - - def test_get_sub_prefix(self): - x = object() - initial = empty_sub() - a = extend_substitution(Var(0), 1, initial) - b = extend_substitution( - Var(2), - x, - extend_substitution(Var(1), Var(2), a), - ) - assert set(get_sub_prefix(b, a).items()) == set( - pmap({Var(1): Var(2), Var(2): x}).items() - ) - - -class TestEq: - def test_simple_eq(self): - result = run_all(lambda x: eq(x, 1)) - assert result == [1] - - -def fives(x): - return eq(x, 5) | snooze(fives, x) - - -def sixes(x): - return eq(x, 6) | snooze(sixes, x) - - -def test_snooze(): - result = run(3, lambda x: fives(x)) - assert result == [5, 5, 5] - - result = run(8, lambda x: fives(x) | sixes(x)) - assert result == [5, 6, 5, 6, 5, 6, 5, 6] - - -def test_recursion(): - # Check we don't blow python's stack - assert len(run(10000, lambda x: fives(x))) == 10000 - - -def test_disj_interleaving(): - # Test that the order of results matches examples from the miniKanren paper - - def function_disj_relation(x): - return disj(eq(x, 1), eq(x, 2), eq(x, 3), snooze(function_disj_relation, x)) - - assert run(6, lambda x: function_disj_relation(x)) == [1, 2, 3, 1, 2, 3] - - def operator_disj_relation(x): - return eq(x, 1) | eq(x, 2) | eq(x, 3) | snooze(operator_disj_relation, x) - - assert run(6, lambda x: function_disj_relation(x)) == [1, 2, 3, 1, 2, 3] - - def patho(x, y): - return arco(x, y) | fresh(lambda z: arco(x, z) & patho(z, y)) - - def arco(x, y): - return disj( - eq("a", x) & eq("b", y), - eq("b", x) & eq("a", y), - eq("b", x) & eq("d", y), - ) - - assert "".join(run(9, lambda x: patho("a", x))) == "badbadbad" - - -def test_vars_reified_correctly(): - # Test the reification of fresh variables matches a known example (appendo) - - R = ReifiedVar - result = run(3, lambda x, y, z: appendo(x, y, z)) - assert result[0] == (nil(), R(0), R(0)) - assert result[1] == (_(R(0)), R(1), cons(R(0), R(1))) - assert result[2] == (_(R(0), R(1)), R(2), cons(R(0), cons(R(1), R(2)))) +def test_extend_substitution(): + val = object() + x = Var("x") + s = extend_substitution(x, val, empty_sub()) + assert walk(x, s) is val + + +def test_walk_unbound_var(): + x = Var("x") + assert walk(x, empty_sub()) == x + + +def test_walk_unbound_value(): + assert walk("foo", empty_sub()) == "foo" + + +def test_recursive_walk(): + val = object() + x, y = Var("x"), Var("y") + s = extend_substitution(x, y, extend_substitution(y, val, empty_sub())) + assert walk(x, s) is val + + +def test_symbol_equality(): + s1 = Symbol("test") + s2 = Symbol("test") + s3 = Symbol("other") + assert s1 is s2 # Same symbols are identical + assert s1 == s2 # Same symbols are equal + assert s1 != s3 # Different symbols are not equal + assert str(s1) == "test" + assert repr(s1) == "test" + + +def test_empty_state(): + state = empty_state() + assert state.sub == empty_sub() + + +def test_unify_basic(): + """ + Unification works correctly for basic atomic values. + """ + s = empty_sub() + # Equal atoms unify. + assert unify(1, 1, s) == s + assert unify("a", "a", s) == s + # Different atoms don't unify. + assert unify(1, 2, s) is SENTINEL + assert unify("a", "b", s) is SENTINEL + + +def test_unify_var(): + """ + Unification works correctly with logic variables. + """ + s = empty_sub() + x = Var("x") + y = Var("y") + # Var unifies with anything. + assert unify(x, 1, s) == extend_substitution(x, 1, s) + assert unify(1, x, s) == extend_substitution(x, 1, s) + # Two distinct, fresh vars unify. + assert unify(x, y, s) == extend_substitution(x, y, s) + + +@pytest.mark.parametrize( + ("seq1", "seq2", "expected"), + [ + # Equal sequences unify + ((1, 2), (1, 2), empty_sub()), + ([1, 2], [1, 2], empty_sub()), + (cons(1, cons(2, nil())), cons(1, cons(2, nil())), empty_sub()), + # Different sequences don't unify + ((1, 2), (1, 3), SENTINEL), + ([1, 2], [1], SENTINEL), + (cons(1, cons(2, nil())), cons(1, nil()), SENTINEL), + ], +) +def test_unify_sequences(seq1, seq2, expected): + """ + Unification works correctly for sequences like tuples, lists, and cons pairs. + """ + s = empty_sub() + assert unify(seq1, seq2, s) == expected + + +def test_mzero_is_empty_stream(): + """ + mzero() returns an empty stream. + """ + assert mzero() == () + + +def test_unit_creates_stream_with_state(): + """ + unit() creates a stream containing a single state followed by an empty stream. + """ + state = empty_state() + assert unit(state) == (state, mzero()) + + +def test_pull_evaluates_thunk(): + """ + pull() evaluates a thunk to produce its underlying stream. + """ + state = empty_state() + + def thunk(): + return unit(state) + + assert pull(thunk) == unit(state) + + +def test_take_from_unit_stream(): + """ + take() correctly extracts elements from a unit stream. + """ + state = empty_state() + assert take(1, unit(state)) == [state] # Taking one element returns the state. + assert take(2, unit(state)) == [ + state + ] # Taking more elements still returns just the state. + + +def test_take_from_empty_stream(): + """ + take() returns an empty list when given an empty stream. + """ + assert take(1, mzero()) == [] + + +def test_mplus(): + state = empty_state() + s1 = unit(state) + s2 = unit(State(empty_sub())) + # Combine two streams + combined = mplus(s1, s2) + assert take(2, combined) == [state, State(empty_sub())] + + +@pytest.mark.parametrize( + "val", + [ + Var("x", _id=0), + cons(Var("x", _id=0), nil()), + cons(nil(), Var("x", _id=0)), + (Var("x", _id=0), Var("x", _id=0)), + ("foo", Var("x", _id=0)), + [Var("x", _id=0)], + [0, Var("x", _id=0)], + [[Var("x", _id=0)]], + ([Var("x", _id=0)],), + ], +) +def test_occurs_check_raises(val): + with pytest.raises(OccursError): + extend_substitution(Var("x", _id=0), val, empty_sub()) diff --git a/tests/test_fd.py b/tests/test_fd.py deleted file mode 100644 index f271a8b..0000000 --- a/tests/test_fd.py +++ /dev/null @@ -1,267 +0,0 @@ -from itertools import permutations -from math import sqrt - -import pytest - -from microkanren import ( - Hooks, - compose_constraints, - conj, - default_enforce_constraints, - default_process_prefix, - enforce_constraints_neq, - eq, - freshn, - neq, - process_prefix_neq, - run, - run_all, -) -from microkanren.fd import ( - alldifffd, - domfd, - enforce_constraints_fd, - infd, - ltefd, - ltfd, - make_domain, - mkrange, - neqfd, - plusfd, - process_prefix_fd, -) - - -@pytest.fixture(autouse=True, scope="module") -def setup_process_prefix(): - def process_prefix(prefix, constraints): - return compose_constraints( - process_prefix_neq(prefix, constraints), - process_prefix_fd(prefix, constraints), - ) - - yield Hooks.set_process_prefix(process_prefix) - Hooks.set_process_prefix(default_process_prefix) - - -@pytest.fixture(autouse=True, scope="module") -def setup_enforce_constraints(): - def enforce_constraints(var): - return conj( - enforce_constraints_neq(var), - enforce_constraints_fd(var), - ) - - yield Hooks.set_enforce_constraints(enforce_constraints) - Hooks.set_enforce_constraints(default_enforce_constraints) - - -class TestFdConstraints: - @pytest.mark.parametrize("domain", [make_domain(1, 2, 3), make_domain(5, 7, 9)]) - def test_domfd(self, domain): - result = run_all(lambda x: domfd(x, domain)) - assert set(result) == set(domain) - - @pytest.mark.parametrize( - ("a", "b", "intersection"), - [ - (make_domain(1, 2), make_domain(1, 2), {1, 2}), - (make_domain(1, 2, 3), make_domain(2, 3, 4), {2, 3}), - (make_domain(1, 2), make_domain(3, 4), make_domain()), - ], - ) - def test_domain_intersection(self, a, b, intersection): - result = run_all(lambda x: domfd(x, a) & domfd(x, b)) - assert set(result) == intersection - - def test_violated_infd_fails(self): - result = run_all(lambda x: domfd(x, make_domain(1, 2, 3)) & eq(x, 4)) - assert result == [] - - @pytest.mark.parametrize( - ("a", "b", "expected_x"), - [ - (make_domain(1, 2, 3, 4), make_domain(2, 3), make_domain(1, 2, 3)), - (make_domain(4, 5), make_domain(1, 2), make_domain()), - (make_domain(3, 4), make_domain(2, 3, 4, 5), make_domain(3, 4)), - (make_domain(1, 2, 3, 4), make_domain(1, 2, 3, 4), make_domain(1, 2, 3, 4)), - (make_domain(1, 2, 3), make_domain(1, 2), make_domain(1, 2)), - ], - ) - def test_ltefd(self, a, b, expected_x): - result = run_all(lambda x, y: domfd(x, a) & domfd(y, b) & ltefd(x, y)) - assert {x[0] for x in result} == expected_x - for x, y in result: - assert x <= y - - @pytest.mark.parametrize( - ("a", "b", "expected_x"), - [ - (make_domain(1, 2, 3, 4), make_domain(2, 3), make_domain(1, 2)), - (make_domain(1, 2, 3), make_domain(4, 5), make_domain(1, 2, 3)), - (make_domain(4, 5), make_domain(1, 2), make_domain()), - (make_domain(3, 4), make_domain(2, 3, 4, 5), make_domain(3, 4)), - (make_domain(1, 2, 3, 4), make_domain(1, 2, 3, 4), make_domain(1, 2, 3)), - ], - ) - def test_ltfd(self, a, b, expected_x): - result = run_all(lambda x, y: domfd(x, a) & domfd(y, b) & ltfd(x, y)) - assert {x[0] for x in result} == expected_x - for x, y in result: - assert x < y - - def test_neq_with_domfd(self): - """ - If neq(x, n), then n cannot be in the domain of x. - """ - result = run_all(lambda x: domfd(x, make_domain(1, 2, 3)) & neq(x, 2)) - assert set(result) == {1, 3} - - def test_neq_with_ltefd(self): - """ - If neq(x, n), then n cannot be in the domain of x. - """ - result = run_all( - lambda x, y: domfd(x, make_domain(1, 2, 3)) - & domfd(y, make_domain(1, 2)) - & ltefd(x, y) - & neq(x, 1) - ) - assert result == [(2, 2)] - - def test_plusfd(self): - result = run_all( - lambda x, y, z: domfd(x, mkrange(1, 3)) - & domfd(y, mkrange(1, 3)) - & domfd(z, mkrange(1, 3)) - & plusfd(x, y, z) - ) - assert set(result) == {(1, 1, 2), (1, 2, 3), (2, 1, 3)} - - def test_plusfd_with_ltefd(self): - result = run_all( - lambda x, y, z: domfd(x, mkrange(1, 3)) - & domfd(y, mkrange(1, 2)) - & domfd(z, mkrange(1, 4)) - & plusfd(x, y, z) - & ltefd(x, y) - ) - assert set(result) == {(1, 1, 2), (1, 2, 3), (2, 2, 4)} - - @pytest.mark.parametrize( - ("a", "b", "expected"), - [ - (make_domain(1), make_domain(1), set()), - (make_domain(1, 2, 3), make_domain(1), {2, 3}), - (make_domain(1, 2, 3), make_domain(3), {1, 2}), - (make_domain(4, 5, 6), make_domain(3), {4, 5, 6}), - (make_domain(4, 5, 6), make_domain(4, 5), {4, 5, 6}), - ], - ) - def test_neqfd(self, a, b, expected): - result = run_all(lambda x, y: domfd(x, a) & domfd(y, b) & neqfd(x, y)) - assert {x[0] for x in result} == expected - for x, y in result: - assert x != y - - def test_neqfd_with_ltefd(self): - result = run_all( - lambda x, y: domfd(x, mkrange(2, 3)) - & domfd(y, mkrange(1, 3)) - & ltefd(x, y) - & neqfd(x, y) - ) - assert set(result) == {(2, 3)} - - @pytest.mark.parametrize( - ("a", "b", "expected"), - [ - (1, 1, []), - (2, 3, [(2, 3)]), - ], - ) - def test_alldifffd_constants(self, a, b, expected): - result = run_all(lambda x, y: eq(x, a) & eq(y, b) & alldifffd(x, y)) - assert result == expected - - @pytest.mark.parametrize( - ("a", "b", "expected"), - [ - (make_domain(1, 2), make_domain(1, 2), {(1, 2), (2, 1)}), - (make_domain(2), make_domain(1, 2, 3), {(2, 1), (2, 3)}), - ( - make_domain(7, 9, 11), - make_domain(1, 11), - {(7, 1), (9, 1), (11, 1), (7, 11), (9, 11)}, - ), - ], - ) - def test_alldifffd_domains(self, a, b, expected): - result = run_all(lambda x, y: domfd(x, a) & domfd(y, b) & alldifffd(x, y)) - assert set(result) == expected - - def test_alldifffd_many(self): - result = run_all( - lambda w, x, y, z: domfd(w, mkrange(1, 4)) - & domfd(x, mkrange(1, 4)) - & domfd(y, mkrange(1, 4)) - & domfd(z, mkrange(1, 4)) - & alldifffd(w, x, y, z) - ) - assert set(result) == set(permutations((1, 2, 3, 4), 4)) - - def test_infd(self): - result = run_all( - lambda a, b, c: infd((a, b, c), mkrange(1, 3)) & alldifffd(a, b, c) - ) - assert set(result) == set(permutations((1, 2, 3), 3)) - - -class TestLargeGoals: - def test_sudoku(self): - def sudokuo(a, b, size): - def grido(grid, vs): - return eq( - grid, - [ - tuple(vs[j] for j in range(i, i + size)) - for i in range(0, size * size, size) - ], - ) - - def blocko(vs, block_size): - blocks = [ - tuple( - vs[segment + block + row + i] - for row in range(0, size * block_size, size) - for i in range(block_size) - ) - for segment in range(0, size * size, size * block_size) - for block in range(0, size, block_size) - ] - return conj( - *(alldifffd(*block) for block in blocks), - ) - - def rowo(vs): - rows = [ - tuple(vs[row + i] for i in range(size)) - for row in range(0, size * size, size) - ] - return conj(*(alldifffd(*row) for row in rows)) - - block_size = int(sqrt(size)) - - return freshn( - size * size, - lambda *vs: conj( - grido(a, vs), - infd(vs, mkrange(1, size)), - rowo(vs), - blocko(vs, block_size), - eq(a, b), - ), - ) - - result = run(1, lambda a, b: sudokuo(a, b, 4)) - assert result != [] diff --git a/tests/test_lang/test_defne.hy b/tests/test_lang/test_defne.hy new file mode 100644 index 0000000..ab0f6f2 --- /dev/null +++ b/tests/test_lang/test_defne.hy @@ -0,0 +1,69 @@ +(require microkanren.lang *) +(import microkanren.lang *) +(import microkanren.core [Symbol]) + +(defreader ? + `(hy.I.microkanren.core.Symbol ~f"_.{(hy.eval (.parse-one-form &reader))}")) + +(defn test-unifies-self [] + (defne goal [x] + ([x])) + + (assert (= (run* [q] (goal q)) + [#(#? 0)]))) + +(defn test-unifies-ground-val [] + (defne goal [x] + ([5])) + + (assert (= (run* [q] (goal q)) + [#(5)]))) + +(defn test-unifies-ground-val-alternatives [] + (defne goal [x] + ([5]) + ([6])) + + (assert (= (run* [q] (goal q)) + [#(5) #(6)]))) + +(defn test-unifies-named-fresh-var [] + (defne goal [x] + ([a])) + + (assert (= (run* [q] (goal q)) + [#(#? 0)]))) + +(defn test-unifies-anon-fresh-var [] + (defne goal [x] + ([_])) + + (assert (= (run* [q] (goal q)) + [#(#? 0)]))) + +(defn test-unifies-varieties [] + (defne goal [x] + ([5]) + ([6]) + ([a]) + ([_])) + + (assert (= (run* [q] (goal q)) + [#(5) #(6) #(#? 0) #(#? 0)]))) + +(defn test-unifies-multiple-args [] + (defne goal [x y] + ([5 6]) + ([a b]) + ([_ _]) + ([_ 6])) + + (assert (= (run* [a b] (goal a b)) + [#(5 6) #(#? 0 #? 1) #(#? 0 #? 1) #(#? 0 6)]))) + +(defn test-unifies-nested-structures [] + (defne goal [x] + ([[1 [2 3]]])) + + (assert (= (run* [q] (goal q)) + [#([1 [2 3]])]))) diff --git a/tests/test_lang/test_lang.hy b/tests/test_lang/test_lang.hy new file mode 100644 index 0000000..8fe0e08 --- /dev/null +++ b/tests/test_lang/test_lang.hy @@ -0,0 +1,19 @@ +(require microkanren.lang *) +(import microkanren.lang *) + +(defn fives [x] + (conde + [(== x 5)] + [(Zzz (fives x))])) + +(defn test-run*-eq [] + (assert (= (run* [q] (== q 5)) + [#(5)]))) + +(defn test-run-positive-n [] + (assert (= (len (run 5 [q] (fives q))) + 5))) + +(defn test-run-zero-n [] + (assert (= (len (run 0 [q] (fives q))) + 0))) diff --git a/tests/test_lang/test_lang_tabling.hy b/tests/test_lang/test_lang_tabling.hy new file mode 100644 index 0000000..949e4dd --- /dev/null +++ b/tests/test_lang/test_lang_tabling.hy @@ -0,0 +1,111 @@ +(require microkanren.lang *) +(import microkanren.lang *) +(import microkanren [core]) + +;; Examples from Will Byrd's thesis. + +(defn test-badbadbad [] + (defn path° [x y] + (conde + [(arc° x y)] + [(fresh [z] + (conj+ (arc° x z) (path° z y)))])) + + (defn arc° [x y] + (conde + [(== 'a x) (== 'b y)] + [(== 'b x) (== 'a y)] + [(== 'b x) (== 'd y)])) + + (setv [b a d] '[b a d]) + (setv result (run 9 [q] (path° 'a q))) + (assert (= result + [#(b) #(a) #(d) #(b) #(a) #(d) #(b) #(a) #(d)]))) + +(defn test-badbadbad-tabled [] + (defn [core.tabled] path° [x y] + (conde + [(arc° x y)] + [(fresh [z] + (conj+ (arc° x z) (path° z y)))])) + + (defn arc° [x y] + (conde + [(== 'a x) (== 'b y)] + [(== 'b x) (== 'a y)] + [(== 'b x) (== 'd y)])) + + (setv [b a d] '[b a d]) + (setv result (run* [q] (path° 'a q))) + (assert (= result + [#(b) #(a) #(d)]))) + +(defn test-mutually-recursive-no-table [] + (defn f° [x] + (conde + [(== 0 x)] + [(g° x)])) + + (defn g° [x] + (conde + [(== 1 x)] + [(f° x)])) + + (assert (= (run 5 [q] (f° q)) + [#(0) #(1) #(0) #(1) #(0)]))) + +(defn test-mutually-recursive-table-f° [] + (defn [core.tabled] f° [x] + (conde + [(== 0 x)] + [(g° x)])) + + (defn g° [x] + (conde + [(== 1 x)] + [(f° x)])) + + (assert (= (run 5 [q] (f° q)) + [#(0) #(1)]))) + +(defn test-mutually-recursive-table-both [] + (defn [core.tabled] f° [x] + (conde + [(== 0 x)] + [(g° x)])) + + (defn [core.tabled] g° [x] + (conde + [(== 1 x)] + [(f° x)])) + + (assert (= (run 5 [q] (f° q)) + [#(0) #(1)])) + + (assert (= (run 5 [q] (g° q)) + [#(1) #(0)]))) + +;; Other examples. + +(defn test-same-generation° [] + (defn [core.tabled] same-generation° [x y] + (conde + [(== x y)] + [(fresh [x1 y1] + ;; Eventually diverges without tabling, due to the ordering + ;; of these sub-goals — same-generation° with fresh vars + ;; keeps recurring and calling the same sub-goal again and + ;; again. + (same-generation° x1 y1) + (parent° x x1) + (parent° y y1))])) + + (defn parent° [x y] + (conde + [(== 'john x) (== 'mary y)] + [(== 'jane x) (== 'mary y)] + [(== 'mary x) (== 'sam y)])) + + (setv [john jane] '[john jane]) + (assert (= (run* [q] (same-generation° q john)) + [#(john) #(jane)]))) diff --git a/tests/test_tabling.py b/tests/test_tabling.py new file mode 100644 index 0000000..6000ff8 --- /dev/null +++ b/tests/test_tabling.py @@ -0,0 +1,246 @@ +import pytest +from fastcons import cons + +from microkanren.core import ( + OccursError, + State, + Symbol, + Var, + call_fresh, + conj, + disj, + empty_state, + empty_sub, + eq, + reify, + tabled, + take, +) + + +def test_primary_call_cached(): + @tabled + def goal(x): + return eq(x, 5) + + call_fresh(goal)(empty_state()) + assert goal._table[(Symbol("_.0"),)] == [(5,)] + + +def test_compound_primary_call_cached(): + @tabled + def goal(x): + return eq(x, cons("a", "b")) + + x, y = Var(0), Var(1) + args = (cons(x, y),) + goal(*args)(empty_state()) + assert goal._table[args] == [(cons("a", "b"),)] + + +def test_primary_call_cached_with_ground_arg(): + @tabled + def goal(x): + return eq(x, 5) + + goal(5)(empty_state()) + assert goal._table[(5,)] == [(5,)] + + +def test_primary_call_arg_walked(): + @tabled + def goal(x): + return eq(x, 5) + + x = Var(0) + goal(x)(State(empty_sub().set(x, 5))) + assert goal._table[(5,)] == [(5,)] + + +def test_compound_primary_call_arg_walked(): + """ + Arguments to a tabled goal are walked before being used as keys in the table. + """ + + @tabled + def goal(x): + return eq(x, cons("a", "b")) + + x, y = Var("x"), Var("y") + initial_state = State(empty_sub().set(x, "a").set(y, "b")) + goal(cons(x, y))(initial_state) + + args = (cons("a", "b"),) + assert goal._table[args] == [(cons("a", "b"),)] + + +def test_caching_idempotent(): + @tabled + def goal(x): + return eq(x, 5) + + call_fresh(goal)(empty_state()) + call_fresh(goal)(empty_state()) + assert goal._table[(Symbol("_.0"),)] == [(5,)] + assert len(goal._table) == 1 + + +def test_multiple_solutions_cached(): + @tabled + def goal(x): + return disj(eq(x, "a"), eq(x, "b")) + + call_fresh(goal)(empty_state()) + assert goal._table[(Symbol("_.0"),)] == [("a",), ("b",)] + + +def test_recursive_tabled_goal(): + """ + Tabling prevents infinite recursion in trivially recursive goals. + """ + + @tabled + def goal(x): + return disj( + eq(x, 5), + lambda s: lambda: goal(x)(s), + ) + + result = take(5, call_fresh(goal)(empty_state())) + assert len(result) == 1 + + +def test_mutual_recursion_tabled(): + """ + Tabling works with mutually recursive goals. + """ + + @tabled + def fives_and_sixes(x): + return disj( + eq(x, 5), + sixes_and_fives(x), + ) + + @tabled + def sixes_and_fives(x): + return disj( + eq(x, 6), + fives_and_sixes(x), + ) + + result = call_fresh(fives_and_sixes)(empty_state()) + assert len(take(5, result)) == 2 + + +def test_reuse_cached_results(): + """ + Test that cached results are properly reused in subsequent calls. + """ + + @tabled + def goal(x): + return disj(eq(x, 1), eq(x, 2)) + + # Make first call to populate cache + x = Var(0) + s1 = goal(x)(empty_state()) + assert len(goal._table) == 1 + + # Make second call - should reuse cached results + s2 = goal(x)(empty_state()) + + r1 = [reify(x, state.sub) for state in take(2, s1)] + r2 = [reify(x, state.sub) for state in take(2, s2)] + + # Results should be identical + assert r1 == r2 + + +def test_tabled_with_multiple_vars(): + """ + Test tabling behavior with goals that involve multiple variables. + """ + + @tabled + def goal(x, y): + return conj(eq(x, 1), eq(y, 2)) + + x, y = Var(0), Var(1) + goal(x, y)(empty_state()) + + assert len(goal._table) == 1 + assert goal._table[(Symbol("_.0"), Symbol("_.1"))] == [(1, 2)] + + +def test_tabled_alpha_equivalence(): + """ + Test that tabling correctly handles alpha-equivalent terms. + """ + + @tabled + def goal(x, y): + return eq(x, y) + + a, b, x, y = Var("a"), Var("b"), Var("x"), Var("y") + + # These calls should be considered equivalent + result = take( + 5, + conj(goal(a, b), goal(x, y))(empty_state()), + ) + r1 = [reify((a, b), state.sub) for state in result] + r2 = [reify((x, y), state.sub) for state in result] + assert r1 == r2 + + # Should use same cache entry + assert len(goal._table) == 1 + + +def test_tabled_with_cyclic_terms(): + """ + Test tabling behavior with cyclic terms. + """ + + @tabled + def goal(x): + return eq(x, cons(1, x)) # Creates a cyclic term + + with pytest.raises(OccursError): + take(1, call_fresh(goal)(empty_state())) + + +def test_tabled_with_empty_results(): + """ + Test tabling behavior when goal produces no results. + """ + + @tabled + def goal(x): + return conj(eq(x, 1), eq(x, 2)) # Will never succeed + + result = call_fresh(goal)(empty_state()) + assert take(1, result) == [] + assert goal._table[(Symbol("_.0"),)] == [] + + +def test_tabled_reuse_with_different_var_names(): + """ + Test that tabling works correctly when reusing results with differently named vars. + """ + + @tabled + def goal(x, y): + return conj(eq(x, 1), eq(y, x)) + + # First call with vars a, b + a, b = Var("a"), Var("b") + r1 = take(1, goal(a, b)(empty_state())) + + # Second call with vars x, y + x, y = Var("x"), Var("y") + r2 = take(1, goal(x, y)(empty_state())) + + # Results should be equivalent after reification + assert reify((a, b), r1[0].sub) == reify((x, y), r2[0].sub) + assert len(goal._table) == 1 # Should reuse same cache entry diff --git a/tox.ini b/tox.ini index 30326b3..742390b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,17 @@ [tox] env_list = - py{311,312} -minversion = 4.11.3 + py{312,313} +minversion = 4.23.2 [testenv] description = run the tests with pytest -deps = - pytest==7.2.2 +extras = + hy + dev commands = pytest {tty:--color=yes} {posargs:tests} [gh] python = 3.12 = py312 - 3.11 = py311 + 3.13 = py313 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..cdb2f68 --- /dev/null +++ b/uv.lock @@ -0,0 +1,783 @@ +version = 1 +requires-python = "==3.12.*" + +[[package]] +name = "anyio" +version = "4.6.2.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377 }, +] + +[[package]] +name = "attrs" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 }, +] + +[[package]] +name = "cachetools" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/38/a0f315319737ecf45b4319a8cd1f3a908e29d9277b46942263292115eee7/cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a", size = 27661 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/07/14f8ad37f2d12a5ce41206c21820d8cb6561b728e51fad4530dff0552a67/cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292", size = 9524 }, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, +] + +[[package]] +name = "chardet" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385 }, +] + +[[package]] +name = "click" +version = "8.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "cryptography" +version = "43.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/05/07b55d1fa21ac18c3a8c79f764e2514e6f6a9698f1be44994f5adf0d29db/cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805", size = 686989 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/01/4896f3d1b392025d4fcbecf40fdea92d3df8662123f6835d0af828d148fd/cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e", size = 3760905 }, + { url = "https://files.pythonhosted.org/packages/0a/be/f9a1f673f0ed4b7f6c643164e513dbad28dd4f2dcdf5715004f172ef24b6/cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f", size = 3977271 }, + { url = "https://files.pythonhosted.org/packages/4e/49/80c3a7b5514d1b416d7350830e8c422a4d667b6d9b16a9392ebfd4a5388a/cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6", size = 3746606 }, + { url = "https://files.pythonhosted.org/packages/0e/16/a28ddf78ac6e7e3f25ebcef69ab15c2c6be5ff9743dd0709a69a4f968472/cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18", size = 3986484 }, + { url = "https://files.pythonhosted.org/packages/01/f5/69ae8da70c19864a32b0315049866c4d411cce423ec169993d0434218762/cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd", size = 3852131 }, + { url = "https://files.pythonhosted.org/packages/fd/db/e74911d95c040f9afd3612b1f732e52b3e517cb80de8bf183be0b7d413c6/cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73", size = 4075647 }, + { url = "https://files.pythonhosted.org/packages/2f/78/55356eb9075d0be6e81b59f45c7b48df87f76a20e73893872170471f3ee8/cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5", size = 3762968 }, + { url = "https://files.pythonhosted.org/packages/2a/2c/488776a3dc843f95f86d2f957ca0fc3407d0242b50bede7fad1e339be03f/cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4", size = 3977754 }, + { url = "https://files.pythonhosted.org/packages/7c/04/2345ca92f7a22f601a9c62961741ef7dd0127c39f7310dffa0041c80f16f/cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7", size = 3749458 }, + { url = "https://files.pythonhosted.org/packages/ac/25/e715fa0bc24ac2114ed69da33adf451a38abb6f3f24ec207908112e9ba53/cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405", size = 3988220 }, + { url = "https://files.pythonhosted.org/packages/21/ce/b9c9ff56c7164d8e2edfb6c9305045fbc0df4508ccfdb13ee66eb8c95b0e/cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16", size = 3853898 }, + { url = "https://files.pythonhosted.org/packages/2a/33/b3682992ab2e9476b9c81fff22f02c8b0a1e6e1d49ee1750a67d85fd7ed2/cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", size = 4076592 }, +] + +[[package]] +name = "debugpy" +version = "1.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/00/5a8b5dc8f52617c5e41845e26290ebea1ba06377cc08155b6d245c27b386/debugpy-1.8.7.zip", hash = "sha256:18b8f731ed3e2e1df8e9cdaa23fb1fc9c24e570cd0081625308ec51c82efe42e", size = 4957835 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/4b/9f52ca1a799601a10cd2673503658bd8c8ecc4a7a43302ee29cf062474ec/debugpy-1.8.7-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:4d27d842311353ede0ad572600c62e4bcd74f458ee01ab0dd3a1a4457e7e3706", size = 2529803 }, + { url = "https://files.pythonhosted.org/packages/80/79/8bba39190d2ea17840925d287f1c6c3a7c60b58f5090444e9ecf176c540f/debugpy-1.8.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:703c1fd62ae0356e194f3e7b7a92acd931f71fe81c4b3be2c17a7b8a4b546ec2", size = 4170911 }, + { url = "https://files.pythonhosted.org/packages/3b/19/5b3d312936db8eb281310fa27903459328ed722d845d594ba5feaeb2f0b3/debugpy-1.8.7-cp312-cp312-win32.whl", hash = "sha256:2f729228430ef191c1e4df72a75ac94e9bf77413ce5f3f900018712c9da0aaca", size = 5195476 }, + { url = "https://files.pythonhosted.org/packages/9f/49/ad20b29f8c921fd5124530d3d39b8f2077efd51b71339a2eff02bba693e9/debugpy-1.8.7-cp312-cp312-win_amd64.whl", hash = "sha256:45c30aaefb3e1975e8a0258f5bbd26cd40cde9bfe71e9e5a7ac82e79bad64e39", size = 5235031 }, + { url = "https://files.pythonhosted.org/packages/51/b1/a0866521c71a6ae3d3ca320e74835163a4671b1367ba360a55a0a51e5a91/debugpy-1.8.7-py2.py3-none-any.whl", hash = "sha256:57b00de1c8d2c84a61b90880f7e5b6deaf4c312ecbde3a0e8912f2a56c4ac9ae", size = 5210683 }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, +] + +[[package]] +name = "fastcons" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/57/eafd1c55d5af2adcef889400d7350aff2f5235bb9429474ce772c50c4d6e/fastcons-0.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fdcef9400059696946a9262b6dc2b78675de1754eba4ade47a4019f148e2a27f", size = 11460 }, + { url = "https://files.pythonhosted.org/packages/a5/bf/704243be945ca6f343a94dfc75fd7fafa87316dc2ece9d50e9afbe6f26b4/fastcons-0.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:905dcae823ddc5b4d7841baf4aeb3bb78f3ab1ac6c3669740411983e7d50aaa6", size = 34263 }, + { url = "https://files.pythonhosted.org/packages/95/65/0276857a8fe8d449278e339f4f20ee48e7314867519eaf5ed0f27b6270da/fastcons-0.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a69d20f6b9b7f89798b0e92e6c6bd7e7d38d2624c5d0d9226ca2d5f2ad97964", size = 36974 }, + { url = "https://files.pythonhosted.org/packages/70/2b/d73bef709d7066c182bf8c022ad741949a71f97c96d14397835ae619b985/fastcons-0.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2daf9037f7b4b77434756193f48c86e02810ecde7e495f98f52e989f7f03d5c2", size = 33472 }, + { url = "https://files.pythonhosted.org/packages/76/33/4c391d66999215b031161ce6aeec23732884113edee7ccbe47df80ec81c8/fastcons-0.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:208efb6dc1324dceafc74fbd85c3ece4fe001de6353a0298ba1d55ee6a99748c", size = 35476 }, +] + +[[package]] +name = "filelock" +version = "3.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, +] + +[[package]] +name = "funcparserlib" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/44/a21dfd9c45ad6909257e5186378a4fedaf41406824ce1ec06bc2a6c168e7/funcparserlib-1.0.1.tar.gz", hash = "sha256:a2c4a0d7942f7a0e7635c369d921066c8d4cae7f8b5bf7914466bec3c69837f4", size = 17238 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/66/acd740d84f59c655935f586c113a863aa404dfe932052a68a1163d88ea63/funcparserlib-1.0.1-py2.py3-none-any.whl", hash = "sha256:95da15d3f0d00b9b6f4bf04005c708af3faa115f7b45692ace064ebe758c68e8", size = 17842 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "hatch" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "hatchling" }, + { name = "httpx" }, + { name = "hyperlink" }, + { name = "keyring" }, + { name = "packaging" }, + { name = "pexpect" }, + { name = "platformdirs" }, + { name = "pyperclip" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "tomli-w" }, + { name = "tomlkit" }, + { name = "userpath" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/ff/d0dc75f39798af1d3d2258c82c5fdeca2817cbfadba7c41e8fb7cf0db984/hatch-1.7.0.tar.gz", hash = "sha256:7afc701fd5b33684a6650e1ecab8957e19685f824240ba7458dcacd66f90fb46", size = 312052 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/23/60cb647991cfd20101279f9e96a7a020afe06205df984891bcc733c3a2ae/hatch-1.7.0-py3-none-any.whl", hash = "sha256:efc84112fd02ca85b7bab54f5e2ef71393a98dc849eac9aca390504031f8a1a8", size = 90505 }, +] + +[[package]] +name = "hatchling" +version = "1.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/8a4a67a8174ce59cf49e816e38e9502900aea9b4af672d0127df8e10d3b0/hatchling-1.25.0.tar.gz", hash = "sha256:7064631a512610b52250a4d3ff1bd81551d6d1431c4eb7b72e734df6c74f4262", size = 64632 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/8b/90e80904fdc24ce33f6fc6f35ebd2232fe731a8528a22008458cf197bc4d/hatchling-1.25.0-py3-none-any.whl", hash = "sha256:b47948e45d4d973034584dd4cb39c14b6a70227cf287ab7ec0ad7983408a882c", size = 84077 }, +] + +[[package]] +name = "httpcore" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/44/ed0fa6a17845fb033bd885c03e842f08c1b9406c86a2e60ac1ae1b9206a6/httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f", size = 85180 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/89/b161908e2f51be56568184aeb4a880fd287178d176fd1c860d2217f41106/httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", size = 78011 }, +] + +[[package]] +name = "httpx" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, +] + +[[package]] +name = "hy" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "funcparserlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/7f/f8061ae737f6a564c3d4f80287bd5ff46afc1f606617a9cb5c797e6a974a/hy-1.0.0.tar.gz", hash = "sha256:3a00013e075ff5ce8f5d475ca2be47e4c871f09184ba3533787cb544d32d1f9e", size = 121792 } + +[[package]] +name = "hyperlink" +version = "21.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638 }, +] + +[[package]] +name = "hyrule" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/f6/55dbed29f7b16b393d5aba61bd69762cc836089e1ff922779b09f7dd8827/hyrule-0.7.0.tar.gz", hash = "sha256:3a4b3568abfe16d16895e584c0db84c1251d258fe234ba86f072906b90545ce8", size = 32955 } + +[[package]] +name = "idna" +version = "3.10" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "immutables" +version = "0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/41/0ccaa6ef9943c0609ec5aa663a3b3e681c1712c1007147b84590cec706a0/immutables-0.21.tar.gz", hash = "sha256:b55ffaf0449790242feb4c56ab799ea7af92801a0a43f9e2f4f8af2ab24dfc4a", size = 89008 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/f9/0c46f600702b815182212453f5514c0070ee168b817cdf7c3767554c8489/immutables-0.21-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef1ed262094b755903122c3c3a83ad0e0d5c3ab7887cda12b2fe878769d1ee0d", size = 31885 }, + { url = "https://files.pythonhosted.org/packages/29/34/7608d2eab6179aa47e8f59ab0fbd5b3eeb2333d78c9dc2da0de8de4ed322/immutables-0.21-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce604f81d9d8f26e60b52ebcb56bb5c0462c8ea50fb17868487d15f048a2f13e", size = 31537 }, + { url = "https://files.pythonhosted.org/packages/f7/52/cb9e2bb7a69338155ffabbd2f993c968c750dd2d5c6c6eaa6ebb7bfcbdfa/immutables-0.21-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b48b116aaca4500398058b5a87814857a60c4cb09417fecc12d7da0f5639b73d", size = 104270 }, + { url = "https://files.pythonhosted.org/packages/0f/a4/25df835a9b9b372a4a869a8a1ac30a32199f2b3f581ad0e249f7e3d19eed/immutables-0.21-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dad7c0c74b285cc0e555ec0e97acbdc6f1862fcd16b99abd612df3243732e741", size = 104864 }, + { url = "https://files.pythonhosted.org/packages/4a/51/b548fbc657134d658e179ee8d201ae82d9049aba5c3cb2d858ed2ecb7e3f/immutables-0.21-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e44346e2221a5a676c880ca8e0e6429fa24d1a4ae562573f5c04d7f2e759b030", size = 99733 }, + { url = "https://files.pythonhosted.org/packages/47/db/d7b1e0e88faf07fe9a88579a86f58078a9a37fff871f4b3dbcf28cad9a12/immutables-0.21-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b10139b529a460e53fe8be699ebd848c54c8a33ebe67763bcfcc809a475a26f", size = 101698 }, + { url = "https://files.pythonhosted.org/packages/69/2d/6fe42a1a053dd8cfb9f45e91d5246522637c7287dc6bd347f67aedf7aedb/immutables-0.21-cp312-cp312-win32.whl", hash = "sha256:fc512d808662614feb17d2d92e98f611d69669a98c7af15910acf1dc72737038", size = 30977 }, + { url = "https://files.pythonhosted.org/packages/63/45/d062aca6971e99454ce3ae42a7430037227fee961644ed1f8b6c9b99e0a5/immutables-0.21-cp312-cp312-win_amd64.whl", hash = "sha256:461dcb0f58a131045155e52a2c43de6ec2fe5ba19bdced6858a3abb63cee5111", size = 35088 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 }, +] + +[[package]] +name = "jaraco-context" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825 }, +] + +[[package]] +name = "jaraco-functools" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/23/9894b3df5d0a6eb44611c36aec777823fc2e07740dabbd0b810e19594013/jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", size = 19159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", size = 10187 }, +] + +[[package]] +name = "jeepney" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/f4/154cf374c2daf2020e05c3c6a03c91348d59b23c5366e968feb198306fdf/jeepney-0.8.0.tar.gz", hash = "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806", size = 106005 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/72/2a1e2290f1ab1e06f71f3d0f1646c9e4634e70e1d37491535e19266e8dc9/jeepney-0.8.0-py3-none-any.whl", hash = "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755", size = 48435 }, +] + +[[package]] +name = "keyring" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/24/64447b13df6a0e2797b586dad715766d756c932ce8ace7f67bd384d76ae0/keyring-25.5.0.tar.gz", hash = "sha256:4c753b3ec91717fe713c4edd522d625889d8973a349b0e582622f49766de58e6", size = 62675 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/c9/353c156fa2f057e669106e5d6bcdecf85ef8d3536ce68ca96f18dc7b6d6f/keyring-25.5.0-py3-none-any.whl", hash = "sha256:e67f8ac32b04be4714b42fe84ce7dad9c40985b9ca827c592cc303e7c26d9741", size = 39096 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "microkanren" +version = "0.4.4" +source = { editable = "." } +dependencies = [ + { name = "fastcons" }, + { name = "immutables" }, + { name = "pyrsistent" }, +] + +[package.optional-dependencies] +dev = [ + { name = "debugpy" }, + { name = "hatch" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "tox" }, + { name = "tox-uv" }, +] +hy = [ + { name = "hy" }, + { name = "hyrule" }, +] + +[package.metadata] +requires-dist = [ + { name = "debugpy", marker = "extra == 'dev'", specifier = ">=1.8.7" }, + { name = "fastcons", specifier = "==0.5.0" }, + { name = "hatch", marker = "extra == 'dev'", specifier = "==1.7.0" }, + { name = "hy", marker = "extra == 'hy'", specifier = ">=1.0.0,<2.0.0" }, + { name = "hyrule", marker = "extra == 'hy'", specifier = ">=0.7.0,<1.0.0" }, + { name = "immutables", specifier = ">=0.19,<1" }, + { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.377" }, + { name = "pyrsistent", specifier = ">=0.19,<1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "==7.2.2" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.7.1" }, + { name = "tox", marker = "extra == 'dev'", specifier = ">=4.23.2,<5" }, + { name = "tox-uv", marker = "extra == 'dev'", specifier = ">=1.16.0" }, +] + +[[package]] +name = "more-itertools" +version = "10.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/78/65922308c4248e0eb08ebcbe67c95d48615cc6f27854b6f2e57143e9178f/more-itertools-10.5.0.tar.gz", hash = "sha256:5482bfef7849c25dc3c6dd53a6173ae4795da2a41a80faea6700d9f5846c5da6", size = 121020 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/7e/3a64597054a70f7c86eb0a7d4fc315b8c1ab932f64883a297bdffeb5f967/more_itertools-10.5.0-py3-none-any.whl", hash = "sha256:037b0d3203ce90cca8ab1defbbdac29d5f993fc20131f3664dc8d6acfa872aef", size = 60952 }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "packaging" +version = "24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pygments" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, +] + +[[package]] +name = "pyperclip" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/23/2f0a3efc4d6a32f3b63cdff36cd398d9701d26cda58e3ab97ac79fb5e60d/pyperclip-1.9.0.tar.gz", hash = "sha256:b7de0142ddc81bfc5c7507eea19da920b92252b548b96186caf94a5e2527d310", size = 20961 } + +[[package]] +name = "pyproject-api" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/19/441e0624a8afedd15bbcce96df1b80479dd0ff0d965f5ce8fde4f2f6ffad/pyproject_api-1.8.0.tar.gz", hash = "sha256:77b8049f2feb5d33eefcc21b57f1e279636277a8ac8ad6b5871037b243778496", size = 22340 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/f4/3c4ddfcc0c19c217c6de513842d286de8021af2f2ab79bbb86c00342d778/pyproject_api-1.8.0-py3-none-any.whl", hash = "sha256:3d7d347a047afe796fd5d1885b1e391ba29be7169bd2f102fcd378f04273d228", size = 13100 }, +] + +[[package]] +name = "pyright" +version = "1.1.386" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/50/1a57054b5585fa72a93a6244c1b4b5639f8f7a1cc60b2e807cc67da8f0bc/pyright-1.1.386.tar.gz", hash = "sha256:8e9975e34948ba5f8e07792a9c9d2bdceb2c6c0b61742b068d2229ca2bc4a9d9", size = 21949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/68/47fd6b3ffa27c99d7e0c866c618f07784b8806712059049daa492ca7e526/pyright-1.1.386-py3-none-any.whl", hash = "sha256:7071ac495593b2258ccdbbf495f1a5c0e5f27951f6b429bed4e8b296eb5cd21d", size = 18577 }, +] + +[[package]] +name = "pyrsistent" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/3a/5031723c09068e9c8c2f0bc25c3a9245f2b1d1aea8396c787a408f2b95ca/pyrsistent-0.20.0.tar.gz", hash = "sha256:4c48f78f62ab596c679086084d0dd13254ae4f3d6c72a83ffdf5ebdef8f265a4", size = 103642 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/ee/ff2ed52032ac1ce2e7ba19e79bd5b05d152ebfb77956cf08fcd6e8d760ea/pyrsistent-0.20.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:09848306523a3aba463c4b49493a760e7a6ca52e4826aa100ee99d8d39b7ad1e", size = 83537 }, + { url = "https://files.pythonhosted.org/packages/80/f1/338d0050b24c3132bcfc79b68c3a5f54bce3d213ecef74d37e988b971d8a/pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a14798c3005ec892bbada26485c2eea3b54109cb2533713e355c806891f63c5e", size = 122615 }, + { url = "https://files.pythonhosted.org/packages/07/3a/e56d6431b713518094fae6ff833a04a6f49ad0fbe25fb7c0dc7408e19d20/pyrsistent-0.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b14decb628fac50db5e02ee5a35a9c0772d20277824cfe845c8a8b717c15daa3", size = 122335 }, + { url = "https://files.pythonhosted.org/packages/4a/bb/5f40a4d5e985a43b43f607250e766cdec28904682c3505eb0bd343a4b7db/pyrsistent-0.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e2c116cc804d9b09ce9814d17df5edf1df0c624aba3b43bc1ad90411487036d", size = 118510 }, + { url = "https://files.pythonhosted.org/packages/1c/13/e6a22f40f5800af116c02c28e29f15c06aa41cb2036f6a64ab124647f28b/pyrsistent-0.20.0-cp312-cp312-win32.whl", hash = "sha256:e78d0c7c1e99a4a45c99143900ea0546025e41bb59ebc10182e947cf1ece9174", size = 60865 }, + { url = "https://files.pythonhosted.org/packages/75/ef/2fa3b55023ec07c22682c957808f9a41836da4cd006b5f55ec76bf0fbfa6/pyrsistent-0.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:4021a7f963d88ccd15b523787d18ed5e5269ce57aa4037146a2377ff607ae87d", size = 63239 }, + { url = "https://files.pythonhosted.org/packages/23/88/0acd180010aaed4987c85700b7cc17f9505f3edb4e5873e4dc67f613e338/pyrsistent-0.20.0-py3-none-any.whl", hash = "sha256:c55acc4733aad6560a7f5f818466631f07efc001fd023f34a6c203f8b6df0f0b", size = 58106 }, +] + +[[package]] +name = "pytest" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/29/311895d9cd3f003dd58e8fdea36dd895ba2da5c0c90601836f7de79f76fe/pytest-7.2.2.tar.gz", hash = "sha256:c99ab0c73aceb050f68929bc93af19ab6db0558791c6a0715723abe9d0ade9d4", size = 1320028 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/68/5321b5793bd506961bd40bdbdd0674e7de4fb873ee7cab33dd27283ad513/pytest-7.2.2-py3-none-any.whl", hash = "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e", size = 317207 }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, +] + +[[package]] +name = "rich" +version = "13.9.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e9/cf9ef5245d835065e6673781dbd4b8911d352fb770d56cf0879cf11b7ee1/rich-13.9.3.tar.gz", hash = "sha256:bc1e01b899537598cf02579d2b9f4a415104d3fc439313a7a2c165d76557a08e", size = 222889 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/e2/10e9819cf4a20bd8ea2f5dabafc2e6bf4a78d6a0965daeb60a4b34d1c11f/rich-13.9.3-py3-none-any.whl", hash = "sha256:9836f5096eb2172c9e77df411c1b009bace4193d6a481d534fea75ebba758283", size = 242157 }, +] + +[[package]] +name = "ruff" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/21/5c6e05e0fd3fbb41be4fb92edbc9a04de70baf60adb61435ce0c6b8c3d55/ruff-0.7.1.tar.gz", hash = "sha256:9d8a41d4aa2dad1575adb98a82870cf5db5f76b2938cf2206c22c940034a36f4", size = 3181670 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/45/8a20a9920175c9c4892b2420f80ff3cf14949cf3067118e212f9acd9c908/ruff-0.7.1-py3-none-linux_armv6l.whl", hash = "sha256:cb1bc5ed9403daa7da05475d615739cc0212e861b7306f314379d958592aaa89", size = 10389268 }, + { url = "https://files.pythonhosted.org/packages/1b/d3/2f8382db2cf4f9488e938602e33e36287f9d26cb283aa31f11c31297ce79/ruff-0.7.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27c1c52a8d199a257ff1e5582d078eab7145129aa02721815ca8fa4f9612dc35", size = 10188348 }, + { url = "https://files.pythonhosted.org/packages/a2/31/7d14e2a88da351200f844b7be889a0845d9e797162cf76b136d21b832a23/ruff-0.7.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:588a34e1ef2ea55b4ddfec26bbe76bc866e92523d8c6cdec5e8aceefeff02d99", size = 9841448 }, + { url = "https://files.pythonhosted.org/packages/db/99/738cafdc768eceeca0bd26c6f03e213aa91203d2278e1d95b1c31c4ece41/ruff-0.7.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94fc32f9cdf72dc75c451e5f072758b118ab8100727168a3df58502b43a599ca", size = 10674864 }, + { url = "https://files.pythonhosted.org/packages/fe/12/bcf2836b50eab53c65008383e7d55201e490d75167c474f14a16e1af47d2/ruff-0.7.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:985818742b833bffa543a84d1cc11b5e6871de1b4e0ac3060a59a2bae3969250", size = 10192105 }, + { url = "https://files.pythonhosted.org/packages/2b/71/261d5d668bf98b6c44e89bfb5dfa4cb8cb6c8b490a201a3d8030e136ea4f/ruff-0.7.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32f1e8a192e261366c702c5fb2ece9f68d26625f198a25c408861c16dc2dea9c", size = 11194144 }, + { url = "https://files.pythonhosted.org/packages/90/1f/0926d18a3b566fa6e7b3b36093088e4ffef6b6ba4ea85a462d9a93f7e35c/ruff-0.7.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:699085bf05819588551b11751eff33e9ca58b1b86a6843e1b082a7de40da1565", size = 11917066 }, + { url = "https://files.pythonhosted.org/packages/cd/a8/9fac41f128b6a44ab4409c1493430b4ee4b11521e8aeeca19bfe1ce851f9/ruff-0.7.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:344cc2b0814047dc8c3a8ff2cd1f3d808bb23c6658db830d25147339d9bf9ea7", size = 11458821 }, + { url = "https://files.pythonhosted.org/packages/25/cd/59644168f086ab13fe4e02943b9489a0aa710171f66b178e179df5383554/ruff-0.7.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4316bbf69d5a859cc937890c7ac7a6551252b6a01b1d2c97e8fc96e45a7c8b4a", size = 12700379 }, + { url = "https://files.pythonhosted.org/packages/fb/30/3bac63619eb97174661829c07fc46b2055a053dee72da29d7c304c1cd2c0/ruff-0.7.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79d3af9dca4c56043e738a4d6dd1e9444b6d6c10598ac52d146e331eb155a8ad", size = 11019813 }, + { url = "https://files.pythonhosted.org/packages/4b/af/f567b885b5cb3bcdbcca3458ebf210cc8c9c7a9f61c332d3c2a050c3b21e/ruff-0.7.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5c121b46abde94a505175524e51891f829414e093cd8326d6e741ecfc0a9112", size = 10662146 }, + { url = "https://files.pythonhosted.org/packages/bc/ad/eb930d3ad117a9f2f7261969c21559ebd82bb13b6e8001c7caed0d44be5f/ruff-0.7.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8422104078324ea250886954e48f1373a8fe7de59283d747c3a7eca050b4e378", size = 10256911 }, + { url = "https://files.pythonhosted.org/packages/20/d5/af292ce70a016fcec792105ca67f768b403dd480a11888bc1f418fed0dd5/ruff-0.7.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:56aad830af8a9db644e80098fe4984a948e2b6fc2e73891538f43bbe478461b8", size = 10767488 }, + { url = "https://files.pythonhosted.org/packages/24/85/cc04a3bd027f433bebd2a097e63b3167653c079f7f13d8f9a1178e693412/ruff-0.7.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:658304f02f68d3a83c998ad8bf91f9b4f53e93e5412b8f2388359d55869727fd", size = 11093368 }, + { url = "https://files.pythonhosted.org/packages/0b/fb/c39cbf32d1f3e318674b8622f989417231794926b573f76dd4d0ca49f0f1/ruff-0.7.1-py3-none-win32.whl", hash = "sha256:b517a2011333eb7ce2d402652ecaa0ac1a30c114fbbd55c6b8ee466a7f600ee9", size = 8594180 }, + { url = "https://files.pythonhosted.org/packages/5a/71/ec8cdea34ecb90c830ca60d54ac7b509a7b5eab50fae27e001d4470fe813/ruff-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f38c41fcde1728736b4eb2b18850f6d1e3eedd9678c914dede554a70d5241307", size = 9419751 }, + { url = "https://files.pythonhosted.org/packages/79/7b/884553415e9f0a9bf358ed52fb68b934e67ef6c5a62397ace924a1afdf9a/ruff-0.7.1-py3-none-win_arm64.whl", hash = "sha256:19aa200ec824c0f36d0c9114c8ec0087082021732979a359d6f3c390a6ff2a37", size = 8717402 }, +] + +[[package]] +name = "secretstorage" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "tomli-w" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/19/b65f1a088ee23e37cdea415b357843eca8b1422a7b11a9eee6e35d4ec273/tomli_w-1.1.0.tar.gz", hash = "sha256:49e847a3a304d516a169a601184932ef0f6b61623fe680f836a2aa7128ed0d33", size = 6929 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ac/ce90573ba446a9bbe65838ded066a805234d159b4446ae9f8ec5bbd36cbd/tomli_w-1.1.0-py3-none-any.whl", hash = "sha256:1403179c78193e3184bfaade390ddbd071cba48a32a2e62ba11aae47490c63f7", size = 6440 }, +] + +[[package]] +name = "tomlkit" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 }, +] + +[[package]] +name = "tox" +version = "4.23.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "chardet" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/86/32b10f91b4b975a37ac402b0f9fa016775088e0565c93602ba0b3c729ce8/tox-4.23.2.tar.gz", hash = "sha256:86075e00e555df6e82e74cfc333917f91ecb47ffbc868dcafbd2672e332f4a2c", size = 189998 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/c0/124b73d01c120e917383bc6c53ebc34efdf7243faa9fca64d105c94cf2ab/tox-4.23.2-py3-none-any.whl", hash = "sha256:452bc32bb031f2282881a2118923176445bac783ab97c874b8770ab4c3b76c38", size = 166758 }, +] + +[[package]] +name = "tox-uv" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tox" }, + { name = "uv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/5e/c3d2a45ab5465dddbbc267a589c9cfce23b91750d49af10738a08c98534e/tox_uv-1.16.0.tar.gz", hash = "sha256:71b2e2fa6c35c1360b91a302df1d65b3e5a1f656b321c5ebf7b84545804c9f01", size = 16337 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/8d/1baa9f725ddd4824708759cf7b74bc43379f5f7feb079fde0629d7b32b3e/tox_uv-1.16.0-py3-none-any.whl", hash = "sha256:e6f0b525a687e745ab878d07cbf5c7e85d582028d4a7c8935f95e84350651432", size = 13661 }, +] + +[[package]] +name = "trove-classifiers" +version = "2024.10.21.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/85/92c2667cf221b37648041ce9319427f92fa76cbec634aad844e67e284706/trove_classifiers-2024.10.21.16.tar.gz", hash = "sha256:17cbd055d67d5e9d9de63293a8732943fabc21574e4c7b74edf112b4928cf5f3", size = 16153 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/35/5055ab8d215af853d07bbff1a74edf48f91ed308f037380a5ca52dd73348/trove_classifiers-2024.10.21.16-py3-none-any.whl", hash = "sha256:0fb11f1e995a757807a8ef1c03829fbd4998d817319abcef1f33165750f103be", size = 13546 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "userpath" +version = "1.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/b7/30753098208505d7ff9be5b3a32112fb8a4cb3ddfccbbb7ba9973f2e29ff/userpath-1.9.2.tar.gz", hash = "sha256:6c52288dab069257cc831846d15d48133522455d4677ee69a9781f11dbefd815", size = 11140 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl", hash = "sha256:2cbf01a23d655a1ff8fc166dfb78da1b641d1ceabf0fe5f970767d380b14e89d", size = 9065 }, +] + +[[package]] +name = "uv" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/f2/0c3d2d741a62985b570787f8613fae1552de25fdd696c59c9bff1fd485d8/uv-0.5.0.tar.gz", hash = "sha256:d796198163478a8db4e2f27fa6a21fb7c96c3b62c4af28bfaf8a654b7a86ce0a", size = 2126398 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/ce/cfa36f135d09c13a7e27183cb5a28ac93a3bdde100c4747ca4c5c3d9f2ac/uv-0.5.0-py3-none-linux_armv6l.whl", hash = "sha256:b52fd615c4dba8366677528122f4ead7d0651dc6cbc8cd6d17be72e2deb0390c", size = 13507588 }, + { url = "https://files.pythonhosted.org/packages/ef/c8/4fc2ac6ef7b1d5d64016d0250c2651e3fb2a85ed953aeb2ed3fd2b4e3fca/uv-0.5.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b9e22f38bd4cd66ea252fe9060ae567da92eec2dc9154fedab1f059c37288ee0", size = 13496193 }, + { url = "https://files.pythonhosted.org/packages/69/fb/e778bce57b12eac37c1e9e8e1efc8b190a2c72dee02b532d13706be447f4/uv-0.5.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a3bc6911be7d86f3750bce1580e664877a3a88c126eb68afbb132cd0896fd109", size = 12502854 }, + { url = "https://files.pythonhosted.org/packages/24/40/8c6483c5ab7998c5dcde7ab1428181e1c43ff321ddd8967c0c1bf3cc5566/uv-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:de8c70d26bc4231ada30d14eaf105740ad735b2b41fde9b81978df5f0ed25152", size = 12788512 }, + { url = "https://files.pythonhosted.org/packages/a5/30/2d152f914e8d89581d2f5c5a314993325b8c502689da40ccd398b1e3c314/uv-0.5.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b846b92230d64e50425cbf183e119f9c27ebd2eae77c197b3625c701a5c13b08", size = 13325013 }, + { url = "https://files.pythonhosted.org/packages/e1/35/47b01db8ba2ff6339a418630a90d89282b3271923f69a8d163f4cabdc2cc/uv-0.5.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:886c85e53b99cb66c544feab20d5a64467556ec59c92445a7aa2fc637e4f5820", size = 13847245 }, + { url = "https://files.pythonhosted.org/packages/b5/e0/96e5c2cdf0cf8d8a51851c102cba67257c24c8e09d6ec434ca0434ec7d43/uv-0.5.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4f0bcd3e97010e79a7a75e840d1177a859bf07764da1079e9fbce66e7ebd9428", size = 14409958 }, + { url = "https://files.pythonhosted.org/packages/91/7b/c7d92e81de7180df60960ac55a96582118f192e07a9f4c525fff262f5ca0/uv-0.5.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6c071304fae1e530c7d24464f80f5efdc3e03b04c620703e1d351d27afc970b", size = 14187544 }, + { url = "https://files.pythonhosted.org/packages/86/43/e05414cee4e984a9dc94aeb350e39f11ee0889e9cdd31e67275a6ab3bdd1/uv-0.5.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c59e971c02a953d1dc1a937ef84de527d8fbe9ae13faa71ee8c0d5f697127cc", size = 18442066 }, + { url = "https://files.pythonhosted.org/packages/b0/a8/028903341efb9c9a95487ef5287ba736b37507c4251339b1e397e258a182/uv-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb131612a96b719b80e15e3261b2dee67028b137a4bb86730f8fb02808f2d79", size = 13982574 }, + { url = "https://files.pythonhosted.org/packages/10/19/3eddfb5636ac7b67da89b1fefd59d9d9cd3fa389bc03c1f4c5775e26a7e4/uv-0.5.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:313c9fc30c6679fbf5bf4acc043ad171bee7853bb16f366af064e835d1fb1a74", size = 12967310 }, + { url = "https://files.pythonhosted.org/packages/b6/a9/60d913266feea57ce1fa0badf01afb7ab0b9bcd47bf9f6b4ae5bcffa6b8b/uv-0.5.0-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:63cc3a9f346b74012f7ac1daea1aee22568da1023993d8f4a7b8bc30bcb4edf2", size = 13293381 }, + { url = "https://files.pythonhosted.org/packages/95/d7/358021866c3d8ea2c046a50bd44f900e99e909707944803166a3d8cc1b73/uv-0.5.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:b256e450f103e98e6d8ebd92af44db16d5d699766c73f9da979cddcc9665577c", size = 13597775 }, + { url = "https://files.pythonhosted.org/packages/40/96/c79530823e580f13012e52845c115d2d68afd28462cd539eff379e981c42/uv-0.5.0-py3-none-musllinux_1_1_ppc64le.whl", hash = "sha256:d1b7fa52da65196c29569032c1c1144574e75b0caaaca77ea4c22f4a09dedc60", size = 15484475 }, + { url = "https://files.pythonhosted.org/packages/73/1d/9614d0712275679c0229abfa09f78fda4b0939e6494f5b6d6025e052aef5/uv-0.5.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8a603ed4c91fba250cc62aaf3b54b68cf70b7fefda07b6c2f230a6d8a8005616", size = 14088554 }, + { url = "https://files.pythonhosted.org/packages/b0/fd/1262b86c18d1e9511d86d55cad99ae94dd84721c1585fb96c8b9e015de0b/uv-0.5.0-py3-none-win32.whl", hash = "sha256:feb4db59fd402461f64d9493525b2dd7bda5f8b1bb1502f1f1dbb8cd9dff7c62", size = 13377423 }, + { url = "https://files.pythonhosted.org/packages/3c/e5/019c86e33fd1216f4412968b096e4c16ef472b6a326e942b75bc043e6be1/uv-0.5.0-py3-none-win_amd64.whl", hash = "sha256:f5ad860fb028179ce4467fec6dd2b2a1a369cbd67e2a058f1b50116055fda5b8", size = 15189443 }, +] + +[[package]] +name = "virtualenv" +version = "20.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/7f/192dd6ab6d91ebea7adf6c030eaf549b1ec0badda9f67a77b633602f66ac/virtualenv-20.27.0.tar.gz", hash = "sha256:2ca56a68ed615b8fe4326d11a0dca5dfbe8fd68510fb6c6349163bed3c15f2b2", size = 6483858 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/15/828ec11907aee2349a9342fa71fba4ba7f3af938162a382dd7da339dea16/virtualenv-20.27.0-py3-none-any.whl", hash = "sha256:44a72c29cceb0ee08f300b314848c86e57bf8d1f13107a5e671fb9274138d655", size = 3110969 }, +]