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
-
+[](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)