Skip to content

Commit a0d6a95

Browse files
Merge pull request #103 from MohammadKanaan/refactor/builtin-plugin-handling
Refactor/builtin plugin handling
2 parents 33003ef + 5b0ed7d commit a0d6a95

19 files changed

Lines changed: 448 additions & 122 deletions

File tree

.github/workflows/ci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,14 @@ jobs:
2828
- name: Lint
2929
run: uv run ruff check
3030

31+
- name: Typecheck
32+
run: uv run mypy nomnom
33+
3134
- name: Test
3235
run: uv run pytest
36+
37+
- name: Build distributions
38+
run: uv build
39+
40+
- name: Check distributions
41+
run: uvx twine check dist/*

.gitignore

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ env/
3939

4040
# Testing
4141
.pytest_cache/
42+
.mypy_cache/
4243
.coverage
4344
htmlcov/
4445

@@ -47,13 +48,13 @@ htmlcov/
4748

4849
# plugins
4950
plugins/*
50-
!plugins/nomnom-plugin-rules/
5151
rules.toml
5252

5353
# config
5454
config.toml
5555

5656
# ai tools
5757
.jules/
58+
.jj/
5859
.codex/
59-
.conductor/
60+
.conductor/

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
- **Entry point:** `nomnom/cli.py` (Typer app)
88
- **Package:** `nomnom/`
99
- **Tests:** `tests/`
10-
- **Built-in plugin:** `plugins/nomnom-plugin-rules/`
10+
- **Built-in plugin:** `nomnom/builtin/rules.py`
1111

1212
## Tech Stack
1313

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Use the built-in scaffold to create a local plugin package:
3030
uv run nomnom plugin create my-plugin
3131
```
3232

33-
Local plugins live under `plugins/`. The repository currently includes `nomnom-plugin-rules` as a reference implementation.
33+
Local plugins live under `plugins/`. The built-in `rules` plugin is implemented in `nomnom.builtin.rules`.
3434

3535
## Pull Requests
3636

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pip install nomnom-py
2121
### Initialize
2222

2323
Generates a default `config.toml` in your project root.
24+
If you use the built-in `rules` plugin, keep `rules.toml` in that same directory so `nomnom` can read both files together.
2425

2526
```bash
2627
nomnom setup
@@ -39,19 +40,20 @@ Use `nomnom watch --once <group>` to process a single watch group one time, or `
3940
## Example Configuration
4041

4142
`nomnom` reads `config.toml` from the current project by default, or a custom path passed with `--config`.
43+
When the built-in `rules` plugin is enabled, `nomnom` also reads `rules.toml` from that same directory.
4244

4345
```toml
4446
[[watch]]
4547
name = "inbox"
4648
paths = ["./fixtures/inbox"]
47-
extensions = [".pdf", ".txt"]
49+
include = ["*.pdf", "*.txt"]
4850

4951
[[watch]]
5052
name = "archive"
5153
paths = ["./fixtures/archive"]
5254

5355
[[plugins]]
54-
name = "nomnom-plugin-rules"
56+
name = "rules"
5557
priority = 10
5658
enabled = true
5759
```
@@ -63,7 +65,10 @@ For a fuller starter file, see [`config.example.toml`](config.example.toml).
6365
### The Built-in Rules Plugin
6466

6567
A rules plugin for declarative file automation driven by `rules.toml`.
66-
See [`plugins/nomnom-plugin-rules/README.md`](plugins/nomnom-plugin-rules/README.md) for the rule format and supported actions.
68+
It ships with the main `nomnom-py` install and is configured as `rules`.
69+
Keep `config.toml` and `rules.toml` side by side. If you run `nomnom --config /path/to/config.toml`, the built-in plugin will read `/path/to/rules.toml` automatically.
70+
See [`RULES.md`](RULES.md) for the rule format and supported actions, and
71+
[`rules.example.toml`](rules.example.toml) for a starter example.
6772

6873
### Installing Community Plugins
6974

@@ -97,6 +102,7 @@ If you'd like to publish your plugin, move it to its own repo and publish it to
97102
uv sync --dev
98103
uv run pytest
99104
uv run ruff check
105+
uv run mypy nomnom
100106
```
101107

102108
Contributor guidance lives in [`CONTRIBUTING.md`](CONTRIBUTING.md).
@@ -106,7 +112,7 @@ Contributor guidance lives in [`CONTRIBUTING.md`](CONTRIBUTING.md).
106112

107113
```text
108114
Core Commands:
109-
nomnom watch [GROUP] [OPTS] Start watching (Opts: -c/--config, -v/--verbose, --dry-run, --once)
115+
nomnom watch [WATCH_GROUP] [OPTS] Start watching (Opts: -c/--config, -V/--verbose, --dry-run, --once)
110116
nomnom setup [OPTS] Create or update the config file
111117
nomnom --version / -v Print installed version
112118
Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
# nomnom-plugin-rules
1+
# Rules Plugin
22

3-
This is a built-in plugin for simple workflows.
3+
`nomnom` ships with a built-in `rules` plugin for simple file automation.
44

5-
If you are an AI, follow the rules below attentively to implement what the user asked you to implement.
6-
7-
Define file-event rules in `rules.toml`.
5+
Define file-event rules in `rules.toml` next to your active `config.toml`.
6+
`nomnom` scans those two files together: by default it reads both from the current project directory, and if you pass `--config /path/to/config.toml` it will read `/path/to/rules.toml`.
87

98
## Rule Format
109

@@ -52,3 +51,9 @@ content = "..." # required for prepend/append
5251
- Missing `rules.toml`: plugin starts with no rules and logs an info message.
5352
- Invalid TOML syntax: plugin starts with no rules and logs a warning.
5453
- Invalid rules handling: Invalid rules are skipped and a warning is logged; other rules still load.
54+
55+
## File Placement
56+
57+
- Keep `config.toml` and `rules.toml` in the same directory.
58+
- Relative paths inside `config.toml` are resolved from the config file's directory.
59+
- The built-in `rules` plugin reads the colocated `rules.toml` from that same directory.

config.example.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
# Keep this file next to rules.toml when using the built-in rules plugin.
2+
# nomnom reads both files from the same directory.
13
plugins = [
2-
{ name = "nomnom-plugin-rules", priority = 10 },
4+
{ name = "rules", priority = 10 },
35
]
46

57
[[watch]]

nomnom/builtin/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from nomnom.builtin.rules import RulesPlugin
2+
3+
__all__ = ["RulesPlugin"]

plugins/nomnom-plugin-rules/nomnom_plugin_rules/__init__.py renamed to nomnom/builtin/rules/__init__.py

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,6 @@
1111

1212
logger = logging.getLogger(__name__)
1313

14-
PLUGIN_ROOT = Path(__file__).resolve().parent.parent
15-
RULES_PATH = PLUGIN_ROOT / "rules.toml"
16-
1714
VALID_EVENTS = {"created", "modified", "deleted"}
1815
VALID_ACTIONS = {"prepend", "append", "delete", "move"}
1916

@@ -37,11 +34,11 @@ def matches(self, event_type_value: str, watch_group: str, path_name: str) -> bo
3734

3835

3936
class RulesPlugin:
40-
def __init__(self) -> None:
37+
def __init__(self, rules_path: Path | None = None) -> None:
38+
self._rules_path = rules_path or Path("rules.toml")
4139
self._rules = self._load_rules()
4240

4341
def matches(self, event: FileEvent) -> bool:
44-
# Cache properties to avoid redundant attribute access overhead in the loop
4542
event_type_value = event.event_type.value
4643
watch_group = event.watch_group
4744
path_name = event.path.name
@@ -50,7 +47,6 @@ def matches(self, event: FileEvent) -> bool:
5047
def handle(self, event: FileEvent) -> list[Effect]:
5148
effects: list[Effect] = []
5249

53-
# Cache properties to avoid redundant attribute access overhead in the loop
5450
event_type_value = event.event_type.value
5551
watch_group = event.watch_group
5652
path_name = event.path.name
@@ -91,14 +87,14 @@ def handle(self, event: FileEvent) -> list[Effect]:
9187
return effects
9288

9389
def _load_rules(self) -> list[Rule]:
94-
if not RULES_PATH.is_file():
95-
logger.info("Rules file not found at %s", RULES_PATH)
90+
if not self._rules_path.is_file():
91+
logger.info("Rules file not found at %s", self._rules_path)
9692
return []
9793

9894
try:
99-
raw = tomllib.loads(RULES_PATH.read_text())
95+
raw = tomllib.loads(self._rules_path.read_text())
10096
except Exception as exc:
101-
logger.warning("Failed to parse rules file at %s: %s", RULES_PATH, exc)
97+
logger.warning("Failed to parse rules file at %s: %s", self._rules_path, exc)
10298
return []
10399

104100
items = raw.get("rule")
@@ -117,7 +113,7 @@ def _load_rules(self) -> list[Rule]:
117113
logger.info(
118114
"Loaded %d rule(s) from %s. Restart nomnom to pick up changes.",
119115
len(rules),
120-
RULES_PATH,
116+
self._rules_path,
121117
)
122118
return rules
123119

@@ -147,9 +143,7 @@ def _parse_rule(candidate: object) -> Rule | None:
147143

148144
if action in {"prepend", "append"} and not isinstance(content, str):
149145
return None
150-
if action == "move" and (
151-
not isinstance(destination, str) or not destination.strip()
152-
):
146+
if action == "move" and (not isinstance(destination, str) or not destination.strip()):
153147
return None
154148

155149
try:

nomnom/commands/plugin.py

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from pathlib import Path
2+
from subprocess import CompletedProcess
23

34
import typer
45
from rich.console import Console
@@ -105,27 +106,53 @@ def _remove_plugin_from_config(config_path: Path, plugin_name: str) -> bool:
105106
return False
106107

107108

109+
def _run_package_command(*, action: str, package: str) -> CompletedProcess[str]:
110+
import subprocess
111+
import sys
112+
113+
if action == "install":
114+
commands = [
115+
["uv", "pip", "install", "--python", sys.executable, "--", package],
116+
[sys.executable, "-m", "pip", "install", package],
117+
]
118+
elif action == "uninstall":
119+
commands = [
120+
["uv", "pip", "uninstall", "--python", sys.executable, package],
121+
[sys.executable, "-m", "pip", "uninstall", "-y", package],
122+
]
123+
else:
124+
raise ValueError(f"Unsupported package action: {action}")
125+
126+
last_error: OSError | None = None
127+
for command in commands:
128+
try:
129+
return subprocess.run(
130+
command,
131+
capture_output=True,
132+
text=True,
133+
)
134+
except OSError as e:
135+
last_error = e
136+
137+
if last_error is not None:
138+
raise last_error
139+
140+
raise RuntimeError("No package command was attempted")
141+
142+
108143
def plugin_add_command(
109144
*,
110145
package: str,
111146
no_setup: bool,
112147
config_path: Path,
113148
) -> None:
114-
import subprocess
115-
import sys
116-
117149
typer.echo(f"Installing {package}...")
118150
installed_before = get_installed_plugin_names()
119-
install_cmd = ["uv", "pip", "install", "--python", sys.executable, "--", package]
120151

121152
try:
122-
result = subprocess.run(
123-
install_cmd,
124-
capture_output=True,
125-
text=True,
126-
)
153+
result = _run_package_command(action="install", package=package)
127154
except OSError as e:
128-
typer.echo(f"Installation failed: could not execute '{install_cmd[0]}': {e}")
155+
typer.echo(f"Installation failed: could not execute a package installer: {e}")
129156
raise typer.Exit(1) from e
130157

131158
if result.returncode != 0:
@@ -165,21 +192,13 @@ def plugin_remove_command(
165192
package: str,
166193
config_path: Path,
167194
) -> None:
168-
import subprocess
169-
import sys
170-
171195
typer.echo(f"Removing {package}...")
172196
installed_before = get_installed_plugin_names()
173-
uninstall_cmd = ["uv", "pip", "uninstall", "--python", sys.executable, package]
174197

175198
try:
176-
result = subprocess.run(
177-
uninstall_cmd,
178-
capture_output=True,
179-
text=True,
180-
)
199+
result = _run_package_command(action="uninstall", package=package)
181200
except OSError as e:
182-
typer.echo(f"Removal failed: could not execute '{uninstall_cmd[0]}': {e}")
201+
typer.echo(f"Removal failed: could not execute a package installer: {e}")
183202
raise typer.Exit(1) from e
184203

185204
if result.returncode != 0:
@@ -248,7 +267,7 @@ def plugin_list_command(
248267
config_path: Path,
249268
console: Console,
250269
) -> None:
251-
discovered = discover_plugins()
270+
discovered = discover_plugins(rules_path=config_path.parent.resolve() / "rules.toml")
252271
discovered_names = {name for name, _ in discovered}
253272

254273
config_plugins = {}

0 commit comments

Comments
 (0)