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
+
+
+ 
+
+ [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" },
]