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 kind: pipeline
type: docker type: docker
name: build name: build and test
trigger: trigger:
ref: ref:
@ -52,6 +52,7 @@ steps:
pull: true pull: true
image: git.roboces.dev/catalin/poetry:beta image: git.roboces.dev/catalin/poetry:beta
commands: commands:
- apk add --no-cache age openssh
- .venv/bin/pytest - .venv/bin/pytest
depends_on: depends_on:
- install_deps - install_deps

View file

@ -1,10 +1,10 @@
# halig # halig
[![Build Status](https://qa.roboces.dev/api/badges/catalin/halig/status.svg?ref=refs/heads/main)](https://qa.roboces.dev/catalin/halig)
```shell ```shell
$ halig init # create and write $HOME/.config/halig/halig.yml $ halig init # create and write $HOME/.config/halig/halig.yml
$ halig edit some_notebook # edit today's note $ 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 $ halig notebooks # list current notebooks
``` ```

View file

@ -1,15 +1,17 @@
import os
from datetime import datetime
from pathlib import Path from pathlib import Path
import yaml
from pydantic import ( from pydantic import (
BaseSettings, BaseSettings,
FilePath, FilePath,
DirectoryPath, DirectoryPath,
validator, validator,
Field, Field,
ValidationError,
) )
from halig import exceptions
import yaml
from halig import exceptions
from halig.exceptions import ConfigFileIsInvalid from halig.exceptions import ConfigFileIsInvalid
DEFAULT_CONFIGURATION_PATH = Path("~/.config/halig/halig.yml").expanduser() DEFAULT_CONFIGURATION_PATH = Path("~/.config/halig/halig.yml").expanduser()
@ -32,6 +34,17 @@ class Config(BaseSettings):
notes_root_path: DirectoryPath = Field(default="~/Documents/notes") notes_root_path: DirectoryPath = Field(default="~/Documents/notes")
encryption_keys: EncryptionKeysConfig = Field(default_factory=EncryptionKeysConfig) encryption_keys: EncryptionKeysConfig = Field(default_factory=EncryptionKeysConfig)
age_binary_path: FilePath = Field(default="/usr/bin/age") 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) @validator("notes_root_path", "age_binary_path", pre=True)
def expanduser_paths(cls, v) -> Path: def expanduser_paths(cls, v) -> Path:
@ -72,5 +85,5 @@ def get_config(config_path: Path = DEFAULT_CONFIGURATION_PATH) -> Config:
raise ConfigFileIsInvalid raise ConfigFileIsInvalid
try: try:
return Config(**yml) return Config(**yml)
except (TypeError, ValidationError): except TypeError:
raise ConfigFileIsInvalid raise ConfigFileIsInvalid

View file

@ -8,7 +8,7 @@ from halig.utils import wait_for_editor
def edit_note(note_path: Path, config: Config): def edit_note(note_path: Path, config: Config):
"""Edit an existing note. This method assumes that `note_path` exists, is """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: Args:
note_path (Path): The path to the note to edit note_path (Path): The path to the note to edit

View file

@ -50,6 +50,11 @@ class CouldNotEditTempfile(HaligError):
error_code = 6 error_code = 6
class InvalidNotePath(HaligError):
msg = "Note path is invalid"
error_code = 7
def handle_errors(func): def handle_errors(func):
# TODO: parse age/sh errors # TODO: parse age/sh errors
@wraps(func) @wraps(func)

View file

@ -1,82 +1,28 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from typer import Typer
from pathlib import Path
from tempfile import NamedTemporaryFile
from datetime import datetime 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 ( from halig.config import (
get_config, get_config,
DEFAULT_CONFIGURATION_PATH, DEFAULT_CONFIGURATION_PATH,
Config, Config,
) )
from halig.exceptions import NoteDoesNotExist from halig.edit_note import edit_note
import subprocess from halig.exceptions import InvalidNotePath
import os
import yaml
from halig.exceptions import handle_errors from halig.exceptions import handle_errors
from halig import logger from halig.new_note import new_note
from halig.utils import wait_for_editor
app = Typer(pretty_exceptions_enable=False) 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() @app.command()
def init(force_recreate: bool = False): def init(force_recreate: bool = False):
"""Create config file. If the config file already exists, it'll not """Create the config file. If the config file already exists, it'll not
be overwritten unless `--force-recreate` flag is provided be overwritten unless the `--force-recreate` flag is provided
""" """
if DEFAULT_CONFIGURATION_PATH.exists() and not force_recreate: if DEFAULT_CONFIGURATION_PATH.exists() and not force_recreate:
logger.error( logger.error(
@ -110,25 +56,25 @@ def notebooks(
@app.command() @app.command()
@handle_errors @handle_errors
def edit(path: Path, configuration_path: Path = DEFAULT_CONFIGURATION_PATH): def edit(path: Path, configuration_path: Path = DEFAULT_CONFIGURATION_PATH):
"""Edit a new or existing file by providing a path. Note that if only """Edit a new or existing note by providing a relative path. The relative path
a dir is provided, an attempt to create or open `<dir>/<current date>.norg.age` will be appended to the notes root path that is specified in the configuration file.
will be made""" 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) config = get_config(configuration_path)
path = Path(path) if path.is_absolute():
raise InvalidNotePath
if not path.is_absolute(): full_path = (config.notes_root_path / path).expanduser().resolve()
path = config.notes_root_path / path
if not path.exists(): if full_path.is_dir():
logger.error(f"{path} does not exist; available notebooks:\n") return new_note(full_path / f"{datetime.now():%Y-%m-%d}.age", config)
logger.tree(config.notes_root_path)
exit(1) if full_path.exists():
if path.is_file() or any( return edit_note(full_path, config)
filter(lambda f: f.name == f"{TODAY_NOTE}", path.iterdir()) # type: ignore
): new_note(full_path, config)
_edit_note(path, config)
return
_new_note(path, config)
app() 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 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 """Wait for $EDITOR to be opened and then manually closed by the user
Args: Args:
path(Path): The filepath which $EDITOR should modify filepath(Path): The filepath which $EDITOR should modify
Raises: Raises:
CouldNotEditTempfile if the subprocess call errors out CouldNotEditTempfile if the subprocess call errors out
""" """
try: try:
subprocess.call([os.environ.get("EDITOR", "vim"), path]) subprocess.call([os.environ.get("EDITOR", "vim"), filepath])
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(e.output) logger.error(e.output)
raise CouldNotEditTempfile raise CouldNotEditTempfile

17
poetry.lock generated
View file

@ -270,6 +270,17 @@ category = "main"
optional = false optional = false
python-versions = ">=3.6" 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]] [[package]]
name = "rich" name = "rich"
version = "12.5.1" version = "12.5.1"
@ -341,7 +352,7 @@ python-versions = ">=3.7"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "9723100b02ae7c884db7d0ae2607d20c1e8321a7a39d93028060fd8022305370" content-hash = "e582e85120d3be9711b98d1064ca409be111a0ff02547fb5e382ff7e49814f06"
[metadata.files] [metadata.files]
atomicwrites = [] atomicwrites = []
@ -559,6 +570,10 @@ pyyaml = [
{file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
{file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, {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 = [] rich = []
seedir = [ seedir = [
{file = "seedir-0.3.1-py3-none-any.whl", hash = "sha256:9361daf7f2355621600e0a349e9c1e3539aecc7cb24b309b93880fa35d3d5007"}, {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" PyYAML = "^6.0"
seedir = "^0.3.1" seedir = "^0.3.1"
pytest-mock = "^3.8.2" pytest-mock = "^3.8.2"
returns = "^0.19.0"
[tool.poetry.group.linters.dependencies] [tool.poetry.group.linters.dependencies]

View file

@ -4,7 +4,7 @@ from subprocess import CalledProcessError
import pytest import pytest
from halig.config import Config, EncryptionKeysConfig 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 halig.exceptions import CouldNotEditTempfile
from tests.file_fixtures import tmpfile, tempfile # noqa: F401 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) mocker.patch("subprocess.call", side_effect=mock_subprocess)
edit_note(config.notes_root_path / "note.age", config) edit_note(config.notes_root_path / "note.age", config)
assert ( assert (
age( age(
"-d", "-d",
"-i", "-i",
config.encryption_keys.private_key_path, config.encryption_keys.private_key_path,
config.notes_root_path / "note.age", config.notes_root_path / "note.age",
) )
== "mocked" == "mocked"
) )
def test_edit_not_raises_could_not_edit_tempfile(config: Config, mocker): # noqa: F811 def test_edit_not_raises_could_not_edit_tempfile(config: Config, mocker): # noqa: F811
mocker.patch("subprocess.call", side_effect=CalledProcessError( mocker.patch(
returncode=1, "subprocess.call",
cmd="foo", side_effect=CalledProcessError(returncode=1, cmd="foo", output="mocked error"),
output="mocked error")) )
with pytest.raises(CouldNotEditTempfile): with pytest.raises(CouldNotEditTempfile):
edit_note(config.notes_root_path / "note.age", config) 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)