Compare commits
3 commits
9b2dc37279
...
13c34b08e2
| Author | SHA1 | Date | |
|---|---|---|---|
| 13c34b08e2 | |||
| ff0e6cccfb | |||
| 6517eb3971 |
6 changed files with 249 additions and 97 deletions
27
test/conftest.py
Normal file
27
test/conftest.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from topen import OPTIONS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_id(monkeypatch):
|
||||||
|
monkeypatch.setattr("sys.argv", ["topen", "0"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def isolate_env(monkeypatch):
|
||||||
|
# delete all existing env vars that could interfere
|
||||||
|
monkeypatch.delenv("EDITOR", raising=False)
|
||||||
|
monkeypatch.delenv("VISUAL", raising=False)
|
||||||
|
for opt in OPTIONS.values():
|
||||||
|
if opt.env:
|
||||||
|
monkeypatch.delenv(opt.env, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_rc(tmp_path: Path, monkeypatch):
|
||||||
|
rc = tmp_path / "test.taskrc"
|
||||||
|
monkeypatch.setattr(OPTIONS["task_rc"], "default", rc)
|
||||||
|
return rc
|
||||||
|
|
@ -1,94 +1,11 @@
|
||||||
|
# pyright: reportUnusedImport=false, reportUnusedParameter=false
|
||||||
|
# ruff: noqa: F401, F811
|
||||||
|
# ^ Turn off for implicit pytest fixture import
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from topen import OPTIONS, TConf, parse_cli, parse_env, parse_rc
|
from topen import TConf, build_config
|
||||||
|
|
||||||
|
|
||||||
class TestCli:
|
|
||||||
def test_cli_minimum_id(self, monkeypatch):
|
|
||||||
monkeypatch.setattr("sys.argv", ["topen", "42"])
|
|
||||||
assert parse_cli() == {"task_id": "42"}
|
|
||||||
|
|
||||||
def test_cli_options(self, monkeypatch):
|
|
||||||
monkeypatch.setattr(
|
|
||||||
"sys.argv",
|
|
||||||
[
|
|
||||||
"topen",
|
|
||||||
"123",
|
|
||||||
"--extension",
|
|
||||||
"txt",
|
|
||||||
"--editor",
|
|
||||||
"vim",
|
|
||||||
"--quiet",
|
|
||||||
"True",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert parse_cli() == {
|
|
||||||
"task_id": "123",
|
|
||||||
"notes_ext": "txt",
|
|
||||||
"notes_editor": "vim",
|
|
||||||
"notes_quiet": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def isolate_env(monkeypatch):
|
|
||||||
# delete all existing env vars that could interfere
|
|
||||||
monkeypatch.delenv("EDITOR", raising=False)
|
|
||||||
monkeypatch.delenv("VISUAL", raising=False)
|
|
||||||
for opt in OPTIONS.values():
|
|
||||||
if opt.env:
|
|
||||||
monkeypatch.delenv(opt.env, raising=False)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEnv:
|
|
||||||
def test_env_notes_ext(self, isolate_env, monkeypatch):
|
|
||||||
monkeypatch.setenv("TOPEN_NOTES_DIR", "/blablubb")
|
|
||||||
monkeypatch.setenv("TOPEN_NOTES_EXT", "rst")
|
|
||||||
monkeypatch.setenv("TOPEN_NOTES_ANNOT", "qmd")
|
|
||||||
monkeypatch.setenv("TOPEN_NOTES_EDITOR", "vim")
|
|
||||||
monkeypatch.setenv("TOPEN_NOTES_QUIET", "true")
|
|
||||||
env = parse_env()
|
|
||||||
assert env["notes_dir"] == Path("/blablubb")
|
|
||||||
assert env["notes_ext"] == "rst"
|
|
||||||
assert env["notes_annot"] == "qmd"
|
|
||||||
assert env["notes_editor"] == "vim"
|
|
||||||
assert env["notes_quiet"] is True
|
|
||||||
|
|
||||||
def test_env_task_rc(self, isolate_env, monkeypatch):
|
|
||||||
monkeypatch.setenv("TASKRC", "/a/dir/that/dont/exist/file")
|
|
||||||
monkeypatch.setenv("TASKDATA", "~/somewhere/tasks")
|
|
||||||
env = parse_env()
|
|
||||||
assert env["task_rc"] == Path("/a/dir/that/dont/exist/file")
|
|
||||||
assert env["task_data"] == Path("~/somewhere/tasks")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def fake_rc(tmp_path: Path, monkeypatch):
|
|
||||||
rc = tmp_path / "test.taskrc"
|
|
||||||
monkeypatch.setattr(OPTIONS["task_rc"], "default", rc)
|
|
||||||
return rc
|
|
||||||
|
|
||||||
|
|
||||||
class TestRcFile:
|
|
||||||
def test_taskrc_parsing(self, fake_rc):
|
|
||||||
fake_rc.write_text("""
|
|
||||||
data.location=~/.taskies
|
|
||||||
notes.dir=/there
|
|
||||||
notes.ext=yaml
|
|
||||||
notes.annot=Boo!
|
|
||||||
notes.editor=micro
|
|
||||||
notes.quiet=true
|
|
||||||
""")
|
|
||||||
rc_cfg = parse_rc(fake_rc)
|
|
||||||
assert rc_cfg["task_data"] == Path("~/.taskies")
|
|
||||||
assert rc_cfg["notes_dir"] == Path("/there")
|
|
||||||
assert rc_cfg["notes_ext"] == "yaml"
|
|
||||||
assert rc_cfg["notes_annot"] == "Boo!"
|
|
||||||
assert rc_cfg["notes_editor"] == "micro"
|
|
||||||
assert rc_cfg["notes_quiet"] is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestTConf:
|
class TestTConf:
|
||||||
def test_paths_are_expanded(self):
|
def test_paths_are_expanded(self):
|
||||||
|
|
@ -116,7 +33,68 @@ class TestTConf:
|
||||||
({"VISUAL": "nvim", "EDITOR": "notepad"}, "notepad"),
|
({"VISUAL": "nvim", "EDITOR": "notepad"}, "notepad"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_editor_env_resolution(isolate_env, monkeypatch, env, expected):
|
def test_editor_env_resolution(self, isolate_env, monkeypatch, env, expected):
|
||||||
for k, v in env.items():
|
for k, v in env.items():
|
||||||
monkeypatch.setenv(k, v)
|
monkeypatch.setenv(k, v)
|
||||||
assert TConf(0).notes_editor == expected
|
assert TConf(0).notes_editor == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildConfigPrecedence:
|
||||||
|
"""
|
||||||
|
All tests exercise the same key (notes_ext) to keep the assertions short.
|
||||||
|
Each source sets a different value so we can be sure the right one wins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_defaults_only(self, fake_rc, monkeypatch, isolate_env, fake_id):
|
||||||
|
cfg = build_config()
|
||||||
|
assert cfg.notes_ext == "md"
|
||||||
|
|
||||||
|
def test_taskrc_overrides_defaults(
|
||||||
|
self, fake_rc, monkeypatch, isolate_env, fake_id
|
||||||
|
):
|
||||||
|
fake_rc.write_text("notes.ext=from-rc\n")
|
||||||
|
cfg = build_config()
|
||||||
|
assert cfg.notes_ext == "from-rc"
|
||||||
|
|
||||||
|
def test_env_overrides_taskrc(self, fake_rc, monkeypatch, isolate_env, fake_id):
|
||||||
|
fake_rc.write_text("notes.ext=from-rc\n")
|
||||||
|
monkeypatch.setenv("TOPEN_NOTES_EXT", "from-env")
|
||||||
|
cfg = build_config()
|
||||||
|
assert cfg.notes_ext == "from-env"
|
||||||
|
|
||||||
|
def test_cli_overrides_env(self, fake_rc, monkeypatch, isolate_env):
|
||||||
|
fake_rc.write_text("notes.ext=from-rc\n")
|
||||||
|
monkeypatch.setenv("TOPEN_NOTES_EXT", "from-env")
|
||||||
|
monkeypatch.setattr("sys.argv", ["topen", "0", "--extension", "from-cli"])
|
||||||
|
cfg = build_config()
|
||||||
|
assert cfg.notes_ext == "from-cli"
|
||||||
|
|
||||||
|
def test_cli_overrides_everything(self, fake_rc, monkeypatch, isolate_env):
|
||||||
|
fake_rc.write_text("notes.ext=from-rc\nnotes.dir=/rc-dir\nnotes.editor=joe")
|
||||||
|
monkeypatch.setenv("TOPEN_NOTES_EXT", "from-env")
|
||||||
|
monkeypatch.setenv("TOPEN_NOTES_DIR", "/env-dir")
|
||||||
|
monkeypatch.setenv("EDITOR", "emacs")
|
||||||
|
# CLI wins
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sys.argv",
|
||||||
|
[
|
||||||
|
"topen",
|
||||||
|
"0",
|
||||||
|
"--extension",
|
||||||
|
"cli-ext",
|
||||||
|
"--notes-dir",
|
||||||
|
"cli-dir",
|
||||||
|
"--editor",
|
||||||
|
"helix",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
cfg = build_config()
|
||||||
|
assert cfg.notes_ext == "cli-ext"
|
||||||
|
assert cfg.notes_dir == Path("cli-dir")
|
||||||
|
assert cfg.notes_editor == "helix"
|
||||||
|
|
||||||
|
# sanity check that the task-id coming from CLI is preserved
|
||||||
|
def test_cli_supplies_task_id(self, fake_rc, monkeypatch, isolate_env):
|
||||||
|
monkeypatch.setattr("sys.argv", ["topen", "42"])
|
||||||
|
cfg = build_config()
|
||||||
|
assert cfg.task_id == "42"
|
||||||
|
|
|
||||||
48
test/test_parse_cli.py
Normal file
48
test/test_parse_cli.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from topen import parse_cli
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
def test_cli_minimum_id(self, monkeypatch):
|
||||||
|
monkeypatch.setattr("sys.argv", ["topen", "42"])
|
||||||
|
assert parse_cli() == {"task_id": "42"}
|
||||||
|
|
||||||
|
def test_cli_options(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sys.argv",
|
||||||
|
[
|
||||||
|
"topen",
|
||||||
|
"123",
|
||||||
|
"--extension",
|
||||||
|
"txt",
|
||||||
|
"--editor",
|
||||||
|
"vim",
|
||||||
|
"--annotation",
|
||||||
|
"HERENOTE",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert parse_cli() == {
|
||||||
|
"task_id": "123",
|
||||||
|
"notes_ext": "txt",
|
||||||
|
"notes_editor": "vim",
|
||||||
|
"notes_annot": "HERENOTE",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_cli_notes_quiet_is_flag(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sys.argv",
|
||||||
|
[
|
||||||
|
"topen",
|
||||||
|
"123",
|
||||||
|
"--quiet",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert parse_cli()["notes_quiet"] is True
|
||||||
|
|
||||||
|
def test_cli_parses_paths(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sys.argv",
|
||||||
|
["topen", "123", "--notes-dir", "/somewhere/else"],
|
||||||
|
)
|
||||||
|
assert parse_cli()["notes_dir"] == Path("/somewhere/else")
|
||||||
35
test/test_parse_env.py
Normal file
35
test/test_parse_env.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from topen import parse_env
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnv:
|
||||||
|
def test_env_notes_ext(self, isolate_env, monkeypatch):
|
||||||
|
monkeypatch.setenv("TOPEN_NOTES_DIR", "/blablubb")
|
||||||
|
monkeypatch.setenv("TOPEN_NOTES_EXT", "rst")
|
||||||
|
monkeypatch.setenv("TOPEN_NOTES_ANNOT", "qmd")
|
||||||
|
monkeypatch.setenv("TOPEN_NOTES_EDITOR", "vim")
|
||||||
|
monkeypatch.setenv("TOPEN_NOTES_QUIET", "true")
|
||||||
|
env = parse_env()
|
||||||
|
assert env["notes_dir"] == Path("/blablubb")
|
||||||
|
assert env["notes_ext"] == "rst"
|
||||||
|
assert env["notes_annot"] == "qmd"
|
||||||
|
assert env["notes_editor"] == "vim"
|
||||||
|
assert env["notes_quiet"] is True
|
||||||
|
|
||||||
|
def test_env_task_rc(self, isolate_env, monkeypatch):
|
||||||
|
monkeypatch.setenv("TASKRC", "/a/dir/that/dont/exist/file")
|
||||||
|
monkeypatch.setenv("TASKDATA", "~/somewhere/tasks")
|
||||||
|
env = parse_env()
|
||||||
|
assert env["task_rc"] == Path("/a/dir/that/dont/exist/file")
|
||||||
|
assert env["task_data"] == Path("~/somewhere/tasks")
|
||||||
|
|
||||||
|
def test_env_parses_boolean_true(self, isolate_env, monkeypatch):
|
||||||
|
monkeypatch.setenv("TOPEN_NOTES_QUIET", "true")
|
||||||
|
env = parse_env()
|
||||||
|
assert env["notes_quiet"] is True
|
||||||
|
|
||||||
|
def test_env_parses_boolean_false(self, isolate_env, monkeypatch):
|
||||||
|
monkeypatch.setenv("TOPEN_NOTES_QUIET", "false")
|
||||||
|
env = parse_env()
|
||||||
|
assert env["notes_quiet"] is False
|
||||||
36
test/test_parse_rc.py
Normal file
36
test/test_parse_rc.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from topen import parse_rc
|
||||||
|
|
||||||
|
|
||||||
|
class TestRcFile:
|
||||||
|
def test_taskrc_parsing(self, fake_rc):
|
||||||
|
fake_rc.write_text("""
|
||||||
|
data.location=~/.taskies
|
||||||
|
notes.dir=/there
|
||||||
|
notes.ext=yaml
|
||||||
|
notes.annot=Boo!
|
||||||
|
notes.editor=micro
|
||||||
|
notes.quiet=true
|
||||||
|
""")
|
||||||
|
rc_cfg = parse_rc(fake_rc)
|
||||||
|
assert rc_cfg["task_data"] == Path("~/.taskies")
|
||||||
|
assert rc_cfg["notes_dir"] == Path("/there")
|
||||||
|
assert rc_cfg["notes_ext"] == "yaml"
|
||||||
|
assert rc_cfg["notes_annot"] == "Boo!"
|
||||||
|
assert rc_cfg["notes_editor"] == "micro"
|
||||||
|
assert rc_cfg["notes_quiet"] is True
|
||||||
|
|
||||||
|
def test_taskrc_parses_boolean_true(self, fake_rc):
|
||||||
|
fake_rc.write_text("""
|
||||||
|
notes.quiet=true
|
||||||
|
""")
|
||||||
|
rc_cfg = parse_rc(fake_rc)
|
||||||
|
assert rc_cfg["notes_quiet"] is True
|
||||||
|
|
||||||
|
def test_taskrc_parses_boolean_false(self, fake_rc):
|
||||||
|
fake_rc.write_text("""
|
||||||
|
notes.quiet=false
|
||||||
|
""")
|
||||||
|
rc_cfg = parse_rc(fake_rc)
|
||||||
|
assert rc_cfg["notes_quiet"] is False
|
||||||
46
topen.py
46
topen.py
|
|
@ -23,7 +23,7 @@ import subprocess
|
||||||
import sys
|
import sys
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Self, cast
|
from typing import Any, Callable, Self, cast
|
||||||
|
|
||||||
from tasklib import Task, TaskWarrior
|
from tasklib import Task, TaskWarrior
|
||||||
|
|
||||||
|
|
@ -130,8 +130,9 @@ class Opt:
|
||||||
rc: str | None
|
rc: str | None
|
||||||
default: Any = None
|
default: Any = None
|
||||||
metavar: str | None = None
|
metavar: str | None = None
|
||||||
cast: type = str
|
cast: type | Callable = str
|
||||||
help_text: str = ""
|
help_text: str = ""
|
||||||
|
is_flag: bool = False
|
||||||
|
|
||||||
|
|
||||||
def _real_path(p: Path | str) -> Path:
|
def _real_path(p: Path | str) -> Path:
|
||||||
|
|
@ -146,6 +147,24 @@ def _determine_default_task_rc() -> Path:
|
||||||
return _real_path("~/.config/task/taskrc")
|
return _real_path("~/.config/task/taskrc")
|
||||||
|
|
||||||
|
|
||||||
|
def _strtobool(val: str) -> bool:
|
||||||
|
"""Convert a string representation of truth.
|
||||||
|
|
||||||
|
Coverts either to True or False, raising an error if it does not find a
|
||||||
|
valid value.
|
||||||
|
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values are 'n',
|
||||||
|
'no', 'f', 'false', 'off', and '0'.
|
||||||
|
Raises ValueError if 'val' is anything else.
|
||||||
|
"""
|
||||||
|
val = val.lower()
|
||||||
|
if val in ("y", "yes", "t", "true", "on", "1"):
|
||||||
|
return True
|
||||||
|
elif val in ("n", "no", "f", "false", "off", "0"):
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid boolean value {val}")
|
||||||
|
|
||||||
|
|
||||||
OPTIONS: dict[str, Opt] = {
|
OPTIONS: dict[str, Opt] = {
|
||||||
"task_id": Opt(None, None, None, default=None),
|
"task_id": Opt(None, None, None, default=None),
|
||||||
"task_rc": Opt(
|
"task_rc": Opt(
|
||||||
|
|
@ -204,8 +223,9 @@ OPTIONS: dict[str, Opt] = {
|
||||||
"TOPEN_NOTES_QUIET",
|
"TOPEN_NOTES_QUIET",
|
||||||
"notes.quiet",
|
"notes.quiet",
|
||||||
default=False,
|
default=False,
|
||||||
cast=bool,
|
cast=_strtobool,
|
||||||
help_text="Silence any verbose displayed information",
|
help_text="Silence any verbosely displayed information",
|
||||||
|
is_flag=True,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,17 +287,16 @@ class TConf:
|
||||||
|
|
||||||
def build_config() -> TConf:
|
def build_config() -> TConf:
|
||||||
"""Return final configuration object."""
|
"""Return final configuration object."""
|
||||||
cli = parse_cli()
|
defaults = {k: opt.default for k, opt in OPTIONS.items()}
|
||||||
env = parse_env()
|
env = parse_env()
|
||||||
|
cli = parse_cli()
|
||||||
|
|
||||||
rc_path = Path(
|
rc_path = Path(
|
||||||
cli.get("task_rc") or env.get("task_rc") or OPTIONS["task_rc"].default
|
cli.get("task_rc") or env.get("task_rc") or OPTIONS["task_rc"].default
|
||||||
).expanduser()
|
).expanduser()
|
||||||
|
defaults["task_rc"] = rc_path # use XDG-included paths
|
||||||
rc = parse_rc(rc_path) if rc_path.exists() else {}
|
rc = parse_rc(rc_path) if rc_path.exists() else {}
|
||||||
|
|
||||||
# we use the 'parsed' XDG-included taskrc locations for defaults
|
|
||||||
defaults = {k: opt.default for k, opt in OPTIONS.items()}
|
|
||||||
defaults["task_rc"] = rc_path
|
|
||||||
|
|
||||||
merged = defaults | rc | env | cli # later wins
|
merged = defaults | rc | env | cli # later wins
|
||||||
return TConf.from_dict({k: v for k, v in merged.items() if v is not None})
|
return TConf.from_dict({k: v for k, v in merged.items() if v is not None})
|
||||||
|
|
||||||
|
|
@ -303,6 +322,15 @@ you view the task.
|
||||||
for key, opt in OPTIONS.items():
|
for key, opt in OPTIONS.items():
|
||||||
if opt.cli is None:
|
if opt.cli is None:
|
||||||
continue
|
continue
|
||||||
|
if opt.is_flag:
|
||||||
|
parser.add_argument(
|
||||||
|
*opt.cli,
|
||||||
|
dest=key,
|
||||||
|
help=opt.help_text,
|
||||||
|
default=None,
|
||||||
|
action="store_true",
|
||||||
|
)
|
||||||
|
continue
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
*opt.cli,
|
*opt.cli,
|
||||||
dest=key,
|
dest=key,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue