feat: move new_note function to a separate file
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
cătălin 2022-08-28 15:35:37 +02:00
commit 3d053d532a
Signed by: catalin
GPG key ID: 0178DF42F43E5FD2
12 changed files with 239 additions and 105 deletions

View file

@ -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

View file

@ -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
```

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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 `<dir>/<current date>.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
`<dir>/<current date>.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()

42
halig/new_note.py Normal file
View file

@ -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()

View file

@ -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

17
poetry.lock generated
View file

@ -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"},

View file

@ -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]

View file

@ -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)

95
tests/test_new_note.py Normal file
View file

@ -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)