From 3d053d532add3d311b5cc4918b0fad6656545abe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?c=C4=83t=C4=83lin?= Date: Sun, 28 Aug 2022 15:35:37 +0200 Subject: [PATCH] feat: move `new_note` function to a separate file --- .drone.yml | 3 +- README.md | 4 +- halig/config.py | 21 +++++-- halig/{edit.py => edit_note.py} | 2 +- halig/exceptions.py | 5 ++ halig/main.py | 108 ++++++++------------------------ halig/new_note.py | 42 +++++++++++++ halig/utils.py | 22 ++++++- poetry.lock | 17 ++++- pyproject.toml | 1 + tests/test_edit.py | 24 +++---- tests/test_new_note.py | 95 ++++++++++++++++++++++++++++ 12 files changed, 239 insertions(+), 105 deletions(-) rename halig/{edit.py => edit_note.py} (90%) create mode 100644 halig/new_note.py create mode 100644 tests/test_new_note.py diff --git a/.drone.yml b/.drone.yml index a7cda8e..4a06147 100644 --- a/.drone.yml +++ b/.drone.yml @@ -1,7 +1,7 @@ --- kind: pipeline type: docker -name: build +name: build and test trigger: ref: @@ -52,6 +52,7 @@ steps: pull: true image: git.roboces.dev/catalin/poetry:beta commands: + - apk add --no-cache age openssh - .venv/bin/pytest depends_on: - install_deps diff --git a/README.md b/README.md index 88dfb9e..7cf57d0 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # halig - +[![Build Status](https://qa.roboces.dev/api/badges/catalin/halig/status.svg?ref=refs/heads/main)](https://qa.roboces.dev/catalin/halig) ```shell $ halig init # create and write $HOME/.config/halig/halig.yml $ halig edit some_notebook # edit today's note -$ halig edit some_notebook/foo.age # edit /path/to/some_notebook/foo.age +$ halig edit some_notebook/foo # edit /path/to/some_notebook/foo.age $ halig notebooks # list current notebooks ``` diff --git a/halig/config.py b/halig/config.py index 218596d..8eb4aa9 100644 --- a/halig/config.py +++ b/halig/config.py @@ -1,15 +1,17 @@ +import os +from datetime import datetime from pathlib import Path + +import yaml from pydantic import ( BaseSettings, FilePath, DirectoryPath, validator, Field, - ValidationError, ) -from halig import exceptions -import yaml +from halig import exceptions from halig.exceptions import ConfigFileIsInvalid DEFAULT_CONFIGURATION_PATH = Path("~/.config/halig/halig.yml").expanduser() @@ -32,6 +34,17 @@ class Config(BaseSettings): notes_root_path: DirectoryPath = Field(default="~/Documents/notes") encryption_keys: EncryptionKeysConfig = Field(default_factory=EncryptionKeysConfig) age_binary_path: FilePath = Field(default="/usr/bin/age") + _default_template_data: str = f"""@document.meta +title: {datetime.now():%Y-%m-%d} +description: {datetime.now():%Y-%m-%d} +authors: {os.getenv("USER")} +categories: [ + +] +created: {datetime.now():%Y-%m-%d} +version: 1.0.0 +@end +""" @validator("notes_root_path", "age_binary_path", pre=True) def expanduser_paths(cls, v) -> Path: @@ -72,5 +85,5 @@ def get_config(config_path: Path = DEFAULT_CONFIGURATION_PATH) -> Config: raise ConfigFileIsInvalid try: return Config(**yml) - except (TypeError, ValidationError): + except TypeError: raise ConfigFileIsInvalid diff --git a/halig/edit.py b/halig/edit_note.py similarity index 90% rename from halig/edit.py rename to halig/edit_note.py index 3bb0194..e96d944 100644 --- a/halig/edit.py +++ b/halig/edit_note.py @@ -8,7 +8,7 @@ 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 + a fully-formed, absolute path and an age file inside the configured notebooks Args: note_path (Path): The path to the note to edit diff --git a/halig/exceptions.py b/halig/exceptions.py index 6725ddd..075ad24 100644 --- a/halig/exceptions.py +++ b/halig/exceptions.py @@ -50,6 +50,11 @@ class CouldNotEditTempfile(HaligError): error_code = 6 +class InvalidNotePath(HaligError): + msg = "Note path is invalid" + error_code = 7 + + def handle_errors(func): # TODO: parse age/sh errors @wraps(func) diff --git a/halig/main.py b/halig/main.py index 0bcea75..c446e26 100755 --- a/halig/main.py +++ b/halig/main.py @@ -1,82 +1,28 @@ #!/usr/bin/env python3 -from typer import Typer -from pathlib import Path -from tempfile import NamedTemporaryFile from datetime import datetime -from sh import age +from pathlib import Path + +import yaml +from typer import Typer + +from halig import logger from halig.config import ( get_config, DEFAULT_CONFIGURATION_PATH, Config, ) -from halig.exceptions import NoteDoesNotExist -import subprocess -import os -import yaml +from halig.edit_note import edit_note +from halig.exceptions import InvalidNotePath from halig.exceptions import handle_errors -from halig import logger -from halig.utils import wait_for_editor +from halig.new_note import new_note 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 -title: {TODAY} -description: {TODAY} -authors: {os.getenv("USER")} -categories: [ - -] -created: {TODAY} -version: 1.0.0 -@end - -""" - - -def _edit_note(path: Path, config: Config): - path = path.expanduser() - if not path.exists(): - raise NoteDoesNotExist - if path.is_dir(): - path = path / f"{TODAY}.norg.age" - - 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) - 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() - - -def _get_template_data(path: Path) -> str | None: - template_path = path / "template.norg" - if not (template_path.exists() and template_path.is_file()): - return None - - with open(template_path) as f: - return f.read() - - -def _new_note(path: Path, config: Config): - with NamedTemporaryFile(delete=False) as tempfile: - tempfile.write((_get_template_data(path) or DEFAULT_NEORG_METADATA).encode()) - subprocess.call([os.environ.get("EDITOR") or "vim", tempfile.name]) - file = Path(path / f"{TODAY_NOTE}") - file.touch(exist_ok=True) - print(f"age-encrypting {tempfile.name}") - age( - "-R", str(config.encryption_keys.public_key_path), tempfile.name, _out=str(file) - ) - Path(tempfile.name).unlink() - @app.command() def init(force_recreate: bool = False): - """Create config file. If the config file already exists, it'll not - be overwritten unless `--force-recreate` flag is provided + """Create the config file. If the config file already exists, it'll not + be overwritten unless the `--force-recreate` flag is provided """ if DEFAULT_CONFIGURATION_PATH.exists() and not force_recreate: logger.error( @@ -110,25 +56,25 @@ def notebooks( @app.command() @handle_errors 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""" + """Edit a new or existing note by providing a relative path. The relative path + will be appended to the notes root path that is specified in the configuration file. + Note that if only a dir is provided, an attempt to create or open + `/.age` will be made. + """ + config = get_config(configuration_path) - path = Path(path) + if path.is_absolute(): + raise InvalidNotePath - if not path.is_absolute(): - path = config.notes_root_path / path + full_path = (config.notes_root_path / path).expanduser().resolve() - if not path.exists(): - logger.error(f"{path} does not exist; available notebooks:\n") - logger.tree(config.notes_root_path) - exit(1) - if path.is_file() or any( - filter(lambda f: f.name == f"{TODAY_NOTE}", path.iterdir()) # type: ignore - ): - _edit_note(path, config) - return - _new_note(path, config) + if full_path.is_dir(): + return new_note(full_path / f"{datetime.now():%Y-%m-%d}.age", config) + + if full_path.exists(): + return edit_note(full_path, config) + + new_note(full_path, config) app() diff --git a/halig/new_note.py b/halig/new_note.py new file mode 100644 index 0000000..072d630 --- /dev/null +++ b/halig/new_note.py @@ -0,0 +1,42 @@ +from pathlib import Path +from tempfile import NamedTemporaryFile + +from sh import age + +from halig.config import Config +from halig.utils import get_template_data, wait_for_editor + + +def new_note(note_path: Path, config: Config): + """Create a new note from a given path. This function assumes that `note_path` + **does not exist** but is a fully-formed, absolute path and an age file inside any + of the configured notebooks + + + 1. the file is touched + 2. a temporary file is created and the template data is dumped into + 3. the program waits for $EDITOR to open, edit and finish the tempfile + 4. the temp file contents are encrypted and dumped into the age file + + Args: + note_path (Path): The path to the note + config (Config): The config object + """ + + note_path.touch(exist_ok=True) + template_data: str = ( + get_template_data(note_path.parent) or config._default_template_data + ) + + with NamedTemporaryFile(delete=False) as tempfile: + tempfile.write(template_data.encode()) + + wait_for_editor(tempfile.name) + + age( + "-R", + str(config.encryption_keys.public_key_path), + tempfile.name, + _out=str(note_path), + ) + Path(tempfile.name).unlink() diff --git a/halig/utils.py b/halig/utils.py index 8d94050..217bb9c 100644 --- a/halig/utils.py +++ b/halig/utils.py @@ -19,16 +19,32 @@ def resolve_path(path: Path) -> Path: return path -def wait_for_editor(path): +def get_template_data(path: Path) -> str | None: + """Read template data from path/template.halig + + Args: + path(Path): The path to read template data from, usually a notebook + Returns: + maybe returns a string containing the template data + """ + template_path = path / "template.halig" + if not (template_path.exists() and template_path.is_file()): + return None + + with open(template_path) as f: + return f.read() + + +def wait_for_editor(filepath: str): """Wait for $EDITOR to be opened and then manually closed by the user Args: - path(Path): The filepath which $EDITOR should modify + filepath(Path): The filepath which $EDITOR should modify Raises: CouldNotEditTempfile if the subprocess call errors out """ try: - subprocess.call([os.environ.get("EDITOR", "vim"), path]) + subprocess.call([os.environ.get("EDITOR", "vim"), filepath]) except subprocess.CalledProcessError as e: logger.error(e.output) raise CouldNotEditTempfile diff --git a/poetry.lock b/poetry.lock index a44d4ff..9421a09 100644 --- a/poetry.lock +++ b/poetry.lock @@ -270,6 +270,17 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "returns" +version = "0.19.0" +description = "Make your functions return something meaningful, typed, and safe!" +category = "main" +optional = false +python-versions = ">=3.7,<4.0" + +[package.dependencies] +typing-extensions = ">=4.0,<5.0" + [[package]] name = "rich" version = "12.5.1" @@ -341,7 +352,7 @@ python-versions = ">=3.7" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "9723100b02ae7c884db7d0ae2607d20c1e8321a7a39d93028060fd8022305370" +content-hash = "e582e85120d3be9711b98d1064ca409be111a0ff02547fb5e382ff7e49814f06" [metadata.files] atomicwrites = [] @@ -559,6 +570,10 @@ pyyaml = [ {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] +returns = [ + {file = "returns-0.19.0-py3-none-any.whl", hash = "sha256:ae3ce9e5165d1218905291b4d5881b4e8a86ca478437bef3b0af1de8df57ec69"}, + {file = "returns-0.19.0.tar.gz", hash = "sha256:4544bb67849c1ef1bbf7823759d433a773959e5b77a8fd06d01fef6d060f2ac5"}, +] rich = [] seedir = [ {file = "seedir-0.3.1-py3-none-any.whl", hash = "sha256:9361daf7f2355621600e0a349e9c1e3539aecc7cb24b309b93880fa35d3d5007"}, diff --git a/pyproject.toml b/pyproject.toml index 83ff31d..4ccd429 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ pydantic = "^1.9.1" PyYAML = "^6.0" seedir = "^0.3.1" pytest-mock = "^3.8.2" +returns = "^0.19.0" [tool.poetry.group.linters.dependencies] diff --git a/tests/test_edit.py b/tests/test_edit.py index 454e464..bb3de32 100644 --- a/tests/test_edit.py +++ b/tests/test_edit.py @@ -4,7 +4,7 @@ from subprocess import CalledProcessError import pytest from halig.config import Config, EncryptionKeysConfig -from halig.edit import edit_note +from halig.edit_note import edit_note from halig.exceptions import CouldNotEditTempfile from tests.file_fixtures import tmpfile, tempfile # noqa: F401 @@ -60,20 +60,20 @@ 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" + 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")) + 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) diff --git a/tests/test_new_note.py b/tests/test_new_note.py new file mode 100644 index 0000000..a57ba72 --- /dev/null +++ b/tests/test_new_note.py @@ -0,0 +1,95 @@ +from pathlib import Path +from subprocess import CalledProcessError + +import pytest + +from halig.config import Config, EncryptionKeysConfig +from halig.edit_note import edit_note +from halig.exceptions import CouldNotEditTempfile +from halig.new_note import new_note +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], "a") as f: + f.write(" mocked") + + +def test_new_note_default_template_data(config: Config, mocker): # noqa: F811 + mocker.patch("subprocess.call", side_effect=mock_subprocess) + new_note(config.notes_root_path / "new_default_note.age", config) + note_contents = age( + "-d", + "-i", + config.encryption_keys.private_key_path, + config.notes_root_path / "new_default_note.age", + ) + assert note_contents == f"{config._default_template_data} mocked" + + +def test_new_note_custom_template_data(config: Config, mocker): # noqa: F811 + mocker.patch("subprocess.call", side_effect=mock_subprocess) + template_path = config.notes_root_path / "template.halig" + template_path.touch(exist_ok=True) + with open(template_path, "w") as f: + f.write("template string") + new_note(config.notes_root_path / "new_note.age", config) + template_path.unlink() + note_contents = age( + "-d", + "-i", + config.encryption_keys.private_key_path, + config.notes_root_path / "new_note.age", + ) + assert note_contents == "template string mocked" + + +def test_new_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)