diff --git a/README.md b/README.md index b1c5ee4..b20f41a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,100 @@ -# mthd -Automatically commit code experiments with hypothesis-driven git messages +
+ + ![logo](https://github.com/user-attachments/assets/cb3ddb4f-5f40-4231-9efe-29e045705dda) + + [Discord](https://discord.gg/kTkF2e69fH) • [Website](https://flywhl.dev) • [Installation](#installation) +
+
+
+ +## Features + +`mthd` turns your commit history into a searchable scientific log. + +Every time you run an experiment, your code will be auto-committed with metadata in the commit message. + + +## Installation + +* `uv add mthd` + +## Usage + +* Put the `@commit` decorator on your experiment function. +* `mthd` will store hyperparameters and metrics as metadata in the commit message. +* Query your scientific log, e.g. `mthd query metrics.accuracy < 0.8`. + +```python +from mthd import commit +from pydantic import BaseModel + +class Hypers(BaseModel): + + lr: float + epochs: int + + +class Metrics(BaseModel): + + accuracy: float + + +@commit(hypers="hypers") +def my_experiment(hypers: Hypers) -> Metrics: + ... + # experiment + ... + + metrics = Metrics(...) + + return metrics +``` + +Then run your experiment: + +``` +$ python experiment.py + +Generating commit with message: + + exp: run test at 2025-02-10 18:39:18.759230 + + --- + + { + "experiment": "test", + "hyperparameters": { + "lr": 0.001, + "epochs": 100, + }, + "metrics": { + "accuracy": 0.9, + }, + "uuid": "94871de1-4d6c-4e70-9c9d-60ec11df1159", + "artifacts": null, + "annotations": null, + "timestamp": "2025-02-10T18:39:18.759230" + } +``` + +Finally, query for relevant commits: + +``` +$ mthd query metrics.accuracy > 0.8 + +Found 1 commit(s): + + af6cd7 +``` + + +## Development + +* `git clone https://github.com/flywhl/mthd.git` +* `cd mthd` +* `uv sync` +* `just test` + +## Flywheel + +Science needs better software tools. [Flywheel](https://flywhl.dev/) is an open source collective building simple tools to preserve scientific momentum, inspired by devtools and devops culture. Join our Discord [here](discord.gg/fd37MFZ7RS). diff --git a/mthd/cli/__init__.py b/mthd/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mthd/cli/app.py b/mthd/cli/app.py new file mode 100644 index 0000000..f28d0f1 --- /dev/null +++ b/mthd/cli/app.py @@ -0,0 +1,18 @@ +import click + +from dishka.integrations.click import setup_dishka + +from mthd.cli.commands.query import query +from mthd.util.di import DI + + +def start(): + @click.group() + @click.pass_context + def main(context: click.Context): + di = DI() + setup_dishka(container=di.container, context=context, auto_inject=True) + + main.command("query")(query) + + main() diff --git a/mthd/cli/commands/__init__.py b/mthd/cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mthd/cli/commands/query.py b/mthd/cli/commands/query.py new file mode 100644 index 0000000..94230cf --- /dev/null +++ b/mthd/cli/commands/query.py @@ -0,0 +1,18 @@ +import click + +from dishka import FromDishka + +from mthd.service.query import QueryService + + +@click.argument("query", type=str) +@click.option("limit", "--limit", type=int, default=-1) +def query(query: str, limit: int, query_service: FromDishka[QueryService]): + query_parts = query.split(" ") + print(query_parts) + if len(query_parts) != 3: + raise ValueError(f"Invalid query: {query}") + + result = query_service.execute_simple(*query_parts, limit=limit) + + print(result) diff --git a/mthd/config.py b/mthd/config.py new file mode 100644 index 0000000..90e99a3 --- /dev/null +++ b/mthd/config.py @@ -0,0 +1,2 @@ +BODY_METADATA_SEPARATOR = "---" +SUMMARY_BODY_SEPARATOR = "\n\n" diff --git a/mthd/decorator.py b/mthd/decorator.py index a67c9ad..2d06685 100644 --- a/mthd/decorator.py +++ b/mthd/decorator.py @@ -1,3 +1,5 @@ +import os + from functools import wraps from typing import Callable, Optional, cast @@ -5,13 +7,18 @@ from rich.console import Console from rich.padding import Padding -from mthd.domain.commit import CommitMessage, StageStrategy +from mthd.domain.experiment import ExperimentRun +from mthd.domain.git import StageStrategy from mthd.error import MthdError +from mthd.service.git import GitService +from mthd.util.di import DI def commit( fn: Optional[Callable] = None, + *, hypers: str = "hypers", + template: str = "run {experiment}", strategy: StageStrategy = StageStrategy.ALL, ) -> Callable: """Decorator to auto-commit experimental code with scientific metadata. @@ -22,37 +29,38 @@ def commit( def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs): - # di = DI() + di = DI() + # @todo: handle this better (eg. positional args) hyperparameters = cast(BaseModel, kwargs.get(hypers, None)) if not hyperparameters: - raise MthdError( - "Hyperparameters must be provided in the function call." - ) - # git_service = di[GitService] + raise MthdError("Hyperparameters must be provided in the function call.") + git_service = di[GitService] # codebase_service = di[CodebaseService] # Generate commit message - commit_msg = CommitMessage( - summary="exp: foo bar baz", + + # Run experiment + metrics = func(*args, **kwargs) + + experiment = ExperimentRun( + experiment=func.__name__, hyperparameters=hyperparameters.model_dump(), + metrics=metrics.model_dump(), # annotations=codebase_service.get_all_annotations(), ) - # print(hyperparameters.model_dump_json(indent=2)) - # print(commit_msg.format()) - - # Run experiment - result = func(*args, **kwargs) + message = experiment.as_commit_message(template=template) # Commit changes - console = Console() - console.print("Generating commit with message:\n") - console.print( - Padding(commit_msg.format(), pad=(0, 0, 0, 4)) - ) # Indent by 4 spaces. - # if git_service.should_commit(strategy): - # git_service.stage_and_commit(commit_msg) + if git_service.should_commit(strategy): + console = Console() + console.print("Generating commit with message:\n") + console.print(Padding(message.render(with_metadata=True), pad=(0, 0, 0, 4))) # Indent by 4 spaces. + if os.getenv("MTHD_DRY_RUN") == "1": + console.print("\nDry run enabled. Not committing changes.") + else: + git_service.stage_and_commit(message.render()) - return result + return metrics return wrapper @@ -62,14 +70,21 @@ def wrapper(*args, **kwargs): if __name__ == "__main__": + os.environ["MTHD_DRY_RUN"] = "1" class Hyperparameters(BaseModel): a: int b: float c: str - @commit(hypers="hypers") + class Metrics(BaseModel): + a: int + b: float + c: str + + @commit(hypers="hypers", template="run {experiment} at {timestamp}") def test(hypers: Hyperparameters): - print("\n") + print("\n\n") + return Metrics(a=1, b=2.0, c="3") test(hypers=Hyperparameters(a=1, b=2.0, c="3")) diff --git a/mthd/domain/commit.py b/mthd/domain/commit.py deleted file mode 100644 index 568cdd8..0000000 --- a/mthd/domain/commit.py +++ /dev/null @@ -1,18 +0,0 @@ -from enum import Enum, auto - -from mthd.util.model import Model - - -class CommitMessage(Model): - summary: str - hyperparameters: dict - # annotations: set[Annotation] # @todo: fix anot - - def format(self) -> str: - return ( - f"{self.summary}\n\n{self.model_dump_json(indent=2, exclude={'summary'})}" - ) - - -class StageStrategy(Enum): - ALL = auto() diff --git a/mthd/domain/experiment.py b/mthd/domain/experiment.py index e69de29..91915fa 100644 --- a/mthd/domain/experiment.py +++ b/mthd/domain/experiment.py @@ -0,0 +1,115 @@ +import json +import logging + +from datetime import datetime +from enum import StrEnum +from typing import TYPE_CHECKING, Any, Optional +from uuid import uuid4 + +from pydantic import UUID4, Field + +from mthd.config import BODY_METADATA_SEPARATOR, SUMMARY_BODY_SEPARATOR +from mthd.util.model import Model + +if TYPE_CHECKING: + from mthd.domain.git import Commit + +logger = logging.getLogger(__name__) + + +class ExperimentRun(Model): + """The core data from an experiment run""" + + experiment: str + hyperparameters: dict + metrics: dict + uuid: UUID4 = Field(default_factory=uuid4) + artifacts: Optional[dict] = None # Any generated files/data + annotations: Optional[dict] = None + timestamp: datetime = datetime.now() + + def as_commit_message(self, template: str) -> "SemanticMessage": + """Convert this experiment run into a commit""" + return SemanticMessage( + kind=CommitKind.EXP, + summary=template.format(**self.model_dump(include={"experiment", "timestamp"})), + metadata=self.model_dump(mode="json"), + ) + + @staticmethod + def from_commit(commit: "Commit") -> Optional["ExperimentRun"]: + message = SemanticMessage.from_commit(commit) + # if not message: + # # @todo: is :20s the right syntax to truncate? + # logger.debug(f"Could not parse semantic message: '{commit.message:.20s}'") + # return None + + return ExperimentRun.model_validate(message.metadata) + + +class CommitKind(StrEnum): + """Types of semantic commits""" + + EXP = "exp" + FIX = "fix" + FEAT = "feat" + CHORE = "chore" + TOOLING = "tooling" + REFACTOR = "refactor" + + @property + def has_metadata(self) -> bool: + return self is CommitKind.EXP + + @staticmethod + def from_header(header: str) -> Optional["CommitKind"]: + """Parse a commit kind from a header string""" + try: + kind = header.split(":")[0].strip() + return CommitKind(kind) + except Exception: + return None + + +class SemanticMessage(Model): + """Base class for our semantic commit formats""" + + kind: CommitKind + summary: str + body: Optional[str] = None + metadata: Optional[dict[str, Any]] = None + + def render(self, with_metadata: bool = False) -> str: + msg = f"{self.kind.value}: {self.summary}" + if self.body: + msg += f"\n\n{self.body}" + if with_metadata and self.metadata: + msg += f"\n\n{BODY_METADATA_SEPARATOR}\n\n{json.dumps(self.metadata, indent=2)}" + return msg + + @classmethod + def from_commit(cls, commit: "Commit") -> "SemanticMessage": + kind, summary, body, metadata = cls._parse_semantic_parts(commit) + + return cls(kind=kind, summary=summary.strip(), body=body, metadata=metadata) + + @staticmethod + def _parse_semantic_parts(commit: "Commit") -> tuple[CommitKind, str, Optional[str], Optional[dict]]: + # we only want to split on the first separator + parts = commit.message.split(SUMMARY_BODY_SEPARATOR, maxsplit=1) + header = parts[0] + kind_str, summary = header.split(":", 1) + kind = CommitKind(kind_str) + + if len(parts) == 2: + body = parts[1] + if kind.has_metadata: + body, raw_metadata = body.split(BODY_METADATA_SEPARATOR) + metadata = json.loads(raw_metadata) + assert isinstance(metadata, dict) + else: + metadata = None + else: + body = metadata = None + + return kind, summary, body, metadata diff --git a/mthd/domain/git.py b/mthd/domain/git.py new file mode 100644 index 0000000..4fb862b --- /dev/null +++ b/mthd/domain/git.py @@ -0,0 +1,47 @@ +from datetime import datetime +from enum import Enum, auto +from typing import Optional + +import git + +from mthd.domain.experiment import ExperimentRun +from mthd.util.model import Model + + +class Commit(Model): + """Represents a git commit""" + + sha: str + message: str + date: datetime + + @staticmethod + def from_git(commit: git.Commit) -> "Commit": + message = commit.message if isinstance(commit.message, str) else commit.message.decode() + return Commit( + sha=commit.hexsha, + message=message, + date=commit.committed_datetime, + ) + + def startswith(self, value: str) -> bool: + return self.message.startswith(value) + + +class ExperimentCommit(Commit): + """A commit that contains experiment data""" + + experiment_run: ExperimentRun + + @classmethod + def from_commit(cls, commit: Commit) -> Optional["ExperimentCommit"]: + exp = ExperimentRun.from_commit(commit) + if exp: + return cls(sha=commit.sha, message=commit.message, date=commit.date, experiment_run=exp) + return None + + +class StageStrategy(Enum): + """Strategy for staging files in git""" + + ALL = auto() diff --git a/mthd/domain/query.py b/mthd/domain/query.py new file mode 100644 index 0000000..7fb5eea --- /dev/null +++ b/mthd/domain/query.py @@ -0,0 +1,64 @@ +from typing import Sequence + +import jmespath + +from jmespath.parser import ParsedResult +from pydantic import BaseModel + +from mthd.domain.git import Commit + +# SimpleQueryOp = Literal[">", "<", ">=", "<=", "=="] +# SimpleQueryValue = str | int | float +SimpleQueryOp = str +SimpleQueryValue = str | int | float + + +class Query(BaseModel): + """A query to filter experiment commits.""" + + expression: str + + def compile(self) -> ParsedResult: + """Compile the JMESPath expression.""" + return jmespath.compile(self.expression) + + @staticmethod + def from_expression(expr: str) -> "Query": + """Create a query from a JMESPath expression.""" + return Query(expression=expr) + + @staticmethod + def where(field: str, op: SimpleQueryOp, value: SimpleQueryValue) -> "Query": + """Create a query that filters on a field value. + + $ mthd viz "accuracy from ['lr', 'batch_size']" + + $ mthd query "accuracy > 0.9" + + Example: + Query.where("accuracy", ">", 0.9) + """ + # Convert comparison operators to JMESPath + match op: + case ">": + expr = f"[?{field} > `{value}`]" + case "<": + expr = f"[?{field} < `{value}`]" + case ">=": + expr = f"[?{field} >= `{value}`]" + case "<=": + expr = f"[?{field} <= `{value}`]" + case "==": + expr = f"[?{field} == `{value}`]" + case _: + raise ValueError(f"Invalid operator: {op}") + + return Query(expression=expr) + + +class QueryResult(BaseModel): + """Result of executing a query.""" + + commits: Sequence[Commit] + query: Query + num_searched: int diff --git a/mthd/service/git.py b/mthd/service/git.py index 4d67100..2b2a1ba 100644 --- a/mthd/service/git.py +++ b/mthd/service/git.py @@ -1,23 +1,38 @@ +from typing import Optional + import git -from mthd.domain.commit import CommitMessage, StageStrategy +from mthd.domain.experiment import CommitKind +from mthd.domain.git import Commit, StageStrategy class GitService: def __init__(self, repo: git.Repo): self._repo = repo - def stage_and_commit(self, message: CommitMessage): + def get_all_commits(self, kind: Optional[CommitKind] = None) -> list[Commit]: + """Get all commits in the repository. + + Returns: + List of Commit objects representing the git history + """ + commits = [] + for commit in self._repo.iter_commits(): + commits.append(Commit.from_git(commit)) + + if kind: + pass # @todo: filter by kind + return commits + + def stage_and_commit(self, message: str): """Stage all changes and create a commit with the given message. Args: message: CommitMessage object containing commit metadata """ - # Stage all changes self._repo.git.add(A=True) - # Create commit with formatted message - self._repo.index.commit(message.format()) + self._repo.index.commit(message) def should_commit(self, strategy: StageStrategy) -> bool: """Determine if the repo state can be staged and committed diff --git a/mthd/service/query.py b/mthd/service/query.py new file mode 100644 index 0000000..ea1980e --- /dev/null +++ b/mthd/service/query.py @@ -0,0 +1,66 @@ +from typing import Optional + +from mthd.domain.git import ExperimentCommit +from mthd.domain.query import Query, QueryResult, SimpleQueryOp, SimpleQueryValue +from mthd.service.git import GitService + + +class QueryService: + """Service for querying experiment commits.""" + + def __init__(self, git_service: GitService): + self.git_service = git_service + + def execute(self, query: Query, limit: Optional[int] = None) -> QueryResult: + """Execute a query against the experiment commit history. + + Args: + query: The query to execute + limit: Optional maximum number of results to return + + Returns: + QueryResult containing matching commits + """ + commits = self.git_service.get_all_commits() + total = len(commits) + + # Convert regular commits to experiment commits + exp_commits = [ + {"commit": exp_commit, "run": exp_commit.experiment_run.model_dump()} + for commit in commits + if (exp_commit := ExperimentCommit.from_commit(commit)) + ] + + query_str = query.expression.replace("metrics.", "run.metrics.").replace( + "hyperparameters.", "run.hyperparameters." + ) + modified_query = Query(expression=query_str) + search = modified_query.compile().search(exp_commits) + matching: list[ExperimentCommit] = [match["commit"] for match in search] + + results = matching or [] + if limit and limit > 0: + results = results[:limit] + + return QueryResult(commits=results, query=query, num_searched=total) + + def execute_simple( + self, + metric: str, + op: SimpleQueryOp, + value: SimpleQueryValue, + limit: Optional[int] = None, + ) -> QueryResult: + """Convenience method to find experiments by metric value. + + Args: + metric: Name of the metric to filter on + op: Comparison operator (">", "<", ">=", "<=", "==") + value: Value to compare against + limit: Optional maximum number of results + + Returns: + QueryResult containing matching commits + """ + query = Query.where(metric, op, value) + return self.execute(query, limit=limit) diff --git a/mthd/util/di.py b/mthd/util/di.py index b1c82f3..86a2149 100644 --- a/mthd/util/di.py +++ b/mthd/util/di.py @@ -6,6 +6,7 @@ from mthd.service.codebase import CodebaseService from mthd.service.experiment import ExperimentService from mthd.service.git import GitService +from mthd.service.query import QueryService T = TypeVar("T") @@ -16,6 +17,7 @@ def provide_repo(self) -> Repo: try: return Repo() except Exception as e: + print(e) raise RuntimeError(f"Failed to initialize Git repository: {e}") @@ -40,6 +42,7 @@ def services(self) -> Provider: provider.provide(GitService) provider.provide(ExperimentService) provider.provide(CodebaseService) + provider.provide(QueryService) return provider diff --git a/pyproject.toml b/pyproject.toml index c3a2f32..e71767e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,10 @@ authors = [ requires-python = ">=3.10" dependencies = [ "anot>=0.0.6", + "click>=8.1.8", "dishka>=1.4.2", "gitpython>=3.1.44", + "jmespath>=1.0.1", "pydantic>=2.10.5", "rich>=13.9.4", ] @@ -37,6 +39,9 @@ Repository = "https://github.com/flywhl/mthd" Documentation = "https://github.com/flywhl/mthd#readme" "Bug Tracker" = "https://github.com/flywhl/mthd/issues" +[project.scripts] +mthd = "mthd.cli.app:start" + [build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" @@ -56,6 +61,7 @@ dev-dependencies = [ [tool.ruff] src = ["mthd"] +line-length = 120 [tool.ruff.lint] extend-select = ["I"] diff --git a/tests/unit/domain/commit_test.py b/tests/unit/domain/commit_test.py index 5e5b59f..e69de29 100644 --- a/tests/unit/domain/commit_test.py +++ b/tests/unit/domain/commit_test.py @@ -1,17 +0,0 @@ -from pydantic import BaseModel - -from mthd.domain.commit import CommitMessage - - -def test_commitmessage_format_success(): - class Hypers(BaseModel): - a: int - b: float - c: str - - msg = CommitMessage( - summary="test", - hyperparameters=Hypers(a=1, b=2.0, c="3").model_dump(), - ) - - print(msg.format()) diff --git a/tests/unit/service/query_test.py b/tests/unit/service/query_test.py new file mode 100644 index 0000000..6d5d598 --- /dev/null +++ b/tests/unit/service/query_test.py @@ -0,0 +1,82 @@ +from datetime import datetime +from unittest.mock import Mock, patch + +import pytest + +from mthd.domain.experiment import ExperimentRun +from mthd.domain.git import Commit +from mthd.domain.query import Query +from mthd.service.git import GitService +from mthd.service.query import QueryService + + +@pytest.fixture +def git_service(): + service = Mock(spec=GitService) + commits = [ + Commit( + sha="abc123", + message='exp: Test 1\n\n---\n\n{"experiment": "test1", "hyperparameters": {}, "metrics": {"accuracy": 0.8, "loss": 0.2}}', + date=datetime(2024, 1, 1), + ), + Commit( + sha="def456", + message='exp: Test 2\n\n---\n\n{"experiment": "test2", "hyperparameters": {}, "metrics": {"accuracy": 0.9, "loss": 0.1}}', + date=datetime(2024, 1, 2), + ), + ] + service.get_all_commits.return_value = commits + return service + + +@pytest.fixture +def query_service(git_service: GitService): + return QueryService(git_service) + + +# @patch("mthd.domain.experiment.ExperimentRun.from_commit") +def test_execute_query(query_service: QueryService): + # Setup mock experiments + # mock_from_commit.side_effect = [ + # ExperimentRun(experiment="test1", hyperparameters={}, metrics={"accuracy": 0.8, "loss": 0.2}), + # ExperimentRun(experiment="test2", hyperparameters={}, metrics={"accuracy": 0.9, "loss": 0.1}), + # ] + + # Test query for high accuracy + query = Query.where("metrics.accuracy", ">", 0.85) + result = query_service.execute(query) + + assert len(result.commits) == 1 + assert result.num_searched == 2 + assert result.query == query + + +@patch("mthd.domain.experiment.ExperimentRun.from_commit") +def test_execute_query_with_limit(mock_from_commit, query_service: QueryService): + # Setup mock experiments + mock_from_commit.side_effect = [ + ExperimentRun(experiment="test1", hyperparameters={}, metrics={"accuracy": 0.9}), + ExperimentRun(experiment="test2", hyperparameters={}, metrics={"accuracy": 0.95}), + ] + + # Test query with limit + query = Query.where("metrics.accuracy", ">=", 0.9) + result = query_service.execute(query, limit=1) + + assert len(result.commits) == 1 + assert result.num_searched == 2 + + +@patch("mthd.domain.experiment.ExperimentRun.from_commit") +def test_execute_simple(mock_from_commit, query_service: QueryService): + # Setup mock experiments + mock_from_commit.side_effect = [ + ExperimentRun(experiment="test1", hyperparameters={}, metrics={"accuracy": 0.9}), + ExperimentRun(experiment="test2", hyperparameters={}, metrics={"accuracy": 0.95}), + ] + + # Test query with limit + result = query_service.execute_simple("metrics.accuracy", "<", 0.95, limit=1) + + assert len(result.commits) == 1 + assert result.num_searched == 2 diff --git a/uv.lock b/uv.lock index cde7f66..6c2af94 100644 --- a/uv.lock +++ b/uv.lock @@ -100,6 +100,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, ] +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -249,6 +261,15 @@ 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 = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -295,12 +316,13 @@ wheels = [ [[package]] name = "mthd" -version = "0.0.3.dev4+g0bdb96d.d20250118" source = { editable = "." } dependencies = [ { name = "anot" }, + { name = "click" }, { name = "dishka" }, { name = "gitpython" }, + { name = "jmespath" }, { name = "pydantic" }, { name = "rich" }, ] @@ -318,8 +340,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "anot", specifier = ">=0.0.6" }, + { name = "click", specifier = ">=8.1.8" }, { name = "dishka", specifier = ">=1.4.2" }, { name = "gitpython", specifier = ">=3.1.44" }, + { name = "jmespath", specifier = ">=1.0.1" }, { name = "pydantic", specifier = ">=2.10.5" }, { name = "rich", specifier = ">=13.9.4" }, ]