From 02217ae977976a02a6938dd0884bec41d617a437 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Mon, 24 Nov 2025 18:40:59 +0100 Subject: [PATCH 1/5] Dependencies update --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 71a9de1..ebe74fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,11 +20,11 @@ dependencies = [ "keyring>=25.6.0", ] -[project.optional-dependencies] +[dependency-groups] dev = [ "httpx>=0.27.0", - "pytest>=8.3.0", - "pytest-asyncio>=0.23.0", + "pytest==9.0.1", + "pytest-asyncio>=1.3.0", "ruff>=0.6.0", "mypy>=1.11.0", "types-Jinja2>=2.11.9", From fea7838ead5ba02605f24bfb81e009b9389acd2e Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Mon, 24 Nov 2025 18:42:18 +0100 Subject: [PATCH 2/5] test: Changed unittest to pytest --- tests/test_mo.py | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/tests/test_mo.py b/tests/test_mo.py index b3d25d4..e9f54d0 100644 --- a/tests/test_mo.py +++ b/tests/test_mo.py @@ -1,24 +1,35 @@ -import os from pathlib import Path -from unittest import TestCase +import pytest import yaml from app.services import Passwords from app.services.mo import Mo -class TestMo(TestCase): - def test_process(self): - target_path = Path('tests/files/test1/test.yaml') - mo = Mo(Passwords()) - mo.process(Path('tests/files/test1/test.mo.yaml').absolute()) - self.assertTrue(os.path.exists(target_path)) - with open(target_path, 'r') as f: - content = f.read() - self.assertFalse(content.__contains__('${')) - self.assertFalse(content.__contains__('%{')) - parsed = yaml.load(content, Loader=yaml.FullLoader) - self.assertEqual('some_pass', parsed['value']) - self.assertEqual('nested_pass', parsed['nested']) - self.assertEqual('custom_content', parsed['custom']) +@pytest.fixture +def target_path(): + p = Path('tests/files/test1/test.yaml') + # posprzątaj przed testem, gdyby plik istniał z poprzednich uruchomień + if p.exists(): + p.unlink() + yield p + # sprzątanie po teście + if p.exists(): + p.unlink() + + +def test_process(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 + assert '%{' not in content + + parsed = yaml.load(content, Loader=yaml.FullLoader) + assert parsed['value'] == 'some_pass' + assert parsed['nested'] == 'nested_pass' + assert parsed['custom'] == 'custom_content' From 082ab463c1a41fef2dd4c1deae9dee58a449706c Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Mon, 24 Nov 2025 18:56:08 +0100 Subject: [PATCH 3/5] test: new field convention TDD --- tests/files/test1/test.mo.yaml | 7 ++++--- tests/test_mo.py | 25 +++++++++++++++++++------ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/files/test1/test.mo.yaml b/tests/files/test1/test.mo.yaml index c494bd1..f2aaac7 100644 --- a/tests/files/test1/test.mo.yaml +++ b/tests/files/test1/test.mo.yaml @@ -1,3 +1,4 @@ -value: %{sample} -nested: %{some.nested.value} -custom: ${custom.field} +value: ${sample} +nested: ${some.nested.value} +custom: ${custom/field} +uname: ${sample/username} diff --git a/tests/test_mo.py b/tests/test_mo.py index e9f54d0..8237e8d 100644 --- a/tests/test_mo.py +++ b/tests/test_mo.py @@ -19,7 +19,8 @@ def target_path(): p.unlink() -def test_process(target_path: Path): +@pytest.fixture +def test1_content(target_path: Path): mo = Mo(Passwords()) mo.process(Path('tests/files/test1/test.mo.yaml').absolute()) @@ -27,9 +28,21 @@ def test_process(target_path: Path): content = target_path.read_text() assert '${' not in content - assert '%{' not in content - parsed = yaml.load(content, Loader=yaml.FullLoader) - assert parsed['value'] == 'some_pass' - assert parsed['nested'] == 'nested_pass' - assert parsed['custom'] == 'custom_content' + return yaml.load(content, Loader=yaml.FullLoader) + + +def test_simple(test1_content: dict): + assert test1_content['value'] == 'some_pass' + + +def test_nested(test1_content: dict): + assert test1_content['nested'] == 'nested_pass' + + +def test_custom_field(test1_content: dict): + assert test1_content['custom'] == 'custom_content' + + +def test_username_field(test1_content: dict): + assert test1_content['uname'] == 'sample_username' From 227d8107aa06404ad13c98db21079006292c7e89 Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Tue, 25 Nov 2025 01:22:08 +0100 Subject: [PATCH 4/5] test: cleanup --- app/services/mo.py | 19 ++++++------------- app/services/passwords.py | 33 +++++++++++++++++++++++++++------ tests/files/test1/test.mo.yaml | 1 + tests/test_mo.py | 26 ++++++++++++++------------ 4 files changed, 48 insertions(+), 31 deletions(-) diff --git a/app/services/mo.py b/app/services/mo.py index 33fa240..84792f7 100644 --- a/app/services/mo.py +++ b/app/services/mo.py @@ -7,13 +7,9 @@ from injectable import injectable, autowired, Autowired from app.services import Passwords -class ComplexValueTemplate(Template): - # Pozwala na kropki w nazwach placeholderów, np. ${user.name.first} - idpattern = r'[_a-zA-Z][_a-zA-Z0-9.]*' - - -class SimpleValueTemplate(ComplexValueTemplate): - delimiter = '%' +class ValueTemplate(Template): + # Pozwala na kropki i ukośniki w nazwach placeholderów, np. ${user.name/first} + idpattern = r'[_a-zA-Z][_a-zA-Z0-9.\/]*' @injectable @@ -26,12 +22,9 @@ class Mo: raw = '' with open(mo_file, "r") as mo: raw = mo.read() - cmp = ComplexValueTemplate(raw) - rendered = cmp.substitute(self._passwords.get_values(cmp.get_identifiers())) - smp = SimpleValueTemplate(rendered) - 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) + parsed = ValueTemplate(raw) + mappings = self._passwords.get_values(parsed.get_identifiers()) + rendered = parsed.safe_substitute(mappings) de_mo_ified = str(mo_file).replace(".mo", "") with open(de_mo_ified, "w") as mo: mo.write(rendered) diff --git a/app/services/passwords.py b/app/services/passwords.py index 39ff368..054e234 100644 --- a/app/services/passwords.py +++ b/app/services/passwords.py @@ -6,6 +6,30 @@ import keyring from injectable import injectable 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): + + pass + # k_parts = k.split("/") + # field_name = None + # match len(k_parts): + # case 1: + # field_name = 'password' + # case 2: + # field_name = k_parts[1] + # k = k_parts[0] + # case _: + # output[k] = None + # continue + # key_parts = k.split(".") + # path = key_parts[:-1] if len(key_parts) > 2 else None + # entry_name = key_parts[-1] @injectable(singleton=True) class Passwords: @@ -28,13 +52,10 @@ class Passwords: def get_values(self, keys: list[str]) -> dict[str, str]: output = {} for k in keys: - key_parts = k.split(".") - path = key_parts[:-1] if len(key_parts) > 2 else None - entry_name = key_parts[-2] - field_name = key_parts[-1] + request = KeyRequest(k) with self.open() as kp: - kp_entry = kp.find_entries(path=path, first=True, title=entry_name) - output[k] = self._get_field_value(kp_entry, field_name) + kp_entry = kp.find_entries(path=request.path, first=True, title=request.entry_name) + output[k] = self._get_field_value(kp_entry, request.field_name) return output @staticmethod diff --git a/tests/files/test1/test.mo.yaml b/tests/files/test1/test.mo.yaml index f2aaac7..11bd6b5 100644 --- a/tests/files/test1/test.mo.yaml +++ b/tests/files/test1/test.mo.yaml @@ -2,3 +2,4 @@ value: ${sample} nested: ${some.nested.value} custom: ${custom/field} uname: ${sample/username} +invalid: ${double/slash/example} diff --git a/tests/test_mo.py b/tests/test_mo.py index 8237e8d..01e63c0 100644 --- a/tests/test_mo.py +++ b/tests/test_mo.py @@ -7,7 +7,7 @@ from app.services import Passwords from app.services.mo import Mo -@pytest.fixture +@pytest.fixture(scope='class') def target_path(): p = Path('tests/files/test1/test.yaml') # posprzątaj przed testem, gdyby plik istniał z poprzednich uruchomień @@ -19,7 +19,7 @@ def target_path(): p.unlink() -@pytest.fixture +@pytest.fixture(scope='class') def test1_content(target_path: Path): mo = Mo(Passwords()) mo.process(Path('tests/files/test1/test.mo.yaml').absolute()) @@ -29,20 +29,22 @@ def test1_content(target_path: Path): content = target_path.read_text() assert '${' not in content - return yaml.load(content, Loader=yaml.FullLoader) + yield yaml.load(content, Loader=yaml.FullLoader) -def test_simple(test1_content: dict): - assert test1_content['value'] == 'some_pass' +class TestParsing: + def test_simple(self, test1_content: dict): + assert test1_content['value'] == 'some_pass' -def test_nested(test1_content: dict): - assert test1_content['nested'] == 'nested_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_custom_field(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_username_field(test1_content: dict): - assert test1_content['uname'] == 'sample_username' + def test_invalid_key(self, test1_content: dict): + assert test1_content.get('invalid') == 'None' From fc550b853601827f51a2289ceab7de789bdb4b9c Mon Sep 17 00:00:00 2001 From: Piotr Dec Date: Wed, 26 Nov 2025 00:03:05 +0100 Subject: [PATCH 5/5] fix: KP search method redesigned --- app/services/passwords.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/app/services/passwords.py b/app/services/passwords.py index 054e234..e851d4e 100644 --- a/app/services/passwords.py +++ b/app/services/passwords.py @@ -14,22 +14,22 @@ class KeyRequest: self._parse_prompt(prompt) def _parse_prompt(self, prompt: str): - - pass - # k_parts = k.split("/") - # field_name = None - # match len(k_parts): - # case 1: - # field_name = 'password' - # case 2: - # field_name = k_parts[1] - # k = k_parts[0] - # case _: - # output[k] = None - # continue - # key_parts = k.split(".") - # path = key_parts[:-1] if len(key_parts) > 2 else None - # entry_name = key_parts[-1] + 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) class Passwords: