From 75d623a12469271576e9cd1975502389447f0a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?c=C4=83t=C4=83lin?= Date: Sun, 28 Aug 2022 12:12:31 +0200 Subject: [PATCH] feat: move `edit_note` function to a separate file --- halig/config.py | 5 +-- halig/edit.py | 29 ++++++++++++++++ halig/exceptions.py | 5 +++ halig/main.py | 13 +++---- halig/utils.py | 34 ++++++++++++++++++ poetry.lock | 22 ++++++------ pyproject.toml | 1 + tests/file_fixtures.py | 21 +++++++++++ tests/test_config.py | 24 +++---------- tests/test_edit.py | 79 ++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 191 insertions(+), 42 deletions(-) create mode 100644 halig/edit.py create mode 100644 halig/utils.py create mode 100644 tests/file_fixtures.py create mode 100644 tests/test_edit.py diff --git a/halig/config.py b/halig/config.py index 18cbaba..218596d 100644 --- a/halig/config.py +++ b/halig/config.py @@ -46,12 +46,13 @@ class Config(BaseSettings): return {k: str(v) for k, v in values.items()} -def get_config(config_path: Path | None = DEFAULT_CONFIGURATION_PATH) -> Config: +def get_config(config_path: Path = DEFAULT_CONFIGURATION_PATH) -> Config: """Init and retrieve a config object. If the `config_path` argument is not provided, the default config initialization will be provided Args: - config_path (Path|None): The path to the config + config_path (Path): The path to the config, defaults to + DEFAULT_CONFIGURATION_PATH Returns: a Config object Raises: diff --git a/halig/edit.py b/halig/edit.py new file mode 100644 index 0000000..3bb0194 --- /dev/null +++ b/halig/edit.py @@ -0,0 +1,29 @@ +from pathlib import Path +from tempfile import NamedTemporaryFile + +from sh import age +from halig.config import Config +from halig.utils import wait_for_editor + + +def edit_note(note_path: Path, config: Config): + """Edit an existing note. This method assumes that `note_path` exists, is + a fully-formed, absolute path an age file + + Args: + note_path (Path): The path to the note to edit + config (Config): config object + """ + + note_contents = age("-d", "-i", config.encryption_keys.private_key_path, note_path) + + with NamedTemporaryFile(delete=False) as tmpfile: + tmpfile.write(str(note_contents).encode()) + wait_for_editor(tmpfile.name) + age( + "-R", + str(config.encryption_keys.public_key_path), + tmpfile.name, + _out=str(note_path), + ) + Path(tmpfile.name).unlink() diff --git a/halig/exceptions.py b/halig/exceptions.py index 3a545bf..6725ddd 100644 --- a/halig/exceptions.py +++ b/halig/exceptions.py @@ -45,6 +45,11 @@ class NoteDoesNotExist(HaligError): error_code = 5 +class CouldNotEditTempfile(HaligError): + msg = "Could not edit temporary file; original file was not replaced" + error_code = 6 + + def handle_errors(func): # TODO: parse age/sh errors @wraps(func) diff --git a/halig/main.py b/halig/main.py index 139c669..0bcea75 100755 --- a/halig/main.py +++ b/halig/main.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 from typer import Typer from pathlib import Path -from typing import Optional from tempfile import NamedTemporaryFile from datetime import datetime from sh import age @@ -16,10 +15,10 @@ import os import yaml from halig.exceptions import handle_errors from halig import logger +from halig.utils import wait_for_editor app = Typer(pretty_exceptions_enable=False) - TODAY = f"{datetime.now():%Y-%m-%d}" TODAY_NOTE = f"{TODAY}.norg.age" DEFAULT_NEORG_METADATA = f"""@document.meta @@ -36,10 +35,6 @@ version: 1.0.0 """ -def _wait_for_editor(path): - subprocess.call([os.environ.get("EDITOR") or "vim", path]) - - def _edit_note(path: Path, config: Config): path = path.expanduser() if not path.exists(): @@ -50,7 +45,7 @@ def _edit_note(path: Path, config: Config): note_contents = age("-d", "-i", config.encryption_keys.private_key_path, path) with NamedTemporaryFile(delete=False) as tmpfile: tmpfile.write(str(note_contents).encode()) - _wait_for_editor(tmpfile.name) + wait_for_editor(tmpfile.name) logger.info(f"encrypting updates into {path}") age("-R", str(config.encryption_keys.public_key_path), tmpfile.name, _out=str(path)) Path(tmpfile.name).unlink() @@ -103,7 +98,7 @@ contents with the default one""" def notebooks( print_files: bool = False, print_hidden: bool = False, - configuration_path: Optional[Path] = None, + configuration_path: Path = DEFAULT_CONFIGURATION_PATH, ): """Print notebooks and their contents, tree-style""" config = get_config(configuration_path) @@ -114,7 +109,7 @@ def notebooks( @app.command() @handle_errors -def edit(path: Path, configuration_path: Optional[Path] = None): +def edit(path: Path, configuration_path: Path = DEFAULT_CONFIGURATION_PATH): """Edit a new or existing file by providing a path. Note that if only a dir is provided, an attempt to create or open `/.norg.age` will be made""" diff --git a/halig/utils.py b/halig/utils.py new file mode 100644 index 0000000..8d94050 --- /dev/null +++ b/halig/utils.py @@ -0,0 +1,34 @@ +import os +import subprocess +from pathlib import Path + +from halig import logger +from halig.exceptions import CouldNotEditTempfile + + +def resolve_path(path: Path) -> Path: + """Resolve a path's relative attributes, like envvars, relative notations, etc + + Args: + path(Path): The path to resolve + Returns: + Path: The resolved path + """ + path = Path("~/.config/halig/halig.yml").expanduser() + + return path + + +def wait_for_editor(path): + """Wait for $EDITOR to be opened and then manually closed by the user + + Args: + path(Path): The filepath which $EDITOR should modify + Raises: + CouldNotEditTempfile if the subprocess call errors out + """ + try: + subprocess.call([os.environ.get("EDITOR", "vim"), path]) + except subprocess.CalledProcessError as e: + logger.error(e.output) + raise CouldNotEditTempfile diff --git a/poetry.lock b/poetry.lock index 82edf3d..a44d4ff 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,7 +2,7 @@ name = "atomicwrites" version = "1.4.1" description = "Atomic file writes." -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -10,7 +10,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" name = "attrs" version = "22.1.0" description = "Classes Without Boilerplate" -category = "dev" +category = "main" optional = false python-versions = ">=3.5" @@ -100,7 +100,7 @@ dev = ["pytest", "coverage", "coveralls"] name = "iniconfig" version = "1.1.1" description = "iniconfig: brain-dead simple config-ini parsing" -category = "dev" +category = "main" optional = false python-versions = "*" @@ -128,7 +128,7 @@ icu = ["PyICU (>=1.0.0)"] name = "packaging" version = "21.3" description = "Core utilities for Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -159,7 +159,7 @@ test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytes name = "pluggy" version = "1.0.0" description = "plugin and hook calling mechanisms for python" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -171,7 +171,7 @@ testing = ["pytest", "pytest-benchmark"] name = "py" version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" @@ -205,7 +205,7 @@ plugins = ["importlib-metadata"] name = "pyparsing" version = "3.0.9" description = "pyparsing module - Classes and methods to define and execute parsing grammars" -category = "dev" +category = "main" optional = false python-versions = ">=3.6.8" @@ -216,7 +216,7 @@ diagrams = ["railroad-diagrams", "jinja2"] name = "pytest" version = "7.1.2" description = "pytest: simple powerful testing with Python" -category = "dev" +category = "main" optional = false python-versions = ">=3.7" @@ -252,7 +252,7 @@ testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtuale name = "pytest-mock" version = "3.8.2" description = "Thin-wrapper around the mock package for easier use with pytest" -category = "dev" +category = "main" optional = false python-versions = ">=3.7" @@ -309,7 +309,7 @@ python-versions = "*" name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" +category = "main" optional = false python-versions = ">=3.7" @@ -341,7 +341,7 @@ python-versions = ">=3.7" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "8f7eb9a03471694fe9f4950887b2857e65800302965f5add814793b5f6a4e946" +content-hash = "9723100b02ae7c884db7d0ae2607d20c1e8321a7a39d93028060fd8022305370" [metadata.files] atomicwrites = [] diff --git a/pyproject.toml b/pyproject.toml index 9f3ac63..83ff31d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ sh = "^1.14.3" pydantic = "^1.9.1" PyYAML = "^6.0" seedir = "^0.3.1" +pytest-mock = "^3.8.2" [tool.poetry.group.linters.dependencies] diff --git a/tests/file_fixtures.py b/tests/file_fixtures.py new file mode 100644 index 0000000..e362937 --- /dev/null +++ b/tests/file_fixtures.py @@ -0,0 +1,21 @@ +import shutil +import tempfile +from pathlib import Path +from tempfile import NamedTemporaryFile + +import pytest + + +@pytest.fixture() +def tmpfile(): + _tmpfile = NamedTemporaryFile(delete=False) + with open(_tmpfile.name, "w") as file: + yield file + Path(_tmpfile.name).unlink() + + +@pytest.fixture() +def tmpdir(): + tmpdir = Path(tempfile.mkdtemp()) + yield tmpdir + shutil.rmtree(tmpdir) diff --git a/tests/test_config.py b/tests/test_config.py index 1727056..cff26d9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,3 @@ -import shutil -import tempfile from pathlib import Path from tempfile import NamedTemporaryFile @@ -8,21 +6,7 @@ import yaml from halig.config import get_config, Config, EncryptionKeysConfig from halig.exceptions import ConfigFileDoesNotExist, ConfigFileIsInvalid - - -@pytest.fixture() -def tmpfile(): - _tmpfile = NamedTemporaryFile(delete=False) - with open(_tmpfile.name, "w") as file: - yield file - Path(_tmpfile.name).unlink() - - -@pytest.fixture() -def tmpdir(): - tmpdir = Path(tempfile.mkdtemp()) - yield tmpdir - shutil.rmtree(tmpdir) +from tests.file_fixtures import tmpfile, tmpdir # noqa: 401 def test_get_config_raises_config_file_does_not_exist(): @@ -36,19 +20,19 @@ def test_get_config_with_empty_file_raises_invalid_config_file(): get_config(Path(f.name)) -def test_get_config_raises_invalid_config_file_00(tmpfile): +def test_get_config_raises_invalid_config_file_00(tmpfile): # noqa: F811 tmpfile.write("foobar") with pytest.raises(ConfigFileIsInvalid): get_config(Path(tmpfile.name)) -def test_get_config_raises_invalid_config_file_01(tmpfile): +def test_get_config_raises_invalid_config_file_01(tmpfile): # noqa: F811 yaml.dump({"foo": "bar"}, tmpfile, Dumper=yaml.SafeDumper) with pytest.raises(ConfigFileIsInvalid): get_config(Path(tmpfile.name)) -def test_get_config(tmpdir): +def test_get_config(tmpdir): # noqa: F811 notes_root_path = Path(tmpdir / "notes") notes_root_path.mkdir(exist_ok=True) age_binary_path = Path(tmpdir / "age") diff --git a/tests/test_edit.py b/tests/test_edit.py new file mode 100644 index 0000000..454e464 --- /dev/null +++ b/tests/test_edit.py @@ -0,0 +1,79 @@ +from pathlib import Path +from subprocess import CalledProcessError + +import pytest + +from halig.config import Config, EncryptionKeysConfig +from halig.edit import edit_note +from halig.exceptions import CouldNotEditTempfile +from tests.file_fixtures import tmpfile, tempfile # noqa: F401 + +from sh import age, ssh_keygen, yes + + +@pytest.fixture() +def notes_path(tmpdir): # noqa: F811 + notes_path = Path(tmpdir.dirname) / "notes" + notes_path.mkdir(exist_ok=True) + + yield notes_path + + +@pytest.fixture() +def config(notes_path): # noqa: F811 + keys_path = Path(notes_path.parent / ".ssh") + keys_path.mkdir(exist_ok=True) + private_key_path = keys_path / "key" + public_key_path = keys_path / "key.pub" + + ssh_keygen(yes(_piped=True), t="ed25519", f=str(private_key_path)) + + config = Config( + notes_root_path=notes_path, + encryption_keys=EncryptionKeysConfig( + public_key_path=public_key_path, + private_key_path=private_key_path, + ), + ) + + note_path = notes_path / "note" + note_path.touch(exist_ok=True) + with open(note_path, "w") as f: + f.write("this is a test") + + age( + "-R", + str(config.encryption_keys.public_key_path), + str(note_path), + _out=f"{note_path}.age", + ) + + yield config + + +def mock_subprocess(args: list): + with open(args[1], "w") as f: + f.write("mocked") + + +def test_edit_note(config: Config, mocker): # noqa: F811 + mocker.patch("subprocess.call", side_effect=mock_subprocess) + edit_note(config.notes_root_path / "note.age", config) + assert ( + age( + "-d", + "-i", + config.encryption_keys.private_key_path, + config.notes_root_path / "note.age", + ) + == "mocked" + ) + + +def test_edit_not_raises_could_not_edit_tempfile(config: Config, mocker): # noqa: F811 + mocker.patch("subprocess.call", side_effect=CalledProcessError( + returncode=1, + cmd="foo", + output="mocked error")) + with pytest.raises(CouldNotEditTempfile): + edit_note(config.notes_root_path / "note.age", config)