Skip to content

Commit 6331c4f

Browse files
authored
Merge pull request #2446 from AppDaemon/ns-fix
Namespace fix
2 parents 5cdbc47 + 90eb51e commit 6331c4f

File tree

8 files changed

+154
-42
lines changed

8 files changed

+154
-42
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ nosetests.xml
4646
coverage.xml
4747
*,cover
4848
.hypothesis/
49+
tests/conf/namespaces
4950

5051
# Translations
5152
*.mo

appdaemon/adapi.py

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -765,9 +765,9 @@ def _check_entity(self, namespace: str, entity_id: str | None) -> None:
765765
"""Ensures that the entity exists in the given namespace"""
766766
if entity_id is not None and "." in entity_id and not self.AD.state.entity_exists(namespace, entity_id):
767767
if namespace == "default":
768-
self.logger.warning(f"Entity {entity_id} not found in the default namespace")
768+
self.logger.warning("Entity %s not found in the default namespace", entity_id)
769769
else:
770-
self.logger.warning(f"Entity {entity_id} not found in namespace {namespace}")
770+
self.logger.warning("Entity %s not found in namespace %s", entity_id, namespace)
771771

772772
@staticmethod
773773
def get_ad_version() -> str:
@@ -817,7 +817,7 @@ async def add_entity(
817817
>>> self.add_entity('mqtt.living_room_temperature', namespace='mqtt')
818818
819819
"""
820-
namespace = namespace or self.namespace
820+
namespace = namespace if namespace is not None else self.namespace
821821
return await self.AD.state.add_entity(namespace, entity_id, state, attributes)
822822

823823
@utils.sync_decorator
@@ -850,7 +850,7 @@ async def entity_exists(self, entity_id: str, namespace: str | None = None) -> b
850850
>>> if self.entity_exists("mqtt.security_settings", namespace = "mqtt"):
851851
>>> #do something
852852
"""
853-
namespace = namespace or self.namespace
853+
namespace = namespace if namespace is not None else self.namespace
854854
return self.AD.state.entity_exists(namespace, entity_id)
855855

856856
@utils.sync_decorator
@@ -877,7 +877,7 @@ async def split_entity(self, entity_id: str, namespace: str | None = None) -> li
877877
>>> #do something specific to scenes
878878
879879
"""
880-
namespace = namespace or self.namespace
880+
namespace = namespace if namespace is not None else self.namespace
881881
self._check_entity(namespace, entity_id)
882882
return entity_id.split(".")
883883

@@ -907,7 +907,7 @@ async def remove_entity(self, entity_id: str, namespace: str | None = None) -> N
907907
>>> self.remove_entity('mqtt.living_room_temperature', namespace = 'mqtt')
908908
909909
"""
910-
namespace = namespace or self.namespace
910+
namespace = namespace if namespace is not None else self.namespace
911911
await self.AD.state.remove_entity(namespace, entity_id)
912912

913913
@staticmethod
@@ -952,7 +952,7 @@ async def get_plugin_config(self, namespace: str | None = None) -> Any:
952952
My current position is 50.8333(Lat), 4.3333(Long)
953953
954954
"""
955-
namespace = namespace or self.namespace
955+
namespace = namespace if namespace is not None else self.namespace
956956
return self.AD.plugins.get_plugin_meta(namespace)
957957

958958
@utils.sync_decorator
@@ -976,7 +976,7 @@ async def friendly_name(self, entity_id: str, namespace: str | None = None) -> s
976976
device_tracker.andrew (Andrew Tracker) is on.
977977
978978
"""
979-
namespace = namespace or self.namespace
979+
namespace = namespace if namespace is not None else self.namespace
980980
self._check_entity(namespace, entity_id)
981981
return await self.get_state(
982982
entity_id=entity_id,
@@ -1584,7 +1584,7 @@ async def listen_state(
15841584
"""
15851585
kwargs = dict(new=new, old=old, duration=duration, attribute=attribute, **kwargs)
15861586
kwargs = {k: v for k, v in kwargs.items() if v is not None}
1587-
namespace = namespace or self.namespace
1587+
namespace = namespace if namespace is not None else self.namespace
15881588

15891589
# pre-fill some arguments here
15901590
add_callback = functools.partial(
@@ -1724,9 +1724,10 @@ async def get_state(
17241724
if kwargs:
17251725
self.logger.warning(f"Extra kwargs passed to get_state, will be ignored: {kwargs}")
17261726

1727+
namespace = namespace if namespace is not None else self.namespace
17271728
return await self.AD.state.get_state(
17281729
name=self.name,
1729-
namespace=namespace or self.namespace,
1730+
namespace=namespace,
17301731
entity_id=entity_id,
17311732
attribute=attribute,
17321733
default=default,
@@ -1783,7 +1784,7 @@ async def set_state(
17831784
>>> self.set_state("light.office_1", state="off", namespace="hass")
17841785
17851786
"""
1786-
namespace = namespace or self.namespace
1787+
namespace = namespace if namespace is not None else self.namespace
17871788
if check_existence:
17881789
self._check_entity(namespace, entity_id)
17891790
return await self.AD.state.set_state(
@@ -1846,7 +1847,7 @@ def register_service(self, service: str, cb: Callable, namespace: str | None = N
18461847
self._check_service(service)
18471848
self.logger.debug("register_service: %s, %s", service, kwargs)
18481849

1849-
namespace = namespace or self.namespace
1850+
namespace = namespace if namespace is not None else self.namespace
18501851
try:
18511852
domain, service = service.split("/", 2)
18521853
except ValueError as e:
@@ -1886,7 +1887,7 @@ def deregister_service(self, service: str, namespace: str | None = None) -> bool
18861887
>>> self.deregister_service("myservices/service1")
18871888
18881889
"""
1889-
namespace = namespace or self.namespace
1890+
namespace = namespace if namespace is not None else self.namespace
18901891
self.logger.debug("deregister_service: %s, %s", service, namespace)
18911892
self._check_service(service)
18921893
return self.AD.services.deregister_service(namespace, *service.split("/"), name=self.name)
@@ -1996,7 +1997,7 @@ async def call_service(
19961997
"""
19971998
self.logger.debug("call_service: %s, %s", service, data)
19981999
self._check_service(service)
1999-
namespace = namespace or self.namespace
2000+
namespace = namespace if namespace is not None else self.namespace
20002001

20012002
# Check the entity_id if it exists
20022003
if eid := data.get("entity_id"):
@@ -2054,7 +2055,7 @@ async def run_sequence(self, sequence: str | list[dict[str, dict[str, str]]], na
20542055
])
20552056
20562057
"""
2057-
namespace = namespace or self.namespace
2058+
namespace = namespace if namespace is not None else self.namespace
20582059
self.logger.debug("Calling run_sequence() for %s from %s", sequence, self.name)
20592060

20602061
try:
@@ -2197,11 +2198,12 @@ async def listen_event(
21972198
"""
21982199
self.logger.debug(f"Calling listen_event() for {self.name} for {event}: {kwargs}")
21992200

2201+
namespace = namespace if namespace is not None else self.namespace
22002202
# pre-fill some arguments here
22012203
add_callback = functools.partial(
22022204
self.AD.events.add_event_callback,
22032205
name=self.name,
2204-
namespace=namespace or self.namespace,
2206+
namespace=namespace,
22052207
cb=callback,
22062208
timeout=timeout,
22072209
oneshot=oneshot,
@@ -2311,7 +2313,7 @@ async def fire_event(
23112313
# Convert to float if it's not None
23122314
timeout = utils.parse_timedelta(timeout).total_seconds() if timeout is not None else timeout
23132315
kwargs["timeout"] = timeout
2314-
namespace = namespace or self.namespace
2316+
namespace = namespace if namespace is not None else self.namespace
23152317
await self.AD.events.fire_event(namespace, event, **kwargs)
23162318

23172319
#
@@ -3743,7 +3745,7 @@ async def sleep(delay: float, result: T = None) -> T:
37433745
#
37443746

37453747
def get_entity(self, entity: str, namespace: str | None = None, check_existence: bool = True) -> Entity:
3746-
namespace = namespace or self.namespace
3748+
namespace = namespace if namespace is not None else self.namespace
37473749
if check_existence:
37483750
self._check_entity(namespace, entity)
37493751
return Entity(self, namespace, entity)

appdaemon/state.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import threading
22
import traceback
33
import uuid
4+
from collections.abc import Mapping
45
from copy import copy, deepcopy
56
from datetime import timedelta
67
from logging import Logger
@@ -78,7 +79,7 @@ def stop(self) -> None:
7879
self.save_all_namespaces()
7980

8081
def namespace_db_path(self, namespace: str) -> Path:
81-
return self.namespace_path / f"{namespace}.db"
82+
return self.namespace_path / f"{namespace}"
8283

8384
async def add_namespace(
8485
self,
@@ -159,7 +160,7 @@ async def add_persistent_namespace(self, namespace: str, writeback: str) -> Path
159160
self.state[namespace] = utils.PersistentDict(ns_db_path, safe)
160161
except Exception as exc:
161162
raise ade.PersistentNamespaceFailed(namespace, ns_db_path) from exc
162-
current_thread = threading.current_thread().getName()
163+
current_thread = threading.current_thread().name
163164
self.logger.info(f"Persistent namespace '{namespace}' initialized from {current_thread}")
164165
return ns_db_path
165166

@@ -482,10 +483,8 @@ async def process_state_callbacks(self, namespace, state):
482483

483484
def entity_exists(self, namespace: str, entity: str) -> bool:
484485
match self.state.get(namespace):
485-
case dict(ns_state):
486-
match ns_state.get(entity):
487-
case dict():
488-
return True
486+
case Mapping() as ns_state:
487+
return entity in ns_state
489488
return False
490489

491490
def get_entity(self, namespace: Optional[str] = None, entity_id: Optional[str] = None, name: Optional[str] = None):
@@ -854,6 +853,8 @@ def save_all_namespaces(self) -> None:
854853
case utils.PersistentDict() as state:
855854
self.logger.debug("Saving namespace: %s", ns)
856855
state.sync()
856+
self.logger.debug("Closing namespace: %s", ns)
857+
state.close()
857858

858859
def save_hybrid_namespaces(self) -> None:
859860
for ns_name, cfg in self.AD.namespaces.items():

docs/HISTORY.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44

55
**Features**
66

7-
- Added the ``ws_max_msg_size`` config option to the Hass plugin
7+
- Added some basic test for persistent namespaces
88

99
**Fixes**
1010

11-
- Better error handling for receiving huge websocket messages in the Hass plugin
1211
- Fix for sunrise and sunset with offsets - contributed by [ekutner](https://github.com/ekutner)
1312
- Fix for random MQTT disconnects - contributed by [Xsandor](https://github.com/Xsandor)
13+
- Fix for persistent namespaces in Python 3.12
14+
- Better error handling for receiving huge websocket messages in the Hass plugin
1415

1516
**Breaking Changes**
1617

tests/conf/apps/apps.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,7 @@ test_run_daily:
2424
module: scheduler_test_app
2525
class: TestSchedulerRunDaily
2626
time: "00:00:05"
27+
28+
basic_namespace_app:
29+
module: namespace_app
30+
class: BasicNamespaceTester

tests/conf/apps/namespace_app.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from datetime import timedelta
2+
from typing import Any
3+
4+
from appdaemon.adapi import ADAPI, Entity
5+
6+
7+
class BasicNamespaceTester(ADAPI):
8+
handle: str | None
9+
10+
def initialize(self) -> None:
11+
self.set_namespace(self.custom_test_namespace)
12+
self.logger.info('Current namespaces: %s', sorted(self.current_namespaces))
13+
14+
self.show_entities()
15+
16+
exists = self.test_entity.exists()
17+
self.logger.info(f"Entity exists: {exists}")
18+
if not exists:
19+
self.add_entity("sensor.test", state="initial", attributes={"friendly_name": "Test Sensor"})
20+
21+
self.show_entities()
22+
23+
non_existence = "sensor.other_entity"
24+
self.logger.info("Setting %s in default namespace", non_existence)
25+
self.set_state(non_existence, state="other", attributes={"friendly_name": "Other Entity"})
26+
27+
self.run_in(self.start_test, self.start_delay)
28+
self.test_entity.listen_state(self.handle_state)
29+
self.log(f"Initialized {self.name}")
30+
31+
@property
32+
def current_namespaces(self) -> set[str]:
33+
return set(self.AD.state.state.keys())
34+
35+
@property
36+
def custom_test_namespace(self) -> str:
37+
return self.args.get("custom_namespace", "test_namespace")
38+
39+
@property
40+
def start_delay(self) -> timedelta:
41+
return timedelta(seconds=self.args.get("start_delay", 1.0))
42+
43+
@property
44+
def test_entity(self) -> Entity:
45+
return self.get_entity("sensor.test", check_existence=False)
46+
47+
def show_entities(self, *args, **kwargs):
48+
ns = self.AD.state.state.get(self.custom_test_namespace, {})
49+
entities = sorted(ns.keys())
50+
self.log('Test entities: %s', entities)
51+
return entities
52+
53+
def start_test(self, *args, **kwargs: Any) -> None:
54+
match kwargs:
55+
case {"__thread_id": str(thread_id)}:
56+
self.log(f"Change called from thread {thread_id}")
57+
self.test_entity.set_state("changed")
58+
59+
def handle_state(self, entity: str, attribute: str, old: Any, new: Any, **kwargs: Any) -> None:
60+
self.log(f"State changed for {entity}: {attribute} = {old} -> {new}")
61+
self.log(f"Test val: {self.args.get('test_val')}")
62+
63+
full_state = self.test_entity.get_state('all')
64+
self.log(f"Full state: {full_state}")

tests/conftest.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,7 @@
1717

1818

1919
@pytest_asyncio.fixture(scope="function")
20-
async def ad(running_loop: asyncio.BaseEventLoop, ad_cfg: AppDaemonConfig, logging_obj: Logging) -> AsyncGenerator[AppDaemon]:
21-
"""Pytest fixture that provides a full AppDaemon instance for tests.
22-
23-
General steps:
24-
- Create the top-level AppDaemon object.
25-
- Set the log levels of the main logs to DEBUG.
26-
- Process the import paths.
27-
- Set up the dependency manager with the app directory.
28-
- Reads all the config files in the app directory.
29-
- Disables apps for the duration of the fixture.
30-
- Starts/stops the AppDaemon instance.
31-
"""
32-
# logger.info(f"Passed loop: {hex(id(running_loop))}")
33-
assert running_loop == asyncio.get_running_loop(), "The running loop should match the one passed in"
34-
20+
async def ad_obj(running_loop: asyncio.BaseEventLoop, ad_cfg: AppDaemonConfig, logging_obj: Logging) -> AsyncGenerator[AppDaemon]:
3521
ad = AppDaemon(
3622
logging=logging_obj,
3723
loop=running_loop,
@@ -45,6 +31,26 @@ async def ad(running_loop: asyncio.BaseEventLoop, ad_cfg: AppDaemonConfig, loggi
4531
logger_.setLevel("DEBUG")
4632

4733
await ad.app_management._process_import_paths()
34+
ad.app_management.dependency_manager = DependencyManager(python_files=list(), config_files=list())
35+
yield ad
36+
37+
38+
@pytest_asyncio.fixture(scope="function")
39+
async def ad(ad_obj: AppDaemon, running_loop: asyncio.BaseEventLoop) -> AsyncGenerator[AppDaemon]:
40+
"""Pytest fixture that provides a full AppDaemon instance for tests.
41+
42+
General steps:
43+
- Create the top-level AppDaemon object.
44+
- Set the log levels of the main logs to DEBUG.
45+
- Process the import paths.
46+
- Set up the dependency manager with the app directory.
47+
- Reads all the config files in the app directory.
48+
- Disables apps for the duration of the fixture.
49+
- Starts/stops the AppDaemon instance.
50+
"""
51+
# logger.info(f"Passed loop: {hex(id(running_loop))}")
52+
assert running_loop == asyncio.get_running_loop(), "The running loop should match the one passed in"
53+
ad = ad_obj
4854
config_files = list(recursive_get_files(base=ad.app_dir, suffix=ad.config.ext))
4955
ad.app_management.dependency_manager = DependencyManager(python_files=list(), config_files=config_files)
5056

@@ -89,7 +95,7 @@ def ad_cfg() -> AppDaemonConfig:
8995
# "_scheduler": "DEBUG",
9096
"_utility": "DEBUG",
9197
},
92-
namespaces={"test": {}},
98+
# namespaces={"test_namespace": {"writeback": "hybrid", "persist": False}},
9399
)
94100
)
95101

0 commit comments

Comments
 (0)