Merge pull request 'Passwords template redesigned' (#12) from passwd into develop

Reviewed-on: https://hattori.ztsh.eu/iac/karl/pulls/12
This commit is contained in:
Piotr Dec 2025-11-26 00:07:20 +01:00
commit f1f1772c9a
5 changed files with 83 additions and 41 deletions

View file

@ -7,13 +7,9 @@ from injectable import injectable, autowired, Autowired
from app.services import Passwords from app.services import Passwords
class ComplexValueTemplate(Template): class ValueTemplate(Template):
# Pozwala na kropki w nazwach placeholderów, np. ${user.name.first} # Pozwala na kropki i ukośniki w nazwach placeholderów, np. ${user.name/first}
idpattern = r'[_a-zA-Z][_a-zA-Z0-9.]*' idpattern = r'[_a-zA-Z][_a-zA-Z0-9.\/]*'
class SimpleValueTemplate(ComplexValueTemplate):
delimiter = '%'
@injectable @injectable
@ -26,12 +22,9 @@ class Mo:
raw = '' raw = ''
with open(mo_file, "r") as mo: with open(mo_file, "r") as mo:
raw = mo.read() raw = mo.read()
cmp = ComplexValueTemplate(raw) parsed = ValueTemplate(raw)
rendered = cmp.substitute(self._passwords.get_values(cmp.get_identifiers())) mappings = self._passwords.get_values(parsed.get_identifiers())
smp = SimpleValueTemplate(rendered) rendered = parsed.safe_substitute(mappings)
ids = [_id + '.password' for _id in smp.get_identifiers()]
mappings = {k.replace('.password', ''): v for k, v in self._passwords.get_values(ids).items()}
rendered = smp.substitute(mappings)
de_mo_ified = str(mo_file).replace(".mo", "") de_mo_ified = str(mo_file).replace(".mo", "")
with open(de_mo_ified, "w") as mo: with open(de_mo_ified, "w") as mo:
mo.write(rendered) mo.write(rendered)

View file

@ -6,6 +6,30 @@ import keyring
from injectable import injectable from injectable import injectable
from pykeepass import PyKeePass, create_database from pykeepass import PyKeePass, create_database
class KeyRequest:
def __init__(self, prompt: str):
self.field_name = None
self.entry_name = None
self.path = None
self._parse_prompt(prompt)
def _parse_prompt(self, prompt: str):
prompt_parts = prompt.split("/")
key = None
match len(prompt_parts):
case 1:
self.field_name = 'password'
key = prompt_parts[0]
case 2:
self.field_name = prompt_parts[1]
key = prompt_parts[0]
case _:
key = None
if key is None:
return
key_parts = key.split(".")
self.path = key_parts[:] if len(key_parts) > 1 else None
self.entry_name = key_parts[-1]
@injectable(singleton=True) @injectable(singleton=True)
class Passwords: class Passwords:
@ -28,13 +52,10 @@ class Passwords:
def get_values(self, keys: list[str]) -> dict[str, str]: def get_values(self, keys: list[str]) -> dict[str, str]:
output = {} output = {}
for k in keys: for k in keys:
key_parts = k.split(".") request = KeyRequest(k)
path = key_parts[:-1] if len(key_parts) > 2 else None
entry_name = key_parts[-2]
field_name = key_parts[-1]
with self.open() as kp: with self.open() as kp:
kp_entry = kp.find_entries(path=path, first=True, title=entry_name) kp_entry = kp.find_entries(path=request.path, first=True, title=request.entry_name)
output[k] = self._get_field_value(kp_entry, field_name) output[k] = self._get_field_value(kp_entry, request.field_name)
return output return output
@staticmethod @staticmethod

View file

@ -20,11 +20,11 @@ dependencies = [
"keyring>=25.6.0", "keyring>=25.6.0",
] ]
[project.optional-dependencies] [dependency-groups]
dev = [ dev = [
"httpx>=0.27.0", "httpx>=0.27.0",
"pytest>=8.3.0", "pytest==9.0.1",
"pytest-asyncio>=0.23.0", "pytest-asyncio>=1.3.0",
"ruff>=0.6.0", "ruff>=0.6.0",
"mypy>=1.11.0", "mypy>=1.11.0",
"types-Jinja2>=2.11.9", "types-Jinja2>=2.11.9",

View file

@ -1,3 +1,5 @@
value: %{sample} value: ${sample}
nested: %{some.nested.value} nested: ${some.nested.value}
custom: ${custom.field} custom: ${custom/field}
uname: ${sample/username}
invalid: ${double/slash/example}

View file

@ -1,24 +1,50 @@
import os
from pathlib import Path from pathlib import Path
from unittest import TestCase
import pytest
import yaml import yaml
from app.services import Passwords from app.services import Passwords
from app.services.mo import Mo from app.services.mo import Mo
class TestMo(TestCase): @pytest.fixture(scope='class')
def test_process(self): def target_path():
target_path = Path('tests/files/test1/test.yaml') p = Path('tests/files/test1/test.yaml')
mo = Mo(Passwords()) # posprzątaj przed testem, gdyby plik istniał z poprzednich uruchomień
mo.process(Path('tests/files/test1/test.mo.yaml').absolute()) if p.exists():
self.assertTrue(os.path.exists(target_path)) p.unlink()
with open(target_path, 'r') as f: yield p
content = f.read() # sprzątanie po teście
self.assertFalse(content.__contains__('${')) if p.exists():
self.assertFalse(content.__contains__('%{')) p.unlink()
parsed = yaml.load(content, Loader=yaml.FullLoader)
self.assertEqual('some_pass', parsed['value'])
self.assertEqual('nested_pass', parsed['nested']) @pytest.fixture(scope='class')
self.assertEqual('custom_content', parsed['custom']) def test1_content(target_path: Path):
mo = Mo(Passwords())
mo.process(Path('tests/files/test1/test.mo.yaml').absolute())
assert target_path.exists()
content = target_path.read_text()
assert '${' not in content
yield yaml.load(content, Loader=yaml.FullLoader)
class TestParsing:
def test_simple(self, test1_content: dict):
assert test1_content['value'] == 'some_pass'
def test_nested(self, test1_content: dict):
assert test1_content['nested'] == 'nested_pass'
def test_custom_field(self, test1_content: dict):
assert test1_content['custom'] == 'custom_content'
def test_username_field(self, test1_content: dict):
assert test1_content['uname'] == 'sample_username'
def test_invalid_key(self, test1_content: dict):
assert test1_content.get('invalid') == 'None'