diff --git a/.gitignore b/.gitignore index 9ab9cc9..0eb9e1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ __pycache__ .pytest_cache -*.egg-info \ No newline at end of file +*.egg-info +.python-version +.venv \ No newline at end of file diff --git a/conf/apps/food-repo/src/food/__init__.py b/conf/apps/food-repo/src/food/__init__.py index ebac54a..0f35724 100644 --- a/conf/apps/food-repo/src/food/__init__.py +++ b/conf/apps/food-repo/src/food/__init__.py @@ -2,4 +2,4 @@ from .eggs import Eggs from .ham import Ham from .spam import Spam -__all__ = ['Eggs', 'Ham', 'Spam'] +__all__ = ["Eggs", "Ham", "Spam"] diff --git a/conf/apps/food-repo/src/food/eggs.py b/conf/apps/food-repo/src/food/eggs.py index 49c1a9e..982c26b 100644 --- a/conf/apps/food-repo/src/food/eggs.py +++ b/conf/apps/food-repo/src/food/eggs.py @@ -1,5 +1,6 @@ from appdaemon.adapi import ADAPI + class Eggs(ADAPI): def initialize(self): - self.log(f'{self.__class__.__name__} Initialized') + self.log(f"{self.__class__.__name__} Initialized") diff --git a/conf/apps/food-repo/src/food/ham.py b/conf/apps/food-repo/src/food/ham.py index 7ef3624..0924527 100644 --- a/conf/apps/food-repo/src/food/ham.py +++ b/conf/apps/food-repo/src/food/ham.py @@ -1,5 +1,6 @@ from appdaemon.adapi import ADAPI + class Ham(ADAPI): def initialize(self): - self.log(f'{self.__class__.__name__} Initialized') + self.log(f"{self.__class__.__name__} Initialized") diff --git a/conf/apps/food-repo/src/food/spam.py b/conf/apps/food-repo/src/food/spam.py index 4279cad..fdbd691 100644 --- a/conf/apps/food-repo/src/food/spam.py +++ b/conf/apps/food-repo/src/food/spam.py @@ -1,5 +1,6 @@ from appdaemon.adapi import ADAPI + class Spam(ADAPI): def initialize(self): - self.log(f'{self.__class__.__name__} Initialized') + self.log(f"{self.__class__.__name__} Initialized") diff --git a/conf/apps/hello.py b/conf/apps/hello.py index e375568..a4780fd 100644 --- a/conf/apps/hello.py +++ b/conf/apps/hello.py @@ -1,5 +1,6 @@ from appdaemon.adapi import ADAPI + class HelloWorld(ADAPI): def initialize(self): - self.log(f'{self.__class__.__name__} Initialized') + self.log(f"{self.__class__.__name__} Initialized") diff --git a/conf/apps/restaurant/restaurant.py b/conf/apps/restaurant/restaurant.py index eb65c3e..ce52f5a 100644 --- a/conf/apps/restaurant/restaurant.py +++ b/conf/apps/restaurant/restaurant.py @@ -7,4 +7,5 @@ class Restaurant(ADAPI): def initialize(self): meal = Meal() eggs: Eggs = meal.dishes[0] - self.log(f'{self.__class__.__name__} initialized with {eggs}') + self.log(f"{self.__class__.__name__} initialized with {eggs}") + self.log(f"Last dish: {meal.dishes[-1]}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8b484c8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "ad-test" +version = "0.1.0" +description = "Add your description here" +authors = [ + { name = "John Lancaster", email = "32917998+jsl12@users.noreply.github.com" } +] +dependencies = [ + "pytest>=8.3.2", + "gitpython>=3.1.43", +] +readme = "README.md" +requires-python = ">= 3.8" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.rye] +managed = true +dev-dependencies = [] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["src/ad_test"] diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 0000000..696489b --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,26 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: false +# with-sources: false +# generate-hashes: false +# universal: false + +-e file:. +gitdb==4.0.11 + # via gitpython +gitpython==3.1.43 + # via ad-test +iniconfig==2.0.0 + # via pytest +packaging==24.1 + # via pytest +pluggy==1.5.0 + # via pytest +pytest==8.3.2 + # via ad-test +smmap==5.0.1 + # via gitdb diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..696489b --- /dev/null +++ b/requirements.lock @@ -0,0 +1,26 @@ +# generated by rye +# use `rye lock` or `rye sync` to update this lockfile +# +# last locked with the following flags: +# pre: false +# features: [] +# all-features: false +# with-sources: false +# generate-hashes: false +# universal: false + +-e file:. +gitdb==4.0.11 + # via gitpython +gitpython==3.1.43 + # via ad-test +iniconfig==2.0.0 + # via pytest +packaging==24.1 + # via pytest +pluggy==1.5.0 + # via pytest +pytest==8.3.2 + # via ad-test +smmap==5.0.1 + # via gitdb diff --git a/src/ad_test/__init__.py b/src/ad_test/__init__.py new file mode 100644 index 0000000..8f990cf --- /dev/null +++ b/src/ad_test/__init__.py @@ -0,0 +1,2 @@ +def hello() -> str: + return "Hello from ad-test!" diff --git a/src/ad_test/conftest.py b/src/ad_test/conftest.py new file mode 100644 index 0000000..cd95578 --- /dev/null +++ b/src/ad_test/conftest.py @@ -0,0 +1,12 @@ +from typing import List + +from pytest import Function + + +def pytest_collection_modifyitems(session, config, items: List[Function]): + for i, item in enumerate(items): + if isinstance(item, Function) and "startup" in item.name: + items.insert(0, items.pop(i)) + break + + return items diff --git a/src/ad_test/fixtures.py b/src/ad_test/fixtures.py new file mode 100644 index 0000000..b8af9f1 --- /dev/null +++ b/src/ad_test/fixtures.py @@ -0,0 +1,50 @@ +import asyncio +from pathlib import Path + +import pytest +from appdaemon.appdaemon import AppDaemon +from appdaemon.logging import Logging +from appdaemon.models.config import AppDaemonConfig +from git import Repo + +from .utils import delayed_stop + +CONFIG_DIR = Path(__file__).parents[2] / "conf" + + +@pytest.fixture +def config_repo() -> Repo: + repo = Repo("/home/john/ad-test") + return repo + + +@pytest.fixture(scope="session") +def base_config() -> AppDaemonConfig: + return AppDaemonConfig( + latitude=0.0, + longitude=0.0, + elevation=0, + time_zone="America/Chicago", + config_dir=CONFIG_DIR, + config_file="appdaemon.yaml", + # stop_function=lambda: print('Stopping'), + ) + + +@pytest.fixture(scope="session") +def ad(base_config: AppDaemonConfig): + loop = asyncio.new_event_loop() + + ad = AppDaemon(Logging(), loop, base_config) + yield ad + ad.stop() + + tasks = asyncio.all_tasks(loop) + for t in tasks: + t.cancel() + + with pytest.raises(asyncio.exceptions.CancelledError): + loop.run_until_complete(asyncio.gather(*tasks)) + + loop.close() + print("Cleaned up running AD") diff --git a/src/ad_test/test_file_change.py b/src/ad_test/test_file_change.py new file mode 100644 index 0000000..bc1ae5a --- /dev/null +++ b/src/ad_test/test_file_change.py @@ -0,0 +1,51 @@ +import asyncio +import logging +import re +from pathlib import Path + +import food.meal +import pytest +from appdaemon.appdaemon import AppDaemon +from git import Repo + +from .fixtures import ad, base_config +from .utils import get_load_order + + +def reset_file(changed: Path): + for p in Path(__file__).parents: + if p.with_name(".git").exists(): + root = p.parent + break + + repo = Repo(root) + if not changed.is_absolute(): + changed = root / changed + repo.git.checkout("HEAD", "--", changed) + + +def modify_file(path: Path): + file_content = path.read_text() + modified_content = re.sub(r"Ham", r"Spam", file_content, flags=re.MULTILINE) + modified_content = re.sub(r"ham", r"spam", modified_content, flags=re.MULTILINE) + path.write_text(modified_content) + + +def test_file(ad: AppDaemon, caplog: pytest.LogCaptureFixture): + assert isinstance(ad, AppDaemon) + + # ad.loop.run_until_complete(asyncio.sleep(2.0)) + + module_file = Path(food.meal.__file__) + reset_file(module_file) + modify_file(module_file) + + logging.getLogger("AppDaemon").propagate = True + with caplog.at_level(logging.DEBUG, logger="AppDaemon._app_management"): + # running_ad.loop.run_until_complete(delayed_stop(running_ad, 2.0)) + print("waiting in test") + ad.loop.run_until_complete(asyncio.sleep(2.0)) + + module_load_order = get_load_order(caplog) + assert module_load_order == ["food.meal", "restaurant"] + print("done") diff --git a/src/ad_test/test_startup.py b/src/ad_test/test_startup.py new file mode 100644 index 0000000..80df439 --- /dev/null +++ b/src/ad_test/test_startup.py @@ -0,0 +1,56 @@ +import asyncio +import logging +from typing import List + +import pytest +from appdaemon.appdaemon import AppDaemon + +from .fixtures import ad, base_config +from .utils import get_load_order + + +def validate_app_dependencies(ad: AppDaemon): + graph = ad.app_management.app_config.depedency_graph() + assert "hello_world" in graph["food_app"] + + +def validate_module_dependencies(ad: AppDaemon): + graph = ad.app_management.module_dependencies + assert "food" in graph["restaurant"] + assert "food.meal" in graph["restaurant"] + + +def validate_module_load_order(ad: AppDaemon, module_load_order: List[str]): + dependency_graph = ad.app_management.module_dependencies + for node, deps in dependency_graph.items(): + # skip parts that + if not node.startswith("appdaemon"): + continue + + node_idx = module_load_order.index(node) + for dep in deps: + dep_idx = module_load_order.index(dep) + assert dep_idx < node_idx + + +def test_startup(ad: AppDaemon, caplog: pytest.LogCaptureFixture): + assert isinstance(ad, AppDaemon) + + # logger = logging.getLogger('AppDaemon._app_management') + ad_logger = logging.getLogger("AppDaemon") + ad_logger.propagate = True + + with caplog.at_level(logging.DEBUG, logger="AppDaemon._app_management"): + # ad_system.loop.run_until_complete(stop_coro) + ad.loop.run_until_complete(asyncio.sleep(2.0)) + + assert "Started 'hello_world'" in caplog.text + assert "App initialization complete" in caplog.text + + validate_app_dependencies(ad) + validate_module_dependencies(ad) + + module_load_order = get_load_order(caplog) + validate_module_load_order(ad, module_load_order) + + # ad_system.loop.close() diff --git a/src/ad_test/utils.py b/src/ad_test/utils.py new file mode 100644 index 0000000..1bfd4e1 --- /dev/null +++ b/src/ad_test/utils.py @@ -0,0 +1,17 @@ +import asyncio +from typing import List + +import pytest +from appdaemon.appdaemon import AppDaemon + + +async def delayed_stop(ad: AppDaemon, delay: float): + await asyncio.sleep(delay) + ad.stop() + + +def get_load_order(caplog: pytest.LogCaptureFixture) -> List[str]: + for record in caplog.records: + if record.name == "AppDaemon._app_management": + if "Determined module load order" in record.msg: + return record.args[0] diff --git a/test_startup.py b/test_startup.py deleted file mode 100644 index 88aaffa..0000000 --- a/test_startup.py +++ /dev/null @@ -1,90 +0,0 @@ -import asyncio -import logging -from pathlib import Path -from typing import List - -import pytest -from appdaemon.appdaemon import AppDaemon -from appdaemon.logging import Logging -from appdaemon.models.config import AppDaemonConfig - -CONFIG_DIR = Path(__file__).parent / 'conf' - - -@pytest.fixture -def base_config() -> AppDaemonConfig: - return AppDaemonConfig( - latitude=0.0, - longitude=0.0, - elevation=0, - time_zone='America/Chicago', - config_dir=CONFIG_DIR, - config_file='appdaemon.yaml', - stop_function=lambda: print('Stopping'), - ) - - -@pytest.fixture -def ad_system(base_config) -> AppDaemon: - loop = asyncio.new_event_loop() - ad = AppDaemon(Logging(), loop, base_config) - return ad - - -async def delayed_stop(ad: AppDaemon, delay: float): - await asyncio.sleep(delay) - ad.stop() - - -def get_load_order(caplog: pytest.LogCaptureFixture) -> List[str]: - for record in caplog.records: - if record.name == 'AppDaemon._app_management': - if 'Determined module load order' in record.msg: - return record.args[0] - - -def validate_app_dependencies(ad: AppDaemon): - graph = ad.app_management.app_config.depedency_graph() - assert 'hello_world' in graph['food_app'] - - -def validate_module_dependencies(ad: AppDaemon): - graph = ad.app_management.module_dependencies - assert 'food' in graph['restaurant'] - assert 'food.meal' in graph['restaurant'] - - -def validate_module_load_order(ad: AppDaemon, module_load_order: List[str]): - dependency_graph = ad.app_management.module_dependencies - for node, deps in dependency_graph.items(): - # skip parts that - if not node.startswith('appdaemon'): - continue - - node_idx = module_load_order.index(node) - for dep in deps: - dep_idx = module_load_order.index(dep) - assert dep_idx < node_idx - - -def test_startup(ad_system: AppDaemon, caplog: pytest.LogCaptureFixture): - assert isinstance(ad_system, AppDaemon) - - # logger = logging.getLogger('AppDaemon._app_management') - logging.getLogger('AppDaemon').propagate = True - - stop_coro = delayed_stop(ad_system, 2.0) - - with caplog.at_level(logging.DEBUG, logger='AppDaemon._app_management'): - ad_system.loop.run_until_complete(stop_coro) - - assert "Started 'hello_world'" in caplog.text - assert 'App initialization complete' in caplog.text - - validate_app_dependencies(ad_system) - validate_module_dependencies(ad_system) - - module_load_order = get_load_order(caplog) - validate_module_load_order(ad_system, module_load_order) - - print()