Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test-unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@ jobs:
python -m pip install --upgrade pip
python -m pip install .

- name: Run unit tests to chech whether invalid meta is getting indexed or not
- name: Run unit tests (meta validation, cache operations, typo detection)
run: |
python -m unittest discover -s tests -p "test_*.py" -v
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ On February 9, 2025, MLCFlow released its first stable version, 1.0.0.
### Key Features
Building upon the core idea of CMind—wrapping native scripts with Python wrappers and YAML metadata—MLCFlow focuses exclusively on key automation components: **Scripts**, along with its complementary modules: **Cache**, **Docker**, and **Experiments**. This targeted design simplifies both implementation and interface, enabling a more user-friendly experience.

- **Typo detection** — Mistyped actions or targets produce a "Did you mean …?" hint before the error message, so mistakes are quick to correct:
```
$ mlc rune script
Did you mean 'run'?
mlc: error: argument command: invalid choice: 'rune' …
```
See [docs/typo_detection.md](docs/typo_detection.md) for details.

---

### Status
Expand Down
125 changes: 125 additions & 0 deletions docs/typo_detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Typo Detection ("Did you mean…?")

MLCFlow detects mistyped commands and targets and suggests the closest valid
alternative before showing the standard error.

## How it works

When you type an invalid action or target, the CLI now prints a suggestion
immediately after the usage line:

```
$ mlc rune script

usage: mlc [-h] {run,pull,test,add,...} ...

Did you mean 'run'?

mlc: error: argument command: invalid choice: 'rune' (choose from 'run', 'pull', ...)
```

```
$ mlc run scrip

usage: mlc run [-h] {repo,repos,script,cache} ...

Did you mean 'script'?

mlc: error: argument target: invalid choice: 'scrip' (choose from 'repo', 'repos', 'script', 'cache')
```

When several alternatives are similarly close:

```
$ mlc find rep

Did you mean one of: 'repo', 'repos'?
```

If the input has no close match (e.g. a completely unrelated word), no hint is
shown — only the standard argparse error.

## Typos detected

Suggestions are shown for **both** levels of the command syntax:

| Typo location | Example input | Suggestion shown |
|---|---|---|
| Action | `mlc rune script` | `Did you mean 'run'?` |
| Action | `mlc fnd script` | `Did you mean 'find'?` |
| Action | `mlc serach cache` | `Did you mean 'search'?` |
| Action | `mlc mrak-tmp cache` | `Did you mean 'mark-tmp'?` |
| Target | `mlc run scrip` | `Did you mean 'script'?` |
| Target | `mlc find cach` | `Did you mean 'cache'?` |
| Target | `mlc pull rpo` | `Did you mean 'repo'?` |

## Implementation

The feature is implemented in [`mlc/typo_mixin.py`](../mlc/typo_mixin.py) as a
Python mixin class — `TypoMixin` — that can be mixed into any
`argparse.ArgumentParser` subclass.

```python
class TypoMixin:
def suggest(self, word, candidates) -> list[str]: ...
def error(self, message) -> None: ...
```

`TypoMixin` overrides `ArgumentParser.error()` to:

1. Call `self.print_usage(sys.stderr)` (identical to the standard behaviour).
2. Parse the mistyped value and the valid-choices list from argparse's error
text using a regex.
3. Run `difflib.get_close_matches()` (Python stdlib, no extra dependencies)
with a similarity cutoff of `0.6`.
4. If one or more matches are found, write the hint line to stderr.
5. Call `self.exit(2, …)` with the original error message — identical to the
standard behaviour.

In `main.py`, a concrete `TypoArgumentParser` class is assembled:

```python
class TypoArgumentParser(TypoMixin, argparse.ArgumentParser):
pass
```

Both `build_pre_parser()` and `build_parser()` return `TypoArgumentParser`
instances. Subparsers created via `add_subparsers()` automatically inherit the
class (argparse uses `type(self)` as the default `parser_class`), so target
typos inside a valid command are also caught.

## Tuning

Two class-level attributes control the matching behaviour; override them on
`TypoArgumentParser` if needed:

| Attribute | Default | Meaning |
|---|---|---|
| `_TYPO_CUTOFF` | `0.6` | Minimum similarity ratio (0–1). Raise to require a closer match. |
| `_TYPO_MAX_SUGGESTIONS` | `3` | Maximum number of alternatives displayed. |

## Testing

Tests live in [`tests/test_typo_mixin.py`](../tests/test_typo_mixin.py) and are
split into two suites:

- **`TypoMixinSuggestTests`** — unit tests for `TypoMixin.suggest()` covering
single matches, multiple matches, edge cases (empty input, empty candidates,
exact match, transposed characters).

- **`TypoMixinCliTests`** — subprocess integration tests that invoke
`python -m mlc.main <typo> <target>` and assert that the correct hint
appears in stderr with exit code 2.

Run locally with:

```bash
pip install -e .
python -m unittest tests/test_typo_mixin.py -v
```

Or via the full test suite:

```bash
python -m unittest discover -s tests -p "test_*.py" -v
```
9 changes: 7 additions & 2 deletions mlc/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@
from .cache_action import CacheAction
from .cfg_action import CfgAction
from .experiment_action import ExperimentAction
from .typo_mixin import TypoMixin

from .item import Item
from .action_factory import get_action
from .logger import logger, logging


class TypoArgumentParser(TypoMixin, argparse.ArgumentParser):
"""ArgumentParser that suggests close matches when the user mistype a command or target."""


class Automation:
action_object = None
automation_type = None
Expand Down Expand Up @@ -330,7 +335,7 @@ def convert_hyphen_to_underscore_in_args():


def build_pre_parser():
pre_parser = argparse.ArgumentParser(add_help=False)
pre_parser = TypoArgumentParser(add_help=False)
pre_parser.add_argument(
"action",
nargs="?",
Expand All @@ -352,7 +357,7 @@ def build_pre_parser():


def build_parser(pre_args):
parser = argparse.ArgumentParser(
parser = TypoArgumentParser(
prog="mlc",
description="Manage repos, scripts, and caches.",
add_help=False)
Expand Down
111 changes: 111 additions & 0 deletions mlc/typo_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""TypoMixin: argparse error mixin that suggests near-matches on invalid choice errors.

When a user types an unrecognised action or target, argparse normally exits
with a bare "invalid choice" message. TypoMixin intercepts that error and
inserts a "Did you mean …?" hint before the standard error line, using
difflib.get_close_matches (stdlib, no extra dependencies).

Usage::

class TypoArgumentParser(TypoMixin, argparse.ArgumentParser):
pass

parser = TypoArgumentParser(prog="mlc", ...)
# Subparsers created from this parser inherit the class automatically
# because argparse uses type(self) as the default parser_class.
"""

import difflib
import re
import sys


class TypoMixin:
"""Mixin for argparse.ArgumentParser that adds 'Did you mean?' suggestions.

Drop this mixin to the left of argparse.ArgumentParser in your class MRO.
It overrides only error() — no other behaviour changes.

Attributes
----------
_TYPO_CUTOFF : float
Minimum SequenceMatcher similarity ratio (0–1) for a candidate to be
shown. Defaults to 0.6 (same as difflib.get_close_matches default).
_TYPO_MAX_SUGGESTIONS : int
Maximum number of alternatives displayed.
"""

_TYPO_CUTOFF: float = 0.6
_TYPO_MAX_SUGGESTIONS: int = 3

# Matches argparse's invalid-choice message in Python 3.8 – 3.14:
# "invalid choice: 'rune' (choose from 'run', 'pull', 'test')"
# The value is repr()'d so it's surrounded by single quotes.
_INVALID_CHOICE_RE = re.compile(
r"invalid choice: '?([^'()\s]+)'? \(choose from ([^)]+)\)"
)

# ------------------------------------------------------------------ #
# Public helpers (tested directly) #
# ------------------------------------------------------------------ #

def suggest(self, word: str, candidates) -> list:
"""Return the closest matches for *word* from *candidates*.

Thin wrapper around difflib.get_close_matches so callers and tests
can access the suggestion logic without triggering sys.exit.

Parameters
----------
word:
The mistyped string entered by the user.
candidates:
Iterable of valid strings to compare against.

Returns
-------
list[str]
Up to _TYPO_MAX_SUGGESTIONS matches ordered by similarity,
or an empty list when no match exceeds _TYPO_CUTOFF.
"""
return difflib.get_close_matches(
word,
candidates,
n=self._TYPO_MAX_SUGGESTIONS,
cutoff=self._TYPO_CUTOFF,
)

# ------------------------------------------------------------------ #
# argparse.ArgumentParser override #
# ------------------------------------------------------------------ #

def error(self, message: str) -> None:
"""Print usage, an optional 'Did you mean?' hint, then the standard error.

The output order mirrors standard argparse except for the hint line
injected between the usage block and the error message:

usage: mlc [-h] {run,pull,...} ...

Did you mean 'run'?

mlc: error: argument command: invalid choice: 'rune' (…)
"""
self.print_usage(sys.stderr)

m = self._INVALID_CHOICE_RE.search(message)
if m:
invalid = m.group(1)
# Choices are repr()'d in the error text: "'run', 'pull', ..."
raw_choices = [c.strip().strip("'\"") for c in m.group(2).split(",")]
suggestions = self.suggest(invalid, raw_choices)
if suggestions:
if len(suggestions) == 1:
hint = f"Did you mean '{suggestions[0]}'?"
else:
quoted = ", ".join(f"'{s}'" for s in suggestions)
hint = f"Did you mean one of: {quoted}?"
sys.stderr.write(f"\n{hint}\n\n")

args = {"prog": self.prog, "message": message}
self.exit(2, "%(prog)s: error: %(message)s\n" % args)
Loading
Loading