From 781fbaddd6dab06a97d82e626bb587c3515bfff3 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sat, 24 Jun 2023 22:25:33 +0200 Subject: [PATCH 001/165] Started 0.13.0 development --- CHANGELOG.md | 13 +++++++++++++ README.md | 2 +- acme/etc/Constants.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea544724..93602a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [unreleased] - xxxx-xx-xx + +### Added + +### Experimental + +### Changed + +### Fixed + +### Removed + + ## [0.12.0] - 2023-06-24 ### Added diff --git a/README.md b/README.md index bbbc5057..6b587a09 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # ACME oneM2M CSE An open source CSE Middleware for Education. -Version 0.12.0 +Version 0.13.0-dev [![oneM2M](https://img.shields.io/badge/oneM2M-f00)](https://www.onem2m.org) [![Python](https://img.shields.io/badge/Python-3.8-blue)](https://www.python.org) [![Maintenance](https://img.shields.io/badge/Maintained-Yes-green.svg)](https://github.com/ankraft/ACME-oneM2M-CSE/graphs/commit-activity) [![License](https://img.shields.io/badge/License-BSD%203--Clause-green)](LICENSE) [![MyPy](https://img.shields.io/badge/MyPy-covered-green)](LICENSE) [![Mastodon](https://img.shields.io/badge/-@acmeCSE@mstdn.social-FFF?label=mastodon&logo=mastodon&style=social)](https://mstdn.social/@acmeCSE) diff --git a/acme/etc/Constants.py b/acme/etc/Constants.py index 16f6494d..24b63089 100644 --- a/acme/etc/Constants.py +++ b/acme/etc/Constants.py @@ -11,7 +11,7 @@ class Constants(object): """ Various CSE and oneM2M constants """ - version = '0.12.0' + version = '0.13.0-dev' """ ACME's release version """ logoColor = '#b42025' From ba0b0e9e5286ff8496f465d5203d81b232ca9853 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 29 Jun 2023 22:51:13 +0200 Subject: [PATCH 002/165] Improved attribute comment layout when two lines --- acme/helpers/TextTools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/acme/helpers/TextTools.py b/acme/helpers/TextTools.py index c78d25a1..ccc8d78d 100644 --- a/acme/helpers/TextTools.py +++ b/acme/helpers/TextTools.py @@ -82,7 +82,7 @@ def commentJson(data:Union[str, dict], maxLength:int = 0 for line in data.splitlines(): # Find the key - if len(_sp := line.strip().split(':')) == 1: + if len(_sp := re.split(r':(?=\ )', line.strip())) == 1: if key: previousKey = key key = '' @@ -120,6 +120,7 @@ def commentJson(data:Union[str, dict], result.append(line) else: if width is not None and maxLength > width: # Put comment above line + result.append('') result.append(f'{" " * (len(line) - len(line.lstrip()))}{comment}') result.append(line) else: From 17a8d13c60ef90f9e0200721c35a3125e121b7ca Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 2 Jul 2023 14:30:42 +0200 Subject: [PATCH 003/165] Added first part of resource type support --- acme/etc/Constants.py | 6 +- acme/etc/Types.py | 6 ++ acme/resources/ACTR.py | 9 +- acme/resources/CRS.py | 2 +- acme/resources/CSEBase.py | 1 + acme/resources/CSEBaseAnnc.py | 1 + acme/resources/CSRAnnc.py | 13 +-- acme/resources/Factory.py | 6 +- acme/resources/NOD.py | 1 + acme/resources/NODAnnc.py | 5 +- acme/resources/SCH.py | 111 ++++++++++++++++++++ acme/resources/SCHAnnc.py | 57 ++++++++++ acme/resources/SUB.py | 3 +- acme/services/Console.py | 3 +- tests/init.py | 1 + tests/testSCH.py | 191 ++++++++++++++++++++++++++++++++++ 16 files changed, 398 insertions(+), 18 deletions(-) create mode 100644 acme/resources/SCH.py create mode 100644 acme/resources/SCHAnnc.py create mode 100644 tests/testSCH.py diff --git a/acme/etc/Constants.py b/acme/etc/Constants.py index 24b63089..187e61fe 100644 --- a/acme/etc/Constants.py +++ b/acme/etc/Constants.py @@ -134,4 +134,8 @@ class Constants(object): """ Maximum length of identifiers generated by the CSE """ - + # + # Network Coordination supported + # + networkCoordinationSupported = False + """ Network coordination supported by the CSE """ diff --git a/acme/etc/Types.py b/acme/etc/Types.py index 07eef7b5..d133f393 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -66,6 +66,8 @@ class ResourceTypes(ACMEIntEnum): """ Remote CSE resource type. """ REQ = 17 """ Request resource type. """ + SCH = 18 + """ Schedule resource type. """ SUB = 23 """ Subscription resource type. """ SMD = 24 @@ -158,6 +160,8 @@ class ResourceTypes(ACMEIntEnum): """ Announced Node resource type. """ CSRAnnc = 10016 """ Announced Remote CSE resource type. """ + SCHAnnc = 10018 + """ Announced Schedule resource type. """ SMDAnnc = 10024 """ Announced SemanticDescriptor resouce type. """ FCNTAnnc = 10028 @@ -429,6 +433,8 @@ class ResourceDescription(): ResourceTypes.PCH : ResourceDescription(typeName = 'm2m:pch', fullName='PollingChannel'), ResourceTypes.PCH_PCU : ResourceDescription(typeName = 'm2m:pcu', virtualResourceName = 'pcu', fullName='PollingChannel URI'), ResourceTypes.REQ : ResourceDescription(typeName = 'm2m:req', isRequestCreatable = False, fullName='Request'), + ResourceTypes.SCH : ResourceDescription(typeName = 'm2m:sch', announcedType = ResourceTypes.SCHAnnc, fullName='Schedule'), + ResourceTypes.SCHAnnc : ResourceDescription(typeName = 'm2m:schA', isAnnouncedResource = True, fullName='Schedule Announced'), ResourceTypes.SMD : ResourceDescription(typeName = 'm2m:smd', announcedType = ResourceTypes.SMDAnnc, fullName='SemanticDescriptor'), ResourceTypes.SMDAnnc : ResourceDescription(typeName = 'm2m:smdA', isAnnouncedResource = True, fullName='SemanticDescriptor Announced'), ResourceTypes.SUB : ResourceDescription(typeName = 'm2m:sub', fullName='Subscription'), diff --git a/acme/resources/ACTR.py b/acme/resources/ACTR.py index ca7ef272..b27c466f 100644 --- a/acme/resources/ACTR.py +++ b/acme/resources/ACTR.py @@ -7,13 +7,12 @@ # ResourceType: Action # -""" Action (ACTRA) resource type. """ +""" Action (ACTR) resource type. """ from __future__ import annotations -from typing import Optional, Tuple, Any, cast +from typing import Optional, Tuple -from ..etc.Types import AttributePolicyDict, EvalMode, ResourceTypes, Result, JSON, Permission, EvalCriteriaOperator -from ..etc.Types import BasicType +from ..etc.Types import AttributePolicyDict, EvalMode, ResourceTypes, JSON, Permission, EvalCriteriaOperator from ..etc.ResponseStatusCodes import ResponseException, BAD_REQUEST from ..etc.Utils import riFromID from ..helpers.TextTools import findXPath @@ -24,7 +23,7 @@ class ACTR(AnnounceableResource): - """ Action (ACTRA) resource type. """ + """ Action (ACTR) resource type. """ # Specify the allowed child-resource types _allowedChildResourceTypes:list[ResourceTypes] = [ ResourceTypes.DEPR, diff --git a/acme/resources/CRS.py b/acme/resources/CRS.py index 42b40540..c90394c6 100644 --- a/acme/resources/CRS.py +++ b/acme/resources/CRS.py @@ -33,7 +33,7 @@ class CRS(Resource): _sudRI = '__sudRI__' # Reference when the resource is been deleted because of the deletion of a rrat or srat subscription. Usually empty # Specify the allowed child-resource types - _allowedChildResourceTypes:list[ResourceTypes] = [ ] + _allowedChildResourceTypes:list[ResourceTypes] = [ ResourceTypes.SCH ] # Attributes and Attribute policies for this Resource Class # Assigned during startup in the Importer diff --git a/acme/resources/CSEBase.py b/acme/resources/CSEBase.py index acd83615..353e72a3 100644 --- a/acme/resources/CSEBase.py +++ b/acme/resources/CSEBase.py @@ -34,6 +34,7 @@ class CSEBase(AnnounceableResource): ResourceTypes.GRP, ResourceTypes.NOD, ResourceTypes.REQ, + ResourceTypes.SCH, ResourceTypes.SUB, ResourceTypes.TS, ResourceTypes.TSB, diff --git a/acme/resources/CSEBaseAnnc.py b/acme/resources/CSEBaseAnnc.py index 13d27a08..f57d51b6 100644 --- a/acme/resources/CSEBaseAnnc.py +++ b/acme/resources/CSEBaseAnnc.py @@ -24,6 +24,7 @@ class CSEBaseAnnc(AnnouncedResource): ResourceTypes.FCNTAnnc, ResourceTypes.GRPAnnc, ResourceTypes.NODAnnc, + ResourceTypes.SCHAnnc, ResourceTypes.SUB, ResourceTypes.TSAnnc, ResourceTypes.TSBAnnc ] diff --git a/acme/resources/CSRAnnc.py b/acme/resources/CSRAnnc.py index 2699b7da..cd6a83e6 100644 --- a/acme/resources/CSRAnnc.py +++ b/acme/resources/CSRAnnc.py @@ -20,22 +20,23 @@ class CSRAnnc(AnnouncedResource): # Specify the allowed child-resource types _allowedChildResourceTypes = [ ResourceTypes.ACTR, ResourceTypes.ACTRAnnc, + ResourceTypes.ACP, + ResourceTypes.ACPAnnc, + ResourceTypes.AEAnnc, ResourceTypes.CNT, ResourceTypes.CNTAnnc, ResourceTypes.CINAnnc, + ResourceTypes.CSRAnnc, ResourceTypes.FCNT, ResourceTypes.FCNTAnnc, ResourceTypes.GRP, ResourceTypes.GRPAnnc, - ResourceTypes.ACP, - ResourceTypes.ACPAnnc, + ResourceTypes.MGMTOBJAnnc, + ResourceTypes.NODAnnc, + ResourceTypes.SCHAnnc, ResourceTypes.SUB, ResourceTypes.TS, ResourceTypes.TSAnnc, - ResourceTypes.CSRAnnc, - ResourceTypes.MGMTOBJAnnc, - ResourceTypes.NODAnnc, - ResourceTypes.AEAnnc, ResourceTypes.TSB, ResourceTypes.TSBAnnc ] diff --git a/acme/resources/Factory.py b/acme/resources/Factory.py index caa7ebf7..02125d8d 100644 --- a/acme/resources/Factory.py +++ b/acme/resources/Factory.py @@ -15,7 +15,7 @@ from ..etc.Types import ResourceTypes, addResourceFactoryCallback, FactoryCallableT from ..etc.ResponseStatusCodes import BAD_REQUEST -from ..etc.Types import Result, JSON +from ..etc.Types import JSON from ..etc.Utils import pureResource from ..etc.Constants import Constants from ..services.Logging import Logging as L @@ -57,6 +57,8 @@ from ..resources.SUB import SUB from ..resources.SMD import SMD from ..resources.SMDAnnc import SMDAnnc +from ..resources.SCH import SCH +from ..resources.SCHAnnc import SCHAnnc from ..resources.TS import TS from ..resources.TSAnnc import TSAnnc from ..resources.TS_LA import TS_LA @@ -129,6 +131,8 @@ addResourceFactoryCallback(ResourceTypes.PCH, PCH, lambda dct, tpe, pi, create : PCH(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.PCH_PCU, PCH_PCU, lambda dct, tpe, pi, create : PCH_PCU(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.REQ, REQ, lambda dct, tpe, pi, create : REQ(dct, pi = pi, create = create)) +addResourceFactoryCallback(ResourceTypes.SCH, SCH, lambda dct, tpe, pi, create : SCH(dct, pi = pi, create = create)) +addResourceFactoryCallback(ResourceTypes.SCHAnnc, SCHAnnc, lambda dct, tpe, pi, create : SCHAnnc(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.SMD, SMD, lambda dct, tpe, pi, create : SMD(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.SMDAnnc, SMDAnnc, lambda dct, tpe, pi, create : SMDAnnc(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.SUB, SUB, lambda dct, tpe, pi, create : SUB(dct, pi = pi, create = create)) diff --git a/acme/resources/NOD.py b/acme/resources/NOD.py index 8c0d889e..afbbd7c7 100644 --- a/acme/resources/NOD.py +++ b/acme/resources/NOD.py @@ -25,6 +25,7 @@ class NOD(AnnounceableResource): # Specify the allowed child-resource types _allowedChildResourceTypes = [ ResourceTypes.ACTR, ResourceTypes.MGMTOBJ, + ResourceTypes.SCH, ResourceTypes.SMD, ResourceTypes.SUB ] diff --git a/acme/resources/NODAnnc.py b/acme/resources/NODAnnc.py index c8e30c6a..110ff123 100644 --- a/acme/resources/NODAnnc.py +++ b/acme/resources/NODAnnc.py @@ -1,10 +1,10 @@ # -# GRPAnnc.py +# NODAnnc.py # # (c) 2020 by Andreas Kraft # License: BSD 3-Clause License. See the LICENSE file for further details. # -# GRP : Announceable variant +# NODAnnc : Announceable variant # from __future__ import annotations @@ -20,6 +20,7 @@ class NODAnnc(AnnouncedResource): _allowedChildResourceTypes = [ ResourceTypes.ACTR, ResourceTypes.ACTRAnnc, ResourceTypes.MGMTOBJAnnc, + ResourceTypes.SCHAnnc, ResourceTypes.SUB ] # Attributes and Attribute policies for this Resource Class diff --git a/acme/resources/SCH.py b/acme/resources/SCH.py new file mode 100644 index 00000000..888d8d56 --- /dev/null +++ b/acme/resources/SCH.py @@ -0,0 +1,111 @@ + # +# SCH.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# ResourceType: Schedule +# + +""" Schedule (SCH) resource type. """ + +from __future__ import annotations +from typing import Optional + +from ..etc.Constants import Constants as C +from ..etc.Types import AttributePolicyDict, ResourceTypes, JSON +from ..services.Logging import Logging as L +from ..resources.Resource import Resource +from ..etc.ResponseStatusCodes import CONTENTS_UNACCEPTABLE, NOT_IMPLEMENTED +from ..resources.AnnounceableResource import AnnounceableResource + + +class SCH(AnnounceableResource): + """ Schedule (SCH) resource type. """ + + # Specify the allowed child-resource types + _allowedChildResourceTypes:list[ResourceTypes] = [ ResourceTypes.SUB + ] + """ The allowed child-resource types. """ + + # Attributes and Attribute policies for this Resource Class + # Assigned during startup in the Importer + _attributes:AttributePolicyDict = { + # Common and universal attributes + 'rn': None, + 'ty': None, + 'ri': None, + 'pi': None, + 'ct': None, + 'lt': None, + 'lbl': None, + 'acpi':None, + 'et': None, + 'daci': None, + 'cstn': None, + 'at': None, + 'aa': None, + 'ast': None, + + # Resource attributes + 'se': None, + 'nco': None, + } + """ Attributes and `AttributePolicy` for this resource type. """ + + + def __init__(self, dct:Optional[JSON] = None, pi:Optional[str] = None, create:Optional[bool] = False) -> None: + super().__init__(ResourceTypes.SCH, dct, pi, create = create) + + + + def activate(self, parentResource:Resource, originator:str) -> None: + super().activate(parentResource, originator) + + # Check if the parent is not a resource then the "nco" attribute is not set + _nco = self.nco + if parentResource.ty != ResourceTypes.NOD: + if _nco is not None: + raise CONTENTS_UNACCEPTABLE (L.logWarn(f'"nco" must not be set for a SCH resource that is not a child of a resource')) + + + # If nco is set to true, NOT_IMPLEMENTED is returned + if _nco is not None and _nco == True and not C.networkCoordinationSupported: + raise NOT_IMPLEMENTED (L.logWarn(f'Network Coordinated Operation is not supported by this CSE')) + + # TODO When is supported + # c)The request shall be rejected with the "OPERATION_NOT_ALLOWED" Response Status Code if the target resource + # is a resource that has a campaignEnabled attribute with a value of true. + + + def update(self, dct: JSON = None, originator: str | None = None, doValidateAttributes: bool | None = True) -> None: + + _nco = self.getFinalResourceAttribute('nco', dct) + _parentResource = self.retrieveParentResource() + + # Check if the parent is not a resource then the "nco" attribute is not set + if _parentResource.ty != ResourceTypes.NOD: + if _nco is not None: + raise CONTENTS_UNACCEPTABLE (L.logWarn(f'"nco" must not be set for a SCH resource that is not a child of a resource')) + + # If nco is set to true, NOT_IMPLEMENTED is returned + if _nco is not None and _nco == True and not C.networkCoordinationSupported: + raise NOT_IMPLEMENTED (L.logWarn(f'Network Coordinated Operation is not supported by this CSE')) + + # TODO When is supported + # c)The request shall be rejected with the "OPERATION_NOT_ALLOWED" Response Status Code + # if thetarget resource is a resource that has a campaignEnabled attribute with a value of true. + + super().update(dct, originator, doValidateAttributes) + + + def deactivate(self, originator: str) -> None: + + # TODO When is supported + # a) The request shall be rejected with the "OPERATION_NOT_ALLOWED" Response Status Code + # if the target resource is a resource that has a campaignEnabled attribute with a value of true. + + super().deactivate(originator) + + +# TODO coninue \ No newline at end of file diff --git a/acme/resources/SCHAnnc.py b/acme/resources/SCHAnnc.py new file mode 100644 index 00000000..e1252259 --- /dev/null +++ b/acme/resources/SCHAnnc.py @@ -0,0 +1,57 @@ +# +# SCHAnnc.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# ResourceType: Schedule Announced +# + +""" Schedule Announced(SCHA) resource type. """ + +from __future__ import annotations +from typing import Optional + +from ..etc.Types import AttributePolicyDict, ResourceTypes, JSON +from ..services.Logging import Logging as L +from .AnnouncedResource import AnnouncedResource + + +class SCHAnnc(AnnouncedResource): + """ Schedule Announced (SCHA) resource type. """ + + # Specify the allowed child-resource types + _allowedChildResourceTypes:list[ResourceTypes] = [ ] + """ The allowed child-resource types. """ + + # Attributes and Attribute policies for this Resource Class + # Assigned during startup in the Importer + _attributes:AttributePolicyDict = { + # Common and universal attributes + 'rn': None, + 'ty': None, + 'ri': None, + 'pi': None, + 'ct': None, + 'lt': None, + 'et': None, + 'lbl': None, + 'acpi':None, + 'daci': None, + 'lnk': None, + 'ast': None, + + # Resource attributes + 'se': None, + 'nco': None, + } + """ Attributes and `AttributePolicy` for this resource type. """ + + + def __init__(self, dct:Optional[JSON] = None, + pi:Optional[str] = None, + create:Optional[bool] = False) -> None: + super().__init__(ResourceTypes.SCHAnnc, dct, pi = pi, create = create) + + +# TODO coninue \ No newline at end of file diff --git a/acme/resources/SUB.py b/acme/resources/SUB.py index 49caed50..22f47ef8 100644 --- a/acme/resources/SUB.py +++ b/acme/resources/SUB.py @@ -29,7 +29,8 @@ class SUB(Resource): # Specify the allowed child-resource types - _allowedChildResourceTypes:list[ResourceTypes] = [ ] + _allowedChildResourceTypes:list[ResourceTypes] = [ ResourceTypes.SCH + ] # Attributes and Attribute policies for this Resource Class # Assigned during startup in the Importer diff --git a/acme/services/Console.py b/acme/services/Console.py index 2fc0f897..8760a686 100644 --- a/acme/services/Console.py +++ b/acme/services/Console.py @@ -1264,6 +1264,7 @@ def _stats() -> Table: resourceTypes += f'NOD : {CSE.dispatcher.countResources(ResourceTypes.NOD)}\n' resourceTypes += f'PCH : {CSE.dispatcher.countResources(ResourceTypes.PCH)}\n' resourceTypes += f'REQ : {CSE.dispatcher.countResources(ResourceTypes.REQ)}\n' + resourceTypes += f'SCH : {CSE.dispatcher.countResources(ResourceTypes.SCH)}\n' resourceTypes += f'SMD : {CSE.dispatcher.countResources(ResourceTypes.SMD)}\n' resourceTypes += f'SUB : {CSE.dispatcher.countResources(ResourceTypes.SUB)}\n' resourceTypes += f'TS : {CSE.dispatcher.countResources(ResourceTypes.TS)}\n' @@ -1273,7 +1274,7 @@ def _stats() -> Table: resourceTypes += '\n' resourceTypes += _markup(f'[bold]Total[/bold] : {int(stats[Statistics.resourceCount]) - _virtualCount}\n') # substract the virtual resources # Correct height - resourceTypes += '\n' * (tableWorkers.row_count + 6) + resourceTypes += '\n' * (tableWorkers.row_count + 5) result = Table.grid(expand = True) diff --git a/tests/init.py b/tests/init.py index d7dacf1b..1c13bd2a 100755 --- a/tests/init.py +++ b/tests/init.py @@ -227,6 +227,7 @@ def isRaspberrypi() -> bool: nodRN = 'testNOD' pchRN = 'testPCH' reqRN = 'testREQ' +schRN = 'testSCH' smdRN = 'testSMD' subRN = 'testSUB' tsRN = 'testTS' diff --git a/tests/testSCH.py b/tests/testSCH.py new file mode 100644 index 00000000..0bf5e00a --- /dev/null +++ b/tests/testSCH.py @@ -0,0 +1,191 @@ + # +# testSCH.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# Unit tests for Schedule functionality +# + +import unittest, sys +if '..' not in sys.path: + sys.path.append('..') +from typing import Tuple +from acme.etc.Types import ResourceTypes as T, ResponseStatusCode as RC, Permission +from acme.etc.Types import EvalMode, Operation, EvalCriteriaOperator +from init import * + +nodeID = 'urn:sn:1234' + + +class TestSCH(unittest.TestCase): + + ae = None + aeRI = None + ae2 = None + nod = None + nodRI = None + + + originator = None + + @classmethod + @unittest.skipIf(noCSE, 'No CSEBase') + def setUpClass(cls) -> None: + testCaseStart('Setup TestSCH') + dct = { 'm2m:ae' : { + 'rn' : aeRN, + 'api' : APPID, + 'rr' : True, + 'srv' : [ RELEASEVERSION ] + }} + cls.ae, rsc = CREATE(cseURL, 'C', T.AE, dct) # AE to work under + assert rsc == RC.CREATED, 'cannot create parent AE' + cls.originator = findXPath(cls.ae, 'm2m:ae/aei') + cls.aeRI = findXPath(cls.ae, 'm2m:ae/ri') + + + dct = { 'm2m:nod' : { + 'rn' : nodRN, + 'ni' : nodeID + }} + cls.nod, rsc = CREATE(cseURL, ORIGINATOR, T.NOD, dct) + assert rsc == RC.CREATED + cls.nodRI = findXPath(cls.nod, 'm2m:nod/ri') + + testCaseEnd('Setup TestSCH') + + + @classmethod + @unittest.skipIf(noCSE, 'No CSEBase') + def tearDownClass(cls) -> None: + if not isTearDownEnabled(): + return + testCaseStart('TearDown TestSCH') + DELETE(aeURL, ORIGINATOR) # Just delete the AE and everything below it. Ignore whether it exists or not + DELETE(nodURL, ORIGINATOR) # Just delete the NOD and everything below it. Ignore whether it exists or not + DELETE(f'{cseURL}/{schRN}', ORIGINATOR) + testCaseEnd('TearDown TestSCH') + + + def setUp(self) -> None: + testCaseStart(self._testMethodName) + + + def tearDown(self) -> None: + testCaseEnd(self._testMethodName) + + + ######################################################################### + +# TODO validate schedule element format ***** + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderCBwithNOCFail(self) -> None: + """ CREATE invalid with "nco" under CSEBase -> Fail""" + self.assertIsNotNone(TestSCH.ae) + dct = { 'm2m:sch' : { + 'rn' : schRN, + 'se': { 'sce': [ '* * * * * * *' ] }, + 'nco': True + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CONTENTS_UNACCEPTABLE, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderNODwithNOCUnsupportedFail(self) -> None: + """ CREATE with nco under NOD (unsupported) -> Fail""" + self.assertIsNotNone(TestSCH.ae) + dct = { 'm2m:sch' : { + 'rn' : schRN, + 'se': { 'sce': [ '* * * * * * *' ] }, + 'nco': True + }} + r, rsc = CREATE(nodURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.NOT_IMPLEMENTED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderCBwithoutNCO(self) -> None: + """ CREATE without "nco" under CSEBase""" + self.assertIsNotNone(TestSCH.ae) + dct = { 'm2m:sch' : { + 'rn' : schRN, + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/{schRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_updateSCHunderCBwithNCOFail(self) -> None: + """ UPDATE without "nco" under CSEBase -> Fail""" + self.assertIsNotNone(TestSCH.ae) + dct:JSON = { 'm2m:sch' : { + 'rn' : schRN, + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # UPDATE with nco + dct = { 'm2m:sch' : { + 'nco': True + }} + r, rsc = UPDATE(f'{cseURL}/{schRN}', ORIGINATOR, dct) + self.assertEqual(rsc, RC.CONTENTS_UNACCEPTABLE, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/{schRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_updateSCHunderNODwithNOCUnsupportedFail(self) -> None: + """ CREATE with nco under NOD (unsupported-> Fail""" + self.assertIsNotNone(TestSCH.ae) + dct:JSON = { 'm2m:sch' : { + 'rn' : schRN, + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(nodURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # UPDATE with nco + dct = { 'm2m:sch' : { + 'nco': True + }} + r, rsc = UPDATE(f'{nodURL}/{schRN}', ORIGINATOR, dct) + self.assertEqual(rsc, RC.NOT_IMPLEMENTED, r) + + # DELETE again + r, rsc = DELETE(f'{nodURL}/{schRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + + +def run(testFailFast:bool) -> Tuple[int, int, int, float]: + suite = unittest.TestSuite() + + # basic tests + addTest(suite, TestSCH('test_createSCHunderCBwithNOCFail')) + addTest(suite, TestSCH('test_createSCHunderNODwithNOCUnsupportedFail')) + addTest(suite, TestSCH('test_createSCHunderCBwithoutNCO')) + addTest(suite, TestSCH('test_updateSCHunderCBwithNCOFail')) + addTest(suite, TestSCH('test_updateSCHunderNODwithNOCUnsupportedFail')) + + + result = unittest.TextTestRunner(verbosity = testVerbosity, failfast = testFailFast).run(suite) + printResult(result) + return result.testsRun, len(result.errors + result.failures), len(result.skipped), getSleepTimeCount() + + +if __name__ == '__main__': + r, errors, s, t = run(True) + sys.exit(errors) \ No newline at end of file From 301819745e1058b2f0872e5ba3ff3f968554d04a Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 2 Jul 2023 14:31:10 +0200 Subject: [PATCH 004/165] Attribute definitions for --- init/attributePolicies.ap | 80 ++++++++++++++++++++++++++++++++----- init/complexTypePolicies.ap | 5 ++- init/enumTypesPolicies.ep | 4 +- 3 files changed, 74 insertions(+), 15 deletions(-) diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index db430998..8b1a0513 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -1607,7 +1607,7 @@ "rtypes": [ "ACPAnnc", "ACTRAnnc", "AEAnnc", "ANDIAnnc", "ANIAnnc", "BATAnnc", "CINAnnc", "CNTAnnc", "CSEBaseAnnc", "CSRAnnc", "DATCAnnc", "DEPRAnnc", "DVCAnnc", "DVIAnnc", "EVLAnnc", "FCNTAnnc", "FWRAnnc", "GRPAnnc", "MEMAnnc", "NODAnnc", "NYCFCAnnc", "RBOAnnc", - "SMDAnnc", "SWRAnnc", "TSAnnc", "TSBAnnc", "TSIAnnc", "WIFIC", "WIFICAnnc", + "SCHAnnc", "SMDAnnc", "SWRAnnc", "TSAnnc", "TSBAnnc", "TSIAnnc", "WIFIC", "WIFICAnnc", "REQRESP" ], "lname": "link", "ns": "m2m", @@ -2044,6 +2044,32 @@ "annc": "OA" } ], + "nco": [ + { + "rtypes": [ "SCH", "SCHAnnc", "REQRESP" ], + "lname": "networkCoordinated", + "ns": "m2m", + "type": "boolean", + "car": "01", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], + "nct": [ + { + "rtypes": [ "ALL" ], + "lname": "notificationContentType", + "ns": "m2m", + "type": "nonNegInteger", + "car": "1", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "NA" + } + ], "nec": [ { "rtypes": [ "CRS" ], @@ -2068,17 +2094,12 @@ "annc": "NA" } ], - "nct": [ + "nev": [ { - "rtypes": [ "ALL" ], - "lname": "notificationContentType", + "rtypes": [ "UNKNOWN" ], + "lname": "notificationEvent", "ns": "m2m", - "type": "nonNegInteger", - "car": "1", - "oc": "O", - "ou": "O", - "od": "O", - "annc": "NA" + "type": "any" } ], "nfu": [ @@ -2479,6 +2500,14 @@ "annc": "OA" } ], + "rep": [ + { + "rtypes": [ "UNKNOWN" ], + "lname": "representation", + "ns": "m2m", + "type": "any" + } + ], "rid": [ { "rtypes": [ "ALL", "REQRESP" ], @@ -2671,7 +2700,7 @@ "lname": "sessionCapabilities", "ns": "m2m", "type": "list", - "ltype": "string", // m2m:sessionCapabilities + "ltype": "string", "car": "01", "oc": "O", "ou": "O", @@ -2679,6 +2708,19 @@ "annc": "OA" } ], + "se": [ + { + "rtypes": [ "SCH", "SCHAnnc", "REQRESP" ], + "lname": "scheduleElement", + "ns": "m2m", + "type": "m2m:scheduleEntries", + "car": "1L", + "oc": "M", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], "sfc": [ { "rtypes": [ "DEPR", "DEPRAnnc", "REQRESP" ], @@ -2692,6 +2734,14 @@ "annc": "OA" } ], + "m2m:sgn": [ + { + "rtypes": [ "UNKNOWN" ], + "lname": "notification", + "ns": "m2m", + "type": "any" + } + ], "sld": [ { "rtypes": [ "ALL" ], @@ -2917,6 +2967,14 @@ "annc": "NA" } ], + "sur": [ + { + "rtypes": [ "UNKNOWN" ], + "lname": "subscriptionReference", + "ns": "m2m", + "type": "any" + } + ], // EXPERIMENTAL diff --git a/init/complexTypePolicies.ap b/init/complexTypePolicies.ap index 2d7d6099..16802cc6 100644 --- a/init/complexTypePolicies.ap +++ b/init/complexTypePolicies.ap @@ -1867,8 +1867,9 @@ "ctype": "m2m:scheduleEntries", "lname": "scheduleEntry", "ns": "m2m", - "type": "schedule", - "car": "01" + "type": "list", + "ltype": "schedule", + "car": "1LN" } ], diff --git a/init/enumTypesPolicies.ep b/init/enumTypesPolicies.ep index 306a0005..fff64224 100644 --- a/init/enumTypesPolicies.ep +++ b/init/enumTypesPolicies.ep @@ -70,8 +70,8 @@ }, "m2m:resourceType" : { // Adapt to supported resource types - "evalues" : [ "1..5", 9, "13..17", 23, 24, "28..30", 48, 58, 60, 65, 66, - "10001..10005", 10009, "10013..10014", 10016, 10021, "10028..10030", 10060, 10065, 10066 ] + "evalues" : [ "1..5", 9, "13..18", 23, 24, "28..30", 48, 58, 60, 65, 66, + "10001..10005", 10009, "10013..10014", 10016, 10018, 10021, "10028..10030", 10060, 10065, 10066 ] }, "m2m:responseStatusCode" : { "evalues": [ "1000..1002", From f0b861adddd7edcf54e43a989f31e2ecf5fcf9d7 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 2 Jul 2023 14:31:28 +0200 Subject: [PATCH 005/165] Improved resource layout --- acme/textui/ACMEContainerRequests.py | 7 +++++++ acme/textui/ACMETuiApp.py | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/acme/textui/ACMEContainerRequests.py b/acme/textui/ACMEContainerRequests.py index 3f8b95bd..8899e6a4 100644 --- a/acme/textui/ACMEContainerRequests.py +++ b/acme/textui/ACMEContainerRequests.py @@ -200,6 +200,7 @@ def _showRequests(self, item:ACMEListItem) -> None: explanations = self.app.attributeExplanations, # type: ignore [attr-defined] getAttributeValueName = CSE.validator.getAttributeValueName, # type: ignore [attr-defined] width = self.requestListRequest.size[0] - 2) # type: ignore [attr-defined] + _l1 = jsns.count('\n') # Add syntax highlighting and explanations, and add to the view self.requestListRequest.update(Syntax(jsns, 'json', theme = self.app.syntaxTheme)) # type: ignore [attr-defined] @@ -209,7 +210,13 @@ def _showRequests(self, item:ACMEListItem) -> None: explanations = self.app.attributeExplanations, # type: ignore [attr-defined] getAttributeValueName = CSE.validator.getAttributeValueName, # type: ignore [attr-defined] width = self.requestListRequest.size[0] - 2) # type: ignore [attr-defined] + _l2 = jsns.count('\n') + # Make sure the response has the same number of lines as the request + # (This is a hack to make sure the separator line covers the entire height of the view) + if _l1 > _l2: + jsns += '\n' * (_l1 - _l2) + # Add syntax highlighting and explanations, and add to the view self.requestListResponse.update(Syntax(jsns, 'json', theme = self.app.syntaxTheme)) # type: ignore [attr-defined] diff --git a/acme/textui/ACMETuiApp.py b/acme/textui/ACMETuiApp.py index fb937e8a..6b30846b 100644 --- a/acme/textui/ACMETuiApp.py +++ b/acme/textui/ACMETuiApp.py @@ -23,10 +23,12 @@ from ..textui.ACMEContainerRequests import ACMEContainerRequests from ..textui.ACMEContainerTools import ACMEContainerTools from ..services import CSE +from ..etc.Types import ResourceTypes from ..helpers.BackgroundWorker import BackgroundWorkerPool + tabResources = 'tab-resources' tabRequests = 'tab-requests' tabRegistrations = 'tab-registrations' @@ -91,6 +93,10 @@ def __init__(self, textUI:TextUI.TextUI): self.textUI = textUI # Keep backward link to the textUI manager self.quitReason = ACMETuiQuitReason.undefined self.attributeExplanations = CSE.validator.getShortnameLongNameMapping() + + for n in ResourceTypes: + self.attributeExplanations[ResourceTypes(n).tpe()] = f'{ResourceTypes.fullname(n)} resource type' + # This is used to keep track of the current tab. # This is a bit different from the actual current tab from the self.tabs # attribute because at one point it is used to determine the previous tab. From 3158ceefe85fb79c6834e1cf6bbd472384c72c01 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 2 Jul 2023 14:32:16 +0200 Subject: [PATCH 006/165] Added automatic installation of required packages --- acme/__main__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/acme/__main__.py b/acme/__main__.py index d4fbc692..327bd7ee 100644 --- a/acme/__main__.py +++ b/acme/__main__.py @@ -33,6 +33,19 @@ m = re.search("'(.+?)'", e.msg) package = f' ({m.group(1)}) ' if m else ' ' print(f'\nOne or more required packages or modules{package}could not be found.\nPlease install the missing packages, e.g. by running the following command:\n\n\t{sys.executable} -m pip install -r requirements.txt\n') + + # Ask if the user wants to install the missing packages + try: + if input('\nDo you want to install the missing packages now? [y/N] ') in ['y', 'Y']: + import os + os.system(f'{sys.executable} -m pip install -r requirements.txt') + + # Ask if the user wants to start ACME + if input('\nDo you want to start ACME now? [Y/n] ') in ['y', 'Y', '']: + os.system(f'{sys.executable} -m acme {" ".join(sys.argv[1:])}') + + except Exception as e2: + print(f'\nError during installation: {e2}\n') else: print(f'\nError during import: {e.msg}\n') From b125d1724cb586aa638b593aea1b5d835fcf8066 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 4 Jul 2023 16:27:12 +0200 Subject: [PATCH 007/165] Support "warn" and "warning" for warning loglevel --- acme/services/Configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/services/Configuration.py b/acme/services/Configuration.py index 8d94afc8..5122c853 100644 --- a/acme/services/Configuration.py +++ b/acme/services/Configuration.py @@ -493,7 +493,7 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: Configuration._configuration['logging.level'] = LogLevel.OFF elif logLevel == 'info': Configuration._configuration['logging.level'] = LogLevel.INFO - elif logLevel == 'warn': + elif logLevel in [ 'warn', 'warning' ]: Configuration._configuration['logging.level'] = LogLevel.WARNING elif logLevel == 'error': Configuration._configuration['logging.level'] = LogLevel.ERROR From 5d9150b61540c4a8acad7035fd8940860aa577ae Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 12 Jul 2023 12:17:37 +0200 Subject: [PATCH 008/165] Changes according to SDS-2022-0010 --- CHANGELOG.md | 1 + acme/resources/REQ.py | 65 +++++++++++++++++++++---------------------- tests/testREQ.py | 8 +++++- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93602a19..4a531b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Experimental ### Changed +- [CSE] Changed the *operationResult* of <request> according to SDS-2022-0010R02. ### Fixed diff --git a/acme/resources/REQ.py b/acme/resources/REQ.py index db9b304e..75743ad8 100644 --- a/acme/resources/REQ.py +++ b/acme/resources/REQ.py @@ -11,8 +11,8 @@ from __future__ import annotations from typing import Optional, Dict, Any -from ..etc.Types import AttributePolicyDict, ResourceTypes, Result, RequestStatus, CSERequest, JSON -from ..etc.ResponseStatusCodes import BAD_REQUEST +from ..etc.Types import AttributePolicyDict, ResourceTypes, RequestStatus, CSERequest, JSON +from ..etc.ResponseStatusCodes import ResponseStatusCode from ..helpers.TextTools import setXPath from ..etc.DateUtils import getResourceDate from ..services.Configuration import Configuration @@ -83,47 +83,44 @@ def createRequestResource(request:CSERequest) -> Resource: elif request._rpts: et = request._rpts - # otherwise calculate request et + # otherwise get the request's et from the configuration else: et = getResourceDate(offset = Configuration.get('resource.req.et')) - # minEt = getResourceDate(Configuration.get('resource.req.minet')) - # maxEt = getResourceDate(Configuration.get('resource.req.maxet')) - # if request.args.rpts: - # et = request.args.rpts if request.args.rpts < maxEt else maxEt - # else: - # et = minEt + # Build the REQ resource from the original request dct:Dict[str, Any] = { 'm2m:req' : { - 'et' : et, - 'lbl' : [ request.originator ], - 'op' : request.op, - 'tg' : request.id, - 'org' : request.originator, - 'rid' : request.rqi, - 'mi' : { - 'ty' : request.ty, - 'ot' : getResourceDate(), - 'rqet' : request.rqet, - 'rset' : request.rset, - 'rt' : { - 'rtv' : request.rt + 'et': et, + 'lbl': [ request.originator ], + 'op': request.op, + 'tg': request.id, + 'org': request.originator, + 'rid': request.rqi, + 'mi': { + 'ty': request.ty, + 'ot': getResourceDate(), + 'rqet': request.rqet, + 'rset': request.rset, + 'rt': { + 'rtv': request.rt }, - 'rp' : request.rp, - 'rcn' : request.rcn, - 'fc' : { - 'fu' : request.fc.fu, - 'fo' : request.fc.fo, + 'rp': request.rp, + 'rcn': request.rcn, + 'fc': { + 'fu': request.fc.fu, + 'fo': request.fc.fo, }, - 'drt' : request.drt, - 'rvi' : request.rvi if request.rvi else CSE.releaseVersion, - 'vsi' : request.vsi, - 'sqi' : request.sqi, + 'drt': request.drt, + 'rvi': request.rvi if request.rvi else CSE.releaseVersion, + 'vsi': request.vsi, + 'sqi': request.sqi, }, - 'rs' : RequestStatus.PENDING, - # 'ors' : { - # } + 'rs': RequestStatus.PENDING, + 'ors': { + 'rsc': ResponseStatusCode.ACCEPTED, + 'rqi': request.rqi, + } }} # add handlings, conditions and attributes from filter diff --git a/tests/testREQ.py b/tests/testREQ.py index 45462f17..1b9b990c 100644 --- a/tests/testREQ.py +++ b/tests/testREQ.py @@ -140,13 +140,19 @@ def test_retrieveCSENBSynchValidateREQ(self) -> None: self.assertEqual(rsc, RC.ACCEPTED_NON_BLOCKING_REQUEST_SYNC, r) self.assertIsNotNone(findXPath(r, 'm2m:uri')) requestURI = findXPath(r, 'm2m:uri') + rqi = lastRequestID() # Immediately retrieve r, rsc = RETRIEVE(f'{csiURL}/{requestURI}', TestREQ.originator) self.assertEqual(rsc, RC.OK, r) self.assertIsNotNone(findXPath(r, 'm2m:req/rs'), r) self.assertEqual(findXPath(r, 'm2m:req/rs'), RequestStatus.PENDING, r) - self.assertIsNone(findXPath(r, 'm2m:req/ors'), r) + self.assertIsNotNone(findXPath(r, 'm2m:req/ors'), r) + self.assertIsNotNone(findXPath(r, 'm2m:req/ors/rsc')) + self.assertEqual(findXPath(r, 'm2m:req/ors/rsc'), RC.ACCEPTED) + self.assertIsNotNone(findXPath(r, 'm2m:req/ors/rqi')) + self.assertEqual(findXPath(r, 'm2m:req/ors/rqi'), rqi) # test the request ID from the original request + # get and check after a delay to give the operation time to run testSleep(requestCheckDelay * 2) From 2fa3d08bf5fecffc90f976413010f2a6aa77b2e6 Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 12 Jul 2023 22:43:56 +0200 Subject: [PATCH 009/165] Increased @at script execution test resolution to once a second --- acme/services/ScriptManager.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/acme/services/ScriptManager.py b/acme/services/ScriptManager.py index 22423a76..2ad4af78 100644 --- a/acme/services/ScriptManager.py +++ b/acme/services/ScriptManager.py @@ -20,7 +20,7 @@ from ..helpers.KeyHandler import FunctionKey from ..etc.Types import JSON, ACMEIntEnum, CSERequest, Operation, ResourceTypes, Result from ..etc.ResponseStatusCodes import ResponseException -from ..etc.DateUtils import cronMatchesTimestamp, getResourceDate +from ..etc.DateUtils import cronMatchesTimestamp, getResourceDate, utcDatetime from ..etc.Utils import runsInIPython, uniqueRI, isURL, uniqueID, pureResource from .Configuration import Configuration from ..helpers.Interpreter import PContext, PFuncCallable, PUndefinedError, PError, PState, SSymbol, SType, PSymbolCallable @@ -541,7 +541,6 @@ def doHttp(self, pcontext:PContext, symbol:SSymbol) -> PContext: except requests.exceptions.ConnectionError: pcontext.variables['response.status'] = SSymbol() # nil return pcontext.setResult(SSymbol()) - #print(response) # parse response and assign to variables @@ -1542,8 +1541,9 @@ def cseStarted(self, name:str) -> None: if self.scriptMonitorInterval > 0.0: self.scriptUpdatesMonitor.start() - # Add a worker to check scheduled script, fixed every minute - self.scriptCronWorker = BackgroundWorkerPool.newWorker(60.0, + # Add a worker to check scheduled script, fixed every second + # TODO resolution + self.scriptCronWorker = BackgroundWorkerPool.newWorker(1, self.cronMonitor, 'scriptCronMonitor').start() @@ -1652,9 +1652,10 @@ def cronMonitor(self) -> bool: Boolean. Usually *True* to continue with monitoring. """ #L.isDebug and L.logDebug(f'Looking for scheduled scripts') + _ts = utcDatetime() for each in self.findScripts(meta = _metaAt): try: - if cronMatchesTimestamp(at := each.meta.get(_metaAt)): + if cronMatchesTimestamp(at := each.meta.get(_metaAt), _ts): L.isDebug and L.logDebug(f'Running script: {each.scriptName} at: {at}') self.runScript(each) except ValueError as e: From a20e7dde8a1ef31cf9b5e9a84bff4cd372387093 Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 12 Jul 2023 22:45:48 +0200 Subject: [PATCH 010/165] Added second resolution to crontab format --- acme/etc/DateUtils.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/acme/etc/DateUtils.py b/acme/etc/DateUtils.py index 4699d9fe..4e86ddd4 100644 --- a/acme/etc/DateUtils.py +++ b/acme/etc/DateUtils.py @@ -125,13 +125,22 @@ def rfc1123Date(timeval:Optional[float] = None) -> str: return formatdate(timeval = timeval, localtime = False, usegmt = True) +def utcDatetime() -> datetime: + """ Return the current datetime, but relative to UTC. + + Returns: + Datetime with current UTC-based time. + """ + return datetime.now(tz = timezone.utc) + + def utcTime() -> float: """ Return the current time's timestamp, but relative to UTC. Returns: Float with current UTC-based POSIX time. """ - return datetime.now(tz = timezone.utc).timestamp() + return utcDatetime().timestamp() def timeUntilTimestamp(ts:float) -> float: @@ -222,14 +231,13 @@ def waitFor(timeout:float, # Cron # -def cronMatchesTimestamp(cronPattern:Union[str, - list[str]], +def cronMatchesTimestamp(cronPattern:Union[str, list[str]], ts:Optional[datetime] = None) -> bool: ''' A cron parser to determine if the *cronPattern* matches for a given timestamp *ts*. The cronPattern must follow the usual crontab pattern of 5 fields: - minute hour dayOfMonth month dayOfWeek + second minute hour dayOfMonth month dayOfWeek year which each must comply to the following patterns: @@ -324,18 +332,20 @@ def _parseMatchCronArg(element:str, target:int) -> bool: return False if ts is None: - ts = datetime.now(tz = timezone.utc) + ts = utcDatetime() cronElements = cronPattern.split() if isinstance(cronPattern, str) else cronPattern - if len(cronElements) != 5: - raise ValueError(f'Invalid or empty cron pattern: "{cronPattern}". Must have 5 elements.') + if len(cronElements) != 7: + raise ValueError(f'Invalid or empty cron pattern: "{cronPattern}". Must have 7 elements.') weekday = ts.isoweekday() - return _parseMatchCronArg(cronElements[0], ts.minute) \ - and _parseMatchCronArg(cronElements[1], ts.hour) \ - and _parseMatchCronArg(cronElements[2], ts.day) \ - and _parseMatchCronArg(cronElements[3], ts.month) \ - and _parseMatchCronArg(cronElements[4], 0 if weekday == 7 else weekday) + return _parseMatchCronArg(cronElements[0], ts.second) \ + and _parseMatchCronArg(cronElements[1], ts.minute) \ + and _parseMatchCronArg(cronElements[2], ts.hour) \ + and _parseMatchCronArg(cronElements[3], ts.day) \ + and _parseMatchCronArg(cronElements[4], ts.month) \ + and _parseMatchCronArg(cronElements[5], 0 if weekday == 7 else weekday) \ + and _parseMatchCronArg(cronElements[6], ts.year) def cronInPeriod(cronPattern:Union[str, @@ -367,7 +377,7 @@ def cronInPeriod(cronPattern:Union[str, # Fill in the default if endTs is None: - endTs = datetime.now(tz = timezone.utc) + endTs = utcDatetime() # Check the validity of the range if endTs < startTs: From fb6a503c880ca5b4c600ed39ff398be62d4bb693 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 13 Jul 2023 11:55:39 +0200 Subject: [PATCH 011/165] Added support for resource type --- CHANGELOG.md | 2 + acme/etc/Types.py | 83 +---- acme/resources/CRS.py | 10 + acme/resources/CSEBase.py | 6 + acme/resources/SCH.py | 19 +- acme/resources/SUB.py | 9 + acme/services/CSE.py | 3 + acme/services/Dispatcher.py | 31 +- acme/services/NotificationManager.py | 49 ++- acme/services/Storage.py | 171 +++++++++- docs/ACMEScript-metatags.md | 8 +- docs/Supported.md | 3 +- tests/testSCH.py | 456 ++++++++++++++++++++++++++- tests/testSUB.py | 8 +- 14 files changed, 743 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a531b2f..69a0b56a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] - xxxx-xx-xx ### Added +- [CSE] Added automatic pip install of missing dependencies during startup. +- [CSE] Added support for <schedule> resource type. ### Experimental diff --git a/acme/etc/Types.py b/acme/etc/Types.py index d133f393..18407bdd 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -842,84 +842,6 @@ def fromBitfield(cls, bitfield:int) -> List[Permission]: if bitfield == Permission.ALL.value: return [ Permission.ALL ] return [ p for p in Permission if p != Permission.ALL and p & bitfield ] - - -# #/usr/local/bin/python3 acop.py {query} -# import sys - -# operations = [ -# (32, 'DISCOVERY', 'i'), -# (16, 'NOTIFY', 'n'), -# ( 8, 'DELETE', 'd'), -# ( 4, 'UPDATE', 'u'), -# ( 2, 'RETRIEVE', 'r'), -# ( 1, 'CREATE', 'c') -# ] - -# def bitfield(n, length = 6): -# r = [int(digit) for digit in bin(n)[2:]] -# while len(r) < length: -# r.insert(0, 0) -# return r - - -# def opsBitfield(field): -# sm = [] -# for i in range(len(field)-1, -1, -1): -# if field[i]: -# sm.append(operations[i][1]) -# return ', '.join(sm) - - -# def toBitfield(query): -# r = [] -# for each in query.lower(): -# for op in operations: -# if each == op[2]: -# if op[0] not in r: -# r.append(op[0]) -# break # break for if found -# else: -# return -1 # return error if for did not exit - -# return sum(r) - - - - -# qu = sys.argv[1] -# try: -# query = int(qu) - -# except ValueError: -# # Not a number, so try to calculate the reverse -# result = toBitfield(qu) -# if result > 0: -# result = str(result) -# print('') -# #print('Access Control Operations') -# print('' + qu + ' = ' + result + '') -# print('' + result + '') -# print('' + result + '') -# print('') - -# else: -# error() - -# else: -# # If no exception, ie. query is an integer -# if 0 < query < 64: -# result = 'ALL' if query == 63 else opsBitfield(bitfield(query)) -# print('') -# #print('Access Control Operations') -# print('' + qu + ' = ' + result + '') -# print('' + result + '') -# print('' + result + '') -# print('') -# else: -# error() - - ############################################################################## @@ -1137,10 +1059,13 @@ class RequestStatus(ACMEIntEnum): # class EventCategory(ACMEIntEnum): - """ Event Categories """ + """ Event Categories from m2m:stdEventCats """ Immediate = 2 + """ Immediate event. """ BestEffort = 3 + """ Best effort event. """ Latest = 4 + """ Only latest event. """ ############################################################################## diff --git a/acme/resources/CRS.py b/acme/resources/CRS.py index c90394c6..4d823c92 100644 --- a/acme/resources/CRS.py +++ b/acme/resources/CRS.py @@ -13,6 +13,7 @@ from typing import Optional, cast from copy import deepcopy + from ..etc.Utils import pureResource, toSPRelative, csiFromSPRelative, compareIDs from ..helpers.TextTools import findXPath, setXPath from ..helpers.ResourceSemaphore import criticalResourceSection, inCriticalSection @@ -219,6 +220,15 @@ def validate(self, originator:Optional[str] = None, raise BAD_REQUEST(L.logDebug(f'eem = {eem} is not allowed with twt = SLIDINGWINDOW')) + def childWillBeAdded(self, childResource: Resource, originator: str) -> None: + super().childWillBeAdded(childResource, originator) + if childResource.ty == ResourceTypes.SCH: + if (rn := childResource._originalDict.get('rn')) is None: + childResource.setResourceName('notificationSchedule') + elif rn != 'notificationSchedule': + raise BAD_REQUEST(L.logDebug(f'rn of under must be "notificationSchedule"')) + + def handleNotification(self, request:CSERequest, originator:str) -> None: """ Handle a notification request to a CRS resource. diff --git a/acme/resources/CSEBase.py b/acme/resources/CSEBase.py index 353e72a3..b5e4230d 100644 --- a/acme/resources/CSEBase.py +++ b/acme/resources/CSEBase.py @@ -130,6 +130,12 @@ def willBeRetrieved(self, originator:str, self.setAttribute('srv', CSE.supportedReleaseVersions) + def childWillBeAdded(self, childResource: Resource, originator: str) -> None: + super().childWillBeAdded(childResource, originator) + if childResource.ty == ResourceTypes.SCH: + if CSE.dispatcher.directChildResources(self.ri, ResourceTypes.SCH): + raise BAD_REQUEST('Only one resource is allowed for the CSEBase') + def getCSE() -> CSEBase: # Actual: CSEBase Resource """ Return the resource. diff --git a/acme/resources/SCH.py b/acme/resources/SCH.py index 888d8d56..6249a0f7 100644 --- a/acme/resources/SCH.py +++ b/acme/resources/SCH.py @@ -15,6 +15,7 @@ from ..etc.Constants import Constants as C from ..etc.Types import AttributePolicyDict, ResourceTypes, JSON from ..services.Logging import Logging as L +from ..services import CSE from ..resources.Resource import Resource from ..etc.ResponseStatusCodes import CONTENTS_UNACCEPTABLE, NOT_IMPLEMENTED from ..resources.AnnounceableResource import AnnounceableResource @@ -73,6 +74,9 @@ def activate(self, parentResource:Resource, originator:str) -> None: if _nco is not None and _nco == True and not C.networkCoordinationSupported: raise NOT_IMPLEMENTED (L.logWarn(f'Network Coordinated Operation is not supported by this CSE')) + # Add the schedule to the schedules DB + CSE.storage.upsertSchedule(self) + # TODO When is supported # c)The request shall be rejected with the "OPERATION_NOT_ALLOWED" Response Status Code if the target resource # is a resource that has a campaignEnabled attribute with a value of true. @@ -97,8 +101,20 @@ def update(self, dct: JSON = None, originator: str | None = None, doValidateAttr # if thetarget resource is a resource that has a campaignEnabled attribute with a value of true. super().update(dct, originator, doValidateAttributes) + + # Update the schedule in the schedules DB + CSE.storage.upsertSchedule(self) + def validate(self, originator: str | None = None, dct: JSON | None = None, parentResource: Resource | None = None) -> None: + super().validate(originator, dct, parentResource) + + # Set the active schedule in the CSE when updated + if parentResource.ty == ResourceTypes.CSEBase: + CSE.cseActiveSchedule = self.getFinalResourceAttribute('se/sce', dct) + L.isDebug and L.logDebug(f'Setting active schedule in CSE to {CSE.cseActiveSchedule}') + + def deactivate(self, originator: str) -> None: # TODO When is supported @@ -107,5 +123,6 @@ def deactivate(self, originator: str) -> None: super().deactivate(originator) + # Remove the schedule from the schedules DB + CSE.storage.removeSchedule(self) -# TODO coninue \ No newline at end of file diff --git a/acme/resources/SUB.py b/acme/resources/SUB.py index 22f47ef8..e648f8d2 100644 --- a/acme/resources/SUB.py +++ b/acme/resources/SUB.py @@ -267,6 +267,15 @@ def validate(self, originator:Optional[str] = None, self._normalizeURIAttribute('su') + def childWillBeAdded(self, childResource: Resource, originator: str) -> None: + super().childWillBeAdded(childResource, originator) + if childResource.ty == ResourceTypes.SCH: + if (rn := childResource._originalDict.get('rn')) is None: + childResource.setResourceName('notificationSchedule') + elif rn != 'notificationSchedule': + raise BAD_REQUEST(L.logDebug(f'rn of under must be "notificationSchedule"')) + + def _checkAllowedCHTY(self, parentResource:Resource, chty:list[ResourceTypes]) -> None: """ Check whether an observed child resource types are actually allowed by the parent. diff --git a/acme/services/CSE.py b/acme/services/CSE.py index ab3b7931..625d1f7b 100644 --- a/acme/services/CSE.py +++ b/acme/services/CSE.py @@ -169,6 +169,9 @@ cseStatus:CSEStatus = CSEStatus.STOPPED """ The CSE's internal runtime status. """ +cseActiveSchedule:list[str] = [] +""" List of active schedules when the CSE is active and will process requests. """ + _cseResetLock = Lock() # lock for resetting the CSE """ Internal CSE's lock when resetting. """ diff --git a/acme/services/Dispatcher.py b/acme/services/Dispatcher.py index 122c2778..2b54128e 100644 --- a/acme/services/Dispatcher.py +++ b/acme/services/Dispatcher.py @@ -23,10 +23,12 @@ from ..etc.ResponseStatusCodes import ORIGINATOR_HAS_NO_PRIVILEGE, NOT_FOUND, BAD_REQUEST from ..etc.ResponseStatusCodes import REQUEST_TIMEOUT, OPERATION_NOT_ALLOWED, TARGET_NOT_SUBSCRIBABLE, INVALID_CHILD_RESOURCE_TYPE from ..etc.ResponseStatusCodes import INTERNAL_SERVER_ERROR, SECURITY_ASSOCIATION_REQUIRED, CONFLICT +from ..etc.ResponseStatusCodes import TARGET_NOT_REACHABLE from ..etc.Utils import localResourceID, isSPRelative, isStructured, resourceModifiedAttributes, filterAttributes, riFromID from ..etc.Utils import srnFromHybrid, uniqueRI, noNamespace, riFromStructuredPath, csiFromSPRelative, toSPRelative, structuredPathFromRI from ..helpers.TextTools import findXPath from ..etc.DateUtils import waitFor, timeUntilTimestamp, timeUntilAbsRelTimestamp, getResourceDate +from ..etc.DateUtils import cronMatchesTimestamp from ..services import CSE from ..services.Configuration import Configuration from ..resources.Factory import resourceFromDict @@ -113,8 +115,9 @@ def processRetrieveRequest(self, request:CSERequest, raise BAD_REQUEST(L.logWarn(f'Only "m2m:atrl" is allowed in Content for RETRIEVE.')) CSE.validator.validateAttribute('atrl', attributeList) - # Handle operation execution time and check request expiration + # Handle operation execution time , and check CSE schedule and request expiration self._handleOperationExecutionTime(request) + self._checkActiveCSESchedule() self._checkRequestExpiration(request) # handle fanout point requests @@ -562,8 +565,9 @@ def processCreateRequest(self, request:CSERequest, # return Result.errorResult(rsc = RC.notFound, dbg = L.logDebug('resource not found')) raise NOT_FOUND(L.logDebug('resource not found')) - # Handle operation execution time and check request expiration + # Handle operation execution time, and check CSE schedule and request expiration self._handleOperationExecutionTime(request) + self._checkActiveCSESchedule() self._checkRequestExpiration(request) # handle fanout point requests @@ -792,8 +796,9 @@ def processUpdateRequest(self, request:CSERequest, if not id and not fopsrn: raise NOT_FOUND(L.logDebug('resource not found')) - # Handle operation execution time and check request expiration + # Handle operation execution time , and check CSE schedule and request expiration self._handleOperationExecutionTime(request) + self._checkActiveCSESchedule() self._checkRequestExpiration(request) # handle fanout point requests @@ -957,8 +962,9 @@ def processDeleteRequest(self, request:CSERequest, if not id and not fopsrn: raise NOT_FOUND(L.logDebug('resource not found')) - # Handle operation execution time and check request expiration + # Handle operation execution time , and check CSE schedule and request expiration self._handleOperationExecutionTime(request) + self._checkActiveCSESchedule() self._checkRequestExpiration(request) # handle fanout point requests @@ -1118,8 +1124,9 @@ def processNotifyRequest(self, request:CSERequest, srn, id = self._checkHybridID(request, id) # overwrite id if another is given - # Handle operation execution time and check request expiration + # Handle operation execution time, and check CSE schedule and request expiration self._handleOperationExecutionTime(request) + self._checkActiveCSESchedule() self._checkRequestExpiration(request) # get resource to be notified and check permissions @@ -1380,6 +1387,20 @@ def _checkRequestExpiration(self, request:CSERequest) -> None: raise REQUEST_TIMEOUT(L.logDebug('request timed out')) + def _checkActiveCSESchedule(self) -> None: + """ Check if the CSE is currently active according to its schedule. + + Raises: + `TARGET_NOT_REACHABLE`: In case the CSE is not active. + """ + if CSE.cseActiveSchedule: + for s in CSE.cseActiveSchedule: + if cronMatchesTimestamp(s): + return + # TODO not sure if this is the right error code + raise TARGET_NOT_REACHABLE(L.logDebug('request exection time outside of CSE\'s allowed schedule')) + + ######################################################################### # diff --git a/acme/services/NotificationManager.py b/acme/services/NotificationManager.py index c53fc9e7..e79463c9 100644 --- a/acme/services/NotificationManager.py +++ b/acme/services/NotificationManager.py @@ -22,8 +22,8 @@ from ..etc.ResponseStatusCodes import ResponseStatusCode, ResponseException, exceptionFromRSC from ..etc.ResponseStatusCodes import INTERNAL_SERVER_ERROR, SUBSCRIPTION_VERIFICATION_INITIATION_FAILED from ..etc.ResponseStatusCodes import TARGET_NOT_REACHABLE, REMOTE_ENTITY_NOT_REACHABLE, OPERATION_NOT_ALLOWED -from ..etc.ResponseStatusCodes import OPERATION_DENIED_BY_REMOTE_ENTITY -from ..etc.DateUtils import fromDuration, getResourceDate +from ..etc.ResponseStatusCodes import OPERATION_DENIED_BY_REMOTE_ENTITY, NOT_FOUND +from ..etc.DateUtils import fromDuration, getResourceDate, cronMatchesTimestamp, utcDatetime from ..etc.Utils import toSPRelative, pureResource, isAcmeUrl, compareIDs from ..helpers.TextTools import setXPath, findXPath from ..services import CSE @@ -179,8 +179,11 @@ def removeSubscription(self, subscription:SUB|CRS, originator:str) -> None: self.sendDeletionNotification([ nu for nu in acrs ], subscription.ri) # Finally remove subscriptions from storage - if not CSE.storage.removeSubscription(subscription): - raise INTERNAL_SERVER_ERROR('cannot remove subscription from database') + try: + if not CSE.storage.removeSubscription(subscription): + raise INTERNAL_SERVER_ERROR('cannot remove subscription from database') + except NOT_FOUND: + pass # ignore, could be expected def updateSubscription(self, subscription:SUB, previousNus:list[str], originator:str) -> None: @@ -276,19 +279,29 @@ def checkSubscriptions( self, # TODO ensure uniqueness subs.append(sub) - - - # TODO: Add access control check here. Perhaps then the special subscription - # DB data structure should go away and be replaced by the normal subscriptions - - for sub in subs: # Prevent own notifications for subscriptions ri = sub['ri'] + + # Check the subscription's schedule, but only if it is not an immediate notification + if not ((nec := sub['nec']) and nec == EventCategory.Immediate): + if (_sc := CSE.storage.searchScheduleForTarget(ri)): + _ts = utcDatetime() + + # Check whether the current time matches the schedule + for s in _sc: + if cronMatchesTimestamp(s, _ts): + break + else: + # No schedule matches the current time, so continue with the next subscription + continue + + # Check whether reason is included in the subscription if childResource and \ ri == childResource.ri and \ reason in [ NotificationEventType.createDirectChild, NotificationEventType.deleteDirectChild ]: continue + if reason not in sub['net']: # check whether reason is actually included in the subscription continue if reason in [ NotificationEventType.createDirectChild, NotificationEventType.deleteDirectChild ]: # reasons for child resources @@ -604,6 +617,20 @@ def _crsCheckForNotification(self, data:list[str], L.isDebug and L.logDebug(f'Received sufficient notifications - sending notification') + # Check the crossResourceSubscription's schedule, if there is one + if (_sc := CSE.storage.searchScheduleForTarget(crsRi)): + _ts = utcDatetime() + + # Check whether the current time matches any schedule + for s in _sc: + if cronMatchesTimestamp(s, _ts): + break + else: + # No schedule matches the current time, so clear the data and just return + L.isDebug and L.logDebug(f'No matching schedule found for : {crsRi}') + data.clear() + return + try: resource = CSE.dispatcher.retrieveResource(crsRi) except ResponseException as e: @@ -986,7 +1013,7 @@ def _verifyNusInSubscription(self, subscription:SUB|CRS, def sendVerificationRequest(self, uri:Union[str, list[str]], ri:str, originator:Optional[str] = None) -> bool: - """" Define the callback function for verification notifications and send + """ Define the callback function for verification notifications and send the notification. Args: diff --git a/acme/services/Storage.py b/acme/services/Storage.py index 4d916a5d..09a90003 100644 --- a/acme/services/Storage.py +++ b/acme/services/Storage.py @@ -32,6 +32,7 @@ from ..services import CSE from ..resources.Resource import Resource from ..resources.ACTR import ACTR +from ..resources.SCH import SCH from ..resources.Factory import resourceFromDict from ..services.Logging import Logging as L @@ -46,6 +47,7 @@ _statistics = 'statistics' _actions = 'actions' _requests = 'requests' +_schedules = 'schedules' class Storage(object): @@ -153,6 +155,9 @@ def _validateDB(self) -> bool: self.getStatistics() dbFile = _actions self.getActions() + dbFile = _schedules + self.getSchedules() + # TODO requests except Exception as e: @@ -313,9 +318,8 @@ def deleteResource(self, resource:Resource) -> None: self.db.deleteResource(resource) self.db.deleteIdentifier(resource) self.db.removeChildResource(resource) - except KeyError as e: - L.isDebug and L.logDebug(f'Cannot remove: {resource.ri} (NOT_FOUND). Could be an expected error.') - raise NOT_FOUND(dbg = str(e)) + except KeyError: + raise NOT_FOUND(dbg = L.logDebug(f'Cannot remove: {resource.ri} (NOT_FOUND). Could be an expected error.')) def directChildResources(self, pi:str, @@ -444,7 +448,10 @@ def addSubscription(self, subscription:Resource) -> bool: def removeSubscription(self, subscription:Resource) -> bool: # L.logDebug(f'Removing subscription: {subscription.ri}') - return self.db.removeSubscription(subscription) + try: + return self.db.removeSubscription(subscription) + except KeyError as e: + raise NOT_FOUND(dbg = L.logDebug(f'Cannot subscription data for: {subscription.ri} (NOT_FOUND). Could be an expected error.')) def updateSubscription(self, subscription:Resource) -> bool: @@ -452,6 +459,11 @@ def updateSubscription(self, subscription:Resource) -> bool: return self.db.upsertSubscription(subscription) + def updateSubscriptionSchedule(self, subscription:Resource, schedule:list[str]) -> bool: + # L.logDebug(f'Updating subscription schedule: {ri} - {schedule}') + return self.db.updateSubscriptionSchedule(subscription, schedule) + + ######################################################################### ## ## BatchNotifications @@ -586,6 +598,57 @@ def deleteRequests(self, ri:Optional[str] = None) -> None: return self.db.deleteRequests(ri) + ######################################################################### + ## + ## Schedules + ## + + def getSchedules(self) -> list[Document]: + """ Retrieve the schedules data from the DB. + + Return: + List of *Documents*. May be empty. + """ + return self.db.getSchedules() + + + def searchScheduleForTarget(self, pi:str) -> list[str]: + """ Search for schedules for a target resource. + + Args: + pi: The target resource's resource ID. + + Return: + List of schedule resource IDs. + """ + result = [] + for s in self.db.searchSchedules(pi): + result.extend(s['sce']) + return result + + + def upsertSchedule(self, schedule:SCH) -> bool: + """ Add or update a schedule in the DB. + + Args: + schedule: The schedule to add or update. + + Return: + Boolean value to indicate success or failure. + """ + return self.db.upsertSchedule(schedule.ri, schedule.pi, schedule.attribute('se/sce')) + + + def removeSchedule(self, schedule:SCH) -> bool: + """ Remove a schedule from the DB. + + Args: + schedule: The schedule to remove. + + Return: + Boolean value to indicate success or failure. + """ + return self.db.removeSchedule(schedule.ri) ######################################################################### # @@ -611,6 +674,7 @@ class TinyDBBinding(object): 'lockStatistics', 'lockActions', 'lockRequests', + 'lockSchedules', 'fileResources', 'fileIdentifiers', @@ -619,6 +683,7 @@ class TinyDBBinding(object): 'fileStatistics', 'fileActions', 'fileRequests', + 'fileSchedules', 'dbResources', 'dbIdentifiers', @@ -627,6 +692,7 @@ class TinyDBBinding(object): 'dbStatistics', 'dbActions', 'dbRequests', + 'dbSchedules', 'tabResources', 'tabIdentifiers', @@ -637,6 +703,7 @@ class TinyDBBinding(object): 'tabStatistics', 'tabActions', 'tabRequests', + 'tabSchedules', 'resourceQuery', 'identifierQuery', @@ -644,6 +711,7 @@ class TinyDBBinding(object): 'batchNotificationQuery', 'actionsQuery', 'requestsQuery', + 'schedulesQuery', ) def __init__(self, path:str, postfix:str) -> None: @@ -661,6 +729,7 @@ def __init__(self, path:str, postfix:str) -> None: self.lockStatistics = Lock() self.lockActions = Lock() self.lockRequests = Lock() + self.lockSchedules = Lock() # file names self.fileResources = f'{self.path}/{_resources}-{postfix}.json' @@ -670,6 +739,7 @@ def __init__(self, path:str, postfix:str) -> None: self.fileStatistics = f'{self.path}/{_statistics}-{postfix}.json' self.fileActions = f'{self.path}/{_actions}-{postfix}.json' self.fileRequests = f'{self.path}/{_requests}-{postfix}.json' + self.fileSchedules = f'{self.path}/{_schedules}-{postfix}.json' # All databases/tables will use the smart query cache if Configuration.get('database.inMemory'): @@ -681,6 +751,7 @@ def __init__(self, path:str, postfix:str) -> None: self.dbStatistics = TinyDB(storage = MemoryStorage) self.dbActions = TinyDB(storage = MemoryStorage) self.dbRequests = TinyDB(storage = MemoryStorage) + self.dbSchedules = TinyDB(storage = MemoryStorage) else: L.isInfo and L.log('DB in file system') # self.dbResources = TinyDB(self.fileResources) @@ -698,6 +769,7 @@ def __init__(self, path:str, postfix:str) -> None: self.dbStatistics = TinyDB(self.fileStatistics, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) self.dbActions = TinyDB(self.fileActions, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) self.dbRequests = TinyDB(self.fileRequests, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) + self.dbSchedules = TinyDB(self.fileSchedules, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) # Open/Create tables @@ -728,6 +800,10 @@ def __init__(self, path:str, postfix:str) -> None: self.tabRequests = self.dbRequests.table(_requests, cache_size = self.cacheSize) TinyDBBetterTable.assign(self.tabRequests) + self.tabSchedules = self.dbSchedules.table(_schedules, cache_size = self.cacheSize) + TinyDBBetterTable.assign(self.tabSchedules) + + # Create the Queries self.resourceQuery = Query() @@ -736,6 +812,7 @@ def __init__(self, path:str, postfix:str) -> None: self.batchNotificationQuery = Query() self.actionsQuery = Query() self.requestsQuery = Query() + self.schedulesQuery = Query() def _assignConfig(self) -> None: @@ -762,6 +839,8 @@ def closeDB(self) -> None: self.dbActions.close() with self.lockRequests: self.dbRequests.close() + with self.lockSchedules: + self.dbSchedules.close() def purgeDB(self) -> None: @@ -775,6 +854,7 @@ def purgeDB(self) -> None: self.tabStatistics.truncate() self.tabActions.truncate() self.tabRequests.truncate() + self.tabSchedules.truncate() def backupDB(self, dir:str) -> bool: @@ -784,7 +864,9 @@ def backupDB(self, dir:str) -> bool: self.fileBatchNotifications, self.fileStatistics, self.fileActions, - self.fileRequests]: + self.fileRequests, + self.fileSchedules + ]: if Path(fn).is_file(): shutil.copy2(fn, dir) return True @@ -1048,6 +1130,7 @@ def upsertSubscription(self, subscription:Resource) -> bool: 'nus' : subscription.nu, 'bn' : subscription.bn, 'cr' : subscription.cr, + 'nec' : subscription.nec, 'org' : subscription.getOriginator(), 'ma' : fromDuration(subscription.ma) if subscription.ma else None, # EXPERIMENTAL ma = maxAge 'nse' : subscription.nse @@ -1055,6 +1138,11 @@ def upsertSubscription(self, subscription:Resource) -> bool: # self.subscriptionQuery.ri == ri) is not None + def updateSubscriptionSchedule(self, subscription:Resource, schedule:list[str]) -> bool: + with self.lockSubscriptions: + return self.tabSubscriptions.update({'sce' : schedule}, doc_ids = [subscription.ri]) == 1 + + def removeSubscription(self, subscription:Resource) -> bool: with self.lockSubscriptions: return len(self.tabSubscriptions.remove(doc_ids = [subscription.ri])) > 0 @@ -1270,4 +1358,75 @@ def deleteRequests(self, ri:Optional[str] = None) -> None: self.tabRequests.remove(self.requestsQuery.ri == ri) else: with self.lockRequests: - self.tabRequests.truncate() \ No newline at end of file + self.tabRequests.truncate() + + # + # Schedules + # + + def getSchedules(self) -> list[Document]: + """ Get all schedules from the database. + + Return: + List of *Documents*. May be empty. + """ + with self.lockSchedules: + return self.tabSchedules.all() + + + def getSchedule(self, ri:str) -> Optional[Document]: + """ Get a schedule from the database. + + Args: + ri: The resource ID of the schedule. + + Return: + The schedule, or *None* if not found. + """ + with self.lockSchedules: + return self.tabSchedules.get(doc_id = ri) # type:ignore[arg-type] + + + def searchSchedules(self, pi:str) -> list[Document]: + """ Search for schedules in the database. + + Args: + pi: The resource ID of the parent resource. + + Return: + List of *Documents*. May be empty. + """ + with self.lockSchedules: + return self.tabSchedules.search(self.schedulesQuery.pi == pi) + + + def upsertSchedule(self, ri:str, pi:str, schedule:list[str]) -> bool: + """ Add or update a schedule in the database. + + Args: + ri: The resource ID of the schedule. + pi: The resource ID of the schedule's parent resource. + schedule: The schedule to store. + + Return: + True if the schedule was added or updated, False otherwise. + """ + with self.lockSchedules: + return self.tabSchedules.upsert(Document( + { 'ri': ri, + 'pi': pi, + 'sce': schedule }, + ri)) is not None # type:ignore[arg-type] + + + def removeSchedule(self, ri:str) -> bool: + """ Remove a schedule from the database. + + Args: + ri: The resource ID of the schedule to remove. + + Return: + True if the schedule was removed, False otherwise. + """ + with self.lockSchedules: + return len(self.tabSchedules.remove(doc_ids = [ri])) > 0 # type:ignore[arg-type, list-item] \ No newline at end of file diff --git a/docs/ACMEScript-metatags.md b/docs/ACMEScript-metatags.md index 41c2e7ba..44b35ddb 100644 --- a/docs/ACMEScript-metatags.md +++ b/docs/ACMEScript-metatags.md @@ -54,9 +54,9 @@ They can be accessed like any other environment variable, for example: The `@at` meta tag specifies a time / date pattern when a script should be executed. This pattern follows the Unix [crontab](https://crontab.guru/crontab.5.html) pattern. -A crontab pattern consists of the following five fields: +A crontab pattern consists of the following six fields: -`minute hour dayOfMonth month dayOfWeek` +`second minute hour dayOfMonth month dayOfWeek year` Each field is mandatory and must comply to the following values: @@ -68,9 +68,9 @@ Each field is mandatory and must comply to the following values: Example: ```lisp ;; Run a script every 5 minutes -@at */5 * * * * +@at 0 */5 * * * * * ;; Run a script every Friday at 2:30 am -@at 30 2 * * 4 +@at 0 30 2 * * 4 * ``` [top](#top) diff --git a/docs/Supported.md b/docs/Supported.md index 089e8ab6..035fbff8 100644 --- a/docs/Supported.md +++ b/docs/Supported.md @@ -46,8 +46,9 @@ The ACME CSE supports the following oneM2M resource types: | Polling Channel (PCH) | ✓ | Support for Request and Notification long-polling via the *pcu* (pollingChannelURI) virtual resource. *requestAggregation* functionality is supported, too. | | Remote CSE (CSR) | ✓ | Announced resources are supported. Transit request to resources on registered CSE's are supported. | | Request (REQ) | ✓ | Support for non-blocking requests. | +| Schedule (SCH) | ✓ | Support for CSE communication, nodes, subscriptions and crossResourceSubscriptions. | | SemanticDescriptor (SMD) | ✓ | Support for basic resource handling and semantic queries. | -| Subscription (SUB) | ✓ | Notifications via http(s) (direct url or an AE's Point-of-Access (POA)). BatchNotifications, attributes. Not all features are supported yet. | +| Subscription (SUB) | ✓ | Notifications via http(s) (direct url or an AE's Point-of-Access (POA)). BatchNotifications, attributes, statistics. Not all features are supported yet. | | TimeSeries (TS) | ✓ | Including missing data notifications. | | TimeSeriesInstance (TSI) | ✓ | *dataGenerationTime* attribute only supports absolute timestamps. | | TimeSyncBeacon (TSB) | ✓ | Experimental. Implemented functionality might change according to specification changes. | diff --git a/tests/testSCH.py b/tests/testSCH.py index 0bf5e00a..d890bbc4 100644 --- a/tests/testSCH.py +++ b/tests/testSCH.py @@ -11,12 +11,19 @@ if '..' not in sys.path: sys.path.append('..') from typing import Tuple -from acme.etc.Types import ResourceTypes as T, ResponseStatusCode as RC, Permission -from acme.etc.Types import EvalMode, Operation, EvalCriteriaOperator +from acme.etc.Types import ResourceTypes as T, ResponseStatusCode as RC, TimeWindowType +from acme.etc.Types import NotificationEventType, NotificationEventType as NET from init import * +from datetime import timedelta nodeID = 'urn:sn:1234' +def createScheduleString(range:int, delay:int = 0) -> str: + """ Create schedule string for range seconds """ + dts = datetime.now(tz = timezone.utc) + timedelta(seconds = delay) + dte = dts + timedelta(seconds = range) + return f'{dts.second}-{dte.second} {dts.minute}-{dte.minute} {dts.hour}-{dte.hour} * * * *' + class TestSCH(unittest.TestCase): @@ -25,6 +32,8 @@ class TestSCH(unittest.TestCase): ae2 = None nod = None nodRI = None + crs = None + crsRI = None originator = None @@ -33,6 +42,11 @@ class TestSCH(unittest.TestCase): @unittest.skipIf(noCSE, 'No CSEBase') def setUpClass(cls) -> None: testCaseStart('Setup TestSCH') + + # Start notification server + startNotificationServer() + + dct = { 'm2m:ae' : { 'rn' : aeRN, 'api' : APPID, @@ -53,6 +67,27 @@ def setUpClass(cls) -> None: assert rsc == RC.CREATED cls.nodRI = findXPath(cls.nod, 'm2m:nod/ri') + dct = { 'm2m:crs' : { + 'rn' : crsRN, + 'nu' : [ NOTIFICATIONSERVER ], + 'twt' : TimeWindowType.PERIODICWINDOW, + 'tws' : f'PT{crsTimeWindowSize}S', + 'rrat' : [ cls.nodRI ], + 'encs' : { + 'enc' : [ + { + 'net': [ NotificationEventType.createDirectChild ], + } + ] + } + + + }} + cls.nod, rsc = CREATE(cseURL, ORIGINATOR, T.CRS, dct) + assert rsc == RC.CREATED + cls.crsRI = findXPath(cls.nod, 'm2m:crs/ri') + + testCaseEnd('Setup TestSCH') @@ -60,10 +95,12 @@ def setUpClass(cls) -> None: @unittest.skipIf(noCSE, 'No CSEBase') def tearDownClass(cls) -> None: if not isTearDownEnabled(): + stopNotificationServer() return testCaseStart('TearDown TestSCH') DELETE(aeURL, ORIGINATOR) # Just delete the AE and everything below it. Ignore whether it exists or not DELETE(nodURL, ORIGINATOR) # Just delete the NOD and everything below it. Ignore whether it exists or not + DELETE(f'{cseURL}/{crsRN}', ORIGINATOR) DELETE(f'{cseURL}/{schRN}', ORIGINATOR) testCaseEnd('TearDown TestSCH') @@ -147,7 +184,7 @@ def test_updateSCHunderCBwithNCOFail(self) -> None: @unittest.skipIf(noCSE, 'No CSEBase') def test_updateSCHunderNODwithNOCUnsupportedFail(self) -> None: - """ CREATE with nco under NOD (unsupported-> Fail""" + """ CREATE with nco under NOD (unsupported) -> Fail""" self.assertIsNotNone(TestSCH.ae) dct:JSON = { 'm2m:sch' : { 'rn' : schRN, @@ -168,6 +205,399 @@ def test_updateSCHunderNODwithNOCUnsupportedFail(self) -> None: self.assertEqual(rsc, RC.DELETED, r) + # + # Testing CREATE with different parent types + # + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderSUBwrongRn(self) -> None: + """ CREATE with wrong rn under -> Fail""" + # create + dct:JSON = { 'm2m:sub' : { + 'rn' : f'{subRN}', + 'enc': { + 'net': [ NotificationEventType.resourceUpdate ] + }, + 'nu': [ NOTIFICATIONSERVER ] + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SUB, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # create with wrong rn + dct = { 'm2m:sch' : { + 'rn' : 'wrong', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(f'{cseURL}/{subRN}', ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + # DELETE SUB again + r, rsc = DELETE(f'{cseURL}/{subRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderSUBemptyRn(self) -> None: + """ CREATE with empty rn under """ + # create + dct:JSON = { 'm2m:sub' : { + 'rn' : f'{subRN}', + 'enc': { + 'net': [ NotificationEventType.resourceUpdate ] + }, + 'nu': [ NOTIFICATIONSERVER ] + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SUB, dct) + self.assertEqual(rsc, RC.CREATED, r) + + dct = { 'm2m:sch' : { + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(f'{cseURL}/{subRN}', ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/{subRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderSUBcorrectRn(self) -> None: + """ CREATE with correct rn under """ + # create + dct:JSON = { 'm2m:sub' : { + 'rn' : f'{subRN}', + 'enc': { + 'net': [ NotificationEventType.resourceUpdate ] + }, + 'nu': [ NOTIFICATIONSERVER ] + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SUB, dct) + self.assertEqual(rsc, RC.CREATED, r) + + dct = { 'm2m:sch' : { + 'rn' : 'notificationSchedule', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(f'{cseURL}/{subRN}', ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/{subRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderCRSwrongRn(self) -> None: + """ CREATE with wrong rn under -> Fail""" + dct:JSON = { 'm2m:sch' : { + 'rn' : 'wrong', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(f'{cseURL}/{crsRN}', ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderCRSemptyRn(self) -> None: + """ CREATE with empty rn under """ + dct:JSON = { 'm2m:sch' : { + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(f'{cseURL}/{crsRN}', ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/{crsRN}/notificationSchedule', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderCRScorrectRn(self) -> None: + """ CREATE with correct rn under """ + dct:JSON = { 'm2m:sch' : { + 'rn' : 'notificationSchedule', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(f'{cseURL}/{crsRN}', ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/{crsRN}/notificationSchedule', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderCB(self) -> None: + """ CREATE under CB""" + dct:JSON = { 'm2m:sch' : { + 'rn' : 'schedule', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/schedule', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderCBTwiceFail(self) -> None: + """ CREATE under CB twice -> Fail""" + dct:JSON = { 'm2m:sch' : { + 'rn' : 'schedule', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # second create + dct = { 'm2m:sch' : { + 'rn' : 'schedule2', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/schedule', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderNOD(self) -> None: + """ CREATE under """ + dct:JSON = { 'm2m:sch' : { + 'rn' : 'schedule', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(nodURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # DELETE again + r, rsc = DELETE(f'{nodURL}/schedule', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_testSCHunderSUBinsideSchedule(self) -> None: + """ CREATE under and receive notification within schedule """ + # create + dct:JSON = { 'm2m:sub' : { + 'rn' : f'{subRN}', + 'enc': { + 'net': [ NotificationEventType.resourceUpdate ] + }, + 'nu': [ NOTIFICATIONSERVER ] + }} + r, rsc = CREATE(aeURL, TestSCH.originator, T.SUB, dct) + self.assertEqual(rsc, RC.CREATED, r) + + dct = { 'm2m:sch' : { + 'rn' : 'notificationSchedule', + 'se': { 'sce': [ createScheduleString(requestCheckDelay * 2) ] } + }} + r, rsc = CREATE(f'{aeURL}/{subRN}', TestSCH.originator, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Update the AE to trigger a notification immediately + clearLastNotification() + dct = { 'm2m:ae' : { + 'lbl' : ['test'] + }} + r, rsc = UPDATE(aeURL, TestSCH.originator, dct) + self.assertEqual(rsc, RC.UPDATED, r) + + # Check notification + testSleep(requestCheckDelay) + notification = getLastNotification() + self.assertIsNotNone(notification) # notification received + + # DELETE again + r, rsc = DELETE(f'{aeURL}/{subRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_testSCHunderSUBoutsideSchedule(self) -> None: + """ CREATE under and receive notification outside schedule """ + # create + dct:JSON = { 'm2m:sub' : { + 'rn' : f'{subRN}', + 'enc': { + 'net': [ NotificationEventType.resourceUpdate ] + }, + 'nu': [ NOTIFICATIONSERVER ] + }} + r, rsc = CREATE(aeURL, TestSCH.originator, T.SUB, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # add schedule + dct = { 'm2m:sch' : { + 'rn' : 'notificationSchedule', + 'se': { 'sce': [ createScheduleString(requestCheckDelay * 2, requestCheckDelay * 2) ] } + }} + r, rsc = CREATE(f'{aeURL}/{subRN}', TestSCH.originator, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Update the AE to trigger a notification immediately + clearLastNotification() + dct = { 'm2m:ae' : { + 'lbl' : ['test'] + }} + r, rsc = UPDATE(aeURL, TestSCH.originator, dct) + self.assertEqual(rsc, RC.UPDATED, r) + + # Check notification + testSleep(requestCheckDelay) # wait a short time but run before the schedule starts + notification = getLastNotification() + self.assertIsNone(notification) # notification received + + # DELETE again + r, rsc = DELETE(f'{aeURL}/{subRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_testSCHunderSUBoutsideScheduleImmediate(self) -> None: + """ CREATE under and receive notification outside schedule, nec = immediate """ + # create + dct:JSON = { 'm2m:sub' : { + 'rn' : f'{subRN}', + 'enc': { + 'net': [ NotificationEventType.resourceUpdate ], + }, + 'nec': 2, # immediate notification + 'nu': [ NOTIFICATIONSERVER ] + }} + r, rsc = CREATE(aeURL, TestSCH.originator, T.SUB, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Add schedule + dct = { 'm2m:sch' : { + 'rn' : 'notificationSchedule', + 'se': { 'sce': [ createScheduleString(requestCheckDelay * 2, requestCheckDelay * 2) ] } + }} + r, rsc = CREATE(f'{aeURL}/{subRN}', TestSCH.originator, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Update the AE to trigger a notification immediately + clearLastNotification() + dct = { 'm2m:ae' : { + 'lbl' : ['test'] + }} + r, rsc = UPDATE(aeURL, TestSCH.originator, dct) + self.assertEqual(rsc, RC.UPDATED, r) + + # Check notification + testSleep(requestCheckDelay) # wait a short time but run before the schedule starts + notification = getLastNotification() + self.assertIsNotNone(notification) # notification received + + # DELETE again + r, rsc = DELETE(f'{aeURL}/{subRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + # + # Testing crossResourceSubscription with schedule + # + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_testSCHunderCRSinsideSchedule(self) -> None: + """ CREATE under and receive notification within schedule """ + # create + dct = { 'm2m:crs' : { + 'rn' : crsRN, + 'nu' : [ NOTIFICATIONSERVER ], + 'twt': 1, + 'eem': 1, # all events present + 'tws' : f'PT{requestCheckDelay}S', + 'rrat': [ self.aeRI], + 'encs': { + 'enc' : [ + { + 'net': [ NET.resourceUpdate ], + } + ] + } + }} + r, rsc = CREATE(aeURL, TestSCH.originator, T.CRS, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Add schedule + dct = { 'm2m:sch' : { + 'rn' : 'notificationSchedule', + 'se': { 'sce': [ createScheduleString(requestCheckDelay * 2) ] } + }} + r, rsc = CREATE(f'{aeURL}/{crsRN}', TestSCH.originator, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # # Update the AE to trigger a notification immediately + clearLastNotification() + dct = { 'm2m:ae' : { + 'lbl' : ['test'] + }} + r, rsc = UPDATE(aeURL, TestSCH.originator, dct) + self.assertEqual(rsc, RC.UPDATED, r) + + # # Check notification + testSleep(requestCheckDelay * 2) + notification = getLastNotification() + self.assertIsNotNone(notification) # notification received + + # DELETE again + r, rsc = DELETE(f'{aeURL}/{crsRN}', TestSCH.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_testSCHunderCRSoutsideScheduleFail(self) -> None: + """ CREATE under and receive notification outside schedule -> Fail """ + # create + dct = { 'm2m:crs' : { + 'rn' : crsRN, + 'nu' : [ NOTIFICATIONSERVER ], + 'twt': 1, + 'eem': 1, # all events present + 'tws' : f'PT{requestCheckDelay}S', + 'rrat': [ self.aeRI], + 'encs': { + 'enc' : [ + { + 'net': [ NET.resourceUpdate ], + } + ] + } + }} + r, rsc = CREATE(aeURL, TestSCH.originator, T.CRS, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Add schedule + dct = { 'm2m:sch' : { + 'rn' : 'notificationSchedule', + 'se': { 'sce': [ createScheduleString(requestCheckDelay * 2, requestCheckDelay * 4) ] } # outside time window + }} + r, rsc = CREATE(f'{aeURL}/{crsRN}', TestSCH.originator, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # # Update the AE to trigger a notification immediately, but outside schedule + clearLastNotification() + dct = { 'm2m:ae' : { + 'lbl' : ['test'] + }} + r, rsc = UPDATE(aeURL, TestSCH.originator, dct) + self.assertEqual(rsc, RC.UPDATED, r) + + # # Check notification + testSleep(requestCheckDelay * 2) + notification = getLastNotification() + self.assertIsNone(notification) # NO notification received + + # DELETE again + r, rsc = DELETE(f'{aeURL}/{crsRN}', TestSCH.originator) + self.assertEqual(rsc, RC.DELETED, r) def run(testFailFast:bool) -> Tuple[int, int, int, float]: @@ -180,9 +610,27 @@ def run(testFailFast:bool) -> Tuple[int, int, int, float]: addTest(suite, TestSCH('test_updateSCHunderCBwithNCOFail')) addTest(suite, TestSCH('test_updateSCHunderNODwithNOCUnsupportedFail')) + # testing for specific parent types + addTest(suite, TestSCH('test_createSCHunderSUBwrongRn')) + addTest(suite, TestSCH('test_createSCHunderSUBemptyRn')) + addTest(suite, TestSCH('test_createSCHunderSUBcorrectRn')) + addTest(suite, TestSCH('test_createSCHunderCRSwrongRn')) + addTest(suite, TestSCH('test_createSCHunderCRSemptyRn')) + addTest(suite, TestSCH('test_createSCHunderCRScorrectRn')) + addTest(suite, TestSCH('test_createSCHunderCB')) + addTest(suite, TestSCH('test_createSCHunderCBTwiceFail')) + addTest(suite, TestSCH('test_createSCHunderNOD')) + + # testing subscriptions with schedule + addTest(suite, TestSCH('test_testSCHunderSUBinsideSchedule')) + addTest(suite, TestSCH('test_testSCHunderSUBoutsideSchedule')) + addTest(suite, TestSCH('test_testSCHunderSUBoutsideScheduleImmediate')) + + # testing crossResourceSubscription with schedule + addTest(suite, TestSCH('test_testSCHunderCRSinsideSchedule')) + addTest(suite, TestSCH('test_testSCHunderCRSoutsideScheduleFail')) result = unittest.TextTestRunner(verbosity = testVerbosity, failfast = testFailFast).run(suite) - printResult(result) return result.testsRun, len(result.errors + result.failures), len(result.skipped), getSleepTimeCount() diff --git a/tests/testSUB.py b/tests/testSUB.py index a5fddcb3..e40daa75 100644 --- a/tests/testSUB.py +++ b/tests/testSUB.py @@ -313,8 +313,8 @@ def test_deleteSUBByUnknownOriginator(self) -> None: @unittest.skipIf(noCSE, 'No CSEBase') def test_deleteSUBByAssignedOriginator(self) -> None: """ DELETE with correct originator -> Succeed. Send deletion notification. """ - _, rsc = DELETE(subURL, TestSUB.originator) - self.assertEqual(rsc, RC.DELETED) + r, rsc = DELETE(subURL, TestSUB.originator) + self.assertEqual(rsc, RC.DELETED, r) lastNotification = getLastNotification() # no delay! blocking self.assertTrue(findXPath(lastNotification, 'm2m:sgn/sud')) @@ -1596,8 +1596,8 @@ def test_createSUBnoNCTwrongNETFail(self) -> None: 'su': NOTIFICATIONSERVER, 'nse': True }} - r, rsc = CREATE(self.aePOAURL, TestSUB.originatorPoa, T.SUB, dct) - self.assertEqual(rsc, RC.BAD_REQUEST) + r, rsc = CREATE(aeURL, TestSUB.originator, T.SUB, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) # From f7f3e06ac392194c3555ed6f9b26ec9b3f78153b Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 13 Jul 2023 12:04:38 +0200 Subject: [PATCH 012/165] Reduced hight of intermediate headers to 1 line --- acme/textui/ACMEContainerDelete.py | 2 +- acme/textui/ACMEContainerRequests.py | 4 ++-- acme/textui/ACMEHeader.py | 5 ++--- acme/textui/ACMETuiApp.py | 19 ++++++++++++------- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/acme/textui/ACMEContainerDelete.py b/acme/textui/ACMEContainerDelete.py index 14e28304..7aa3a46d 100644 --- a/acme/textui/ACMEContainerDelete.py +++ b/acme/textui/ACMEContainerDelete.py @@ -49,7 +49,7 @@ class ACMEContainerDelete(Container): width: 1fr; display: block; overflow: auto; - height: 3; + height: 1; content-align: center middle; background: $panel; } diff --git a/acme/textui/ACMEContainerRequests.py b/acme/textui/ACMEContainerRequests.py index 8899e6a4..d8a1a01a 100644 --- a/acme/textui/ACMEContainerRequests.py +++ b/acme/textui/ACMEContainerRequests.py @@ -82,7 +82,7 @@ class ACMEViewRequests(Vertical): #request-list-header { /* overflow: auto hidden; */ width: 1fr; - height: 3; + height: 1; align-vertical: middle; background: $panel; } @@ -95,7 +95,7 @@ class ACMEViewRequests(Vertical): #request-list-details-header { overflow: auto; - height: 3; + height: 1; align-vertical: middle; background: $panel; } diff --git a/acme/textui/ACMEHeader.py b/acme/textui/ACMEHeader.py index 11c1ede9..3b554a4e 100644 --- a/acme/textui/ACMEHeader.py +++ b/acme/textui/ACMEHeader.py @@ -6,8 +6,6 @@ # """ This module defines the header for the ACME text UI. """ -from datetime import datetime, timezone - from rich.text import Text from textual.app import ComposeResult, RenderResult from textual.widgets import Header, Label @@ -17,6 +15,7 @@ from ..services import CSE from ..etc.Constants import Constants from ..etc.DateUtils import toISO8601Date +from ..etc.DateUtils import utcDatetime class ACMEHeaderClock(HeaderClock): @@ -39,7 +38,7 @@ def render(self) -> RenderResult: Returns: The rendered clock. """ - return Text(f'{toISO8601Date(datetime.now(tz = timezone.utc), readable = True)[:19]} UTC') + return Text(f'{toISO8601Date(utcDatetime(), readable = True)[:19]} UTC') class ACMEHeaderTitle(HeaderTitle): diff --git a/acme/textui/ACMETuiApp.py b/acme/textui/ACMETuiApp.py index 6b30846b..599770cb 100644 --- a/acme/textui/ACMETuiApp.py +++ b/acme/textui/ACMETuiApp.py @@ -184,27 +184,32 @@ def logDebug(self, msg:str) -> None: def scriptPrint(self, scriptName:str, msg:str) -> None: - self.containerTools.scriptPrint(scriptName, msg) + if self.containerTools: + self.containerTools.scriptPrint(scriptName, msg) def scriptLog(self, scriptName:str, msg:str) -> None: - self.containerTools.scriptLog(scriptName, msg) + if self.containerTools: + self.containerTools.scriptLog(scriptName, msg) def scriptLogError(self, scriptName:str, msg:str) -> None: - self.containerTools.scriptLogError(scriptName, msg) + if self.containerTools: + self.containerTools.scriptLogError(scriptName, msg) def scriptClearConsole(self, scriptName:str) -> None: - self.containerTools.scriptClearConsole(scriptName) + if self.containerTools: + self.containerTools.scriptClearConsole(scriptName) def scriptVisualBell(self, scriptName:str) -> None: - BackgroundWorkerPool.runJob(lambda:self.containerTools.scriptVisualBell(scriptName)) - # self.containerTools.scriptVisualBell(scriptName) + if self.containerTools: + BackgroundWorkerPool.runJob(lambda:self.containerTools.scriptVisualBell(scriptName)) def refreshResources(self) -> None: - self.containerTree.update() + if self.containerTree: + self.containerTree.update() ######################################################################### From f152f21a9b53f9ba0bdb9468eff31069f25c57a2 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 13 Jul 2023 14:51:18 +0200 Subject: [PATCH 013/165] Make TS.mdc mandatory (see SDS-2023-0095) --- acme/resources/TS.py | 2 ++ init/attributePolicies.ap | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/acme/resources/TS.py b/acme/resources/TS.py index 71b7b616..2471b848 100644 --- a/acme/resources/TS.py +++ b/acme/resources/TS.py @@ -365,6 +365,8 @@ def _validateDataDetect(self, updatedAttributes:Optional[JSON] = None) -> None: # Always set the mdc to the length of mdlt if present if self.mdlt is not None: self.setAttribute('mdc', len(self.mdlt)) + else: + self.setAttribute('mdc', 0) # Save changes self.dbUpdate(True) diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index 8b1a0513..f946d4d0 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -1719,7 +1719,7 @@ "lname": "missingDataCurrentNr", "ns": "m2m", "type": "nonNegInteger", - "car": "01", + "car": "1", "oc": "NP", "ou": "NP", "od": "O", From b09055e55953f5a689e1c91bf6ccc96ae50c95e8 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 14 Jul 2023 16:03:04 +0200 Subject: [PATCH 014/165] Added "dotimes" function to the script interpreter. --- CHANGELOG.md | 1 + acme/helpers/Interpreter.py | 75 ++++++++++++++++++++++++++++ docs/ACMEScript-functions.md | 95 ++++++++++++++++++++++++------------ 3 files changed, 139 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a0b56a..c6edf6b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - [CSE] Added automatic pip install of missing dependencies during startup. - [CSE] Added support for <schedule> resource type. +- [SCRIPTS] Added "dotimes" function to the script interpreter. ### Experimental diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index 7474abd7..3594ac85 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -1376,6 +1376,7 @@ def _executeExpression(self, symbol:SSymbol, parentSymbol:SSymbol) -> PContext: Args: symbol: The symbol to execute. + parentSymbol: The parent symbol of the symbol to execute. Return: The updated `PContext` object with the result. @@ -1946,6 +1947,79 @@ def _doDefun(pcontext:PContext, symbol:SSymbol) -> PContext: return pcontext +def _doDotimes(pcontext:PContext, symbol:SSymbol) -> PContext: + """ This function executes a code block a number of times. + + The first argument is a list that contains the loop counter symbol and the + loop limit. An optional third argument is the result variable for the loop. + The second argument is the code block to execute. + + Example: + :: + + (dotimes (i 10) (print i)) + (dotimes (i 10 result) (setq result i)) + + Args: + pcontext: Current `PContext` for the script. + symbol: The symbol to execute. + + Return: + The updated `PContext` object. The result + + """ + pcontext.assertSymbol(symbol, 3) + + # arguments + pcontext, _arguments = pcontext.valueFromArgument(symbol, 1, SType.tList, doEval = False) # don't evaluate the argument + if 2 <= len(_arguments) <= 3: + # get loop variable + _loopvar = cast(SSymbol, _arguments[0]) + if _loopvar.type != SType.tSymbol: + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'dotimes "counter" must be a symbol, got: {pcontext.result.type}')) + + # get loop count + pcontext = pcontext._executeExpression(_arguments[1], _arguments) + if pcontext.result.type != SType.tNumber: + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'dotimes "count" must be a number, got: {pcontext.result.type}')) + _loopcount = pcontext.result + if int(_loopcount.value) < 0: # type:ignore[arg-type] + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'dotimes "count" must be a non-negative number, got: {_loopcount.value}')) + else: + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'dotimes first argument requires 2 or 3 arguments, got: {len(_arguments)}')) + + # Get result variable name + if len(_arguments) == 3: + _resultvar = cast(SSymbol, _arguments[2]) + if _resultvar.type != SType.tSymbol: + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'dotimes "result" must be a symbol, got: {pcontext.result.type}')) + + # if the variable does not exist, create it as a nil symbol + if not str(_resultvar) in pcontext.variables: + pcontext.variables[str(_resultvar)] = SSymbol() + else: + _resultvar = None + + # code + pcontext, _code = pcontext.valueFromArgument(symbol, 2, SType.tList, doEval = False) # don't evaluate the argument (yet) + _code = SSymbol(lst = _code) # We got a python list, but must have a SSymbol list + + # execute the code + pcontext.variables[str(_loopvar)] = SSymbol(number = Decimal(0)) + for i in range(0, int(cast(Decimal, _loopcount.value))): + pcontext.variables[str(_loopvar)] = SSymbol(number = Decimal(i)) + pcontext = pcontext._executeExpression(_code, symbol) + + # set the result + if _resultvar: + pcontext.result = pcontext.variables[str(_resultvar)] + else: + pcontext.result = SSymbol() + + # return + return pcontext + + def _doError(pcontext:PContext, symbol:SSymbol) -> PContext: """ End script execution with an error. The optional argument will be assigned as the result of the script (pcontext.result). @@ -3288,6 +3362,7 @@ def _doWhile(pcontext:PContext, symbol:SSymbol) -> PContext: 'datetime': _doDatetime, 'dec': lambda p, a: _doIncDec(p, a, False), 'defun': _doDefun, + 'dotimes': _doDotimes, 'eval': _doEval, 'evaluate-inline': _doEvaluateInline, 'false': lambda p, a: _doBoolean(p, a, False), diff --git a/docs/ACMEScript-functions.md b/docs/ACMEScript-functions.md index 5d1a22ca..9b5f372f 100644 --- a/docs/ACMEScript-functions.md +++ b/docs/ACMEScript-functions.md @@ -20,6 +20,7 @@ The following built-in functions and variables are provided by the ACMEScript in | | [datetime](#datetime) | Return a timestamp | | | [defun](#defun) | Define a function | | | [dec](#dec) | Decrement a variable | +| | [dotimes](#dotimes) | Simple loop over an s-expression | | | [eval](#eval) | Evaluate and execute a quoted list | | | [evaluate-inline](#evaluate-inline) | Enable and disable inline string evaluation | | | [get-json-attribute](#get-json-attribute) | Get a JSON attribute from a JSON structure | @@ -234,7 +235,7 @@ The `case` function implements the functionality of a `switch...case` statement The *key* s-expression is evaluated and its value taken for the following comparisons. After this expression a number of lists may be given. -Each of these list contains two symbols that are handled in order: The first symbol evaluates to a value that is compared to the result of the *key* s-expression. If there is a match then the second s-exprersion is evaluated, and then the comparisons are stopped and the *case* function returns. +Each of these list contains two symbols that are handled in order: The first symbol evaluates to a value that is compared to the result of the *key* s-expression. If there is a match then the second s-expression is evaluated, and then the comparisons are stopped and the *case* function returns. The special symbol *otherwise* for a *condition* s-expression always matches and can be used as a default or fallback case . @@ -314,30 +315,6 @@ Example: --- - - -### dec - -`(dec [])` - -The `dec` function decrements a provided variable. The default for the increment is 1, but can be given as an optional second argument. If this argument is provided then the variable is decemented by this value. The value can be an integer or a float. - -The function returns the variable's new value. - -See also: [inc](#inc) - -Example: - -```lisp -(setq a 1) ;; Set variable "a" to 1 -(dec a) ;; Decrement variable "a" by 1 -(dec a 2.5) ;; Decrement variable "a" by 2.5 -``` - -[top](#top) - ---- - ### defun @@ -377,6 +354,60 @@ Examples: --- + + +### dec + +`(dec [])` + +The `dec` function decrements a provided variable. The default for the increment is 1, but can be given as an optional second argument. If this argument is provided then the variable is decremented by this value. The value can be an integer or a float. + +The function returns the variable's new value. + +See also: [inc](#inc) + +Example: + +```lisp +(setq a 1) ;; Set variable "a" to 1 +(dec a) ;; Decrement variable "a" by 1 +(dec a 2.5) ;; Decrement variable "a" by 2.5 +``` + +[top](#top) + +--- + + + +### dotimes + +`(dotimes ( []) (+))` + +The `dotimes` function provides a simple loop functionality. +The first arguments is a list that contains a loop variable that starts at 0, the loop `count` (which must be a non-negative number), and an optional +`result` variable. The second argument is a list that contains one or more s-expressions that are executed in the loop. + +If the `result variable` is specified then the loop returns the value of that variable, otherwise `nil`. + +See also: [while](#while) + +Example: + +```lisp +(dotimes (i 10) + (print i)) ;; print 1..10 + +(setq result 0) +(dotimes (i 10 result) + (setq result (+ result i))) ;; sum 1..10 +(print result) ;; 45 +``` + +[top](#top) + +--- + ### eval @@ -510,7 +541,7 @@ Example: `(inc [])` -The `inc` function increments a provided variable. The default for the increment is 1, but can be given as an optional second argument. If this argument is provided then the variable is incemented by this value. The value can be an integer or a float. +The `inc` function increments a provided variable. The default for the increment is 1, but can be given as an optional second argument. If this argument is provided then the variable is incremented by this value. The value can be an integer or a float. The function returns the variable's new value. @@ -1030,8 +1061,8 @@ Example: `(round [])` -The `round` function rounds a number to *precission* digits after the decimal point. The default is 0, meaning to round to nearest integer. - +The `round` function rounds a number to *precision* digits after the decimal point. The default is 0, meaning to round to nearest integer. + Example: ```lisp @@ -1092,7 +1123,7 @@ Example: `(sleep )` -The `sleep` function adds a delay to the script execution. The evaludation stops for a number of seconds. The delay could be provided as an integer or float number. +The `sleep` function adds a delay to the script execution. The evaluation stops for a number of seconds. The delay could be provided as an integer or float number. If the script execution timeouts during a sleep, the function is interrupted and all subsequent s-expressions are not evaluated. @@ -1116,7 +1147,7 @@ Example: The `slice` function returns the slice of a list or a string. -The behaviour is the same as slicing in Python, except that both *start* and *end* must be provided. The first argument is the *start* (including) of the slice, the second is the *end* (exlcuding) of the slice. The fourth argument is the list or string to slice. +The behavior is the same as slicing in Python, except that both *start* and *end* must be provided. The first argument is the *start* (including) of the slice, the second is the *end* (excluding) of the slice. The fourth argument is the list or string to slice. Example: @@ -1283,7 +1314,7 @@ A `while` loop continues to run when the first *guard* s-expression evaluates to The `while` function returns the result of the last evaluated s-expression in the *body*. -See also: [return](#return) +See also: [dotime](#dotimes), [return](#return) Example: @@ -1559,7 +1590,7 @@ Example: `(log-divider [])` -The `log-divider` function inserts a divider line in the CSE's *DEBUG* log. It can help to easily identifiy the different sections when working with many requests. An optional (short) message can be provided in the argument. +The `log-divider` function inserts a divider line in the CSE's *DEBUG* log. It can help to easily identify the different sections when working with many requests. An optional (short) message can be provided in the argument. Examples: From 444d35c626e0a657cddea28df81ba422b363f3a4 Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 17 Jul 2023 15:09:41 +0200 Subject: [PATCH 015/165] Moved some if...elif...else constructs to match...case --- acme/helpers/Interpreter.py | 64 +++++----- acme/helpers/UDPServer.py | 243 ++++++++++++++++++++++++++++++++++++ acme/resources/AE.py | 25 ++-- 3 files changed, 290 insertions(+), 42 deletions(-) create mode 100644 acme/helpers/UDPServer.py diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index 3594ac85..14cbd94a 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -358,25 +358,27 @@ def __contains__(self, obj:Any) -> bool: def toString(self, quoteStrings:bool = False, pythonList:bool = False) -> str: - if self.type in [ SType.tList, SType.tListQuote ]: - # Set the list chars - lchar1 = '[' if pythonList else '(' - lchar2 = ']' if pythonList else ')' - return f'{lchar1} {" ".join(lchar1 if v == "[" else lchar2 if v == "]" else v.toString(quoteStrings = quoteStrings, pythonList = pythonList) for v in cast(list, self.value))} {lchar2}' - # return f'( {" ".join(str(v) for v in cast(list, self.value))} )' - elif self.type == SType.tLambda: - return f'( ( {", ".join(v.toString(quoteStrings = quoteStrings, pythonList = pythonList) for v in cast(tuple, self.value)[0])} ) {str(cast(tuple, self.value)[1])} )' - elif self.type == SType.tBool: - return str(self.value).lower() - elif self.type == SType.tString: - if quoteStrings: - return f'"{str(self.value)}"' - return str(self.value) - elif self.type == SType.tJson: - return json.dumps(self.value) - elif self.type == SType.tNIL: - return 'nil' - return str(self.value) + match self.type: + case SType.tList | SType.tListQuote: + # Set the list chars + lchar1 = '[' if pythonList else '(' + lchar2 = ']' if pythonList else ')' + return f'{lchar1} {" ".join(lchar1 if v == "[" else lchar2 if v == "]" else v.toString(quoteStrings = quoteStrings, pythonList = pythonList) for v in cast(list, self.value))} {lchar2}' + # return f'( {" ".join(str(v) for v in cast(list, self.value))} )' + case SType.tLambda: + return f'( ( {", ".join(v.toString(quoteStrings = quoteStrings, pythonList = pythonList) for v in cast(tuple, self.value)[0])} ) {str(cast(tuple, self.value)[1])} )' + case SType.tBool: + return str(self.value).lower() + case SType.tString: + if quoteStrings: + return f'"{str(self.value)}"' + return str(self.value) + case SType.tJson: + return json.dumps(self.value) + case SType.tNIL: + return 'nil' + case _: + return str(self.value) def append(self, arg:SSymbol) -> SSymbol: @@ -2112,17 +2114,19 @@ def _doGetJSONAttribute(pcontext:PContext, symbol:SSymbol) -> PContext: """ def _toSymbol(value:Any) -> SSymbol: - if isinstance(value, str): - return SSymbol(string = value) - elif isinstance(value, (int, float)): - return SSymbol(number = Decimal(value)) - elif isinstance(value, dict): - return SSymbol(jsn = value) - elif isinstance(value, bool): - return SSymbol(boolean = value) - elif isinstance(value, list): - return SSymbol(lst = [ _toSymbol(l) for l in value]) - return SSymbol() # nil + match value: + case str(): + return SSymbol(string = value) + case int(), float(): + return SSymbol(number = Decimal(value)) + case dict(): + return SSymbol(jsn = value) + case bool(): + return SSymbol(boolean = value) + case list(): + return SSymbol(lst = [ _toSymbol(l) for l in value]) + case _: + return SSymbol() # nil pcontext.assertSymbol(symbol, 3) diff --git a/acme/helpers/UDPServer.py b/acme/helpers/UDPServer.py new file mode 100644 index 00000000..94615e8b --- /dev/null +++ b/acme/helpers/UDPServer.py @@ -0,0 +1,243 @@ +# +# UdpServer.py +# +# (c) 2023 by Andreas Kraft, Yann Garcia +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# This module contains various utilty functions that are used from various +# modules and entities of the CSE. +# + +import threading +from typing import Callable, Any, Tuple +import socket +# Dtls +import ssl +from dtls.wrapper import wrap_server, wrap_client, DtlsSocket +import dtls.sslconnection as sslconnection + +from ..helpers.BackgroundWorker import BackgroundWorkerPool + +class UdpServer(object): + + __slots__ = ( + 'addr', + 'port', + 'socket', + 'listen_socket', + 'doListen', + 'received_data_callback', + 'useTLS', + 'verifyCertificate', + 'tlsVersion', + 'ssl_version', + 'privateKeyFile', + 'certificateFile', + 'privateKeyFile', + 'certificateFile', + 'logging', + 'ssl_ctx', + 'mtu' + ) + + def __init__(self, server_address:str, + port:str, + useDTLS:bool, + tlsVersion:str, + verifyCertificate:bool, + privateKeyFile:str, + certificateFile:str, + received_data_callback:Callable, + logging:Callable) -> None: + self.addr = server_address + self.port = port + self.socket:socket.socket = None # Client socket + self.listen_socket:socket.socket = None # Server socket + self.doListen = False + self.received_data_callback = received_data_callback + self.useTLS = useDTLS + self.tlsVersion = tlsVersion + self.ssl_version = { 'tls1.1': sslconnection.PROTOCOL_DTLSv1, + 'tls1.2': sslconnection.PROTOCOL_DTLSv1_2, + 'auto': sslconnection.PROTOCOL_DTLS }[self.tlsVersion] + self.verifyCertificate = verifyCertificate + + self.privateKeyFile = privateKeyFile + self.certificateFile = certificateFile + self.logging = logging + self.ssl_ctx:DtlsSocket = None + self.mtu = 512 #1500 TODO configurable + + + def listen(self, timeout:int = 5) -> None: # This does NOT return + self.listen_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + self.listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + + def _listen(listenSocket:Tuple[socket.socket, DtlsSocket]) -> None: + self.doListen = True + while self.doListen: + self.logging(f'UdpServer.listen: In loop: {str(self.doListen)}') + try: + data, client_address = listenSocket.recvfrom(4096) + self.logging(f'UdpServer.listen: client_address: {str(client_address)}') + if len(client_address) > 2: + client_address = (client_address[0], client_address[1]) + self.logging(f'UdpServer.listen: receive_datagram (1) - {str(data)}') + if data is not None: + self.logging(f'UdpServer.listen: receive_datagram - - {str(data)}') + BackgroundWorkerPool.runJob(lambda : self.received_data_callback(data, client_address), f'CoAP_{str(client_address)}') # TODO a better thread name + # t = threading.Thread(target=self.received_data_callback, args=(data, client_address)) + # t.setDaemon(True) + # t.start() + except socket.timeout: + continue + except Exception as e: + self.logging(f'UdpServer.listen (secure): {str(e)}') + continue + + + if self.useTLS == True: + + # Setup DTLS context + self.logging(f'Setup SSL context. Certfile: {self.certificateFile}, KeyFile: {self.privateKeyFile}, TLS version: {self.tlsVersion}') + self.ssl_ctx = wrap_server( + self.listen_socket, + keyfile = self.privateKeyFile, + certfile = self.certificateFile, + cert_reqs = ssl.CERT_NONE if self.verifyCertificate == False else ssl.CERT_REQUIRED, + ssl_version = self.ssl_version, + #ca_certs=self.caCertificateFile, + do_handshake_on_connect = True, + user_mtu = self.mtu, + ssl_logging = True, + cb_ignore_ssl_exception_in_handshake = None, + cb_ignore_ssl_exception_read = None, + cb_ignore_ssl_exception_write = None) + + # Initialize and start listening + self.ssl_ctx.bind((self.addr, self.port)) + self.ssl_ctx.settimeout(timeout) + self.ssl_ctx.listen(0) + _listen(self.ssl_ctx) # Does not return + # self.doListen = True + # while self.doListen: + # self.logging(f'UdpServer.listen: In loop: {str(self.doListen)}') + # try: + # data, client_address = self.ssl_ctx.recvfrom(4096) + # self.logging(f'UdpServer.listen: client_address: {str(client_address)}') + # if len(client_address) > 2: + # client_address = (client_address[0], client_address[1]) + # self.logging(f'UdpServer.listen: receive_datagram (1) - {str(data)}') + # if not data is None: + # self.logging(f'UdpServer.listen: receive_datagram - - {str(data)}') + # BackgroundWorkerPool.runJob(lambda : self.received_data_callback(data, client_address), f'CoAP_{str(client_address)}') # TODO a better thread name + # # t = threading.Thread(target=self.received_data_callback, args=(data, client_address)) + # # t.setDaemon(True) + # # t.start() + # except socket.timeout: + # continue + # except Exception as e: + # self.logging(f'UdpServer.listen (secure): {str(e)}') + # continue + + else: + # Initialize and start listening (non-secure) + self.listen_socket.bind((self.addr, self.port)) + self.listen_socket.settimeout(timeout) + _listen(self.listen_socket) # Does not return + + # self.doListen = True + # while self.doListen: + # try: + # data, client_address = self.listen_socket.recvfrom(4096) + # if len(client_address) > 2: + # client_address = (client_address[0], client_address[1]) + # Logging.log(f'UdpServer.listen: receive_datagram - {str(data)}') + # t = threading.Thread(target=self.received_data_callback, args=(data, client_address)) + # t.setDaemon(True) + # t.start() + # except socket.timeout: + # continue + # except Exception as e: + # Logging.logWarn(f'UdpServer.listen: {str(e)}') + # break + + + # # def _cb_ignore_listen_exception(self, exception, server): + # """ + # In the CoAP server listen method, different exceptions can arise from the DTLS stack. Depending on the type of exception, a + # continuation might not be possible, or a logging might be desirable. With this callback both needs can be satisfied. + # :param exception: What happened inside the DTLS stack + # :param server: Reference to the running CoAP server + # :return: True if further processing should be done, False processing should be stopped + # """ + # Logging.log('>>> UdpServer.listen: _cb_ignore_listen_exception: ' + str(exception)) + # if isinstance(exception, ssl.SSLError): + # # A client which couldn't verify the server tried to connect, continue but log the event + # if exception.errqueue[-1][0] == ssl.ERR_TLSV1_ALERT_UNKNOWN_CA: + # Logging.logWarn("Ignoring ERR_TLSV1_ALERT_UNKNOWN_CA from client %s" % ('unknown' if not hasattr(exception, 'peer') else str(exception.peer))) + # return True + # # ... and more ... + # return False + + # def _cb_ignore_write_exception(self, exception, client): + # """ + # In the CoAP client write method, different exceptions can arise from the DTLS stack. Depending on the type of exception, a + # continuation might not be possible, or a logging might be desirable. With this callback both needs can be satisfied. + # note: Default behaviour of CoAPthon without DTLS if no _cb_ignore_write_exception would be called is with "return True" + # :param exception: What happened inside the DTLS stack + # :param client: Reference to the running CoAP client + # :return: True if further processing should be done, False processing should be stopped + # """ + # Logging.log('>>> UdpServer.listen: _cb_ignore_write_exception: ' + str(exception)) + # return False + + # def _cb_ignore_read_exception(self, exception, client) -> bool: + # """ In the CoAP client read method, different exceptions can arise from the DTLS stack. Depending on the type of exception, a + # continuation might not be possible, or a logging might be desirable. With this callback both needs can be satisfied. + # note: Default behaviour of CoAPthon without DTLS if no _cb_ignore_read_exception would be called is with "return False" + + # Args: + # exception: What happened inside the DTLS stack. + # client: Reference to the running CoAP client. + + # Returns: + # True if further processing should be done, False processing should be stopped + # """ + # Logging.log('>>> UdpServer.listen: _cb_ignore_read_exception: ' + str(exception)) + # return False + +# def send(self, p_coapMessage:CoapMessageResponse) -> None: +# self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +# self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + def close(self) -> None: + self.doListen = False + if self.listen_socket: + if self.ssl_ctx: + self.ssl_ctx.unwrap() + self.listen_socket.close() + self.ssl_ctx = None + self.listen_socket = None + if self.socket: + self.socket.close() + self.socket = None + + + def sendTo(self, datagram): + self.logging(f'==> UdpServer.sendTo: /{str(datagram[0])} - {str(datagram[1])}') + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + if self.useTLS == True: + sock = wrap_client(sock, cert_reqs = ssl.CERT_REQUIRED, + keyfile = self.privateKeyFile, + certfile = self.certificateFile, + ca_certs = self.caCertificateFile, + do_handshake_on_connect = True, + ssl_version = self.ssl_version) + sock.sendto(datagram[0], datagram[1]) + except Exception as e: + self.logging(f'UdpServer.sendTo: {str(e)}') + finally: + sock.close() diff --git a/acme/resources/AE.py b/acme/resources/AE.py index 462fc501..4ff43236 100644 --- a/acme/resources/AE.py +++ b/acme/resources/AE.py @@ -140,18 +140,19 @@ def validate(self, originator:Optional[str] = None, # check api attribute if not (api := self['api']) or len(api) < 2: # at least R|N + another char raise BAD_REQUEST('missing or empty attribute: "api"') - if api.startswith('N'): - pass # simple format - elif api.startswith('R'): - if len(api.split('.')) < 3: - raise BAD_REQUEST('wrong format for registered ID in attribute "api": to few elements') - - # api must normally begin with a lower-case "r", but it is allowed for release 2a and 3 - elif api.startswith('r'): - if (rvi := self.getRVI()) is not None and rvi not in ['2a', '3']: - raise BAD_REQUEST(L.logWarn('lower case "r" is only allowed for release versions "2a" and "3"')) - else: - raise BAD_REQUEST(L.logWarn(f'wrong format for ID in attribute "api": {api} (must start with "R" or "N")')) + + match api: + case x if x.startswith('N'): + pass # simple format + case x if x.startswith('R'): + if len(x.split('.')) < 3: + raise BAD_REQUEST('wrong format for registered ID in attribute "api": to few elements') + # api must normally begin with a lower-case "r", but it is allowed for release 2a and 3 + case x if x.startswith('r'): + if (rvi := self.getRVI()) is not None and rvi not in ['2a', '3']: + raise BAD_REQUEST(L.logWarn('lower case "r" is only allowed for release versions "2a" and "3"')) + case _: + raise BAD_REQUEST(L.logWarn(f'wrong format for ID in attribute "api": {api} (must start with "R" or "N")')) def deactivate(self, originator:str) -> None: From 1a38c5fe6e86f47321c61f870ab820dfebb28ca7 Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 17 Jul 2023 15:10:21 +0200 Subject: [PATCH 016/165] Changed mypy's python version to 3.10 --- mypy.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy.ini b/mypy.ini index e6d844a1..1df759ec 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.8 +python_version = 3.10 #mypy_path = acme files = acme/__main__.py,tests/*.py,tools/notificationServer/notificationServer.py disallow_untyped_calls = true From 4fdbbebcb8628d955982cdde5e87325036578add Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 18 Jul 2023 11:38:23 +0200 Subject: [PATCH 017/165] Converted more elif to match --- acme/__main__.py | 48 ++++----- acme/etc/RequestUtils.py | 26 ++--- acme/etc/Types.py | 39 ++++---- acme/services/Logging.py | 37 +++---- acme/services/RegistrationManager.py | 112 ++++++++++----------- acme/services/ScriptManager.py | 141 +++++++++++++-------------- 6 files changed, 202 insertions(+), 201 deletions(-) diff --git a/acme/__main__.py b/acme/__main__.py index 327bd7ee..ccb60fb7 100644 --- a/acme/__main__.py +++ b/acme/__main__.py @@ -24,30 +24,32 @@ if 'ACME_DEBUG' in os.environ: raise e - # Give hint to run ACME as a module - if 'attempted relative import' in e.msg: - print(f'\nPlease run acme as a package:\n\n\t{sys.executable} -m {sys.argv[0]} [arguments]\n') + match e.msg: + # Give hint to run ACME as a module + case x if 'attempted relative import' in x: + print(f'\nPlease run acme as a package:\n\n\t{sys.executable} -m {sys.argv[0]} [arguments]\n') - # Give hint how to do the installation - elif 'No module named' in e.msg: - m = re.search("'(.+?)'", e.msg) - package = f' ({m.group(1)}) ' if m else ' ' - print(f'\nOne or more required packages or modules{package}could not be found.\nPlease install the missing packages, e.g. by running the following command:\n\n\t{sys.executable} -m pip install -r requirements.txt\n') - - # Ask if the user wants to install the missing packages - try: - if input('\nDo you want to install the missing packages now? [y/N] ') in ['y', 'Y']: - import os - os.system(f'{sys.executable} -m pip install -r requirements.txt') - - # Ask if the user wants to start ACME - if input('\nDo you want to start ACME now? [Y/n] ') in ['y', 'Y', '']: - os.system(f'{sys.executable} -m acme {" ".join(sys.argv[1:])}') - - except Exception as e2: - print(f'\nError during installation: {e2}\n') - else: - print(f'\nError during import: {e.msg}\n') + # Give hint how to do the installation + case x if 'No module named' in x: + m = re.search("'(.+?)'", e.msg) + package = f' ({m.group(1)}) ' if m else ' ' + print(f'\nOne or more required packages or modules{package}could not be found.\nPlease install the missing packages, e.g. by running the following command:\n\n\t{sys.executable} -m pip install -r requirements.txt\n') + + # Ask if the user wants to install the missing packages + try: + if input('\nDo you want to install the missing packages now? [y/N] ') in ['y', 'Y']: + import os + os.system(f'{sys.executable} -m pip install -r requirements.txt') + + # Ask if the user wants to start ACME + if input('\nDo you want to start ACME now? [Y/n] ') in ['y', 'Y', '']: + os.system(f'{sys.executable} -m acme {" ".join(sys.argv[1:])}') + + except Exception as e2: + print(f'\nError during installation: {e2}\n') + + case _: + print(f'\nError during import: {e.msg}\n') quit(1) diff --git a/acme/etc/RequestUtils.py b/acme/etc/RequestUtils.py index f654d336..ca76b842 100644 --- a/acme/etc/RequestUtils.py +++ b/acme/etc/RequestUtils.py @@ -50,11 +50,13 @@ def deserializeData(data:bytes, ct:ContentSerializationType) -> Optional[JSON]: """ if len(data) == 0: return {} - if ct == ContentSerializationType.JSON: - return cast(JSON, json.loads(TextTools.removeCommentsFromJSON(data.decode('utf-8')))) - elif ct == ContentSerializationType.CBOR: - return cast(JSON, cbor2.loads(data)) - return None + match ct: + case ContentSerializationType.JSON: + return cast(JSON, json.loads(TextTools.removeCommentsFromJSON(data.decode('utf-8')))) + case ContentSerializationType.CBOR: + return cast(JSON, cbor2.loads(data)) + case _: + return None def toHttpUrl(url:str) -> str: @@ -67,12 +69,14 @@ def toHttpUrl(url:str) -> str: A valid URL with escaped special characters. """ u = list(urlparse(url)) - if u[2].startswith('///'): - u[2] = f'/_{u[2][2:]}' - url = urlunparse(u) - elif u[2].startswith('//'): - u[2] = f'/~{u[2][1:]}' - url = urlunparse(u) + match u[2]: + case x if x.startswith('///'): + u[2] = f'/_{u[2][2:]}' + url = urlunparse(u) + case x if x.startswith('//'): + u[2] = f'/~{u[2][1:]}' + url = urlunparse(u) + return url diff --git a/acme/etc/Types.py b/acme/etc/Types.py index 18407bdd..c4375e8d 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -1208,24 +1208,27 @@ def isAllowedNCT(self, nct:NotificationContentType) -> bool: Return: True if the NotificationEventType is allowed for the NotificationContentType. """ - if nct == NotificationContentType.allAttributes: - return self.value in [ NotificationEventType.resourceUpdate, - NotificationEventType.resourceDelete, - NotificationEventType.createDirectChild, - NotificationEventType.deleteDirectChild ] - elif nct == NotificationContentType.modifiedAttributes: - return self.value in [ NotificationEventType.resourceUpdate, - NotificationEventType.blockingUpdate ] - elif nct == NotificationContentType.ri: - return self.value in [ NotificationEventType.resourceUpdate, - NotificationEventType.resourceDelete, - NotificationEventType.createDirectChild, - NotificationEventType.deleteDirectChild ] - elif nct == NotificationContentType.triggerPayload: - return self.value in [ NotificationEventType.triggerReceivedForAE ] - elif nct == NotificationContentType.timeSeriesNotification: - return self.value in [ NotificationEventType.reportOnGeneratedMissingDataPoints ] - return False + match nct: + case NotificationContentType.allAttributes: + return self.value in [ NotificationEventType.resourceUpdate, + NotificationEventType.resourceDelete, + NotificationEventType.createDirectChild, + NotificationEventType.deleteDirectChild ] + case NotificationContentType.modifiedAttributes: + return self.value in [ NotificationEventType.resourceUpdate, + NotificationEventType.blockingUpdate ] + case NotificationContentType.ri: + return self.value in [ NotificationEventType.resourceUpdate, + NotificationEventType.resourceDelete, + NotificationEventType.createDirectChild, + NotificationEventType.deleteDirectChild ] + case NotificationContentType.triggerPayload: + return self.value in [ NotificationEventType.triggerReceivedForAE ] + case NotificationContentType.timeSeriesNotification: + return self.value in [ NotificationEventType.reportOnGeneratedMissingDataPoints ] + case _: + return False + def defaultNCT(self) -> NotificationContentType: """ Return the default NotificationContentType for this NotificationEventType. diff --git a/acme/services/Logging.py b/acme/services/Logging.py index 0c3866f4..59f7f300 100644 --- a/acme/services/Logging.py +++ b/acme/services/Logging.py @@ -379,15 +379,17 @@ def logWithLevel(level:int, msg:Any, """ # TODO add a parameter frame substractor to correct the line number, here and in In _log() # TODO change to match in Python10 - if level == logging.DEBUG: - return Logging.logDebug(msg, stackOffset = stackOffset) - elif level == logging.INFO: - return Logging.log(msg, stackOffset = stackOffset) - elif level == logging.WARNING: - return Logging.logWarn(msg, stackOffset = stackOffset) - elif level == logging.ERROR: - return Logging.logErr(msg, showStackTrace = showStackTrace, stackOffset = stackOffset) - return msg + match level: + case logging.DEBUG: + return Logging.logDebug(msg, stackOffset = stackOffset) + case logging.INFO: + return Logging.log(msg, stackOffset = stackOffset) + case logging.WARNING: + return Logging.logWarn(msg, stackOffset = stackOffset) + case logging.ERROR: + return Logging.logErr(msg, showStackTrace = showStackTrace, stackOffset = stackOffset) + case _: + return msg @staticmethod @@ -454,14 +456,15 @@ def console(msg:Union[str, Text, Tree, Table, JSON] = ' ', style = Logging.terminalStyle if not isError else Logging.terminalStyleError if nlb: # Empty line before Logging._console.print() - if isinstance(msg, str): - Logging._console.print(msg if plain else Markdown(msg), style = style, end = end, highlight = False) - elif isinstance(msg, dict): - Logging._console.print(msg, style = style, end = end) - elif isinstance(msg, (Tree, Table, Text)): - Logging._console.print(msg, style = style, end = end) - else: - Logging._console.print(str(msg), style = style, end = end) + + match msg: + case str(): + Logging._console.print(msg if plain else Markdown(msg), style = style, end = end, highlight = False) + case dict() | Tree() | Table() | Text(): + Logging._console.print(msg, style = style, end = end) + case _: + Logging._console.print(str(msg), style = style, end = end) + if nl: # Empty line after Logging._console.print() diff --git a/acme/services/RegistrationManager.py b/acme/services/RegistrationManager.py index e092cead..3ac03164 100644 --- a/acme/services/RegistrationManager.py +++ b/acme/services/RegistrationManager.py @@ -116,31 +116,27 @@ def checkResourceCreation(self, resource:Resource, originator:str, parentResource:Optional[Resource] = None) -> str: # Some Resources are not allowed to be created in a request, return immediately - ty = resource.ty - - if ty == ResourceTypes.AE: - originator = self.handleAERegistration(resource, originator, parentResource) - - elif ty == ResourceTypes.REQ: - if not self.handleREQRegistration(resource, originator): - raise BAD_REQUEST('cannot register REQ') - - elif ty == ResourceTypes.CSR: - if CSE.cseType == CSEType.ASN: - raise OPERATION_NOT_ALLOWED('cannot register to ASN CSE') - try: - self.handleCSRRegistration(resource, originator) - except ResponseException as e: - e.dbg = f'cannot register CSR: {e.dbg}' - raise e - - elif ty == ResourceTypes.CSEBaseAnnc: - try: - self.handleCSEBaseAnncRegistration(resource, originator) - except ResponseException as e: - e.dbg = f'cannot register CSEBaseAnnc: {e.dbg}' - raise e - # fall-through + + match resource.ty: + case ResourceTypes.AE: + originator = self.handleAERegistration(resource, originator, parentResource) + case ResourceTypes.CSR: + if CSE.cseType == CSEType.ASN: + raise OPERATION_NOT_ALLOWED('cannot register to ASN CSE') + try: + self.handleCSRRegistration(resource, originator) + except ResponseException as e: + e.dbg = f'cannot register CSR: {e.dbg}' + raise e + case ResourceTypes.REQ: + if not self.handleREQRegistration(resource, originator): + raise BAD_REQUEST('cannot register REQ') + case ResourceTypes.CSEBaseAnnc: + try: + self.handleCSEBaseAnncRegistration(resource, originator) + except ResponseException as e: + e.dbg = f'cannot register CSEBaseAnnc: {e.dbg}' + raise e # Test and set creator attribute. self.handleCreator(resource, originator) @@ -155,13 +151,13 @@ def postResourceCreation(self, resource:Resource) -> None: Args: resource: Resource that was created. """ - ty = resource.ty - if ty == ResourceTypes.AE: - # Send event - self._eventAEHasRegistered(resource) - elif ty == ResourceTypes.CSR: - # send event - self._eventRegistreeCSEHasRegistered(resource) + match resource.ty: + case ResourceTypes.AE: + # Send event + self._eventAEHasRegistered(resource) + case ResourceTypes.CSR: + # send event + self._eventRegistreeCSEHasRegistered(resource) def handleCreator(self, resource:Resource, originator:str) -> None: @@ -184,19 +180,16 @@ def checkResourceUpdate(self, resource:Resource, updateDict:JSON) -> None: def checkResourceDeletion(self, resource:Resource) -> None: - ty = resource.ty - if ty == ResourceTypes.AE: - if not self.handleAEDeRegistration(resource): - raise BAD_REQUEST('cannot deregister AE') - - elif ty == ResourceTypes.REQ: - if not self.handleREQDeRegistration(resource): - raise BAD_REQUEST('cannot deregister REQ') - - elif ty == ResourceTypes.CSR: - if not self.handleRegistreeCSRDeRegistration(resource): - raise BAD_REQUEST('cannot deregister CSR') - # fall-through + match resource.ty: + case ResourceTypes.AE: + if not self.handleAEDeRegistration(resource): + raise BAD_REQUEST('cannot deregister AE') + case ResourceTypes.REQ: + if not self.handleREQDeRegistration(resource): + raise BAD_REQUEST('cannot deregister REQ') + case ResourceTypes.CSR: + if not self.handleRegistreeCSRDeRegistration(resource): + raise BAD_REQUEST('cannot deregister CSR') def postResourceDeletion(self, resource:Resource) -> None: @@ -205,13 +198,13 @@ def postResourceDeletion(self, resource:Resource) -> None: Args: resource: Resource that was created. """ - ty = resource.ty - if ty == ResourceTypes.AE: - # Send event - self._eventAEHasDeregistered(resource) - elif ty == ResourceTypes.CSR: - # send event - self._eventRegistreeCSEHasDeregistered(resource) + match resource.ty: + case ResourceTypes.AE: + # Send event + self._eventAEHasDeregistered(resource) + case ResourceTypes.CSR: + # send event + self._eventRegistreeCSEHasDeregistered(resource) ######################################################################### @@ -235,14 +228,13 @@ def handleAERegistration(self, ae:Resource, originator:str, parentResource:Resou raise APP_RULE_VALIDATION_FAILED(L.logDebug('Originator not allowed')) # Assign originator for the AE - if originator == 'C': - originator = uniqueAEI('C') - elif originator == 'S': - originator = uniqueAEI('S') - elif originator is not None: # Allow empty originators - originator = getIdFromOriginator(originator) - # elif originator is None or len(originator) == 0: - # originator = uniqueAEI('S') + match originator: + case 'C': + originator = uniqueAEI('C') + case 'S': + originator = uniqueAEI('S') + case x if x is not None: + originator = getIdFromOriginator(originator) # Check whether an originator has already registered with the same AE-ID if self.hasRegisteredAE(originator): diff --git a/acme/services/ScriptManager.py b/acme/services/ScriptManager.py index 2ad4af78..e81bcd86 100644 --- a/acme/services/ScriptManager.py +++ b/acme/services/ScriptManager.py @@ -1110,44 +1110,41 @@ def doSetConfig(self, pcontext:PContext, symbol:SSymbol) -> PContext: if Configuration.has(_key): # could be None, False, 0, empty string etc # Do some conversions first - v = Configuration.get(_key) - if isinstance(v, ACMEIntEnum): - if result.type == SType.tString: - r = Configuration.update(_key, v.__class__.to(cast(str, result.value), insensitive = True)) - else: - raise PInvalidTypeError(pcontext.setError(PError.invalid, 'configuration value must be a string')) - - elif isinstance(v, str): - if result.type == SType.tString: - r = Configuration.update(_key, cast(str, result.value).strip()) - else: - raise PInvalidTypeError(pcontext.setError(PError.invalid, 'configuration value must be a string')) - - # bool must be tested before int! - # See https://stackoverflow.com/questions/37888620/comparing-boolean-and-int-using-isinstance/37888668#37888668 - elif isinstance(v, bool): - if result.type == SType.tBool: - r = Configuration.update(_key, result.value) - else: - raise PInvalidTypeError(pcontext.setError(PError.invalidType, f'configuration value must be a boolean')) - - elif isinstance(v, int): - if result.type == SType.tNumber: - r = Configuration.update(_key, int(cast(Decimal, result.value))) - else: - raise PInvalidTypeError(pcontext.setError(PError.invalidType, f'configuration value must be an integer')) - - elif isinstance(v, float): - if result.type == SType.tNumber: - r = Configuration.update(_key, float(cast(Decimal, result.value))) - else: - raise PInvalidTypeError(pcontext.setError(PError.invalidType, f'configuration value must be a float, is: {result.type}')) - - elif isinstance(v, list): - raise PUnsupportedError(pcontext.setError(PError.invalidType, f'unsupported type: {type(v)}')) - else: - raise PUnsupportedError(pcontext.setError(PError.invalidType, f'unsupported type: {type(v)}')) + match (v := Configuration.get(_key)): + case ACMEIntEnum(): + if result.type == SType.tString: + r = Configuration.update(_key, v.__class__.to(cast(str, result.value), insensitive = True)) + else: + raise PInvalidTypeError(pcontext.setError(PError.invalid, 'configuration value must be a string')) + case str(): + if result.type == SType.tString: + r = Configuration.update(_key, cast(str, result.value).strip()) + else: + raise PInvalidTypeError(pcontext.setError(PError.invalid, 'configuration value must be a string')) + # bool must be tested before int! + # See https://stackoverflow.com/questions/37888620/comparing-boolean-and-int-using-isinstance/37888668#37888668 + case bool(): + if result.type == SType.tBool: + r = Configuration.update(_key, result.value) + else: + raise PInvalidTypeError(pcontext.setError(PError.invalidType, f'configuration value must be a boolean')) + + case int(): + if result.type == SType.tNumber: + r = Configuration.update(_key, int(cast(Decimal, result.value))) + else: + raise PInvalidTypeError(pcontext.setError(PError.invalidType, f'configuration value must be an integer')) + + case float(): + if result.type == SType.tNumber: + r = Configuration.update(_key, float(cast(Decimal, result.value))) + else: + raise PInvalidTypeError(pcontext.setError(PError.invalidType, f'configuration value must be a float, is: {result.type}')) + + case _: + raise PUnsupportedError(pcontext.setError(PError.invalidType, f'unsupported type: {type(v)}')) + # Check whether something went wrong while setting the config if r: raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'Error setting configuration: {r}')) @@ -1366,41 +1363,41 @@ def _handleRequest(self, pcontext:PContext, symbol:SSymbol, operation:Operation) # Send request L.isDebug and L.logDebug(f'Sending request from script: {request.originalRequest} to: {target}') if isURL(target): - if operation == Operation.RETRIEVE: - res = CSE.request.handleSendRequest(CSERequest(op = Operation.RETRIEVE, - ot = getResourceDate(), - to = target, - originator = originator) - )[0].result # there should be at least one result - - elif operation == Operation.DELETE: - res = CSE.request.handleSendRequest(CSERequest(op = Operation.DELETE, - ot = getResourceDate(), - to = target, - originator = originator) - )[0].result # there should be at least one result - elif operation == Operation.CREATE: - res = CSE.request.handleSendRequest(CSERequest(op = Operation.CREATE, - ot = getResourceDate(), - to = target, - originator = originator, - ty = ty, - pc = request.pc) - )[0].result # there should be at least one result - elif operation == Operation.UPDATE: - res = CSE.request.handleSendRequest(CSERequest(op = Operation.UPDATE, - ot = getResourceDate(), - to = target, - originator = originator, - pc = request.pc) - )[0].result # there should be at least one result - elif operation == Operation.NOTIFY: - res = CSE.request.handleSendRequest(CSERequest(op = Operation.NOTIFY, - ot = getResourceDate(), - to = target, - originator = originator, - pc = request.pc) - )[0].result # there should be at least one result + match operation: + case Operation.RETRIEVE: + res = CSE.request.handleSendRequest(CSERequest(op = Operation.RETRIEVE, + ot = getResourceDate(), + to = target, + originator = originator) + )[0].result # there should be at least one result + case Operation.DELETE: + res = CSE.request.handleSendRequest(CSERequest(op = Operation.DELETE, + ot = getResourceDate(), + to = target, + originator = originator) + )[0].result # there should be at least one result + case Operation.CREATE: + res = CSE.request.handleSendRequest(CSERequest(op = Operation.CREATE, + ot = getResourceDate(), + to = target, + originator = originator, + ty = ty, + pc = request.pc) + )[0].result # there should be at least one result + case Operation.UPDATE: + res = CSE.request.handleSendRequest(CSERequest(op = Operation.UPDATE, + ot = getResourceDate(), + to = target, + originator = originator, + pc = request.pc) + )[0].result # there should be at least one result + case Operation.NOTIFY: + res = CSE.request.handleSendRequest(CSERequest(op = Operation.NOTIFY, + ot = getResourceDate(), + to = target, + originator = originator, + pc = request.pc) + )[0].result # there should be at least one result else: # Request via CSE-ID, either local, or otherwise a transit request. Let the CSE handle it From 22c700ef7b60f5345ae5bb210ad128bd3d2b40d1 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 18 Jul 2023 16:26:55 +0200 Subject: [PATCH 018/165] More match...case --- acme/resources/AnnounceableResource.py | 13 +++++++----- acme/resources/CRS.py | 9 ++++---- acme/resources/Factory.py | 18 +++++++++------- acme/services/GroupManager.py | 29 +++++++++++++------------- acme/services/HttpServer.py | 9 ++++---- acme/services/RequestManager.py | 1 - 6 files changed, 43 insertions(+), 36 deletions(-) diff --git a/acme/resources/AnnounceableResource.py b/acme/resources/AnnounceableResource.py index f8c7b182..9ac963fb 100644 --- a/acme/resources/AnnounceableResource.py +++ b/acme/resources/AnnounceableResource.py @@ -250,11 +250,14 @@ def _getAnnouncedAttributes(self, attributes:AttributePolicyDict) -> list[str]: if not (policy := attributes.get(attr)): continue - if policy.announcement == Announced.MA: - mandatory.append(attr) - elif policy.announcement == Announced.OA and attr in announceableAttributes: # only add optional attributes that are also in aa - optional.append(attr) - # else: just ignore Announced.NA + match policy.announcement: + case Announced.MA: + mandatory.append(attr) + case Announced.OA if attr in announceableAttributes: # only add optional attributes that are also in aa + optional.append(attr) + case Announced.NA: + # just ignore Announced.NA + pass return mandatory + optional diff --git a/acme/resources/CRS.py b/acme/resources/CRS.py index 4d823c92..99145d48 100644 --- a/acme/resources/CRS.py +++ b/acme/resources/CRS.py @@ -180,10 +180,11 @@ def update(self, dct:Optional[JSON] = None, def deactivate(self, originator:str) -> None: # Deactivate time windows - if self.twt == TimeWindowType.PERIODICWINDOW: - CSE.notification.stopCRSPeriodicWindow(self.ri) - elif self.twt == TimeWindowType.SLIDINGWINDOW: - CSE.notification.stopCRSSlidingWindow(self.ri) + match self.twt: + case TimeWindowType.PERIODICWINDOW: + CSE.notification.stopCRSPeriodicWindow(self.ri) + case TimeWindowType.SLIDINGWINDOW: + CSE.notification.stopCRSSlidingWindow(self.ri) # Delete rrat and srat subscriptions self._deleteSubscriptions(originator) diff --git a/acme/resources/Factory.py b/acme/resources/Factory.py index 02125d8d..10a7d1b9 100644 --- a/acme/resources/Factory.py +++ b/acme/resources/Factory.py @@ -231,14 +231,16 @@ def resourceFromDict(resDict:Optional[JSON] = {}, # Determine a factory and call it factory:FactoryCallableT = None - if typ == ResourceTypes.MGMTOBJ: # for - # mgd = resDict['mgd'] if 'mgd' in resDict else None # Identify mdg in - factory = ResourceTypes(resDict['mgd']).resourceFactory() - elif typ == ResourceTypes.MGMTOBJAnnc: # for - # mgd = resDict['mgd'] if 'mgd' in resDict else None # Identify mdg in - factory = ResourceTypes(resDict['mgd']).announced().resourceFactory() - else: - factory = typ.resourceFactory() + match typ: + case ResourceTypes.MGMTOBJ: + # mgd = resDict['mgd'] if 'mgd' in resDict else None # Identify mdg in + factory = ResourceTypes(resDict['mgd']).resourceFactory() + case ResourceTypes.MGMTOBJAnnc: + # mgd = resDict['mgd'] if 'mgd' in resDict else None # Identify mdg in + factory = ResourceTypes(resDict['mgd']).announced().resourceFactory() + case _: + factory = typ.resourceFactory() + if factory: return cast(Resource, factory(resDict, tpe, pi, create)) diff --git a/acme/services/GroupManager.py b/acme/services/GroupManager.py index 63a27e34..9853724e 100644 --- a/acme/services/GroupManager.py +++ b/acme/services/GroupManager.py @@ -149,24 +149,25 @@ def _checkMembersAndPrivileges(self, group:Resource, originator:str) -> None: # check specializationType spty if (spty := group.spty): - if isinstance(spty, int): # mgmtobj type - if isinstance(resource, MgmtObj) and ty != spty: - raise GROUP_MEMBER_TYPE_INCONSISTENT(f'resource and group member types mismatch: {ty} != {spty} for: {mid}') - elif isinstance(spty, str): # fcnt specialization - if isinstance(resource, FCNT) and resource.cnd != spty: - raise GROUP_MEMBER_TYPE_INCONSISTENT(f'resource and group member specialization types mismatch: {resource.cnd} != {spty} for: {mid}') + match spty: + case int(): # mgmtobj type + if isinstance(resource, MgmtObj) and ty != spty: + raise GROUP_MEMBER_TYPE_INCONSISTENT(f'resource and group member types mismatch: {ty} != {spty} for: {mid}') + case str(): # fcnt specialization + if isinstance(resource, FCNT) and resource.cnd != spty: + raise GROUP_MEMBER_TYPE_INCONSISTENT(f'resource and group member specialization types mismatch: {resource.cnd} != {spty} for: {mid}') # check type of resource and member type of group mt = group.mt if not (mt == ResourceTypes.MIXED or ty == mt): # types don't match - csy = group.csy - if csy == ConsistencyStrategy.abandonMember: # abandon member - continue - elif csy == ConsistencyStrategy.setMixed: # change group's member type - mt = ResourceTypes.MIXED - group['mt'] = ResourceTypes.MIXED - else: # abandon group - raise GROUP_MEMBER_TYPE_INCONSISTENT('group consistency strategy and type "mixed" mismatch') + match group.csy: + case ConsistencyStrategy.abandonMember: # abandon member + continue + case ConsistencyStrategy.setMixed: # change group's member type + mt = ResourceTypes.MIXED + group['mt'] = ResourceTypes.MIXED + case _: + raise GROUP_MEMBER_TYPE_INCONSISTENT('group consistency strategy and type "mixed" mismatch') # member seems to be ok, so add ri to the list if isLocalResource: diff --git a/acme/services/HttpServer.py b/acme/services/HttpServer.py index e2e71f27..b22ec399 100644 --- a/acme/services/HttpServer.py +++ b/acme/services/HttpServer.py @@ -659,10 +659,11 @@ def extractMultipleArgs(args:MultiDict, argName:str) -> None: req['op'] = operation.value # Needed later for validation # resolve http's /~ and /_ special prefixs - if path[0] == '~': - path = path[1:] # ~/xxx -> /xxx - elif path[0] == '_': - path = f'/{path[1:]}' # _/xxx -> //xxx + match path[0]: + case '~': + path = path[1:] # ~/xxx -> /xxx + case '_': + path = f'/{path[1:]}' # _/xxx -> //xxx req['to'] = path diff --git a/acme/services/RequestManager.py b/acme/services/RequestManager.py index a64f0855..95c17f90 100644 --- a/acme/services/RequestManager.py +++ b/acme/services/RequestManager.py @@ -853,7 +853,6 @@ def queueRequestForPCH( self, # If the request has no id, then use the to field if not request.id: request.id = request.to - L.logErr(f'Internal error. {request}') # Always mark the request as a REQUEST request.requestType = reqType From e10bd4dd8732a9d21f21bafd7757a89b92a80ef3 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 18 Jul 2023 16:27:12 +0200 Subject: [PATCH 019/165] corrected wrong test cases --- tests/testMgmtObj.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/testMgmtObj.py b/tests/testMgmtObj.py index a0fb5fb9..5061170f 100644 --- a/tests/testMgmtObj.py +++ b/tests/testMgmtObj.py @@ -1134,7 +1134,7 @@ def test_updateDATCrpscInvalidSchedule1Fail(self) -> None: def test_updateDATCrpscInvalidSchedule2Fail(self) -> None: """ UPDATE [dataCollection] rpsc with an invalid schedule -> FAIL""" dct = { 'dcfg:datc' : { - 'rpsc': [ { 'sce': '10 * * * *' } ], # invalid format, must be 7 + 'rpsc': [ { 'sce': [ '10 * * * *' ] } ], # invalid format, must be 7 }} r, rsc = UPDATE(self.datcURL, ORIGINATOR, dct) self.assertEqual(rsc, RC.BAD_REQUEST, r) @@ -1144,7 +1144,7 @@ def test_updateDATCrpscInvalidSchedule2Fail(self) -> None: def test_updateDATCrpscValidSchedule(self) -> None: """ UPDATE [dataCollection] rpsc with a valid schedule""" dct = { 'dcfg:datc' : { - 'rpsc': [ { 'sce': '10 * * * * * *' } ], + 'rpsc': [ { 'sce': [ '10 * * * * * *' ] } ], }} r, rsc = UPDATE(self.datcURL, ORIGINATOR, dct) self.assertEqual(rsc, RC.UPDATED, r) @@ -1187,7 +1187,7 @@ def test_updateDATCmescInvalidSchedule1Fail(self) -> None: def test_updateDATCmescInvalidSchedule2Fail(self) -> None: """ UPDATE [dataCollection] mesc with an invalid schedule -> FAIL""" dct = { 'dcfg:datc' : { - 'mesc': [ { 'sce': '10 * * * *' } ], # invalid format, must be 7 + 'mesc': [ { 'sce': [ '10 * * * *' ] } ], # invalid format, must be 7 }} r, rsc = UPDATE(self.datcURL, ORIGINATOR, dct) self.assertEqual(rsc, RC.BAD_REQUEST, r) @@ -1197,7 +1197,7 @@ def test_updateDATCmescInvalidSchedule2Fail(self) -> None: def test_updateDATCmescValidSchedule(self) -> None: """ UPDATE [dataCollection] mesc with a valid schedule""" dct = { 'dcfg:datc' : { - 'mesc': [ { 'sce': '10 * * * * * *' } ], + 'mesc': [ { 'sce': [ '10 * * * * * *' ] } ], }} r, rsc = UPDATE(self.datcURL, ORIGINATOR, dct) self.assertEqual(rsc, RC.UPDATED, r) @@ -1259,7 +1259,7 @@ def test_attributesDATC(self) -> None: self.assertEqual(len(rpsc), 1, r) self.assertIsInstance((rpsce := rpsc[0]), dict, r) self.assertIsNotNone((sce := rpsce.get('sce')), r) - self.assertEqual(sce, '10 * * * * * *', r) + self.assertEqual(sce, [ '10 * * * * * *' ], r) @unittest.skipIf(noCSE, 'No CSEBase') From 0bac1dae63fc69403a2b9b9b7867aa3a6a9baa77 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 18 Jul 2023 16:49:14 +0200 Subject: [PATCH 020/165] Added "tui-notify" and "get-loglevel" functions to the script interpreter --- CHANGELOG.md | 2 +- acme/helpers/Interpreter.py | 49 ++++++++++++++++------ acme/services/ScriptManager.py | 77 ++++++++++++++++++++++++++++++++++ acme/services/TextUI.py | 10 +++++ acme/textui/ACMETuiApp.py | 41 +++++++++++++++++- docs/ACMEScript-functions.md | 61 +++++++++++++++++++++++++++ 6 files changed, 224 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6edf6b6..a971ae8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - [CSE] Added automatic pip install of missing dependencies during startup. - [CSE] Added support for <schedule> resource type. -- [SCRIPTS] Added "dotimes" function to the script interpreter. +- [SCRIPTS] Added "dotimes", "tui-notify", and "get-loglevel" functions to the script interpreter. ### Experimental diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index 14cbd94a..b2047105 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -299,7 +299,7 @@ def __init__(self, string:str = None, self.length = 1 else: self.type = SType.tNIL - self.value = False + self.value = None # was: False self.length = 0 @@ -1125,9 +1125,10 @@ def hasMeta(self, key:str) -> bool: def getArgument(self, symbol:SSymbol, - idx:int = None, - expectedType:SType|Tuple[SType, ...] = None, - doEval:bool = True) -> PContext: + idx:Optional[int] = None, + expectedType:Optional[SType|Tuple[SType, ...]] = None, + doEval:Optional[bool] = True, + optional:Optional[bool] = False) -> PContext: """ Verify that an expression is a list and return an argument symbol, while optionally verify the allowed type(s) for that argument. @@ -1140,6 +1141,7 @@ def getArgument(self, symbol:SSymbol, idx: Optional index if the symbol contains a list of symbols. expectedType: one or multiple data types that are allowed for the retrieved argument symbol. doEval: Optionally recursively evaluate the symbol. + optional: Allow the argument to be None. Return: Result `PContext` object with the result, possible changed variable and other states. @@ -1161,6 +1163,9 @@ def getArgument(self, symbol:SSymbol, if expectedType is not None: if isinstance(expectedType, SType): expectedType = ( expectedType, ) + # add NIL if optional + if optional: + expectedType = expectedType + ( SType.tNIL, ) if pcontext.result is not None and pcontext.result.type not in expectedType: raise PInvalidArgumentError(self.setError(PError.invalid, f'expression: {symbol} - invalid type for argument: {_symbol}, expected type: {expectedType}, is: {pcontext.result.type}')) @@ -1170,9 +1175,10 @@ def getArgument(self, symbol:SSymbol, def valueFromArgument(self, symbol:SSymbol, - idx:int = None, - expectedType:SType|Tuple[SType, ...] = None, - doEval:bool = True) -> Tuple[PContext, Any]: + idx:Optional[int] = None, + expectedType:Optional[SType|Tuple[SType, ...]] = None, + doEval:Optional[bool] = True, + optional:Optional[bool] = False) -> Tuple[PContext, Any]: """ Return the actual value from an argument symbol. Args: @@ -1180,18 +1186,24 @@ def valueFromArgument(self, symbol:SSymbol, idx: Optional index if the symbol contains a list of symbols. expectedType: one or multiple data types that are allowed for the retrieved argument symbol. doEval: Optionally recursively evaluate the symbol. + optional: Allow the argument to be optional. Return: Result tuple of the updated `PContext` object with the result and the value. """ - p,r = self.resultFromArgument(symbol, idx, expectedType, doEval) - return (p, r.value) + if idx < symbol.length: + p, r = self.resultFromArgument(symbol, idx, expectedType, doEval, optional) + return (p, r.value) + elif optional: + return (self, None) + raise PInvalidArgumentError(self.setError(PError.invalid, f'expression: {symbol} - invalid argument index: {idx}')) def resultFromArgument(self, symbol:SSymbol, - idx:int = None, - expectedType:SType|Tuple[SType, ...] = None, - doEval:bool = True) -> Tuple[PContext, SSymbol]: + idx:Optional[int] = None, + expectedType:Optional[SType|Tuple[SType, ...]] = None, + doEval:Optional[bool] = True, + optional:Optional[bool] = False) -> Tuple[PContext, SSymbol]: """ Return the `SSymbol` result from an argument symbol. Args: @@ -1199,11 +1211,12 @@ def resultFromArgument(self, symbol:SSymbol, idx: Optional index if the symbol contains a list of symbols. expectedType: one or multiple data types that are allowed for the retrieved argument symbol. doEval: Optionally recursively evaluate the symbol. + optional: Allow the argument to be optional. Return: Result tuple of the updated `PContext` object with the result and the symbol. """ - return (p := self.getArgument(symbol, idx, expectedType, doEval), p.result) + return (p := self.getArgument(symbol, idx, expectedType, doEval, optional), p.result) def executeSubexpression(self, expression:str) -> PContext: @@ -1453,6 +1466,9 @@ def _executeExpression(self, symbol:SSymbol, parentSymbol:SSymbol) -> PContext: elif firstSymbol.type == SType.tJson: return self.checkInStringExpressions(symbol) + + elif firstSymbol.type == SType.tNIL: + return self.setResult(firstSymbol) raise PInvalidArgumentError(self.setError(PError.invalid, f'Unexpected symbol: {firstSymbol.type} - {firstSymbol}')) @@ -1895,6 +1911,13 @@ def _doDatetime(pcontext:PContext, symbol:SSymbol) -> PContext: """ pcontext.assertSymbol(symbol, maxLength = 2) _format = '%Y%m%dT%H%M%S.%f' + + # get format + pcontext, format = pcontext.valueFromArgument(symbol, 1, SType.tString, optional = True) + if format is None: + format = _format + return pcontext.setResult(SSymbol(string = _utcNow().strftime(_format))) + if symbol.length == 2: pcontext, _format = pcontext.valueFromArgument(symbol, 1, SType.tString) return pcontext.setResult(SSymbol(string = _utcNow().strftime(_format))) diff --git a/acme/services/ScriptManager.py b/acme/services/ScriptManager.py index e81bcd86..90d27895 100644 --- a/acme/services/ScriptManager.py +++ b/acme/services/ScriptManager.py @@ -122,6 +122,7 @@ def __init__(self, 'cse-status': self.doCseStatus, 'delete-resource': self.doDeleteResource, 'get-config': self.doGetConfiguration, + 'get-loglevel': self.doGetLogLevel, 'get-storage': self.doGetStorage, 'has-config': self.doHasConfiguration, 'has-storage': self.doHasStorage, @@ -145,6 +146,7 @@ def __init__(self, 'set-config': self.doSetConfig, 'set-console-logging': self.doSetLogging, 'schedule-next-script': self.doScheduleNextScript, + 'tui-notify': self.doTuiNotify, 'tui-refresh-resources': self.doTuiRefreshResources, 'tui-visual-bell': self.doTuiVisualBell, 'update-resource': self.doUpdateResource, @@ -399,6 +401,32 @@ def doGetConfiguration(self, pcontext:PContext, symbol:SSymbol) -> PContext: return pcontext.setResult(SSymbol(value = _v)) + def doGetLogLevel(self, pcontext:PContext, symbol:SSymbol) -> PContext: + """ Get the log level of the CSE. This will be one of the following strings: + + - "DEBUG" + - "INFO" + - "WARNING" + - "ERROR" + - "OFF" + + + Example: + :: + + (get-loglevel) -> "INFO" + + Args: + pcontext: PContext object of the running script. + symbol: The symbol to execute. + + Return: + The updated `PContext` object with the operation result. + """ + pcontext.assertSymbol(symbol, 1) + return pcontext.setResult(SSymbol(string = str(L.logLevel))) + + def doGetStorage(self, pcontext:PContext, symbol:SSymbol) -> PContext: """ Retrieve a value for *key* from the persistent storage *storage*. @@ -1178,6 +1206,55 @@ def doSetLogging(self, pcontext:PContext, symbol:SSymbol) -> PContext: return pcontext.setResult(SSymbol()) + def doTuiNotify(self, pcontext:PContext, symbol:SSymbol) -> PContext: + """ Show a TUI notification. + + This function is only available in TUI mode. It has the following + arguments: + + - message: The message to show. + - title: (Optional) The title of the notification. + - severity: (Optional) The severity of the notification. Can be + one of the following values: `information`, `warning`, `error`. + - timeout: (Optional) The timeout in seconds after which the + notification will disappear. If not specified, the notification + will disappear after 3 seconds. + + + The function returns NIL. + + Example: + :: + + (tui-notify "This is a notification") + + Args: + pcontext: `PContext` object of the running script. + symbol: The symbol to execute. + + Return: + The updated `PContext` object. + """ + pcontext.assertSymbol(symbol, minLength = 2, maxLength = 5) + + # Value + pcontext, value = pcontext.valueFromArgument(symbol, 1, SType.tString) + + # Title + pcontext, title = pcontext.valueFromArgument(symbol, 2, SType.tString, optional = True) + + # Severity + pcontext, severity = pcontext.valueFromArgument(symbol, 3, SType.tString, optional = True) + + # Timeout + pcontext, timeout = pcontext.valueFromArgument(symbol, 4, SType.tNumber, optional = True) + + # show the notification + CSE.textUI.scriptShowNotification(value, title, severity, float(timeout) if timeout is not None else None) + + return pcontext.setResult(SSymbol()) + + def doTuiRefreshResources(self, pcontext:PContext, symbol:SSymbol) -> PContext: """ Refresh the TUI resources. This will update the resource Tree and the resource details. diff --git a/acme/services/TextUI.py b/acme/services/TextUI.py index 1efbd45e..be471ed3 100644 --- a/acme/services/TextUI.py +++ b/acme/services/TextUI.py @@ -182,6 +182,16 @@ def scriptClearConsole(self, scriptName:str) -> None: self.tuiApp.scriptClearConsole(scriptName) + def scriptShowNotification(self, msg:str, title:str, severity:str, timeout:float) -> None: + """ Show a notification. + + Args: + msg: Message to show. + """ + if self.tuiApp: + self.tuiApp.scriptShowNotification(msg, title, severity, timeout) + + def scriptVisualBell(self, scriptName:str) -> None: """ Visual bell. """ diff --git a/acme/textui/ACMETuiApp.py b/acme/textui/ACMETuiApp.py index 599770cb..770b4d93 100644 --- a/acme/textui/ACMETuiApp.py +++ b/acme/textui/ACMETuiApp.py @@ -8,12 +8,17 @@ """ from __future__ import annotations +from typing import Callable +from typing_extensions import Literal, get_args +import asyncio from enum import IntEnum, auto from textual.app import App, ComposeResult from textual import on from textual.widgets import Tab, Footer, TabbedContent, TabPane, Static from textual.binding import Binding from textual.design import ColorSystem +from textual.notifications import Notification, SeverityLevel + from ..textui.ACMEHeader import ACMEHeader from ..textui.ACMEContainerAbout import ACMEContainerAbout from ..textui.ACMEContainerConfigurations import ACMEContainerConfigurations @@ -101,8 +106,11 @@ def __init__(self, textUI:TextUI.TextUI): # This is a bit different from the actual current tab from the self.tabs # attribute because at one point it is used to determine the previous tab. self.currentTab:Tab = None - #self.app.DEFAULT_COLORS = CUSTOM_COLORS - # _app.DEFAULT_COLORS = CUSTOM_COLORS + + # This is used to keep a pointer to the current event loop to use it + # for async calls from non-async functions. + # This is set in the on_load() function. + self.event_loop:asyncio.AbstractEventLoop = None self.tabs = TabbedContent() self.containerTree = ACMEContainerTree() @@ -114,6 +122,7 @@ def __init__(self, textUI:TextUI.TextUI): self.containerAbout = ACMEContainerAbout() self.debugConsole = Static('', id = 'debug-console') + def compose(self) -> ComposeResult: """Build the Main UI.""" yield ACMEHeader(show_clock = True) @@ -140,6 +149,7 @@ def compose(self) -> ComposeResult: def on_load(self) -> None: self.dark = self.textUI.theme == 'dark' self.syntaxTheme = 'ansi_dark' if self.dark else 'ansi_light' + self.event_loop = asyncio.get_event_loop() # self.design = CUSTOM_COLORS # self.refresh_css() @@ -203,6 +213,21 @@ def scriptClearConsole(self, scriptName:str) -> None: self.containerTools.scriptClearConsole(scriptName) + def scriptShowNotification(self, message:str, title:str, severity:Literal['information', 'warning', 'error'], timeout:float) -> None: + + async def _call() -> None: + self.notify(message = message, title = title, severity = severity, timeout = timeout) + + if timeout is None: + timeout = Notification.timeout + if severity is None: + severity = 'information' + elif severity not in get_args(SeverityLevel): + raise ValueError(f'Invalid severity level: {severity}') + + self.runAsyncTask(_call) + + def scriptVisualBell(self, scriptName:str) -> None: if self.containerTools: BackgroundWorkerPool.runJob(lambda:self.containerTools.scriptVisualBell(scriptName)) @@ -213,6 +238,17 @@ def refreshResources(self) -> None: ######################################################################### + + def runAsyncTask(self, task:Callable) -> None: + """ Run an async task from a non-async function. + + Args: + task: The async task to run. + """ + if self.event_loop: + self.event_loop.create_task(task()) + + def restart(self) -> None: self.quitReason = ACMETuiQuitReason.restart self.exit() @@ -222,6 +258,7 @@ def cleanUp(self) -> None: """ Clean up the UI before exiting. """ self.containerTools.cleanUp() + self.event_loop = None # diff --git a/docs/ACMEScript-functions.md b/docs/ACMEScript-functions.md index 9b5f372f..1aac2c13 100644 --- a/docs/ACMEScript-functions.md +++ b/docs/ACMEScript-functions.md @@ -68,6 +68,7 @@ The following built-in functions and variables are provided by the ACMEScript in | [CSE](#_cse) | [clear-console](#clear-console) | Clear the console screen | | | [cse-status](#cse-status) | Return the CSE's current status | | | [get-config](#get-config) | Retrieve a CSE's configuration setting | +| | [get-loglevel](#get-loglevel) | Retrieve the CSE's current log level | | | [get-storage](#get-storage) | Retrieve a value from the CSE's internal script-data storage | | | [has-config](#has-config) | Determine the existence of a CSE's configuration setting | | | [has-storage](#has-storage) | Determine the existence of a key/value in the CSE's internal script-data storage | @@ -91,6 +92,7 @@ The following built-in functions and variables are provided by the ACMEScript in | [Text UI](#_textui) | [open-web-browser](#open-web-browser) | Open a web page in the default browser | | | [set-category-description](#set-category-description) | Set the description for a whole category of scripts | | | [runs-in-tui](#runs-in-tui) | Determine whether the CSE runs in Text UI mode | +| | [tui-notify](#tui-notify) | Display a desktop-like notification | | | [tui-refresh-resources](#tui-refresh-resources) | Force a refresh of the Text UI's resource tree | | | [tui-visual-bell](#tui-visual-bell) | Shortly flashes the script's entry in the text UI's scripts list | | [Network](#_network) | [http](#http) | Send http requests | @@ -1501,6 +1503,29 @@ Examples: --- + + +### get-loglevel + +`(get-loglevel)` + +The `get-loglevel` function retrieves a the CSE's current log level setting. The return value will be one of the following strings: + +- "DEBUG" +- "INFO" +- "WARNING" +- "ERROR" +- "OFF" + +Example: +```lisp +(get-loglevel) ;; Return, for example, INFO +``` + +[top](#top) + +--- + ### get-storage @@ -2137,6 +2162,42 @@ Examples: --- + + +### tui-notify + +`(tui-notify [] [:str>] [])` + +Show a desktop-like notification in the TUI. + +This function is only available in TUI mode. It has the following arguments: + +- message: The message to show. +- title: (Optional) The title of the notification. +- severity: (Optional) The severity of the notification. This can be one of the following values: + - information (the default) + - warning + - error +- timeout: (Optional) The timeout in seconds after which the notification will disappear again. If not specified, the notification will disappear after 3 seconds. + +If one of the optional arguments needs to be left out, a *nil* symbol must be used instead. +The function returns NIL. + +Examples: + +```lisp +(tui-notify "a message") ;; Displays "a message" in an information notification for 3 seconds +(tui-notify "a message" "a title") ;; Displays "a message" with title "a title in an information notification for 3 seconds +(tui-notify "a message") ;; Displays "a message" in an information notification for 3 seconds +(tui-notify "a message" nil "warning") ;; Displays "a message" in a warning notification, no title +(tui-notify "a message" nil nil 10) ;; Displays "a message" in an information notification, no title, for 3 seconds + +``` + +[top](#top) + +--- + ### tui-refresh-resources From eb008905577416474b63817a3e636d5c104a635d Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 20 Jul 2023 12:20:01 +0200 Subject: [PATCH 021/165] More match..case conversions --- acme/etc/Utils.py | 196 ++++++------ acme/helpers/Interpreter.py | 263 ++++++++-------- acme/helpers/MQTTConnection.py | 41 +-- acme/resources/SUB.py | 28 +- acme/resources/TS.py | 57 ++-- acme/services/Configuration.py | 79 +++-- acme/services/Console.py | 51 ++-- acme/services/Dispatcher.py | 288 +++++++++--------- acme/services/Importer.py | 55 ++-- acme/services/NotificationManager.py | 117 +++---- acme/services/RequestManager.py | 115 ++++--- acme/services/Storage.py | 19 +- acme/services/Validator.py | 238 ++++++++------- acme/textui/ACMEContainerTree.py | 13 +- acme/textui/ACMETuiApp.py | 1 + tests/config.py | 38 ++- tests/init.py | 55 ++-- .../notificationServer/notificationServer.py | 108 ++++--- 18 files changed, 928 insertions(+), 834 deletions(-) diff --git a/acme/etc/Utils.py b/acme/etc/Utils.py index d446100e..791abd26 100644 --- a/acme/etc/Utils.py +++ b/acme/etc/Utils.py @@ -160,13 +160,15 @@ def isStructured(uri:str) -> bool: Return: Boolean if the URI is in structured format """ - if isCSERelative(uri): - return '/' in uri or uri == CSE.cseRn - elif isSPRelative(uri): - return uri.count('/') > 2 - elif isAbsolute(uri): - return uri.count('/') > 4 - return False + match uri: + case x if isCSERelative(uri): + return '/' in uri or uri == CSE.cseRn + case x if isSPRelative(uri): + return uri.count('/') > 2 + case x if isAbsolute(uri): + return uri.count('/') > 4 + case _: + return False def localResourceID(ri:str) -> Optional[str]: @@ -197,16 +199,19 @@ def _checkDash(ri:str) -> str: if ri == CSE.cseCsi: return CSE.cseRn - if isAbsolute(ri): - if ri.startswith(CSE.cseAbsoluteSlash): - return _checkDash(ri[len(CSE.cseAbsoluteSlash):]) - return None - elif isSPRelative(ri): - if ri.startswith(CSE.cseCsiSlash): - return _checkDash(ri[len(CSE.cseCsiSlash):]) - return None - return ri + match ri: + case x if isAbsolute(x): + if ri.startswith(CSE.cseAbsoluteSlash): + return _checkDash(ri[len(CSE.cseAbsoluteSlash):]) + return None + case x if isSPRelative(x): + if ri.startswith(CSE.cseCsiSlash): + return _checkDash(ri[len(CSE.cseCsiSlash):]) + return None + case _: + return ri + def isValidID(id:str, allowEmpty:Optional[bool] = False) -> bool: """ Test for a valid ID. @@ -318,10 +323,11 @@ def csiFromRelativeAbsoluteUnstructured(id:str) -> Tuple[str, list[str]]: Tuple (CSE ID (no leading slashes) without any SP-ID or CSE-ID, list of path elements) """ ids = id.split('/') - if isSPRelative(id): - return ids[1], ids - elif isAbsolute(id): - return ids[3], ids + match id: + case x if isSPRelative(x): + return ids[1], ids + case x if isAbsolute(x): + return ids[3], ids return id, ids @@ -386,67 +392,62 @@ def retrieveIDFromPath(id:str) -> Tuple[str, str, str, str]: vrPresent = ids.pop() # remove and return last path element idsLen -= 1 - # CSE-Relative (first element is not /) - if lvl == 0: - # L.logDebug("CSE-Relative") - if idsLen == 1 and ((ids[0] != CSE.cseRn and ids[0] != '-') or ids[0] == CSE.cseCsiSlashLess): # unstructured - ri = ids[0] - else: # structured - if ids[0] == '-': # replace placeholder "-". Always convert in CSE-relative - ids[0] = CSE.cseRn - srn = '/'.join(ids) - - # SP-Relative (first element is /) - elif lvl == 1: - # L.logDebug("SP-Relative") - if idsLen < 2: - return None, None, None, f'ID too short: {id}. Must be //.' - csi = ids[0] # extract the csi - if csi != CSE.cseCsiSlashLess: # Not for this CSE? retargeting - if vrPresent: # append last path element again - ids.append(vrPresent) - return id, csi, srn, None # Early return. ri is the (un)structured path - # if idsLen == 1: - # # ri = ids[0] - # return None, None, None, 'ID too short' - #elif idsLen > 1: + match lvl: + + # CSE-Relative (first element is not /) + case 0: + if idsLen == 1 and ((ids[0] != CSE.cseRn and ids[0] != '-') or ids[0] == CSE.cseCsiSlashLess): # unstructured + ri = ids[0] + else: # structured + if ids[0] == '-': # replace placeholder "-". Always convert in CSE-relative + ids[0] = CSE.cseRn + srn = '/'.join(ids) + + # SP-Relative (first element is /) + case 1: + # L.logDebug("SP-Relative") + if idsLen < 2: + return None, None, None, f'ID too short: {id}. Must be //.' + csi = ids[0] # extract the csi + if csi != CSE.cseCsiSlashLess: # Not for this CSE? retargeting + if vrPresent: # append last path element again + ids.append(vrPresent) + return id, csi, srn, None # Early return. ri is the (un)structured path - # replace placeholder "-", convert in CSE-relative when the target is this CSE - if ids[1] == '-' and ids[0] == CSE.cseCsiSlashLess: - ids[1] = CSE.cseRn - if ids[1] == CSE.cseRn: # structured - srn = '/'.join(ids[1:]) # remove the csi part - elif idsLen == 2: # unstructured - ri = ids[1] - else: - return None, None, None, 'Too many "/" level' - - # Absolute (2 first elements are /) - elif lvl == 2: - # L.logDebug("Absolute") - if idsLen < 3: - return None, None, None, 'ID too short. Must be ////.' - spi = ids[0] - csi = ids[1] - if spi != CSE.cseSpid: # Check for SP-ID - return None, None, None, f'SP-ID: {CSE.cseSpid} does not match the request\'s target ID SP-ID: {spi}' - if csi != CSE.cseCsiSlashLess: # Check for CSE-ID - if vrPresent: # append virtual last path element again - ids.append(vrPresent) - return id, csi, srn, None # Not for this CSE? retargeting - # if idsLen == 2: - # ri = ids[1] - # elif idsLen > 2: - - # replace placeholder "-", convert in absolute when the target is this CSE - if ids[2] == '-' and ids[1] == CSE.cseCsiSlashLess: - ids[2] = CSE.cseRn - if ids[2] == CSE.cseRn: # structured - srn = '/'.join(ids[2:]) - elif idsLen == 3: # unstructured - ri = ids[2] - else: - return None, None, None, 'Too many "/" level' + # replace placeholder "-", convert in CSE-relative when the target is this CSE + if ids[1] == '-' and ids[0] == CSE.cseCsiSlashLess: + ids[1] = CSE.cseRn + if ids[1] == CSE.cseRn: # structured + srn = '/'.join(ids[1:]) # remove the csi part + elif idsLen == 2: # unstructured + ri = ids[1] + else: + return None, None, None, 'Too many "/" level' + + + # Absolute (2 first elements are /) + case 2: + # L.logDebug("Absolute") + if idsLen < 3: + return None, None, None, 'ID too short. Must be ////.' + spi = ids[0] + csi = ids[1] + if spi != CSE.cseSpid: # Check for SP-ID + return None, None, None, f'SP-ID: {CSE.cseSpid} does not match the request\'s target ID SP-ID: {spi}' + if csi != CSE.cseCsiSlashLess: # Check for CSE-ID + if vrPresent: # append virtual last path element again + ids.append(vrPresent) + return id, csi, srn, None # Not for this CSE? retargeting + + # replace placeholder "-", convert in absolute when the target is this CSE + if ids[2] == '-' and ids[1] == CSE.cseCsiSlashLess: + ids[2] = CSE.cseRn + if ids[2] == CSE.cseRn: # structured + srn = '/'.join(ids[2:]) + elif idsLen == 3: # unstructured + ri = ids[2] + else: + return None, None, None, 'Too many "/" level' # Now either csi, ri or structured srn is set if ri: @@ -766,22 +767,25 @@ def getAttributeSize(attribute:Any) -> int: Byte size of the attribute's value. """ size = 0 - if isinstance(attribute, str): - size = len(attribute) - elif isinstance(attribute, int): - size = 4 - elif isinstance(attribute, float): - size = 8 - elif isinstance(attribute, bool): - size = 1 - elif isinstance(attribute, list): # recurse a list - for e in attribute: - size += getAttributeSize(e) - elif isinstance(attribute, dict): # recurse a dictionary - for _,v in attribute: - size += getAttributeSize(v) - else: - size = sys.getsizeof(attribute) # fallback for not handled types + + match attribute: + case str(): + size = len(attribute) + case int(): + size = 4 + case float(): + size = 8 + case bool(): + size = 1 + case list(): # recurse a list + for e in attribute: + size += getAttributeSize(e) + case dict(): # recurse a dictionary + for _,v in attribute: + size += getAttributeSize(v) + case _: # fallback for not handled types + size = sys.getsizeof(attribute) + return size diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index b2047105..af3fc2b9 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -176,11 +176,13 @@ def unquote(self) -> SType: Return: The unquotde version of a quoted type. If the type is not a quoted type then return the same type. """ - if self == SType.tListQuote: - return SType.tList - elif self == SType.tSymbolQuote: - return SType.tSymbol - return self + match self: + case SType.tListQuote: + return SType.tList + case SType.tSymbolQuote: + return SType.tSymbol + case _: + return self class SSymbol(object): @@ -237,20 +239,19 @@ def __init__(self, string:str = None, # Try to determine an unknown type if value: - if isinstance(value, bool): - boolean = value - elif isinstance(value, str): - string = value - elif isinstance(value, (int, float)): - number = Decimal(value) - # elif isinstance(value, list): - # lstQuote = value - elif isinstance(value, dict): - jsn = value - elif isinstance(value, list): - lstQuote = [ SSymbol(value = _v) for _v in value ] - else: - raise ValueError(f'Unsupported type: {type(value)} for value: {value}') + match value: + case bool(): + boolean = value + case str(): + string = value + case int() | float(): + number = Decimal(value) + case dict(): + jsn = value + case list(): + lstQuote = [ SSymbol(value = _v) for _v in value ] + case _: + raise ValueError(f'Unsupported type: {type(value)} for value: {value}') # Assign known types if string is not None: # could be empty string @@ -402,16 +403,18 @@ def raw(self) -> Any: Return: The raw value. For types that could not be converted directly the stringified version is returned. """ - if self.type in [ SType.tList, SType.tListQuote ]: - return [ v.raw() for v in cast(list, self.value) ] - elif self.type in [ SType.tBool, SType.tString, SType.tSymbol, SType.tSymbolQuote, SType.tJson ]: - return self.value - if self.type == SType.tNumber: - if '.' in str(self.value): # float or int? - return float(cast(Decimal, self.value)) - return int(cast(Decimal, self.value)) - return str(self.value) - + match self.type: + case SType.tList | SType.tListQuote: + return [ v.raw() for v in cast(list, self.value) ] + case SType.tBool | SType.tString | SType.tSymbol | SType.tSymbolQuote | SType.tJson: + return self.value + case SType.tNumber: + if '.' in str(self.value): # float or int? + return float(cast(Decimal, self.value)) + return int(cast(Decimal, self.value)) + case _: + return str(self.value) + class SExprParser(object): """ Class that implements an S-Expression parser. """ @@ -554,45 +557,51 @@ def ast(self, input:List[SSymbol]|str, index += 1 continue - if symbol.type == SType.tListBegin: # Start of another list - startIndex = index + 1 - matchCtr = 1 # If 0, parenthesis has been matched. - # Determine the matching closing paranthesis on the same level - while matchCtr != 0: - index += 1 - if index >= len(input): - self.errorExpression = input # type:ignore[assignment] - raise ValueError(f'Invalid input: Unmatched opening parenthesis: {input}') - symbol = input[index] - if symbol.type == SType.tListBegin: - matchCtr += 1 - elif symbol.type == SType.tListEnd: - matchCtr -= 1 - - if isQuote: # escaped with ' -> plain list - ast.append(SSymbol(lstQuote = self.ast(input[startIndex:index], False, allowBrackets))) - else: # normal list - ast.append(SSymbol(lst = self.ast(input[startIndex:index], False, allowBrackets))) - elif symbol.type == SType.tListEnd: + match symbol.type: + case SType.tListBegin: + startIndex = index + 1 + matchCtr = 1 # If 0, parenthesis has been matched. + # Determine the matching closing paranthesis on the same level + while matchCtr != 0: + index += 1 + if index >= len(input): + self.errorExpression = input # type:ignore[assignment] + raise ValueError(f'Invalid input: Unmatched opening parenthesis: {input}') + symbol = input[index] + + match symbol.type: + case SType.tListBegin: + matchCtr += 1 + case SType.tListEnd: + matchCtr -= 1 + # ignore other types + + if isQuote: # escaped with ' -> plain list + ast.append(SSymbol(lstQuote = self.ast(input[startIndex:index], False, allowBrackets))) + else: # normal list + ast.append(SSymbol(lst = self.ast(input[startIndex:index], False, allowBrackets))) + + case SType.tListEnd: self.errorExpression = input # type:ignore[assignment] raise ValueError('Invalid input: Unmatched closing parenthesis.') - elif symbol.type == SType.tJson: - ast.append(symbol) - elif symbol.type == SType.tString: - ast.append(symbol) - else: - try: - ast.append(SSymbol(number = Decimal(symbol.value))) # type:ignore [arg-type] - except InvalidOperation: - if symbol.type == SType.tSymbol and symbol.value in [ 'true', 'false' ]: - ast.append(SSymbol(boolean = (symbol.value == 'true'))) - elif symbol.type == SType.tSymbol and symbol.value == 'nil': - ast.append(SSymbol()) - else: - if (_s := cast(str, symbol.value)).startswith('\''): - ast.append(SSymbol(symbolQuote = _s)) - else: - ast.append(symbol) + + case SType.tJson | SType.tString: + ast.append(symbol) + + case _: + try: + ast.append(SSymbol(number = Decimal(symbol.value))) # type:ignore [arg-type] + except InvalidOperation: + match symbol.type: + case SType.tSymbol if symbol.value in [ 'true', 'false' ]: + ast.append(SSymbol(boolean = (symbol.value == 'true'))) + case SType.tSymbol if symbol.value == 'nil': + ast.append(SSymbol()) + case _: + if (_s := cast(str, symbol.value)).startswith('\''): + ast.append(SSymbol(symbolQuote = _s)) + else: + ast.append(symbol) index += 1 isQuote = False @@ -1412,65 +1421,61 @@ def _executeExpression(self, symbol:SSymbol, parentSymbol:SSymbol) -> PContext: return self.setResult(SSymbol()) firstSymbol = symbol[0] if symbol.length and symbol.type == SType.tList else symbol - if firstSymbol.type == SType.tList: - if firstSymbol.length > 0: - # implicit progn - return _doProgn(self, SSymbol(lst = [ SSymbol(symbol = 'progn') ] + symbol.value )) #type:ignore[operator] - else: - self.result = SSymbol() - return self - - elif firstSymbol.type == SType.tListQuote: - return _doQuote(self, SSymbol(lst = [ SSymbol(symbol = 'quote'), SSymbol(lst = firstSymbol.value)])) - - elif firstSymbol.type == SType.tSymbol: - _s = cast(str, firstSymbol.value) - - # Just return already boolean values in the result here - if (_fn := self.functions.get(_s)) is not None: - return self._executeFunction(symbol, _s, _fn) - elif (_cb := self.symbols.get(_s)) is not None: # type:ignore[arg-type] - if self.monitorFunc: - self.monitorFunc(self, firstSymbol) - return _cb(self, symbol) - elif _s in self.call.arguments: - self.result = deepcopy(self.call.arguments[_s]) - return self - elif _s in self.variables: - self.result = deepcopy(self.variables[_s]) - return self - elif _s in self.environment: - self.result = deepcopy(self.environment[_s]) - return self - - # Try to get the symbol's value from the caller, if possible - else: - if self.fallbackFunc: - return self.fallbackFunc(self, symbol) - raise PUndefinedError(self.setError(PError.undefined, f'undefined symbol: {_s} | in symbol: {parentSymbol}')) - - elif firstSymbol.type == SType.tSymbolQuote: - return _doQuote(self, SSymbol(lst = [ SSymbol(symbol = 'quote'), SSymbol(symbol = firstSymbol.value)])) - - elif firstSymbol.type == SType.tLambda: - return self._executeFunction(symbol, cast(str, firstSymbol.value)) - - elif firstSymbol.type == SType.tString: - return self.checkInStringExpressions(firstSymbol) + match firstSymbol.type: + case SType.tList: + if firstSymbol.length > 0: + # implicit progn + return _doProgn(self, SSymbol(lst = [ SSymbol(symbol = 'progn') ] + symbol.value )) #type:ignore[operator] + else: + self.result = SSymbol() + return self - elif firstSymbol.type == SType.tNumber: - return self.setResult(firstSymbol) # type:ignore [arg-type] - - elif firstSymbol.type == SType.tBool: - return self.setResult(firstSymbol) + case SType.tListQuote: + return _doQuote(self, SSymbol(lst = [ SSymbol(symbol = 'quote'), SSymbol(lst = firstSymbol.value)])) + + case SType.tSymbol: + _s = cast(str, firstSymbol.value) + + # Execute function, if defined, or try to find the value in variables, environment, etc. + if (_fn := self.functions.get(_s)) is not None: + return self._executeFunction(symbol, _s, _fn) + elif (_cb := self.symbols.get(_s)) is not None: # type:ignore[arg-type] + if self.monitorFunc: + self.monitorFunc(self, firstSymbol) + return _cb(self, symbol) + elif _s in self.call.arguments: + self.result = deepcopy(self.call.arguments[_s]) + return self + elif _s in self.variables: + self.result = deepcopy(self.variables[_s]) + return self + elif _s in self.environment: + self.result = deepcopy(self.environment[_s]) + return self + + # Try to get the symbol's value from the caller as a last resort + else: + if self.fallbackFunc: + return self.fallbackFunc(self, symbol) + raise PUndefinedError(self.setError(PError.undefined, f'undefined symbol: {_s} | in symbol: {parentSymbol}')) - elif firstSymbol.type == SType.tJson: - return self.checkInStringExpressions(symbol) + case SType.tSymbolQuote: + return _doQuote(self, SSymbol(lst = [ SSymbol(symbol = 'quote'), SSymbol(symbol = firstSymbol.value)])) - elif firstSymbol.type == SType.tNIL: - return self.setResult(firstSymbol) - - raise PInvalidArgumentError(self.setError(PError.invalid, f'Unexpected symbol: {firstSymbol.type} - {firstSymbol}')) + case SType.tLambda: + return self._executeFunction(symbol, cast(str, firstSymbol.value)) + + case SType.tString: + return self.checkInStringExpressions(firstSymbol) + + case SType.tNumber | SType.tBool | SType.tNIL: + return self.setResult(firstSymbol) # type:ignore [arg-type] + + case SType.tJson: + return self.checkInStringExpressions(symbol) + + case _: + raise PInvalidArgumentError(self.setError(PError.invalid, f'Unexpected symbol: {firstSymbol.type} - {firstSymbol}')) def checkInStringExpressions(self, symbol:SSymbol) -> PContext: @@ -1879,12 +1884,14 @@ def _doCons(pcontext:PContext, symbol:SSymbol) -> PContext: # get second symbol pcontext, _second = pcontext.valueFromArgument(symbol, 2) - if _second.type in [SType.tList, SType.tListQuote]: - pcontext.result = deepcopy(_second) - elif _second.type == SType.tNIL: - pcontext.result = SSymbol(lst = []) - else: - pcontext.result = SSymbol(lst = [ deepcopy(_second) ]) + match _second.type: + case SType.tList | SType.tListQuote: + pcontext.result = deepcopy(_second) + case SType.tNIL: + pcontext.result = SSymbol(lst = []) + case _: + pcontext.result = SSymbol(lst = [ deepcopy(_second) ]) + cast(list, pcontext.result.value).insert(0, deepcopy(_first)) return pcontext diff --git a/acme/helpers/MQTTConnection.py b/acme/helpers/MQTTConnection.py index b2c84621..9cd870da 100644 --- a/acme/helpers/MQTTConnection.py +++ b/acme/helpers/MQTTConnection.py @@ -249,20 +249,22 @@ def _onDisconnect(self, client:mqtt.Client, userdata:Any, rc:int) -> None: """ self.messageHandler and self.messageHandler.logging(self, logging.DEBUG, f'MQTT: Disconnected with result code: {rc} ({mqtt.error_string(rc)})') self.subscribedTopics.clear() - if rc == 0: - self.isConnected = False - self.messageHandler and self.messageHandler.onDisconnect(self) - elif rc == 7: - self.isConnected = False - self.messageHandler.logging(self, logging.ERROR, f'MQTT: Cannot disconnect from broker. Result code: {rc} ({mqtt.error_string(rc)})') - self.messageHandler.logging(self, logging.ERROR, f'MQTT: Did another client connected with the same ID ({self.clientID})?') - self.messageHandler and self.messageHandler.onDisconnect(self) - else: - self.isConnected = False - if self.messageHandler: + + match rc: + case 0: + self.isConnected = False + self.messageHandler and self.messageHandler.onDisconnect(self) + case 7: + self.isConnected = False self.messageHandler.logging(self, logging.ERROR, f'MQTT: Cannot disconnect from broker. Result code: {rc} ({mqtt.error_string(rc)})') - self.messageHandler.onDisconnect(self) - self.messageHandler.onError(self, rc) + self.messageHandler.logging(self, logging.ERROR, f'MQTT: Did another client connected with the same ID ({self.clientID})?') + self.messageHandler and self.messageHandler.onDisconnect(self) + case _: + self.isConnected = False + if self.messageHandler: + self.messageHandler.logging(self, logging.ERROR, f'MQTT: Cannot disconnect from broker. Result code: {rc} ({mqtt.error_string(rc)})') + self.messageHandler.onDisconnect(self) + self.messageHandler.onError(self, rc) def _onLog(self, client:mqtt.Client, userdata:Any, level:int, buf:str) -> None: @@ -402,12 +404,13 @@ def idToMQTTClientID(id:str, isCSE:Optional[bool] = True) -> str: def mqttToId(mqttId:str, isCSE:Optional[bool] = True) -> Tuple[str, bool]: """ Convert an MQTT compatible path element to an ID. """ - if mqttId.startswith('A:'): - isCSE = False - elif mqttId.startswith('C:'): - isCSE = True - else: - return None, False + match mqttId: + case x if x.startswith('A:'): + isCSE = False + case x if x.startswith('C:'): + isCSE = True + case _: + return None, False return mqttId[2:].replace(':', '/'), isCSE diff --git a/acme/resources/SUB.py b/acme/resources/SUB.py index e648f8d2..911681e8 100644 --- a/acme/resources/SUB.py +++ b/acme/resources/SUB.py @@ -100,17 +100,23 @@ def activate(self, parentResource:Resource, originator:str) -> None: # Apply the nct only on the first element of net. Do the combination checks later in validate() net = self['enc/net'] if len(net) > 0: - if net[0] in [ NotificationEventType.resourceUpdate, NotificationEventType.resourceDelete, - NotificationEventType.createDirectChild, NotificationEventType.deleteDirectChild, - NotificationEventType.retrieveCNTNoChild ]: - self.setAttribute('nct', NotificationContentType.allAttributes, overwrite = False) - elif net[0] in [ NotificationEventType.triggerReceivedForAE ]: - self.setAttribute('nct', NotificationContentType.triggerPayload, overwrite = False) - elif net[0] in [ NotificationEventType.blockingUpdate ]: - self.setAttribute('nct', NotificationContentType.modifiedAttributes, overwrite = False) - elif net[0] in [ NotificationEventType.reportOnGeneratedMissingDataPoints ]: - self.setAttribute('nct', NotificationContentType.timeSeriesNotification, overwrite = False) - + match net[0]: + case NotificationEventType.resourceUpdate |\ + NotificationEventType.resourceDelete |\ + NotificationEventType.createDirectChild |\ + NotificationEventType.deleteDirectChild |\ + NotificationEventType.retrieveCNTNoChild: + self.setAttribute('nct', NotificationContentType.allAttributes, overwrite = False) + + case NotificationEventType.triggerReceivedForAE: + self.setAttribute('nct', NotificationContentType.triggerPayload, overwrite = False) + + case NotificationEventType.blockingUpdate: + self.setAttribute('nct', NotificationContentType.modifiedAttributes, overwrite = False) + + case NotificationEventType.reportOnGeneratedMissingDataPoints: + self.setAttribute('nct', NotificationContentType.timeSeriesNotification, overwrite = False) + # check whether an observed child resource type is actually allowed by the parent if chty := self['enc/chty']: self._checkAllowedCHTY(parentResource, chty) diff --git a/acme/resources/TS.py b/acme/resources/TS.py index 2471b848..f3d1484d 100644 --- a/acme/resources/TS.py +++ b/acme/resources/TS.py @@ -237,39 +237,42 @@ def childWillBeAdded(self, childResource:Resource, originator:str) -> None: def childAdded(self, childResource:Resource, originator:str) -> None: L.isDebug and L.logDebug(f'Child resource added: {childResource.ri}') super().childAdded(childResource, originator) - if childResource.ty == ResourceTypes.TSI: # Validate if child is TSI - - # Check for mia handling. This sets the et attribute in the TSI - if self.mia is not None: - # Take either mia or the maxExpirationDelta, whatever is smaller - maxEt = getResourceDate(self.mia - if self.mia <= CSE.request.maxExpirationDelta - else CSE.request.maxExpirationDelta) - # Only replace the childresource's et if it is greater than the calculated maxEt - if childResource.et > maxEt: - childResource.setAttribute('et', maxEt) - childResource.dbUpdate(True) - - self.validate(originator) # Handle old TSI removals - - # Add to monitoring if this is enabled for this TS (mdd & pei & mdt are not None, and mdd==True) - if self.mdd and self.pei is not None and self.mdt is not None: - CSE.timeSeries.updateTimeSeries(self, childResource) - - elif childResource.ty == ResourceTypes.SUB: # start monitoring - if childResource['enc/md']: - CSE.timeSeries.addSubscription(self, childResource) + match childResource.ty: + case ResourceTypes.TSI: + # Check for mia handling. This sets the et attribute in the TSI + if self.mia is not None: + # Take either mia or the maxExpirationDelta, whatever is smaller + maxEt = getResourceDate(self.mia + if self.mia <= CSE.request.maxExpirationDelta + else CSE.request.maxExpirationDelta) + # Only replace the childresource's et if it is greater than the calculated maxEt + if childResource.et > maxEt: + childResource.setAttribute('et', maxEt) + childResource.dbUpdate(True) + + self.validate(originator) # Handle old TSI removals + + # Add to monitoring if this is enabled for this TS (mdd & pei & mdt are not None, and mdd==True) + if self.mdd and self.pei is not None and self.mdt is not None: + CSE.timeSeries.updateTimeSeries(self, childResource) + + case ResourceTypes.SUB: + # start monitoring + if childResource['enc/md']: + CSE.timeSeries.addSubscription(self, childResource) # Handle the removal of a TSI. def childRemoved(self, childResource:Resource, originator:str) -> None: L.isDebug and L.logDebug(f'Child resource removed: {childResource.ri}') super().childRemoved(childResource, originator) - if childResource.ty == ResourceTypes.TSI: # Validate if child was TSI - self._validateChildren() - elif childResource.ty == ResourceTypes.SUB: - if childResource['enc/md']: - CSE.timeSeries.removeSubscription(self, childResource) + match childResource.ty: + case ResourceTypes.TSI: + # Validate if removed child was TSI + self._validateChildren() + case ResourceTypes.SUB: + if childResource['enc/md']: + CSE.timeSeries.removeSubscription(self, childResource) # handle eventuel updates of subscriptions diff --git a/acme/services/Configuration.py b/acme/services/Configuration.py index 5122c853..7f09ed4e 100644 --- a/acme/services/Configuration.py +++ b/acme/services/Configuration.py @@ -362,6 +362,24 @@ def init(args:argparse.Namespace = None) -> bool: 'mqtt.security.useTLS' : config.getboolean('mqtt.security', 'useTLS', fallback = False), 'mqtt.security.verifyCertificate' : config.getboolean('mqtt.security', 'verifyCertificate', fallback = False), + # + # CoAP Client + # + + 'coap.enable' : config.getboolean('coap', 'enable', fallback = False), + 'coap.listenIF' : config.get('coap', 'listenIF', fallback = '127.0.0.1'), + 'coap.port' : config.getint('coap', 'port', fallback = None), # Default will be determined later (s.b.) + + # + # CoAP Client Security + # + + 'coap.security.certificateFile' : config.get('coap.security', 'certificateFile', fallback = None), + 'coap.security.privateKeyFile' : config.get('coap.security', 'privateKeyFile', fallback = None), + 'coap.security.dtlsVersion' : config.get('coap.security', 'dtlsVersion', fallback = 'auto'), + 'coap.security.useDTLS' : config.getboolean('coap.security', 'useDTLS', fallback = False), + 'coap.security.verifyCertificate' : config.getboolean('coap.security', 'verifyCertificate', fallback = False), + # # Defaults for Access Control Policies @@ -465,12 +483,15 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: # CSE type if isinstance(cseType := Configuration._configuration['cse.type'], str): cseType = cseType.lower() - if cseType == 'asn': - Configuration._configuration['cse.type'] = CSEType.ASN - elif cseType == 'mn': - Configuration._configuration['cse.type'] = CSEType.MN - else: - Configuration._configuration['cse.type'] = CSEType.IN + match cseType: + case 'asn': + Configuration._configuration['cse.type'] = CSEType.ASN + case 'mn': + Configuration._configuration['cse.type'] = CSEType.MN + case 'in': + Configuration._configuration['cse.type'] = CSEType.IN + case _: + return False, f'Configuration Error: Unsupported \[cse]:type: {cseType}' # CSE Serialization if isinstance(ct := Configuration._configuration['cse.defaultSerialization'], str): @@ -489,16 +510,21 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: if isinstance(logLevel := Configuration._configuration['logging.level'], str): logLevel = logLevel.lower() logLevel = (Configuration._argsLoglevel or logLevel) # command line args override config - if logLevel == 'off': - Configuration._configuration['logging.level'] = LogLevel.OFF - elif logLevel == 'info': - Configuration._configuration['logging.level'] = LogLevel.INFO - elif logLevel in [ 'warn', 'warning' ]: - Configuration._configuration['logging.level'] = LogLevel.WARNING - elif logLevel == 'error': - Configuration._configuration['logging.level'] = LogLevel.ERROR - else: - Configuration._configuration['logging.level'] = LogLevel.DEBUG + + match logLevel: + case 'off': + Configuration._configuration['logging.level'] = LogLevel.OFF + case 'info': + Configuration._configuration['logging.level'] = LogLevel.INFO + case 'warn' | 'warning': + Configuration._configuration['logging.level'] = LogLevel.WARNING + case 'error': + Configuration._configuration['logging.level'] = LogLevel.ERROR + case 'debug': + Configuration._configuration['logging.level'] = LogLevel.DEBUG + case _: + return False, f'Configuration Error: Unsupported \[logging]:level: {logLevel}' + # Test for correct logging queue size if (queueSize := Configuration._configuration['logging.queueSize']) < 0: @@ -557,7 +583,7 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: # Some sanity and validity checks # - # TLS & certificates + # HTTP TLS & certificates if not Configuration._configuration['http.security.useTLS']: # clear certificates configuration if not in use Configuration._configuration['http.security.verifyCertificate'] = False Configuration._configuration['http.security.tlsVersion'] = 'auto' @@ -591,6 +617,25 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: Configuration._configuration['mqtt.security.allowedCredentialIDs'] = [ cid for cid in Configuration._configuration['mqtt.security.allowedCredentialIDs'] if len(cid) ] + # COAP TLS & certificates + if not Configuration._configuration['coap.security.useDTLS']: # clear certificates configuration if not in use + Configuration._configuration['coap.security.verifyCertificate'] = False + Configuration._configuration['coap.security.tlsVersion'] = 'auto' + Configuration._configuration['coap.security.caCertificateFile'] = '' + Configuration._configuration['coap.security.caPrivateKeyFile'] = '' + else: + if not (val := Configuration._configuration['coap.security.dtlsVersion']).lower() in [ 'tls1.1', 'tls1.2', 'auto' ]: + return False, f'Configuration Error: Unknown value for [i]\[coap.security]:dtlsVersion[/i]: {val}' + if not (val := Configuration._configuration['coap.security.certificateFile']): + return False, 'Configuration Error: [i]\[coap.security]:certificateFile[/i] must be set when DTLS is enabled' + if not os.path.exists(val): + return False, f'Configuration Error: [i]\[coap.security]:certificateFile[/i] does not exists or is not accessible: {val}' + if not (val := Configuration._configuration['coap.security.privateKeyFile']): + return False, 'Configuration Error: [i]\[coap.security]:privateKeyFile[/i] must be set when TLS is enabled' + if not os.path.exists(val): + return False, f'Configuration Error: [i]\[coap.security]:privateKeyFile[/i] does not exists or is not accessible: {val}' + + # check the csi format and value if not isValidCSI(val:=Configuration._configuration['cse.cseID']): return False, f'Configuration Error: Wrong format for [i]\[cse]:cseID[/i]: {val}' diff --git a/acme/services/Console.py b/acme/services/Console.py index 8760a686..36eb7e62 100644 --- a/acme/services/Console.py +++ b/acme/services/Console.py @@ -1374,36 +1374,39 @@ def info(res:Resource) -> str: if self.treeMode not in [ TreeMode.COMPACT, TreeMode.CONTENTONLY ]: # if res.ty in [ T.FCNT, T.FCI] : # extraInfo = f' (cnd={res.cnd})' - if res.ty in [ ResourceTypes.CIN, ResourceTypes.TS ]: - extraInfo = f' ({res.cnf})' if res.cnf else '' - elif res.ty in [ ResourceTypes.CSEBase, ResourceTypes.CSEBaseAnnc, ResourceTypes.CSR ]: - extraInfo = f' (csi={res.csi})' - + match res.ty: + case ResourceTypes.FCNT | ResourceTypes.FCI: + extraInfo = f' ({res.cnf})' if res.cnf else '' + case ResourceTypes.CSEBase | ResourceTypes.CSEBaseAnnc | ResourceTypes.CSR: + extraInfo = f' (csi={res.csi})' + # Determine content contentInfo = '' if self.treeMode in [ TreeMode.CONTENT, TreeMode.CONTENTONLY ]: - if res.ty in [ ResourceTypes.CIN, ResourceTypes.TSI ]: - contentInfo = f'{res.con}' if res.con else '' - elif res.ty in [ ResourceTypes.FCNT, ResourceTypes.FCI ]: # All the custom attributes - contentInfo = ', '.join([ f'{attr}={str(res[attr])}' for attr in res.dict if CSE.validator.isExtraResourceAttribute(attr, res) ]) + match res.ty: + case ResourceTypes.CIN | ResourceTypes.TSI: + contentInfo = f'{res.con}' if res.con else '' + case ResourceTypes.FCNT | ResourceTypes.FCI: + contentInfo = ', '.join([ f'{attr}={str(res[attr])}' for attr in res.dict if CSE.validator.isExtraResourceAttribute(attr, res) ]) # construct the info info = '' - if self.treeMode == TreeMode.COMPACT: - info = f'-> {res.__rtype__}' - elif self.treeMode == TreeMode.CONTENT: - if len(contentInfo) > 0: - info = f'-> {res.__rtype__}{extraInfo} | {contentInfo}' - else: - info = f'-> {res.__rtype__}{extraInfo}' - elif self.treeMode == TreeMode.CONTENTONLY: - if len(contentInfo) > 0: - info = f'-> {contentInfo}' - else: # self.treeMode == NORMAL - if res.isVirtual(): - info = f'-> {res.__rtype__}{extraInfo} (virtual)' - else: - info = f'-> {res.__rtype__}{extraInfo} | ri={res.ri}' + match self.treeMode: + case TreeMode.COMPACT: + info = f'-> {res.__rtype__}' + case TreeMode.CONTENT: + if len(contentInfo) > 0: + info = f'-> {res.__rtype__}{extraInfo} | {contentInfo}' + else: + info = f'-> {res.__rtype__}{extraInfo}' + case TreeMode.CONTENTONLY: + if len(contentInfo) > 0: + info = f'-> {contentInfo}' + case _: # self.treeMode == NORMAL + if res.isVirtual(): + info = f'-> {res.__rtype__}{extraInfo} (virtual)' + else: + info = f'-> {res.__rtype__}{extraInfo} | ri={res.ri}' return f'{res.rn} [dim]{info}[/dim]' diff --git a/acme/services/Dispatcher.py b/acme/services/Dispatcher.py index 2b54128e..971a151c 100644 --- a/acme/services/Dispatcher.py +++ b/acme/services/Dispatcher.py @@ -177,57 +177,62 @@ def processRetrieveRequest(self, request:CSERequest, return Result(rsc = ResponseStatusCode.OK, resource = self._resourcesToURIList(_resources, request.drt)) else: - if rcn in [ ResultContentType.attributes, - ResultContentType.attributesAndChildResources, - ResultContentType.childResources, - ResultContentType.attributesAndChildResourceReferences, - ResultContentType.originalResource ]: - resource = self.retrieveResource(id, originator, request) - - if not CSE.security.hasAccess(originator, resource, permission): - raise ORIGINATOR_HAS_NO_PRIVILEGE(L.logDebug(f'originator: {originator} has no {permission} privileges for resource: {resource.ri}')) - - # if rcn == "attributes" then we can return here, whatever the result is - if rcn == ResultContentType.attributes: - resource.willBeRetrieved(originator, request) # resource instance may be changed in this call - - # partial retrieve? - return self._partialFromResource(resource, attributeList) - - # if rcn == original-resource we retrieve the linked resource - if rcn == ResultContentType.originalResource: - # Some checks for resource validity - if not resource.isAnnounced(): - raise BAD_REQUEST(L.logDebug(f'Resource {resource.ri} is not an announced resource')) - if not (lnk := resource.lnk): # no link attribute? - raise INTERNAL_SERVER_ERROR('internal error: missing lnk attribute in target resource') - - # Retrieve and check the linked-to request - linkedResource = self.retrieveResource(lnk, originator, request) - - # Normally, we would do some checks here and call "willBeRetrieved", - # but we don't have to, because the resource is already checked during the - # retrieveResource call by the hosting CSE - # partial retrieve? - return self._partialFromResource(linkedResource, attributeList) - - # - # Semantic query request - # This is indicated by rcn = semantic content - # - if rcn == ResultContentType.semanticContent: - L.isDebug and L.logDebug('Performing semantic discovery / query') - # Validate SPARQL in semanticFilter - CSE.semantic.validateSPARQL(request.fc.smf) - - # Get all accessible semanticDescriptors - resources = self.discoverResources(id, originator, filterCriteria = FilterCriteria(ty = [ResourceTypes.SMD])) - - # Execute semantic query - res = CSE.semantic.executeSPARQLQuery(request.fc.smf, cast(Sequence[SMD], resources)) - L.isDebug and L.logDebug(f'SPARQL query result: {res.data}') - return Result(rsc = ResponseStatusCode.OK, data = { 'm2m:qres' : res.data }) + # We can handle some rcn here directly, but some will be handled after this + match rcn: + case ResultContentType.attributes |\ + ResultContentType.attributesAndChildResources |\ + ResultContentType.childResources |\ + ResultContentType.attributesAndChildResourceReferences|\ + ResultContentType.originalResource: + + resource = self.retrieveResource(id, originator, request) + + if not CSE.security.hasAccess(originator, resource, permission): + raise ORIGINATOR_HAS_NO_PRIVILEGE(L.logDebug(f'originator: {originator} has no {permission} privileges for resource: {resource.ri}')) + + match rcn: + case ResultContentType.attributes: + # if rcn == "attributes" then we can return here, whatever the result is + resource.willBeRetrieved(originator, request) # resource instance may be changed in this call + + # partial retrieve? + return self._partialFromResource(resource, attributeList) + + case ResultContentType.originalResource: + # if rcn == original-resource we retrieve the linked resource + + # Some checks for resource validity + if not resource.isAnnounced(): + raise BAD_REQUEST(L.logDebug(f'Resource {resource.ri} is not an announced resource')) + if not (lnk := resource.lnk): # no link attribute? + raise INTERNAL_SERVER_ERROR('internal error: missing lnk attribute in target resource') + + # Retrieve and check the linked-to request + linkedResource = self.retrieveResource(lnk, originator, request) + + # Normally, we would do some checks here and call "willBeRetrieved", + # but we don't have to, because the resource is already checked during the + # retrieveResource call by the hosting CSE + + # partial retrieve? + return self._partialFromResource(linkedResource, attributeList) + + + case ResultContentType.semanticContent: + # Semantic query request + # This is indicated by rcn = semantic content + L.isDebug and L.logDebug('Performing semantic discovery / query') + # Validate SPARQL in semanticFilter + CSE.semantic.validateSPARQL(request.fc.smf) + + # Get all accessible semanticDescriptors + resources = self.discoverResources(id, originator, filterCriteria = FilterCriteria(ty = [ResourceTypes.SMD])) + + # Execute semantic query + res = CSE.semantic.executeSPARQLQuery(request.fc.smf, cast(Sequence[SMD], resources)) + L.isDebug and L.logDebug(f'SPARQL query result: {res.data}') + return Result(rsc = ResponseStatusCode.OK, data = { 'm2m:qres' : res.data }) # # Discovery request @@ -248,28 +253,29 @@ def processRetrieveRequest(self, request:CSERequest, # Handle more sophisticated RCN # - if rcn == ResultContentType.attributesAndChildResources: - self.resourceTreeDict(allowedResources, resource) # the function call add attributes to the target resource - return Result(rsc = ResponseStatusCode.OK, resource = resource) - - elif rcn == ResultContentType.attributesAndChildResourceReferences: - self._resourceTreeReferences(allowedResources, resource, request.drt, 'ch') # the function call add attributes to the target resource - return Result(rsc = ResponseStatusCode.OK, resource = resource) - - elif rcn == ResultContentType.childResourceReferences: - childResourcesRef = self._resourceTreeReferences(allowedResources, None, request.drt, 'm2m:rrl') - return Result(rsc = ResponseStatusCode.OK, resource = childResourcesRef) - - elif rcn == ResultContentType.childResources: - childResources:JSON = { resource.tpe : {} } # Root resource as a dict with no attribute - self.resourceTreeDict(allowedResources, childResources[resource.tpe]) # Adding just child resources - return Result(rsc = ResponseStatusCode.OK, resource = childResources) + match rcn: + case ResultContentType.attributesAndChildResources: + self.resourceTreeDict(allowedResources, resource) # the function call add attributes to the target resource + return Result(rsc = ResponseStatusCode.OK, resource = resource) + + case ResultContentType.attributesAndChildResourceReferences: + self._resourceTreeReferences(allowedResources, resource, request.drt, 'ch') # the function call add attributes to the target resource + return Result(rsc = ResponseStatusCode.OK, resource = resource) + + case ResultContentType.childResourceReferences: + childResourcesRef = self._resourceTreeReferences(allowedResources, None, request.drt, 'm2m:rrl') + return Result(rsc = ResponseStatusCode.OK, resource = childResourcesRef) - elif rcn == ResultContentType.discoveryResultReferences: # URIList - return Result(rsc = ResponseStatusCode.OK, resource = self._resourcesToURIList(allowedResources, request.drt)) + case ResultContentType.childResources: + childResources:JSON = { resource.tpe : {} } # Root resource as a dict with no attribute + self.resourceTreeDict(allowedResources, childResources[resource.tpe]) # Adding just child resources + return Result(rsc = ResponseStatusCode.OK, resource = childResources) - else: - raise BAD_REQUEST(f'unsuppored rcn: {rcn} for RETRIEVE') + case ResultContentType.discoveryResultReferences: + return Result(rsc = ResponseStatusCode.OK, resource = self._resourcesToURIList(allowedResources, request.drt)) + + case _: + raise BAD_REQUEST(f'unsuppored rcn: {rcn} for RETRIEVE') def retrieveResource(self, id:str, @@ -630,29 +636,32 @@ def processCreateRequest(self, request:CSERequest, # Handle RCN's # tpe = _resource.tpe - rcn = request.rcn - if rcn is None or rcn == ResultContentType.attributes: # Just the resource & attributes, integer - return Result(rsc = ResponseStatusCode.CREATED, resource = _resource) - - elif rcn == ResultContentType.modifiedAttributes: - dictOrg = request.pc[tpe] - dictNew = _resource.asDict()[tpe] - return Result(resource = { tpe : resourceModifiedAttributes(dictOrg, dictNew, request.pc[tpe]) }, - rsc = ResponseStatusCode.CREATED) - - elif rcn == ResultContentType.hierarchicalAddress: - return Result(resource = { 'm2m:uri' : _resource.structuredPath() }, - rsc = ResponseStatusCode.CREATED) - - elif rcn == ResultContentType.hierarchicalAddressAttributes: - return Result(resource = { 'm2m:rce' : { noNamespace(tpe) : _resource.asDict()[tpe], 'uri' : _resource.structuredPath() }}, - rsc = ResponseStatusCode.CREATED) - elif rcn == ResultContentType.nothing: - return Result(rsc = ResponseStatusCode.CREATED) + match request.rcn: + case None | ResultContentType.attributes: + # Just the resource & attributes, integer + return Result(rsc = ResponseStatusCode.CREATED, resource = _resource) + + case ResultContentType.modifiedAttributes: + dictOrg = request.pc[tpe] + dictNew = _resource.asDict()[tpe] + return Result(resource = { tpe : resourceModifiedAttributes(dictOrg, dictNew, request.pc[tpe]) }, + rsc = ResponseStatusCode.CREATED) + + case ResultContentType.hierarchicalAddress: + return Result(resource = { 'm2m:uri' : _resource.structuredPath() }, + rsc = ResponseStatusCode.CREATED) + + case ResultContentType.hierarchicalAddressAttributes: + return Result(resource = { 'm2m:rce' : { noNamespace(tpe) : _resource.asDict()[tpe], 'uri' : _resource.structuredPath() }}, + rsc = ResponseStatusCode.CREATED) + + case ResultContentType.nothing: + return Result(rsc = ResponseStatusCode.CREATED) + + case _: + raise BAD_REQUEST('wrong rcn for CREATE') - else: - raise BAD_REQUEST('wrong rcn for CREATE') # TODO C.rcnDiscoveryResultReferences @@ -839,25 +848,28 @@ def processUpdateRequest(self, request:CSERequest, # tpe = resource.tpe - if request.rcn is None or request.rcn == ResultContentType.attributes: # rcn is an int - return Result(rsc = ResponseStatusCode.UPDATED, resource = resource) + + match request.rcn: + case None | ResultContentType.attributes: + return Result(rsc = ResponseStatusCode.UPDATED, resource = resource) + + case ResultContentType.modifiedAttributes: + dictNew = deepcopy(resource.dict) + requestPC = request.pc[tpe] + # return only the modified attributes. This does only include those attributes that are updated differently, or are + # changed by the CSE, then from the original request. Luckily, all key/values that are touched in the update request + # are in the resource's __modified__ variable. + return Result(rsc = ResponseStatusCode.UPDATED, + resource = { tpe : resourceModifiedAttributes(dictOrg, dictNew, requestPC, modifiers = resource[Constants.attrModified]) }) + + case ResultContentType.nothing: + return Result(rsc = ResponseStatusCode.UPDATED) - elif request.rcn == ResultContentType.modifiedAttributes: - dictNew = deepcopy(resource.dict) - requestPC = request.pc[tpe] - # return only the modified attributes. This does only include those attributes that are updated differently, or are - # changed by the CSE, then from the original request. Luckily, all key/values that are touched in the update request - # are in the resource's __modified__ variable. - return Result(rsc = ResponseStatusCode.UPDATED, - resource = { tpe : resourceModifiedAttributes(dictOrg, dictNew, requestPC, modifiers = resource[Constants.attrModified]) }) - elif request.rcn == ResultContentType.nothing: - return Result(rsc = ResponseStatusCode.UPDATED) + case _: + raise BAD_REQUEST('wrong rcn for UPDATE') # TODO C.rcnDiscoveryResultReferences - else: - raise BAD_REQUEST('wrong rcn for UPDATE') - def updateLocalResource(self, resource:Resource, dct:Optional[JSON] = None, @@ -987,38 +999,42 @@ def processDeleteRequest(self, request:CSERequest, # resultContent:Resource|JSON = None - if request.rcn is None or request.rcn == ResultContentType.nothing: # rcn is an int - resultContent = None - - elif request.rcn == ResultContentType.attributes: - resultContent = resource - - # resource and child resources, full attributes - elif request.rcn == ResultContentType.attributesAndChildResources: - children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) - self._childResourceTree(children, resource) # the function call add attributes to the result resource. Don't use the return value directly - resultContent = resource - - # direct child resources, NOT the root resource - elif request.rcn == ResultContentType.childResources: - children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) - childResources:JSON = { resource.tpe : {} } # Root resource as a dict with no attributes - self.resourceTreeDict(children, childResources[resource.tpe]) - resultContent = childResources - - elif request.rcn == ResultContentType.attributesAndChildResourceReferences: - children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) - self._resourceTreeReferences(children, resource, request.drt, 'ch') # the function call add attributes to the result resource - resultContent = resource - - elif request.rcn == ResultContentType.childResourceReferences: # child resource references - children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) - childResourcesRef = self._resourceTreeReferences(children, None, request.drt, 'm2m:rrl') - resultContent = childResourcesRef + match request.rcn: + case None | ResultContentType.nothing: + resultContent = None + + case ResultContentType.attributes: + resultContent = resource + + case ResultContentType.attributesAndChildResources: + # resource and child resources, full attributes + children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) + self._childResourceTree(children, resource) # the function call add attributes to the result resource. Don't use the return value directly + resultContent = resource + + case ResultContentType.childResources: + # direct child resources, NOT the root resource + children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) + childResources:JSON = { resource.tpe : {} } # Root resource as a dict with no attributes + self.resourceTreeDict(children, childResources[resource.tpe]) + resultContent = childResources + + case ResultContentType.attributesAndChildResourceReferences: + # resource and child resource references + children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) + self._resourceTreeReferences(children, resource, request.drt, 'ch') # the function call add attributes to the result resource + resultContent = resource + + case ResultContentType.childResourceReferences: + # direct child resource references, NOT the root resource + children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) + childResourcesRef = self._resourceTreeReferences(children, None, request.drt, 'm2m:rrl') + resultContent = childResourcesRef + + case _: + raise BAD_REQUEST('wrong rcn for DELETE') # TODO RCN.discoveryResultReferences - else: - raise BAD_REQUEST('wrong rcn for DELETE') # remove resource self.deleteLocalResource(resource, originator, withDeregistration = True) diff --git a/acme/services/Importer.py b/acme/services/Importer.py index 31d8d2e8..f2f98b76 100644 --- a/acme/services/Importer.py +++ b/acme/services/Importer.py @@ -117,21 +117,21 @@ def importScripts(self, path:Optional[str] = None) -> bool: return False # Check that there is only one startup script, then execute it - if len(scripts := CSE.script.findScripts(meta = _metaInit)) > 1: - L.logErr(f'Only one initialization script allowed. Found: {",".join([ s.scriptName for s in scripts ])}') - return False - - elif len(scripts) == 1: - # Check whether there is already a filled DB, then skip the imports - if CSE.dispatcher.countResources() > 0: - L.isInfo and L.log('Resources already imported, skipping boostrap') - else: - # Run the startup script. There shall only be one. - s = scripts[0] - L.isInfo and L.log(f'Running boostrap script: {s.scriptName}') - if not CSE.script.runScript(s): - L.logErr(f'Error during startup: {s.error}') - return False + match len(scripts := CSE.script.findScripts(meta = _metaInit)): + case l if l > 1: + L.logErr(f'Only one initialization script allowed. Found: {",".join([ s.scriptName for s in scripts ])}') + return False + case 1: + # Check whether there is already a filled DB, then skip the imports + if CSE.dispatcher.countResources() > 0: + L.isInfo and L.log('Resources already imported, skipping boostrap') + else: + # Run the startup script. There shall only be one. + s = scripts[0] + L.isInfo and L.log(f'Running boostrap script: {s.scriptName}') + if not CSE.script.runScript(s): + L.logErr(f'Error during startup: {s.error}') + return False finally: # This is executed no matter whether the code above returned or just succeeded self._finishImporting() @@ -387,21 +387,22 @@ def importAttributePolicies(self, path:Optional[str] = None) -> bool: # Check whether there is an unresolved type used in any of the attributes (in the type and listType) # TODO ? The following can be optimized sometimes, but since it is only called once during startup the small overhead may be neglectable. for p in CSE.validator.getAllAttributePolicies().values(): - if p.type == BasicType.complex: - for each in CSE.validator.getAllAttributePolicies().values(): - if p.typeName == each.ctype: # found a definition - break - else: - L.logErr(f'No type or complex type definition found: {p.typeName} for attribute: {p.sname} in file: {p.fname}', showStackTrace = False) - return False - elif p.type == BasicType.list and p.ltype is not None: - if p.ltype == BasicType.complex: + match p.type: + case BasicType.complex: for each in CSE.validator.getAllAttributePolicies().values(): - if p.lTypeName == each.ctype: # found a definition + if p.typeName == each.ctype: # found a definition break else: - L.logErr(f'No list sub-type definition found: {p.lTypeName} for attribute: {p.sname} in file: {p.fname}', showStackTrace = False) - return False + L.logErr(f'No type or complex type definition found: {p.typeName} for attribute: {p.sname} in file: {p.fname}', showStackTrace = False) + return False + case BasicType.list if p.ltype is not None: + if p.ltype == BasicType.complex: + for each in CSE.validator.getAllAttributePolicies().values(): + if p.lTypeName == each.ctype: # found a definition + break + else: + L.logErr(f'No list sub-type definition found: {p.lTypeName} for attribute: {p.sname} in file: {p.fname}', showStackTrace = False) + return False L.isDebug and L.logDebug(f'Imported {countAP} attribute policies') return True diff --git a/acme/services/NotificationManager.py b/acme/services/NotificationManager.py index e79463c9..d38048de 100644 --- a/acme/services/NotificationManager.py +++ b/acme/services/NotificationManager.py @@ -304,59 +304,62 @@ def checkSubscriptions( self, if reason not in sub['net']: # check whether reason is actually included in the subscription continue - if reason in [ NotificationEventType.createDirectChild, NotificationEventType.deleteDirectChild ]: # reasons for child resources - chty = sub['chty'] - if chty and not childResource.ty in chty: # skip if chty is set and child.type is not in the list - continue - self._handleSubscriptionNotification(sub, - reason, - resource = childResource, - modifiedAttributes = modifiedAttributes, - asynchronous = self.asyncSubscriptionNotifications) - self.countNotificationEvents(ri) - - # Check Update and enc/atr vs the modified attributes - elif reason == NotificationEventType.resourceUpdate and (atr := sub['atr']) and modifiedAttributes: - found = False - for k in atr: - if k in modifiedAttributes: - found = True - if found: + + match reason: + case NotificationEventType.createDirectChild | NotificationEventType.deleteDirectChild: # reasons for child resources + chty = sub['chty'] + if chty and not childResource.ty in chty: # skip if chty is set and child.type is not in the list + continue self._handleSubscriptionNotification(sub, reason, - resource = resource, - modifiedAttributes = modifiedAttributes, + resource = childResource, + modifiedAttributes = modifiedAttributes, asynchronous = self.asyncSubscriptionNotifications) self.countNotificationEvents(ri) - else: - L.isDebug and L.logDebug('Skipping notification: No matching attributes found') - # Check for missing data points (only for ) - elif reason == NotificationEventType.reportOnGeneratedMissingDataPoints and missingData: - md = missingData[sub['ri']] - if md.missingDataCurrentNr >= md.missingDataNumber: # Always send missing data if the count is greater then the minimum number + # Check Update and enc/atr vs the modified attributes + case NotificationEventType.resourceUpdate if (atr := sub['atr']) and modifiedAttributes: + found = False + for k in atr: + if k in modifiedAttributes: + found = True + if found: # any one found + self._handleSubscriptionNotification(sub, + reason, + resource = resource, + modifiedAttributes = modifiedAttributes, + asynchronous = self.asyncSubscriptionNotifications) + self.countNotificationEvents(ri) + else: + L.isDebug and L.logDebug('Skipping notification: No matching attributes found') + + # Check for missing data points (only for ) + case NotificationEventType.reportOnGeneratedMissingDataPoints if missingData: + md = missingData[sub['ri']] + if md.missingDataCurrentNr >= md.missingDataNumber: # Always send missing data if the count is greater then the minimum number + self._handleSubscriptionNotification(sub, + NotificationEventType.reportOnGeneratedMissingDataPoints, + missingData = copy.deepcopy(md), + asynchronous = self.asyncSubscriptionNotifications) + self.countNotificationEvents(ri) + md.clearMissingDataList() + + case NotificationEventType.blockingUpdate | NotificationEventType.blockingRetrieve | NotificationEventType.blockingRetrieveDirectChild: self._handleSubscriptionNotification(sub, - NotificationEventType.reportOnGeneratedMissingDataPoints, - missingData = copy.deepcopy(md), - asynchronous = self.asyncSubscriptionNotifications) + reason, + resource, + modifiedAttributes = modifiedAttributes, + asynchronous = False) # blocking NET always synchronous! self.countNotificationEvents(ri) - md.clearMissingDataList() - - elif reason in [NotificationEventType.blockingUpdate, NotificationEventType.blockingRetrieve, NotificationEventType.blockingRetrieveDirectChild]: - self._handleSubscriptionNotification(sub, - reason, - resource, - modifiedAttributes = modifiedAttributes, - asynchronous = False) # blocking NET always synchronous! - self.countNotificationEvents(ri) - else: # all other reasons that target the resource - self._handleSubscriptionNotification(sub, - reason, - resource, - modifiedAttributes = modifiedAttributes, - asynchronous = self.asyncSubscriptionNotifications) - self.countNotificationEvents(ri) + # all other reasons that target the resource + case _: + self._handleSubscriptionNotification(sub, + reason, + resource, + modifiedAttributes = modifiedAttributes, + asynchronous = self.asyncSubscriptionNotifications) + self.countNotificationEvents(ri) def checkPerformBlockingUpdate(self, resource:Resource, @@ -764,17 +767,19 @@ def receivedCrossResourceSubscriptionNotification(self, sur:str, crs:Resource) - crsTwt = crs.twt crsTws = crs.tws L.isDebug and L.logDebug(f'Received notification for : {crsRi}, twt: {crsTwt}, tws: {crsTws}') - if crsTwt == TimeWindowType.SLIDINGWINDOW: - if (workers := BackgroundWorkerPool.findWorkers(self._getSlidingWorkerName(crsRi))): - L.isDebug and L.logDebug(f'Adding notification to worker: {workers[0].name}') - if sur not in workers[0].data: - workers[0].data.append(sur) - else: - workers = [ self.startCRSSlidingWindow(crsRi, crsTws, sur, crs._countSubscriptions(), crs.eem) ] # sur is added automatically when creating actor - elif crsTwt == TimeWindowType.PERIODICWINDOW: - if (workers := BackgroundWorkerPool.findWorkers(self._getPeriodicWorkerName(crsRi))): - if sur not in workers[0].data: - workers[0].data.append(sur) + match crsTwt: + case TimeWindowType.SLIDINGWINDOW: + if (workers := BackgroundWorkerPool.findWorkers(self._getSlidingWorkerName(crsRi))): + L.isDebug and L.logDebug(f'Adding notification to worker: {workers[0].name}') + if sur not in workers[0].data: + workers[0].data.append(sur) + else: + workers = [ self.startCRSSlidingWindow(crsRi, crsTws, sur, crs._countSubscriptions(), crs.eem) ] # sur is added automatically when creating actor + + case TimeWindowType.PERIODICWINDOW: + if (workers := BackgroundWorkerPool.findWorkers(self._getPeriodicWorkerName(crsRi))): + if sur not in workers[0].data: + workers[0].data.append(sur) # No else: Periodic is running or not diff --git a/acme/services/RequestManager.py b/acme/services/RequestManager.py index 95c17f90..8c011ed2 100644 --- a/acme/services/RequestManager.py +++ b/acme/services/RequestManager.py @@ -306,17 +306,16 @@ def handleReceivedNotifyRequest(self, id:str, request:CSERequest, originator:str def retrieveRequest(self, request:CSERequest) -> Result: L.isDebug and L.logDebug(f'RETRIEVE ID: {request.id if request.id else request.srn}, originator: {request.originator}') - if request.rt == ResponseType.blockingRequest: - return CSE.dispatcher.processRetrieveRequest(request, request.originator) - - elif request.rt in [ ResponseType.nonBlockingRequestSynch, ResponseType.nonBlockingRequestAsynch ]: - return self._handleNonBlockingRequest(request) - - elif request.rt == ResponseType.flexBlocking: - if self.flexBlockingBlocking: # flexBlocking as blocking - return CSE.dispatcher.processRetrieveRequest(request, request .originator) - else: # flexBlocking as non-blocking + match request.rt: + case ResponseType.blockingRequest: + return CSE.dispatcher.processRetrieveRequest(request, request.originator) + case ResponseType.nonBlockingRequestSynch | ResponseType.nonBlockingRequestAsynch: return self._handleNonBlockingRequest(request) + case ResponseType.flexBlocking: + if self.flexBlockingBlocking: # flexBlocking as blocking + return CSE.dispatcher.processRetrieveRequest(request, request .originator) + else: # flexBlocking as non-blocking + return self._handleNonBlockingRequest(request) raise BAD_REQUEST(f'Unknown or unsupported ResponseType: {request.rt}') @@ -333,17 +332,16 @@ def createRequest(self, request:CSERequest) -> Result: if request.ty == None: raise BAD_REQUEST('missing or wrong resourceType in request') - if request.rt == ResponseType.blockingRequest: - return CSE.dispatcher.processCreateRequest(request, request.originator) - - elif request.rt in [ ResponseType.nonBlockingRequestSynch, ResponseType.nonBlockingRequestAsynch ]: - return self._handleNonBlockingRequest(request) - - elif request.rt == ResponseType.flexBlocking: - if self.flexBlockingBlocking: # flexBlocking as blocking + match request.rt: + case ResponseType.blockingRequest: return CSE.dispatcher.processCreateRequest(request, request.originator) - else: # flexBlocking as non-blocking + case ResponseType.nonBlockingRequestSynch | ResponseType.nonBlockingRequestAsynch: return self._handleNonBlockingRequest(request) + case ResponseType.flexBlocking: + if self.flexBlockingBlocking: # flexBlocking as blocking + return CSE.dispatcher.processCreateRequest(request, request.originator) + else: # flexBlocking as non-blocking + return self._handleNonBlockingRequest(request) raise BAD_REQUEST(f'Unknown or unsupported ResponseType: {request.rt}') @@ -361,17 +359,16 @@ def updateRequest(self, request:CSERequest) -> Result: raise OPERATION_NOT_ALLOWED('operation not allowed for CSEBase') # Check contentType and resourceType - if request.rt == ResponseType.blockingRequest: - return CSE.dispatcher.processUpdateRequest(request, request.originator) - - elif request.rt in [ ResponseType.nonBlockingRequestSynch, ResponseType.nonBlockingRequestAsynch ]: - return self._handleNonBlockingRequest(request) - - elif request.rt == ResponseType.flexBlocking: - if self.flexBlockingBlocking: # flexBlocking as blocking + match request.rt: + case ResponseType.blockingRequest: return CSE.dispatcher.processUpdateRequest(request, request.originator) - else: # flexBlocking as non-blocking + case ResponseType.nonBlockingRequestSynch | ResponseType.nonBlockingRequestAsynch: return self._handleNonBlockingRequest(request) + case ResponseType.flexBlocking: + if self.flexBlockingBlocking: # flexBlocking as blocking + return CSE.dispatcher.processUpdateRequest(request, request.originator) + else: # flexBlocking as non-blocking + return self._handleNonBlockingRequest(request) raise BAD_REQUEST(f'Unknown or unsupported ResponseType: {request.rt}') @@ -389,18 +386,17 @@ def deleteRequest(self, request:CSERequest,) -> Result: if request.id in [ CSE.cseRi, CSE.cseRi, CSE.cseRn ]: raise OPERATION_NOT_ALLOWED(dbg = 'DELETE operation is not allowed for CSEBase') - if request.rt == ResponseType.blockingRequest or (request.rt == ResponseType.flexBlocking and self.flexBlockingBlocking): - return CSE.dispatcher.processDeleteRequest(request, request.originator) - - elif request.rt in [ ResponseType.nonBlockingRequestSynch, ResponseType.nonBlockingRequestAsynch ]: - return self._handleNonBlockingRequest(request) - - elif request.rt == ResponseType.flexBlocking: - if self.flexBlockingBlocking: # flexBlocking as blocking + match request.rt: + case ResponseType.blockingRequest: return CSE.dispatcher.processDeleteRequest(request, request.originator) - else: # flexBlocking as non-blocking + case ResponseType.nonBlockingRequestSynch | ResponseType.nonBlockingRequestAsynch: return self._handleNonBlockingRequest(request) - + case ResponseType.flexBlocking: # flexBlocking as non-blocking + if self.flexBlockingBlocking: # flexBlocking as blocking + return CSE.dispatcher.processDeleteRequest(request, request.originator) + else: # flexBlocking as non-blocking + return self._handleNonBlockingRequest(request) + raise BAD_REQUEST(f'Unknown or unsupported ResponseType: {request.rt}') @@ -412,18 +408,17 @@ def deleteRequest(self, request:CSERequest,) -> Result: def notifyRequest(self, request:CSERequest) -> Result: L.isDebug and L.logDebug(f'NOTIFY ID: {request.id if request.id else request.srn}, originator: {request.originator}') - # Check contentType and resourceType - if request.rt == ResponseType.blockingRequest: - return CSE.dispatcher.processNotifyRequest(request, request.originator) - - elif request.rt in [ ResponseType.nonBlockingRequestSynch, ResponseType.nonBlockingRequestAsynch ]: - return self._handleNonBlockingRequest(request) - elif request.rt == ResponseType.flexBlocking: - if self.flexBlockingBlocking: # flexBlocking as blocking + match request.rt: + case ResponseType.blockingRequest: return CSE.dispatcher.processNotifyRequest(request, request.originator) - else: # flexBlocking as non-blocking + case ResponseType.nonBlockingRequestSynch | ResponseType.nonBlockingRequestAsynch: return self._handleNonBlockingRequest(request) + case ResponseType.flexBlocking: + if self.flexBlockingBlocking: # flexBlocking as blocking + return CSE.dispatcher.processNotifyRequest(request, request.originator) + else: # flexBlocking as non-blocking + return self._handleNonBlockingRequest(request) raise BAD_REQUEST(f'Unknown or unsupported ResponseType: {request.rt}') @@ -1231,10 +1226,11 @@ def gget(dct:dict, # assign defaults when not provided if cseRequest.fc.fu != FilterUsage.discoveryCriteria: # Different defaults for each operation - if cseRequest.op in [ Operation.RETRIEVE, Operation.CREATE, Operation.UPDATE ]: - rcn = ResultContentType.attributes - elif cseRequest.op == Operation.DELETE: - rcn = ResultContentType.nothing + match cseRequest.op: + case Operation.RETRIEVE | Operation.CREATE | Operation.UPDATE: + rcn = ResultContentType.attributes + case Operation.DELETE: + rcn = ResultContentType.nothing else: # discovery-result-references as default for Discovery operation rcn = ResultContentType.discoveryResultReferences @@ -1552,14 +1548,15 @@ def recordRequest(self, request:CSERequest, result:Result) -> None: return # Construct and store request & response - if result.resource and isinstance(result.resource, Resource): - pc = result.resource.asDict() - elif isinstance(result.resource, dict): - pc = result.resource - elif result.data: - pc = result.data # type:ignore - else: - pc = None + match _resource := result.resource: + case Resource(): + pc = _resource.asDict() + case dict(): + pc = _resource + case x if result.data: + pc = result.data # type:ignore + case _: + pc = None # Determine the structure address if not (srn := request.srn): diff --git a/acme/services/Storage.py b/acme/services/Storage.py index 09a90003..9e272145 100644 --- a/acme/services/Storage.py +++ b/acme/services/Storage.py @@ -258,10 +258,11 @@ def retrieveResource(self, ri:Optional[str] = None, elif aei: # get an AE by its AE-ID resources = self.db.searchResources(aei = aei) - if (l := len(resources)) == 1: - return resourceFromDict(resources[0]) - elif l == 0: - raise NOT_FOUND('resource not found') + match len(resources): + case 1: + return resourceFromDict(resources[0]) + case 0: + raise NOT_FOUND('resource not found') raise INTERNAL_SERVER_ERROR('database inconsistency') @@ -275,10 +276,12 @@ def retrieveResourceRaw(self, ri:str) -> JSON: The resource dictionary. """ resources = self.db.searchResources(ri = ri) - if (l := len(resources)) == 1: - return resources[0] - elif l == 0: - raise NOT_FOUND('resource not found') + match len(resources): + case 1: + return resources[0] + case 0: + raise NOT_FOUND('resource not found') + raise INTERNAL_SERVER_ERROR('database inconsistency') diff --git a/acme/services/Validator.py b/acme/services/Validator.py index 247aa740..64568504 100644 --- a/acme/services/Validator.py +++ b/acme/services/Validator.py @@ -319,10 +319,11 @@ def validatePrimitiveContent(self, pc:JSON) -> None: def validatePvs(self, dct:JSON) -> None: """ Validating special case for lists that are not allowed to be empty (pvs in ACP). """ - if (l :=len(dct['pvs'])) == 0: - raise BAD_REQUEST(L.logWarn('Attribute pvs must not be an empty list')) - elif l > 1: - raise BAD_REQUEST(L.logWarn('Attribute pvs must contain only one item')) + match len(dct['pvs']): + case 0: + raise BAD_REQUEST(L.logWarn('Attribute pvs must not be an empty list')) + case l if l > 1: + raise BAD_REQUEST(L.logWarn('Attribute pvs must contain only one item')) if not (acr := findXPath(dct, 'pvs/acr')): raise BAD_REQUEST(L.logWarn('Attribute pvs/acr not found')) if not isinstance(acr, list): @@ -578,144 +579,145 @@ def _validateType(self, dataType:BasicType, # convert some types if necessary if convert: - if dataType in ( BasicType.positiveInteger, - BasicType.nonNegInteger, - BasicType.unsignedInt, - BasicType.unsignedLong, - BasicType.integer, - BasicType.enum ) and isinstance(value, str): - try: - value = int(value) - except Exception as e: - raise BAD_REQUEST(str(e)) - elif dataType == BasicType.boolean and isinstance(value, str): # "true"/"false" - try: - value = strToBool(value) - except Exception as e: - raise BAD_REQUEST(str(e)) - elif dataType == BasicType.float and isinstance(value, str): + if isinstance(value, str): try: - value = float(value) + match dataType: + case BasicType.positiveInteger |\ + BasicType.nonNegInteger |\ + BasicType.unsignedInt |\ + BasicType.unsignedLong |\ + BasicType.integer |\ + BasicType.enum: + value = int(value) + + case BasicType.boolean: + value = strToBool(value) + + case BasicType.float: + value = float(value) + except Exception as e: raise BAD_REQUEST(str(e)) # Check types and values - if dataType == BasicType.positiveInteger: - if isinstance(value, int): - if value > 0: + match dataType: + case BasicType.positiveInteger: + if isinstance(value, int) and value > 0: + return (dataType, value) + raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: positive integer') + + case BasicType.enum: + if isinstance(value, int): + if policy is not None and len(policy.evalues) and value not in policy.evalues: + raise BAD_REQUEST('undefined enum value') return (dataType, value) - raise BAD_REQUEST('value must be > 0') - raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: positive integer') + raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: integer') + + case BasicType.nonNegInteger: + if isinstance(value, int) and value >= 0: + return (dataType, value) + raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: non-negative integer') + + case BasicType.unsignedInt | BasicType.unsignedLong: + if isinstance(value, int): + return (dataType, value) + raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: unsigned integer') + + case BasicType.timestamp if isinstance(value, str): + if fromAbsRelTimestamp(value) == 0.0: + raise BAD_REQUEST(f'format error in timestamp: {value}') + return (dataType, value) - if dataType == BasicType.enum: - if isinstance(value, int): - if policy is not None and len(policy.evalues) and value not in policy.evalues: - raise BAD_REQUEST('undefined enum value') + case BasicType.absRelTimestamp: + match value: + case str(): + try: + rel = int(value) + # fallthrough + except Exception as e: # could happen if this is a string with an iso timestamp. Then try next test + if fromAbsRelTimestamp(value) == 0.0: + raise BAD_REQUEST(f'format error in absRelTimestamp: {value}') + # fallthrough + case int(): + pass + # fallthrough + case _: + raise BAD_REQUEST(f'unsupported data type for absRelTimestamp') + return (dataType, value) # int/long is ok + + case BasicType.string | BasicType.anyURI if isinstance(value, str): return (dataType, value) - raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: positive integer') - if dataType == BasicType.nonNegInteger: - if isinstance(value, int): - if value >= 0: + case BasicType.list | BasicType.listNE if isinstance(value, list): + if dataType == BasicType.listNE and len(value) == 0: + raise BAD_REQUEST('empty list is not allowed') + if policy is not None and policy.ltype is not None: + for each in value: + self._validateType(policy.ltype, each, convert = convert, policy = policy) + return (dataType, value) + + case BasicType.complex: + # Check complex types + if not policy: + raise BAD_REQUEST(L.logErr(f'internal error: policy is missing for validation of complex attribute')) + + if isinstance(value, dict): + typeName = policy.lTypeName if policy.type == BasicType.list else policy.typeName; + for k, v in value.items(): + if not (p := self.getAttributePolicy(typeName, k)): + raise BAD_REQUEST(f'unknown or undefined attribute:{k} in complex type: {typeName}') + # recursively validate a dictionary attribute + self._validateType(p.type, v, convert = convert, policy = p) + + # Check that all mandatory attributes are present + attributeNames = value.keys() + for ap in self.getComplexTypeAttributePolicies(typeName): + if Cardinality.isMandatory(ap.cardinality) and ap.sname not in attributeNames: + raise BAD_REQUEST(f'attribute is mandatory for complex type : {typeName}.{ap.sname}') return (dataType, value) - raise BAD_REQUEST('value must be >= 0') - raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: non-negative integer') + raise BAD_REQUEST(f'Expected complex type, found: {value}') - if dataType in ( BasicType.unsignedInt, BasicType.unsignedLong ): - if isinstance(value, int): + case BasicType.dict if isinstance(value, dict): return (dataType, value) - raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: unsigned integer') - if dataType == BasicType.timestamp and isinstance(value, str): - if fromAbsRelTimestamp(value) == 0.0: - raise BAD_REQUEST(f'format error in timestamp: {value}') - return (dataType, value) - - if dataType == BasicType.absRelTimestamp: - if isinstance(value, str): - try: - rel = int(value) - # fallthrough - except Exception as e: # could happen if this is a string with an iso timestamp. Then try next test - if fromAbsRelTimestamp(value) == 0.0: - raise BAD_REQUEST(f'format error in absRelTimestamp: {value}') - # fallthrough - elif not isinstance(value, int): - raise BAD_REQUEST(f'unsupported data type for absRelTimestamp') - return (dataType, value) # int/long is ok - - if dataType in ( BasicType.string, BasicType.anyURI ) and isinstance(value, str): - return (dataType, value) + case BasicType.boolean: + if isinstance(value, bool): + return (dataType, value) + raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: bool') - if dataType in ( BasicType.list, BasicType.listNE ) and isinstance(value, list): - if dataType == BasicType.listNE and len(value) == 0: - raise BAD_REQUEST('empty list is not allowed') - if policy is not None and policy.ltype is not None: - for each in value: - self._validateType(policy.ltype, each, convert = convert, policy = policy) - return (dataType, value) + case BasicType.integer: + if isinstance(value, int): + return (dataType, value) + raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: integer') + + case BasicType.float: + if isinstance(value, (float, int)): + return (dataType, value) + raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: float') - if dataType == BasicType.dict and isinstance(value, dict): - return (dataType, value) - - if dataType == BasicType.boolean: - if isinstance(value, bool): + case BasicType.geoCoordinates if isinstance(value, dict): return (dataType, value) - raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: bool') - if dataType == BasicType.float: - if isinstance(value, (float, int)): + case BasicType.duration: + try: + isodate.parse_duration(value) + except Exception as e: + raise BAD_REQUEST(f'must be an ISO duration: {str(e)}') return (dataType, value) - raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: float') - if dataType == BasicType.integer: - if isinstance(value, int): + case BasicType.base64: + if not TextTools.isBase64(value): + raise BAD_REQUEST(f'value is not base64-encoded') return (dataType, value) - raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: integer') - if dataType == BasicType.geoCoordinates and isinstance(value, dict): - return (dataType, value) - - if dataType == BasicType.duration: - try: - isodate.parse_duration(value) - except Exception as e: - raise BAD_REQUEST(f'must be an ISO duration: {str(e)}') - return (dataType, value) - - if dataType == BasicType.base64: - if not TextTools.isBase64(value): - raise BAD_REQUEST(f'value is not base64-encoded') - return (dataType, value) - - if dataType == BasicType.schedule: - if isinstance(value, str) and re.match(self._scheduleRegex, value): - return (dataType, value) - raise BAD_REQUEST(f'invalid type: {type(value).__name__} or pattern {value}. Expected: cron-like schedule') + case BasicType.schedule: + if isinstance(value, str) and re.match(self._scheduleRegex, value): + return (dataType, value) + raise BAD_REQUEST(f'invalid type: {type(value).__name__} or pattern {value}. Expected: cron-like schedule') - if dataType == BasicType.any: - return (dataType, value) - - if dataType == BasicType.complex: - if not policy: - raise BAD_REQUEST(L.logErr(f'internal error: policy is missing for validation of complex attribute')) - - if isinstance(value, dict): - typeName = policy.lTypeName if policy.type == BasicType.list else policy.typeName; - for k, v in value.items(): - if not (p := self.getAttributePolicy(typeName, k)): - raise BAD_REQUEST(f'unknown or undefined attribute:{k} in complex type: {typeName}') - # recursively validate a dictionary attribute - self._validateType(p.type, v, convert = convert, policy = p) - - # Check that all mandatory attributes are present - attributeNames = value.keys() - for ap in self.getComplexTypeAttributePolicies(typeName): - if Cardinality.isMandatory(ap.cardinality) and ap.sname not in attributeNames: - raise BAD_REQUEST(f'attribute is mandatory for complex type : {typeName}.{ap.sname}') + case BasicType.any: return (dataType, value) - raise BAD_REQUEST(f'Expected complex type, found: {value}') raise BAD_REQUEST(f'type mismatch or unknown; expected type: {str(dataType)}, value type: {type(value).__name__}') diff --git a/acme/textui/ACMEContainerTree.py b/acme/textui/ACMEContainerTree.py index 784af776..38576a23 100644 --- a/acme/textui/ACMEContainerTree.py +++ b/acme/textui/ACMEContainerTree.py @@ -243,11 +243,14 @@ async def on_tabbed_content_tab_activated(self, event:TabbedContent.TabActivated """Handle TabActivated message sent by Tabs.""" # self.app.debugConsole.update(event.tab.id) - if self.tabs.active == 'tree-tab-requests': - self._update_requests() - self.requestView.updateBindings() - elif self.tabs.active == 'tree-tab-delete': - pass + match self.tabs.active: + case 'tree-tab-requests': + self._update_requests() + self.requestView.updateBindings() + case 'tree-tab-resource': + pass + case 'tree-tab-delete': + pass self.app.updateFooter() # type:ignore[attr-defined] diff --git a/acme/textui/ACMETuiApp.py b/acme/textui/ACMETuiApp.py index 770b4d93..f1ab9780 100644 --- a/acme/textui/ACMETuiApp.py +++ b/acme/textui/ACMETuiApp.py @@ -220,6 +220,7 @@ async def _call() -> None: if timeout is None: timeout = Notification.timeout + if severity is None: severity = 'information' elif severity not in get_args(SeverityLevel): diff --git a/tests/config.py b/tests/config.py index ec71e3ee..40fd2aa8 100644 --- a/tests/config.py +++ b/tests/config.py @@ -9,26 +9,24 @@ BINDING = 'http' # possible values: http, https, mqtt -if BINDING == 'mqtt': - PROTOCOL = 'mqtt' - CONFIGPROTOCOL = 'http' - NOTIFICATIONPROTOCOL = 'http' - REMOTEPROTOCOL = 'http' - -elif BINDING == 'http': - PROTOCOL = 'http' - CONFIGPROTOCOL = 'http' - NOTIFICATIONPROTOCOL = 'http' - REMOTEPROTOCOL = 'http' - -elif BINDING == 'https': - PROTOCOL = 'https' - CONFIGPROTOCOL = 'https' - NOTIFICATIONPROTOCOL = 'http' - REMOTEPROTOCOL = 'http' - -else: - assert False, 'Supported values for BINDING are "mqtt", "http", and "https"' +match BINDING: + case 'mqtt': + PROTOCOL = 'mqtt' + CONFIGPROTOCOL = 'http' + NOTIFICATIONPROTOCOL = 'http' + REMOTEPROTOCOL = 'http' + case 'http': + PROTOCOL = 'http' + CONFIGPROTOCOL = 'http' + NOTIFICATIONPROTOCOL = 'http' + REMOTEPROTOCOL = 'http' + case 'https': + PROTOCOL = 'https' + CONFIGPROTOCOL = 'https' + NOTIFICATIONPROTOCOL = 'http' + REMOTEPROTOCOL = 'http' + case _: + assert False, 'Supported values for BINDING are "mqtt", "http", and "https"' # TODO ENCODING = diff --git a/tests/init.py b/tests/init.py index 1c13bd2a..028c2e20 100755 --- a/tests/init.py +++ b/tests/init.py @@ -336,27 +336,31 @@ def sendRequest(operation:Operation, url:str, originator:str, ty:ResourceTypes=N # return sendHttpRequest(requests.delete, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) # elif operation == Operation.NOTIFY: # return sendHttpRequest(requests.post, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - if operation == Operation.CREATE: - return sendHttpRequest(httpSession.post, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.RETRIEVE: - return sendHttpRequest(httpSession.get, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.UPDATE: - return sendHttpRequest(httpSession.put, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.DELETE: - return sendHttpRequest(httpSession.delete, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.NOTIFY: - return sendHttpRequest(httpSession.post, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + match operation: + case Operation.CREATE: + return sendHttpRequest(requests.post, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.RETRIEVE: + return sendHttpRequest(requests.get, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.UPDATE: + return sendHttpRequest(requests.put, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.DELETE: + return sendHttpRequest(requests.delete, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.NOTIFY: + return sendHttpRequest(requests.post, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + elif url.startswith('mqtt'): - if operation == Operation.CREATE: - return sendMqttRequest(Operation.CREATE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.RETRIEVE: - return sendMqttRequest(Operation.RETRIEVE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.UPDATE: - return sendMqttRequest(Operation.UPDATE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.DELETE: - return sendMqttRequest(Operation.DELETE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.NOTIFY: - return sendMqttRequest(Operation.NOTIFY, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + match operation: + case Operation.CREATE: + return sendMqttRequest(Operation.CREATE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.RETRIEVE: + return sendMqttRequest(Operation.RETRIEVE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.UPDATE: + return sendMqttRequest(Operation.UPDATE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.DELETE: + return sendMqttRequest(Operation.DELETE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.NOTIFY: + return sendMqttRequest(Operation.NOTIFY, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + else: print('ERROR') return None, 5103 @@ -800,12 +804,11 @@ def do_POST(self) -> None: contentType = '' if (val := self.headers.get('Content-Type')) is not None: contentType = val.lower() - if contentType in [ 'application/json', 'application/vnd.onem2m-res+json' ]: - setLastNotification(decoded_data := json.loads(post_data.decode('utf-8'))) - elif contentType in [ 'application/cbor', 'application/vnd.onem2m-res+cbor' ]: - setLastNotification(decoded_data := cbor2.loads(post_data)) - # else: - # setLastNotification(post_data.decode('utf-8')) + match contentType: + case 'application/json' | 'application/vnd.onem2m-res+json': + setLastNotification(decoded_data := json.loads(post_data.decode('utf-8'))) + case 'application/cbor' | 'application/vnd.onem2m-res+cbor': + setLastNotification(decoded_data := cbor2.loads(post_data)) setLastNotificationHeaders(dict(self.headers)) # make a dict out of the headers # make a dict out of the query arguments diff --git a/tools/notificationServer/notificationServer.py b/tools/notificationServer/notificationServer.py index 736b8511..70a76ac2 100644 --- a/tools/notificationServer/notificationServer.py +++ b/tools/notificationServer/notificationServer.py @@ -82,32 +82,29 @@ def do_POST(self) -> None: self.end_headers() - - # Print JSON - if contentType in [ 'application/json', 'application/vnd.onem2m-res+json' ]: - console.print(Syntax(json.dumps(json.loads(post_data.decode('utf-8')), indent=4), - 'json', - theme='monokai', - line_numbers=False)) - - # Print CBOR - elif contentType in [ 'application/cbor', 'application/vnd.onem2m-res+cbor' ]: - console.print('[dim]Content as Hexdump:\n') - console.print(toHex(post_data), highlight=False) - console.print('\n[dim]Content as JSON:\n') - console.print(Syntax(json.dumps(cbor2.loads(post_data), indent=4), - 'json', - theme='monokai', - line_numbers=False)) - - # Print plain text formats - elif contentType in ['text/plain']: - console.print(post_data.decode(), highlight = False) - console.print() - - # Print other binary content - else: - console.print(toHex(post_data), highlight=False) + match contentType: + # Print JSON + case 'application/json', 'application/vnd.onem2m-res+json': + console.print(Syntax(json.dumps(json.loads(post_data.decode('utf-8')), indent=4), + 'json', + theme='monokai', + line_numbers=False)) + # Print CBOR + case 'application/cbor', 'application/vnd.onem2m-res+cbor': + console.print('[dim]Content as Hexdump:\n') + console.print(toHex(post_data), highlight=False) + console.print('\n[dim]Content as JSON:\n') + console.print(Syntax(json.dumps(cbor2.loads(post_data), indent=4), + 'json', + theme='monokai', + line_numbers=False)) + # Print plain text formats + case 'text/plain': + console.print(post_data.decode(), highlight = False) + console.print() + # Print other binary content + case _: + console.print(toHex(post_data), highlight=False) # Print HTTP Response # This looks a it more complicated but is necessary to render nicely in Jupyter @@ -196,38 +193,35 @@ def _constructResponse(frm:str, to:str, jsn:dict) -> dict: _frm = 'non-onem2m-entity' _to = 'unknown' encoding = 'json' - - # Print JSON + responseData = None - if encoding.upper() == 'JSON': - console.print(Syntax(json.dumps((jsn := json.loads(data)), indent=4), - 'json', - theme='monokai', - line_numbers=False)) - to = jsn['to'] if 'to' in jsn else _to - frm = jsn['fr'] if 'fr' in jsn else _frm - responseData = cast(bytes, serializeData(_constructResponse(to, frm, jsn), ContentSerializationType.JSON)) - console.print(responseData) - - - - # Print CBOR - elif encoding.upper() == 'CBOR': - console.print('[dim]Content as Hexdump:\n') - console.print(toHex(data), highlight=False) - console.print('\n[dim]Content as JSON:\n') - console.print(Syntax(json.dumps((jsn := cbor2.loads(data)), indent=4), - 'json', - theme='monokai', - line_numbers=False)) - to = jsn['to'] if 'to' in jsn else to - frm = jsn['fr'] if 'fr' in jsn else frm - responseData = cast(bytes, serializeData(_constructResponse(to, frm, jsn), ContentSerializationType.CBOR)) - - # Print other binary content - else: - console.print('[dim]Content as Hexdump:\n') - console.print(toHex(data), highlight=False) + match encoding.upper(): + # Print JSON + case 'JSON': + console.print(Syntax(json.dumps((jsn := json.loads(data)), indent=4), + 'json', + theme='monokai', + line_numbers=False)) + to = jsn['to'] if 'to' in jsn else _to + frm = jsn['fr'] if 'fr' in jsn else _frm + responseData = cast(bytes, serializeData(_constructResponse(to, frm, jsn), ContentSerializationType.JSON)) + console.print(responseData) + # Print CBOR + case 'CBOR': + console.print('[dim]Content as Hexdump:\n') + console.print(toHex(data), highlight=False) + console.print('\n[dim]Content as JSON:\n') + console.print(Syntax(json.dumps((jsn := cbor2.loads(data)), indent=4), + 'json', + theme='monokai', + line_numbers=False)) + to = jsn['to'] if 'to' in jsn else to + frm = jsn['fr'] if 'fr' in jsn else frm + responseData = cast(bytes, serializeData(_constructResponse(to, frm, jsn), ContentSerializationType.CBOR)) + # Print other binary content + case _: + console.print('[dim]Content as Hexdump:\n') + console.print(toHex(data), highlight=False) # TODO send a response if responseData: From 9e46a478a4a18db1539c117bc013eee1866ed276 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 20 Jul 2023 12:20:19 +0200 Subject: [PATCH 022/165] A bit more time between checks for CRS --- tests/testCRS.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testCRS.py b/tests/testCRS.py index 2e56f7e0..eda30f53 100644 --- a/tests/testCRS.py +++ b/tests/testCRS.py @@ -770,7 +770,7 @@ def test_updateCRSPeriodicWindowSize(self) -> None: self.assertIsNone(notification := getLastNotification()) # wait second half - testSleep(crsTimeWindowSize) + testSleep(crsTimeWindowSize * 1.2) self.assertIsNotNone(notification := getLastNotification()) self.assertIsNotNone(findXPath(notification, 'm2m:sgn')) self.assertEqual(findXPath(notification, 'm2m:sgn/sur'), toSPRelative(findXPath(self.crs, 'm2m:crs/ri'))) From 6123c5f882fc89ea4daed2f9f1453a1a6e669bf1 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 20 Jul 2023 12:20:53 +0200 Subject: [PATCH 023/165] Small tweaks --- init/testCaseEnd.as | 2 ++ init/testCaseStart.as | 4 ++++ init/utReset.as | 7 +++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/init/testCaseEnd.as b/init/testCaseEnd.as index 406977c2..86866c3a 100644 --- a/init/testCaseEnd.as +++ b/init/testCaseEnd.as @@ -12,6 +12,8 @@ (if (< argc 2) ( (log-error "Wrong number of arguments: testCaseEnd ") (quit-with-error))) +(if (== (get-loglevel) "OFF") + (quit)) ;; Print start line to the debug log (log-divider "End of ${(argv 1)}") diff --git a/init/testCaseStart.as b/init/testCaseStart.as index 4f89b7d0..48648944 100644 --- a/init/testCaseStart.as +++ b/init/testCaseStart.as @@ -13,6 +13,10 @@ ( (logError "Wrong number of arguments: testCaseStart ") (quit-with-error))) +(if (== (get-loglevel) "OFF") + (quit)) + ;; Print start line to the debug log (log-divider "Start of ${(argv 1)}") +;;(tui-notify (argv 1) "Running Test Case") diff --git a/init/utReset.as b/init/utReset.as index 2bd71dd0..df78f307 100644 --- a/init/utReset.as +++ b/init/utReset.as @@ -14,9 +14,12 @@ (print "Resetting CSE") +(if (runs-in-tui) + (tui-notify "Resetting CSE" "CSE Reset" "warning")) + (reset-cse) +(print "CSE Reset Complete") (if (runs-in-tui) - (print "[green3 b]CSE Reset Complete") - (print "CSE Reset Complete")) + (tui-notify "CSE Reset Complete" "CSE Reset" "warning")) From c796856a75cdd676b990473318fa4a155b3dcfba Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 25 Jul 2023 17:10:15 +0200 Subject: [PATCH 024/165] Changed enumeration definition format --- CHANGELOG.md | 3 + acme/etc/Types.py | 10 +- acme/etc/Utils.py | 4 +- acme/helpers/TextTools.py | 2 +- acme/resources/SCH.py | 2 +- acme/services/Importer.py | 52 +++- acme/services/TextUI.py | 25 +- acme/services/Validator.py | 55 +++- acme/textui/ACMEContainerRequests.py | 22 +- acme/textui/ACMEContainerTree.py | 4 +- acme/textui/ACMETuiApp.py | 1 + docs/Importing.md | 9 +- init/attributePolicies.ap | 46 +++- init/enumTypesPolicies.ep | 369 ++++++++++++++++++++++++--- 14 files changed, 532 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a971ae8c..4a1c4a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [CSE] Added automatic pip install of missing dependencies during startup. - [CSE] Added support for <schedule> resource type. - [SCRIPTS] Added "dotimes", "tui-notify", and "get-loglevel" functions to the script interpreter. +- [TUI] Improved resource view in the text UI. Enumeration interpretations are now shown. ### Experimental ### Changed - [CSE] Changed the *operationResult* of <request> according to SDS-2022-0010R02. +- [CSE] Changed the oneM2M enumeration definition format. Each enumeration type is now a dictionary of enumeration values and their interpretations. + ### Fixed diff --git a/acme/etc/Types.py b/acme/etc/Types.py index c4375e8d..f69b3473 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -56,6 +56,8 @@ class ResourceTypes(ACMEIntEnum): """ CSEBase resource type. """ GRP = 9 """ Group resouce type. """ + LCP = 10 + """ LocationPolicy resource type. """ MGMTOBJ = 13 """ ManagementObject resource type. """ NOD = 14 @@ -154,6 +156,8 @@ class ResourceTypes(ACMEIntEnum): """ Announced CSEBase resource type. """ GRPAnnc = 10009 """ Announced Group resouce type. """ + LCPAnnc = 10010 + """ Announced LocationPolicy resource type. """ MGMTOBJAnnc = 10013 """ Announced ManagementObject resource type. """ NODAnnc = 10014 @@ -426,6 +430,8 @@ class ResourceDescription(): ResourceTypes.GRP : ResourceDescription(typeName = 'm2m:grp', announcedType = ResourceTypes.GRPAnnc, fullName='Group'), ResourceTypes.GRPAnnc : ResourceDescription(typeName = 'm2m:grpA', isAnnouncedResource = True, fullName='Group Announced'), ResourceTypes.GRP_FOPT : ResourceDescription(typeName = 'm2m:fopt', virtualResourceName = 'fopt', fullName='Fanout Point'), # not an official type name + ResourceTypes.LCP : ResourceDescription(typeName = 'm2m:lcp', announcedType = ResourceTypes.LCPAnnc, fullName='LocationPolicy'), + ResourceTypes.LCPAnnc : ResourceDescription(typeName = 'm2m:lcpA', isAnnouncedResource = True, fullName='LocationPolicy Announced'), ResourceTypes.MGMTOBJ : ResourceDescription(typeName = 'm2m:mgo', announcedType = ResourceTypes.MGMTOBJAnnc, fullName = 'ManagementObject'), # not an official type name ResourceTypes.MGMTOBJAnnc : ResourceDescription(typeName = 'm2m:mgoA', isAnnouncedResource = True, fullName = 'ManagementObject Announced'), # not an official type name ResourceTypes.NOD : ResourceDescription(typeName = 'm2m:nod', announcedType = ResourceTypes.NODAnnc, fullName='Node'), @@ -530,7 +536,7 @@ def addResourceFactoryCallback(ty:ResourceTypes, clazz:Resource, factory:Factory _ResourceTypesAnnouncedResourceTypes.sort() -_ResourceTypesSupportedResourceTypes = [ t +_ResourceTypesSupportedResourceTypes:list[ResourceTypes] = [ t for t, d in _ResourceTypeDetails.items() if not d.isMgmtSpecialization and not d.virtualResourceName and not d.isInternalType and t != ResourceTypes.CSEBaseAnnc] """ Sorted list of supported resource types (without MgmtObj spezializations and virtual resources). """ @@ -2035,7 +2041,7 @@ class AttributePolicy: fname:str = None # Name of the definition file ltype:BasicType = None # sub-type of a list lTypeName:str = None # sub-type of a list as writen in the definition - evalues:list[Any] = None # List of enum values + evalues:dict[int, str] = None # Dict of enum values and interpretations ptype:Type = None # Implementation type of the enum values # TODO support annnouncedSyncType diff --git a/acme/etc/Utils.py b/acme/etc/Utils.py index 791abd26..3e6ca5fc 100644 --- a/acme/etc/Utils.py +++ b/acme/etc/Utils.py @@ -152,7 +152,7 @@ def isCSERelative(uri:str) -> bool: return uri is not None and not uri.startswith('/') -def isStructured(uri:str) -> bool: +def isStructured(uri:str) -> bool: # type: ignore[return] """ Test whether a URI is in structured format. Args: @@ -171,7 +171,7 @@ def isStructured(uri:str) -> bool: return False -def localResourceID(ri:str) -> Optional[str]: +def localResourceID(ri:str) -> Optional[str]: # type: ignore[return] """ Test whether an ID is a resource ID of the local CSE. Args: diff --git a/acme/helpers/TextTools.py b/acme/helpers/TextTools.py index ccc8d78d..0a345f65 100644 --- a/acme/helpers/TextTools.py +++ b/acme/helpers/TextTools.py @@ -102,7 +102,7 @@ def commentJson(data:Union[str, dict], elif previousKey and value: # when the value is on the next line, w/o a key - lines.append(f'// {value}') + lines.append(f'// {getAttributeValueName(key, value)}') lines.append(line) _m = len(lines[-2]) + maxLineLength maxLength = _m if _m > maxLength else maxLength diff --git a/acme/resources/SCH.py b/acme/resources/SCH.py index 6249a0f7..5194ae10 100644 --- a/acme/resources/SCH.py +++ b/acme/resources/SCH.py @@ -1,4 +1,4 @@ - # +# # SCH.py # # (c) 2023 by Andreas Kraft diff --git a/acme/services/Importer.py b/acme/services/Importer.py index f2f98b76..6c1439f8 100644 --- a/acme/services/Importer.py +++ b/acme/services/Importer.py @@ -11,7 +11,7 @@ """ Import various resources, scripts, policies etc into the CSE. """ from __future__ import annotations -from typing import cast, Sequence, Optional +from typing import cast, Sequence, Optional, Tuple import json, os, fnmatch, re from copy import deepcopy @@ -46,7 +46,7 @@ class Importer(object): # List of "priority" resources that must be imported first for correct CSE operation _firstImporters = [ 'csebase.json'] - _enumValues:dict[str, list[int]] = {} + _enumValues:dict[str, dict[int, str]] = {} def __init__(self) -> None: """ Initialization of an *Importer* instance. @@ -243,10 +243,38 @@ def importEnumPolicies(self, path:Optional[str] = None) -> bool: return False for enumName, enumDef in enums.items(): - if not (evalues := enumDef.get('evalues')): - L.logErr(f'Missing or empty enumeration values (evalues) in file: {fn}') + if not isinstance(enumDef, dict): + L.logErr(f'Wrong or empty enumeration definition for enum: {enumName} in file: {fn}') return False - self._enumValues[enumName] = self._expandEnumValues(evalues, enumName, fn) + + enm:dict[int, str] = {} + for enumValue, enumInterpretation in enumDef.items(): + s, found, e = enumValue.partition('..') + if not found: + # Single value + try: + value = int(enumValue) + except ValueError: + L.logErr(f'Wrong enumeration value: {enumValue} in enum: {enumName} in file: {fn} (must be an integer)') + return False + if not isinstance(enumInterpretation, str): + L.logErr(f'Wrong interpretation for enum value: {enumValue} in enum: {enumName} in file: {fn}') + return False + enm[value] = enumInterpretation + + else: + # Range + try: + si = int(s) + ei = int(e) + except ValueError: + L.logErr(f'Error in evalue range definition: {enumValue} (range shall consist of integer numbers) for enum attribute: {enumName} in file: {fn}', showStackTrace=False) + return None + for i in range(si, ei+1): + enm[i] = enumInterpretation + + self._enumValues[enumName] = enm + return True @@ -518,6 +546,7 @@ def _parseAttribute(self, attr:JSON, # Check and determine the list type lTypeName:str = None ltype:BasicType = None + evalues:dict[int, str] = None if checkListType: # TODO remove this when flexContainer definitions support list sub-types if lTypeName := findXPath(attr, 'ltype'): if not isinstance(lTypeName, str) or len(lTypeName) == 0: @@ -529,15 +558,14 @@ def _parseAttribute(self, attr:JSON, if not (ltype := BasicType.to(lTypeName)): # automatically a complex type if not found in the type definition. Check for this happens later ltype = BasicType.complex if ltype == BasicType.enum: # check sub-type enums - evalues:Sequence[int|str] if (etype := findXPath(attr, 'etype')): # Get the values indirectly from the enums read above evalues = self._enumValues.get(etype) else: - evalues = findXPath(attr, 'evalues') - if not evalues or not isinstance(evalues, list): + evalues = findXPath(attr, 'evalues') # TODO? + if not evalues or not isinstance(evalues, dict): L.logErr(f'Missing, wrong of empty enum values (evalue) list for attribute: {tpe} in file: {fn}', showStackTrace=False) return None - evalues = self._expandEnumValues(evalues, tpe, fn) + # evalues = self._expandEnumValues(evalues, tpe, fn) # TODO this is perhaps wrong, bc we changed the evalue handling to a different format if typ == BasicType.list and lTypeName is None: L.isDebug and L.logDebug(f'Missing list type for attribute: {tpe} in file: {fn}') @@ -547,11 +575,11 @@ def _parseAttribute(self, attr:JSON, if (etype := findXPath(attr, 'etype')): # Get the values indirectly from the enums read above evalues = self._enumValues.get(etype) else: - evalues = findXPath(attr, 'evalues') - if not evalues or not isinstance(evalues, list): + evalues = findXPath(attr, 'evalues') # TODO? + if not evalues or not isinstance(evalues, dict): L.logErr(f'Missing, wrong of empty enum values (evalue) list for attribute: {tpe} etype: {etype} in file: {fn}', showStackTrace=False) return None - evalues = self._expandEnumValues(evalues, tpe, fn) + # evalues = self._expandEnumValues(evalues, tpe, fn) # Check missing complex type definition if typ == BasicType.dict or ltype == BasicType.dict: diff --git a/acme/services/TextUI.py b/acme/services/TextUI.py index be471ed3..7c1aa93d 100644 --- a/acme/services/TextUI.py +++ b/acme/services/TextUI.py @@ -11,7 +11,7 @@ from __future__ import annotations -from typing import Optional, Any +from typing import Optional, Any, Literal import asyncio from . import CSE @@ -156,6 +156,10 @@ def refreshResources(self) -> None: def scriptPrint(self, scriptName:str, msg:str) -> None: """ Print a line to the script output. + + Args: + scriptName: Name of the script. + msg: Message to print. """ if self.tuiApp: self.tuiApp.scriptPrint(scriptName, msg) @@ -163,6 +167,10 @@ def scriptPrint(self, scriptName:str, msg:str) -> None: def scriptLog(self, scriptName:str, msg:str) -> None: """ Print a line to the script log output. + + Args: + scriptName: Name of the script. + msg: Message to print. """ if self.tuiApp: self.tuiApp.scriptLog(scriptName, msg) @@ -170,6 +178,10 @@ def scriptLog(self, scriptName:str, msg:str) -> None: def scriptLogError(self, scriptName:str, msg:str) -> None: """ Print a line to the script log output. + + Args: + scriptName: Name of the script. + msg: Message to print. """ if self.tuiApp: self.tuiApp.scriptLogError(scriptName, msg) @@ -177,16 +189,22 @@ def scriptLogError(self, scriptName:str, msg:str) -> None: def scriptClearConsole(self, scriptName:str) -> None: """ Clear the script console. + + Args: + scriptName: Name of the script. """ if self.tuiApp: self.tuiApp.scriptClearConsole(scriptName) - def scriptShowNotification(self, msg:str, title:str, severity:str, timeout:float) -> None: + def scriptShowNotification(self, msg:str, title:str, severity:Literal['information', 'warning', 'error'], timeout:float) -> None: """ Show a notification. Args: msg: Message to show. + title: Title of the notification. + severity: Severity of the notification. + timeout: Timeout in seconds. """ if self.tuiApp: self.tuiApp.scriptShowNotification(msg, title, severity, timeout) @@ -194,6 +212,9 @@ def scriptShowNotification(self, msg:str, title:str, severity:str, timeout:float def scriptVisualBell(self, scriptName:str) -> None: """ Visual bell. + + Args: + scriptName: Name of the script. """ if self.tuiApp: self.tuiApp.scriptVisualBell(scriptName) \ No newline at end of file diff --git a/acme/services/Validator.py b/acme/services/Validator.py index 64568504..695488e3 100644 --- a/acme/services/Validator.py +++ b/acme/services/Validator.py @@ -54,6 +54,9 @@ complexTypeAttributes:dict[str, list[str]] = {} # TODO doc +attributesComplexTypes:dict[str, list[str]] = {} +# TODO doc + # TODO make this more generic! _valueNameMappings = { @@ -61,8 +64,8 @@ 'bts': lambda v: BatteryStatus(int(v)).name, 'chty': lambda v: ResourceTypes.fullname(int(v)), 'cst': lambda v: CSEType(int(v)).name, - 'nct': lambda v: NotificationContentType(int(v)).name, - 'net': lambda v: NotificationEventType(int(v)).name, + #'nct': lambda v: NotificationContentType(int(v)).name, + #'net': lambda v: NotificationEventType(int(v)).name, 'op': lambda v: Operation(int(v)).name, 'rcn': lambda v: ResultContentType(int(v)).name, 'rsc': lambda v: ResponseStatusCode(int(v)).name, @@ -488,9 +491,21 @@ def addAttributePolicy(self, rtype:ResourceTypes|str, attr:str, attrPolicy:Attri else: complexTypeAttributes[attrPolicy.ctype] = [ attr ] + if (ctypes := attributesComplexTypes.get(attr)): + ctypes.append(attrPolicy.ctype) + else: + attributesComplexTypes[attr] = [ attrPolicy.ctype ] + def getAttributePolicy(self, rtype:ResourceTypes|str, attr:str) -> AttributePolicy: """ Return the attributePolicy for a resource type. + + Args: + rtype: Resource type. + attr: Attribute name. + + Return: + AttributePolicy or None. """ # Search for the specific type first if (ap := attributePolicies.get((rtype, attr))): @@ -533,24 +548,46 @@ def getShortnameLongNameMapping(self) -> dict[str, str]: return result - def getAttributeValueName(self, key:str, value:str) -> str: + def getAttributeValueName(self, attr:str, value:int, rtype:Optional[ResourceTypes] = None) -> str: """ Return the name of an attribute value. This is usually used for enumerations, where the value is a number and the name is a string. Args: - key: String, attribute name. - value: String, attribute value. + attr: Attribute name. + value: Attribute value. Return: String, name of the attribute value. """ try: - if key in _valueNameMappings: - return _valueNameMappings[key](value) # type: ignore [no-untyped-call] + if attr in _valueNameMappings: + return _valueNameMappings[attr](value) # type: ignore [no-untyped-call] + from ..services import CSE + return CSE.validator.getEnumInterpretation(rtype, attr, value) except Exception as e: return str(e) - return '' - + + + def getEnumInterpretation(self, rtype: ResourceTypes, attr:str, value:int) -> str: + """ Return the interpretation of an enumeration. + + Args: + rtype: Resource type. May be None. + attr: Attribute name. + value: Enumeration value. + + Return: + String, interpretation of the enumeration, or the value itself if no interpretation is available. + """ + if rtype is not None: + if (policy := self.getAttributePolicy(rtype, attr)) and policy.evalues: + return policy.evalues.get(int(value), str(value)) + + if (ctype := attributesComplexTypes.get(attr)): + if (policy := self.getAttributePolicy(ctype[0], attr)) and policy.evalues: # just any policy for the complex type + return policy.evalues.get(int(value), str(value)) + return str(value) + # # Internals. diff --git a/acme/textui/ACMEContainerRequests.py b/acme/textui/ACMEContainerRequests.py index d8a1a01a..5bdb12f3 100644 --- a/acme/textui/ACMEContainerRequests.py +++ b/acme/textui/ACMEContainerRequests.py @@ -61,7 +61,8 @@ class ACMEViewRequests(Vertical): BINDINGS = [ Binding('r', 'refresh_requests', 'Refresh'), Binding('D', 'delete_requests', 'Delete ALL Requests', key_display = 'SHIFT+D'), - Binding('e', 'enable_requests', '') + Binding('e', 'enable_requests', ''), + Binding('t', 'toggle_list_details', 'List Details'), ] DEFAULT_CSS = """ @@ -132,6 +133,7 @@ def __init__(self) -> None: # Request List self.requestList = ListView(id = 'request-list-list') + self.listDetails = False # Request view: request + response self.requestListRequest = Static(id = 'request-list-request') @@ -237,6 +239,13 @@ def action_enable_requests(self) -> None: def action_disable_requests(self) -> None: CSE.request.enableRequestRecording = False self.updateBindings() + + + def action_toggle_list_details(self) -> None: + self.listDetails = not self.listDetails + self.updateRequests() + + # TODO def updateBindings(self) -> None: @@ -278,11 +287,18 @@ def rscFmt(rsc:int) -> str: # _to = _to if _to else '' _srn = r.get('srn', '') # _srn = _srn if _srn else '' - self.requestList.append(_l := ACMEListItem( - Label(f' {i:4} - {_ts[1]} {Operation(r["op"]).name:10.10} {str(r.get("org", "")):30.30} {str(_to):30.30} {rscFmt(r["rsc"])}\n [dim]{_ts[0]}[/dim] [dim]{_srn}[/dim]'))) + match self.listDetails: + case True: + _l = ACMEListItem(Label(f' {i:4} - {_ts[1]} {Operation(r["op"]).name:10.10} {str(r.get("org", "")):30.30} {str(_to):30.30} {rscFmt(r["rsc"])}\n [dim]{_ts[0]}[/dim] [dim]{_srn}[/dim]')) + case False: + _l = ACMEListItem(Label(f' {i:4} - {_ts[1]} {Operation(r["op"]).name:10.10} {str(r.get("org", "")):30.30} {str(_to):30.30} {rscFmt(r["rsc"])}')) + _l._data = i if r['out']: _l.set_class(True, '--outgoing') + self.requestList.append(_l) + # self.requestList.append(_l := ACMEListItem( + # Label(f' {i:4} - {_ts[1]} {Operation(r["op"]).name:10.10} {str(r.get("org", "")):30.30} {str(_to):30.30} {rscFmt(r["rsc"])}\n [dim]{_ts[0]}[/dim] [dim]{_srn}[/dim]'))) def deleteRequests(self) -> None: diff --git a/acme/textui/ACMEContainerTree.py b/acme/textui/ACMEContainerTree.py index 38576a23..f4a3b7e4 100644 --- a/acme/textui/ACMEContainerTree.py +++ b/acme/textui/ACMEContainerTree.py @@ -212,8 +212,8 @@ def updateResource(self, resource:Optional[Resource] = None) -> None: if resource: jsns = commentJson(resource.asDict(sort = True), explanations = self.app.attributeExplanations, # type: ignore [attr-defined] - getAttributeValueName = CSE.validator.getAttributeValueName, # type: ignore [attr-defined] - width = (self.resourceView.size[0] - 2) if self.resourceView.size[0] > 0 else 9999) # type: ignore [attr-defined] + getAttributeValueName = lambda a, v: CSE.validator.getAttributeValueName(a, v, resource.ty if resource else None), # type: ignore [attr-defined] + width = (self.resourceView.size[0] - 2) if self.resourceView.size[0] > 0 else 9999) # type: ignore [attr-defined] # Update the requests view self._update_requests(resource.ri) diff --git a/acme/textui/ACMETuiApp.py b/acme/textui/ACMETuiApp.py index f1ab9780..813fcf08 100644 --- a/acme/textui/ACMETuiApp.py +++ b/acme/textui/ACMETuiApp.py @@ -99,6 +99,7 @@ def __init__(self, textUI:TextUI.TextUI): self.quitReason = ACMETuiQuitReason.undefined self.attributeExplanations = CSE.validator.getShortnameLongNameMapping() + # Add the resource types to the attribute explanations for n in ResourceTypes: self.attributeExplanations[ResourceTypes(n).tpe()] = f'{ResourceTypes.fullname(n)} resource type' diff --git a/docs/Importing.md b/docs/Importing.md index 0afa69c1..abe49d0c 100644 --- a/docs/Importing.md +++ b/docs/Importing.md @@ -273,12 +273,15 @@ The format for enumeration data type definitions is a bit simpler: // The attributePolicy.ep file contains a dictionary of enumeration data types { - // Each enumeration definition is identified by its name + // Each enumeration definition is identified by its name. It is a dictionary. "enumerationType": { - // Each definition can only contain a the following attribute (definition see above) + // A single enumeration definition is key value pair. The key is the enumeration + // value, the value is the interpretation of that value. + "" : "" - "evalues" : ... + // This defines a range of values. Each one gets the same interpretation assigned. + ".." : "" } } ``` diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index f946d4d0..900ca0ff 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -1575,6 +1575,20 @@ "annc": "OA" } ], + "lit": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationInformationType", + "ns": "m2m", + "type": "enum", + "etype": "m2m:locationInformationType", + "car": "1", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], "ln": [ { "rtypes": [ "ALL" ], @@ -1619,6 +1633,35 @@ "annc": "MA" } ], + "los": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationSource", + "ns": "m2m", + "type": "enum", + "etype": "m2m:locationSource", + "car": "1", + "oc": "M", + "ou": "NP", + "od": "NP", + "annc": "OA" + } + ], + "lou": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationUpdatePeriod", + "ns": "m2m", + "type": "list", + "ltype": "duration", + "car": "01L", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], + // TODO Align later // EXPERIMENTAL "ma": [ @@ -2062,7 +2105,8 @@ "rtypes": [ "ALL" ], "lname": "notificationContentType", "ns": "m2m", - "type": "nonNegInteger", + "type": "enum", + "etype": "m2m:notificationContentType", "car": "1", "oc": "O", "ou": "O", diff --git a/init/enumTypesPolicies.ep b/init/enumTypesPolicies.ep index fff64224..450ca744 100644 --- a/init/enumTypesPolicies.ep +++ b/init/enumTypesPolicies.ep @@ -7,101 +7,402 @@ { "m2m:batteryStatus" : { - "evalues": [ "1..7" ] + "1": "Normal", + "2": "Charging", + "3": "Charging complete", + "4": "Damaged", + "5": "Low battery", + "6": "Not installed", + "7": "Unknown" }, "m2m:contentStatus" : { - "evalues": [ 1, 2 ] + "1": "Partial content", + "2": "Full content" }, "m2m:evalCriteriaOperator" : { - "evalues": [ "1..6" ] + "1": "equal", + "2": "not equal", + "3": "greater than", + "4": "less than", + "5": "greater than or equal", + "6": "less than or equal" }, "m2m:evalMode" : { - "evalues": [ "0..3" ] + "0": "off", + "1": "once", + "2": "periodic", + "3": "continuous" }, "m2m:eventCat" : { // m2m:stdEventCat + user defined range - "evalues" : [ "2..4", "100..999"] + "2": "Immediate", + "3": "Best Effort", + "4": "Latest", + "100..999": "User defined" }, // EXPERIMENTAL "m2m:eventEvaluationMode" : { - "evalues" : [ "1..5" ] + "1": "All events present", + "2": "All or some events present", + "3": "All or some events missing", + "4": "All events missing", + "5": "Some events missing" }, "m2m:filterOperation" : { - "evalues" : [ "1..3" ] + "1": "Logical AND", + "2": "Logical OR", + "3": "Logical XOR" }, "m2m:contentFilterSyntax" : { - "evalues" : [ 1 ] + "1": "JSONPath Syntax" }, "m2m:desIdResType" : { - "evalues": [ 1, 2 ] + "1": "Structured", + "2": "Unstructured" }, "m2m:logTypeId" : { - "evalues": [ "1..5" ] + "1": "System", + "2": "Security", + "3": "Event", + "4": "Trace", + "5": "Panic" }, "m2m:filterUsage" : { - "evalues" : [ "1..4" ] + "1": "Discovery", + "2": "Conditional Operation", + "3": "IPE On-demand Discovery" }, "m2m:geometryType" : { - "evalues" : [ "1..6" ] + "1": "Point", + "2": "LineString", + "3": "Polygon", + "4": "MultiPoint", + "5": "MultiLineString", + "6": "MultiPolygon" }, "m2m:geoSpatialFunctionType" : { - "evalues": [ "1..3" ] + "1": "Within", + "2": "Contains", + "3": "Intersects" + }, + "m2m:locationInformationType" : { + "1": "Position fix", + "2": "Geofence event" + }, + "m2m:locationSource" : { + "1": "Network based", + "2": "Device based", + "3": "User based" }, "m2m:logStatus" : { - "evalues": [ "1..5" ] + "1": "Started", + "2": "Stopped", + "3": "Unknown", + "4": "Not present", + "5": "Error" }, "m2m:mgmtDefinition" : { // Adapt to supported MgmtObj types - "evalues" : [ "1001..1010", 1021, 1023, 1028 ] + "0": "Self-defined", + "1001": "firmware", + "1002": "software", + "1003": "memory", + "1004": "areaNwkInfo", + "1005": "areaNwkDeviceInfo", + "1006": "battery", + "1007": "deviceInfo", + "1008": "deviceCapability", + "1009": "reboot", + "1010": "eventLog", + "1011": "cmdhPolicy", + "1012": "activeCmdhPolicy", + "1013": "cmdhDefaults", + "1014": "cmdhDefEcValue", + "1015": "cmdhEcDefParamValues", + "1016": "cmdhLimits", + "1017": "cmdhNetworkAccessRules", + "1018": "cmdhNwAccessRule", + "1019": "cmdhBuffer", + "1020": "registration", + "1021": "dataCollection", + "1022": "authenticationProfile", + "1023": "myCertFileCred", + "1024": "trustAnchorCred", + "1025": "MAFClientRegCfg", + "1026": "MEFClientRegCfg", + "1027": "OAuth2Authentication", + "1028": "wifiClient" }, "m2m:multicastCapability" : { - "evalues" : [ 1, 2 ] + "1": "MBMS", + "2": "IP" + }, + "m2m:notificationContentType" : { + "1": "m2m:", + "2": "m2m:", + "3": "m2m:URI", + "4": "m2m:triggerPayload", + "5": "m2m:timeSeriesNotification" }, "m2m:notificationEventType" : { - "evalues": [ "1..8", 9, 10 ] // EXPERIMENTAL 9, 10 experimental + "1": "Update of Resource", + "2": "Delete of Resource", + "3": "Create of Direct Child Resource", + "4": "Delete of Direct Child Resource", + "5": "Retrieve of Container Resource with No Child Resource", + "6": "Trigger Received for AE Resource", + "7": "Blocking Update", + "8": "Report on Missing Data Points", + + "9": "blockingRetrieve (EXPERIMENTAL)", // EXPERIMENTAL + "10": "blockingRetrieveDirectChild (EXPERIMENTAL)" // EXPERIMENTAL }, "m2m:operation" : { - "evalues": [ "1..5" ] + "1": "Create", + "2": "Retrieve", + "3": "Update", + "4": "Delete", + "5": "Notify" }, "m2m:responseType" : { - "evalues" : [ "1..5" ] + "1": "Non-blocking Request Synch", + "2": "Non-blocking Request Asynch", + "3": "Blocking Request", + "4": "FlexBlocking", + "5": "No Response" }, "m2m:resourceType" : { // Adapt to supported resource types - "evalues" : [ "1..5", 9, "13..18", 23, 24, "28..30", 48, 58, 60, 65, 66, - "10001..10005", 10009, "10013..10014", 10016, 10018, 10021, "10028..10030", 10060, 10065, 10066 ] + "1": "accessControlPolicy", + "2": "AE", + "3": "container", + "4": "contentInstance", + "5": "CSEBase", + "9": "group", + "13": "locationPolicy", + "14": "mgmtCmd", + "15": "mgmtObj", + "16": "node", + "17": "pollingChannel", + "18": "remoteCSE", + "23": "schedule", + "24": "serviceSubscribedAppRule", + "28": "flexContainer", + "29": "timeSeries", + "30": "timeSeriesInstance", + "48": "crossResourceSubscription", + "58": "flexContainerInstance", + "60": "timeSyncBeacon", + "65": "state", + "66": "action", + + "10001": "accessControlPolicyAnnc", + "10002": "AEAnnc", + "10003": "containerAnnc", + "10004": "contentInstanceAnnc", + "10005": "CSEBaseAnnc", + "10009": "groupAnnc", + "10013": "locationPolicyAnnc", + "10014": "mgmtObjAnnc", + "10016": "nodeAnnc", + "10018": "remoteCSEAnnc", + "10021": "scheduleAnnc", + "10028": "flexContainerAnnc", + "10029": "timeSeriesAnnc", + "10030": "timeSeriesInstanceAnnc", + "10060": "timeSyncBeaconAnnc", + "10065": "stateAnnc", + "10066": "actionAnnc" }, "m2m:responseStatusCode" : { - "evalues": [ "1000..1002", - "2000..2002", 2004, - 4000, 4001, 4004, 4005, 4008, 4015, "4101..4133", "4135..4143", - 5000, 5001, 5103, "5105..5107", "5203..5222", "5230..5232", - 6003, 6005, 6010, "6020..6026", "6028..6034"] + "1000": "ACCEPTED", + "1001": "ACCEPTED for nonBlockingRequestSynch", + "1002": "ACCEPTED for nonBlockingRequestAsynch", + + "2000": "OK", + "2001": "CREATED", + "2002": "DELETED", + "2004": "UPDATED", + + "4000": "Bad Request", + "4001": "Release Version Not Supported", + "4004": "Not Found", + "4005": "Operation Not Allowed", + "4008": "Request Timeout", + "4015": "Unsupported Media Type", + "4101": "Subscription Creator Has No Privilege", + "4102": "Contents Unacceptable", + "4103": "Originator Has No Privilege", + "4104": "Group Request Identifier Exists", + "4105": "Conflict", + "4106": "Originator Has Not Registered", + "4107": "Security Association Required", + "4108": "Invalid Child Resource Type", + "4109": "No Members", + "4110": "Group Member Type Inconsistent", + "4111": "ESPRIM Unsupported Option", + "4112": "ESPRIM Unknown Key ID", + "4113": "ESPRIM Unknown Orig RAND ID", + "4114": "ESPRIM Unknown Recv RAND ID", + "4115": "ESPRIM Bad MAC", + "4116": "ESPRIM Impersonation Error", + "4117": "Originator Has Already Registered", + "4118": "Ontology Not Available", + "4119": "Linked Semantics Not Available", + "4120": "Invalid Semantics", + "4121": "Mashup Member Not Found", + "4122": "Invalid Trigger Purpose", + "4123": "Illegal Transaction State Transition Attempted", + "4124": "Blocking Subscription Already Exists", + "4125": "Specialization Schema Not Found", + "4126": "App Rule Validation Failed", + "4127": "Operation Denied By Remote Entity", + "4128": "Service Subscription Not Established", + "4130": "Discovery Limit Exceeded", + "4131": "Ontology Mapping Algorithm Not Available", + "4132": "Ontology Mapping Policy Not Matched", + "4133": "Ontology Mapping Not Available", + "4135": "Bad Fact Inputs For Reasoning", + "4136": "Bad Rule Inputs For Reasoning", + "4137": "Discovery Limit Exceeded", + "4138": "Primitive Profile Not Accessible", + "4139": "Primitive Profile Bad Request", + "4140": "Unauthorized User", + "4141": "Service Subscription Limits Exceeded", + "4142": "Invalid Process Configuration", + "4143": "Invalid SPARQL Query", + + "5000": "Internal Server Error", + "5001": "Not Implemented", + "5103": "Target Not Reachable", + "5105": "Receiver Has No Privilege", + "5106": "Already Exists", + "5107": "Remote Entity Not Reachable", + "5203": "Target Not Subscribable", + "5204": "Subscription Verification Initiation Failed", + "5205": "Subscription Host Has No Privilege", + "5206": "Non Blocking Synch Request Not Supported", + "5207": "Not Acceptable", + "5208": "Discovery Denied By IPE", + "5209": "Group Members Not Responded", + "5210": "ESPRIM Decryption Error", + "5211": "ESPRIM Encryption Error", + "5212": "SPARQL Update Error", + "5214": "Target Has No Session Capability", + "5215": "Session Is Online", + "5216": "Join Multicast Group Failed", + "5217": "Leave Multicast Group Failed", + "5218": "Triggering Disabled For Recipient", + "5219": "Unable To Replace Request", + "5220": "Unable To Recall Request", + "5221": "Cross Resource Operation Failure", + "5222": "Transaction Processing Is Incomplete", + "5230": "Ontology Mapping Algorithm Failed", + "5231": "Ontology Conversion Failed", + "5232": "Reasoning Processing Failed", + + "6003": "External Object Not Reachable", + "6005": "External Object Not Found", + "6010": "Max Number Of Member Exceeded", + "6020": "Mgmt Session Cannot Be Established", + "6021": "Mgmt Session Establishment Timeout", + "6022": "Invalid Cmdtype", + "6023": "Invalid Arguments", + "6024": "Insufficient Arguments", + "6025": "Mgmt Conversion Error", + "6026": "Mgmt Cancellation Failed", + "6028": "Already Complete", + "6029": "Mgmt Command Not Cancellable", + "6030": "External Object Not Reachable Before RQET Timeout", + "6031": "External Object Not Reachable Before OET Timeout", + "6033": "Network QoS Configuration Error", + "6034": "Requested Activity Pattern Not Permitted" }, "m2m:resultContent" : { - "evalues": [ "0..12" ] + "0": "Nothing", + "1": "Attributes", + "2": "Hierarchical address", + "3": "Hierarchical address and attributes", + "4": "Attributes and child resources", + "5": "Attributes and child resource references", + "6": "Child resource references", + "7": "Original resource", + "8": "Child resources", + "9": "Modified attributes", + "10": "Semantic content", + "11": "Semantic content and child resources", + "12": "Permissions" }, "m2m:semanticFormat" : { - "evalues" : [ "1..7" ] + "1": "IRI", + "2": "Functional-style", + "3": "OWL/XML", + "4": "RDF/XML", + "5": "RDF/Turtle", + "6": "Manchester", + "7": "JSON-LD" }, "m2m:stationaryIndication" : { - "evalues" : [ 1, 2 ] + "1": "Stationary", + "2": "Mobile (Moving)" }, + "m2m:status" : { - "evalues" : [ "0..3" ] + "0": "Uninitialized", + "1": "Successful", + "2": "Failure", + "3": "In Process" }, "m2m:suid" : { - "evalues" : [ "10..15", "21..25", "32..35", "40..45" ] + "10": "A pre-provisioned symmetric key intended to be shared with a MEF", + "11": "A pre-provisioned symmetric key intended to be shared with a MAF", + "12": "A pre-provisioned symmetric key intended for use in a Security Associated Establishment Framework (SAEF)", + "13": "A pre-provisioned symmetric key intended for use in End-to-End Security of Primitives (ESPrim)", + "14": "A pre-provisioned symmetric key intended for use with authenticated encryption in the Encryption-only or Nested Sign-then-Encrypt End-to-End Security of Data (ESData) Data classes", + "15": "A pre-provisioned symmetric key intended for use in Signature-only ESData Security Class", + + "21": "A symmetric key, provisioned via a Remote Security Provisioning Framework (RSPF), and intended to be shared with a MAF", + "22": "A symmetric key, provisioned via a RSPF, and intended for use in a SAEF", + "23": "A symmetric key, provisioned via a RSPF, and intended for use in ESPrim", + "24": "A symmetric key, provisioned via a RSPF, and intended for use with authenticated encryption in the Encryption-only or Nested Sign-then-Encrypt ESData) Data classes", + "25": "A symmetric key, provisioned via a RSPF, and intended for use in Signature-only ESData Security Class", + + "32": "A MAF-distributed symmetric key intended for use in a SAEF", + "33": "A MAF-distributed symmetric key intended for use in ESPrim", + "34": "A MAF-distributed symmetric key intended for use with authenticated encryption in the Encryption-only or Nested Sign-then-Encrypt ESData Data classes", + "35": "A MAF-distributed symmetric key intended for use in Signature-only ESData Security Class", + + "40": "A certificate intended to be shared with a MEF", + "41": "A certificate intended to be shared with a MAF", + "42": "A certificate intended for use in a Security Associated Establishment Framework (SAEF)", + "43": "A certificate intended for use in End-to-End Security of Primitives (ESPrim)", + "44": "A certificate intended for use with authenticated encryption in the Encryption-only or Nested Sign-then-Encrypt End-to-End Security of Data (ESData) Data classes", + "45": "A certificate intended for use in Signature-only ESData Security Class" }, "m2m:timeWindowType" : { - "evalues" : [ 1, 2 ] + "1": "Periodic Window", + "2": "Sliding Window" }, "dcfg:wifiConnectionStatus" : { - "evalues" : [ "0..6" ] + "0": "Disconnected", + "1": "Connected", + "2": "Idle", + "3": "No SSID available", + "4": "Scan completed", + "5": "Failed", + "6": "Lost" }, "dcfg:wifiEncryptionType" : { - "evalues" : [ "1..8" ] + "1": "None", + "2": "WEP", + "3": "WPA Personal", + "4": "WPA2 Personal", + "5": "WPA3 Personal", + "6": "WPA Enterprise", + "7": "WPA2 Enterprise", + "8": "WPA3 Enterprise" } } + From 19ccd94b4d3d4f684994ca0588d0f1a1fbf45f5a Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 26 Jul 2023 11:49:27 +0200 Subject: [PATCH 025/165] Start of LocationPolicy implementation --- acme/resources/AE.py | 1 + acme/resources/AEAnnc.py | 3 +- acme/resources/CSEBaseAnnc.py | 1 + acme/resources/CSR.py | 3 +- acme/resources/CSRAnnc.py | 1 + acme/resources/Factory.py | 4 + acme/resources/LCP.py | 71 ++++++++++++++++++ init/attributePolicies.ap | 134 +++++++++++++++++++++++++++++++++- init/enumTypesPolicies.ep | 9 +++ 9 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 acme/resources/LCP.py diff --git a/acme/resources/AE.py b/acme/resources/AE.py index 4ff43236..c8f4b1ff 100644 --- a/acme/resources/AE.py +++ b/acme/resources/AE.py @@ -28,6 +28,7 @@ class AE(AnnounceableResource): ResourceTypes.CRS, ResourceTypes.FCNT, ResourceTypes.GRP, + ResourceTypes.LCP, ResourceTypes.PCH, ResourceTypes.SMD, ResourceTypes.SUB, diff --git a/acme/resources/AEAnnc.py b/acme/resources/AEAnnc.py index 4187500e..1d93eb4d 100644 --- a/acme/resources/AEAnnc.py +++ b/acme/resources/AEAnnc.py @@ -25,7 +25,8 @@ class AEAnnc(AnnouncedResource): ResourceTypes.FCNT, ResourceTypes.FCNTAnnc, ResourceTypes.GRP, - ResourceTypes.GRPAnnc, + ResourceTypes.GRPAnnc, + ResourceTypes.LCPAnnc, ResourceTypes.TS, ResourceTypes.TSAnnc ] diff --git a/acme/resources/CSEBaseAnnc.py b/acme/resources/CSEBaseAnnc.py index f57d51b6..0502279a 100644 --- a/acme/resources/CSEBaseAnnc.py +++ b/acme/resources/CSEBaseAnnc.py @@ -23,6 +23,7 @@ class CSEBaseAnnc(AnnouncedResource): ResourceTypes.CNTAnnc, ResourceTypes.FCNTAnnc, ResourceTypes.GRPAnnc, + ResourceTypes.LCPAnnc, ResourceTypes.NODAnnc, ResourceTypes.SCHAnnc, ResourceTypes.SUB, diff --git a/acme/resources/CSR.py b/acme/resources/CSR.py index 35d2fc8b..3e44cc7a 100644 --- a/acme/resources/CSR.py +++ b/acme/resources/CSR.py @@ -36,7 +36,8 @@ class CSR(AnnounceableResource): ResourceTypes.FCNTAnnc, ResourceTypes.FCI, ResourceTypes.GRP, - ResourceTypes.GRPAnnc, + ResourceTypes.GRPAnnc, + ResourceTypes.LCPAnnc, ResourceTypes.MGMTOBJAnnc, ResourceTypes.NODAnnc, ResourceTypes.PCH, diff --git a/acme/resources/CSRAnnc.py b/acme/resources/CSRAnnc.py index cd6a83e6..009f42dd 100644 --- a/acme/resources/CSRAnnc.py +++ b/acme/resources/CSRAnnc.py @@ -31,6 +31,7 @@ class CSRAnnc(AnnouncedResource): ResourceTypes.FCNTAnnc, ResourceTypes.GRP, ResourceTypes.GRPAnnc, + ResourceTypes.LCPAnnc, ResourceTypes.MGMTOBJAnnc, ResourceTypes.NODAnnc, ResourceTypes.SCHAnnc, diff --git a/acme/resources/Factory.py b/acme/resources/Factory.py index 10a7d1b9..1f9adb81 100644 --- a/acme/resources/Factory.py +++ b/acme/resources/Factory.py @@ -49,6 +49,8 @@ from ..resources.GRP import GRP from ..resources.GRPAnnc import GRPAnnc from ..resources.GRP_FOPT import GRP_FOPT +from ..resources.LCP import LCP +# TODO from ..resources.LCPAnnc import LCPAnnc from ..resources.NOD import NOD from ..resources.NODAnnc import NODAnnc from ..resources.PCH import PCH @@ -126,6 +128,8 @@ addResourceFactoryCallback(ResourceTypes.GRP, GRP, lambda dct, tpe, pi, create : GRP(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.GRPAnnc, GRPAnnc, lambda dct, tpe, pi, create : GRPAnnc(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.GRP_FOPT, GRP_FOPT, lambda dct, tpe, pi, create : GRP_FOPT(dct, pi = pi, create = create)) +addResourceFactoryCallback(ResourceTypes.LCP, LCP, lambda dct, tpe, pi, create : LCP(dct, pi = pi, create = create)) +# TODO addResourceFactoryCallback(ResourceTypes.LCPAnnc, LCPAnnc, lambda dct, tpe, pi, create : LCPAnnc(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.NOD, NOD, lambda dct, tpe, pi, create : NOD(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.NODAnnc, NODAnnc, lambda dct, tpe, pi, create : NODAnnc(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.PCH, PCH, lambda dct, tpe, pi, create : PCH(dct, pi = pi, create = create)) diff --git a/acme/resources/LCP.py b/acme/resources/LCP.py new file mode 100644 index 00000000..4febcea4 --- /dev/null +++ b/acme/resources/LCP.py @@ -0,0 +1,71 @@ + # +# LCP.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# ResourceType: LocationPolicy +# + +""" LocationPolicy (LCP) resource type. """ + +from __future__ import annotations +from typing import Optional + +from ..etc.Constants import Constants as C +from ..etc.Types import AttributePolicyDict, ResourceTypes, JSON +from ..services.Logging import Logging as L +from ..services import CSE +from ..resources.Resource import Resource +from ..resources.AnnounceableResource import AnnounceableResource + +# TODO add annc +# TODO add to supported resources of CSE + +class LCP(AnnounceableResource): + """ Schedule (SCH) resource type. """ + + # Specify the allowed child-resource types + _allowedChildResourceTypes:list[ResourceTypes] = [ ResourceTypes.SUB ] + """ The allowed child-resource types. """ + + # Attributes and Attribute policies for this Resource Class + # Assigned during startup in the Importer + _attributes:AttributePolicyDict = { + # Common and universal attributes + 'rn': None, + 'ty': None, + 'ri': None, + 'pi': None, + 'ct': None, + 'lt': None, + 'lbl': None, + 'acpi':None, + 'et': None, + 'daci': None, + 'cstn': None, + 'at': None, + 'aa': None, + 'ast': None, + + # Resource attributes + 'los': None, + 'lit': None, + 'lou': None, + 'lot': None, + 'lor': None, + 'loi': None, + 'lon': None, + 'lost': None, + 'gta': None, + 'gec': None, + 'aid': None, + 'rlkl': None, + 'luec': None + } + """ Attributes and `AttributePolicy` for this resource type. """ + + + def __init__(self, dct:Optional[JSON] = None, pi:Optional[str] = None, create:Optional[bool] = False) -> None: + super().__init__(ResourceTypes.LCP, dct, pi, create = create) + diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index 900ca0ff..4b0f363e 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -365,6 +365,19 @@ "annc": "OA" } ], + "aid": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "authID", + "ns": "m2m", + "type": "string", + "car": "01", + "oc": "O", + "ou": "NP", + "od": "NP", + "annc": "OA" + } + ], "air": [ { "rtypes": [ "ACTR", "ACTRAnnc", "REQRESP" ], @@ -1360,6 +1373,20 @@ "annc": "OA" } ], + "gec": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "geofenceEventCriteria", + "ns": "m2m", + "type": "enum", + "etype": "m2m:geofenceEventCriteria", + "car": "01", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], "gn": [ { "rtypes": [ "ALL" ], @@ -1386,6 +1413,19 @@ "annc": "NA" } ], + "gta": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "geographicalTargetArea", + "ns": "m2m", + "type": "any", + "car": "01", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], "hael": [ { "rtypes": [ "ALL" ], @@ -1633,6 +1673,32 @@ "annc": "MA" } ], + "loi": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationContainerID", + "ns": "m2m", + "type": "anyURI", + "car": "1", + "oc": "NP", + "ou": "NP", + "od": "NP", + "annc": "OA" + } + ], + "lon": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationContainerName", + "ns": "m2m", + "type": "string", + "car": "01", + "oc": "O", + "ou": "NP", + "od": "NP", + "annc": "OA" + } + ], "los": [ { "rtypes": [ "LCP", "LCPAnnc" ], @@ -1640,13 +1706,52 @@ "ns": "m2m", "type": "enum", "etype": "m2m:locationSource", - "car": "1", + "car": "01", "oc": "M", "ou": "NP", "od": "NP", "annc": "OA" } ], + "lost": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationStatus", + "ns": "m2m", + "type": "string", + "car": "1", + "oc": "NP", + "ou": "NP", + "od": "NP", + "annc": "OA" + } + ], + "lor": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationServer", + "ns": "m2m", + "type": "anyURI", + "car": "01", + "oc": "O", + "ou": "NP", + "od": "NP", + "annc": "OA" + } + ], + "lot": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationTargetID", + "ns": "m2m", + "type": "string", + "car": "01L", + "oc": "O", + "ou": "NP", + "od": "NP", + "annc": "OA" + } + ], "lou": [ { "rtypes": [ "LCP", "LCPAnnc" ], @@ -1661,6 +1766,20 @@ "annc": "OA" } ], + "luec": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationUpdateEventCriteria", + "ns": "m2m", + "type": "enum", + "etype": "m2m:locationUpdateEventCriteria", + "car": "01", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], // TODO Align later // EXPERIMENTAL @@ -2578,6 +2697,19 @@ "annc": "NA" } ], + "rlkl": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "retrieveLastKnownLocation", + "ns": "m2m", + "type": "boolean", + "car": "01", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], "rms": [ { "rtypes": [ "ALL" ], diff --git a/init/enumTypesPolicies.ep b/init/enumTypesPolicies.ep index 450ca744..ecd7fac8 100644 --- a/init/enumTypesPolicies.ep +++ b/init/enumTypesPolicies.ep @@ -74,6 +74,12 @@ "2": "Conditional Operation", "3": "IPE On-demand Discovery" }, + "m2m:geofenceEventCriteria" : { + "1": "Entering", + "2": "Leaving", + "3": "Inside", + "4": "Outside" + }, "m2m:geometryType" : { "1": "Point", "2": "LineString", @@ -96,6 +102,9 @@ "2": "Device based", "3": "User based" }, + "m2m:locationUpdateEventCriteria": { + "0": "Location_Change" + }, "m2m:logStatus" : { "1": "Started", "2": "Stopped", From e55ae56e583868bdb4b42800d992a02825d3c7eb Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 28 Jul 2023 11:11:03 +0200 Subject: [PATCH 026/165] Improved request sending --- acme/helpers/Interpreter.py | 1 - acme/services/ScriptManager.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index af3fc2b9..4eeea634 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -2253,7 +2253,6 @@ def _doIn(pcontext:PContext, symbol:SSymbol) -> PContext: # Get symbol (!) to check pcontext, _s = pcontext.resultFromArgument(symbol, 2, (SType.tString, SType.tList, SType.tListQuote)) - # check return pcontext.setResult(SSymbol(boolean = _v in _s)) diff --git a/acme/services/ScriptManager.py b/acme/services/ScriptManager.py index 90d27895..c797d859 100644 --- a/acme/services/ScriptManager.py +++ b/acme/services/ScriptManager.py @@ -396,7 +396,7 @@ def doGetConfiguration(self, pcontext:PContext, symbol:SSymbol) -> PContext: # config value if (_v := Configuration.get(_key)) is None: - raise PUndefinedError(pcontext.setError(PError.undefined, f'undefined key: {_key}')) + raise PUndefinedError(pcontext.setError(PError.undefined, f'undefined configuration key: {_key}')) return pcontext.setResult(SSymbol(value = _v)) @@ -1423,7 +1423,7 @@ def _handleRequest(self, pcontext:PContext, symbol:SSymbol, operation:Operation) if operation == Operation.CREATE: if (ty := ResourceTypes.fromTPE( list(content.keys())[0] )) is None: # first is tpe raise PInvalidArgumentError(pcontext.setError(PError.invalid, 'Cannot determine resource type')) - req['ty'] = ty + req['ty'] = ty.value # Add primitive content when content is available req['pc'] = content From f12d06064d89dfcec2bb0850353179c04c63d8f3 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 28 Jul 2023 12:48:20 +0200 Subject: [PATCH 027/165] Prevent crash when no originator is available for a resource --- acme/textui/ACMEContainerDelete.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/acme/textui/ACMEContainerDelete.py b/acme/textui/ACMEContainerDelete.py index 7aa3a46d..36534e2a 100644 --- a/acme/textui/ACMEContainerDelete.py +++ b/acme/textui/ACMEContainerDelete.py @@ -85,7 +85,10 @@ def on_show(self) -> None: def updateResource(self, resource:Resource) -> None: self.requestOriginator = resource.getOriginator() - self.fieldOriginator.update(self.requestOriginator, [CSE.cseOriginator, self.requestOriginator]) + if self.requestOriginator: + self.fieldOriginator.update(self.requestOriginator, [CSE.cseOriginator, self.requestOriginator]) + else: # No originator, use CSE originator + self.fieldOriginator.update(CSE.cseOriginator, [CSE.cseOriginator]) self.resource = resource self.response.update('') From 570b11bd0badfeedf954de91eb23969f0ffc522b Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 28 Jul 2023 14:28:16 +0200 Subject: [PATCH 028/165] Corrected resource types --- init/enumTypesPolicies.ep | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/init/enumTypesPolicies.ep b/init/enumTypesPolicies.ep index ecd7fac8..bdc25acb 100644 --- a/init/enumTypesPolicies.ep +++ b/init/enumTypesPolicies.ep @@ -190,22 +190,23 @@ "4": "contentInstance", "5": "CSEBase", "9": "group", - "13": "locationPolicy", - "14": "mgmtCmd", - "15": "mgmtObj", - "16": "node", - "17": "pollingChannel", - "18": "remoteCSE", - "23": "schedule", - "24": "serviceSubscribedAppRule", + "10": "locationPolicy", + "13": "mgmtObj", + "14": "node", + "15": "pollingChannel", + "16": "remoteCSE", + "17": "request", + "18": "schedule", + "23": "subscription", + "24": "semanticDescriptor", "28": "flexContainer", "29": "timeSeries", "30": "timeSeriesInstance", "48": "crossResourceSubscription", "58": "flexContainerInstance", "60": "timeSyncBeacon", - "65": "state", - "66": "action", + "65": "action", + "66": "dependency", "10001": "accessControlPolicyAnnc", "10002": "AEAnnc", @@ -213,17 +214,18 @@ "10004": "contentInstanceAnnc", "10005": "CSEBaseAnnc", "10009": "groupAnnc", - "10013": "locationPolicyAnnc", - "10014": "mgmtObjAnnc", - "10016": "nodeAnnc", - "10018": "remoteCSEAnnc", - "10021": "scheduleAnnc", + "10010": "locationPolicyAnnc", + "10013": "mgmtObjAnnc", + "10014": "nodeAnnc", + "10016": "remoteCSEAnnc", + "10018": "scheduleAnnc", + "10024": "semanticDescriptorAnnc", "10028": "flexContainerAnnc", "10029": "timeSeriesAnnc", "10030": "timeSeriesInstanceAnnc", "10060": "timeSyncBeaconAnnc", - "10065": "stateAnnc", - "10066": "actionAnnc" + "10065": "actionAnnc", + "10066": "dependencyAnnc" }, "m2m:responseStatusCode" : { "1000": "ACCEPTED", From 9cc8016f22713612e85b945b7c8dbae35125c9b5 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 28 Jul 2023 14:28:42 +0200 Subject: [PATCH 029/165] Added locationPolicy enum types --- acme/etc/Types.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/acme/etc/Types.py b/acme/etc/Types.py index f69b3473..a92bf5d4 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -1477,10 +1477,48 @@ class SemanticFormat(ACMEIntEnum): SemanticFormat.FF_RdfTurtle: 'ttl', SemanticFormat.FF_Manchester: 'manchester', SemanticFormat.FF_JsonLD: 'json-ld', +} + + +############################################################################## +# +# LocationPolicy related +# + +class LocationSource(ACMEIntEnum): + """ Location Source. + """ + Network_based = 1 + """ Network based. """ + Device_based = 2 + """ Device based. """ + Sharing_based = 3 + """ Sharing based. """ -} +class GeofenceEventCriteria(ACMEIntEnum): + """ Geofence Event Criteria. + """ + + Entering = 1 + """ Entering. """ + Leaving = 2 + """ Leaving. """ + Inside = 3 + """ Inside. """ + Outside = 4 + """ Outside. """ + + +class LocationUpdateEventCriteria(ACMEIntEnum): + """ Location Update Event Criteria. + """ + + Location_Change = 0 + """ Location Change. """ + + ############################################################################## # # Result and Argument and Header Data Classes From 6479053fc9641dea3e24a16035fc6b3dd72f5c37 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 28 Jul 2023 17:02:04 +0200 Subject: [PATCH 030/165] Added default configurations for LCP containers --- acme.ini.default | 83 ++++++++++++++++++++++++++-------- acme/services/Configuration.py | 8 ++++ docs/Configuration.md | 14 ++++++ init/configurations.docmd | 24 ++++++++++ 4 files changed, 111 insertions(+), 18 deletions(-) diff --git a/acme.ini.default b/acme.ini.default index 80f18903..d0b12d74 100644 --- a/acme.ini.default +++ b/acme.ini.default @@ -248,6 +248,41 @@ caCertificateFile=${basic.config:dataDirectory}/certs/m2mqtt_ca.crt allowedCredentialIDs= +; +; CoAP client settings +; + +[coap] +; Enable the CoAP binding. +; Default: false +enable=false +serverPort=5683 +; Interface to listen to. Use 0.0.0.0 for "all" interfaces. +; Default: +listenIF=${basic.config:networkInterface} + + +; +; CoAP security settings +; + +[coap.security] +; Enable DTLS for communications with the CoAP server. +; Default: False +useDTLS=false +; TLS version to be used in connections. +; Allowed versions: TLS1.1, TLS1.2, auto . Use "auto" to allow client-server certificate +; version negotiation. +; Default: auto +dtlsVersion=auto +; Verify certificates in requests. Set to False when using self-signed certificates. +; Default: False +verifyCertificate=False +; Path and filename of the certificate file. Default: ${basic.config:dataDirectory}/certs/coap_cert.pem +certificateFile=${basic.config:dataDirectory}/certs/coap_cert.pem +; Path and filename of the private key file. Default: None +privateKeyFile=${basic.config:dataDirectory}/certs/coap_key.pem + ; ; Database settings ; @@ -405,6 +440,36 @@ mni=10 mbs=10000 +; +; Resource defaults: LocationPolicy +; + +[resource.lcp] +; Default for maxNrOfInstances for the LocationPolicy's container. +; Default: 10 +mni=10 +; Default for maxByteSize for the LocationPolicy's container. Default: 10.000 bytes +mbs=10000 + + +; +; Resource defaults: Request +; + +[resource.req] +; A resource's expiration time in seconds. Must be >0. Default: 60 +expirationTime=60 + + +; +; Resource defaults: Subscription +; + +[resource.sub] +; Default for batchNotify/duration in seconds. Must be >0. Default: 60 +batchNotifyDuration=60 + + ; ; Resource defaults: TimeSeries ; @@ -435,24 +500,6 @@ bcni=PT1H bcnt=10.0 -; -; Resource defaults: Request -; - -[resource.req] -; A resource's expiration time in seconds. Must be >0. Default: 60 -expirationTime=60 - - -; -; Resource defaults: Subscription -; - -[resource.sub] -; Default for batchNotify/duration in seconds. Must be >0. Default: 60 -batchNotifyDuration=60 - - ; ; Web UI settings ; diff --git a/acme/services/Configuration.py b/acme/services/Configuration.py index 7f09ed4e..5361e5a5 100644 --- a/acme/services/Configuration.py +++ b/acme/services/Configuration.py @@ -405,6 +405,14 @@ def init(args:argparse.Namespace = None) -> bool: 'resource.cnt.mbs' : config.getint('resource.cnt', 'mbs', fallback = 10000), + # + # Defaults for LocationPolicy Resources + # + + 'resource.lcp.mni' : config.getint('resource.lcp', 'mni', fallback = 10), + 'resource.lcp.mbs' : config.getint('resource.lcp', 'mbs', fallback = 10000), + + # # Defaults for Request Resources # diff --git a/docs/Configuration.md b/docs/Configuration.md index 87f09936..a6899d01 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -76,6 +76,7 @@ The following tables provide detailed descriptions of all the possible CSE confi [[resource.acp] - Resource defaults: Access Control Policies](#resource_acp) [[resource.actr] - Resource defaults: Action](#resource_actr) [[resource.cnt] - Resource Defaults: Container](#resource_cnt) +[[resource.lcp] - Resource Defaults: LocationPolicy](#resource_lcp) [[resource.req] - Resource Defaults: Request](#resource_req) [[resource.sub] - Resource Defaults: Subscription](#resource_sub) [[resource.ts] - Resource Defaults: TimeSeries](#resource_ts) @@ -376,6 +377,19 @@ The following tables provide detailed descriptions of all the possible CSE confi --- + + +### [resource.lcp] - Resource Defaults: + +| Setting | Description | Configuration Name | +|:--------|:--------------------------------------------------------------------------------------|:-------------------| +| mni | Default for maxNrOfInstances for the LocationPolicy's container.
Default: 10 | resource.lcp.mni | +| mbs | Default for maxByteSize for the LocationPolicy's container.
Default: 10.000 bytes | resource.lcp.mbs | + +[top](#sections) + +--- + ### [resource.req] - Resource Defaults: Request diff --git a/init/configurations.docmd b/init/configurations.docmd index 42807d42..708af284 100644 --- a/init/configurations.docmd +++ b/init/configurations.docmd @@ -1157,6 +1157,30 @@ The default value is `10000 bytes`. +# resource.lcp + +This section specifies the CSE's defaults for LCP (LocationPolicy) resources. + +Settings in this section are listed under the `[resource.lcp]` section. + + + +# resource.lcp.mni + +This setting specifies the value of the *mni* (maxNrOfInstances) attribute for the "locations" CNT resource that is created by the CSE when the LCP is created. + +The default value is `10`. + + + +# resource.lcp.mbs + +This setting specifies the value of the *mbs* (maxByteSize) attribute for the "locations" CNT resource that is created by the CSE when the LCP is created. + +The default value is `10000 bytes`. + + + # resource.req This section specifies the CSE's defaults for REQ (Request) resources. From ca3da14755ce65af201242e3535d7418d425d551 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 4 Aug 2023 14:54:34 +0200 Subject: [PATCH 031/165] Added utilities to work with geo locations, positions, and geo-fences --- acme/etc/GeoUtils.py | 75 ++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 38 ++++++++++++---------- setup.py | 2 ++ 3 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 acme/etc/GeoUtils.py diff --git a/acme/etc/GeoUtils.py b/acme/etc/GeoUtils.py new file mode 100644 index 00000000..53ff8d98 --- /dev/null +++ b/acme/etc/GeoUtils.py @@ -0,0 +1,75 @@ +# +# GeoUtils.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# Various helpers for working with geo-coordinates, shapely, and geoJSON +# + +""" Utility functions for geo-coordinates and geoJSON +""" + +from typing import Union, Optional, cast +import json +from shapely import Point, Polygon + + +def getGeoPoint(jsn:Optional[Union[dict, str]]) -> Optional[tuple[float, float]]: + """ Get the geo-point from a geoJSON object. + + Args: + jsn: The geoJSON object as a dictionary or a string. + + Returns: + A tuple of the geo-point (latitude, longitude). None if not found or invalid JSON. + """ + if jsn is None: + return None + if isinstance(jsn, str): + try: + jsn = json.loads(jsn) + except ValueError: + return None + if cast(dict, jsn).get('type') != 'Point': + return None + if coordinates := cast(dict, jsn).get('coordinates'): + return coordinates[0], coordinates[1] + return None + + +def getGeoPolygon(jsn:Optional[Union[dict, str]]) -> Optional[list[tuple[float, float]]]: + """ Get the geo-polygon from a geoJSON object. + + Args: + jsn: The geoJSON object as a dictionary or a string. + + Returns: + A list of tuples of the geo-polygon (latitude, longitude). None if not found or invalid JSON. + """ + if jsn is None: + return None + if isinstance(jsn, str): + try: + jsn = json.loads(jsn) + except ValueError: + return None + if cast(dict, jsn).get('type') != 'Polygon': + return None + if coordinates := cast(dict, jsn).get('coordinates'): + return coordinates[0] + return None + + +def isLocationInsidePolygon(polygon:list[tuple[float, float]], location:tuple[float, float]) -> bool: + """ Check if a location is inside a polygon. + + Args: + polygon: The polygon as a list of tuples (latitude, longitude). + location: The location as a tuple (latitude, longitude). + + Returns: + True if the location is inside the polygon, False otherwise. + """ + return Polygon(polygon).contains(Point(location)) + diff --git a/requirements.txt b/requirements.txt index 03d0f878..194b5f86 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,21 +8,21 @@ blinker==1.6.2 # via flask cbor2==5.4.6 # via ACME-oneM2M-CSE (setup.py) -certifi==2023.5.7 +certifi==2023.7.22 # via requests -charset-normalizer==3.1.0 +charset-normalizer==3.2.0 # via requests -click==8.1.3 +click==8.1.6 # via flask flask==2.3.2 # via # ACME-oneM2M-CSE (setup.py) # flask-cors -flask-cors==3.0.10 +flask-cors==4.0.0 # via ACME-oneM2M-CSE (setup.py) idna==3.4 # via requests -importlib-metadata==6.7.0 +importlib-metadata==6.8.0 # via textual inquirerpy==0.3.4 # via ACME-oneM2M-CSE (setup.py) @@ -36,7 +36,7 @@ jinja2==3.1.2 # via flask linkify-it-py==2.0.2 # via markdown-it-py -markdown-it-py[linkify,plugins]==2.2.0 +markdown-it-py[linkify,plugins]==3.0.0 # via # mdit-py-plugins # rich @@ -49,43 +49,47 @@ mdit-py-plugins==0.4.0 # via markdown-it-py mdurl==0.1.2 # via markdown-it-py +numpy==1.25.2 + # via shapely paho-mqtt==1.6.1 # via ACME-oneM2M-CSE (setup.py) pfzy==0.3.4 # via inquirerpy plotext==5.2.8 # via ACME-oneM2M-CSE (setup.py) -prompt-toolkit==3.0.38 +prompt-toolkit==3.0.39 # via inquirerpy pygments==2.15.1 # via rich -pyparsing==3.1.0 +pyparsing==3.1.1 # via rdflib -rdflib==6.3.2 +python3-dtls==1.3.0 + # via ACME-oneM2M-CSE (setup.py) +rdflib==7.0.0 # via ACME-oneM2M-CSE (setup.py) requests==2.31.0 # via ACME-oneM2M-CSE (setup.py) -rich==13.4.2 +rich==13.5.2 # via # ACME-oneM2M-CSE (setup.py) # textual +shapely==2.0.1 + # via ACME-oneM2M-CSE (setup.py) six==1.16.0 - # via - # flask-cors - # isodate -textual==0.28.1 + # via isodate +textual==0.32.0 # via ACME-oneM2M-CSE (setup.py) tinydb==4.8.0 # via ACME-oneM2M-CSE (setup.py) -typing-extensions==4.6.3 +typing-extensions==4.7.1 # via textual uc-micro-py==1.0.2 # via linkify-it-py -urllib3==2.0.3 +urllib3==2.0.4 # via requests wcwidth==0.2.6 # via prompt-toolkit werkzeug==2.3.6 # via flask -zipp==3.15.0 +zipp==3.16.2 # via importlib-metadata diff --git a/setup.py b/setup.py index b5549688..c9259c2b 100644 --- a/setup.py +++ b/setup.py @@ -34,9 +34,11 @@ 'isodate', 'paho-mqtt', 'plotext', + 'python3-dtls', 'rdflib', 'requests', 'rich', + 'shapely', 'textual', 'tinydb', ], From c286eea042e2b2f4410a9234dfa158ac3894dd49 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 4 Aug 2023 14:55:19 +0200 Subject: [PATCH 032/165] Added option to limit durations to ISO only --- acme/etc/DateUtils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/acme/etc/DateUtils.py b/acme/etc/DateUtils.py index 4e86ddd4..9e6b195a 100644 --- a/acme/etc/DateUtils.py +++ b/acme/etc/DateUtils.py @@ -77,11 +77,12 @@ def fromAbsRelTimestamp(absRelTimestamp:str, return default -def fromDuration(duration:str) -> float: +def fromDuration(duration:str, allowMS:bool = True) -> float: """ Convert a duration to a number of seconds (float). Args: duration: String with either an ISO 8601 period or a string with a number of ms. + allowMS: If True, the function tries to convert the string as if it contains a number of ms. Return: Float, number of seconds. Raise: @@ -93,7 +94,9 @@ def fromDuration(duration:str) -> float: try: # Last try: absRelTimestamp could be a relative offset in ms. Try to convert # the string and return an absolute UTC-based duration - return float(duration) / 1000.0 + if allowMS: + return float(duration) / 1000.0 + raise except Exception as e: #if L.isWarn: L.logWarn(f'Wrong format for duration: {duration}') raise From 298a10142cf6b8b3c42518ee414432b98a7563a3 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 4 Aug 2023 14:57:25 +0200 Subject: [PATCH 033/165] Added first support for LocationPolicy --- acme.ini.default | 3 +- acme/etc/Types.py | 9 + acme/resources/CNT.py | 29 ++- acme/resources/CNT_LA.py | 33 ++- acme/resources/CSEBase.py | 1 + acme/resources/LCP.py | 137 ++++++++++- acme/services/CSE.py | 8 +- acme/services/Dispatcher.py | 15 +- acme/services/LocationManager.py | 336 +++++++++++++++++++++++++++ acme/services/Validator.py | 2 +- tests/init.py | 4 +- tests/testLCP.py | 383 +++++++++++++++++++++++++++++++ 12 files changed, 945 insertions(+), 15 deletions(-) create mode 100644 acme/services/LocationManager.py create mode 100644 tests/testLCP.py diff --git a/acme.ini.default b/acme.ini.default index d0b12d74..023f1c99 100644 --- a/acme.ini.default +++ b/acme.ini.default @@ -448,7 +448,8 @@ mbs=10000 ; Default for maxNrOfInstances for the LocationPolicy's container. ; Default: 10 mni=10 -; Default for maxByteSize for the LocationPolicy's container. Default: 10.000 bytes +; Default for maxByteSize for the LocationPolicy's container. +; Default: 10.000 bytes mbs=10000 diff --git a/acme/etc/Types.py b/acme/etc/Types.py index a92bf5d4..06c243ef 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -1518,6 +1518,15 @@ class LocationUpdateEventCriteria(ACMEIntEnum): Location_Change = 0 """ Location Change. """ + +class LocationInformationType(ACMEIntEnum): + """ Location Information Type. + """ + + Position_fix = 1 + """ Position fix. """ + Geofence_event = 2 + """ Geofence event. """ ############################################################################## # diff --git a/acme/resources/CNT.py b/acme/resources/CNT.py index 5599baea..65cad4da 100644 --- a/acme/resources/CNT.py +++ b/acme/resources/CNT.py @@ -76,10 +76,6 @@ def __init__(self, dct:Optional[JSON] = None, create:Optional[bool] = False) -> None: super().__init__(ResourceTypes.CNT, dct, pi, create = create) - # TODO optimize this - if Configuration.get('resource.cnt.enableLimits'): # Only when limits are enabled - self.setAttribute('mni', Configuration.get('resource.cnt.mni'), overwrite = False) - self.setAttribute('mbs', Configuration.get('resource.cnt.mbs'), overwrite = False) self.setAttribute('cni', 0, overwrite = False) self.setAttribute('cbs', 0, overwrite = False) self.setAttribute('st', 0, overwrite = False) @@ -89,7 +85,13 @@ def __init__(self, dct:Optional[JSON] = None, def activate(self, parentResource:Resource, originator:str) -> None: super().activate(parentResource, originator) - + + # Set the limits for this container if enabled + # TODO optimize this + if Configuration.get('resource.cnt.enableLimits'): # Only when limits are enabled + self.setAttribute('mni', Configuration.get('resource.cnt.mni'), overwrite = False) + self.setAttribute('mbs', Configuration.get('resource.cnt.mbs'), overwrite = False) + # register latest and oldest virtual resources L.isDebug and L.logDebug(f'Registering latest and oldest virtual resources for: {self.ri}') @@ -249,3 +251,20 @@ def _validateChildren(self) -> None: # End validating self.__validating = False + + def setLCPLink(self, lcpRi:str) -> None: + """ Set the link to the resource. This is called from the resource. + This also sets the link in the resource. + + Args: + lcpRi: The resource id of the resource. + """ + + self.setAttribute('li', lcpRi) + + # Also, set in the resource + if (latest := CSE.dispatcher.retrieveLocalResource(self.getLatestRI())) is not None: + latest.setLCPLink(lcpRi) + latest.dbUpdate() + + self.dbUpdate() \ No newline at end of file diff --git a/acme/resources/CNT_LA.py b/acme/resources/CNT_LA.py index c07173e2..38ba0eea 100644 --- a/acme/resources/CNT_LA.py +++ b/acme/resources/CNT_LA.py @@ -13,7 +13,7 @@ from __future__ import annotations from typing import Optional -from ..etc.Types import AttributePolicyDict, ResourceTypes, Result, JSON, CSERequest +from ..etc.Types import AttributePolicyDict, ResourceTypes, Result, JSON, CSERequest, LocationSource from ..etc.ResponseStatusCodes import ResponseStatusCode, OPERATION_NOT_ALLOWED, NOT_FOUND from ..services import CSE from ..services.Logging import Logging as L @@ -24,6 +24,9 @@ class CNT_LA(VirtualResource): """ This class implements the virtual resource for resources. """ + _li = '__li__' + """ Link to LCP from the parent resource. """ + _allowedChildResourceTypes:list[ResourceTypes] = [ ] """ A list of allowed child-resource types for this resource type. """ @@ -39,6 +42,9 @@ def __init__(self, dct:Optional[JSON] = None, pi:Optional[str] = None, create:Optional[bool] = False) -> None: super().__init__(ResourceTypes.CNT_LA, dct, pi, create = create, inheritACP = True, readOnly = True, rn = 'la') + + # Add to internal attributes to ignore in validation etc + self._addToInternalAttributes(self._li) def handleRetrieveRequest(self, request:Optional[CSERequest] = None, @@ -55,6 +61,13 @@ def handleRetrieveRequest(self, request:Optional[CSERequest] = None, The latest for the parent , or an error `Result`. """ L.isDebug and L.logDebug('Retrieving latest CIN from CNT') + + # Handle the request when the parent container's locationID is set + # This might create a new CIN + if (li := self.getLCPLink()) is not None: + if (result := self.retrieveLatestOldest(request, originator, ResourceTypes.CIN, oldest = False)) is not None: + CSE.location.handleLatestRetrieve(result.resource, li) + return self.retrieveLatestOldest(request, originator, ResourceTypes.CIN, oldest = False) @@ -107,3 +120,21 @@ def handleDeleteRequest(self, request:CSERequest, id:str, originator:str) -> Res raise NOT_FOUND('no instance for ') CSE.dispatcher.deleteLocalResource(resource, originator, withDeregistration = True) return Result(rsc = ResponseStatusCode.DELETED, resource = resource) + + + def getLCPLink(self) -> str: + """ Retrieve a `LocationPolicy` resource's resource ID. + + Return: + The resource ID. + """ + return self[self._li] + + + def setLCPLink(self, lcpRi:str) -> None: + """ Assign a resource ID of a `LocationPolicy` resource to the latest resource. + + Args: + ri: The resource ID of an `LocationPolicy` resource. + """ + self.setAttribute(self._li, lcpRi, overwrite = True) diff --git a/acme/resources/CSEBase.py b/acme/resources/CSEBase.py index b5e4230d..24655f64 100644 --- a/acme/resources/CSEBase.py +++ b/acme/resources/CSEBase.py @@ -32,6 +32,7 @@ class CSEBase(AnnounceableResource): ResourceTypes.CNT, ResourceTypes.FCNT, ResourceTypes.GRP, + ResourceTypes.LCP, ResourceTypes.NOD, ResourceTypes.REQ, ResourceTypes.SCH, diff --git a/acme/resources/LCP.py b/acme/resources/LCP.py index 4febcea4..389040bf 100644 --- a/acme/resources/LCP.py +++ b/acme/resources/LCP.py @@ -13,17 +13,23 @@ from typing import Optional from ..etc.Constants import Constants as C -from ..etc.Types import AttributePolicyDict, ResourceTypes, JSON +from ..etc.Types import AttributePolicyDict, ResourceTypes, JSON, LocationSource, GeofenceEventCriteria, LocationUpdateEventCriteria, LocationInformationType from ..services.Logging import Logging as L from ..services import CSE +from ..services.Configuration import Configuration from ..resources.Resource import Resource from ..resources.AnnounceableResource import AnnounceableResource +from ..resources import Factory +from ..etc.ResponseStatusCodes import BAD_REQUEST, NOT_IMPLEMENTED +from ..etc.GeoUtils import getGeoPolygon # TODO add annc # TODO add to supported resources of CSE class LCP(AnnounceableResource): - """ Schedule (SCH) resource type. """ + """ LocationPolicy (LCP) resource type. """ + + _gta = '__gta__' # Specify the allowed child-resource types _allowedChildResourceTypes:list[ResourceTypes] = [ ResourceTypes.SUB ] @@ -69,3 +75,130 @@ class LCP(AnnounceableResource): def __init__(self, dct:Optional[JSON] = None, pi:Optional[str] = None, create:Optional[bool] = False) -> None: super().__init__(ResourceTypes.LCP, dct, pi, create = create) + # Add to internal attributes to ignore in validation etc + self._addToInternalAttributes(self._gta) + + + def activate(self, parentResource: Resource, originator: str) -> None: + super().activate(parentResource, originator) + + # Creating extra resource + # Set the li attribute to the LCP's ri afterwards + _cnt:JSON = { + 'mni': Configuration.get('resource.lcp.mni'), + 'mbs': Configuration.get('resource.lcp.mbs'), + } + if self.lon is not None: # add container's resourcename if provided + _cnt['rn'] = self.lon + + container = Factory.resourceFromDict(_cnt, + pi = parentResource.ri, + ty = ResourceTypes.CNT) + try: + container = CSE.dispatcher.createLocalResource(container, parentResource, originator) + except Exception as e: + L.isWarn and L.logWarn(f'Could not create container for LCP: {e}') + raise BAD_REQUEST(f'Could not create container for LCP. Resource name: {self.lon} already exists?') + # set internal attributes afterwards (after validation) + container.setLCPLink(self.ri) + + # Set backlink to container in LCP + self.setAttribute('loi', container.ri) + + + # Register the LCP for periodic positioning procedure + CSE.location.addLocationPolicy(self) + + + + # If the value of locationUpdatePeriod attribute is updated to 0 or NULL, + # the Hosting CSE shall stop periodical positioning procedure and perform the procedure when + # Originator retrieves the resource of the linked resource. See clause 10.2.9.6 and clause 10.2.9.7 for more detail. + + # TODO add event for latest + location retrieval + + # If the value of locationUpdatePeriod attribute is updated to bigger than 0 (e.g. 1 hour) from 0 or NULL, + # the Hosting CSE shall start periodical positioning procedure. + + + def updated(self, dct: JSON | None = None, originator: str | None = None) -> None: + super().updated(dct, originator) + + # update the location policy handling + CSE.location.updateLocationPolicy(self) + + + def deactivate(self, originator:str) -> None: + # Delete the extra resource + if self.loi is not None: + CSE.dispatcher.deleteResource(self.loi, originator) + CSE.location.removeLocationPolicy(self) + super().deactivate(originator) + + + def validate(self, originator: str | None = None, dct: JSON | None = None, parentResource: Resource | None = None) -> None: + + def validateNetworkBasedAttributes() -> None: + """ Validate the Network_based attributes. """ + + if self.getFinalResourceAttribute('lot', dct) is not None: # locationTargetID + raise BAD_REQUEST(f'Attribute lot is only allowed if los is Network_based.') + if self.getFinalResourceAttribute('aid', dct) is not None: # authID + raise BAD_REQUEST(f'Attribute aid is only allowed if los is Network_based.') + if self.getFinalResourceAttribute('lor', dct) is not None: # locationServer + raise BAD_REQUEST(f'Attribute aid is only allowed if los is Network_based.') + if self.getFinalResourceAttribute('rlkl', dct) is not None: # retrieveLastKnownLocation + raise BAD_REQUEST(f'Attribute rlkl is only allowed if los is Network_based.') + if self.getFinalResourceAttribute('luec', dct) is not None: # loocationUpdateEventCriteria + raise BAD_REQUEST(f'Attribute luec is only allowed if los is Network_based.') + + super().validate(originator, dct, parentResource) + + # Error for unsupported location source types + los = self.getFinalResourceAttribute('los', dct) # locationSource + if los in [ LocationSource.Network_based, LocationSource.Sharing_based]: + raise NOT_IMPLEMENTED(L.logWarn(f'Unsupported LocationSource: {LocationSource(self.los)}')) + + + # Check the various locationSource types + match los: + case LocationSource.Network_based | LocationSource.Sharing_based: + raise NOT_IMPLEMENTED(L.logWarn(f'Unsupported LocationSource: {LocationSource(los)}')) + case LocationSource.Device_based: + validateNetworkBasedAttributes() + + # Always set the lost to an empty string as long as the locationSource is not Network_based + self.setAttribute('lost', '') + + # Validate the polygon + if (gta := self.gta) is not None: + if (_gta := getGeoPolygon(gta)) is None: + raise BAD_REQUEST('Invalid geographicalTargetArea. Must be a valid geoJSON polygon.') + self.setAttribute(self._gta, _gta) # store the geoJSON polygon in the internal attribute + + + + # TODO store lou to _lou + + + + # TODO more warnings for unsupported attributes (mainly for geo server) + + +# TODo geographicalTargetArea : What if not closed? +# TODO geofenceEventCriteria should be a list of GeofenceEventCriteria +# TODO retrieveLastKnownLocation: Indicates if the Hosting CSE shall retrieve the last known location when the Hosting CSE fails to retrieve the latest location WTF`????? +# TODO: locationUpdateEventCriteria Not supported + + + +#Procedure for resource that stores location information + +# After the resource that stores the location information is created, each instance of location information shall be stored +# in the different resources. In order to store the location information in the resource, +# the Hosting CSE firstly checks the defined locationUpdatePeriod attribute. +# If a valid period value is set for this attribute, the Hosting CSE shall perform the positioning procedures as defined by locationUpdatePeriod +# in the associated resource and stores the results (e.g. position fix and uncertainty) in the resource +# under the created resource. However, if no value (e.g. null or zero) is set and locationUpdateEventCriteria is absent, +# the positioning procedure shall be performed when an Originator requests to retrieve the resource of the +# resource and the result shall be stored as a resource under the resource. \ No newline at end of file diff --git a/acme/services/CSE.py b/acme/services/CSE.py index 625d1f7b..a23a8aad 100644 --- a/acme/services/CSE.py +++ b/acme/services/CSE.py @@ -30,6 +30,7 @@ from ..services.GroupManager import GroupManager from ..services.HttpServer import HttpServer from ..services.Importer import Importer +from ..services.LocationManager import LocationManager from ..services.MQTTClient import MQTTClient from ..services.NotificationManager import NotificationManager from ..services.RegistrationManager import RegistrationManager @@ -73,6 +74,9 @@ importer:Importer = None """ Runtime instance of the `Importer`. """ +location:LocationManager = None +""" Runtime instance of the `LocationManager`. """ + mqttClient:MQTTClient = None """ Runtime instance of the `MQTTClient`. """ @@ -189,7 +193,7 @@ def startup(args:argparse.Namespace, **kwargs:Dict[str, Any]) -> bool: Return: False if the CSE couldn't initialized and started. """ - global action, announce, console, dispatcher, event, groupResource, httpServer, importer, mqttClient, notification, registration + global action, announce, console, dispatcher, event, groupResource, httpServer, importer, location, mqttClient, notification, registration global remote, request, script, security, semantic, statistics, storage, textUI, time, timeSeries, validator global aeStatistics global supportedReleaseVersions, cseType, defaultSerialization, cseCsi, cseCsiSlash, cseCsiSlashLess, cseAbsoluteSlash @@ -274,6 +278,7 @@ def startup(args:argparse.Namespace, **kwargs:Dict[str, Any]) -> bool: remote = RemoteCSEManager() # Initialize the remote CSE manager announce = AnnouncementManager() # Initialize the announcement manager semantic = SemanticManager() # Initialize the semantic manager + location = LocationManager() # Initialize the location manager time = TimeManager() # Initialize the time mamanger script = ScriptManager() # Initialize the script manager action = ActionManager() # Initialize the action manager @@ -361,6 +366,7 @@ def _shutdown() -> None: textUI and textUI.shutdown() console and console.shutdown() time and time.shutdown() + location and location.shutdown() semantic and semantic.shutdown() remote and remote.shutdown() mqttClient and mqttClient.shutdown() diff --git a/acme/services/Dispatcher.py b/acme/services/Dispatcher.py index 971a151c..19ba36c8 100644 --- a/acme/services/Dispatcher.py +++ b/acme/services/Dispatcher.py @@ -719,12 +719,12 @@ def createResourceFromDict(self, dct:JSON, def createLocalResource(self, resource:Resource, - parentResource:Resource = None, + parentResource:Resource, originator:Optional[str] = None, request:Optional[CSERequest] = None) -> Resource: L.isDebug and L.logDebug(f'CREATING resource ri: {resource.ri}, type: {resource.ty}') - if parentResource: + if parentResource: # parentResource might be None if this is the root resource L.isDebug and L.logDebug(f'Parent ri: {parentResource.ri}') if not parentResource.canHaveChild(resource): if resource.ty == ResourceTypes.SUB: @@ -1079,8 +1079,17 @@ def deleteLocalResource(self, resource:Resource, def deleteResource(self, id:str, originator:Optional[str] = None) -> None: - # TODO doc + """ Delete a resource from the CSE. + + Args: + id: The resource ID to delete. + originator: The originator of the request. Defaults to None. + Raises: + OPERATION_NOT_ALLOWED: If the resource is a CSEBase resource. + NOT_FOUND: If the resource is not found. + ORIGINATOR_HAS_NO_PRIVILEGE: If the originator has no DELETE access to the resource. + """ # Update locally if (rID := localResourceID(id)) is not None: diff --git a/acme/services/LocationManager.py b/acme/services/LocationManager.py new file mode 100644 index 00000000..f4302a28 --- /dev/null +++ b/acme/services/LocationManager.py @@ -0,0 +1,336 @@ +# +# LocationManager.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# + +""" This module implements location service and helper functions. +""" + +from __future__ import annotations + +from typing import Tuple, Optional, Literal +from dataclasses import dataclass + +from ..helpers.BackgroundWorker import BackgroundWorkerPool, BackgroundWorker +from ..etc.Types import LocationInformationType, LocationSource, GeofenceEventCriteria, ResourceTypes +from ..etc.DateUtils import fromDuration +from ..etc.GeoUtils import getGeoPoint, getGeoPolygon, isLocationInsidePolygon +from ..services.Logging import Logging as L +from ..services import CSE +from ..resources.LCP import LCP +from ..resources.CIN import CIN +from ..resources import Factory + +GeofencePositionType = Literal[GeofenceEventCriteria.Inside, GeofenceEventCriteria.Outside] +""" Type alias for the geofence position.""" + +LocationType = Tuple[float, float] +""" Type alias for the location type.""" + +@dataclass +class LocationInformation(object): + """ Location information for a location policy. + """ + worker:BackgroundWorker = None + """ The worker for the location policy. """ + location:Optional[LocationType] = None + """ The current location. """ + targetArea:Optional[list[LocationType]] = None + """ The polygon. """ + geofencePosition:GeofencePositionType = GeofenceEventCriteria.Inside + """ The current position type (inside, outside). """ + eventCriteria:GeofenceEventCriteria = GeofenceEventCriteria.Inside + """ The event criteria. """ + locationContainerID:Optional[str] = None + """ The location container resource ID. """ + + +class LocationManager(object): + """ The LocationManager class implements the location service and helper functions. + + Attributes: + locationPolicyWorkers: A dictionary of location policy workers + """ + + __slots__ = ( + 'locationPolicyInfos', + 'deviceDefaultPosition' + ) + + + def __init__(self) -> None: + """ Initialization of the LocationManager module. + """ + + self.locationPolicyInfos:dict[str, LocationInformation] = {} + + self.deviceDefaultPosition:GeofencePositionType = GeofenceEventCriteria.Inside # Default event criteria + # Add a handler when the CSE is reset + CSE.event.addHandler(CSE.event.cseReset, self.restart) # type: ignore + L.isInfo and L.log('LocationManager initialized') + + +# TODO rebuild the list of location policies when the CSE is reset or started. OR create a DB + + def shutdown(self) -> bool: + """ Shutdown the LocationManager. + + Returns: + Boolean that indicates the success of the operation + """ + L.isInfo and L.log('LocationManager shut down') + return True + + + def restart(self, name:str) -> None: + """ Restart the LocationManager. + """ + L.isDebug and L.logDebug('LocationManager restarted') + + + ######################################################################### + + def addLocationPolicy(self, lcp:LCP) -> None: + """ Add a location policy. + + Args: + lcp: The location policy to add. + """ + L.isDebug and L.logDebug('Adding location policy') + lcpRi = lcp.ri + gta = getGeoPolygon(lcp.gta) + loi = lcp.loi + + # Remove first if already running + if lcpRi in self.locationPolicyInfos: + self.removeLocationPolicy(lcp) + + # Check whether the location source is device based (only one supported right now) + if lcp.los != LocationSource.Device_based: + L.isDebug and L.logDebug('Only device based location source supported') + return # Not supported + + # Add an empty entry first. + self.locationPolicyInfos[lcpRi] = LocationInformation(targetArea = gta, + geofencePosition = self.deviceDefaultPosition, + eventCriteria = lcp.gec, + locationContainerID = loi) + + # Check if the location information type / position is fixed + if (lit := lcp.lit) is None or lit == LocationInformationType.Position_fix: + L.isDebug and L.logDebug('Location information type not set or position fix. Ignored.') + return # No updates needed + + # Get the periodicity + if (lou := lcp.lou) is None or len(lou) == 0: # locationUpdatePeriodicity + L.isDebug and L.logDebug('Location update periodicity not set. Ignored.') + return # No updates needed. Checks are done when the location is requested via + if (_lou := fromDuration(lou[0], False)) == 0.0: # just take the first duration + L.isDebug and L.logDebug('Location update periodicity is 0. Ignored.') + return + + # Create a worker + L.isDebug and L.logDebug(f'Starting location policy worker for: {lcpRi} Intervall: {_lou}') + self.locationPolicyInfos[lcpRi] = LocationInformation(worker = BackgroundWorkerPool.newWorker(interval = _lou, + workerCallback = self.locationWorker, + name = f'lcp_{lcp.ri}', + startWithDelay = True).start(lcpRi = lcpRi), + targetArea = gta, + geofencePosition = self.deviceDefaultPosition, + eventCriteria = lcp.gec, + locationContainerID = loi + ) + # # Immediately update the location + # self.getNewLocation(lcpRi) + + + + def removeLocationPolicy(self, lcp:LCP) -> None: + """ Remove a location policy. This will stop the worker and remove the LCP from the internal list. + + Args: + lcp: The LCP to remove. + """ + L.isDebug and L.logDebug('Removing location policy') + + # Stopping the worker and remove the LCP from the internal list + if (ri := lcp.ri) in self.locationPolicyInfos: + L.isDebug and L.logDebug('Stopping location policy worker') + if (worker := self.locationPolicyInfos[ri].worker) is not None: + worker.stop() + del self.locationPolicyInfos[ri] + + + def updateLocationPolicy(self, lcp:LCP) -> None: + """ Update a location policy. This will remove the old location policy and add a new one. + """ + L.isDebug and L.logDebug('Updating location policy') + self.removeLocationPolicy(lcp) + self.addLocationPolicy(lcp) + + + def handleLatestRetrieve(self, latest:CIN, lcpRi:str) -> None: + """ Handle a latest RETRIEVE request for a CNT with a location policy. + + Args: + latest: The latest CIN + lcpRi: The location policy resource ID + """ + if lcpRi is None: + return + + # Check if the location policy is supported + if (lcp := CSE.dispatcher.retrieveResource(lcpRi)) is not None: + if lcp.los == LocationSource.Network_based and lcp.lou is not None and lcp.lou == 0: + L.isDebug and L.logDebug(f'Handling latest RETRIEVE for CNT with locationID: {lcpRi}') + # Handle Network based location source + # NOT SUPPORTED YET + L.isWarn and L.logWarn('Network-based location source not supported yet') + + if (lit := lcp.lit) is None or lit == LocationInformationType.Position_fix: + L.isDebug and L.logDebug('Location information type not set or position fix. Ignored.') + return # No updates needed + + + if (locations := self.getNewLocation(lcpRi, content = latest.con)) is None: + return + + # check if the location is inside the polygon and update the location event + self.updateLocationEvent(locations[0], locations[1], lcpRi) + + # TODO do something with the result + + + def locationWorker(self, lcpRi:str) -> bool: + """ Worker function for location policies. This will be called periodically to update the location. + + Args: + lcpRi: The resource ID of the location policy + + Returns: + True if the worker should be continued, False otherwise. + """ + + if (locations := self.getNewLocation(lcpRi)) is None: + return True # something went wrong, but still continue + + self.updateLocationEvent(locations[0], locations[1], lcpRi) + + return True + + + + + ######################################################################### + + + + def getNewLocation(self, lcpRi:str, content:Optional[str] = None) -> Optional[Tuple[LocationType, LocationType]]: + """ Get the new location for a location policy. Also, update the internal policy info if necessary. + + Args: + lcpRi: The resource ID of the location policy + cntRi: The resource ID of the location policy's container resource + content: The content of the latest CIN of the location policy's container resource + + Returns: + The new and old locations as a tuple of (latitude, longitude), or None if the location is invalid or not found + """ + + # Get the location policy info + if (info := self.locationPolicyInfos.get(lcpRi)) is None: + L.isWarn and L.logWarn(f'Internal location policy info for: {lcpRi} not found') + return None + + # Get the content if not provided + if not content: + # Get the location from a location instance + if not (cin := CSE.dispatcher.retrieveLatestOldestInstance(info.locationContainerID, ResourceTypes.CIN)): + return None # No resource found, still continue + content = cin.con + + # Check whether the content is a valid location or an event + if content in ('', '1', '2', '3', '4'): # This could be done better... + return None # An event, so return + + # From here on, content is a location + if (newLocation := getGeoPoint(content)) is None: + L.isWarn and L.logWarn(f'Invalid location: {content}. Must be a valid GeoPoint') + + # Check if the location has changed, or there was no location before + oldLocation = info.location + if oldLocation != newLocation: + # Update the location in the location policy + self.locationPolicyInfos[lcpRi].location = newLocation + + return (newLocation, oldLocation) + + + def updateLocationEvent(self, newLocation:LocationType, oldLocation:LocationType, lcpRi:str) -> None: + """ Update the location event for a location policy if the location has changed and/or the event criteria is met. + + Args: + newLocation: The new location + oldLocation: The old location + lcpRi: The resource ID of the location policy + """ + + def addEventContentInstance(info:LocationInformation, eventType:GeofenceEventCriteria) -> None: + """ Add a new event content instance to the location policy's container resource. + + Args: + info: The location policy info + eventType: The type of the event + """ + L.isDebug and L.logDebug(f'Position: {eventType}') + cnt = CSE.dispatcher.retrieveResource(info.locationContainerID) + cin = Factory.resourceFromDict({ 'con': f'{eventType.value}' }, + pi = info.locationContainerID, + ty = ResourceTypes.CIN) + CSE.dispatcher.createLocalResource(cin, cnt) + + + if (info := self.locationPolicyInfos.get(lcpRi)) is None: + L.isWarn and L.logWarn(f'Internal location policy info for: {lcpRi} not found') + return + previousGeofencePosition = info.geofencePosition + currentGeofencePosition = self.checkGeofence(lcpRi, newLocation) + + match currentGeofencePosition: + case GeofenceEventCriteria.Inside if previousGeofencePosition == GeofenceEventCriteria.Outside and info.eventCriteria == GeofenceEventCriteria.Entering: + # Entering + addEventContentInstance(info, GeofenceEventCriteria.Entering) + case GeofenceEventCriteria.Outside if previousGeofencePosition == GeofenceEventCriteria.Inside and info.eventCriteria == GeofenceEventCriteria.Leaving: + # Leaving + addEventContentInstance(info, GeofenceEventCriteria.Leaving) + case GeofenceEventCriteria.Inside if previousGeofencePosition == GeofenceEventCriteria.Inside and info.eventCriteria == GeofenceEventCriteria.Inside: + # Inside + addEventContentInstance(info, GeofenceEventCriteria.Inside) + case GeofenceEventCriteria.Outside if previousGeofencePosition == GeofenceEventCriteria.Outside and info.eventCriteria == GeofenceEventCriteria.Outside: + # Outside + addEventContentInstance(info, GeofenceEventCriteria.Outside) + case _: + # No event + L.isDebug and L.logDebug(f'No event for: {previousGeofencePosition} -> {currentGeofencePosition} and event criteria: {GeofenceEventCriteria(info.eventCriteria)}') + + # update the geofence position + info.geofencePosition = currentGeofencePosition + info.location = newLocation + + + def checkGeofence(self, lcpRi:str, location:tuple[float, float]) -> GeofencePositionType: + """ Check if a location is inside or outside the polygon of a location policy. + + Args: + lcpRi: The resource ID of the location policy + location: The location to check + + Returns: + The geofence position of the location. Either *inside* or *outside*. + """ + result = GeofenceEventCriteria.Inside if isLocationInsidePolygon(self.locationPolicyInfos[lcpRi].targetArea, location) else GeofenceEventCriteria.Outside + # L.isDebug and L.logDebug(f'Location is: {result}') + return result # type:ignore [return-value] + diff --git a/acme/services/Validator.py b/acme/services/Validator.py index 695488e3..92380cfc 100644 --- a/acme/services/Validator.py +++ b/acme/services/Validator.py @@ -740,7 +740,7 @@ def _validateType(self, dataType:BasicType, try: isodate.parse_duration(value) except Exception as e: - raise BAD_REQUEST(f'must be an ISO duration: {str(e)}') + raise BAD_REQUEST(f'must be an ISO duration (e.g. "PT2S"): {str(e)}') return (dataType, value) case BasicType.base64: diff --git a/tests/init.py b/tests/init.py index 028c2e20..c1cf703b 100755 --- a/tests/init.py +++ b/tests/init.py @@ -222,8 +222,9 @@ def isRaspberrypi() -> bool: crsRN = 'testCRS' csrRN = 'testCSR' deprRN = 'testDEPR' -grpRN = 'testGRP' fcntRN = 'testFCNT' +grpRN = 'testGRP' +lcpRN = 'testLCP' nodRN = 'testNOD' pchRN = 'testPCH' reqRN = 'testREQ' @@ -247,6 +248,7 @@ def isRaspberrypi() -> bool: csrURL = f'{cseURL}/{csrRN}' fcntURL = f'{aeURL}/{fcntRN}' grpURL = f'{aeURL}/{grpRN}' +lcpURL = f'{aeURL}/{lcpRN}' # under the nodURL = f'{cseURL}/{nodRN}' # under the pchURL = f'{aeURL}/{pchRN}' pcuURL = f'{pchURL}/pcu' diff --git a/tests/testLCP.py b/tests/testLCP.py new file mode 100644 index 00000000..9cbcb423 --- /dev/null +++ b/tests/testLCP.py @@ -0,0 +1,383 @@ +# +# testLOC.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# Unit tests for LocationPolicy functionality +# + +import unittest, sys +if '..' not in sys.path: + sys.path.append('..') +from typing import Tuple +from acme.etc.Types import ResourceTypes as T, ResponseStatusCode as RC, TimeWindowType +from acme.etc.Types import NotificationEventType, NotificationEventType as NET +from init import * + + +pointInside = { + 'type' : 'Point', + 'coordinates' : [ 52.520817, 13.409446 ] +} + +pointInsideStr = json.dumps(pointInside) + +pointOutside = { + 'type' : 'Point', + 'coordinates' : [ 52.505033, 13.278189 ] +} +pointOutsideStr = json.dumps(pointOutside) + +targetPoligon = { + 'type' : 'Polygon', + 'coordinates' : [ + [ [52.522423, 13.409468], [52.520634, 13.412107], [52.518362, 13.407172], [52.520086, 13.404897] ] + ] +} +targetPoligonStr = json.dumps(targetPoligon) + +# TODO wrong poligon, wrong point + + +class TestLCP(unittest.TestCase): + + ae = None + aeRI = None + ae2 = None + nod = None + nodRI = None + crs = None + crsRI = None + + + originator = None + + @classmethod + @unittest.skipIf(noCSE, 'No CSEBase') + def setUpClass(cls) -> None: + testCaseStart('Setup TestLCP') + + # Start notification server + startNotificationServer() + + dct = { 'm2m:ae' : { + 'rn' : aeRN, + 'api' : APPID, + 'rr' : True, + 'srv' : [ RELEASEVERSION ] + }} + cls.ae, rsc = CREATE(cseURL, 'C', T.AE, dct) # AE to work under + assert rsc == RC.CREATED, 'cannot create parent AE' + cls.originator = findXPath(cls.ae, 'm2m:ae/aei') + cls.aeRI = findXPath(cls.ae, 'm2m:ae/ri') + + testCaseEnd('Setup TestLCP') + + + @classmethod + @unittest.skipIf(noCSE, 'No CSEBase') + def tearDownClass(cls) -> None: + if not isTearDownEnabled(): + stopNotificationServer() + return + testCaseStart('TearDown TestLCP') + DELETE(aeURL, ORIGINATOR) # Just delete the AE and everything below it. Ignore whether it exists or not + testCaseEnd('TearDown TestLCP') + + + def setUp(self) -> None: + testCaseStart(self._testMethodName) + + + def tearDown(self) -> None: + testCaseEnd(self._testMethodName) + + ######################################################################### + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPMissingLosFail(self) -> None: + """ CREATE invalid with missing los -> Fail""" + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'lou': [ 'PT5S' ], + 'lon': 'myLocationContainer' + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createMinimalLCP(self) -> None: + """ CREATE minimal with missing lou""" + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.CREATED, r) + self.assertIsNotNone(findXPath(r, 'm2m:lcp/lost')) + self.assertEqual(findXPath(r, 'm2m:lcp/lost'), '') + + _, rsc = DELETE(lcpURL, self.originator) + self.assertEqual(rsc, RC.DELETED) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithSameCNTRnFail(self) -> None: + """ CREATE with assigned container RN as self -> Fail """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'lon': lcpRN + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithLOS2LotFail(self) -> None: + """ CREATE with los=2 (device based) and set lot -> Fail """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'lot': '1234' # locationTargetID + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithLOS2AidFail(self) -> None: + """ CREATE with los=2 (device based) and set aid -> Fail """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'aid': '1234' # authID + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithLOS2LorFail(self) -> None: + """ CREATE with los=2 (device based) and set lor -> Fail """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'lor': '1234' # locationServer + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithLOS2RlklFail(self) -> None: + """ CREATE with los=2 (device based) and set rlkl -> Fail """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'rlkl': True # retrieveLastKnownLocation + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithLOS2LuecFail(self) -> None: + """ CREATE with los=2 (device based) and set luec -> Fail """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'luec': 0 # locationUpdateEventCriteria + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithWrongGtaFail(self) -> None: + """ CREATE with wrong gta -> Fail""" + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'gta': 'wrong' # geoTargetArea + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithGta(self) -> None: + """ CREATE with gta """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'gta': targetPoligonStr # geoTargetArea + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.CREATED, r) + self.assertIsNotNone(findXPath(r, 'm2m:lcp/gta')) + + _, rsc = DELETE(lcpURL, self.originator) + self.assertEqual(rsc, RC.DELETED) + + + # + # Periodic tests + # + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithLit2Lou0(self) -> None: + """ CREATE with lit = 2, lou = 0s""" + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'lit': 2, # locationInformationType = 2 (geo-fence) + 'lou': [ 'PT0S' ] # locationUpdatePeriod = 0s, + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.CREATED, r) + self.assertIsNotNone(findXPath(r, 'm2m:lcp/lost')) + self.assertEqual(findXPath(r, 'm2m:lcp/lost'), '') + + _, rsc = DELETE(lcpURL, self.originator) + self.assertEqual(rsc, RC.DELETED) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_testPeriodicUpdates(self) -> None: + """ CREATE with lit = 2, lou = 1s""" + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'lit': 2, # locationInformationType = 2 (geo-fence) + 'lou': [ 'PT1S' ], # locationUpdatePeriod = 1s + 'lon': cntRN, # containerName + 'gta': targetPoligonStr,# geoTargetArea + 'gec': 2 # geoEventCategory = 2 (leaving). Assuming that the initial location is inside the target area + + + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.CREATED, r) + self.assertIsNotNone(findXPath(r, 'm2m:lcp/lost')) + self.assertEqual(findXPath(r, 'm2m:lcp/lost'), '') + self.assertIsNotNone(findXPath(r, 'm2m:lcp/loi'), '') + + # Add a location ContentInstance + dct = { 'm2m:cin': { + 'con': pointOutsideStr + }} + r, rsc = CREATE(f'{aeURL}/{cntRN}', self.originator, T.CIN, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Just wait a moment + testSleep(2) + + # Retrieve the latest location ContentInstance to check the event + r, rsc = RETRIEVE(f'{aeURL}/{cntRN}/la', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:cin/con')) + self.assertEqual(findXPath(r, 'm2m:cin/con'), '2', r) # leaving + + + _, rsc = DELETE(lcpURL, self.originator) + self.assertEqual(rsc, RC.DELETED) + + +# TODO add test: move from inside to inside -> no notification +# TODO add test: move from inside to outside -> notification +# TODO add test: move from outside to inside -> notification +# TODO add test: move from outside to outside -> no notification + +# TODO test with invalid location format + + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_testManualUpdates(self) -> None: + """ CREATE with lit = 2, lou = None """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'lit': 2, # locationInformationType = 2 (geo-fence) + 'lon': cntRN, # containerName + 'gta': targetPoligonStr,# geoTargetArea + 'gec': 2 # geoEventCategory = 2 (leaving). Assuming that the initial location is inside the target area + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.CREATED, r) + self.assertIsNotNone(findXPath(r, 'm2m:lcp/lost')) + self.assertEqual(findXPath(r, 'm2m:lcp/lost'), '') + self.assertIsNotNone(findXPath(r, 'm2m:lcp/loi'), '') + + # Add a location ContentInstance + dct = { 'm2m:cin': { + 'con': pointOutsideStr + }} + r, rsc = CREATE(f'{aeURL}/{cntRN}', self.originator, T.CIN, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Retrieve + r, rsc = RETRIEVE(f'{aeURL}/{cntRN}/la', self.originator) + self.assertEqual(rsc, RC.OK, r) + latest = findXPath(r, 'm2m:cin/con') + print(latest) + + + # Just wait a moment + testSleep(2) + + # TODO receive result? + + _, rsc = DELETE(lcpURL, self.originator) + self.assertEqual(rsc, RC.DELETED) + + + + ######################################################################### + + + +def run(testFailFast:bool) -> Tuple[int, int, int, float]: + suite = unittest.TestSuite() + + # basic tests + addTest(suite, TestLCP('test_createLCPMissingLosFail')) + addTest(suite, TestLCP('test_createMinimalLCP')) + addTest(suite, TestLCP('test_createLCPWithSameCNTRnFail')) + addTest(suite, TestLCP('test_createLCPWithLOS2LotFail')) + addTest(suite, TestLCP('test_createLCPWithLOS2AidFail')) + addTest(suite, TestLCP('test_createLCPWithLOS2LorFail')) + addTest(suite, TestLCP('test_createLCPWithLOS2RlklFail')) + addTest(suite, TestLCP('test_createLCPWithLOS2LuecFail')) + addTest(suite, TestLCP('test_createLCPWithWrongGtaFail')) + addTest(suite, TestLCP('test_createLCPWithGta')) + + # periodic tests + addTest(suite, TestLCP('test_createLCPWithLit2Lou0')) + addTest(suite, TestLCP('test_testPeriodicUpdates')) + addTest(suite, TestLCP('test_testManualUpdates')) + + + result = unittest.TextTestRunner(verbosity = testVerbosity, failfast = testFailFast).run(suite) + return result.testsRun, len(result.errors + result.failures), len(result.skipped), getSleepTimeCount() + + +if __name__ == '__main__': + r, errors, s, t = run(True) + sys.exit(errors) \ No newline at end of file From d5ee6a7977d5861c6ec688409b5651e52cb2f839 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 4 Aug 2023 14:57:47 +0200 Subject: [PATCH 034/165] Fixed potential crash when determining the size of a dict --- acme/etc/Utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/etc/Utils.py b/acme/etc/Utils.py index 3e6ca5fc..16c18e9e 100644 --- a/acme/etc/Utils.py +++ b/acme/etc/Utils.py @@ -781,7 +781,7 @@ def getAttributeSize(attribute:Any) -> int: for e in attribute: size += getAttributeSize(e) case dict(): # recurse a dictionary - for _,v in attribute: + for _,v in attribute.items(): size += getAttributeSize(v) case _: # fallback for not handled types size = sys.getsizeof(attribute) From e1419321f85dbebdfcd52a95a5a4a6c0e5424662 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sat, 24 Jun 2023 22:25:33 +0200 Subject: [PATCH 035/165] Started 0.13.0 development --- CHANGELOG.md | 13 +++++++++++++ README.md | 2 +- acme/etc/Constants.py | 2 +- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea544724..93602a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [unreleased] - xxxx-xx-xx + +### Added + +### Experimental + +### Changed + +### Fixed + +### Removed + + ## [0.12.0] - 2023-06-24 ### Added diff --git a/README.md b/README.md index bbbc5057..6b587a09 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # ACME oneM2M CSE An open source CSE Middleware for Education. -Version 0.12.0 +Version 0.13.0-dev [![oneM2M](https://img.shields.io/badge/oneM2M-f00)](https://www.onem2m.org) [![Python](https://img.shields.io/badge/Python-3.8-blue)](https://www.python.org) [![Maintenance](https://img.shields.io/badge/Maintained-Yes-green.svg)](https://github.com/ankraft/ACME-oneM2M-CSE/graphs/commit-activity) [![License](https://img.shields.io/badge/License-BSD%203--Clause-green)](LICENSE) [![MyPy](https://img.shields.io/badge/MyPy-covered-green)](LICENSE) [![Mastodon](https://img.shields.io/badge/-@acmeCSE@mstdn.social-FFF?label=mastodon&logo=mastodon&style=social)](https://mstdn.social/@acmeCSE) diff --git a/acme/etc/Constants.py b/acme/etc/Constants.py index 16f6494d..24b63089 100644 --- a/acme/etc/Constants.py +++ b/acme/etc/Constants.py @@ -11,7 +11,7 @@ class Constants(object): """ Various CSE and oneM2M constants """ - version = '0.12.0' + version = '0.13.0-dev' """ ACME's release version """ logoColor = '#b42025' From 23258396cd6da07c4b07460af59e4960d861b3ce Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 29 Jun 2023 22:51:13 +0200 Subject: [PATCH 036/165] Improved attribute comment layout when two lines --- acme/helpers/TextTools.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/acme/helpers/TextTools.py b/acme/helpers/TextTools.py index c78d25a1..ccc8d78d 100644 --- a/acme/helpers/TextTools.py +++ b/acme/helpers/TextTools.py @@ -82,7 +82,7 @@ def commentJson(data:Union[str, dict], maxLength:int = 0 for line in data.splitlines(): # Find the key - if len(_sp := line.strip().split(':')) == 1: + if len(_sp := re.split(r':(?=\ )', line.strip())) == 1: if key: previousKey = key key = '' @@ -120,6 +120,7 @@ def commentJson(data:Union[str, dict], result.append(line) else: if width is not None and maxLength > width: # Put comment above line + result.append('') result.append(f'{" " * (len(line) - len(line.lstrip()))}{comment}') result.append(line) else: From b255217ba8ac3cc6a420d14db68028989090e78f Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 2 Jul 2023 14:30:42 +0200 Subject: [PATCH 037/165] Added first part of resource type support --- acme/etc/Constants.py | 6 +- acme/etc/Types.py | 6 ++ acme/resources/ACTR.py | 9 +- acme/resources/CRS.py | 2 +- acme/resources/CSEBase.py | 1 + acme/resources/CSEBaseAnnc.py | 1 + acme/resources/CSRAnnc.py | 13 +-- acme/resources/Factory.py | 6 +- acme/resources/NOD.py | 1 + acme/resources/NODAnnc.py | 5 +- acme/resources/SCH.py | 111 ++++++++++++++++++++ acme/resources/SCHAnnc.py | 57 ++++++++++ acme/resources/SUB.py | 3 +- acme/services/Console.py | 3 +- tests/init.py | 1 + tests/testSCH.py | 191 ++++++++++++++++++++++++++++++++++ 16 files changed, 398 insertions(+), 18 deletions(-) create mode 100644 acme/resources/SCH.py create mode 100644 acme/resources/SCHAnnc.py create mode 100644 tests/testSCH.py diff --git a/acme/etc/Constants.py b/acme/etc/Constants.py index 24b63089..187e61fe 100644 --- a/acme/etc/Constants.py +++ b/acme/etc/Constants.py @@ -134,4 +134,8 @@ class Constants(object): """ Maximum length of identifiers generated by the CSE """ - + # + # Network Coordination supported + # + networkCoordinationSupported = False + """ Network coordination supported by the CSE """ diff --git a/acme/etc/Types.py b/acme/etc/Types.py index 07eef7b5..d133f393 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -66,6 +66,8 @@ class ResourceTypes(ACMEIntEnum): """ Remote CSE resource type. """ REQ = 17 """ Request resource type. """ + SCH = 18 + """ Schedule resource type. """ SUB = 23 """ Subscription resource type. """ SMD = 24 @@ -158,6 +160,8 @@ class ResourceTypes(ACMEIntEnum): """ Announced Node resource type. """ CSRAnnc = 10016 """ Announced Remote CSE resource type. """ + SCHAnnc = 10018 + """ Announced Schedule resource type. """ SMDAnnc = 10024 """ Announced SemanticDescriptor resouce type. """ FCNTAnnc = 10028 @@ -429,6 +433,8 @@ class ResourceDescription(): ResourceTypes.PCH : ResourceDescription(typeName = 'm2m:pch', fullName='PollingChannel'), ResourceTypes.PCH_PCU : ResourceDescription(typeName = 'm2m:pcu', virtualResourceName = 'pcu', fullName='PollingChannel URI'), ResourceTypes.REQ : ResourceDescription(typeName = 'm2m:req', isRequestCreatable = False, fullName='Request'), + ResourceTypes.SCH : ResourceDescription(typeName = 'm2m:sch', announcedType = ResourceTypes.SCHAnnc, fullName='Schedule'), + ResourceTypes.SCHAnnc : ResourceDescription(typeName = 'm2m:schA', isAnnouncedResource = True, fullName='Schedule Announced'), ResourceTypes.SMD : ResourceDescription(typeName = 'm2m:smd', announcedType = ResourceTypes.SMDAnnc, fullName='SemanticDescriptor'), ResourceTypes.SMDAnnc : ResourceDescription(typeName = 'm2m:smdA', isAnnouncedResource = True, fullName='SemanticDescriptor Announced'), ResourceTypes.SUB : ResourceDescription(typeName = 'm2m:sub', fullName='Subscription'), diff --git a/acme/resources/ACTR.py b/acme/resources/ACTR.py index ca7ef272..b27c466f 100644 --- a/acme/resources/ACTR.py +++ b/acme/resources/ACTR.py @@ -7,13 +7,12 @@ # ResourceType: Action # -""" Action (ACTRA) resource type. """ +""" Action (ACTR) resource type. """ from __future__ import annotations -from typing import Optional, Tuple, Any, cast +from typing import Optional, Tuple -from ..etc.Types import AttributePolicyDict, EvalMode, ResourceTypes, Result, JSON, Permission, EvalCriteriaOperator -from ..etc.Types import BasicType +from ..etc.Types import AttributePolicyDict, EvalMode, ResourceTypes, JSON, Permission, EvalCriteriaOperator from ..etc.ResponseStatusCodes import ResponseException, BAD_REQUEST from ..etc.Utils import riFromID from ..helpers.TextTools import findXPath @@ -24,7 +23,7 @@ class ACTR(AnnounceableResource): - """ Action (ACTRA) resource type. """ + """ Action (ACTR) resource type. """ # Specify the allowed child-resource types _allowedChildResourceTypes:list[ResourceTypes] = [ ResourceTypes.DEPR, diff --git a/acme/resources/CRS.py b/acme/resources/CRS.py index 42b40540..c90394c6 100644 --- a/acme/resources/CRS.py +++ b/acme/resources/CRS.py @@ -33,7 +33,7 @@ class CRS(Resource): _sudRI = '__sudRI__' # Reference when the resource is been deleted because of the deletion of a rrat or srat subscription. Usually empty # Specify the allowed child-resource types - _allowedChildResourceTypes:list[ResourceTypes] = [ ] + _allowedChildResourceTypes:list[ResourceTypes] = [ ResourceTypes.SCH ] # Attributes and Attribute policies for this Resource Class # Assigned during startup in the Importer diff --git a/acme/resources/CSEBase.py b/acme/resources/CSEBase.py index acd83615..353e72a3 100644 --- a/acme/resources/CSEBase.py +++ b/acme/resources/CSEBase.py @@ -34,6 +34,7 @@ class CSEBase(AnnounceableResource): ResourceTypes.GRP, ResourceTypes.NOD, ResourceTypes.REQ, + ResourceTypes.SCH, ResourceTypes.SUB, ResourceTypes.TS, ResourceTypes.TSB, diff --git a/acme/resources/CSEBaseAnnc.py b/acme/resources/CSEBaseAnnc.py index 13d27a08..f57d51b6 100644 --- a/acme/resources/CSEBaseAnnc.py +++ b/acme/resources/CSEBaseAnnc.py @@ -24,6 +24,7 @@ class CSEBaseAnnc(AnnouncedResource): ResourceTypes.FCNTAnnc, ResourceTypes.GRPAnnc, ResourceTypes.NODAnnc, + ResourceTypes.SCHAnnc, ResourceTypes.SUB, ResourceTypes.TSAnnc, ResourceTypes.TSBAnnc ] diff --git a/acme/resources/CSRAnnc.py b/acme/resources/CSRAnnc.py index 2699b7da..cd6a83e6 100644 --- a/acme/resources/CSRAnnc.py +++ b/acme/resources/CSRAnnc.py @@ -20,22 +20,23 @@ class CSRAnnc(AnnouncedResource): # Specify the allowed child-resource types _allowedChildResourceTypes = [ ResourceTypes.ACTR, ResourceTypes.ACTRAnnc, + ResourceTypes.ACP, + ResourceTypes.ACPAnnc, + ResourceTypes.AEAnnc, ResourceTypes.CNT, ResourceTypes.CNTAnnc, ResourceTypes.CINAnnc, + ResourceTypes.CSRAnnc, ResourceTypes.FCNT, ResourceTypes.FCNTAnnc, ResourceTypes.GRP, ResourceTypes.GRPAnnc, - ResourceTypes.ACP, - ResourceTypes.ACPAnnc, + ResourceTypes.MGMTOBJAnnc, + ResourceTypes.NODAnnc, + ResourceTypes.SCHAnnc, ResourceTypes.SUB, ResourceTypes.TS, ResourceTypes.TSAnnc, - ResourceTypes.CSRAnnc, - ResourceTypes.MGMTOBJAnnc, - ResourceTypes.NODAnnc, - ResourceTypes.AEAnnc, ResourceTypes.TSB, ResourceTypes.TSBAnnc ] diff --git a/acme/resources/Factory.py b/acme/resources/Factory.py index caa7ebf7..02125d8d 100644 --- a/acme/resources/Factory.py +++ b/acme/resources/Factory.py @@ -15,7 +15,7 @@ from ..etc.Types import ResourceTypes, addResourceFactoryCallback, FactoryCallableT from ..etc.ResponseStatusCodes import BAD_REQUEST -from ..etc.Types import Result, JSON +from ..etc.Types import JSON from ..etc.Utils import pureResource from ..etc.Constants import Constants from ..services.Logging import Logging as L @@ -57,6 +57,8 @@ from ..resources.SUB import SUB from ..resources.SMD import SMD from ..resources.SMDAnnc import SMDAnnc +from ..resources.SCH import SCH +from ..resources.SCHAnnc import SCHAnnc from ..resources.TS import TS from ..resources.TSAnnc import TSAnnc from ..resources.TS_LA import TS_LA @@ -129,6 +131,8 @@ addResourceFactoryCallback(ResourceTypes.PCH, PCH, lambda dct, tpe, pi, create : PCH(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.PCH_PCU, PCH_PCU, lambda dct, tpe, pi, create : PCH_PCU(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.REQ, REQ, lambda dct, tpe, pi, create : REQ(dct, pi = pi, create = create)) +addResourceFactoryCallback(ResourceTypes.SCH, SCH, lambda dct, tpe, pi, create : SCH(dct, pi = pi, create = create)) +addResourceFactoryCallback(ResourceTypes.SCHAnnc, SCHAnnc, lambda dct, tpe, pi, create : SCHAnnc(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.SMD, SMD, lambda dct, tpe, pi, create : SMD(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.SMDAnnc, SMDAnnc, lambda dct, tpe, pi, create : SMDAnnc(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.SUB, SUB, lambda dct, tpe, pi, create : SUB(dct, pi = pi, create = create)) diff --git a/acme/resources/NOD.py b/acme/resources/NOD.py index 8c0d889e..afbbd7c7 100644 --- a/acme/resources/NOD.py +++ b/acme/resources/NOD.py @@ -25,6 +25,7 @@ class NOD(AnnounceableResource): # Specify the allowed child-resource types _allowedChildResourceTypes = [ ResourceTypes.ACTR, ResourceTypes.MGMTOBJ, + ResourceTypes.SCH, ResourceTypes.SMD, ResourceTypes.SUB ] diff --git a/acme/resources/NODAnnc.py b/acme/resources/NODAnnc.py index c8e30c6a..110ff123 100644 --- a/acme/resources/NODAnnc.py +++ b/acme/resources/NODAnnc.py @@ -1,10 +1,10 @@ # -# GRPAnnc.py +# NODAnnc.py # # (c) 2020 by Andreas Kraft # License: BSD 3-Clause License. See the LICENSE file for further details. # -# GRP : Announceable variant +# NODAnnc : Announceable variant # from __future__ import annotations @@ -20,6 +20,7 @@ class NODAnnc(AnnouncedResource): _allowedChildResourceTypes = [ ResourceTypes.ACTR, ResourceTypes.ACTRAnnc, ResourceTypes.MGMTOBJAnnc, + ResourceTypes.SCHAnnc, ResourceTypes.SUB ] # Attributes and Attribute policies for this Resource Class diff --git a/acme/resources/SCH.py b/acme/resources/SCH.py new file mode 100644 index 00000000..888d8d56 --- /dev/null +++ b/acme/resources/SCH.py @@ -0,0 +1,111 @@ + # +# SCH.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# ResourceType: Schedule +# + +""" Schedule (SCH) resource type. """ + +from __future__ import annotations +from typing import Optional + +from ..etc.Constants import Constants as C +from ..etc.Types import AttributePolicyDict, ResourceTypes, JSON +from ..services.Logging import Logging as L +from ..resources.Resource import Resource +from ..etc.ResponseStatusCodes import CONTENTS_UNACCEPTABLE, NOT_IMPLEMENTED +from ..resources.AnnounceableResource import AnnounceableResource + + +class SCH(AnnounceableResource): + """ Schedule (SCH) resource type. """ + + # Specify the allowed child-resource types + _allowedChildResourceTypes:list[ResourceTypes] = [ ResourceTypes.SUB + ] + """ The allowed child-resource types. """ + + # Attributes and Attribute policies for this Resource Class + # Assigned during startup in the Importer + _attributes:AttributePolicyDict = { + # Common and universal attributes + 'rn': None, + 'ty': None, + 'ri': None, + 'pi': None, + 'ct': None, + 'lt': None, + 'lbl': None, + 'acpi':None, + 'et': None, + 'daci': None, + 'cstn': None, + 'at': None, + 'aa': None, + 'ast': None, + + # Resource attributes + 'se': None, + 'nco': None, + } + """ Attributes and `AttributePolicy` for this resource type. """ + + + def __init__(self, dct:Optional[JSON] = None, pi:Optional[str] = None, create:Optional[bool] = False) -> None: + super().__init__(ResourceTypes.SCH, dct, pi, create = create) + + + + def activate(self, parentResource:Resource, originator:str) -> None: + super().activate(parentResource, originator) + + # Check if the parent is not a resource then the "nco" attribute is not set + _nco = self.nco + if parentResource.ty != ResourceTypes.NOD: + if _nco is not None: + raise CONTENTS_UNACCEPTABLE (L.logWarn(f'"nco" must not be set for a SCH resource that is not a child of a resource')) + + + # If nco is set to true, NOT_IMPLEMENTED is returned + if _nco is not None and _nco == True and not C.networkCoordinationSupported: + raise NOT_IMPLEMENTED (L.logWarn(f'Network Coordinated Operation is not supported by this CSE')) + + # TODO When is supported + # c)The request shall be rejected with the "OPERATION_NOT_ALLOWED" Response Status Code if the target resource + # is a resource that has a campaignEnabled attribute with a value of true. + + + def update(self, dct: JSON = None, originator: str | None = None, doValidateAttributes: bool | None = True) -> None: + + _nco = self.getFinalResourceAttribute('nco', dct) + _parentResource = self.retrieveParentResource() + + # Check if the parent is not a resource then the "nco" attribute is not set + if _parentResource.ty != ResourceTypes.NOD: + if _nco is not None: + raise CONTENTS_UNACCEPTABLE (L.logWarn(f'"nco" must not be set for a SCH resource that is not a child of a resource')) + + # If nco is set to true, NOT_IMPLEMENTED is returned + if _nco is not None and _nco == True and not C.networkCoordinationSupported: + raise NOT_IMPLEMENTED (L.logWarn(f'Network Coordinated Operation is not supported by this CSE')) + + # TODO When is supported + # c)The request shall be rejected with the "OPERATION_NOT_ALLOWED" Response Status Code + # if thetarget resource is a resource that has a campaignEnabled attribute with a value of true. + + super().update(dct, originator, doValidateAttributes) + + + def deactivate(self, originator: str) -> None: + + # TODO When is supported + # a) The request shall be rejected with the "OPERATION_NOT_ALLOWED" Response Status Code + # if the target resource is a resource that has a campaignEnabled attribute with a value of true. + + super().deactivate(originator) + + +# TODO coninue \ No newline at end of file diff --git a/acme/resources/SCHAnnc.py b/acme/resources/SCHAnnc.py new file mode 100644 index 00000000..e1252259 --- /dev/null +++ b/acme/resources/SCHAnnc.py @@ -0,0 +1,57 @@ +# +# SCHAnnc.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# ResourceType: Schedule Announced +# + +""" Schedule Announced(SCHA) resource type. """ + +from __future__ import annotations +from typing import Optional + +from ..etc.Types import AttributePolicyDict, ResourceTypes, JSON +from ..services.Logging import Logging as L +from .AnnouncedResource import AnnouncedResource + + +class SCHAnnc(AnnouncedResource): + """ Schedule Announced (SCHA) resource type. """ + + # Specify the allowed child-resource types + _allowedChildResourceTypes:list[ResourceTypes] = [ ] + """ The allowed child-resource types. """ + + # Attributes and Attribute policies for this Resource Class + # Assigned during startup in the Importer + _attributes:AttributePolicyDict = { + # Common and universal attributes + 'rn': None, + 'ty': None, + 'ri': None, + 'pi': None, + 'ct': None, + 'lt': None, + 'et': None, + 'lbl': None, + 'acpi':None, + 'daci': None, + 'lnk': None, + 'ast': None, + + # Resource attributes + 'se': None, + 'nco': None, + } + """ Attributes and `AttributePolicy` for this resource type. """ + + + def __init__(self, dct:Optional[JSON] = None, + pi:Optional[str] = None, + create:Optional[bool] = False) -> None: + super().__init__(ResourceTypes.SCHAnnc, dct, pi = pi, create = create) + + +# TODO coninue \ No newline at end of file diff --git a/acme/resources/SUB.py b/acme/resources/SUB.py index 49caed50..22f47ef8 100644 --- a/acme/resources/SUB.py +++ b/acme/resources/SUB.py @@ -29,7 +29,8 @@ class SUB(Resource): # Specify the allowed child-resource types - _allowedChildResourceTypes:list[ResourceTypes] = [ ] + _allowedChildResourceTypes:list[ResourceTypes] = [ ResourceTypes.SCH + ] # Attributes and Attribute policies for this Resource Class # Assigned during startup in the Importer diff --git a/acme/services/Console.py b/acme/services/Console.py index 2fc0f897..8760a686 100644 --- a/acme/services/Console.py +++ b/acme/services/Console.py @@ -1264,6 +1264,7 @@ def _stats() -> Table: resourceTypes += f'NOD : {CSE.dispatcher.countResources(ResourceTypes.NOD)}\n' resourceTypes += f'PCH : {CSE.dispatcher.countResources(ResourceTypes.PCH)}\n' resourceTypes += f'REQ : {CSE.dispatcher.countResources(ResourceTypes.REQ)}\n' + resourceTypes += f'SCH : {CSE.dispatcher.countResources(ResourceTypes.SCH)}\n' resourceTypes += f'SMD : {CSE.dispatcher.countResources(ResourceTypes.SMD)}\n' resourceTypes += f'SUB : {CSE.dispatcher.countResources(ResourceTypes.SUB)}\n' resourceTypes += f'TS : {CSE.dispatcher.countResources(ResourceTypes.TS)}\n' @@ -1273,7 +1274,7 @@ def _stats() -> Table: resourceTypes += '\n' resourceTypes += _markup(f'[bold]Total[/bold] : {int(stats[Statistics.resourceCount]) - _virtualCount}\n') # substract the virtual resources # Correct height - resourceTypes += '\n' * (tableWorkers.row_count + 6) + resourceTypes += '\n' * (tableWorkers.row_count + 5) result = Table.grid(expand = True) diff --git a/tests/init.py b/tests/init.py index d7dacf1b..1c13bd2a 100755 --- a/tests/init.py +++ b/tests/init.py @@ -227,6 +227,7 @@ def isRaspberrypi() -> bool: nodRN = 'testNOD' pchRN = 'testPCH' reqRN = 'testREQ' +schRN = 'testSCH' smdRN = 'testSMD' subRN = 'testSUB' tsRN = 'testTS' diff --git a/tests/testSCH.py b/tests/testSCH.py new file mode 100644 index 00000000..0bf5e00a --- /dev/null +++ b/tests/testSCH.py @@ -0,0 +1,191 @@ + # +# testSCH.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# Unit tests for Schedule functionality +# + +import unittest, sys +if '..' not in sys.path: + sys.path.append('..') +from typing import Tuple +from acme.etc.Types import ResourceTypes as T, ResponseStatusCode as RC, Permission +from acme.etc.Types import EvalMode, Operation, EvalCriteriaOperator +from init import * + +nodeID = 'urn:sn:1234' + + +class TestSCH(unittest.TestCase): + + ae = None + aeRI = None + ae2 = None + nod = None + nodRI = None + + + originator = None + + @classmethod + @unittest.skipIf(noCSE, 'No CSEBase') + def setUpClass(cls) -> None: + testCaseStart('Setup TestSCH') + dct = { 'm2m:ae' : { + 'rn' : aeRN, + 'api' : APPID, + 'rr' : True, + 'srv' : [ RELEASEVERSION ] + }} + cls.ae, rsc = CREATE(cseURL, 'C', T.AE, dct) # AE to work under + assert rsc == RC.CREATED, 'cannot create parent AE' + cls.originator = findXPath(cls.ae, 'm2m:ae/aei') + cls.aeRI = findXPath(cls.ae, 'm2m:ae/ri') + + + dct = { 'm2m:nod' : { + 'rn' : nodRN, + 'ni' : nodeID + }} + cls.nod, rsc = CREATE(cseURL, ORIGINATOR, T.NOD, dct) + assert rsc == RC.CREATED + cls.nodRI = findXPath(cls.nod, 'm2m:nod/ri') + + testCaseEnd('Setup TestSCH') + + + @classmethod + @unittest.skipIf(noCSE, 'No CSEBase') + def tearDownClass(cls) -> None: + if not isTearDownEnabled(): + return + testCaseStart('TearDown TestSCH') + DELETE(aeURL, ORIGINATOR) # Just delete the AE and everything below it. Ignore whether it exists or not + DELETE(nodURL, ORIGINATOR) # Just delete the NOD and everything below it. Ignore whether it exists or not + DELETE(f'{cseURL}/{schRN}', ORIGINATOR) + testCaseEnd('TearDown TestSCH') + + + def setUp(self) -> None: + testCaseStart(self._testMethodName) + + + def tearDown(self) -> None: + testCaseEnd(self._testMethodName) + + + ######################################################################### + +# TODO validate schedule element format ***** + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderCBwithNOCFail(self) -> None: + """ CREATE invalid with "nco" under CSEBase -> Fail""" + self.assertIsNotNone(TestSCH.ae) + dct = { 'm2m:sch' : { + 'rn' : schRN, + 'se': { 'sce': [ '* * * * * * *' ] }, + 'nco': True + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CONTENTS_UNACCEPTABLE, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderNODwithNOCUnsupportedFail(self) -> None: + """ CREATE with nco under NOD (unsupported) -> Fail""" + self.assertIsNotNone(TestSCH.ae) + dct = { 'm2m:sch' : { + 'rn' : schRN, + 'se': { 'sce': [ '* * * * * * *' ] }, + 'nco': True + }} + r, rsc = CREATE(nodURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.NOT_IMPLEMENTED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderCBwithoutNCO(self) -> None: + """ CREATE without "nco" under CSEBase""" + self.assertIsNotNone(TestSCH.ae) + dct = { 'm2m:sch' : { + 'rn' : schRN, + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/{schRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_updateSCHunderCBwithNCOFail(self) -> None: + """ UPDATE without "nco" under CSEBase -> Fail""" + self.assertIsNotNone(TestSCH.ae) + dct:JSON = { 'm2m:sch' : { + 'rn' : schRN, + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # UPDATE with nco + dct = { 'm2m:sch' : { + 'nco': True + }} + r, rsc = UPDATE(f'{cseURL}/{schRN}', ORIGINATOR, dct) + self.assertEqual(rsc, RC.CONTENTS_UNACCEPTABLE, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/{schRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_updateSCHunderNODwithNOCUnsupportedFail(self) -> None: + """ CREATE with nco under NOD (unsupported-> Fail""" + self.assertIsNotNone(TestSCH.ae) + dct:JSON = { 'm2m:sch' : { + 'rn' : schRN, + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(nodURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # UPDATE with nco + dct = { 'm2m:sch' : { + 'nco': True + }} + r, rsc = UPDATE(f'{nodURL}/{schRN}', ORIGINATOR, dct) + self.assertEqual(rsc, RC.NOT_IMPLEMENTED, r) + + # DELETE again + r, rsc = DELETE(f'{nodURL}/{schRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + + +def run(testFailFast:bool) -> Tuple[int, int, int, float]: + suite = unittest.TestSuite() + + # basic tests + addTest(suite, TestSCH('test_createSCHunderCBwithNOCFail')) + addTest(suite, TestSCH('test_createSCHunderNODwithNOCUnsupportedFail')) + addTest(suite, TestSCH('test_createSCHunderCBwithoutNCO')) + addTest(suite, TestSCH('test_updateSCHunderCBwithNCOFail')) + addTest(suite, TestSCH('test_updateSCHunderNODwithNOCUnsupportedFail')) + + + result = unittest.TextTestRunner(verbosity = testVerbosity, failfast = testFailFast).run(suite) + printResult(result) + return result.testsRun, len(result.errors + result.failures), len(result.skipped), getSleepTimeCount() + + +if __name__ == '__main__': + r, errors, s, t = run(True) + sys.exit(errors) \ No newline at end of file From 5d742602fd59ebcbd370a2646d542776e2607971 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 2 Jul 2023 14:31:10 +0200 Subject: [PATCH 038/165] Attribute definitions for --- init/attributePolicies.ap | 80 ++++++++++++++++++++++++++++++++----- init/complexTypePolicies.ap | 5 ++- init/enumTypesPolicies.ep | 4 +- 3 files changed, 74 insertions(+), 15 deletions(-) diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index db430998..8b1a0513 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -1607,7 +1607,7 @@ "rtypes": [ "ACPAnnc", "ACTRAnnc", "AEAnnc", "ANDIAnnc", "ANIAnnc", "BATAnnc", "CINAnnc", "CNTAnnc", "CSEBaseAnnc", "CSRAnnc", "DATCAnnc", "DEPRAnnc", "DVCAnnc", "DVIAnnc", "EVLAnnc", "FCNTAnnc", "FWRAnnc", "GRPAnnc", "MEMAnnc", "NODAnnc", "NYCFCAnnc", "RBOAnnc", - "SMDAnnc", "SWRAnnc", "TSAnnc", "TSBAnnc", "TSIAnnc", "WIFIC", "WIFICAnnc", + "SCHAnnc", "SMDAnnc", "SWRAnnc", "TSAnnc", "TSBAnnc", "TSIAnnc", "WIFIC", "WIFICAnnc", "REQRESP" ], "lname": "link", "ns": "m2m", @@ -2044,6 +2044,32 @@ "annc": "OA" } ], + "nco": [ + { + "rtypes": [ "SCH", "SCHAnnc", "REQRESP" ], + "lname": "networkCoordinated", + "ns": "m2m", + "type": "boolean", + "car": "01", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], + "nct": [ + { + "rtypes": [ "ALL" ], + "lname": "notificationContentType", + "ns": "m2m", + "type": "nonNegInteger", + "car": "1", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "NA" + } + ], "nec": [ { "rtypes": [ "CRS" ], @@ -2068,17 +2094,12 @@ "annc": "NA" } ], - "nct": [ + "nev": [ { - "rtypes": [ "ALL" ], - "lname": "notificationContentType", + "rtypes": [ "UNKNOWN" ], + "lname": "notificationEvent", "ns": "m2m", - "type": "nonNegInteger", - "car": "1", - "oc": "O", - "ou": "O", - "od": "O", - "annc": "NA" + "type": "any" } ], "nfu": [ @@ -2479,6 +2500,14 @@ "annc": "OA" } ], + "rep": [ + { + "rtypes": [ "UNKNOWN" ], + "lname": "representation", + "ns": "m2m", + "type": "any" + } + ], "rid": [ { "rtypes": [ "ALL", "REQRESP" ], @@ -2671,7 +2700,7 @@ "lname": "sessionCapabilities", "ns": "m2m", "type": "list", - "ltype": "string", // m2m:sessionCapabilities + "ltype": "string", "car": "01", "oc": "O", "ou": "O", @@ -2679,6 +2708,19 @@ "annc": "OA" } ], + "se": [ + { + "rtypes": [ "SCH", "SCHAnnc", "REQRESP" ], + "lname": "scheduleElement", + "ns": "m2m", + "type": "m2m:scheduleEntries", + "car": "1L", + "oc": "M", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], "sfc": [ { "rtypes": [ "DEPR", "DEPRAnnc", "REQRESP" ], @@ -2692,6 +2734,14 @@ "annc": "OA" } ], + "m2m:sgn": [ + { + "rtypes": [ "UNKNOWN" ], + "lname": "notification", + "ns": "m2m", + "type": "any" + } + ], "sld": [ { "rtypes": [ "ALL" ], @@ -2917,6 +2967,14 @@ "annc": "NA" } ], + "sur": [ + { + "rtypes": [ "UNKNOWN" ], + "lname": "subscriptionReference", + "ns": "m2m", + "type": "any" + } + ], // EXPERIMENTAL diff --git a/init/complexTypePolicies.ap b/init/complexTypePolicies.ap index 2d7d6099..16802cc6 100644 --- a/init/complexTypePolicies.ap +++ b/init/complexTypePolicies.ap @@ -1867,8 +1867,9 @@ "ctype": "m2m:scheduleEntries", "lname": "scheduleEntry", "ns": "m2m", - "type": "schedule", - "car": "01" + "type": "list", + "ltype": "schedule", + "car": "1LN" } ], diff --git a/init/enumTypesPolicies.ep b/init/enumTypesPolicies.ep index 306a0005..fff64224 100644 --- a/init/enumTypesPolicies.ep +++ b/init/enumTypesPolicies.ep @@ -70,8 +70,8 @@ }, "m2m:resourceType" : { // Adapt to supported resource types - "evalues" : [ "1..5", 9, "13..17", 23, 24, "28..30", 48, 58, 60, 65, 66, - "10001..10005", 10009, "10013..10014", 10016, 10021, "10028..10030", 10060, 10065, 10066 ] + "evalues" : [ "1..5", 9, "13..18", 23, 24, "28..30", 48, 58, 60, 65, 66, + "10001..10005", 10009, "10013..10014", 10016, 10018, 10021, "10028..10030", 10060, 10065, 10066 ] }, "m2m:responseStatusCode" : { "evalues": [ "1000..1002", From 72cced877b7c103b41d7989f54c95ccea29b495a Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 2 Jul 2023 14:31:28 +0200 Subject: [PATCH 039/165] Improved resource layout --- acme/textui/ACMEContainerRequests.py | 7 +++++++ acme/textui/ACMETuiApp.py | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/acme/textui/ACMEContainerRequests.py b/acme/textui/ACMEContainerRequests.py index 3f8b95bd..8899e6a4 100644 --- a/acme/textui/ACMEContainerRequests.py +++ b/acme/textui/ACMEContainerRequests.py @@ -200,6 +200,7 @@ def _showRequests(self, item:ACMEListItem) -> None: explanations = self.app.attributeExplanations, # type: ignore [attr-defined] getAttributeValueName = CSE.validator.getAttributeValueName, # type: ignore [attr-defined] width = self.requestListRequest.size[0] - 2) # type: ignore [attr-defined] + _l1 = jsns.count('\n') # Add syntax highlighting and explanations, and add to the view self.requestListRequest.update(Syntax(jsns, 'json', theme = self.app.syntaxTheme)) # type: ignore [attr-defined] @@ -209,7 +210,13 @@ def _showRequests(self, item:ACMEListItem) -> None: explanations = self.app.attributeExplanations, # type: ignore [attr-defined] getAttributeValueName = CSE.validator.getAttributeValueName, # type: ignore [attr-defined] width = self.requestListRequest.size[0] - 2) # type: ignore [attr-defined] + _l2 = jsns.count('\n') + # Make sure the response has the same number of lines as the request + # (This is a hack to make sure the separator line covers the entire height of the view) + if _l1 > _l2: + jsns += '\n' * (_l1 - _l2) + # Add syntax highlighting and explanations, and add to the view self.requestListResponse.update(Syntax(jsns, 'json', theme = self.app.syntaxTheme)) # type: ignore [attr-defined] diff --git a/acme/textui/ACMETuiApp.py b/acme/textui/ACMETuiApp.py index fb937e8a..6b30846b 100644 --- a/acme/textui/ACMETuiApp.py +++ b/acme/textui/ACMETuiApp.py @@ -23,10 +23,12 @@ from ..textui.ACMEContainerRequests import ACMEContainerRequests from ..textui.ACMEContainerTools import ACMEContainerTools from ..services import CSE +from ..etc.Types import ResourceTypes from ..helpers.BackgroundWorker import BackgroundWorkerPool + tabResources = 'tab-resources' tabRequests = 'tab-requests' tabRegistrations = 'tab-registrations' @@ -91,6 +93,10 @@ def __init__(self, textUI:TextUI.TextUI): self.textUI = textUI # Keep backward link to the textUI manager self.quitReason = ACMETuiQuitReason.undefined self.attributeExplanations = CSE.validator.getShortnameLongNameMapping() + + for n in ResourceTypes: + self.attributeExplanations[ResourceTypes(n).tpe()] = f'{ResourceTypes.fullname(n)} resource type' + # This is used to keep track of the current tab. # This is a bit different from the actual current tab from the self.tabs # attribute because at one point it is used to determine the previous tab. From ec2cd1dbf0d219a5d7c06d9d8a1c53ea9de05411 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 2 Jul 2023 14:32:16 +0200 Subject: [PATCH 040/165] Added automatic installation of required packages --- acme/__main__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/acme/__main__.py b/acme/__main__.py index d4fbc692..327bd7ee 100644 --- a/acme/__main__.py +++ b/acme/__main__.py @@ -33,6 +33,19 @@ m = re.search("'(.+?)'", e.msg) package = f' ({m.group(1)}) ' if m else ' ' print(f'\nOne or more required packages or modules{package}could not be found.\nPlease install the missing packages, e.g. by running the following command:\n\n\t{sys.executable} -m pip install -r requirements.txt\n') + + # Ask if the user wants to install the missing packages + try: + if input('\nDo you want to install the missing packages now? [y/N] ') in ['y', 'Y']: + import os + os.system(f'{sys.executable} -m pip install -r requirements.txt') + + # Ask if the user wants to start ACME + if input('\nDo you want to start ACME now? [Y/n] ') in ['y', 'Y', '']: + os.system(f'{sys.executable} -m acme {" ".join(sys.argv[1:])}') + + except Exception as e2: + print(f'\nError during installation: {e2}\n') else: print(f'\nError during import: {e.msg}\n') From 31a0420130a1479de2a9de3c88a24b417dc8b9cf Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 4 Jul 2023 16:27:12 +0200 Subject: [PATCH 041/165] Support "warn" and "warning" for warning loglevel --- acme/services/Configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/services/Configuration.py b/acme/services/Configuration.py index 8d94afc8..5122c853 100644 --- a/acme/services/Configuration.py +++ b/acme/services/Configuration.py @@ -493,7 +493,7 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: Configuration._configuration['logging.level'] = LogLevel.OFF elif logLevel == 'info': Configuration._configuration['logging.level'] = LogLevel.INFO - elif logLevel == 'warn': + elif logLevel in [ 'warn', 'warning' ]: Configuration._configuration['logging.level'] = LogLevel.WARNING elif logLevel == 'error': Configuration._configuration['logging.level'] = LogLevel.ERROR From 46f1fd5e29c90cebff73e09dfeffcdf67c5d02a6 Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 12 Jul 2023 12:17:37 +0200 Subject: [PATCH 042/165] Changes according to SDS-2022-0010 --- CHANGELOG.md | 1 + acme/resources/REQ.py | 65 +++++++++++++++++++++---------------------- tests/testREQ.py | 8 +++++- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93602a19..4a531b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Experimental ### Changed +- [CSE] Changed the *operationResult* of <request> according to SDS-2022-0010R02. ### Fixed diff --git a/acme/resources/REQ.py b/acme/resources/REQ.py index db9b304e..75743ad8 100644 --- a/acme/resources/REQ.py +++ b/acme/resources/REQ.py @@ -11,8 +11,8 @@ from __future__ import annotations from typing import Optional, Dict, Any -from ..etc.Types import AttributePolicyDict, ResourceTypes, Result, RequestStatus, CSERequest, JSON -from ..etc.ResponseStatusCodes import BAD_REQUEST +from ..etc.Types import AttributePolicyDict, ResourceTypes, RequestStatus, CSERequest, JSON +from ..etc.ResponseStatusCodes import ResponseStatusCode from ..helpers.TextTools import setXPath from ..etc.DateUtils import getResourceDate from ..services.Configuration import Configuration @@ -83,47 +83,44 @@ def createRequestResource(request:CSERequest) -> Resource: elif request._rpts: et = request._rpts - # otherwise calculate request et + # otherwise get the request's et from the configuration else: et = getResourceDate(offset = Configuration.get('resource.req.et')) - # minEt = getResourceDate(Configuration.get('resource.req.minet')) - # maxEt = getResourceDate(Configuration.get('resource.req.maxet')) - # if request.args.rpts: - # et = request.args.rpts if request.args.rpts < maxEt else maxEt - # else: - # et = minEt + # Build the REQ resource from the original request dct:Dict[str, Any] = { 'm2m:req' : { - 'et' : et, - 'lbl' : [ request.originator ], - 'op' : request.op, - 'tg' : request.id, - 'org' : request.originator, - 'rid' : request.rqi, - 'mi' : { - 'ty' : request.ty, - 'ot' : getResourceDate(), - 'rqet' : request.rqet, - 'rset' : request.rset, - 'rt' : { - 'rtv' : request.rt + 'et': et, + 'lbl': [ request.originator ], + 'op': request.op, + 'tg': request.id, + 'org': request.originator, + 'rid': request.rqi, + 'mi': { + 'ty': request.ty, + 'ot': getResourceDate(), + 'rqet': request.rqet, + 'rset': request.rset, + 'rt': { + 'rtv': request.rt }, - 'rp' : request.rp, - 'rcn' : request.rcn, - 'fc' : { - 'fu' : request.fc.fu, - 'fo' : request.fc.fo, + 'rp': request.rp, + 'rcn': request.rcn, + 'fc': { + 'fu': request.fc.fu, + 'fo': request.fc.fo, }, - 'drt' : request.drt, - 'rvi' : request.rvi if request.rvi else CSE.releaseVersion, - 'vsi' : request.vsi, - 'sqi' : request.sqi, + 'drt': request.drt, + 'rvi': request.rvi if request.rvi else CSE.releaseVersion, + 'vsi': request.vsi, + 'sqi': request.sqi, }, - 'rs' : RequestStatus.PENDING, - # 'ors' : { - # } + 'rs': RequestStatus.PENDING, + 'ors': { + 'rsc': ResponseStatusCode.ACCEPTED, + 'rqi': request.rqi, + } }} # add handlings, conditions and attributes from filter diff --git a/tests/testREQ.py b/tests/testREQ.py index 45462f17..1b9b990c 100644 --- a/tests/testREQ.py +++ b/tests/testREQ.py @@ -140,13 +140,19 @@ def test_retrieveCSENBSynchValidateREQ(self) -> None: self.assertEqual(rsc, RC.ACCEPTED_NON_BLOCKING_REQUEST_SYNC, r) self.assertIsNotNone(findXPath(r, 'm2m:uri')) requestURI = findXPath(r, 'm2m:uri') + rqi = lastRequestID() # Immediately retrieve r, rsc = RETRIEVE(f'{csiURL}/{requestURI}', TestREQ.originator) self.assertEqual(rsc, RC.OK, r) self.assertIsNotNone(findXPath(r, 'm2m:req/rs'), r) self.assertEqual(findXPath(r, 'm2m:req/rs'), RequestStatus.PENDING, r) - self.assertIsNone(findXPath(r, 'm2m:req/ors'), r) + self.assertIsNotNone(findXPath(r, 'm2m:req/ors'), r) + self.assertIsNotNone(findXPath(r, 'm2m:req/ors/rsc')) + self.assertEqual(findXPath(r, 'm2m:req/ors/rsc'), RC.ACCEPTED) + self.assertIsNotNone(findXPath(r, 'm2m:req/ors/rqi')) + self.assertEqual(findXPath(r, 'm2m:req/ors/rqi'), rqi) # test the request ID from the original request + # get and check after a delay to give the operation time to run testSleep(requestCheckDelay * 2) From 762e784c69b6d810927ce49d9a1049b90c7a5df7 Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 12 Jul 2023 22:43:56 +0200 Subject: [PATCH 043/165] Increased @at script execution test resolution to once a second --- acme/services/ScriptManager.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/acme/services/ScriptManager.py b/acme/services/ScriptManager.py index 22423a76..2ad4af78 100644 --- a/acme/services/ScriptManager.py +++ b/acme/services/ScriptManager.py @@ -20,7 +20,7 @@ from ..helpers.KeyHandler import FunctionKey from ..etc.Types import JSON, ACMEIntEnum, CSERequest, Operation, ResourceTypes, Result from ..etc.ResponseStatusCodes import ResponseException -from ..etc.DateUtils import cronMatchesTimestamp, getResourceDate +from ..etc.DateUtils import cronMatchesTimestamp, getResourceDate, utcDatetime from ..etc.Utils import runsInIPython, uniqueRI, isURL, uniqueID, pureResource from .Configuration import Configuration from ..helpers.Interpreter import PContext, PFuncCallable, PUndefinedError, PError, PState, SSymbol, SType, PSymbolCallable @@ -541,7 +541,6 @@ def doHttp(self, pcontext:PContext, symbol:SSymbol) -> PContext: except requests.exceptions.ConnectionError: pcontext.variables['response.status'] = SSymbol() # nil return pcontext.setResult(SSymbol()) - #print(response) # parse response and assign to variables @@ -1542,8 +1541,9 @@ def cseStarted(self, name:str) -> None: if self.scriptMonitorInterval > 0.0: self.scriptUpdatesMonitor.start() - # Add a worker to check scheduled script, fixed every minute - self.scriptCronWorker = BackgroundWorkerPool.newWorker(60.0, + # Add a worker to check scheduled script, fixed every second + # TODO resolution + self.scriptCronWorker = BackgroundWorkerPool.newWorker(1, self.cronMonitor, 'scriptCronMonitor').start() @@ -1652,9 +1652,10 @@ def cronMonitor(self) -> bool: Boolean. Usually *True* to continue with monitoring. """ #L.isDebug and L.logDebug(f'Looking for scheduled scripts') + _ts = utcDatetime() for each in self.findScripts(meta = _metaAt): try: - if cronMatchesTimestamp(at := each.meta.get(_metaAt)): + if cronMatchesTimestamp(at := each.meta.get(_metaAt), _ts): L.isDebug and L.logDebug(f'Running script: {each.scriptName} at: {at}') self.runScript(each) except ValueError as e: From 607ef68dc6bd6d0da68a2735c872706d1f1c3979 Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 12 Jul 2023 22:45:48 +0200 Subject: [PATCH 044/165] Added second resolution to crontab format --- acme/etc/DateUtils.py | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/acme/etc/DateUtils.py b/acme/etc/DateUtils.py index 4699d9fe..4e86ddd4 100644 --- a/acme/etc/DateUtils.py +++ b/acme/etc/DateUtils.py @@ -125,13 +125,22 @@ def rfc1123Date(timeval:Optional[float] = None) -> str: return formatdate(timeval = timeval, localtime = False, usegmt = True) +def utcDatetime() -> datetime: + """ Return the current datetime, but relative to UTC. + + Returns: + Datetime with current UTC-based time. + """ + return datetime.now(tz = timezone.utc) + + def utcTime() -> float: """ Return the current time's timestamp, but relative to UTC. Returns: Float with current UTC-based POSIX time. """ - return datetime.now(tz = timezone.utc).timestamp() + return utcDatetime().timestamp() def timeUntilTimestamp(ts:float) -> float: @@ -222,14 +231,13 @@ def waitFor(timeout:float, # Cron # -def cronMatchesTimestamp(cronPattern:Union[str, - list[str]], +def cronMatchesTimestamp(cronPattern:Union[str, list[str]], ts:Optional[datetime] = None) -> bool: ''' A cron parser to determine if the *cronPattern* matches for a given timestamp *ts*. The cronPattern must follow the usual crontab pattern of 5 fields: - minute hour dayOfMonth month dayOfWeek + second minute hour dayOfMonth month dayOfWeek year which each must comply to the following patterns: @@ -324,18 +332,20 @@ def _parseMatchCronArg(element:str, target:int) -> bool: return False if ts is None: - ts = datetime.now(tz = timezone.utc) + ts = utcDatetime() cronElements = cronPattern.split() if isinstance(cronPattern, str) else cronPattern - if len(cronElements) != 5: - raise ValueError(f'Invalid or empty cron pattern: "{cronPattern}". Must have 5 elements.') + if len(cronElements) != 7: + raise ValueError(f'Invalid or empty cron pattern: "{cronPattern}". Must have 7 elements.') weekday = ts.isoweekday() - return _parseMatchCronArg(cronElements[0], ts.minute) \ - and _parseMatchCronArg(cronElements[1], ts.hour) \ - and _parseMatchCronArg(cronElements[2], ts.day) \ - and _parseMatchCronArg(cronElements[3], ts.month) \ - and _parseMatchCronArg(cronElements[4], 0 if weekday == 7 else weekday) + return _parseMatchCronArg(cronElements[0], ts.second) \ + and _parseMatchCronArg(cronElements[1], ts.minute) \ + and _parseMatchCronArg(cronElements[2], ts.hour) \ + and _parseMatchCronArg(cronElements[3], ts.day) \ + and _parseMatchCronArg(cronElements[4], ts.month) \ + and _parseMatchCronArg(cronElements[5], 0 if weekday == 7 else weekday) \ + and _parseMatchCronArg(cronElements[6], ts.year) def cronInPeriod(cronPattern:Union[str, @@ -367,7 +377,7 @@ def cronInPeriod(cronPattern:Union[str, # Fill in the default if endTs is None: - endTs = datetime.now(tz = timezone.utc) + endTs = utcDatetime() # Check the validity of the range if endTs < startTs: From 23b39aff26012b0d40f3289f21ccc2849d4edc68 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 13 Jul 2023 11:55:39 +0200 Subject: [PATCH 045/165] Added support for resource type --- CHANGELOG.md | 2 + acme/etc/Types.py | 83 +---- acme/resources/CRS.py | 10 + acme/resources/CSEBase.py | 6 + acme/resources/SCH.py | 19 +- acme/resources/SUB.py | 9 + acme/services/CSE.py | 3 + acme/services/Dispatcher.py | 31 +- acme/services/NotificationManager.py | 49 ++- acme/services/Storage.py | 171 +++++++++- docs/ACMEScript-metatags.md | 8 +- docs/Supported.md | 3 +- tests/testSCH.py | 456 ++++++++++++++++++++++++++- tests/testSUB.py | 8 +- 14 files changed, 743 insertions(+), 115 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a531b2f..69a0b56a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] - xxxx-xx-xx ### Added +- [CSE] Added automatic pip install of missing dependencies during startup. +- [CSE] Added support for <schedule> resource type. ### Experimental diff --git a/acme/etc/Types.py b/acme/etc/Types.py index d133f393..18407bdd 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -842,84 +842,6 @@ def fromBitfield(cls, bitfield:int) -> List[Permission]: if bitfield == Permission.ALL.value: return [ Permission.ALL ] return [ p for p in Permission if p != Permission.ALL and p & bitfield ] - - -# #/usr/local/bin/python3 acop.py {query} -# import sys - -# operations = [ -# (32, 'DISCOVERY', 'i'), -# (16, 'NOTIFY', 'n'), -# ( 8, 'DELETE', 'd'), -# ( 4, 'UPDATE', 'u'), -# ( 2, 'RETRIEVE', 'r'), -# ( 1, 'CREATE', 'c') -# ] - -# def bitfield(n, length = 6): -# r = [int(digit) for digit in bin(n)[2:]] -# while len(r) < length: -# r.insert(0, 0) -# return r - - -# def opsBitfield(field): -# sm = [] -# for i in range(len(field)-1, -1, -1): -# if field[i]: -# sm.append(operations[i][1]) -# return ', '.join(sm) - - -# def toBitfield(query): -# r = [] -# for each in query.lower(): -# for op in operations: -# if each == op[2]: -# if op[0] not in r: -# r.append(op[0]) -# break # break for if found -# else: -# return -1 # return error if for did not exit - -# return sum(r) - - - - -# qu = sys.argv[1] -# try: -# query = int(qu) - -# except ValueError: -# # Not a number, so try to calculate the reverse -# result = toBitfield(qu) -# if result > 0: -# result = str(result) -# print('') -# #print('Access Control Operations') -# print('' + qu + ' = ' + result + '') -# print('' + result + '') -# print('' + result + '') -# print('') - -# else: -# error() - -# else: -# # If no exception, ie. query is an integer -# if 0 < query < 64: -# result = 'ALL' if query == 63 else opsBitfield(bitfield(query)) -# print('') -# #print('Access Control Operations') -# print('' + qu + ' = ' + result + '') -# print('' + result + '') -# print('' + result + '') -# print('') -# else: -# error() - - ############################################################################## @@ -1137,10 +1059,13 @@ class RequestStatus(ACMEIntEnum): # class EventCategory(ACMEIntEnum): - """ Event Categories """ + """ Event Categories from m2m:stdEventCats """ Immediate = 2 + """ Immediate event. """ BestEffort = 3 + """ Best effort event. """ Latest = 4 + """ Only latest event. """ ############################################################################## diff --git a/acme/resources/CRS.py b/acme/resources/CRS.py index c90394c6..4d823c92 100644 --- a/acme/resources/CRS.py +++ b/acme/resources/CRS.py @@ -13,6 +13,7 @@ from typing import Optional, cast from copy import deepcopy + from ..etc.Utils import pureResource, toSPRelative, csiFromSPRelative, compareIDs from ..helpers.TextTools import findXPath, setXPath from ..helpers.ResourceSemaphore import criticalResourceSection, inCriticalSection @@ -219,6 +220,15 @@ def validate(self, originator:Optional[str] = None, raise BAD_REQUEST(L.logDebug(f'eem = {eem} is not allowed with twt = SLIDINGWINDOW')) + def childWillBeAdded(self, childResource: Resource, originator: str) -> None: + super().childWillBeAdded(childResource, originator) + if childResource.ty == ResourceTypes.SCH: + if (rn := childResource._originalDict.get('rn')) is None: + childResource.setResourceName('notificationSchedule') + elif rn != 'notificationSchedule': + raise BAD_REQUEST(L.logDebug(f'rn of under must be "notificationSchedule"')) + + def handleNotification(self, request:CSERequest, originator:str) -> None: """ Handle a notification request to a CRS resource. diff --git a/acme/resources/CSEBase.py b/acme/resources/CSEBase.py index 353e72a3..b5e4230d 100644 --- a/acme/resources/CSEBase.py +++ b/acme/resources/CSEBase.py @@ -130,6 +130,12 @@ def willBeRetrieved(self, originator:str, self.setAttribute('srv', CSE.supportedReleaseVersions) + def childWillBeAdded(self, childResource: Resource, originator: str) -> None: + super().childWillBeAdded(childResource, originator) + if childResource.ty == ResourceTypes.SCH: + if CSE.dispatcher.directChildResources(self.ri, ResourceTypes.SCH): + raise BAD_REQUEST('Only one resource is allowed for the CSEBase') + def getCSE() -> CSEBase: # Actual: CSEBase Resource """ Return the resource. diff --git a/acme/resources/SCH.py b/acme/resources/SCH.py index 888d8d56..6249a0f7 100644 --- a/acme/resources/SCH.py +++ b/acme/resources/SCH.py @@ -15,6 +15,7 @@ from ..etc.Constants import Constants as C from ..etc.Types import AttributePolicyDict, ResourceTypes, JSON from ..services.Logging import Logging as L +from ..services import CSE from ..resources.Resource import Resource from ..etc.ResponseStatusCodes import CONTENTS_UNACCEPTABLE, NOT_IMPLEMENTED from ..resources.AnnounceableResource import AnnounceableResource @@ -73,6 +74,9 @@ def activate(self, parentResource:Resource, originator:str) -> None: if _nco is not None and _nco == True and not C.networkCoordinationSupported: raise NOT_IMPLEMENTED (L.logWarn(f'Network Coordinated Operation is not supported by this CSE')) + # Add the schedule to the schedules DB + CSE.storage.upsertSchedule(self) + # TODO When is supported # c)The request shall be rejected with the "OPERATION_NOT_ALLOWED" Response Status Code if the target resource # is a resource that has a campaignEnabled attribute with a value of true. @@ -97,8 +101,20 @@ def update(self, dct: JSON = None, originator: str | None = None, doValidateAttr # if thetarget resource is a resource that has a campaignEnabled attribute with a value of true. super().update(dct, originator, doValidateAttributes) + + # Update the schedule in the schedules DB + CSE.storage.upsertSchedule(self) + def validate(self, originator: str | None = None, dct: JSON | None = None, parentResource: Resource | None = None) -> None: + super().validate(originator, dct, parentResource) + + # Set the active schedule in the CSE when updated + if parentResource.ty == ResourceTypes.CSEBase: + CSE.cseActiveSchedule = self.getFinalResourceAttribute('se/sce', dct) + L.isDebug and L.logDebug(f'Setting active schedule in CSE to {CSE.cseActiveSchedule}') + + def deactivate(self, originator: str) -> None: # TODO When is supported @@ -107,5 +123,6 @@ def deactivate(self, originator: str) -> None: super().deactivate(originator) + # Remove the schedule from the schedules DB + CSE.storage.removeSchedule(self) -# TODO coninue \ No newline at end of file diff --git a/acme/resources/SUB.py b/acme/resources/SUB.py index 22f47ef8..e648f8d2 100644 --- a/acme/resources/SUB.py +++ b/acme/resources/SUB.py @@ -267,6 +267,15 @@ def validate(self, originator:Optional[str] = None, self._normalizeURIAttribute('su') + def childWillBeAdded(self, childResource: Resource, originator: str) -> None: + super().childWillBeAdded(childResource, originator) + if childResource.ty == ResourceTypes.SCH: + if (rn := childResource._originalDict.get('rn')) is None: + childResource.setResourceName('notificationSchedule') + elif rn != 'notificationSchedule': + raise BAD_REQUEST(L.logDebug(f'rn of under must be "notificationSchedule"')) + + def _checkAllowedCHTY(self, parentResource:Resource, chty:list[ResourceTypes]) -> None: """ Check whether an observed child resource types are actually allowed by the parent. diff --git a/acme/services/CSE.py b/acme/services/CSE.py index ab3b7931..625d1f7b 100644 --- a/acme/services/CSE.py +++ b/acme/services/CSE.py @@ -169,6 +169,9 @@ cseStatus:CSEStatus = CSEStatus.STOPPED """ The CSE's internal runtime status. """ +cseActiveSchedule:list[str] = [] +""" List of active schedules when the CSE is active and will process requests. """ + _cseResetLock = Lock() # lock for resetting the CSE """ Internal CSE's lock when resetting. """ diff --git a/acme/services/Dispatcher.py b/acme/services/Dispatcher.py index 122c2778..2b54128e 100644 --- a/acme/services/Dispatcher.py +++ b/acme/services/Dispatcher.py @@ -23,10 +23,12 @@ from ..etc.ResponseStatusCodes import ORIGINATOR_HAS_NO_PRIVILEGE, NOT_FOUND, BAD_REQUEST from ..etc.ResponseStatusCodes import REQUEST_TIMEOUT, OPERATION_NOT_ALLOWED, TARGET_NOT_SUBSCRIBABLE, INVALID_CHILD_RESOURCE_TYPE from ..etc.ResponseStatusCodes import INTERNAL_SERVER_ERROR, SECURITY_ASSOCIATION_REQUIRED, CONFLICT +from ..etc.ResponseStatusCodes import TARGET_NOT_REACHABLE from ..etc.Utils import localResourceID, isSPRelative, isStructured, resourceModifiedAttributes, filterAttributes, riFromID from ..etc.Utils import srnFromHybrid, uniqueRI, noNamespace, riFromStructuredPath, csiFromSPRelative, toSPRelative, structuredPathFromRI from ..helpers.TextTools import findXPath from ..etc.DateUtils import waitFor, timeUntilTimestamp, timeUntilAbsRelTimestamp, getResourceDate +from ..etc.DateUtils import cronMatchesTimestamp from ..services import CSE from ..services.Configuration import Configuration from ..resources.Factory import resourceFromDict @@ -113,8 +115,9 @@ def processRetrieveRequest(self, request:CSERequest, raise BAD_REQUEST(L.logWarn(f'Only "m2m:atrl" is allowed in Content for RETRIEVE.')) CSE.validator.validateAttribute('atrl', attributeList) - # Handle operation execution time and check request expiration + # Handle operation execution time , and check CSE schedule and request expiration self._handleOperationExecutionTime(request) + self._checkActiveCSESchedule() self._checkRequestExpiration(request) # handle fanout point requests @@ -562,8 +565,9 @@ def processCreateRequest(self, request:CSERequest, # return Result.errorResult(rsc = RC.notFound, dbg = L.logDebug('resource not found')) raise NOT_FOUND(L.logDebug('resource not found')) - # Handle operation execution time and check request expiration + # Handle operation execution time, and check CSE schedule and request expiration self._handleOperationExecutionTime(request) + self._checkActiveCSESchedule() self._checkRequestExpiration(request) # handle fanout point requests @@ -792,8 +796,9 @@ def processUpdateRequest(self, request:CSERequest, if not id and not fopsrn: raise NOT_FOUND(L.logDebug('resource not found')) - # Handle operation execution time and check request expiration + # Handle operation execution time , and check CSE schedule and request expiration self._handleOperationExecutionTime(request) + self._checkActiveCSESchedule() self._checkRequestExpiration(request) # handle fanout point requests @@ -957,8 +962,9 @@ def processDeleteRequest(self, request:CSERequest, if not id and not fopsrn: raise NOT_FOUND(L.logDebug('resource not found')) - # Handle operation execution time and check request expiration + # Handle operation execution time , and check CSE schedule and request expiration self._handleOperationExecutionTime(request) + self._checkActiveCSESchedule() self._checkRequestExpiration(request) # handle fanout point requests @@ -1118,8 +1124,9 @@ def processNotifyRequest(self, request:CSERequest, srn, id = self._checkHybridID(request, id) # overwrite id if another is given - # Handle operation execution time and check request expiration + # Handle operation execution time, and check CSE schedule and request expiration self._handleOperationExecutionTime(request) + self._checkActiveCSESchedule() self._checkRequestExpiration(request) # get resource to be notified and check permissions @@ -1380,6 +1387,20 @@ def _checkRequestExpiration(self, request:CSERequest) -> None: raise REQUEST_TIMEOUT(L.logDebug('request timed out')) + def _checkActiveCSESchedule(self) -> None: + """ Check if the CSE is currently active according to its schedule. + + Raises: + `TARGET_NOT_REACHABLE`: In case the CSE is not active. + """ + if CSE.cseActiveSchedule: + for s in CSE.cseActiveSchedule: + if cronMatchesTimestamp(s): + return + # TODO not sure if this is the right error code + raise TARGET_NOT_REACHABLE(L.logDebug('request exection time outside of CSE\'s allowed schedule')) + + ######################################################################### # diff --git a/acme/services/NotificationManager.py b/acme/services/NotificationManager.py index c53fc9e7..e79463c9 100644 --- a/acme/services/NotificationManager.py +++ b/acme/services/NotificationManager.py @@ -22,8 +22,8 @@ from ..etc.ResponseStatusCodes import ResponseStatusCode, ResponseException, exceptionFromRSC from ..etc.ResponseStatusCodes import INTERNAL_SERVER_ERROR, SUBSCRIPTION_VERIFICATION_INITIATION_FAILED from ..etc.ResponseStatusCodes import TARGET_NOT_REACHABLE, REMOTE_ENTITY_NOT_REACHABLE, OPERATION_NOT_ALLOWED -from ..etc.ResponseStatusCodes import OPERATION_DENIED_BY_REMOTE_ENTITY -from ..etc.DateUtils import fromDuration, getResourceDate +from ..etc.ResponseStatusCodes import OPERATION_DENIED_BY_REMOTE_ENTITY, NOT_FOUND +from ..etc.DateUtils import fromDuration, getResourceDate, cronMatchesTimestamp, utcDatetime from ..etc.Utils import toSPRelative, pureResource, isAcmeUrl, compareIDs from ..helpers.TextTools import setXPath, findXPath from ..services import CSE @@ -179,8 +179,11 @@ def removeSubscription(self, subscription:SUB|CRS, originator:str) -> None: self.sendDeletionNotification([ nu for nu in acrs ], subscription.ri) # Finally remove subscriptions from storage - if not CSE.storage.removeSubscription(subscription): - raise INTERNAL_SERVER_ERROR('cannot remove subscription from database') + try: + if not CSE.storage.removeSubscription(subscription): + raise INTERNAL_SERVER_ERROR('cannot remove subscription from database') + except NOT_FOUND: + pass # ignore, could be expected def updateSubscription(self, subscription:SUB, previousNus:list[str], originator:str) -> None: @@ -276,19 +279,29 @@ def checkSubscriptions( self, # TODO ensure uniqueness subs.append(sub) - - - # TODO: Add access control check here. Perhaps then the special subscription - # DB data structure should go away and be replaced by the normal subscriptions - - for sub in subs: # Prevent own notifications for subscriptions ri = sub['ri'] + + # Check the subscription's schedule, but only if it is not an immediate notification + if not ((nec := sub['nec']) and nec == EventCategory.Immediate): + if (_sc := CSE.storage.searchScheduleForTarget(ri)): + _ts = utcDatetime() + + # Check whether the current time matches the schedule + for s in _sc: + if cronMatchesTimestamp(s, _ts): + break + else: + # No schedule matches the current time, so continue with the next subscription + continue + + # Check whether reason is included in the subscription if childResource and \ ri == childResource.ri and \ reason in [ NotificationEventType.createDirectChild, NotificationEventType.deleteDirectChild ]: continue + if reason not in sub['net']: # check whether reason is actually included in the subscription continue if reason in [ NotificationEventType.createDirectChild, NotificationEventType.deleteDirectChild ]: # reasons for child resources @@ -604,6 +617,20 @@ def _crsCheckForNotification(self, data:list[str], L.isDebug and L.logDebug(f'Received sufficient notifications - sending notification') + # Check the crossResourceSubscription's schedule, if there is one + if (_sc := CSE.storage.searchScheduleForTarget(crsRi)): + _ts = utcDatetime() + + # Check whether the current time matches any schedule + for s in _sc: + if cronMatchesTimestamp(s, _ts): + break + else: + # No schedule matches the current time, so clear the data and just return + L.isDebug and L.logDebug(f'No matching schedule found for : {crsRi}') + data.clear() + return + try: resource = CSE.dispatcher.retrieveResource(crsRi) except ResponseException as e: @@ -986,7 +1013,7 @@ def _verifyNusInSubscription(self, subscription:SUB|CRS, def sendVerificationRequest(self, uri:Union[str, list[str]], ri:str, originator:Optional[str] = None) -> bool: - """" Define the callback function for verification notifications and send + """ Define the callback function for verification notifications and send the notification. Args: diff --git a/acme/services/Storage.py b/acme/services/Storage.py index 4d916a5d..09a90003 100644 --- a/acme/services/Storage.py +++ b/acme/services/Storage.py @@ -32,6 +32,7 @@ from ..services import CSE from ..resources.Resource import Resource from ..resources.ACTR import ACTR +from ..resources.SCH import SCH from ..resources.Factory import resourceFromDict from ..services.Logging import Logging as L @@ -46,6 +47,7 @@ _statistics = 'statistics' _actions = 'actions' _requests = 'requests' +_schedules = 'schedules' class Storage(object): @@ -153,6 +155,9 @@ def _validateDB(self) -> bool: self.getStatistics() dbFile = _actions self.getActions() + dbFile = _schedules + self.getSchedules() + # TODO requests except Exception as e: @@ -313,9 +318,8 @@ def deleteResource(self, resource:Resource) -> None: self.db.deleteResource(resource) self.db.deleteIdentifier(resource) self.db.removeChildResource(resource) - except KeyError as e: - L.isDebug and L.logDebug(f'Cannot remove: {resource.ri} (NOT_FOUND). Could be an expected error.') - raise NOT_FOUND(dbg = str(e)) + except KeyError: + raise NOT_FOUND(dbg = L.logDebug(f'Cannot remove: {resource.ri} (NOT_FOUND). Could be an expected error.')) def directChildResources(self, pi:str, @@ -444,7 +448,10 @@ def addSubscription(self, subscription:Resource) -> bool: def removeSubscription(self, subscription:Resource) -> bool: # L.logDebug(f'Removing subscription: {subscription.ri}') - return self.db.removeSubscription(subscription) + try: + return self.db.removeSubscription(subscription) + except KeyError as e: + raise NOT_FOUND(dbg = L.logDebug(f'Cannot subscription data for: {subscription.ri} (NOT_FOUND). Could be an expected error.')) def updateSubscription(self, subscription:Resource) -> bool: @@ -452,6 +459,11 @@ def updateSubscription(self, subscription:Resource) -> bool: return self.db.upsertSubscription(subscription) + def updateSubscriptionSchedule(self, subscription:Resource, schedule:list[str]) -> bool: + # L.logDebug(f'Updating subscription schedule: {ri} - {schedule}') + return self.db.updateSubscriptionSchedule(subscription, schedule) + + ######################################################################### ## ## BatchNotifications @@ -586,6 +598,57 @@ def deleteRequests(self, ri:Optional[str] = None) -> None: return self.db.deleteRequests(ri) + ######################################################################### + ## + ## Schedules + ## + + def getSchedules(self) -> list[Document]: + """ Retrieve the schedules data from the DB. + + Return: + List of *Documents*. May be empty. + """ + return self.db.getSchedules() + + + def searchScheduleForTarget(self, pi:str) -> list[str]: + """ Search for schedules for a target resource. + + Args: + pi: The target resource's resource ID. + + Return: + List of schedule resource IDs. + """ + result = [] + for s in self.db.searchSchedules(pi): + result.extend(s['sce']) + return result + + + def upsertSchedule(self, schedule:SCH) -> bool: + """ Add or update a schedule in the DB. + + Args: + schedule: The schedule to add or update. + + Return: + Boolean value to indicate success or failure. + """ + return self.db.upsertSchedule(schedule.ri, schedule.pi, schedule.attribute('se/sce')) + + + def removeSchedule(self, schedule:SCH) -> bool: + """ Remove a schedule from the DB. + + Args: + schedule: The schedule to remove. + + Return: + Boolean value to indicate success or failure. + """ + return self.db.removeSchedule(schedule.ri) ######################################################################### # @@ -611,6 +674,7 @@ class TinyDBBinding(object): 'lockStatistics', 'lockActions', 'lockRequests', + 'lockSchedules', 'fileResources', 'fileIdentifiers', @@ -619,6 +683,7 @@ class TinyDBBinding(object): 'fileStatistics', 'fileActions', 'fileRequests', + 'fileSchedules', 'dbResources', 'dbIdentifiers', @@ -627,6 +692,7 @@ class TinyDBBinding(object): 'dbStatistics', 'dbActions', 'dbRequests', + 'dbSchedules', 'tabResources', 'tabIdentifiers', @@ -637,6 +703,7 @@ class TinyDBBinding(object): 'tabStatistics', 'tabActions', 'tabRequests', + 'tabSchedules', 'resourceQuery', 'identifierQuery', @@ -644,6 +711,7 @@ class TinyDBBinding(object): 'batchNotificationQuery', 'actionsQuery', 'requestsQuery', + 'schedulesQuery', ) def __init__(self, path:str, postfix:str) -> None: @@ -661,6 +729,7 @@ def __init__(self, path:str, postfix:str) -> None: self.lockStatistics = Lock() self.lockActions = Lock() self.lockRequests = Lock() + self.lockSchedules = Lock() # file names self.fileResources = f'{self.path}/{_resources}-{postfix}.json' @@ -670,6 +739,7 @@ def __init__(self, path:str, postfix:str) -> None: self.fileStatistics = f'{self.path}/{_statistics}-{postfix}.json' self.fileActions = f'{self.path}/{_actions}-{postfix}.json' self.fileRequests = f'{self.path}/{_requests}-{postfix}.json' + self.fileSchedules = f'{self.path}/{_schedules}-{postfix}.json' # All databases/tables will use the smart query cache if Configuration.get('database.inMemory'): @@ -681,6 +751,7 @@ def __init__(self, path:str, postfix:str) -> None: self.dbStatistics = TinyDB(storage = MemoryStorage) self.dbActions = TinyDB(storage = MemoryStorage) self.dbRequests = TinyDB(storage = MemoryStorage) + self.dbSchedules = TinyDB(storage = MemoryStorage) else: L.isInfo and L.log('DB in file system') # self.dbResources = TinyDB(self.fileResources) @@ -698,6 +769,7 @@ def __init__(self, path:str, postfix:str) -> None: self.dbStatistics = TinyDB(self.fileStatistics, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) self.dbActions = TinyDB(self.fileActions, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) self.dbRequests = TinyDB(self.fileRequests, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) + self.dbSchedules = TinyDB(self.fileSchedules, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) # Open/Create tables @@ -728,6 +800,10 @@ def __init__(self, path:str, postfix:str) -> None: self.tabRequests = self.dbRequests.table(_requests, cache_size = self.cacheSize) TinyDBBetterTable.assign(self.tabRequests) + self.tabSchedules = self.dbSchedules.table(_schedules, cache_size = self.cacheSize) + TinyDBBetterTable.assign(self.tabSchedules) + + # Create the Queries self.resourceQuery = Query() @@ -736,6 +812,7 @@ def __init__(self, path:str, postfix:str) -> None: self.batchNotificationQuery = Query() self.actionsQuery = Query() self.requestsQuery = Query() + self.schedulesQuery = Query() def _assignConfig(self) -> None: @@ -762,6 +839,8 @@ def closeDB(self) -> None: self.dbActions.close() with self.lockRequests: self.dbRequests.close() + with self.lockSchedules: + self.dbSchedules.close() def purgeDB(self) -> None: @@ -775,6 +854,7 @@ def purgeDB(self) -> None: self.tabStatistics.truncate() self.tabActions.truncate() self.tabRequests.truncate() + self.tabSchedules.truncate() def backupDB(self, dir:str) -> bool: @@ -784,7 +864,9 @@ def backupDB(self, dir:str) -> bool: self.fileBatchNotifications, self.fileStatistics, self.fileActions, - self.fileRequests]: + self.fileRequests, + self.fileSchedules + ]: if Path(fn).is_file(): shutil.copy2(fn, dir) return True @@ -1048,6 +1130,7 @@ def upsertSubscription(self, subscription:Resource) -> bool: 'nus' : subscription.nu, 'bn' : subscription.bn, 'cr' : subscription.cr, + 'nec' : subscription.nec, 'org' : subscription.getOriginator(), 'ma' : fromDuration(subscription.ma) if subscription.ma else None, # EXPERIMENTAL ma = maxAge 'nse' : subscription.nse @@ -1055,6 +1138,11 @@ def upsertSubscription(self, subscription:Resource) -> bool: # self.subscriptionQuery.ri == ri) is not None + def updateSubscriptionSchedule(self, subscription:Resource, schedule:list[str]) -> bool: + with self.lockSubscriptions: + return self.tabSubscriptions.update({'sce' : schedule}, doc_ids = [subscription.ri]) == 1 + + def removeSubscription(self, subscription:Resource) -> bool: with self.lockSubscriptions: return len(self.tabSubscriptions.remove(doc_ids = [subscription.ri])) > 0 @@ -1270,4 +1358,75 @@ def deleteRequests(self, ri:Optional[str] = None) -> None: self.tabRequests.remove(self.requestsQuery.ri == ri) else: with self.lockRequests: - self.tabRequests.truncate() \ No newline at end of file + self.tabRequests.truncate() + + # + # Schedules + # + + def getSchedules(self) -> list[Document]: + """ Get all schedules from the database. + + Return: + List of *Documents*. May be empty. + """ + with self.lockSchedules: + return self.tabSchedules.all() + + + def getSchedule(self, ri:str) -> Optional[Document]: + """ Get a schedule from the database. + + Args: + ri: The resource ID of the schedule. + + Return: + The schedule, or *None* if not found. + """ + with self.lockSchedules: + return self.tabSchedules.get(doc_id = ri) # type:ignore[arg-type] + + + def searchSchedules(self, pi:str) -> list[Document]: + """ Search for schedules in the database. + + Args: + pi: The resource ID of the parent resource. + + Return: + List of *Documents*. May be empty. + """ + with self.lockSchedules: + return self.tabSchedules.search(self.schedulesQuery.pi == pi) + + + def upsertSchedule(self, ri:str, pi:str, schedule:list[str]) -> bool: + """ Add or update a schedule in the database. + + Args: + ri: The resource ID of the schedule. + pi: The resource ID of the schedule's parent resource. + schedule: The schedule to store. + + Return: + True if the schedule was added or updated, False otherwise. + """ + with self.lockSchedules: + return self.tabSchedules.upsert(Document( + { 'ri': ri, + 'pi': pi, + 'sce': schedule }, + ri)) is not None # type:ignore[arg-type] + + + def removeSchedule(self, ri:str) -> bool: + """ Remove a schedule from the database. + + Args: + ri: The resource ID of the schedule to remove. + + Return: + True if the schedule was removed, False otherwise. + """ + with self.lockSchedules: + return len(self.tabSchedules.remove(doc_ids = [ri])) > 0 # type:ignore[arg-type, list-item] \ No newline at end of file diff --git a/docs/ACMEScript-metatags.md b/docs/ACMEScript-metatags.md index 41c2e7ba..44b35ddb 100644 --- a/docs/ACMEScript-metatags.md +++ b/docs/ACMEScript-metatags.md @@ -54,9 +54,9 @@ They can be accessed like any other environment variable, for example: The `@at` meta tag specifies a time / date pattern when a script should be executed. This pattern follows the Unix [crontab](https://crontab.guru/crontab.5.html) pattern. -A crontab pattern consists of the following five fields: +A crontab pattern consists of the following six fields: -`minute hour dayOfMonth month dayOfWeek` +`second minute hour dayOfMonth month dayOfWeek year` Each field is mandatory and must comply to the following values: @@ -68,9 +68,9 @@ Each field is mandatory and must comply to the following values: Example: ```lisp ;; Run a script every 5 minutes -@at */5 * * * * +@at 0 */5 * * * * * ;; Run a script every Friday at 2:30 am -@at 30 2 * * 4 +@at 0 30 2 * * 4 * ``` [top](#top) diff --git a/docs/Supported.md b/docs/Supported.md index 089e8ab6..035fbff8 100644 --- a/docs/Supported.md +++ b/docs/Supported.md @@ -46,8 +46,9 @@ The ACME CSE supports the following oneM2M resource types: | Polling Channel (PCH) | ✓ | Support for Request and Notification long-polling via the *pcu* (pollingChannelURI) virtual resource. *requestAggregation* functionality is supported, too. | | Remote CSE (CSR) | ✓ | Announced resources are supported. Transit request to resources on registered CSE's are supported. | | Request (REQ) | ✓ | Support for non-blocking requests. | +| Schedule (SCH) | ✓ | Support for CSE communication, nodes, subscriptions and crossResourceSubscriptions. | | SemanticDescriptor (SMD) | ✓ | Support for basic resource handling and semantic queries. | -| Subscription (SUB) | ✓ | Notifications via http(s) (direct url or an AE's Point-of-Access (POA)). BatchNotifications, attributes. Not all features are supported yet. | +| Subscription (SUB) | ✓ | Notifications via http(s) (direct url or an AE's Point-of-Access (POA)). BatchNotifications, attributes, statistics. Not all features are supported yet. | | TimeSeries (TS) | ✓ | Including missing data notifications. | | TimeSeriesInstance (TSI) | ✓ | *dataGenerationTime* attribute only supports absolute timestamps. | | TimeSyncBeacon (TSB) | ✓ | Experimental. Implemented functionality might change according to specification changes. | diff --git a/tests/testSCH.py b/tests/testSCH.py index 0bf5e00a..d890bbc4 100644 --- a/tests/testSCH.py +++ b/tests/testSCH.py @@ -11,12 +11,19 @@ if '..' not in sys.path: sys.path.append('..') from typing import Tuple -from acme.etc.Types import ResourceTypes as T, ResponseStatusCode as RC, Permission -from acme.etc.Types import EvalMode, Operation, EvalCriteriaOperator +from acme.etc.Types import ResourceTypes as T, ResponseStatusCode as RC, TimeWindowType +from acme.etc.Types import NotificationEventType, NotificationEventType as NET from init import * +from datetime import timedelta nodeID = 'urn:sn:1234' +def createScheduleString(range:int, delay:int = 0) -> str: + """ Create schedule string for range seconds """ + dts = datetime.now(tz = timezone.utc) + timedelta(seconds = delay) + dte = dts + timedelta(seconds = range) + return f'{dts.second}-{dte.second} {dts.minute}-{dte.minute} {dts.hour}-{dte.hour} * * * *' + class TestSCH(unittest.TestCase): @@ -25,6 +32,8 @@ class TestSCH(unittest.TestCase): ae2 = None nod = None nodRI = None + crs = None + crsRI = None originator = None @@ -33,6 +42,11 @@ class TestSCH(unittest.TestCase): @unittest.skipIf(noCSE, 'No CSEBase') def setUpClass(cls) -> None: testCaseStart('Setup TestSCH') + + # Start notification server + startNotificationServer() + + dct = { 'm2m:ae' : { 'rn' : aeRN, 'api' : APPID, @@ -53,6 +67,27 @@ def setUpClass(cls) -> None: assert rsc == RC.CREATED cls.nodRI = findXPath(cls.nod, 'm2m:nod/ri') + dct = { 'm2m:crs' : { + 'rn' : crsRN, + 'nu' : [ NOTIFICATIONSERVER ], + 'twt' : TimeWindowType.PERIODICWINDOW, + 'tws' : f'PT{crsTimeWindowSize}S', + 'rrat' : [ cls.nodRI ], + 'encs' : { + 'enc' : [ + { + 'net': [ NotificationEventType.createDirectChild ], + } + ] + } + + + }} + cls.nod, rsc = CREATE(cseURL, ORIGINATOR, T.CRS, dct) + assert rsc == RC.CREATED + cls.crsRI = findXPath(cls.nod, 'm2m:crs/ri') + + testCaseEnd('Setup TestSCH') @@ -60,10 +95,12 @@ def setUpClass(cls) -> None: @unittest.skipIf(noCSE, 'No CSEBase') def tearDownClass(cls) -> None: if not isTearDownEnabled(): + stopNotificationServer() return testCaseStart('TearDown TestSCH') DELETE(aeURL, ORIGINATOR) # Just delete the AE and everything below it. Ignore whether it exists or not DELETE(nodURL, ORIGINATOR) # Just delete the NOD and everything below it. Ignore whether it exists or not + DELETE(f'{cseURL}/{crsRN}', ORIGINATOR) DELETE(f'{cseURL}/{schRN}', ORIGINATOR) testCaseEnd('TearDown TestSCH') @@ -147,7 +184,7 @@ def test_updateSCHunderCBwithNCOFail(self) -> None: @unittest.skipIf(noCSE, 'No CSEBase') def test_updateSCHunderNODwithNOCUnsupportedFail(self) -> None: - """ CREATE with nco under NOD (unsupported-> Fail""" + """ CREATE with nco under NOD (unsupported) -> Fail""" self.assertIsNotNone(TestSCH.ae) dct:JSON = { 'm2m:sch' : { 'rn' : schRN, @@ -168,6 +205,399 @@ def test_updateSCHunderNODwithNOCUnsupportedFail(self) -> None: self.assertEqual(rsc, RC.DELETED, r) + # + # Testing CREATE with different parent types + # + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderSUBwrongRn(self) -> None: + """ CREATE with wrong rn under -> Fail""" + # create + dct:JSON = { 'm2m:sub' : { + 'rn' : f'{subRN}', + 'enc': { + 'net': [ NotificationEventType.resourceUpdate ] + }, + 'nu': [ NOTIFICATIONSERVER ] + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SUB, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # create with wrong rn + dct = { 'm2m:sch' : { + 'rn' : 'wrong', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(f'{cseURL}/{subRN}', ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + # DELETE SUB again + r, rsc = DELETE(f'{cseURL}/{subRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderSUBemptyRn(self) -> None: + """ CREATE with empty rn under """ + # create + dct:JSON = { 'm2m:sub' : { + 'rn' : f'{subRN}', + 'enc': { + 'net': [ NotificationEventType.resourceUpdate ] + }, + 'nu': [ NOTIFICATIONSERVER ] + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SUB, dct) + self.assertEqual(rsc, RC.CREATED, r) + + dct = { 'm2m:sch' : { + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(f'{cseURL}/{subRN}', ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/{subRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderSUBcorrectRn(self) -> None: + """ CREATE with correct rn under """ + # create + dct:JSON = { 'm2m:sub' : { + 'rn' : f'{subRN}', + 'enc': { + 'net': [ NotificationEventType.resourceUpdate ] + }, + 'nu': [ NOTIFICATIONSERVER ] + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SUB, dct) + self.assertEqual(rsc, RC.CREATED, r) + + dct = { 'm2m:sch' : { + 'rn' : 'notificationSchedule', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(f'{cseURL}/{subRN}', ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/{subRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderCRSwrongRn(self) -> None: + """ CREATE with wrong rn under -> Fail""" + dct:JSON = { 'm2m:sch' : { + 'rn' : 'wrong', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(f'{cseURL}/{crsRN}', ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderCRSemptyRn(self) -> None: + """ CREATE with empty rn under """ + dct:JSON = { 'm2m:sch' : { + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(f'{cseURL}/{crsRN}', ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/{crsRN}/notificationSchedule', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderCRScorrectRn(self) -> None: + """ CREATE with correct rn under """ + dct:JSON = { 'm2m:sch' : { + 'rn' : 'notificationSchedule', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(f'{cseURL}/{crsRN}', ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/{crsRN}/notificationSchedule', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderCB(self) -> None: + """ CREATE under CB""" + dct:JSON = { 'm2m:sch' : { + 'rn' : 'schedule', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/schedule', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderCBTwiceFail(self) -> None: + """ CREATE under CB twice -> Fail""" + dct:JSON = { 'm2m:sch' : { + 'rn' : 'schedule', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # second create + dct = { 'm2m:sch' : { + 'rn' : 'schedule2', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(cseURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + # DELETE again + r, rsc = DELETE(f'{cseURL}/schedule', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createSCHunderNOD(self) -> None: + """ CREATE under """ + dct:JSON = { 'm2m:sch' : { + 'rn' : 'schedule', + 'se': { 'sce': [ '* * * * * * *' ] } + }} + r, rsc = CREATE(nodURL, ORIGINATOR, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # DELETE again + r, rsc = DELETE(f'{nodURL}/schedule', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_testSCHunderSUBinsideSchedule(self) -> None: + """ CREATE under and receive notification within schedule """ + # create + dct:JSON = { 'm2m:sub' : { + 'rn' : f'{subRN}', + 'enc': { + 'net': [ NotificationEventType.resourceUpdate ] + }, + 'nu': [ NOTIFICATIONSERVER ] + }} + r, rsc = CREATE(aeURL, TestSCH.originator, T.SUB, dct) + self.assertEqual(rsc, RC.CREATED, r) + + dct = { 'm2m:sch' : { + 'rn' : 'notificationSchedule', + 'se': { 'sce': [ createScheduleString(requestCheckDelay * 2) ] } + }} + r, rsc = CREATE(f'{aeURL}/{subRN}', TestSCH.originator, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Update the AE to trigger a notification immediately + clearLastNotification() + dct = { 'm2m:ae' : { + 'lbl' : ['test'] + }} + r, rsc = UPDATE(aeURL, TestSCH.originator, dct) + self.assertEqual(rsc, RC.UPDATED, r) + + # Check notification + testSleep(requestCheckDelay) + notification = getLastNotification() + self.assertIsNotNone(notification) # notification received + + # DELETE again + r, rsc = DELETE(f'{aeURL}/{subRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_testSCHunderSUBoutsideSchedule(self) -> None: + """ CREATE under and receive notification outside schedule """ + # create + dct:JSON = { 'm2m:sub' : { + 'rn' : f'{subRN}', + 'enc': { + 'net': [ NotificationEventType.resourceUpdate ] + }, + 'nu': [ NOTIFICATIONSERVER ] + }} + r, rsc = CREATE(aeURL, TestSCH.originator, T.SUB, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # add schedule + dct = { 'm2m:sch' : { + 'rn' : 'notificationSchedule', + 'se': { 'sce': [ createScheduleString(requestCheckDelay * 2, requestCheckDelay * 2) ] } + }} + r, rsc = CREATE(f'{aeURL}/{subRN}', TestSCH.originator, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Update the AE to trigger a notification immediately + clearLastNotification() + dct = { 'm2m:ae' : { + 'lbl' : ['test'] + }} + r, rsc = UPDATE(aeURL, TestSCH.originator, dct) + self.assertEqual(rsc, RC.UPDATED, r) + + # Check notification + testSleep(requestCheckDelay) # wait a short time but run before the schedule starts + notification = getLastNotification() + self.assertIsNone(notification) # notification received + + # DELETE again + r, rsc = DELETE(f'{aeURL}/{subRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_testSCHunderSUBoutsideScheduleImmediate(self) -> None: + """ CREATE under and receive notification outside schedule, nec = immediate """ + # create + dct:JSON = { 'm2m:sub' : { + 'rn' : f'{subRN}', + 'enc': { + 'net': [ NotificationEventType.resourceUpdate ], + }, + 'nec': 2, # immediate notification + 'nu': [ NOTIFICATIONSERVER ] + }} + r, rsc = CREATE(aeURL, TestSCH.originator, T.SUB, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Add schedule + dct = { 'm2m:sch' : { + 'rn' : 'notificationSchedule', + 'se': { 'sce': [ createScheduleString(requestCheckDelay * 2, requestCheckDelay * 2) ] } + }} + r, rsc = CREATE(f'{aeURL}/{subRN}', TestSCH.originator, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Update the AE to trigger a notification immediately + clearLastNotification() + dct = { 'm2m:ae' : { + 'lbl' : ['test'] + }} + r, rsc = UPDATE(aeURL, TestSCH.originator, dct) + self.assertEqual(rsc, RC.UPDATED, r) + + # Check notification + testSleep(requestCheckDelay) # wait a short time but run before the schedule starts + notification = getLastNotification() + self.assertIsNotNone(notification) # notification received + + # DELETE again + r, rsc = DELETE(f'{aeURL}/{subRN}', ORIGINATOR) + self.assertEqual(rsc, RC.DELETED, r) + + + # + # Testing crossResourceSubscription with schedule + # + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_testSCHunderCRSinsideSchedule(self) -> None: + """ CREATE under and receive notification within schedule """ + # create + dct = { 'm2m:crs' : { + 'rn' : crsRN, + 'nu' : [ NOTIFICATIONSERVER ], + 'twt': 1, + 'eem': 1, # all events present + 'tws' : f'PT{requestCheckDelay}S', + 'rrat': [ self.aeRI], + 'encs': { + 'enc' : [ + { + 'net': [ NET.resourceUpdate ], + } + ] + } + }} + r, rsc = CREATE(aeURL, TestSCH.originator, T.CRS, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Add schedule + dct = { 'm2m:sch' : { + 'rn' : 'notificationSchedule', + 'se': { 'sce': [ createScheduleString(requestCheckDelay * 2) ] } + }} + r, rsc = CREATE(f'{aeURL}/{crsRN}', TestSCH.originator, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # # Update the AE to trigger a notification immediately + clearLastNotification() + dct = { 'm2m:ae' : { + 'lbl' : ['test'] + }} + r, rsc = UPDATE(aeURL, TestSCH.originator, dct) + self.assertEqual(rsc, RC.UPDATED, r) + + # # Check notification + testSleep(requestCheckDelay * 2) + notification = getLastNotification() + self.assertIsNotNone(notification) # notification received + + # DELETE again + r, rsc = DELETE(f'{aeURL}/{crsRN}', TestSCH.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_testSCHunderCRSoutsideScheduleFail(self) -> None: + """ CREATE under and receive notification outside schedule -> Fail """ + # create + dct = { 'm2m:crs' : { + 'rn' : crsRN, + 'nu' : [ NOTIFICATIONSERVER ], + 'twt': 1, + 'eem': 1, # all events present + 'tws' : f'PT{requestCheckDelay}S', + 'rrat': [ self.aeRI], + 'encs': { + 'enc' : [ + { + 'net': [ NET.resourceUpdate ], + } + ] + } + }} + r, rsc = CREATE(aeURL, TestSCH.originator, T.CRS, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Add schedule + dct = { 'm2m:sch' : { + 'rn' : 'notificationSchedule', + 'se': { 'sce': [ createScheduleString(requestCheckDelay * 2, requestCheckDelay * 4) ] } # outside time window + }} + r, rsc = CREATE(f'{aeURL}/{crsRN}', TestSCH.originator, T.SCH, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # # Update the AE to trigger a notification immediately, but outside schedule + clearLastNotification() + dct = { 'm2m:ae' : { + 'lbl' : ['test'] + }} + r, rsc = UPDATE(aeURL, TestSCH.originator, dct) + self.assertEqual(rsc, RC.UPDATED, r) + + # # Check notification + testSleep(requestCheckDelay * 2) + notification = getLastNotification() + self.assertIsNone(notification) # NO notification received + + # DELETE again + r, rsc = DELETE(f'{aeURL}/{crsRN}', TestSCH.originator) + self.assertEqual(rsc, RC.DELETED, r) def run(testFailFast:bool) -> Tuple[int, int, int, float]: @@ -180,9 +610,27 @@ def run(testFailFast:bool) -> Tuple[int, int, int, float]: addTest(suite, TestSCH('test_updateSCHunderCBwithNCOFail')) addTest(suite, TestSCH('test_updateSCHunderNODwithNOCUnsupportedFail')) + # testing for specific parent types + addTest(suite, TestSCH('test_createSCHunderSUBwrongRn')) + addTest(suite, TestSCH('test_createSCHunderSUBemptyRn')) + addTest(suite, TestSCH('test_createSCHunderSUBcorrectRn')) + addTest(suite, TestSCH('test_createSCHunderCRSwrongRn')) + addTest(suite, TestSCH('test_createSCHunderCRSemptyRn')) + addTest(suite, TestSCH('test_createSCHunderCRScorrectRn')) + addTest(suite, TestSCH('test_createSCHunderCB')) + addTest(suite, TestSCH('test_createSCHunderCBTwiceFail')) + addTest(suite, TestSCH('test_createSCHunderNOD')) + + # testing subscriptions with schedule + addTest(suite, TestSCH('test_testSCHunderSUBinsideSchedule')) + addTest(suite, TestSCH('test_testSCHunderSUBoutsideSchedule')) + addTest(suite, TestSCH('test_testSCHunderSUBoutsideScheduleImmediate')) + + # testing crossResourceSubscription with schedule + addTest(suite, TestSCH('test_testSCHunderCRSinsideSchedule')) + addTest(suite, TestSCH('test_testSCHunderCRSoutsideScheduleFail')) result = unittest.TextTestRunner(verbosity = testVerbosity, failfast = testFailFast).run(suite) - printResult(result) return result.testsRun, len(result.errors + result.failures), len(result.skipped), getSleepTimeCount() diff --git a/tests/testSUB.py b/tests/testSUB.py index a5fddcb3..e40daa75 100644 --- a/tests/testSUB.py +++ b/tests/testSUB.py @@ -313,8 +313,8 @@ def test_deleteSUBByUnknownOriginator(self) -> None: @unittest.skipIf(noCSE, 'No CSEBase') def test_deleteSUBByAssignedOriginator(self) -> None: """ DELETE with correct originator -> Succeed. Send deletion notification. """ - _, rsc = DELETE(subURL, TestSUB.originator) - self.assertEqual(rsc, RC.DELETED) + r, rsc = DELETE(subURL, TestSUB.originator) + self.assertEqual(rsc, RC.DELETED, r) lastNotification = getLastNotification() # no delay! blocking self.assertTrue(findXPath(lastNotification, 'm2m:sgn/sud')) @@ -1596,8 +1596,8 @@ def test_createSUBnoNCTwrongNETFail(self) -> None: 'su': NOTIFICATIONSERVER, 'nse': True }} - r, rsc = CREATE(self.aePOAURL, TestSUB.originatorPoa, T.SUB, dct) - self.assertEqual(rsc, RC.BAD_REQUEST) + r, rsc = CREATE(aeURL, TestSUB.originator, T.SUB, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) # From 6fe0a0857997ca6054aa8f8b415b344d1291efa8 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 13 Jul 2023 12:04:38 +0200 Subject: [PATCH 046/165] Reduced hight of intermediate headers to 1 line --- acme/textui/ACMEContainerDelete.py | 2 +- acme/textui/ACMEContainerRequests.py | 4 ++-- acme/textui/ACMEHeader.py | 5 ++--- acme/textui/ACMETuiApp.py | 19 ++++++++++++------- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/acme/textui/ACMEContainerDelete.py b/acme/textui/ACMEContainerDelete.py index 14e28304..7aa3a46d 100644 --- a/acme/textui/ACMEContainerDelete.py +++ b/acme/textui/ACMEContainerDelete.py @@ -49,7 +49,7 @@ class ACMEContainerDelete(Container): width: 1fr; display: block; overflow: auto; - height: 3; + height: 1; content-align: center middle; background: $panel; } diff --git a/acme/textui/ACMEContainerRequests.py b/acme/textui/ACMEContainerRequests.py index 8899e6a4..d8a1a01a 100644 --- a/acme/textui/ACMEContainerRequests.py +++ b/acme/textui/ACMEContainerRequests.py @@ -82,7 +82,7 @@ class ACMEViewRequests(Vertical): #request-list-header { /* overflow: auto hidden; */ width: 1fr; - height: 3; + height: 1; align-vertical: middle; background: $panel; } @@ -95,7 +95,7 @@ class ACMEViewRequests(Vertical): #request-list-details-header { overflow: auto; - height: 3; + height: 1; align-vertical: middle; background: $panel; } diff --git a/acme/textui/ACMEHeader.py b/acme/textui/ACMEHeader.py index 11c1ede9..3b554a4e 100644 --- a/acme/textui/ACMEHeader.py +++ b/acme/textui/ACMEHeader.py @@ -6,8 +6,6 @@ # """ This module defines the header for the ACME text UI. """ -from datetime import datetime, timezone - from rich.text import Text from textual.app import ComposeResult, RenderResult from textual.widgets import Header, Label @@ -17,6 +15,7 @@ from ..services import CSE from ..etc.Constants import Constants from ..etc.DateUtils import toISO8601Date +from ..etc.DateUtils import utcDatetime class ACMEHeaderClock(HeaderClock): @@ -39,7 +38,7 @@ def render(self) -> RenderResult: Returns: The rendered clock. """ - return Text(f'{toISO8601Date(datetime.now(tz = timezone.utc), readable = True)[:19]} UTC') + return Text(f'{toISO8601Date(utcDatetime(), readable = True)[:19]} UTC') class ACMEHeaderTitle(HeaderTitle): diff --git a/acme/textui/ACMETuiApp.py b/acme/textui/ACMETuiApp.py index 6b30846b..599770cb 100644 --- a/acme/textui/ACMETuiApp.py +++ b/acme/textui/ACMETuiApp.py @@ -184,27 +184,32 @@ def logDebug(self, msg:str) -> None: def scriptPrint(self, scriptName:str, msg:str) -> None: - self.containerTools.scriptPrint(scriptName, msg) + if self.containerTools: + self.containerTools.scriptPrint(scriptName, msg) def scriptLog(self, scriptName:str, msg:str) -> None: - self.containerTools.scriptLog(scriptName, msg) + if self.containerTools: + self.containerTools.scriptLog(scriptName, msg) def scriptLogError(self, scriptName:str, msg:str) -> None: - self.containerTools.scriptLogError(scriptName, msg) + if self.containerTools: + self.containerTools.scriptLogError(scriptName, msg) def scriptClearConsole(self, scriptName:str) -> None: - self.containerTools.scriptClearConsole(scriptName) + if self.containerTools: + self.containerTools.scriptClearConsole(scriptName) def scriptVisualBell(self, scriptName:str) -> None: - BackgroundWorkerPool.runJob(lambda:self.containerTools.scriptVisualBell(scriptName)) - # self.containerTools.scriptVisualBell(scriptName) + if self.containerTools: + BackgroundWorkerPool.runJob(lambda:self.containerTools.scriptVisualBell(scriptName)) def refreshResources(self) -> None: - self.containerTree.update() + if self.containerTree: + self.containerTree.update() ######################################################################### From f6e3aa79a2ffc5a6744da58e4ca1ee89b5e6acb8 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 13 Jul 2023 14:51:18 +0200 Subject: [PATCH 047/165] Make TS.mdc mandatory (see SDS-2023-0095) --- acme/resources/TS.py | 2 ++ init/attributePolicies.ap | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/acme/resources/TS.py b/acme/resources/TS.py index 71b7b616..2471b848 100644 --- a/acme/resources/TS.py +++ b/acme/resources/TS.py @@ -365,6 +365,8 @@ def _validateDataDetect(self, updatedAttributes:Optional[JSON] = None) -> None: # Always set the mdc to the length of mdlt if present if self.mdlt is not None: self.setAttribute('mdc', len(self.mdlt)) + else: + self.setAttribute('mdc', 0) # Save changes self.dbUpdate(True) diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index 8b1a0513..f946d4d0 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -1719,7 +1719,7 @@ "lname": "missingDataCurrentNr", "ns": "m2m", "type": "nonNegInteger", - "car": "01", + "car": "1", "oc": "NP", "ou": "NP", "od": "O", From 227b0f205d8f4e5e06057b39db556a652b2e7a45 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 14 Jul 2023 16:03:04 +0200 Subject: [PATCH 048/165] Added "dotimes" function to the script interpreter. --- CHANGELOG.md | 1 + acme/helpers/Interpreter.py | 75 ++++++++++++++++++++++++++++ docs/ACMEScript-functions.md | 95 ++++++++++++++++++++++++------------ 3 files changed, 139 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a0b56a..c6edf6b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - [CSE] Added automatic pip install of missing dependencies during startup. - [CSE] Added support for <schedule> resource type. +- [SCRIPTS] Added "dotimes" function to the script interpreter. ### Experimental diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index 7474abd7..3594ac85 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -1376,6 +1376,7 @@ def _executeExpression(self, symbol:SSymbol, parentSymbol:SSymbol) -> PContext: Args: symbol: The symbol to execute. + parentSymbol: The parent symbol of the symbol to execute. Return: The updated `PContext` object with the result. @@ -1946,6 +1947,79 @@ def _doDefun(pcontext:PContext, symbol:SSymbol) -> PContext: return pcontext +def _doDotimes(pcontext:PContext, symbol:SSymbol) -> PContext: + """ This function executes a code block a number of times. + + The first argument is a list that contains the loop counter symbol and the + loop limit. An optional third argument is the result variable for the loop. + The second argument is the code block to execute. + + Example: + :: + + (dotimes (i 10) (print i)) + (dotimes (i 10 result) (setq result i)) + + Args: + pcontext: Current `PContext` for the script. + symbol: The symbol to execute. + + Return: + The updated `PContext` object. The result + + """ + pcontext.assertSymbol(symbol, 3) + + # arguments + pcontext, _arguments = pcontext.valueFromArgument(symbol, 1, SType.tList, doEval = False) # don't evaluate the argument + if 2 <= len(_arguments) <= 3: + # get loop variable + _loopvar = cast(SSymbol, _arguments[0]) + if _loopvar.type != SType.tSymbol: + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'dotimes "counter" must be a symbol, got: {pcontext.result.type}')) + + # get loop count + pcontext = pcontext._executeExpression(_arguments[1], _arguments) + if pcontext.result.type != SType.tNumber: + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'dotimes "count" must be a number, got: {pcontext.result.type}')) + _loopcount = pcontext.result + if int(_loopcount.value) < 0: # type:ignore[arg-type] + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'dotimes "count" must be a non-negative number, got: {_loopcount.value}')) + else: + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'dotimes first argument requires 2 or 3 arguments, got: {len(_arguments)}')) + + # Get result variable name + if len(_arguments) == 3: + _resultvar = cast(SSymbol, _arguments[2]) + if _resultvar.type != SType.tSymbol: + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'dotimes "result" must be a symbol, got: {pcontext.result.type}')) + + # if the variable does not exist, create it as a nil symbol + if not str(_resultvar) in pcontext.variables: + pcontext.variables[str(_resultvar)] = SSymbol() + else: + _resultvar = None + + # code + pcontext, _code = pcontext.valueFromArgument(symbol, 2, SType.tList, doEval = False) # don't evaluate the argument (yet) + _code = SSymbol(lst = _code) # We got a python list, but must have a SSymbol list + + # execute the code + pcontext.variables[str(_loopvar)] = SSymbol(number = Decimal(0)) + for i in range(0, int(cast(Decimal, _loopcount.value))): + pcontext.variables[str(_loopvar)] = SSymbol(number = Decimal(i)) + pcontext = pcontext._executeExpression(_code, symbol) + + # set the result + if _resultvar: + pcontext.result = pcontext.variables[str(_resultvar)] + else: + pcontext.result = SSymbol() + + # return + return pcontext + + def _doError(pcontext:PContext, symbol:SSymbol) -> PContext: """ End script execution with an error. The optional argument will be assigned as the result of the script (pcontext.result). @@ -3288,6 +3362,7 @@ def _doWhile(pcontext:PContext, symbol:SSymbol) -> PContext: 'datetime': _doDatetime, 'dec': lambda p, a: _doIncDec(p, a, False), 'defun': _doDefun, + 'dotimes': _doDotimes, 'eval': _doEval, 'evaluate-inline': _doEvaluateInline, 'false': lambda p, a: _doBoolean(p, a, False), diff --git a/docs/ACMEScript-functions.md b/docs/ACMEScript-functions.md index 5d1a22ca..9b5f372f 100644 --- a/docs/ACMEScript-functions.md +++ b/docs/ACMEScript-functions.md @@ -20,6 +20,7 @@ The following built-in functions and variables are provided by the ACMEScript in | | [datetime](#datetime) | Return a timestamp | | | [defun](#defun) | Define a function | | | [dec](#dec) | Decrement a variable | +| | [dotimes](#dotimes) | Simple loop over an s-expression | | | [eval](#eval) | Evaluate and execute a quoted list | | | [evaluate-inline](#evaluate-inline) | Enable and disable inline string evaluation | | | [get-json-attribute](#get-json-attribute) | Get a JSON attribute from a JSON structure | @@ -234,7 +235,7 @@ The `case` function implements the functionality of a `switch...case` statement The *key* s-expression is evaluated and its value taken for the following comparisons. After this expression a number of lists may be given. -Each of these list contains two symbols that are handled in order: The first symbol evaluates to a value that is compared to the result of the *key* s-expression. If there is a match then the second s-exprersion is evaluated, and then the comparisons are stopped and the *case* function returns. +Each of these list contains two symbols that are handled in order: The first symbol evaluates to a value that is compared to the result of the *key* s-expression. If there is a match then the second s-expression is evaluated, and then the comparisons are stopped and the *case* function returns. The special symbol *otherwise* for a *condition* s-expression always matches and can be used as a default or fallback case . @@ -314,30 +315,6 @@ Example: --- - - -### dec - -`(dec [])` - -The `dec` function decrements a provided variable. The default for the increment is 1, but can be given as an optional second argument. If this argument is provided then the variable is decemented by this value. The value can be an integer or a float. - -The function returns the variable's new value. - -See also: [inc](#inc) - -Example: - -```lisp -(setq a 1) ;; Set variable "a" to 1 -(dec a) ;; Decrement variable "a" by 1 -(dec a 2.5) ;; Decrement variable "a" by 2.5 -``` - -[top](#top) - ---- - ### defun @@ -377,6 +354,60 @@ Examples: --- + + +### dec + +`(dec [])` + +The `dec` function decrements a provided variable. The default for the increment is 1, but can be given as an optional second argument. If this argument is provided then the variable is decremented by this value. The value can be an integer or a float. + +The function returns the variable's new value. + +See also: [inc](#inc) + +Example: + +```lisp +(setq a 1) ;; Set variable "a" to 1 +(dec a) ;; Decrement variable "a" by 1 +(dec a 2.5) ;; Decrement variable "a" by 2.5 +``` + +[top](#top) + +--- + + + +### dotimes + +`(dotimes ( []) (+))` + +The `dotimes` function provides a simple loop functionality. +The first arguments is a list that contains a loop variable that starts at 0, the loop `count` (which must be a non-negative number), and an optional +`result` variable. The second argument is a list that contains one or more s-expressions that are executed in the loop. + +If the `result variable` is specified then the loop returns the value of that variable, otherwise `nil`. + +See also: [while](#while) + +Example: + +```lisp +(dotimes (i 10) + (print i)) ;; print 1..10 + +(setq result 0) +(dotimes (i 10 result) + (setq result (+ result i))) ;; sum 1..10 +(print result) ;; 45 +``` + +[top](#top) + +--- + ### eval @@ -510,7 +541,7 @@ Example: `(inc [])` -The `inc` function increments a provided variable. The default for the increment is 1, but can be given as an optional second argument. If this argument is provided then the variable is incemented by this value. The value can be an integer or a float. +The `inc` function increments a provided variable. The default for the increment is 1, but can be given as an optional second argument. If this argument is provided then the variable is incremented by this value. The value can be an integer or a float. The function returns the variable's new value. @@ -1030,8 +1061,8 @@ Example: `(round [])` -The `round` function rounds a number to *precission* digits after the decimal point. The default is 0, meaning to round to nearest integer. - +The `round` function rounds a number to *precision* digits after the decimal point. The default is 0, meaning to round to nearest integer. + Example: ```lisp @@ -1092,7 +1123,7 @@ Example: `(sleep )` -The `sleep` function adds a delay to the script execution. The evaludation stops for a number of seconds. The delay could be provided as an integer or float number. +The `sleep` function adds a delay to the script execution. The evaluation stops for a number of seconds. The delay could be provided as an integer or float number. If the script execution timeouts during a sleep, the function is interrupted and all subsequent s-expressions are not evaluated. @@ -1116,7 +1147,7 @@ Example: The `slice` function returns the slice of a list or a string. -The behaviour is the same as slicing in Python, except that both *start* and *end* must be provided. The first argument is the *start* (including) of the slice, the second is the *end* (exlcuding) of the slice. The fourth argument is the list or string to slice. +The behavior is the same as slicing in Python, except that both *start* and *end* must be provided. The first argument is the *start* (including) of the slice, the second is the *end* (excluding) of the slice. The fourth argument is the list or string to slice. Example: @@ -1283,7 +1314,7 @@ A `while` loop continues to run when the first *guard* s-expression evaluates to The `while` function returns the result of the last evaluated s-expression in the *body*. -See also: [return](#return) +See also: [dotime](#dotimes), [return](#return) Example: @@ -1559,7 +1590,7 @@ Example: `(log-divider [])` -The `log-divider` function inserts a divider line in the CSE's *DEBUG* log. It can help to easily identifiy the different sections when working with many requests. An optional (short) message can be provided in the argument. +The `log-divider` function inserts a divider line in the CSE's *DEBUG* log. It can help to easily identify the different sections when working with many requests. An optional (short) message can be provided in the argument. Examples: From b4b8a2da32c5aec978fbfad835405e37ac9814ba Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 17 Jul 2023 15:09:41 +0200 Subject: [PATCH 049/165] Moved some if...elif...else constructs to match...case --- acme/helpers/Interpreter.py | 64 +++++----- acme/helpers/UDPServer.py | 243 ++++++++++++++++++++++++++++++++++++ acme/resources/AE.py | 25 ++-- 3 files changed, 290 insertions(+), 42 deletions(-) create mode 100644 acme/helpers/UDPServer.py diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index 3594ac85..14cbd94a 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -358,25 +358,27 @@ def __contains__(self, obj:Any) -> bool: def toString(self, quoteStrings:bool = False, pythonList:bool = False) -> str: - if self.type in [ SType.tList, SType.tListQuote ]: - # Set the list chars - lchar1 = '[' if pythonList else '(' - lchar2 = ']' if pythonList else ')' - return f'{lchar1} {" ".join(lchar1 if v == "[" else lchar2 if v == "]" else v.toString(quoteStrings = quoteStrings, pythonList = pythonList) for v in cast(list, self.value))} {lchar2}' - # return f'( {" ".join(str(v) for v in cast(list, self.value))} )' - elif self.type == SType.tLambda: - return f'( ( {", ".join(v.toString(quoteStrings = quoteStrings, pythonList = pythonList) for v in cast(tuple, self.value)[0])} ) {str(cast(tuple, self.value)[1])} )' - elif self.type == SType.tBool: - return str(self.value).lower() - elif self.type == SType.tString: - if quoteStrings: - return f'"{str(self.value)}"' - return str(self.value) - elif self.type == SType.tJson: - return json.dumps(self.value) - elif self.type == SType.tNIL: - return 'nil' - return str(self.value) + match self.type: + case SType.tList | SType.tListQuote: + # Set the list chars + lchar1 = '[' if pythonList else '(' + lchar2 = ']' if pythonList else ')' + return f'{lchar1} {" ".join(lchar1 if v == "[" else lchar2 if v == "]" else v.toString(quoteStrings = quoteStrings, pythonList = pythonList) for v in cast(list, self.value))} {lchar2}' + # return f'( {" ".join(str(v) for v in cast(list, self.value))} )' + case SType.tLambda: + return f'( ( {", ".join(v.toString(quoteStrings = quoteStrings, pythonList = pythonList) for v in cast(tuple, self.value)[0])} ) {str(cast(tuple, self.value)[1])} )' + case SType.tBool: + return str(self.value).lower() + case SType.tString: + if quoteStrings: + return f'"{str(self.value)}"' + return str(self.value) + case SType.tJson: + return json.dumps(self.value) + case SType.tNIL: + return 'nil' + case _: + return str(self.value) def append(self, arg:SSymbol) -> SSymbol: @@ -2112,17 +2114,19 @@ def _doGetJSONAttribute(pcontext:PContext, symbol:SSymbol) -> PContext: """ def _toSymbol(value:Any) -> SSymbol: - if isinstance(value, str): - return SSymbol(string = value) - elif isinstance(value, (int, float)): - return SSymbol(number = Decimal(value)) - elif isinstance(value, dict): - return SSymbol(jsn = value) - elif isinstance(value, bool): - return SSymbol(boolean = value) - elif isinstance(value, list): - return SSymbol(lst = [ _toSymbol(l) for l in value]) - return SSymbol() # nil + match value: + case str(): + return SSymbol(string = value) + case int(), float(): + return SSymbol(number = Decimal(value)) + case dict(): + return SSymbol(jsn = value) + case bool(): + return SSymbol(boolean = value) + case list(): + return SSymbol(lst = [ _toSymbol(l) for l in value]) + case _: + return SSymbol() # nil pcontext.assertSymbol(symbol, 3) diff --git a/acme/helpers/UDPServer.py b/acme/helpers/UDPServer.py new file mode 100644 index 00000000..94615e8b --- /dev/null +++ b/acme/helpers/UDPServer.py @@ -0,0 +1,243 @@ +# +# UdpServer.py +# +# (c) 2023 by Andreas Kraft, Yann Garcia +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# This module contains various utilty functions that are used from various +# modules and entities of the CSE. +# + +import threading +from typing import Callable, Any, Tuple +import socket +# Dtls +import ssl +from dtls.wrapper import wrap_server, wrap_client, DtlsSocket +import dtls.sslconnection as sslconnection + +from ..helpers.BackgroundWorker import BackgroundWorkerPool + +class UdpServer(object): + + __slots__ = ( + 'addr', + 'port', + 'socket', + 'listen_socket', + 'doListen', + 'received_data_callback', + 'useTLS', + 'verifyCertificate', + 'tlsVersion', + 'ssl_version', + 'privateKeyFile', + 'certificateFile', + 'privateKeyFile', + 'certificateFile', + 'logging', + 'ssl_ctx', + 'mtu' + ) + + def __init__(self, server_address:str, + port:str, + useDTLS:bool, + tlsVersion:str, + verifyCertificate:bool, + privateKeyFile:str, + certificateFile:str, + received_data_callback:Callable, + logging:Callable) -> None: + self.addr = server_address + self.port = port + self.socket:socket.socket = None # Client socket + self.listen_socket:socket.socket = None # Server socket + self.doListen = False + self.received_data_callback = received_data_callback + self.useTLS = useDTLS + self.tlsVersion = tlsVersion + self.ssl_version = { 'tls1.1': sslconnection.PROTOCOL_DTLSv1, + 'tls1.2': sslconnection.PROTOCOL_DTLSv1_2, + 'auto': sslconnection.PROTOCOL_DTLS }[self.tlsVersion] + self.verifyCertificate = verifyCertificate + + self.privateKeyFile = privateKeyFile + self.certificateFile = certificateFile + self.logging = logging + self.ssl_ctx:DtlsSocket = None + self.mtu = 512 #1500 TODO configurable + + + def listen(self, timeout:int = 5) -> None: # This does NOT return + self.listen_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + self.listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + + def _listen(listenSocket:Tuple[socket.socket, DtlsSocket]) -> None: + self.doListen = True + while self.doListen: + self.logging(f'UdpServer.listen: In loop: {str(self.doListen)}') + try: + data, client_address = listenSocket.recvfrom(4096) + self.logging(f'UdpServer.listen: client_address: {str(client_address)}') + if len(client_address) > 2: + client_address = (client_address[0], client_address[1]) + self.logging(f'UdpServer.listen: receive_datagram (1) - {str(data)}') + if data is not None: + self.logging(f'UdpServer.listen: receive_datagram - - {str(data)}') + BackgroundWorkerPool.runJob(lambda : self.received_data_callback(data, client_address), f'CoAP_{str(client_address)}') # TODO a better thread name + # t = threading.Thread(target=self.received_data_callback, args=(data, client_address)) + # t.setDaemon(True) + # t.start() + except socket.timeout: + continue + except Exception as e: + self.logging(f'UdpServer.listen (secure): {str(e)}') + continue + + + if self.useTLS == True: + + # Setup DTLS context + self.logging(f'Setup SSL context. Certfile: {self.certificateFile}, KeyFile: {self.privateKeyFile}, TLS version: {self.tlsVersion}') + self.ssl_ctx = wrap_server( + self.listen_socket, + keyfile = self.privateKeyFile, + certfile = self.certificateFile, + cert_reqs = ssl.CERT_NONE if self.verifyCertificate == False else ssl.CERT_REQUIRED, + ssl_version = self.ssl_version, + #ca_certs=self.caCertificateFile, + do_handshake_on_connect = True, + user_mtu = self.mtu, + ssl_logging = True, + cb_ignore_ssl_exception_in_handshake = None, + cb_ignore_ssl_exception_read = None, + cb_ignore_ssl_exception_write = None) + + # Initialize and start listening + self.ssl_ctx.bind((self.addr, self.port)) + self.ssl_ctx.settimeout(timeout) + self.ssl_ctx.listen(0) + _listen(self.ssl_ctx) # Does not return + # self.doListen = True + # while self.doListen: + # self.logging(f'UdpServer.listen: In loop: {str(self.doListen)}') + # try: + # data, client_address = self.ssl_ctx.recvfrom(4096) + # self.logging(f'UdpServer.listen: client_address: {str(client_address)}') + # if len(client_address) > 2: + # client_address = (client_address[0], client_address[1]) + # self.logging(f'UdpServer.listen: receive_datagram (1) - {str(data)}') + # if not data is None: + # self.logging(f'UdpServer.listen: receive_datagram - - {str(data)}') + # BackgroundWorkerPool.runJob(lambda : self.received_data_callback(data, client_address), f'CoAP_{str(client_address)}') # TODO a better thread name + # # t = threading.Thread(target=self.received_data_callback, args=(data, client_address)) + # # t.setDaemon(True) + # # t.start() + # except socket.timeout: + # continue + # except Exception as e: + # self.logging(f'UdpServer.listen (secure): {str(e)}') + # continue + + else: + # Initialize and start listening (non-secure) + self.listen_socket.bind((self.addr, self.port)) + self.listen_socket.settimeout(timeout) + _listen(self.listen_socket) # Does not return + + # self.doListen = True + # while self.doListen: + # try: + # data, client_address = self.listen_socket.recvfrom(4096) + # if len(client_address) > 2: + # client_address = (client_address[0], client_address[1]) + # Logging.log(f'UdpServer.listen: receive_datagram - {str(data)}') + # t = threading.Thread(target=self.received_data_callback, args=(data, client_address)) + # t.setDaemon(True) + # t.start() + # except socket.timeout: + # continue + # except Exception as e: + # Logging.logWarn(f'UdpServer.listen: {str(e)}') + # break + + + # # def _cb_ignore_listen_exception(self, exception, server): + # """ + # In the CoAP server listen method, different exceptions can arise from the DTLS stack. Depending on the type of exception, a + # continuation might not be possible, or a logging might be desirable. With this callback both needs can be satisfied. + # :param exception: What happened inside the DTLS stack + # :param server: Reference to the running CoAP server + # :return: True if further processing should be done, False processing should be stopped + # """ + # Logging.log('>>> UdpServer.listen: _cb_ignore_listen_exception: ' + str(exception)) + # if isinstance(exception, ssl.SSLError): + # # A client which couldn't verify the server tried to connect, continue but log the event + # if exception.errqueue[-1][0] == ssl.ERR_TLSV1_ALERT_UNKNOWN_CA: + # Logging.logWarn("Ignoring ERR_TLSV1_ALERT_UNKNOWN_CA from client %s" % ('unknown' if not hasattr(exception, 'peer') else str(exception.peer))) + # return True + # # ... and more ... + # return False + + # def _cb_ignore_write_exception(self, exception, client): + # """ + # In the CoAP client write method, different exceptions can arise from the DTLS stack. Depending on the type of exception, a + # continuation might not be possible, or a logging might be desirable. With this callback both needs can be satisfied. + # note: Default behaviour of CoAPthon without DTLS if no _cb_ignore_write_exception would be called is with "return True" + # :param exception: What happened inside the DTLS stack + # :param client: Reference to the running CoAP client + # :return: True if further processing should be done, False processing should be stopped + # """ + # Logging.log('>>> UdpServer.listen: _cb_ignore_write_exception: ' + str(exception)) + # return False + + # def _cb_ignore_read_exception(self, exception, client) -> bool: + # """ In the CoAP client read method, different exceptions can arise from the DTLS stack. Depending on the type of exception, a + # continuation might not be possible, or a logging might be desirable. With this callback both needs can be satisfied. + # note: Default behaviour of CoAPthon without DTLS if no _cb_ignore_read_exception would be called is with "return False" + + # Args: + # exception: What happened inside the DTLS stack. + # client: Reference to the running CoAP client. + + # Returns: + # True if further processing should be done, False processing should be stopped + # """ + # Logging.log('>>> UdpServer.listen: _cb_ignore_read_exception: ' + str(exception)) + # return False + +# def send(self, p_coapMessage:CoapMessageResponse) -> None: +# self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +# self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + def close(self) -> None: + self.doListen = False + if self.listen_socket: + if self.ssl_ctx: + self.ssl_ctx.unwrap() + self.listen_socket.close() + self.ssl_ctx = None + self.listen_socket = None + if self.socket: + self.socket.close() + self.socket = None + + + def sendTo(self, datagram): + self.logging(f'==> UdpServer.sendTo: /{str(datagram[0])} - {str(datagram[1])}') + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + if self.useTLS == True: + sock = wrap_client(sock, cert_reqs = ssl.CERT_REQUIRED, + keyfile = self.privateKeyFile, + certfile = self.certificateFile, + ca_certs = self.caCertificateFile, + do_handshake_on_connect = True, + ssl_version = self.ssl_version) + sock.sendto(datagram[0], datagram[1]) + except Exception as e: + self.logging(f'UdpServer.sendTo: {str(e)}') + finally: + sock.close() diff --git a/acme/resources/AE.py b/acme/resources/AE.py index 462fc501..4ff43236 100644 --- a/acme/resources/AE.py +++ b/acme/resources/AE.py @@ -140,18 +140,19 @@ def validate(self, originator:Optional[str] = None, # check api attribute if not (api := self['api']) or len(api) < 2: # at least R|N + another char raise BAD_REQUEST('missing or empty attribute: "api"') - if api.startswith('N'): - pass # simple format - elif api.startswith('R'): - if len(api.split('.')) < 3: - raise BAD_REQUEST('wrong format for registered ID in attribute "api": to few elements') - - # api must normally begin with a lower-case "r", but it is allowed for release 2a and 3 - elif api.startswith('r'): - if (rvi := self.getRVI()) is not None and rvi not in ['2a', '3']: - raise BAD_REQUEST(L.logWarn('lower case "r" is only allowed for release versions "2a" and "3"')) - else: - raise BAD_REQUEST(L.logWarn(f'wrong format for ID in attribute "api": {api} (must start with "R" or "N")')) + + match api: + case x if x.startswith('N'): + pass # simple format + case x if x.startswith('R'): + if len(x.split('.')) < 3: + raise BAD_REQUEST('wrong format for registered ID in attribute "api": to few elements') + # api must normally begin with a lower-case "r", but it is allowed for release 2a and 3 + case x if x.startswith('r'): + if (rvi := self.getRVI()) is not None and rvi not in ['2a', '3']: + raise BAD_REQUEST(L.logWarn('lower case "r" is only allowed for release versions "2a" and "3"')) + case _: + raise BAD_REQUEST(L.logWarn(f'wrong format for ID in attribute "api": {api} (must start with "R" or "N")')) def deactivate(self, originator:str) -> None: From 8659e6b36b4b41cd4885f81c1843980bdfb42a08 Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 17 Jul 2023 15:10:21 +0200 Subject: [PATCH 050/165] Changed mypy's python version to 3.10 --- mypy.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypy.ini b/mypy.ini index e6d844a1..1df759ec 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.8 +python_version = 3.10 #mypy_path = acme files = acme/__main__.py,tests/*.py,tools/notificationServer/notificationServer.py disallow_untyped_calls = true From d910b4cf43ee029f974641cfd73bb23acde1d250 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 18 Jul 2023 11:38:23 +0200 Subject: [PATCH 051/165] Converted more elif to match --- acme/__main__.py | 48 ++++----- acme/etc/RequestUtils.py | 26 ++--- acme/etc/Types.py | 39 ++++---- acme/services/Logging.py | 37 +++---- acme/services/RegistrationManager.py | 112 ++++++++++----------- acme/services/ScriptManager.py | 141 +++++++++++++-------------- 6 files changed, 202 insertions(+), 201 deletions(-) diff --git a/acme/__main__.py b/acme/__main__.py index 327bd7ee..ccb60fb7 100644 --- a/acme/__main__.py +++ b/acme/__main__.py @@ -24,30 +24,32 @@ if 'ACME_DEBUG' in os.environ: raise e - # Give hint to run ACME as a module - if 'attempted relative import' in e.msg: - print(f'\nPlease run acme as a package:\n\n\t{sys.executable} -m {sys.argv[0]} [arguments]\n') + match e.msg: + # Give hint to run ACME as a module + case x if 'attempted relative import' in x: + print(f'\nPlease run acme as a package:\n\n\t{sys.executable} -m {sys.argv[0]} [arguments]\n') - # Give hint how to do the installation - elif 'No module named' in e.msg: - m = re.search("'(.+?)'", e.msg) - package = f' ({m.group(1)}) ' if m else ' ' - print(f'\nOne or more required packages or modules{package}could not be found.\nPlease install the missing packages, e.g. by running the following command:\n\n\t{sys.executable} -m pip install -r requirements.txt\n') - - # Ask if the user wants to install the missing packages - try: - if input('\nDo you want to install the missing packages now? [y/N] ') in ['y', 'Y']: - import os - os.system(f'{sys.executable} -m pip install -r requirements.txt') - - # Ask if the user wants to start ACME - if input('\nDo you want to start ACME now? [Y/n] ') in ['y', 'Y', '']: - os.system(f'{sys.executable} -m acme {" ".join(sys.argv[1:])}') - - except Exception as e2: - print(f'\nError during installation: {e2}\n') - else: - print(f'\nError during import: {e.msg}\n') + # Give hint how to do the installation + case x if 'No module named' in x: + m = re.search("'(.+?)'", e.msg) + package = f' ({m.group(1)}) ' if m else ' ' + print(f'\nOne or more required packages or modules{package}could not be found.\nPlease install the missing packages, e.g. by running the following command:\n\n\t{sys.executable} -m pip install -r requirements.txt\n') + + # Ask if the user wants to install the missing packages + try: + if input('\nDo you want to install the missing packages now? [y/N] ') in ['y', 'Y']: + import os + os.system(f'{sys.executable} -m pip install -r requirements.txt') + + # Ask if the user wants to start ACME + if input('\nDo you want to start ACME now? [Y/n] ') in ['y', 'Y', '']: + os.system(f'{sys.executable} -m acme {" ".join(sys.argv[1:])}') + + except Exception as e2: + print(f'\nError during installation: {e2}\n') + + case _: + print(f'\nError during import: {e.msg}\n') quit(1) diff --git a/acme/etc/RequestUtils.py b/acme/etc/RequestUtils.py index f654d336..ca76b842 100644 --- a/acme/etc/RequestUtils.py +++ b/acme/etc/RequestUtils.py @@ -50,11 +50,13 @@ def deserializeData(data:bytes, ct:ContentSerializationType) -> Optional[JSON]: """ if len(data) == 0: return {} - if ct == ContentSerializationType.JSON: - return cast(JSON, json.loads(TextTools.removeCommentsFromJSON(data.decode('utf-8')))) - elif ct == ContentSerializationType.CBOR: - return cast(JSON, cbor2.loads(data)) - return None + match ct: + case ContentSerializationType.JSON: + return cast(JSON, json.loads(TextTools.removeCommentsFromJSON(data.decode('utf-8')))) + case ContentSerializationType.CBOR: + return cast(JSON, cbor2.loads(data)) + case _: + return None def toHttpUrl(url:str) -> str: @@ -67,12 +69,14 @@ def toHttpUrl(url:str) -> str: A valid URL with escaped special characters. """ u = list(urlparse(url)) - if u[2].startswith('///'): - u[2] = f'/_{u[2][2:]}' - url = urlunparse(u) - elif u[2].startswith('//'): - u[2] = f'/~{u[2][1:]}' - url = urlunparse(u) + match u[2]: + case x if x.startswith('///'): + u[2] = f'/_{u[2][2:]}' + url = urlunparse(u) + case x if x.startswith('//'): + u[2] = f'/~{u[2][1:]}' + url = urlunparse(u) + return url diff --git a/acme/etc/Types.py b/acme/etc/Types.py index 18407bdd..c4375e8d 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -1208,24 +1208,27 @@ def isAllowedNCT(self, nct:NotificationContentType) -> bool: Return: True if the NotificationEventType is allowed for the NotificationContentType. """ - if nct == NotificationContentType.allAttributes: - return self.value in [ NotificationEventType.resourceUpdate, - NotificationEventType.resourceDelete, - NotificationEventType.createDirectChild, - NotificationEventType.deleteDirectChild ] - elif nct == NotificationContentType.modifiedAttributes: - return self.value in [ NotificationEventType.resourceUpdate, - NotificationEventType.blockingUpdate ] - elif nct == NotificationContentType.ri: - return self.value in [ NotificationEventType.resourceUpdate, - NotificationEventType.resourceDelete, - NotificationEventType.createDirectChild, - NotificationEventType.deleteDirectChild ] - elif nct == NotificationContentType.triggerPayload: - return self.value in [ NotificationEventType.triggerReceivedForAE ] - elif nct == NotificationContentType.timeSeriesNotification: - return self.value in [ NotificationEventType.reportOnGeneratedMissingDataPoints ] - return False + match nct: + case NotificationContentType.allAttributes: + return self.value in [ NotificationEventType.resourceUpdate, + NotificationEventType.resourceDelete, + NotificationEventType.createDirectChild, + NotificationEventType.deleteDirectChild ] + case NotificationContentType.modifiedAttributes: + return self.value in [ NotificationEventType.resourceUpdate, + NotificationEventType.blockingUpdate ] + case NotificationContentType.ri: + return self.value in [ NotificationEventType.resourceUpdate, + NotificationEventType.resourceDelete, + NotificationEventType.createDirectChild, + NotificationEventType.deleteDirectChild ] + case NotificationContentType.triggerPayload: + return self.value in [ NotificationEventType.triggerReceivedForAE ] + case NotificationContentType.timeSeriesNotification: + return self.value in [ NotificationEventType.reportOnGeneratedMissingDataPoints ] + case _: + return False + def defaultNCT(self) -> NotificationContentType: """ Return the default NotificationContentType for this NotificationEventType. diff --git a/acme/services/Logging.py b/acme/services/Logging.py index 0c3866f4..59f7f300 100644 --- a/acme/services/Logging.py +++ b/acme/services/Logging.py @@ -379,15 +379,17 @@ def logWithLevel(level:int, msg:Any, """ # TODO add a parameter frame substractor to correct the line number, here and in In _log() # TODO change to match in Python10 - if level == logging.DEBUG: - return Logging.logDebug(msg, stackOffset = stackOffset) - elif level == logging.INFO: - return Logging.log(msg, stackOffset = stackOffset) - elif level == logging.WARNING: - return Logging.logWarn(msg, stackOffset = stackOffset) - elif level == logging.ERROR: - return Logging.logErr(msg, showStackTrace = showStackTrace, stackOffset = stackOffset) - return msg + match level: + case logging.DEBUG: + return Logging.logDebug(msg, stackOffset = stackOffset) + case logging.INFO: + return Logging.log(msg, stackOffset = stackOffset) + case logging.WARNING: + return Logging.logWarn(msg, stackOffset = stackOffset) + case logging.ERROR: + return Logging.logErr(msg, showStackTrace = showStackTrace, stackOffset = stackOffset) + case _: + return msg @staticmethod @@ -454,14 +456,15 @@ def console(msg:Union[str, Text, Tree, Table, JSON] = ' ', style = Logging.terminalStyle if not isError else Logging.terminalStyleError if nlb: # Empty line before Logging._console.print() - if isinstance(msg, str): - Logging._console.print(msg if plain else Markdown(msg), style = style, end = end, highlight = False) - elif isinstance(msg, dict): - Logging._console.print(msg, style = style, end = end) - elif isinstance(msg, (Tree, Table, Text)): - Logging._console.print(msg, style = style, end = end) - else: - Logging._console.print(str(msg), style = style, end = end) + + match msg: + case str(): + Logging._console.print(msg if plain else Markdown(msg), style = style, end = end, highlight = False) + case dict() | Tree() | Table() | Text(): + Logging._console.print(msg, style = style, end = end) + case _: + Logging._console.print(str(msg), style = style, end = end) + if nl: # Empty line after Logging._console.print() diff --git a/acme/services/RegistrationManager.py b/acme/services/RegistrationManager.py index e092cead..3ac03164 100644 --- a/acme/services/RegistrationManager.py +++ b/acme/services/RegistrationManager.py @@ -116,31 +116,27 @@ def checkResourceCreation(self, resource:Resource, originator:str, parentResource:Optional[Resource] = None) -> str: # Some Resources are not allowed to be created in a request, return immediately - ty = resource.ty - - if ty == ResourceTypes.AE: - originator = self.handleAERegistration(resource, originator, parentResource) - - elif ty == ResourceTypes.REQ: - if not self.handleREQRegistration(resource, originator): - raise BAD_REQUEST('cannot register REQ') - - elif ty == ResourceTypes.CSR: - if CSE.cseType == CSEType.ASN: - raise OPERATION_NOT_ALLOWED('cannot register to ASN CSE') - try: - self.handleCSRRegistration(resource, originator) - except ResponseException as e: - e.dbg = f'cannot register CSR: {e.dbg}' - raise e - - elif ty == ResourceTypes.CSEBaseAnnc: - try: - self.handleCSEBaseAnncRegistration(resource, originator) - except ResponseException as e: - e.dbg = f'cannot register CSEBaseAnnc: {e.dbg}' - raise e - # fall-through + + match resource.ty: + case ResourceTypes.AE: + originator = self.handleAERegistration(resource, originator, parentResource) + case ResourceTypes.CSR: + if CSE.cseType == CSEType.ASN: + raise OPERATION_NOT_ALLOWED('cannot register to ASN CSE') + try: + self.handleCSRRegistration(resource, originator) + except ResponseException as e: + e.dbg = f'cannot register CSR: {e.dbg}' + raise e + case ResourceTypes.REQ: + if not self.handleREQRegistration(resource, originator): + raise BAD_REQUEST('cannot register REQ') + case ResourceTypes.CSEBaseAnnc: + try: + self.handleCSEBaseAnncRegistration(resource, originator) + except ResponseException as e: + e.dbg = f'cannot register CSEBaseAnnc: {e.dbg}' + raise e # Test and set creator attribute. self.handleCreator(resource, originator) @@ -155,13 +151,13 @@ def postResourceCreation(self, resource:Resource) -> None: Args: resource: Resource that was created. """ - ty = resource.ty - if ty == ResourceTypes.AE: - # Send event - self._eventAEHasRegistered(resource) - elif ty == ResourceTypes.CSR: - # send event - self._eventRegistreeCSEHasRegistered(resource) + match resource.ty: + case ResourceTypes.AE: + # Send event + self._eventAEHasRegistered(resource) + case ResourceTypes.CSR: + # send event + self._eventRegistreeCSEHasRegistered(resource) def handleCreator(self, resource:Resource, originator:str) -> None: @@ -184,19 +180,16 @@ def checkResourceUpdate(self, resource:Resource, updateDict:JSON) -> None: def checkResourceDeletion(self, resource:Resource) -> None: - ty = resource.ty - if ty == ResourceTypes.AE: - if not self.handleAEDeRegistration(resource): - raise BAD_REQUEST('cannot deregister AE') - - elif ty == ResourceTypes.REQ: - if not self.handleREQDeRegistration(resource): - raise BAD_REQUEST('cannot deregister REQ') - - elif ty == ResourceTypes.CSR: - if not self.handleRegistreeCSRDeRegistration(resource): - raise BAD_REQUEST('cannot deregister CSR') - # fall-through + match resource.ty: + case ResourceTypes.AE: + if not self.handleAEDeRegistration(resource): + raise BAD_REQUEST('cannot deregister AE') + case ResourceTypes.REQ: + if not self.handleREQDeRegistration(resource): + raise BAD_REQUEST('cannot deregister REQ') + case ResourceTypes.CSR: + if not self.handleRegistreeCSRDeRegistration(resource): + raise BAD_REQUEST('cannot deregister CSR') def postResourceDeletion(self, resource:Resource) -> None: @@ -205,13 +198,13 @@ def postResourceDeletion(self, resource:Resource) -> None: Args: resource: Resource that was created. """ - ty = resource.ty - if ty == ResourceTypes.AE: - # Send event - self._eventAEHasDeregistered(resource) - elif ty == ResourceTypes.CSR: - # send event - self._eventRegistreeCSEHasDeregistered(resource) + match resource.ty: + case ResourceTypes.AE: + # Send event + self._eventAEHasDeregistered(resource) + case ResourceTypes.CSR: + # send event + self._eventRegistreeCSEHasDeregistered(resource) ######################################################################### @@ -235,14 +228,13 @@ def handleAERegistration(self, ae:Resource, originator:str, parentResource:Resou raise APP_RULE_VALIDATION_FAILED(L.logDebug('Originator not allowed')) # Assign originator for the AE - if originator == 'C': - originator = uniqueAEI('C') - elif originator == 'S': - originator = uniqueAEI('S') - elif originator is not None: # Allow empty originators - originator = getIdFromOriginator(originator) - # elif originator is None or len(originator) == 0: - # originator = uniqueAEI('S') + match originator: + case 'C': + originator = uniqueAEI('C') + case 'S': + originator = uniqueAEI('S') + case x if x is not None: + originator = getIdFromOriginator(originator) # Check whether an originator has already registered with the same AE-ID if self.hasRegisteredAE(originator): diff --git a/acme/services/ScriptManager.py b/acme/services/ScriptManager.py index 2ad4af78..e81bcd86 100644 --- a/acme/services/ScriptManager.py +++ b/acme/services/ScriptManager.py @@ -1110,44 +1110,41 @@ def doSetConfig(self, pcontext:PContext, symbol:SSymbol) -> PContext: if Configuration.has(_key): # could be None, False, 0, empty string etc # Do some conversions first - v = Configuration.get(_key) - if isinstance(v, ACMEIntEnum): - if result.type == SType.tString: - r = Configuration.update(_key, v.__class__.to(cast(str, result.value), insensitive = True)) - else: - raise PInvalidTypeError(pcontext.setError(PError.invalid, 'configuration value must be a string')) - - elif isinstance(v, str): - if result.type == SType.tString: - r = Configuration.update(_key, cast(str, result.value).strip()) - else: - raise PInvalidTypeError(pcontext.setError(PError.invalid, 'configuration value must be a string')) - - # bool must be tested before int! - # See https://stackoverflow.com/questions/37888620/comparing-boolean-and-int-using-isinstance/37888668#37888668 - elif isinstance(v, bool): - if result.type == SType.tBool: - r = Configuration.update(_key, result.value) - else: - raise PInvalidTypeError(pcontext.setError(PError.invalidType, f'configuration value must be a boolean')) - - elif isinstance(v, int): - if result.type == SType.tNumber: - r = Configuration.update(_key, int(cast(Decimal, result.value))) - else: - raise PInvalidTypeError(pcontext.setError(PError.invalidType, f'configuration value must be an integer')) - - elif isinstance(v, float): - if result.type == SType.tNumber: - r = Configuration.update(_key, float(cast(Decimal, result.value))) - else: - raise PInvalidTypeError(pcontext.setError(PError.invalidType, f'configuration value must be a float, is: {result.type}')) - - elif isinstance(v, list): - raise PUnsupportedError(pcontext.setError(PError.invalidType, f'unsupported type: {type(v)}')) - else: - raise PUnsupportedError(pcontext.setError(PError.invalidType, f'unsupported type: {type(v)}')) + match (v := Configuration.get(_key)): + case ACMEIntEnum(): + if result.type == SType.tString: + r = Configuration.update(_key, v.__class__.to(cast(str, result.value), insensitive = True)) + else: + raise PInvalidTypeError(pcontext.setError(PError.invalid, 'configuration value must be a string')) + case str(): + if result.type == SType.tString: + r = Configuration.update(_key, cast(str, result.value).strip()) + else: + raise PInvalidTypeError(pcontext.setError(PError.invalid, 'configuration value must be a string')) + # bool must be tested before int! + # See https://stackoverflow.com/questions/37888620/comparing-boolean-and-int-using-isinstance/37888668#37888668 + case bool(): + if result.type == SType.tBool: + r = Configuration.update(_key, result.value) + else: + raise PInvalidTypeError(pcontext.setError(PError.invalidType, f'configuration value must be a boolean')) + + case int(): + if result.type == SType.tNumber: + r = Configuration.update(_key, int(cast(Decimal, result.value))) + else: + raise PInvalidTypeError(pcontext.setError(PError.invalidType, f'configuration value must be an integer')) + + case float(): + if result.type == SType.tNumber: + r = Configuration.update(_key, float(cast(Decimal, result.value))) + else: + raise PInvalidTypeError(pcontext.setError(PError.invalidType, f'configuration value must be a float, is: {result.type}')) + + case _: + raise PUnsupportedError(pcontext.setError(PError.invalidType, f'unsupported type: {type(v)}')) + # Check whether something went wrong while setting the config if r: raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'Error setting configuration: {r}')) @@ -1366,41 +1363,41 @@ def _handleRequest(self, pcontext:PContext, symbol:SSymbol, operation:Operation) # Send request L.isDebug and L.logDebug(f'Sending request from script: {request.originalRequest} to: {target}') if isURL(target): - if operation == Operation.RETRIEVE: - res = CSE.request.handleSendRequest(CSERequest(op = Operation.RETRIEVE, - ot = getResourceDate(), - to = target, - originator = originator) - )[0].result # there should be at least one result - - elif operation == Operation.DELETE: - res = CSE.request.handleSendRequest(CSERequest(op = Operation.DELETE, - ot = getResourceDate(), - to = target, - originator = originator) - )[0].result # there should be at least one result - elif operation == Operation.CREATE: - res = CSE.request.handleSendRequest(CSERequest(op = Operation.CREATE, - ot = getResourceDate(), - to = target, - originator = originator, - ty = ty, - pc = request.pc) - )[0].result # there should be at least one result - elif operation == Operation.UPDATE: - res = CSE.request.handleSendRequest(CSERequest(op = Operation.UPDATE, - ot = getResourceDate(), - to = target, - originator = originator, - pc = request.pc) - )[0].result # there should be at least one result - elif operation == Operation.NOTIFY: - res = CSE.request.handleSendRequest(CSERequest(op = Operation.NOTIFY, - ot = getResourceDate(), - to = target, - originator = originator, - pc = request.pc) - )[0].result # there should be at least one result + match operation: + case Operation.RETRIEVE: + res = CSE.request.handleSendRequest(CSERequest(op = Operation.RETRIEVE, + ot = getResourceDate(), + to = target, + originator = originator) + )[0].result # there should be at least one result + case Operation.DELETE: + res = CSE.request.handleSendRequest(CSERequest(op = Operation.DELETE, + ot = getResourceDate(), + to = target, + originator = originator) + )[0].result # there should be at least one result + case Operation.CREATE: + res = CSE.request.handleSendRequest(CSERequest(op = Operation.CREATE, + ot = getResourceDate(), + to = target, + originator = originator, + ty = ty, + pc = request.pc) + )[0].result # there should be at least one result + case Operation.UPDATE: + res = CSE.request.handleSendRequest(CSERequest(op = Operation.UPDATE, + ot = getResourceDate(), + to = target, + originator = originator, + pc = request.pc) + )[0].result # there should be at least one result + case Operation.NOTIFY: + res = CSE.request.handleSendRequest(CSERequest(op = Operation.NOTIFY, + ot = getResourceDate(), + to = target, + originator = originator, + pc = request.pc) + )[0].result # there should be at least one result else: # Request via CSE-ID, either local, or otherwise a transit request. Let the CSE handle it From 0457ef84bb6224ad688d866f123c4cfaf16d3b14 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 18 Jul 2023 16:26:55 +0200 Subject: [PATCH 052/165] More match...case --- acme/resources/AnnounceableResource.py | 13 +++++++----- acme/resources/CRS.py | 9 ++++---- acme/resources/Factory.py | 18 +++++++++------- acme/services/GroupManager.py | 29 +++++++++++++------------- acme/services/HttpServer.py | 9 ++++---- acme/services/RequestManager.py | 1 - 6 files changed, 43 insertions(+), 36 deletions(-) diff --git a/acme/resources/AnnounceableResource.py b/acme/resources/AnnounceableResource.py index f8c7b182..9ac963fb 100644 --- a/acme/resources/AnnounceableResource.py +++ b/acme/resources/AnnounceableResource.py @@ -250,11 +250,14 @@ def _getAnnouncedAttributes(self, attributes:AttributePolicyDict) -> list[str]: if not (policy := attributes.get(attr)): continue - if policy.announcement == Announced.MA: - mandatory.append(attr) - elif policy.announcement == Announced.OA and attr in announceableAttributes: # only add optional attributes that are also in aa - optional.append(attr) - # else: just ignore Announced.NA + match policy.announcement: + case Announced.MA: + mandatory.append(attr) + case Announced.OA if attr in announceableAttributes: # only add optional attributes that are also in aa + optional.append(attr) + case Announced.NA: + # just ignore Announced.NA + pass return mandatory + optional diff --git a/acme/resources/CRS.py b/acme/resources/CRS.py index 4d823c92..99145d48 100644 --- a/acme/resources/CRS.py +++ b/acme/resources/CRS.py @@ -180,10 +180,11 @@ def update(self, dct:Optional[JSON] = None, def deactivate(self, originator:str) -> None: # Deactivate time windows - if self.twt == TimeWindowType.PERIODICWINDOW: - CSE.notification.stopCRSPeriodicWindow(self.ri) - elif self.twt == TimeWindowType.SLIDINGWINDOW: - CSE.notification.stopCRSSlidingWindow(self.ri) + match self.twt: + case TimeWindowType.PERIODICWINDOW: + CSE.notification.stopCRSPeriodicWindow(self.ri) + case TimeWindowType.SLIDINGWINDOW: + CSE.notification.stopCRSSlidingWindow(self.ri) # Delete rrat and srat subscriptions self._deleteSubscriptions(originator) diff --git a/acme/resources/Factory.py b/acme/resources/Factory.py index 02125d8d..10a7d1b9 100644 --- a/acme/resources/Factory.py +++ b/acme/resources/Factory.py @@ -231,14 +231,16 @@ def resourceFromDict(resDict:Optional[JSON] = {}, # Determine a factory and call it factory:FactoryCallableT = None - if typ == ResourceTypes.MGMTOBJ: # for - # mgd = resDict['mgd'] if 'mgd' in resDict else None # Identify mdg in - factory = ResourceTypes(resDict['mgd']).resourceFactory() - elif typ == ResourceTypes.MGMTOBJAnnc: # for - # mgd = resDict['mgd'] if 'mgd' in resDict else None # Identify mdg in - factory = ResourceTypes(resDict['mgd']).announced().resourceFactory() - else: - factory = typ.resourceFactory() + match typ: + case ResourceTypes.MGMTOBJ: + # mgd = resDict['mgd'] if 'mgd' in resDict else None # Identify mdg in + factory = ResourceTypes(resDict['mgd']).resourceFactory() + case ResourceTypes.MGMTOBJAnnc: + # mgd = resDict['mgd'] if 'mgd' in resDict else None # Identify mdg in + factory = ResourceTypes(resDict['mgd']).announced().resourceFactory() + case _: + factory = typ.resourceFactory() + if factory: return cast(Resource, factory(resDict, tpe, pi, create)) diff --git a/acme/services/GroupManager.py b/acme/services/GroupManager.py index 63a27e34..9853724e 100644 --- a/acme/services/GroupManager.py +++ b/acme/services/GroupManager.py @@ -149,24 +149,25 @@ def _checkMembersAndPrivileges(self, group:Resource, originator:str) -> None: # check specializationType spty if (spty := group.spty): - if isinstance(spty, int): # mgmtobj type - if isinstance(resource, MgmtObj) and ty != spty: - raise GROUP_MEMBER_TYPE_INCONSISTENT(f'resource and group member types mismatch: {ty} != {spty} for: {mid}') - elif isinstance(spty, str): # fcnt specialization - if isinstance(resource, FCNT) and resource.cnd != spty: - raise GROUP_MEMBER_TYPE_INCONSISTENT(f'resource and group member specialization types mismatch: {resource.cnd} != {spty} for: {mid}') + match spty: + case int(): # mgmtobj type + if isinstance(resource, MgmtObj) and ty != spty: + raise GROUP_MEMBER_TYPE_INCONSISTENT(f'resource and group member types mismatch: {ty} != {spty} for: {mid}') + case str(): # fcnt specialization + if isinstance(resource, FCNT) and resource.cnd != spty: + raise GROUP_MEMBER_TYPE_INCONSISTENT(f'resource and group member specialization types mismatch: {resource.cnd} != {spty} for: {mid}') # check type of resource and member type of group mt = group.mt if not (mt == ResourceTypes.MIXED or ty == mt): # types don't match - csy = group.csy - if csy == ConsistencyStrategy.abandonMember: # abandon member - continue - elif csy == ConsistencyStrategy.setMixed: # change group's member type - mt = ResourceTypes.MIXED - group['mt'] = ResourceTypes.MIXED - else: # abandon group - raise GROUP_MEMBER_TYPE_INCONSISTENT('group consistency strategy and type "mixed" mismatch') + match group.csy: + case ConsistencyStrategy.abandonMember: # abandon member + continue + case ConsistencyStrategy.setMixed: # change group's member type + mt = ResourceTypes.MIXED + group['mt'] = ResourceTypes.MIXED + case _: + raise GROUP_MEMBER_TYPE_INCONSISTENT('group consistency strategy and type "mixed" mismatch') # member seems to be ok, so add ri to the list if isLocalResource: diff --git a/acme/services/HttpServer.py b/acme/services/HttpServer.py index e2e71f27..b22ec399 100644 --- a/acme/services/HttpServer.py +++ b/acme/services/HttpServer.py @@ -659,10 +659,11 @@ def extractMultipleArgs(args:MultiDict, argName:str) -> None: req['op'] = operation.value # Needed later for validation # resolve http's /~ and /_ special prefixs - if path[0] == '~': - path = path[1:] # ~/xxx -> /xxx - elif path[0] == '_': - path = f'/{path[1:]}' # _/xxx -> //xxx + match path[0]: + case '~': + path = path[1:] # ~/xxx -> /xxx + case '_': + path = f'/{path[1:]}' # _/xxx -> //xxx req['to'] = path diff --git a/acme/services/RequestManager.py b/acme/services/RequestManager.py index a64f0855..95c17f90 100644 --- a/acme/services/RequestManager.py +++ b/acme/services/RequestManager.py @@ -853,7 +853,6 @@ def queueRequestForPCH( self, # If the request has no id, then use the to field if not request.id: request.id = request.to - L.logErr(f'Internal error. {request}') # Always mark the request as a REQUEST request.requestType = reqType From e37db6e7a6052fe2188746641084b92d7fa7e656 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 18 Jul 2023 16:27:12 +0200 Subject: [PATCH 053/165] corrected wrong test cases --- tests/testMgmtObj.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/testMgmtObj.py b/tests/testMgmtObj.py index a0fb5fb9..5061170f 100644 --- a/tests/testMgmtObj.py +++ b/tests/testMgmtObj.py @@ -1134,7 +1134,7 @@ def test_updateDATCrpscInvalidSchedule1Fail(self) -> None: def test_updateDATCrpscInvalidSchedule2Fail(self) -> None: """ UPDATE [dataCollection] rpsc with an invalid schedule -> FAIL""" dct = { 'dcfg:datc' : { - 'rpsc': [ { 'sce': '10 * * * *' } ], # invalid format, must be 7 + 'rpsc': [ { 'sce': [ '10 * * * *' ] } ], # invalid format, must be 7 }} r, rsc = UPDATE(self.datcURL, ORIGINATOR, dct) self.assertEqual(rsc, RC.BAD_REQUEST, r) @@ -1144,7 +1144,7 @@ def test_updateDATCrpscInvalidSchedule2Fail(self) -> None: def test_updateDATCrpscValidSchedule(self) -> None: """ UPDATE [dataCollection] rpsc with a valid schedule""" dct = { 'dcfg:datc' : { - 'rpsc': [ { 'sce': '10 * * * * * *' } ], + 'rpsc': [ { 'sce': [ '10 * * * * * *' ] } ], }} r, rsc = UPDATE(self.datcURL, ORIGINATOR, dct) self.assertEqual(rsc, RC.UPDATED, r) @@ -1187,7 +1187,7 @@ def test_updateDATCmescInvalidSchedule1Fail(self) -> None: def test_updateDATCmescInvalidSchedule2Fail(self) -> None: """ UPDATE [dataCollection] mesc with an invalid schedule -> FAIL""" dct = { 'dcfg:datc' : { - 'mesc': [ { 'sce': '10 * * * *' } ], # invalid format, must be 7 + 'mesc': [ { 'sce': [ '10 * * * *' ] } ], # invalid format, must be 7 }} r, rsc = UPDATE(self.datcURL, ORIGINATOR, dct) self.assertEqual(rsc, RC.BAD_REQUEST, r) @@ -1197,7 +1197,7 @@ def test_updateDATCmescInvalidSchedule2Fail(self) -> None: def test_updateDATCmescValidSchedule(self) -> None: """ UPDATE [dataCollection] mesc with a valid schedule""" dct = { 'dcfg:datc' : { - 'mesc': [ { 'sce': '10 * * * * * *' } ], + 'mesc': [ { 'sce': [ '10 * * * * * *' ] } ], }} r, rsc = UPDATE(self.datcURL, ORIGINATOR, dct) self.assertEqual(rsc, RC.UPDATED, r) @@ -1259,7 +1259,7 @@ def test_attributesDATC(self) -> None: self.assertEqual(len(rpsc), 1, r) self.assertIsInstance((rpsce := rpsc[0]), dict, r) self.assertIsNotNone((sce := rpsce.get('sce')), r) - self.assertEqual(sce, '10 * * * * * *', r) + self.assertEqual(sce, [ '10 * * * * * *' ], r) @unittest.skipIf(noCSE, 'No CSEBase') From b5c3b0ea9a360a0d865a21f46f83625c5835c542 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 18 Jul 2023 16:49:14 +0200 Subject: [PATCH 054/165] Added "tui-notify" and "get-loglevel" functions to the script interpreter --- CHANGELOG.md | 2 +- acme/helpers/Interpreter.py | 49 ++++++++++++++++------ acme/services/ScriptManager.py | 77 ++++++++++++++++++++++++++++++++++ acme/services/TextUI.py | 10 +++++ acme/textui/ACMETuiApp.py | 41 +++++++++++++++++- docs/ACMEScript-functions.md | 61 +++++++++++++++++++++++++++ 6 files changed, 224 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6edf6b6..a971ae8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - [CSE] Added automatic pip install of missing dependencies during startup. - [CSE] Added support for <schedule> resource type. -- [SCRIPTS] Added "dotimes" function to the script interpreter. +- [SCRIPTS] Added "dotimes", "tui-notify", and "get-loglevel" functions to the script interpreter. ### Experimental diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index 14cbd94a..b2047105 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -299,7 +299,7 @@ def __init__(self, string:str = None, self.length = 1 else: self.type = SType.tNIL - self.value = False + self.value = None # was: False self.length = 0 @@ -1125,9 +1125,10 @@ def hasMeta(self, key:str) -> bool: def getArgument(self, symbol:SSymbol, - idx:int = None, - expectedType:SType|Tuple[SType, ...] = None, - doEval:bool = True) -> PContext: + idx:Optional[int] = None, + expectedType:Optional[SType|Tuple[SType, ...]] = None, + doEval:Optional[bool] = True, + optional:Optional[bool] = False) -> PContext: """ Verify that an expression is a list and return an argument symbol, while optionally verify the allowed type(s) for that argument. @@ -1140,6 +1141,7 @@ def getArgument(self, symbol:SSymbol, idx: Optional index if the symbol contains a list of symbols. expectedType: one or multiple data types that are allowed for the retrieved argument symbol. doEval: Optionally recursively evaluate the symbol. + optional: Allow the argument to be None. Return: Result `PContext` object with the result, possible changed variable and other states. @@ -1161,6 +1163,9 @@ def getArgument(self, symbol:SSymbol, if expectedType is not None: if isinstance(expectedType, SType): expectedType = ( expectedType, ) + # add NIL if optional + if optional: + expectedType = expectedType + ( SType.tNIL, ) if pcontext.result is not None and pcontext.result.type not in expectedType: raise PInvalidArgumentError(self.setError(PError.invalid, f'expression: {symbol} - invalid type for argument: {_symbol}, expected type: {expectedType}, is: {pcontext.result.type}')) @@ -1170,9 +1175,10 @@ def getArgument(self, symbol:SSymbol, def valueFromArgument(self, symbol:SSymbol, - idx:int = None, - expectedType:SType|Tuple[SType, ...] = None, - doEval:bool = True) -> Tuple[PContext, Any]: + idx:Optional[int] = None, + expectedType:Optional[SType|Tuple[SType, ...]] = None, + doEval:Optional[bool] = True, + optional:Optional[bool] = False) -> Tuple[PContext, Any]: """ Return the actual value from an argument symbol. Args: @@ -1180,18 +1186,24 @@ def valueFromArgument(self, symbol:SSymbol, idx: Optional index if the symbol contains a list of symbols. expectedType: one or multiple data types that are allowed for the retrieved argument symbol. doEval: Optionally recursively evaluate the symbol. + optional: Allow the argument to be optional. Return: Result tuple of the updated `PContext` object with the result and the value. """ - p,r = self.resultFromArgument(symbol, idx, expectedType, doEval) - return (p, r.value) + if idx < symbol.length: + p, r = self.resultFromArgument(symbol, idx, expectedType, doEval, optional) + return (p, r.value) + elif optional: + return (self, None) + raise PInvalidArgumentError(self.setError(PError.invalid, f'expression: {symbol} - invalid argument index: {idx}')) def resultFromArgument(self, symbol:SSymbol, - idx:int = None, - expectedType:SType|Tuple[SType, ...] = None, - doEval:bool = True) -> Tuple[PContext, SSymbol]: + idx:Optional[int] = None, + expectedType:Optional[SType|Tuple[SType, ...]] = None, + doEval:Optional[bool] = True, + optional:Optional[bool] = False) -> Tuple[PContext, SSymbol]: """ Return the `SSymbol` result from an argument symbol. Args: @@ -1199,11 +1211,12 @@ def resultFromArgument(self, symbol:SSymbol, idx: Optional index if the symbol contains a list of symbols. expectedType: one or multiple data types that are allowed for the retrieved argument symbol. doEval: Optionally recursively evaluate the symbol. + optional: Allow the argument to be optional. Return: Result tuple of the updated `PContext` object with the result and the symbol. """ - return (p := self.getArgument(symbol, idx, expectedType, doEval), p.result) + return (p := self.getArgument(symbol, idx, expectedType, doEval, optional), p.result) def executeSubexpression(self, expression:str) -> PContext: @@ -1453,6 +1466,9 @@ def _executeExpression(self, symbol:SSymbol, parentSymbol:SSymbol) -> PContext: elif firstSymbol.type == SType.tJson: return self.checkInStringExpressions(symbol) + + elif firstSymbol.type == SType.tNIL: + return self.setResult(firstSymbol) raise PInvalidArgumentError(self.setError(PError.invalid, f'Unexpected symbol: {firstSymbol.type} - {firstSymbol}')) @@ -1895,6 +1911,13 @@ def _doDatetime(pcontext:PContext, symbol:SSymbol) -> PContext: """ pcontext.assertSymbol(symbol, maxLength = 2) _format = '%Y%m%dT%H%M%S.%f' + + # get format + pcontext, format = pcontext.valueFromArgument(symbol, 1, SType.tString, optional = True) + if format is None: + format = _format + return pcontext.setResult(SSymbol(string = _utcNow().strftime(_format))) + if symbol.length == 2: pcontext, _format = pcontext.valueFromArgument(symbol, 1, SType.tString) return pcontext.setResult(SSymbol(string = _utcNow().strftime(_format))) diff --git a/acme/services/ScriptManager.py b/acme/services/ScriptManager.py index e81bcd86..90d27895 100644 --- a/acme/services/ScriptManager.py +++ b/acme/services/ScriptManager.py @@ -122,6 +122,7 @@ def __init__(self, 'cse-status': self.doCseStatus, 'delete-resource': self.doDeleteResource, 'get-config': self.doGetConfiguration, + 'get-loglevel': self.doGetLogLevel, 'get-storage': self.doGetStorage, 'has-config': self.doHasConfiguration, 'has-storage': self.doHasStorage, @@ -145,6 +146,7 @@ def __init__(self, 'set-config': self.doSetConfig, 'set-console-logging': self.doSetLogging, 'schedule-next-script': self.doScheduleNextScript, + 'tui-notify': self.doTuiNotify, 'tui-refresh-resources': self.doTuiRefreshResources, 'tui-visual-bell': self.doTuiVisualBell, 'update-resource': self.doUpdateResource, @@ -399,6 +401,32 @@ def doGetConfiguration(self, pcontext:PContext, symbol:SSymbol) -> PContext: return pcontext.setResult(SSymbol(value = _v)) + def doGetLogLevel(self, pcontext:PContext, symbol:SSymbol) -> PContext: + """ Get the log level of the CSE. This will be one of the following strings: + + - "DEBUG" + - "INFO" + - "WARNING" + - "ERROR" + - "OFF" + + + Example: + :: + + (get-loglevel) -> "INFO" + + Args: + pcontext: PContext object of the running script. + symbol: The symbol to execute. + + Return: + The updated `PContext` object with the operation result. + """ + pcontext.assertSymbol(symbol, 1) + return pcontext.setResult(SSymbol(string = str(L.logLevel))) + + def doGetStorage(self, pcontext:PContext, symbol:SSymbol) -> PContext: """ Retrieve a value for *key* from the persistent storage *storage*. @@ -1178,6 +1206,55 @@ def doSetLogging(self, pcontext:PContext, symbol:SSymbol) -> PContext: return pcontext.setResult(SSymbol()) + def doTuiNotify(self, pcontext:PContext, symbol:SSymbol) -> PContext: + """ Show a TUI notification. + + This function is only available in TUI mode. It has the following + arguments: + + - message: The message to show. + - title: (Optional) The title of the notification. + - severity: (Optional) The severity of the notification. Can be + one of the following values: `information`, `warning`, `error`. + - timeout: (Optional) The timeout in seconds after which the + notification will disappear. If not specified, the notification + will disappear after 3 seconds. + + + The function returns NIL. + + Example: + :: + + (tui-notify "This is a notification") + + Args: + pcontext: `PContext` object of the running script. + symbol: The symbol to execute. + + Return: + The updated `PContext` object. + """ + pcontext.assertSymbol(symbol, minLength = 2, maxLength = 5) + + # Value + pcontext, value = pcontext.valueFromArgument(symbol, 1, SType.tString) + + # Title + pcontext, title = pcontext.valueFromArgument(symbol, 2, SType.tString, optional = True) + + # Severity + pcontext, severity = pcontext.valueFromArgument(symbol, 3, SType.tString, optional = True) + + # Timeout + pcontext, timeout = pcontext.valueFromArgument(symbol, 4, SType.tNumber, optional = True) + + # show the notification + CSE.textUI.scriptShowNotification(value, title, severity, float(timeout) if timeout is not None else None) + + return pcontext.setResult(SSymbol()) + + def doTuiRefreshResources(self, pcontext:PContext, symbol:SSymbol) -> PContext: """ Refresh the TUI resources. This will update the resource Tree and the resource details. diff --git a/acme/services/TextUI.py b/acme/services/TextUI.py index 1efbd45e..be471ed3 100644 --- a/acme/services/TextUI.py +++ b/acme/services/TextUI.py @@ -182,6 +182,16 @@ def scriptClearConsole(self, scriptName:str) -> None: self.tuiApp.scriptClearConsole(scriptName) + def scriptShowNotification(self, msg:str, title:str, severity:str, timeout:float) -> None: + """ Show a notification. + + Args: + msg: Message to show. + """ + if self.tuiApp: + self.tuiApp.scriptShowNotification(msg, title, severity, timeout) + + def scriptVisualBell(self, scriptName:str) -> None: """ Visual bell. """ diff --git a/acme/textui/ACMETuiApp.py b/acme/textui/ACMETuiApp.py index 599770cb..770b4d93 100644 --- a/acme/textui/ACMETuiApp.py +++ b/acme/textui/ACMETuiApp.py @@ -8,12 +8,17 @@ """ from __future__ import annotations +from typing import Callable +from typing_extensions import Literal, get_args +import asyncio from enum import IntEnum, auto from textual.app import App, ComposeResult from textual import on from textual.widgets import Tab, Footer, TabbedContent, TabPane, Static from textual.binding import Binding from textual.design import ColorSystem +from textual.notifications import Notification, SeverityLevel + from ..textui.ACMEHeader import ACMEHeader from ..textui.ACMEContainerAbout import ACMEContainerAbout from ..textui.ACMEContainerConfigurations import ACMEContainerConfigurations @@ -101,8 +106,11 @@ def __init__(self, textUI:TextUI.TextUI): # This is a bit different from the actual current tab from the self.tabs # attribute because at one point it is used to determine the previous tab. self.currentTab:Tab = None - #self.app.DEFAULT_COLORS = CUSTOM_COLORS - # _app.DEFAULT_COLORS = CUSTOM_COLORS + + # This is used to keep a pointer to the current event loop to use it + # for async calls from non-async functions. + # This is set in the on_load() function. + self.event_loop:asyncio.AbstractEventLoop = None self.tabs = TabbedContent() self.containerTree = ACMEContainerTree() @@ -114,6 +122,7 @@ def __init__(self, textUI:TextUI.TextUI): self.containerAbout = ACMEContainerAbout() self.debugConsole = Static('', id = 'debug-console') + def compose(self) -> ComposeResult: """Build the Main UI.""" yield ACMEHeader(show_clock = True) @@ -140,6 +149,7 @@ def compose(self) -> ComposeResult: def on_load(self) -> None: self.dark = self.textUI.theme == 'dark' self.syntaxTheme = 'ansi_dark' if self.dark else 'ansi_light' + self.event_loop = asyncio.get_event_loop() # self.design = CUSTOM_COLORS # self.refresh_css() @@ -203,6 +213,21 @@ def scriptClearConsole(self, scriptName:str) -> None: self.containerTools.scriptClearConsole(scriptName) + def scriptShowNotification(self, message:str, title:str, severity:Literal['information', 'warning', 'error'], timeout:float) -> None: + + async def _call() -> None: + self.notify(message = message, title = title, severity = severity, timeout = timeout) + + if timeout is None: + timeout = Notification.timeout + if severity is None: + severity = 'information' + elif severity not in get_args(SeverityLevel): + raise ValueError(f'Invalid severity level: {severity}') + + self.runAsyncTask(_call) + + def scriptVisualBell(self, scriptName:str) -> None: if self.containerTools: BackgroundWorkerPool.runJob(lambda:self.containerTools.scriptVisualBell(scriptName)) @@ -213,6 +238,17 @@ def refreshResources(self) -> None: ######################################################################### + + def runAsyncTask(self, task:Callable) -> None: + """ Run an async task from a non-async function. + + Args: + task: The async task to run. + """ + if self.event_loop: + self.event_loop.create_task(task()) + + def restart(self) -> None: self.quitReason = ACMETuiQuitReason.restart self.exit() @@ -222,6 +258,7 @@ def cleanUp(self) -> None: """ Clean up the UI before exiting. """ self.containerTools.cleanUp() + self.event_loop = None # diff --git a/docs/ACMEScript-functions.md b/docs/ACMEScript-functions.md index 9b5f372f..1aac2c13 100644 --- a/docs/ACMEScript-functions.md +++ b/docs/ACMEScript-functions.md @@ -68,6 +68,7 @@ The following built-in functions and variables are provided by the ACMEScript in | [CSE](#_cse) | [clear-console](#clear-console) | Clear the console screen | | | [cse-status](#cse-status) | Return the CSE's current status | | | [get-config](#get-config) | Retrieve a CSE's configuration setting | +| | [get-loglevel](#get-loglevel) | Retrieve the CSE's current log level | | | [get-storage](#get-storage) | Retrieve a value from the CSE's internal script-data storage | | | [has-config](#has-config) | Determine the existence of a CSE's configuration setting | | | [has-storage](#has-storage) | Determine the existence of a key/value in the CSE's internal script-data storage | @@ -91,6 +92,7 @@ The following built-in functions and variables are provided by the ACMEScript in | [Text UI](#_textui) | [open-web-browser](#open-web-browser) | Open a web page in the default browser | | | [set-category-description](#set-category-description) | Set the description for a whole category of scripts | | | [runs-in-tui](#runs-in-tui) | Determine whether the CSE runs in Text UI mode | +| | [tui-notify](#tui-notify) | Display a desktop-like notification | | | [tui-refresh-resources](#tui-refresh-resources) | Force a refresh of the Text UI's resource tree | | | [tui-visual-bell](#tui-visual-bell) | Shortly flashes the script's entry in the text UI's scripts list | | [Network](#_network) | [http](#http) | Send http requests | @@ -1501,6 +1503,29 @@ Examples: --- + + +### get-loglevel + +`(get-loglevel)` + +The `get-loglevel` function retrieves a the CSE's current log level setting. The return value will be one of the following strings: + +- "DEBUG" +- "INFO" +- "WARNING" +- "ERROR" +- "OFF" + +Example: +```lisp +(get-loglevel) ;; Return, for example, INFO +``` + +[top](#top) + +--- + ### get-storage @@ -2137,6 +2162,42 @@ Examples: --- + + +### tui-notify + +`(tui-notify [] [:str>] [])` + +Show a desktop-like notification in the TUI. + +This function is only available in TUI mode. It has the following arguments: + +- message: The message to show. +- title: (Optional) The title of the notification. +- severity: (Optional) The severity of the notification. This can be one of the following values: + - information (the default) + - warning + - error +- timeout: (Optional) The timeout in seconds after which the notification will disappear again. If not specified, the notification will disappear after 3 seconds. + +If one of the optional arguments needs to be left out, a *nil* symbol must be used instead. +The function returns NIL. + +Examples: + +```lisp +(tui-notify "a message") ;; Displays "a message" in an information notification for 3 seconds +(tui-notify "a message" "a title") ;; Displays "a message" with title "a title in an information notification for 3 seconds +(tui-notify "a message") ;; Displays "a message" in an information notification for 3 seconds +(tui-notify "a message" nil "warning") ;; Displays "a message" in a warning notification, no title +(tui-notify "a message" nil nil 10) ;; Displays "a message" in an information notification, no title, for 3 seconds + +``` + +[top](#top) + +--- + ### tui-refresh-resources From 18532d00639a7392d3482496dd89e753f10480da Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 20 Jul 2023 12:20:01 +0200 Subject: [PATCH 055/165] More match..case conversions --- acme/etc/Utils.py | 196 ++++++------ acme/helpers/Interpreter.py | 263 ++++++++-------- acme/helpers/MQTTConnection.py | 41 +-- acme/resources/SUB.py | 28 +- acme/resources/TS.py | 57 ++-- acme/services/Configuration.py | 79 +++-- acme/services/Console.py | 51 ++-- acme/services/Dispatcher.py | 288 +++++++++--------- acme/services/Importer.py | 55 ++-- acme/services/NotificationManager.py | 117 +++---- acme/services/RequestManager.py | 115 ++++--- acme/services/Storage.py | 19 +- acme/services/Validator.py | 238 ++++++++------- acme/textui/ACMEContainerTree.py | 13 +- acme/textui/ACMETuiApp.py | 1 + tests/config.py | 38 ++- tests/init.py | 55 ++-- .../notificationServer/notificationServer.py | 108 ++++--- 18 files changed, 928 insertions(+), 834 deletions(-) diff --git a/acme/etc/Utils.py b/acme/etc/Utils.py index d446100e..791abd26 100644 --- a/acme/etc/Utils.py +++ b/acme/etc/Utils.py @@ -160,13 +160,15 @@ def isStructured(uri:str) -> bool: Return: Boolean if the URI is in structured format """ - if isCSERelative(uri): - return '/' in uri or uri == CSE.cseRn - elif isSPRelative(uri): - return uri.count('/') > 2 - elif isAbsolute(uri): - return uri.count('/') > 4 - return False + match uri: + case x if isCSERelative(uri): + return '/' in uri or uri == CSE.cseRn + case x if isSPRelative(uri): + return uri.count('/') > 2 + case x if isAbsolute(uri): + return uri.count('/') > 4 + case _: + return False def localResourceID(ri:str) -> Optional[str]: @@ -197,16 +199,19 @@ def _checkDash(ri:str) -> str: if ri == CSE.cseCsi: return CSE.cseRn - if isAbsolute(ri): - if ri.startswith(CSE.cseAbsoluteSlash): - return _checkDash(ri[len(CSE.cseAbsoluteSlash):]) - return None - elif isSPRelative(ri): - if ri.startswith(CSE.cseCsiSlash): - return _checkDash(ri[len(CSE.cseCsiSlash):]) - return None - return ri + match ri: + case x if isAbsolute(x): + if ri.startswith(CSE.cseAbsoluteSlash): + return _checkDash(ri[len(CSE.cseAbsoluteSlash):]) + return None + case x if isSPRelative(x): + if ri.startswith(CSE.cseCsiSlash): + return _checkDash(ri[len(CSE.cseCsiSlash):]) + return None + case _: + return ri + def isValidID(id:str, allowEmpty:Optional[bool] = False) -> bool: """ Test for a valid ID. @@ -318,10 +323,11 @@ def csiFromRelativeAbsoluteUnstructured(id:str) -> Tuple[str, list[str]]: Tuple (CSE ID (no leading slashes) without any SP-ID or CSE-ID, list of path elements) """ ids = id.split('/') - if isSPRelative(id): - return ids[1], ids - elif isAbsolute(id): - return ids[3], ids + match id: + case x if isSPRelative(x): + return ids[1], ids + case x if isAbsolute(x): + return ids[3], ids return id, ids @@ -386,67 +392,62 @@ def retrieveIDFromPath(id:str) -> Tuple[str, str, str, str]: vrPresent = ids.pop() # remove and return last path element idsLen -= 1 - # CSE-Relative (first element is not /) - if lvl == 0: - # L.logDebug("CSE-Relative") - if idsLen == 1 and ((ids[0] != CSE.cseRn and ids[0] != '-') or ids[0] == CSE.cseCsiSlashLess): # unstructured - ri = ids[0] - else: # structured - if ids[0] == '-': # replace placeholder "-". Always convert in CSE-relative - ids[0] = CSE.cseRn - srn = '/'.join(ids) - - # SP-Relative (first element is /) - elif lvl == 1: - # L.logDebug("SP-Relative") - if idsLen < 2: - return None, None, None, f'ID too short: {id}. Must be //.' - csi = ids[0] # extract the csi - if csi != CSE.cseCsiSlashLess: # Not for this CSE? retargeting - if vrPresent: # append last path element again - ids.append(vrPresent) - return id, csi, srn, None # Early return. ri is the (un)structured path - # if idsLen == 1: - # # ri = ids[0] - # return None, None, None, 'ID too short' - #elif idsLen > 1: + match lvl: + + # CSE-Relative (first element is not /) + case 0: + if idsLen == 1 and ((ids[0] != CSE.cseRn and ids[0] != '-') or ids[0] == CSE.cseCsiSlashLess): # unstructured + ri = ids[0] + else: # structured + if ids[0] == '-': # replace placeholder "-". Always convert in CSE-relative + ids[0] = CSE.cseRn + srn = '/'.join(ids) + + # SP-Relative (first element is /) + case 1: + # L.logDebug("SP-Relative") + if idsLen < 2: + return None, None, None, f'ID too short: {id}. Must be //.' + csi = ids[0] # extract the csi + if csi != CSE.cseCsiSlashLess: # Not for this CSE? retargeting + if vrPresent: # append last path element again + ids.append(vrPresent) + return id, csi, srn, None # Early return. ri is the (un)structured path - # replace placeholder "-", convert in CSE-relative when the target is this CSE - if ids[1] == '-' and ids[0] == CSE.cseCsiSlashLess: - ids[1] = CSE.cseRn - if ids[1] == CSE.cseRn: # structured - srn = '/'.join(ids[1:]) # remove the csi part - elif idsLen == 2: # unstructured - ri = ids[1] - else: - return None, None, None, 'Too many "/" level' - - # Absolute (2 first elements are /) - elif lvl == 2: - # L.logDebug("Absolute") - if idsLen < 3: - return None, None, None, 'ID too short. Must be ////.' - spi = ids[0] - csi = ids[1] - if spi != CSE.cseSpid: # Check for SP-ID - return None, None, None, f'SP-ID: {CSE.cseSpid} does not match the request\'s target ID SP-ID: {spi}' - if csi != CSE.cseCsiSlashLess: # Check for CSE-ID - if vrPresent: # append virtual last path element again - ids.append(vrPresent) - return id, csi, srn, None # Not for this CSE? retargeting - # if idsLen == 2: - # ri = ids[1] - # elif idsLen > 2: - - # replace placeholder "-", convert in absolute when the target is this CSE - if ids[2] == '-' and ids[1] == CSE.cseCsiSlashLess: - ids[2] = CSE.cseRn - if ids[2] == CSE.cseRn: # structured - srn = '/'.join(ids[2:]) - elif idsLen == 3: # unstructured - ri = ids[2] - else: - return None, None, None, 'Too many "/" level' + # replace placeholder "-", convert in CSE-relative when the target is this CSE + if ids[1] == '-' and ids[0] == CSE.cseCsiSlashLess: + ids[1] = CSE.cseRn + if ids[1] == CSE.cseRn: # structured + srn = '/'.join(ids[1:]) # remove the csi part + elif idsLen == 2: # unstructured + ri = ids[1] + else: + return None, None, None, 'Too many "/" level' + + + # Absolute (2 first elements are /) + case 2: + # L.logDebug("Absolute") + if idsLen < 3: + return None, None, None, 'ID too short. Must be ////.' + spi = ids[0] + csi = ids[1] + if spi != CSE.cseSpid: # Check for SP-ID + return None, None, None, f'SP-ID: {CSE.cseSpid} does not match the request\'s target ID SP-ID: {spi}' + if csi != CSE.cseCsiSlashLess: # Check for CSE-ID + if vrPresent: # append virtual last path element again + ids.append(vrPresent) + return id, csi, srn, None # Not for this CSE? retargeting + + # replace placeholder "-", convert in absolute when the target is this CSE + if ids[2] == '-' and ids[1] == CSE.cseCsiSlashLess: + ids[2] = CSE.cseRn + if ids[2] == CSE.cseRn: # structured + srn = '/'.join(ids[2:]) + elif idsLen == 3: # unstructured + ri = ids[2] + else: + return None, None, None, 'Too many "/" level' # Now either csi, ri or structured srn is set if ri: @@ -766,22 +767,25 @@ def getAttributeSize(attribute:Any) -> int: Byte size of the attribute's value. """ size = 0 - if isinstance(attribute, str): - size = len(attribute) - elif isinstance(attribute, int): - size = 4 - elif isinstance(attribute, float): - size = 8 - elif isinstance(attribute, bool): - size = 1 - elif isinstance(attribute, list): # recurse a list - for e in attribute: - size += getAttributeSize(e) - elif isinstance(attribute, dict): # recurse a dictionary - for _,v in attribute: - size += getAttributeSize(v) - else: - size = sys.getsizeof(attribute) # fallback for not handled types + + match attribute: + case str(): + size = len(attribute) + case int(): + size = 4 + case float(): + size = 8 + case bool(): + size = 1 + case list(): # recurse a list + for e in attribute: + size += getAttributeSize(e) + case dict(): # recurse a dictionary + for _,v in attribute: + size += getAttributeSize(v) + case _: # fallback for not handled types + size = sys.getsizeof(attribute) + return size diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index b2047105..af3fc2b9 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -176,11 +176,13 @@ def unquote(self) -> SType: Return: The unquotde version of a quoted type. If the type is not a quoted type then return the same type. """ - if self == SType.tListQuote: - return SType.tList - elif self == SType.tSymbolQuote: - return SType.tSymbol - return self + match self: + case SType.tListQuote: + return SType.tList + case SType.tSymbolQuote: + return SType.tSymbol + case _: + return self class SSymbol(object): @@ -237,20 +239,19 @@ def __init__(self, string:str = None, # Try to determine an unknown type if value: - if isinstance(value, bool): - boolean = value - elif isinstance(value, str): - string = value - elif isinstance(value, (int, float)): - number = Decimal(value) - # elif isinstance(value, list): - # lstQuote = value - elif isinstance(value, dict): - jsn = value - elif isinstance(value, list): - lstQuote = [ SSymbol(value = _v) for _v in value ] - else: - raise ValueError(f'Unsupported type: {type(value)} for value: {value}') + match value: + case bool(): + boolean = value + case str(): + string = value + case int() | float(): + number = Decimal(value) + case dict(): + jsn = value + case list(): + lstQuote = [ SSymbol(value = _v) for _v in value ] + case _: + raise ValueError(f'Unsupported type: {type(value)} for value: {value}') # Assign known types if string is not None: # could be empty string @@ -402,16 +403,18 @@ def raw(self) -> Any: Return: The raw value. For types that could not be converted directly the stringified version is returned. """ - if self.type in [ SType.tList, SType.tListQuote ]: - return [ v.raw() for v in cast(list, self.value) ] - elif self.type in [ SType.tBool, SType.tString, SType.tSymbol, SType.tSymbolQuote, SType.tJson ]: - return self.value - if self.type == SType.tNumber: - if '.' in str(self.value): # float or int? - return float(cast(Decimal, self.value)) - return int(cast(Decimal, self.value)) - return str(self.value) - + match self.type: + case SType.tList | SType.tListQuote: + return [ v.raw() for v in cast(list, self.value) ] + case SType.tBool | SType.tString | SType.tSymbol | SType.tSymbolQuote | SType.tJson: + return self.value + case SType.tNumber: + if '.' in str(self.value): # float or int? + return float(cast(Decimal, self.value)) + return int(cast(Decimal, self.value)) + case _: + return str(self.value) + class SExprParser(object): """ Class that implements an S-Expression parser. """ @@ -554,45 +557,51 @@ def ast(self, input:List[SSymbol]|str, index += 1 continue - if symbol.type == SType.tListBegin: # Start of another list - startIndex = index + 1 - matchCtr = 1 # If 0, parenthesis has been matched. - # Determine the matching closing paranthesis on the same level - while matchCtr != 0: - index += 1 - if index >= len(input): - self.errorExpression = input # type:ignore[assignment] - raise ValueError(f'Invalid input: Unmatched opening parenthesis: {input}') - symbol = input[index] - if symbol.type == SType.tListBegin: - matchCtr += 1 - elif symbol.type == SType.tListEnd: - matchCtr -= 1 - - if isQuote: # escaped with ' -> plain list - ast.append(SSymbol(lstQuote = self.ast(input[startIndex:index], False, allowBrackets))) - else: # normal list - ast.append(SSymbol(lst = self.ast(input[startIndex:index], False, allowBrackets))) - elif symbol.type == SType.tListEnd: + match symbol.type: + case SType.tListBegin: + startIndex = index + 1 + matchCtr = 1 # If 0, parenthesis has been matched. + # Determine the matching closing paranthesis on the same level + while matchCtr != 0: + index += 1 + if index >= len(input): + self.errorExpression = input # type:ignore[assignment] + raise ValueError(f'Invalid input: Unmatched opening parenthesis: {input}') + symbol = input[index] + + match symbol.type: + case SType.tListBegin: + matchCtr += 1 + case SType.tListEnd: + matchCtr -= 1 + # ignore other types + + if isQuote: # escaped with ' -> plain list + ast.append(SSymbol(lstQuote = self.ast(input[startIndex:index], False, allowBrackets))) + else: # normal list + ast.append(SSymbol(lst = self.ast(input[startIndex:index], False, allowBrackets))) + + case SType.tListEnd: self.errorExpression = input # type:ignore[assignment] raise ValueError('Invalid input: Unmatched closing parenthesis.') - elif symbol.type == SType.tJson: - ast.append(symbol) - elif symbol.type == SType.tString: - ast.append(symbol) - else: - try: - ast.append(SSymbol(number = Decimal(symbol.value))) # type:ignore [arg-type] - except InvalidOperation: - if symbol.type == SType.tSymbol and symbol.value in [ 'true', 'false' ]: - ast.append(SSymbol(boolean = (symbol.value == 'true'))) - elif symbol.type == SType.tSymbol and symbol.value == 'nil': - ast.append(SSymbol()) - else: - if (_s := cast(str, symbol.value)).startswith('\''): - ast.append(SSymbol(symbolQuote = _s)) - else: - ast.append(symbol) + + case SType.tJson | SType.tString: + ast.append(symbol) + + case _: + try: + ast.append(SSymbol(number = Decimal(symbol.value))) # type:ignore [arg-type] + except InvalidOperation: + match symbol.type: + case SType.tSymbol if symbol.value in [ 'true', 'false' ]: + ast.append(SSymbol(boolean = (symbol.value == 'true'))) + case SType.tSymbol if symbol.value == 'nil': + ast.append(SSymbol()) + case _: + if (_s := cast(str, symbol.value)).startswith('\''): + ast.append(SSymbol(symbolQuote = _s)) + else: + ast.append(symbol) index += 1 isQuote = False @@ -1412,65 +1421,61 @@ def _executeExpression(self, symbol:SSymbol, parentSymbol:SSymbol) -> PContext: return self.setResult(SSymbol()) firstSymbol = symbol[0] if symbol.length and symbol.type == SType.tList else symbol - if firstSymbol.type == SType.tList: - if firstSymbol.length > 0: - # implicit progn - return _doProgn(self, SSymbol(lst = [ SSymbol(symbol = 'progn') ] + symbol.value )) #type:ignore[operator] - else: - self.result = SSymbol() - return self - - elif firstSymbol.type == SType.tListQuote: - return _doQuote(self, SSymbol(lst = [ SSymbol(symbol = 'quote'), SSymbol(lst = firstSymbol.value)])) - - elif firstSymbol.type == SType.tSymbol: - _s = cast(str, firstSymbol.value) - - # Just return already boolean values in the result here - if (_fn := self.functions.get(_s)) is not None: - return self._executeFunction(symbol, _s, _fn) - elif (_cb := self.symbols.get(_s)) is not None: # type:ignore[arg-type] - if self.monitorFunc: - self.monitorFunc(self, firstSymbol) - return _cb(self, symbol) - elif _s in self.call.arguments: - self.result = deepcopy(self.call.arguments[_s]) - return self - elif _s in self.variables: - self.result = deepcopy(self.variables[_s]) - return self - elif _s in self.environment: - self.result = deepcopy(self.environment[_s]) - return self - - # Try to get the symbol's value from the caller, if possible - else: - if self.fallbackFunc: - return self.fallbackFunc(self, symbol) - raise PUndefinedError(self.setError(PError.undefined, f'undefined symbol: {_s} | in symbol: {parentSymbol}')) - - elif firstSymbol.type == SType.tSymbolQuote: - return _doQuote(self, SSymbol(lst = [ SSymbol(symbol = 'quote'), SSymbol(symbol = firstSymbol.value)])) - - elif firstSymbol.type == SType.tLambda: - return self._executeFunction(symbol, cast(str, firstSymbol.value)) - - elif firstSymbol.type == SType.tString: - return self.checkInStringExpressions(firstSymbol) + match firstSymbol.type: + case SType.tList: + if firstSymbol.length > 0: + # implicit progn + return _doProgn(self, SSymbol(lst = [ SSymbol(symbol = 'progn') ] + symbol.value )) #type:ignore[operator] + else: + self.result = SSymbol() + return self - elif firstSymbol.type == SType.tNumber: - return self.setResult(firstSymbol) # type:ignore [arg-type] - - elif firstSymbol.type == SType.tBool: - return self.setResult(firstSymbol) + case SType.tListQuote: + return _doQuote(self, SSymbol(lst = [ SSymbol(symbol = 'quote'), SSymbol(lst = firstSymbol.value)])) + + case SType.tSymbol: + _s = cast(str, firstSymbol.value) + + # Execute function, if defined, or try to find the value in variables, environment, etc. + if (_fn := self.functions.get(_s)) is not None: + return self._executeFunction(symbol, _s, _fn) + elif (_cb := self.symbols.get(_s)) is not None: # type:ignore[arg-type] + if self.monitorFunc: + self.monitorFunc(self, firstSymbol) + return _cb(self, symbol) + elif _s in self.call.arguments: + self.result = deepcopy(self.call.arguments[_s]) + return self + elif _s in self.variables: + self.result = deepcopy(self.variables[_s]) + return self + elif _s in self.environment: + self.result = deepcopy(self.environment[_s]) + return self + + # Try to get the symbol's value from the caller as a last resort + else: + if self.fallbackFunc: + return self.fallbackFunc(self, symbol) + raise PUndefinedError(self.setError(PError.undefined, f'undefined symbol: {_s} | in symbol: {parentSymbol}')) - elif firstSymbol.type == SType.tJson: - return self.checkInStringExpressions(symbol) + case SType.tSymbolQuote: + return _doQuote(self, SSymbol(lst = [ SSymbol(symbol = 'quote'), SSymbol(symbol = firstSymbol.value)])) - elif firstSymbol.type == SType.tNIL: - return self.setResult(firstSymbol) - - raise PInvalidArgumentError(self.setError(PError.invalid, f'Unexpected symbol: {firstSymbol.type} - {firstSymbol}')) + case SType.tLambda: + return self._executeFunction(symbol, cast(str, firstSymbol.value)) + + case SType.tString: + return self.checkInStringExpressions(firstSymbol) + + case SType.tNumber | SType.tBool | SType.tNIL: + return self.setResult(firstSymbol) # type:ignore [arg-type] + + case SType.tJson: + return self.checkInStringExpressions(symbol) + + case _: + raise PInvalidArgumentError(self.setError(PError.invalid, f'Unexpected symbol: {firstSymbol.type} - {firstSymbol}')) def checkInStringExpressions(self, symbol:SSymbol) -> PContext: @@ -1879,12 +1884,14 @@ def _doCons(pcontext:PContext, symbol:SSymbol) -> PContext: # get second symbol pcontext, _second = pcontext.valueFromArgument(symbol, 2) - if _second.type in [SType.tList, SType.tListQuote]: - pcontext.result = deepcopy(_second) - elif _second.type == SType.tNIL: - pcontext.result = SSymbol(lst = []) - else: - pcontext.result = SSymbol(lst = [ deepcopy(_second) ]) + match _second.type: + case SType.tList | SType.tListQuote: + pcontext.result = deepcopy(_second) + case SType.tNIL: + pcontext.result = SSymbol(lst = []) + case _: + pcontext.result = SSymbol(lst = [ deepcopy(_second) ]) + cast(list, pcontext.result.value).insert(0, deepcopy(_first)) return pcontext diff --git a/acme/helpers/MQTTConnection.py b/acme/helpers/MQTTConnection.py index b2c84621..9cd870da 100644 --- a/acme/helpers/MQTTConnection.py +++ b/acme/helpers/MQTTConnection.py @@ -249,20 +249,22 @@ def _onDisconnect(self, client:mqtt.Client, userdata:Any, rc:int) -> None: """ self.messageHandler and self.messageHandler.logging(self, logging.DEBUG, f'MQTT: Disconnected with result code: {rc} ({mqtt.error_string(rc)})') self.subscribedTopics.clear() - if rc == 0: - self.isConnected = False - self.messageHandler and self.messageHandler.onDisconnect(self) - elif rc == 7: - self.isConnected = False - self.messageHandler.logging(self, logging.ERROR, f'MQTT: Cannot disconnect from broker. Result code: {rc} ({mqtt.error_string(rc)})') - self.messageHandler.logging(self, logging.ERROR, f'MQTT: Did another client connected with the same ID ({self.clientID})?') - self.messageHandler and self.messageHandler.onDisconnect(self) - else: - self.isConnected = False - if self.messageHandler: + + match rc: + case 0: + self.isConnected = False + self.messageHandler and self.messageHandler.onDisconnect(self) + case 7: + self.isConnected = False self.messageHandler.logging(self, logging.ERROR, f'MQTT: Cannot disconnect from broker. Result code: {rc} ({mqtt.error_string(rc)})') - self.messageHandler.onDisconnect(self) - self.messageHandler.onError(self, rc) + self.messageHandler.logging(self, logging.ERROR, f'MQTT: Did another client connected with the same ID ({self.clientID})?') + self.messageHandler and self.messageHandler.onDisconnect(self) + case _: + self.isConnected = False + if self.messageHandler: + self.messageHandler.logging(self, logging.ERROR, f'MQTT: Cannot disconnect from broker. Result code: {rc} ({mqtt.error_string(rc)})') + self.messageHandler.onDisconnect(self) + self.messageHandler.onError(self, rc) def _onLog(self, client:mqtt.Client, userdata:Any, level:int, buf:str) -> None: @@ -402,12 +404,13 @@ def idToMQTTClientID(id:str, isCSE:Optional[bool] = True) -> str: def mqttToId(mqttId:str, isCSE:Optional[bool] = True) -> Tuple[str, bool]: """ Convert an MQTT compatible path element to an ID. """ - if mqttId.startswith('A:'): - isCSE = False - elif mqttId.startswith('C:'): - isCSE = True - else: - return None, False + match mqttId: + case x if x.startswith('A:'): + isCSE = False + case x if x.startswith('C:'): + isCSE = True + case _: + return None, False return mqttId[2:].replace(':', '/'), isCSE diff --git a/acme/resources/SUB.py b/acme/resources/SUB.py index e648f8d2..911681e8 100644 --- a/acme/resources/SUB.py +++ b/acme/resources/SUB.py @@ -100,17 +100,23 @@ def activate(self, parentResource:Resource, originator:str) -> None: # Apply the nct only on the first element of net. Do the combination checks later in validate() net = self['enc/net'] if len(net) > 0: - if net[0] in [ NotificationEventType.resourceUpdate, NotificationEventType.resourceDelete, - NotificationEventType.createDirectChild, NotificationEventType.deleteDirectChild, - NotificationEventType.retrieveCNTNoChild ]: - self.setAttribute('nct', NotificationContentType.allAttributes, overwrite = False) - elif net[0] in [ NotificationEventType.triggerReceivedForAE ]: - self.setAttribute('nct', NotificationContentType.triggerPayload, overwrite = False) - elif net[0] in [ NotificationEventType.blockingUpdate ]: - self.setAttribute('nct', NotificationContentType.modifiedAttributes, overwrite = False) - elif net[0] in [ NotificationEventType.reportOnGeneratedMissingDataPoints ]: - self.setAttribute('nct', NotificationContentType.timeSeriesNotification, overwrite = False) - + match net[0]: + case NotificationEventType.resourceUpdate |\ + NotificationEventType.resourceDelete |\ + NotificationEventType.createDirectChild |\ + NotificationEventType.deleteDirectChild |\ + NotificationEventType.retrieveCNTNoChild: + self.setAttribute('nct', NotificationContentType.allAttributes, overwrite = False) + + case NotificationEventType.triggerReceivedForAE: + self.setAttribute('nct', NotificationContentType.triggerPayload, overwrite = False) + + case NotificationEventType.blockingUpdate: + self.setAttribute('nct', NotificationContentType.modifiedAttributes, overwrite = False) + + case NotificationEventType.reportOnGeneratedMissingDataPoints: + self.setAttribute('nct', NotificationContentType.timeSeriesNotification, overwrite = False) + # check whether an observed child resource type is actually allowed by the parent if chty := self['enc/chty']: self._checkAllowedCHTY(parentResource, chty) diff --git a/acme/resources/TS.py b/acme/resources/TS.py index 2471b848..f3d1484d 100644 --- a/acme/resources/TS.py +++ b/acme/resources/TS.py @@ -237,39 +237,42 @@ def childWillBeAdded(self, childResource:Resource, originator:str) -> None: def childAdded(self, childResource:Resource, originator:str) -> None: L.isDebug and L.logDebug(f'Child resource added: {childResource.ri}') super().childAdded(childResource, originator) - if childResource.ty == ResourceTypes.TSI: # Validate if child is TSI - - # Check for mia handling. This sets the et attribute in the TSI - if self.mia is not None: - # Take either mia or the maxExpirationDelta, whatever is smaller - maxEt = getResourceDate(self.mia - if self.mia <= CSE.request.maxExpirationDelta - else CSE.request.maxExpirationDelta) - # Only replace the childresource's et if it is greater than the calculated maxEt - if childResource.et > maxEt: - childResource.setAttribute('et', maxEt) - childResource.dbUpdate(True) - - self.validate(originator) # Handle old TSI removals - - # Add to monitoring if this is enabled for this TS (mdd & pei & mdt are not None, and mdd==True) - if self.mdd and self.pei is not None and self.mdt is not None: - CSE.timeSeries.updateTimeSeries(self, childResource) - - elif childResource.ty == ResourceTypes.SUB: # start monitoring - if childResource['enc/md']: - CSE.timeSeries.addSubscription(self, childResource) + match childResource.ty: + case ResourceTypes.TSI: + # Check for mia handling. This sets the et attribute in the TSI + if self.mia is not None: + # Take either mia or the maxExpirationDelta, whatever is smaller + maxEt = getResourceDate(self.mia + if self.mia <= CSE.request.maxExpirationDelta + else CSE.request.maxExpirationDelta) + # Only replace the childresource's et if it is greater than the calculated maxEt + if childResource.et > maxEt: + childResource.setAttribute('et', maxEt) + childResource.dbUpdate(True) + + self.validate(originator) # Handle old TSI removals + + # Add to monitoring if this is enabled for this TS (mdd & pei & mdt are not None, and mdd==True) + if self.mdd and self.pei is not None and self.mdt is not None: + CSE.timeSeries.updateTimeSeries(self, childResource) + + case ResourceTypes.SUB: + # start monitoring + if childResource['enc/md']: + CSE.timeSeries.addSubscription(self, childResource) # Handle the removal of a TSI. def childRemoved(self, childResource:Resource, originator:str) -> None: L.isDebug and L.logDebug(f'Child resource removed: {childResource.ri}') super().childRemoved(childResource, originator) - if childResource.ty == ResourceTypes.TSI: # Validate if child was TSI - self._validateChildren() - elif childResource.ty == ResourceTypes.SUB: - if childResource['enc/md']: - CSE.timeSeries.removeSubscription(self, childResource) + match childResource.ty: + case ResourceTypes.TSI: + # Validate if removed child was TSI + self._validateChildren() + case ResourceTypes.SUB: + if childResource['enc/md']: + CSE.timeSeries.removeSubscription(self, childResource) # handle eventuel updates of subscriptions diff --git a/acme/services/Configuration.py b/acme/services/Configuration.py index 5122c853..7f09ed4e 100644 --- a/acme/services/Configuration.py +++ b/acme/services/Configuration.py @@ -362,6 +362,24 @@ def init(args:argparse.Namespace = None) -> bool: 'mqtt.security.useTLS' : config.getboolean('mqtt.security', 'useTLS', fallback = False), 'mqtt.security.verifyCertificate' : config.getboolean('mqtt.security', 'verifyCertificate', fallback = False), + # + # CoAP Client + # + + 'coap.enable' : config.getboolean('coap', 'enable', fallback = False), + 'coap.listenIF' : config.get('coap', 'listenIF', fallback = '127.0.0.1'), + 'coap.port' : config.getint('coap', 'port', fallback = None), # Default will be determined later (s.b.) + + # + # CoAP Client Security + # + + 'coap.security.certificateFile' : config.get('coap.security', 'certificateFile', fallback = None), + 'coap.security.privateKeyFile' : config.get('coap.security', 'privateKeyFile', fallback = None), + 'coap.security.dtlsVersion' : config.get('coap.security', 'dtlsVersion', fallback = 'auto'), + 'coap.security.useDTLS' : config.getboolean('coap.security', 'useDTLS', fallback = False), + 'coap.security.verifyCertificate' : config.getboolean('coap.security', 'verifyCertificate', fallback = False), + # # Defaults for Access Control Policies @@ -465,12 +483,15 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: # CSE type if isinstance(cseType := Configuration._configuration['cse.type'], str): cseType = cseType.lower() - if cseType == 'asn': - Configuration._configuration['cse.type'] = CSEType.ASN - elif cseType == 'mn': - Configuration._configuration['cse.type'] = CSEType.MN - else: - Configuration._configuration['cse.type'] = CSEType.IN + match cseType: + case 'asn': + Configuration._configuration['cse.type'] = CSEType.ASN + case 'mn': + Configuration._configuration['cse.type'] = CSEType.MN + case 'in': + Configuration._configuration['cse.type'] = CSEType.IN + case _: + return False, f'Configuration Error: Unsupported \[cse]:type: {cseType}' # CSE Serialization if isinstance(ct := Configuration._configuration['cse.defaultSerialization'], str): @@ -489,16 +510,21 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: if isinstance(logLevel := Configuration._configuration['logging.level'], str): logLevel = logLevel.lower() logLevel = (Configuration._argsLoglevel or logLevel) # command line args override config - if logLevel == 'off': - Configuration._configuration['logging.level'] = LogLevel.OFF - elif logLevel == 'info': - Configuration._configuration['logging.level'] = LogLevel.INFO - elif logLevel in [ 'warn', 'warning' ]: - Configuration._configuration['logging.level'] = LogLevel.WARNING - elif logLevel == 'error': - Configuration._configuration['logging.level'] = LogLevel.ERROR - else: - Configuration._configuration['logging.level'] = LogLevel.DEBUG + + match logLevel: + case 'off': + Configuration._configuration['logging.level'] = LogLevel.OFF + case 'info': + Configuration._configuration['logging.level'] = LogLevel.INFO + case 'warn' | 'warning': + Configuration._configuration['logging.level'] = LogLevel.WARNING + case 'error': + Configuration._configuration['logging.level'] = LogLevel.ERROR + case 'debug': + Configuration._configuration['logging.level'] = LogLevel.DEBUG + case _: + return False, f'Configuration Error: Unsupported \[logging]:level: {logLevel}' + # Test for correct logging queue size if (queueSize := Configuration._configuration['logging.queueSize']) < 0: @@ -557,7 +583,7 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: # Some sanity and validity checks # - # TLS & certificates + # HTTP TLS & certificates if not Configuration._configuration['http.security.useTLS']: # clear certificates configuration if not in use Configuration._configuration['http.security.verifyCertificate'] = False Configuration._configuration['http.security.tlsVersion'] = 'auto' @@ -591,6 +617,25 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: Configuration._configuration['mqtt.security.allowedCredentialIDs'] = [ cid for cid in Configuration._configuration['mqtt.security.allowedCredentialIDs'] if len(cid) ] + # COAP TLS & certificates + if not Configuration._configuration['coap.security.useDTLS']: # clear certificates configuration if not in use + Configuration._configuration['coap.security.verifyCertificate'] = False + Configuration._configuration['coap.security.tlsVersion'] = 'auto' + Configuration._configuration['coap.security.caCertificateFile'] = '' + Configuration._configuration['coap.security.caPrivateKeyFile'] = '' + else: + if not (val := Configuration._configuration['coap.security.dtlsVersion']).lower() in [ 'tls1.1', 'tls1.2', 'auto' ]: + return False, f'Configuration Error: Unknown value for [i]\[coap.security]:dtlsVersion[/i]: {val}' + if not (val := Configuration._configuration['coap.security.certificateFile']): + return False, 'Configuration Error: [i]\[coap.security]:certificateFile[/i] must be set when DTLS is enabled' + if not os.path.exists(val): + return False, f'Configuration Error: [i]\[coap.security]:certificateFile[/i] does not exists or is not accessible: {val}' + if not (val := Configuration._configuration['coap.security.privateKeyFile']): + return False, 'Configuration Error: [i]\[coap.security]:privateKeyFile[/i] must be set when TLS is enabled' + if not os.path.exists(val): + return False, f'Configuration Error: [i]\[coap.security]:privateKeyFile[/i] does not exists or is not accessible: {val}' + + # check the csi format and value if not isValidCSI(val:=Configuration._configuration['cse.cseID']): return False, f'Configuration Error: Wrong format for [i]\[cse]:cseID[/i]: {val}' diff --git a/acme/services/Console.py b/acme/services/Console.py index 8760a686..36eb7e62 100644 --- a/acme/services/Console.py +++ b/acme/services/Console.py @@ -1374,36 +1374,39 @@ def info(res:Resource) -> str: if self.treeMode not in [ TreeMode.COMPACT, TreeMode.CONTENTONLY ]: # if res.ty in [ T.FCNT, T.FCI] : # extraInfo = f' (cnd={res.cnd})' - if res.ty in [ ResourceTypes.CIN, ResourceTypes.TS ]: - extraInfo = f' ({res.cnf})' if res.cnf else '' - elif res.ty in [ ResourceTypes.CSEBase, ResourceTypes.CSEBaseAnnc, ResourceTypes.CSR ]: - extraInfo = f' (csi={res.csi})' - + match res.ty: + case ResourceTypes.FCNT | ResourceTypes.FCI: + extraInfo = f' ({res.cnf})' if res.cnf else '' + case ResourceTypes.CSEBase | ResourceTypes.CSEBaseAnnc | ResourceTypes.CSR: + extraInfo = f' (csi={res.csi})' + # Determine content contentInfo = '' if self.treeMode in [ TreeMode.CONTENT, TreeMode.CONTENTONLY ]: - if res.ty in [ ResourceTypes.CIN, ResourceTypes.TSI ]: - contentInfo = f'{res.con}' if res.con else '' - elif res.ty in [ ResourceTypes.FCNT, ResourceTypes.FCI ]: # All the custom attributes - contentInfo = ', '.join([ f'{attr}={str(res[attr])}' for attr in res.dict if CSE.validator.isExtraResourceAttribute(attr, res) ]) + match res.ty: + case ResourceTypes.CIN | ResourceTypes.TSI: + contentInfo = f'{res.con}' if res.con else '' + case ResourceTypes.FCNT | ResourceTypes.FCI: + contentInfo = ', '.join([ f'{attr}={str(res[attr])}' for attr in res.dict if CSE.validator.isExtraResourceAttribute(attr, res) ]) # construct the info info = '' - if self.treeMode == TreeMode.COMPACT: - info = f'-> {res.__rtype__}' - elif self.treeMode == TreeMode.CONTENT: - if len(contentInfo) > 0: - info = f'-> {res.__rtype__}{extraInfo} | {contentInfo}' - else: - info = f'-> {res.__rtype__}{extraInfo}' - elif self.treeMode == TreeMode.CONTENTONLY: - if len(contentInfo) > 0: - info = f'-> {contentInfo}' - else: # self.treeMode == NORMAL - if res.isVirtual(): - info = f'-> {res.__rtype__}{extraInfo} (virtual)' - else: - info = f'-> {res.__rtype__}{extraInfo} | ri={res.ri}' + match self.treeMode: + case TreeMode.COMPACT: + info = f'-> {res.__rtype__}' + case TreeMode.CONTENT: + if len(contentInfo) > 0: + info = f'-> {res.__rtype__}{extraInfo} | {contentInfo}' + else: + info = f'-> {res.__rtype__}{extraInfo}' + case TreeMode.CONTENTONLY: + if len(contentInfo) > 0: + info = f'-> {contentInfo}' + case _: # self.treeMode == NORMAL + if res.isVirtual(): + info = f'-> {res.__rtype__}{extraInfo} (virtual)' + else: + info = f'-> {res.__rtype__}{extraInfo} | ri={res.ri}' return f'{res.rn} [dim]{info}[/dim]' diff --git a/acme/services/Dispatcher.py b/acme/services/Dispatcher.py index 2b54128e..971a151c 100644 --- a/acme/services/Dispatcher.py +++ b/acme/services/Dispatcher.py @@ -177,57 +177,62 @@ def processRetrieveRequest(self, request:CSERequest, return Result(rsc = ResponseStatusCode.OK, resource = self._resourcesToURIList(_resources, request.drt)) else: - if rcn in [ ResultContentType.attributes, - ResultContentType.attributesAndChildResources, - ResultContentType.childResources, - ResultContentType.attributesAndChildResourceReferences, - ResultContentType.originalResource ]: - resource = self.retrieveResource(id, originator, request) - - if not CSE.security.hasAccess(originator, resource, permission): - raise ORIGINATOR_HAS_NO_PRIVILEGE(L.logDebug(f'originator: {originator} has no {permission} privileges for resource: {resource.ri}')) - - # if rcn == "attributes" then we can return here, whatever the result is - if rcn == ResultContentType.attributes: - resource.willBeRetrieved(originator, request) # resource instance may be changed in this call - - # partial retrieve? - return self._partialFromResource(resource, attributeList) - - # if rcn == original-resource we retrieve the linked resource - if rcn == ResultContentType.originalResource: - # Some checks for resource validity - if not resource.isAnnounced(): - raise BAD_REQUEST(L.logDebug(f'Resource {resource.ri} is not an announced resource')) - if not (lnk := resource.lnk): # no link attribute? - raise INTERNAL_SERVER_ERROR('internal error: missing lnk attribute in target resource') - - # Retrieve and check the linked-to request - linkedResource = self.retrieveResource(lnk, originator, request) - - # Normally, we would do some checks here and call "willBeRetrieved", - # but we don't have to, because the resource is already checked during the - # retrieveResource call by the hosting CSE - # partial retrieve? - return self._partialFromResource(linkedResource, attributeList) - - # - # Semantic query request - # This is indicated by rcn = semantic content - # - if rcn == ResultContentType.semanticContent: - L.isDebug and L.logDebug('Performing semantic discovery / query') - # Validate SPARQL in semanticFilter - CSE.semantic.validateSPARQL(request.fc.smf) - - # Get all accessible semanticDescriptors - resources = self.discoverResources(id, originator, filterCriteria = FilterCriteria(ty = [ResourceTypes.SMD])) - - # Execute semantic query - res = CSE.semantic.executeSPARQLQuery(request.fc.smf, cast(Sequence[SMD], resources)) - L.isDebug and L.logDebug(f'SPARQL query result: {res.data}') - return Result(rsc = ResponseStatusCode.OK, data = { 'm2m:qres' : res.data }) + # We can handle some rcn here directly, but some will be handled after this + match rcn: + case ResultContentType.attributes |\ + ResultContentType.attributesAndChildResources |\ + ResultContentType.childResources |\ + ResultContentType.attributesAndChildResourceReferences|\ + ResultContentType.originalResource: + + resource = self.retrieveResource(id, originator, request) + + if not CSE.security.hasAccess(originator, resource, permission): + raise ORIGINATOR_HAS_NO_PRIVILEGE(L.logDebug(f'originator: {originator} has no {permission} privileges for resource: {resource.ri}')) + + match rcn: + case ResultContentType.attributes: + # if rcn == "attributes" then we can return here, whatever the result is + resource.willBeRetrieved(originator, request) # resource instance may be changed in this call + + # partial retrieve? + return self._partialFromResource(resource, attributeList) + + case ResultContentType.originalResource: + # if rcn == original-resource we retrieve the linked resource + + # Some checks for resource validity + if not resource.isAnnounced(): + raise BAD_REQUEST(L.logDebug(f'Resource {resource.ri} is not an announced resource')) + if not (lnk := resource.lnk): # no link attribute? + raise INTERNAL_SERVER_ERROR('internal error: missing lnk attribute in target resource') + + # Retrieve and check the linked-to request + linkedResource = self.retrieveResource(lnk, originator, request) + + # Normally, we would do some checks here and call "willBeRetrieved", + # but we don't have to, because the resource is already checked during the + # retrieveResource call by the hosting CSE + + # partial retrieve? + return self._partialFromResource(linkedResource, attributeList) + + + case ResultContentType.semanticContent: + # Semantic query request + # This is indicated by rcn = semantic content + L.isDebug and L.logDebug('Performing semantic discovery / query') + # Validate SPARQL in semanticFilter + CSE.semantic.validateSPARQL(request.fc.smf) + + # Get all accessible semanticDescriptors + resources = self.discoverResources(id, originator, filterCriteria = FilterCriteria(ty = [ResourceTypes.SMD])) + + # Execute semantic query + res = CSE.semantic.executeSPARQLQuery(request.fc.smf, cast(Sequence[SMD], resources)) + L.isDebug and L.logDebug(f'SPARQL query result: {res.data}') + return Result(rsc = ResponseStatusCode.OK, data = { 'm2m:qres' : res.data }) # # Discovery request @@ -248,28 +253,29 @@ def processRetrieveRequest(self, request:CSERequest, # Handle more sophisticated RCN # - if rcn == ResultContentType.attributesAndChildResources: - self.resourceTreeDict(allowedResources, resource) # the function call add attributes to the target resource - return Result(rsc = ResponseStatusCode.OK, resource = resource) - - elif rcn == ResultContentType.attributesAndChildResourceReferences: - self._resourceTreeReferences(allowedResources, resource, request.drt, 'ch') # the function call add attributes to the target resource - return Result(rsc = ResponseStatusCode.OK, resource = resource) - - elif rcn == ResultContentType.childResourceReferences: - childResourcesRef = self._resourceTreeReferences(allowedResources, None, request.drt, 'm2m:rrl') - return Result(rsc = ResponseStatusCode.OK, resource = childResourcesRef) - - elif rcn == ResultContentType.childResources: - childResources:JSON = { resource.tpe : {} } # Root resource as a dict with no attribute - self.resourceTreeDict(allowedResources, childResources[resource.tpe]) # Adding just child resources - return Result(rsc = ResponseStatusCode.OK, resource = childResources) + match rcn: + case ResultContentType.attributesAndChildResources: + self.resourceTreeDict(allowedResources, resource) # the function call add attributes to the target resource + return Result(rsc = ResponseStatusCode.OK, resource = resource) + + case ResultContentType.attributesAndChildResourceReferences: + self._resourceTreeReferences(allowedResources, resource, request.drt, 'ch') # the function call add attributes to the target resource + return Result(rsc = ResponseStatusCode.OK, resource = resource) + + case ResultContentType.childResourceReferences: + childResourcesRef = self._resourceTreeReferences(allowedResources, None, request.drt, 'm2m:rrl') + return Result(rsc = ResponseStatusCode.OK, resource = childResourcesRef) - elif rcn == ResultContentType.discoveryResultReferences: # URIList - return Result(rsc = ResponseStatusCode.OK, resource = self._resourcesToURIList(allowedResources, request.drt)) + case ResultContentType.childResources: + childResources:JSON = { resource.tpe : {} } # Root resource as a dict with no attribute + self.resourceTreeDict(allowedResources, childResources[resource.tpe]) # Adding just child resources + return Result(rsc = ResponseStatusCode.OK, resource = childResources) - else: - raise BAD_REQUEST(f'unsuppored rcn: {rcn} for RETRIEVE') + case ResultContentType.discoveryResultReferences: + return Result(rsc = ResponseStatusCode.OK, resource = self._resourcesToURIList(allowedResources, request.drt)) + + case _: + raise BAD_REQUEST(f'unsuppored rcn: {rcn} for RETRIEVE') def retrieveResource(self, id:str, @@ -630,29 +636,32 @@ def processCreateRequest(self, request:CSERequest, # Handle RCN's # tpe = _resource.tpe - rcn = request.rcn - if rcn is None or rcn == ResultContentType.attributes: # Just the resource & attributes, integer - return Result(rsc = ResponseStatusCode.CREATED, resource = _resource) - - elif rcn == ResultContentType.modifiedAttributes: - dictOrg = request.pc[tpe] - dictNew = _resource.asDict()[tpe] - return Result(resource = { tpe : resourceModifiedAttributes(dictOrg, dictNew, request.pc[tpe]) }, - rsc = ResponseStatusCode.CREATED) - - elif rcn == ResultContentType.hierarchicalAddress: - return Result(resource = { 'm2m:uri' : _resource.structuredPath() }, - rsc = ResponseStatusCode.CREATED) - - elif rcn == ResultContentType.hierarchicalAddressAttributes: - return Result(resource = { 'm2m:rce' : { noNamespace(tpe) : _resource.asDict()[tpe], 'uri' : _resource.structuredPath() }}, - rsc = ResponseStatusCode.CREATED) - elif rcn == ResultContentType.nothing: - return Result(rsc = ResponseStatusCode.CREATED) + match request.rcn: + case None | ResultContentType.attributes: + # Just the resource & attributes, integer + return Result(rsc = ResponseStatusCode.CREATED, resource = _resource) + + case ResultContentType.modifiedAttributes: + dictOrg = request.pc[tpe] + dictNew = _resource.asDict()[tpe] + return Result(resource = { tpe : resourceModifiedAttributes(dictOrg, dictNew, request.pc[tpe]) }, + rsc = ResponseStatusCode.CREATED) + + case ResultContentType.hierarchicalAddress: + return Result(resource = { 'm2m:uri' : _resource.structuredPath() }, + rsc = ResponseStatusCode.CREATED) + + case ResultContentType.hierarchicalAddressAttributes: + return Result(resource = { 'm2m:rce' : { noNamespace(tpe) : _resource.asDict()[tpe], 'uri' : _resource.structuredPath() }}, + rsc = ResponseStatusCode.CREATED) + + case ResultContentType.nothing: + return Result(rsc = ResponseStatusCode.CREATED) + + case _: + raise BAD_REQUEST('wrong rcn for CREATE') - else: - raise BAD_REQUEST('wrong rcn for CREATE') # TODO C.rcnDiscoveryResultReferences @@ -839,25 +848,28 @@ def processUpdateRequest(self, request:CSERequest, # tpe = resource.tpe - if request.rcn is None or request.rcn == ResultContentType.attributes: # rcn is an int - return Result(rsc = ResponseStatusCode.UPDATED, resource = resource) + + match request.rcn: + case None | ResultContentType.attributes: + return Result(rsc = ResponseStatusCode.UPDATED, resource = resource) + + case ResultContentType.modifiedAttributes: + dictNew = deepcopy(resource.dict) + requestPC = request.pc[tpe] + # return only the modified attributes. This does only include those attributes that are updated differently, or are + # changed by the CSE, then from the original request. Luckily, all key/values that are touched in the update request + # are in the resource's __modified__ variable. + return Result(rsc = ResponseStatusCode.UPDATED, + resource = { tpe : resourceModifiedAttributes(dictOrg, dictNew, requestPC, modifiers = resource[Constants.attrModified]) }) + + case ResultContentType.nothing: + return Result(rsc = ResponseStatusCode.UPDATED) - elif request.rcn == ResultContentType.modifiedAttributes: - dictNew = deepcopy(resource.dict) - requestPC = request.pc[tpe] - # return only the modified attributes. This does only include those attributes that are updated differently, or are - # changed by the CSE, then from the original request. Luckily, all key/values that are touched in the update request - # are in the resource's __modified__ variable. - return Result(rsc = ResponseStatusCode.UPDATED, - resource = { tpe : resourceModifiedAttributes(dictOrg, dictNew, requestPC, modifiers = resource[Constants.attrModified]) }) - elif request.rcn == ResultContentType.nothing: - return Result(rsc = ResponseStatusCode.UPDATED) + case _: + raise BAD_REQUEST('wrong rcn for UPDATE') # TODO C.rcnDiscoveryResultReferences - else: - raise BAD_REQUEST('wrong rcn for UPDATE') - def updateLocalResource(self, resource:Resource, dct:Optional[JSON] = None, @@ -987,38 +999,42 @@ def processDeleteRequest(self, request:CSERequest, # resultContent:Resource|JSON = None - if request.rcn is None or request.rcn == ResultContentType.nothing: # rcn is an int - resultContent = None - - elif request.rcn == ResultContentType.attributes: - resultContent = resource - - # resource and child resources, full attributes - elif request.rcn == ResultContentType.attributesAndChildResources: - children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) - self._childResourceTree(children, resource) # the function call add attributes to the result resource. Don't use the return value directly - resultContent = resource - - # direct child resources, NOT the root resource - elif request.rcn == ResultContentType.childResources: - children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) - childResources:JSON = { resource.tpe : {} } # Root resource as a dict with no attributes - self.resourceTreeDict(children, childResources[resource.tpe]) - resultContent = childResources - - elif request.rcn == ResultContentType.attributesAndChildResourceReferences: - children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) - self._resourceTreeReferences(children, resource, request.drt, 'ch') # the function call add attributes to the result resource - resultContent = resource - - elif request.rcn == ResultContentType.childResourceReferences: # child resource references - children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) - childResourcesRef = self._resourceTreeReferences(children, None, request.drt, 'm2m:rrl') - resultContent = childResourcesRef + match request.rcn: + case None | ResultContentType.nothing: + resultContent = None + + case ResultContentType.attributes: + resultContent = resource + + case ResultContentType.attributesAndChildResources: + # resource and child resources, full attributes + children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) + self._childResourceTree(children, resource) # the function call add attributes to the result resource. Don't use the return value directly + resultContent = resource + + case ResultContentType.childResources: + # direct child resources, NOT the root resource + children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) + childResources:JSON = { resource.tpe : {} } # Root resource as a dict with no attributes + self.resourceTreeDict(children, childResources[resource.tpe]) + resultContent = childResources + + case ResultContentType.attributesAndChildResourceReferences: + # resource and child resource references + children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) + self._resourceTreeReferences(children, resource, request.drt, 'ch') # the function call add attributes to the result resource + resultContent = resource + + case ResultContentType.childResourceReferences: + # direct child resource references, NOT the root resource + children = self.discoverChildren(id, resource, originator, request.fc, Permission.DELETE) + childResourcesRef = self._resourceTreeReferences(children, None, request.drt, 'm2m:rrl') + resultContent = childResourcesRef + + case _: + raise BAD_REQUEST('wrong rcn for DELETE') # TODO RCN.discoveryResultReferences - else: - raise BAD_REQUEST('wrong rcn for DELETE') # remove resource self.deleteLocalResource(resource, originator, withDeregistration = True) diff --git a/acme/services/Importer.py b/acme/services/Importer.py index 31d8d2e8..f2f98b76 100644 --- a/acme/services/Importer.py +++ b/acme/services/Importer.py @@ -117,21 +117,21 @@ def importScripts(self, path:Optional[str] = None) -> bool: return False # Check that there is only one startup script, then execute it - if len(scripts := CSE.script.findScripts(meta = _metaInit)) > 1: - L.logErr(f'Only one initialization script allowed. Found: {",".join([ s.scriptName for s in scripts ])}') - return False - - elif len(scripts) == 1: - # Check whether there is already a filled DB, then skip the imports - if CSE.dispatcher.countResources() > 0: - L.isInfo and L.log('Resources already imported, skipping boostrap') - else: - # Run the startup script. There shall only be one. - s = scripts[0] - L.isInfo and L.log(f'Running boostrap script: {s.scriptName}') - if not CSE.script.runScript(s): - L.logErr(f'Error during startup: {s.error}') - return False + match len(scripts := CSE.script.findScripts(meta = _metaInit)): + case l if l > 1: + L.logErr(f'Only one initialization script allowed. Found: {",".join([ s.scriptName for s in scripts ])}') + return False + case 1: + # Check whether there is already a filled DB, then skip the imports + if CSE.dispatcher.countResources() > 0: + L.isInfo and L.log('Resources already imported, skipping boostrap') + else: + # Run the startup script. There shall only be one. + s = scripts[0] + L.isInfo and L.log(f'Running boostrap script: {s.scriptName}') + if not CSE.script.runScript(s): + L.logErr(f'Error during startup: {s.error}') + return False finally: # This is executed no matter whether the code above returned or just succeeded self._finishImporting() @@ -387,21 +387,22 @@ def importAttributePolicies(self, path:Optional[str] = None) -> bool: # Check whether there is an unresolved type used in any of the attributes (in the type and listType) # TODO ? The following can be optimized sometimes, but since it is only called once during startup the small overhead may be neglectable. for p in CSE.validator.getAllAttributePolicies().values(): - if p.type == BasicType.complex: - for each in CSE.validator.getAllAttributePolicies().values(): - if p.typeName == each.ctype: # found a definition - break - else: - L.logErr(f'No type or complex type definition found: {p.typeName} for attribute: {p.sname} in file: {p.fname}', showStackTrace = False) - return False - elif p.type == BasicType.list and p.ltype is not None: - if p.ltype == BasicType.complex: + match p.type: + case BasicType.complex: for each in CSE.validator.getAllAttributePolicies().values(): - if p.lTypeName == each.ctype: # found a definition + if p.typeName == each.ctype: # found a definition break else: - L.logErr(f'No list sub-type definition found: {p.lTypeName} for attribute: {p.sname} in file: {p.fname}', showStackTrace = False) - return False + L.logErr(f'No type or complex type definition found: {p.typeName} for attribute: {p.sname} in file: {p.fname}', showStackTrace = False) + return False + case BasicType.list if p.ltype is not None: + if p.ltype == BasicType.complex: + for each in CSE.validator.getAllAttributePolicies().values(): + if p.lTypeName == each.ctype: # found a definition + break + else: + L.logErr(f'No list sub-type definition found: {p.lTypeName} for attribute: {p.sname} in file: {p.fname}', showStackTrace = False) + return False L.isDebug and L.logDebug(f'Imported {countAP} attribute policies') return True diff --git a/acme/services/NotificationManager.py b/acme/services/NotificationManager.py index e79463c9..d38048de 100644 --- a/acme/services/NotificationManager.py +++ b/acme/services/NotificationManager.py @@ -304,59 +304,62 @@ def checkSubscriptions( self, if reason not in sub['net']: # check whether reason is actually included in the subscription continue - if reason in [ NotificationEventType.createDirectChild, NotificationEventType.deleteDirectChild ]: # reasons for child resources - chty = sub['chty'] - if chty and not childResource.ty in chty: # skip if chty is set and child.type is not in the list - continue - self._handleSubscriptionNotification(sub, - reason, - resource = childResource, - modifiedAttributes = modifiedAttributes, - asynchronous = self.asyncSubscriptionNotifications) - self.countNotificationEvents(ri) - - # Check Update and enc/atr vs the modified attributes - elif reason == NotificationEventType.resourceUpdate and (atr := sub['atr']) and modifiedAttributes: - found = False - for k in atr: - if k in modifiedAttributes: - found = True - if found: + + match reason: + case NotificationEventType.createDirectChild | NotificationEventType.deleteDirectChild: # reasons for child resources + chty = sub['chty'] + if chty and not childResource.ty in chty: # skip if chty is set and child.type is not in the list + continue self._handleSubscriptionNotification(sub, reason, - resource = resource, - modifiedAttributes = modifiedAttributes, + resource = childResource, + modifiedAttributes = modifiedAttributes, asynchronous = self.asyncSubscriptionNotifications) self.countNotificationEvents(ri) - else: - L.isDebug and L.logDebug('Skipping notification: No matching attributes found') - # Check for missing data points (only for ) - elif reason == NotificationEventType.reportOnGeneratedMissingDataPoints and missingData: - md = missingData[sub['ri']] - if md.missingDataCurrentNr >= md.missingDataNumber: # Always send missing data if the count is greater then the minimum number + # Check Update and enc/atr vs the modified attributes + case NotificationEventType.resourceUpdate if (atr := sub['atr']) and modifiedAttributes: + found = False + for k in atr: + if k in modifiedAttributes: + found = True + if found: # any one found + self._handleSubscriptionNotification(sub, + reason, + resource = resource, + modifiedAttributes = modifiedAttributes, + asynchronous = self.asyncSubscriptionNotifications) + self.countNotificationEvents(ri) + else: + L.isDebug and L.logDebug('Skipping notification: No matching attributes found') + + # Check for missing data points (only for ) + case NotificationEventType.reportOnGeneratedMissingDataPoints if missingData: + md = missingData[sub['ri']] + if md.missingDataCurrentNr >= md.missingDataNumber: # Always send missing data if the count is greater then the minimum number + self._handleSubscriptionNotification(sub, + NotificationEventType.reportOnGeneratedMissingDataPoints, + missingData = copy.deepcopy(md), + asynchronous = self.asyncSubscriptionNotifications) + self.countNotificationEvents(ri) + md.clearMissingDataList() + + case NotificationEventType.blockingUpdate | NotificationEventType.blockingRetrieve | NotificationEventType.blockingRetrieveDirectChild: self._handleSubscriptionNotification(sub, - NotificationEventType.reportOnGeneratedMissingDataPoints, - missingData = copy.deepcopy(md), - asynchronous = self.asyncSubscriptionNotifications) + reason, + resource, + modifiedAttributes = modifiedAttributes, + asynchronous = False) # blocking NET always synchronous! self.countNotificationEvents(ri) - md.clearMissingDataList() - - elif reason in [NotificationEventType.blockingUpdate, NotificationEventType.blockingRetrieve, NotificationEventType.blockingRetrieveDirectChild]: - self._handleSubscriptionNotification(sub, - reason, - resource, - modifiedAttributes = modifiedAttributes, - asynchronous = False) # blocking NET always synchronous! - self.countNotificationEvents(ri) - else: # all other reasons that target the resource - self._handleSubscriptionNotification(sub, - reason, - resource, - modifiedAttributes = modifiedAttributes, - asynchronous = self.asyncSubscriptionNotifications) - self.countNotificationEvents(ri) + # all other reasons that target the resource + case _: + self._handleSubscriptionNotification(sub, + reason, + resource, + modifiedAttributes = modifiedAttributes, + asynchronous = self.asyncSubscriptionNotifications) + self.countNotificationEvents(ri) def checkPerformBlockingUpdate(self, resource:Resource, @@ -764,17 +767,19 @@ def receivedCrossResourceSubscriptionNotification(self, sur:str, crs:Resource) - crsTwt = crs.twt crsTws = crs.tws L.isDebug and L.logDebug(f'Received notification for : {crsRi}, twt: {crsTwt}, tws: {crsTws}') - if crsTwt == TimeWindowType.SLIDINGWINDOW: - if (workers := BackgroundWorkerPool.findWorkers(self._getSlidingWorkerName(crsRi))): - L.isDebug and L.logDebug(f'Adding notification to worker: {workers[0].name}') - if sur not in workers[0].data: - workers[0].data.append(sur) - else: - workers = [ self.startCRSSlidingWindow(crsRi, crsTws, sur, crs._countSubscriptions(), crs.eem) ] # sur is added automatically when creating actor - elif crsTwt == TimeWindowType.PERIODICWINDOW: - if (workers := BackgroundWorkerPool.findWorkers(self._getPeriodicWorkerName(crsRi))): - if sur not in workers[0].data: - workers[0].data.append(sur) + match crsTwt: + case TimeWindowType.SLIDINGWINDOW: + if (workers := BackgroundWorkerPool.findWorkers(self._getSlidingWorkerName(crsRi))): + L.isDebug and L.logDebug(f'Adding notification to worker: {workers[0].name}') + if sur not in workers[0].data: + workers[0].data.append(sur) + else: + workers = [ self.startCRSSlidingWindow(crsRi, crsTws, sur, crs._countSubscriptions(), crs.eem) ] # sur is added automatically when creating actor + + case TimeWindowType.PERIODICWINDOW: + if (workers := BackgroundWorkerPool.findWorkers(self._getPeriodicWorkerName(crsRi))): + if sur not in workers[0].data: + workers[0].data.append(sur) # No else: Periodic is running or not diff --git a/acme/services/RequestManager.py b/acme/services/RequestManager.py index 95c17f90..8c011ed2 100644 --- a/acme/services/RequestManager.py +++ b/acme/services/RequestManager.py @@ -306,17 +306,16 @@ def handleReceivedNotifyRequest(self, id:str, request:CSERequest, originator:str def retrieveRequest(self, request:CSERequest) -> Result: L.isDebug and L.logDebug(f'RETRIEVE ID: {request.id if request.id else request.srn}, originator: {request.originator}') - if request.rt == ResponseType.blockingRequest: - return CSE.dispatcher.processRetrieveRequest(request, request.originator) - - elif request.rt in [ ResponseType.nonBlockingRequestSynch, ResponseType.nonBlockingRequestAsynch ]: - return self._handleNonBlockingRequest(request) - - elif request.rt == ResponseType.flexBlocking: - if self.flexBlockingBlocking: # flexBlocking as blocking - return CSE.dispatcher.processRetrieveRequest(request, request .originator) - else: # flexBlocking as non-blocking + match request.rt: + case ResponseType.blockingRequest: + return CSE.dispatcher.processRetrieveRequest(request, request.originator) + case ResponseType.nonBlockingRequestSynch | ResponseType.nonBlockingRequestAsynch: return self._handleNonBlockingRequest(request) + case ResponseType.flexBlocking: + if self.flexBlockingBlocking: # flexBlocking as blocking + return CSE.dispatcher.processRetrieveRequest(request, request .originator) + else: # flexBlocking as non-blocking + return self._handleNonBlockingRequest(request) raise BAD_REQUEST(f'Unknown or unsupported ResponseType: {request.rt}') @@ -333,17 +332,16 @@ def createRequest(self, request:CSERequest) -> Result: if request.ty == None: raise BAD_REQUEST('missing or wrong resourceType in request') - if request.rt == ResponseType.blockingRequest: - return CSE.dispatcher.processCreateRequest(request, request.originator) - - elif request.rt in [ ResponseType.nonBlockingRequestSynch, ResponseType.nonBlockingRequestAsynch ]: - return self._handleNonBlockingRequest(request) - - elif request.rt == ResponseType.flexBlocking: - if self.flexBlockingBlocking: # flexBlocking as blocking + match request.rt: + case ResponseType.blockingRequest: return CSE.dispatcher.processCreateRequest(request, request.originator) - else: # flexBlocking as non-blocking + case ResponseType.nonBlockingRequestSynch | ResponseType.nonBlockingRequestAsynch: return self._handleNonBlockingRequest(request) + case ResponseType.flexBlocking: + if self.flexBlockingBlocking: # flexBlocking as blocking + return CSE.dispatcher.processCreateRequest(request, request.originator) + else: # flexBlocking as non-blocking + return self._handleNonBlockingRequest(request) raise BAD_REQUEST(f'Unknown or unsupported ResponseType: {request.rt}') @@ -361,17 +359,16 @@ def updateRequest(self, request:CSERequest) -> Result: raise OPERATION_NOT_ALLOWED('operation not allowed for CSEBase') # Check contentType and resourceType - if request.rt == ResponseType.blockingRequest: - return CSE.dispatcher.processUpdateRequest(request, request.originator) - - elif request.rt in [ ResponseType.nonBlockingRequestSynch, ResponseType.nonBlockingRequestAsynch ]: - return self._handleNonBlockingRequest(request) - - elif request.rt == ResponseType.flexBlocking: - if self.flexBlockingBlocking: # flexBlocking as blocking + match request.rt: + case ResponseType.blockingRequest: return CSE.dispatcher.processUpdateRequest(request, request.originator) - else: # flexBlocking as non-blocking + case ResponseType.nonBlockingRequestSynch | ResponseType.nonBlockingRequestAsynch: return self._handleNonBlockingRequest(request) + case ResponseType.flexBlocking: + if self.flexBlockingBlocking: # flexBlocking as blocking + return CSE.dispatcher.processUpdateRequest(request, request.originator) + else: # flexBlocking as non-blocking + return self._handleNonBlockingRequest(request) raise BAD_REQUEST(f'Unknown or unsupported ResponseType: {request.rt}') @@ -389,18 +386,17 @@ def deleteRequest(self, request:CSERequest,) -> Result: if request.id in [ CSE.cseRi, CSE.cseRi, CSE.cseRn ]: raise OPERATION_NOT_ALLOWED(dbg = 'DELETE operation is not allowed for CSEBase') - if request.rt == ResponseType.blockingRequest or (request.rt == ResponseType.flexBlocking and self.flexBlockingBlocking): - return CSE.dispatcher.processDeleteRequest(request, request.originator) - - elif request.rt in [ ResponseType.nonBlockingRequestSynch, ResponseType.nonBlockingRequestAsynch ]: - return self._handleNonBlockingRequest(request) - - elif request.rt == ResponseType.flexBlocking: - if self.flexBlockingBlocking: # flexBlocking as blocking + match request.rt: + case ResponseType.blockingRequest: return CSE.dispatcher.processDeleteRequest(request, request.originator) - else: # flexBlocking as non-blocking + case ResponseType.nonBlockingRequestSynch | ResponseType.nonBlockingRequestAsynch: return self._handleNonBlockingRequest(request) - + case ResponseType.flexBlocking: # flexBlocking as non-blocking + if self.flexBlockingBlocking: # flexBlocking as blocking + return CSE.dispatcher.processDeleteRequest(request, request.originator) + else: # flexBlocking as non-blocking + return self._handleNonBlockingRequest(request) + raise BAD_REQUEST(f'Unknown or unsupported ResponseType: {request.rt}') @@ -412,18 +408,17 @@ def deleteRequest(self, request:CSERequest,) -> Result: def notifyRequest(self, request:CSERequest) -> Result: L.isDebug and L.logDebug(f'NOTIFY ID: {request.id if request.id else request.srn}, originator: {request.originator}') - # Check contentType and resourceType - if request.rt == ResponseType.blockingRequest: - return CSE.dispatcher.processNotifyRequest(request, request.originator) - - elif request.rt in [ ResponseType.nonBlockingRequestSynch, ResponseType.nonBlockingRequestAsynch ]: - return self._handleNonBlockingRequest(request) - elif request.rt == ResponseType.flexBlocking: - if self.flexBlockingBlocking: # flexBlocking as blocking + match request.rt: + case ResponseType.blockingRequest: return CSE.dispatcher.processNotifyRequest(request, request.originator) - else: # flexBlocking as non-blocking + case ResponseType.nonBlockingRequestSynch | ResponseType.nonBlockingRequestAsynch: return self._handleNonBlockingRequest(request) + case ResponseType.flexBlocking: + if self.flexBlockingBlocking: # flexBlocking as blocking + return CSE.dispatcher.processNotifyRequest(request, request.originator) + else: # flexBlocking as non-blocking + return self._handleNonBlockingRequest(request) raise BAD_REQUEST(f'Unknown or unsupported ResponseType: {request.rt}') @@ -1231,10 +1226,11 @@ def gget(dct:dict, # assign defaults when not provided if cseRequest.fc.fu != FilterUsage.discoveryCriteria: # Different defaults for each operation - if cseRequest.op in [ Operation.RETRIEVE, Operation.CREATE, Operation.UPDATE ]: - rcn = ResultContentType.attributes - elif cseRequest.op == Operation.DELETE: - rcn = ResultContentType.nothing + match cseRequest.op: + case Operation.RETRIEVE | Operation.CREATE | Operation.UPDATE: + rcn = ResultContentType.attributes + case Operation.DELETE: + rcn = ResultContentType.nothing else: # discovery-result-references as default for Discovery operation rcn = ResultContentType.discoveryResultReferences @@ -1552,14 +1548,15 @@ def recordRequest(self, request:CSERequest, result:Result) -> None: return # Construct and store request & response - if result.resource and isinstance(result.resource, Resource): - pc = result.resource.asDict() - elif isinstance(result.resource, dict): - pc = result.resource - elif result.data: - pc = result.data # type:ignore - else: - pc = None + match _resource := result.resource: + case Resource(): + pc = _resource.asDict() + case dict(): + pc = _resource + case x if result.data: + pc = result.data # type:ignore + case _: + pc = None # Determine the structure address if not (srn := request.srn): diff --git a/acme/services/Storage.py b/acme/services/Storage.py index 09a90003..9e272145 100644 --- a/acme/services/Storage.py +++ b/acme/services/Storage.py @@ -258,10 +258,11 @@ def retrieveResource(self, ri:Optional[str] = None, elif aei: # get an AE by its AE-ID resources = self.db.searchResources(aei = aei) - if (l := len(resources)) == 1: - return resourceFromDict(resources[0]) - elif l == 0: - raise NOT_FOUND('resource not found') + match len(resources): + case 1: + return resourceFromDict(resources[0]) + case 0: + raise NOT_FOUND('resource not found') raise INTERNAL_SERVER_ERROR('database inconsistency') @@ -275,10 +276,12 @@ def retrieveResourceRaw(self, ri:str) -> JSON: The resource dictionary. """ resources = self.db.searchResources(ri = ri) - if (l := len(resources)) == 1: - return resources[0] - elif l == 0: - raise NOT_FOUND('resource not found') + match len(resources): + case 1: + return resources[0] + case 0: + raise NOT_FOUND('resource not found') + raise INTERNAL_SERVER_ERROR('database inconsistency') diff --git a/acme/services/Validator.py b/acme/services/Validator.py index 247aa740..64568504 100644 --- a/acme/services/Validator.py +++ b/acme/services/Validator.py @@ -319,10 +319,11 @@ def validatePrimitiveContent(self, pc:JSON) -> None: def validatePvs(self, dct:JSON) -> None: """ Validating special case for lists that are not allowed to be empty (pvs in ACP). """ - if (l :=len(dct['pvs'])) == 0: - raise BAD_REQUEST(L.logWarn('Attribute pvs must not be an empty list')) - elif l > 1: - raise BAD_REQUEST(L.logWarn('Attribute pvs must contain only one item')) + match len(dct['pvs']): + case 0: + raise BAD_REQUEST(L.logWarn('Attribute pvs must not be an empty list')) + case l if l > 1: + raise BAD_REQUEST(L.logWarn('Attribute pvs must contain only one item')) if not (acr := findXPath(dct, 'pvs/acr')): raise BAD_REQUEST(L.logWarn('Attribute pvs/acr not found')) if not isinstance(acr, list): @@ -578,144 +579,145 @@ def _validateType(self, dataType:BasicType, # convert some types if necessary if convert: - if dataType in ( BasicType.positiveInteger, - BasicType.nonNegInteger, - BasicType.unsignedInt, - BasicType.unsignedLong, - BasicType.integer, - BasicType.enum ) and isinstance(value, str): - try: - value = int(value) - except Exception as e: - raise BAD_REQUEST(str(e)) - elif dataType == BasicType.boolean and isinstance(value, str): # "true"/"false" - try: - value = strToBool(value) - except Exception as e: - raise BAD_REQUEST(str(e)) - elif dataType == BasicType.float and isinstance(value, str): + if isinstance(value, str): try: - value = float(value) + match dataType: + case BasicType.positiveInteger |\ + BasicType.nonNegInteger |\ + BasicType.unsignedInt |\ + BasicType.unsignedLong |\ + BasicType.integer |\ + BasicType.enum: + value = int(value) + + case BasicType.boolean: + value = strToBool(value) + + case BasicType.float: + value = float(value) + except Exception as e: raise BAD_REQUEST(str(e)) # Check types and values - if dataType == BasicType.positiveInteger: - if isinstance(value, int): - if value > 0: + match dataType: + case BasicType.positiveInteger: + if isinstance(value, int) and value > 0: + return (dataType, value) + raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: positive integer') + + case BasicType.enum: + if isinstance(value, int): + if policy is not None and len(policy.evalues) and value not in policy.evalues: + raise BAD_REQUEST('undefined enum value') return (dataType, value) - raise BAD_REQUEST('value must be > 0') - raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: positive integer') + raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: integer') + + case BasicType.nonNegInteger: + if isinstance(value, int) and value >= 0: + return (dataType, value) + raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: non-negative integer') + + case BasicType.unsignedInt | BasicType.unsignedLong: + if isinstance(value, int): + return (dataType, value) + raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: unsigned integer') + + case BasicType.timestamp if isinstance(value, str): + if fromAbsRelTimestamp(value) == 0.0: + raise BAD_REQUEST(f'format error in timestamp: {value}') + return (dataType, value) - if dataType == BasicType.enum: - if isinstance(value, int): - if policy is not None and len(policy.evalues) and value not in policy.evalues: - raise BAD_REQUEST('undefined enum value') + case BasicType.absRelTimestamp: + match value: + case str(): + try: + rel = int(value) + # fallthrough + except Exception as e: # could happen if this is a string with an iso timestamp. Then try next test + if fromAbsRelTimestamp(value) == 0.0: + raise BAD_REQUEST(f'format error in absRelTimestamp: {value}') + # fallthrough + case int(): + pass + # fallthrough + case _: + raise BAD_REQUEST(f'unsupported data type for absRelTimestamp') + return (dataType, value) # int/long is ok + + case BasicType.string | BasicType.anyURI if isinstance(value, str): return (dataType, value) - raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: positive integer') - if dataType == BasicType.nonNegInteger: - if isinstance(value, int): - if value >= 0: + case BasicType.list | BasicType.listNE if isinstance(value, list): + if dataType == BasicType.listNE and len(value) == 0: + raise BAD_REQUEST('empty list is not allowed') + if policy is not None and policy.ltype is not None: + for each in value: + self._validateType(policy.ltype, each, convert = convert, policy = policy) + return (dataType, value) + + case BasicType.complex: + # Check complex types + if not policy: + raise BAD_REQUEST(L.logErr(f'internal error: policy is missing for validation of complex attribute')) + + if isinstance(value, dict): + typeName = policy.lTypeName if policy.type == BasicType.list else policy.typeName; + for k, v in value.items(): + if not (p := self.getAttributePolicy(typeName, k)): + raise BAD_REQUEST(f'unknown or undefined attribute:{k} in complex type: {typeName}') + # recursively validate a dictionary attribute + self._validateType(p.type, v, convert = convert, policy = p) + + # Check that all mandatory attributes are present + attributeNames = value.keys() + for ap in self.getComplexTypeAttributePolicies(typeName): + if Cardinality.isMandatory(ap.cardinality) and ap.sname not in attributeNames: + raise BAD_REQUEST(f'attribute is mandatory for complex type : {typeName}.{ap.sname}') return (dataType, value) - raise BAD_REQUEST('value must be >= 0') - raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: non-negative integer') + raise BAD_REQUEST(f'Expected complex type, found: {value}') - if dataType in ( BasicType.unsignedInt, BasicType.unsignedLong ): - if isinstance(value, int): + case BasicType.dict if isinstance(value, dict): return (dataType, value) - raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: unsigned integer') - if dataType == BasicType.timestamp and isinstance(value, str): - if fromAbsRelTimestamp(value) == 0.0: - raise BAD_REQUEST(f'format error in timestamp: {value}') - return (dataType, value) - - if dataType == BasicType.absRelTimestamp: - if isinstance(value, str): - try: - rel = int(value) - # fallthrough - except Exception as e: # could happen if this is a string with an iso timestamp. Then try next test - if fromAbsRelTimestamp(value) == 0.0: - raise BAD_REQUEST(f'format error in absRelTimestamp: {value}') - # fallthrough - elif not isinstance(value, int): - raise BAD_REQUEST(f'unsupported data type for absRelTimestamp') - return (dataType, value) # int/long is ok - - if dataType in ( BasicType.string, BasicType.anyURI ) and isinstance(value, str): - return (dataType, value) + case BasicType.boolean: + if isinstance(value, bool): + return (dataType, value) + raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: bool') - if dataType in ( BasicType.list, BasicType.listNE ) and isinstance(value, list): - if dataType == BasicType.listNE and len(value) == 0: - raise BAD_REQUEST('empty list is not allowed') - if policy is not None and policy.ltype is not None: - for each in value: - self._validateType(policy.ltype, each, convert = convert, policy = policy) - return (dataType, value) + case BasicType.integer: + if isinstance(value, int): + return (dataType, value) + raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: integer') + + case BasicType.float: + if isinstance(value, (float, int)): + return (dataType, value) + raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: float') - if dataType == BasicType.dict and isinstance(value, dict): - return (dataType, value) - - if dataType == BasicType.boolean: - if isinstance(value, bool): + case BasicType.geoCoordinates if isinstance(value, dict): return (dataType, value) - raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: bool') - if dataType == BasicType.float: - if isinstance(value, (float, int)): + case BasicType.duration: + try: + isodate.parse_duration(value) + except Exception as e: + raise BAD_REQUEST(f'must be an ISO duration: {str(e)}') return (dataType, value) - raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: float') - if dataType == BasicType.integer: - if isinstance(value, int): + case BasicType.base64: + if not TextTools.isBase64(value): + raise BAD_REQUEST(f'value is not base64-encoded') return (dataType, value) - raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: integer') - if dataType == BasicType.geoCoordinates and isinstance(value, dict): - return (dataType, value) - - if dataType == BasicType.duration: - try: - isodate.parse_duration(value) - except Exception as e: - raise BAD_REQUEST(f'must be an ISO duration: {str(e)}') - return (dataType, value) - - if dataType == BasicType.base64: - if not TextTools.isBase64(value): - raise BAD_REQUEST(f'value is not base64-encoded') - return (dataType, value) - - if dataType == BasicType.schedule: - if isinstance(value, str) and re.match(self._scheduleRegex, value): - return (dataType, value) - raise BAD_REQUEST(f'invalid type: {type(value).__name__} or pattern {value}. Expected: cron-like schedule') + case BasicType.schedule: + if isinstance(value, str) and re.match(self._scheduleRegex, value): + return (dataType, value) + raise BAD_REQUEST(f'invalid type: {type(value).__name__} or pattern {value}. Expected: cron-like schedule') - if dataType == BasicType.any: - return (dataType, value) - - if dataType == BasicType.complex: - if not policy: - raise BAD_REQUEST(L.logErr(f'internal error: policy is missing for validation of complex attribute')) - - if isinstance(value, dict): - typeName = policy.lTypeName if policy.type == BasicType.list else policy.typeName; - for k, v in value.items(): - if not (p := self.getAttributePolicy(typeName, k)): - raise BAD_REQUEST(f'unknown or undefined attribute:{k} in complex type: {typeName}') - # recursively validate a dictionary attribute - self._validateType(p.type, v, convert = convert, policy = p) - - # Check that all mandatory attributes are present - attributeNames = value.keys() - for ap in self.getComplexTypeAttributePolicies(typeName): - if Cardinality.isMandatory(ap.cardinality) and ap.sname not in attributeNames: - raise BAD_REQUEST(f'attribute is mandatory for complex type : {typeName}.{ap.sname}') + case BasicType.any: return (dataType, value) - raise BAD_REQUEST(f'Expected complex type, found: {value}') raise BAD_REQUEST(f'type mismatch or unknown; expected type: {str(dataType)}, value type: {type(value).__name__}') diff --git a/acme/textui/ACMEContainerTree.py b/acme/textui/ACMEContainerTree.py index 784af776..38576a23 100644 --- a/acme/textui/ACMEContainerTree.py +++ b/acme/textui/ACMEContainerTree.py @@ -243,11 +243,14 @@ async def on_tabbed_content_tab_activated(self, event:TabbedContent.TabActivated """Handle TabActivated message sent by Tabs.""" # self.app.debugConsole.update(event.tab.id) - if self.tabs.active == 'tree-tab-requests': - self._update_requests() - self.requestView.updateBindings() - elif self.tabs.active == 'tree-tab-delete': - pass + match self.tabs.active: + case 'tree-tab-requests': + self._update_requests() + self.requestView.updateBindings() + case 'tree-tab-resource': + pass + case 'tree-tab-delete': + pass self.app.updateFooter() # type:ignore[attr-defined] diff --git a/acme/textui/ACMETuiApp.py b/acme/textui/ACMETuiApp.py index 770b4d93..f1ab9780 100644 --- a/acme/textui/ACMETuiApp.py +++ b/acme/textui/ACMETuiApp.py @@ -220,6 +220,7 @@ async def _call() -> None: if timeout is None: timeout = Notification.timeout + if severity is None: severity = 'information' elif severity not in get_args(SeverityLevel): diff --git a/tests/config.py b/tests/config.py index ec71e3ee..40fd2aa8 100644 --- a/tests/config.py +++ b/tests/config.py @@ -9,26 +9,24 @@ BINDING = 'http' # possible values: http, https, mqtt -if BINDING == 'mqtt': - PROTOCOL = 'mqtt' - CONFIGPROTOCOL = 'http' - NOTIFICATIONPROTOCOL = 'http' - REMOTEPROTOCOL = 'http' - -elif BINDING == 'http': - PROTOCOL = 'http' - CONFIGPROTOCOL = 'http' - NOTIFICATIONPROTOCOL = 'http' - REMOTEPROTOCOL = 'http' - -elif BINDING == 'https': - PROTOCOL = 'https' - CONFIGPROTOCOL = 'https' - NOTIFICATIONPROTOCOL = 'http' - REMOTEPROTOCOL = 'http' - -else: - assert False, 'Supported values for BINDING are "mqtt", "http", and "https"' +match BINDING: + case 'mqtt': + PROTOCOL = 'mqtt' + CONFIGPROTOCOL = 'http' + NOTIFICATIONPROTOCOL = 'http' + REMOTEPROTOCOL = 'http' + case 'http': + PROTOCOL = 'http' + CONFIGPROTOCOL = 'http' + NOTIFICATIONPROTOCOL = 'http' + REMOTEPROTOCOL = 'http' + case 'https': + PROTOCOL = 'https' + CONFIGPROTOCOL = 'https' + NOTIFICATIONPROTOCOL = 'http' + REMOTEPROTOCOL = 'http' + case _: + assert False, 'Supported values for BINDING are "mqtt", "http", and "https"' # TODO ENCODING = diff --git a/tests/init.py b/tests/init.py index 1c13bd2a..028c2e20 100755 --- a/tests/init.py +++ b/tests/init.py @@ -336,27 +336,31 @@ def sendRequest(operation:Operation, url:str, originator:str, ty:ResourceTypes=N # return sendHttpRequest(requests.delete, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) # elif operation == Operation.NOTIFY: # return sendHttpRequest(requests.post, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - if operation == Operation.CREATE: - return sendHttpRequest(httpSession.post, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.RETRIEVE: - return sendHttpRequest(httpSession.get, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.UPDATE: - return sendHttpRequest(httpSession.put, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.DELETE: - return sendHttpRequest(httpSession.delete, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.NOTIFY: - return sendHttpRequest(httpSession.post, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + match operation: + case Operation.CREATE: + return sendHttpRequest(requests.post, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.RETRIEVE: + return sendHttpRequest(requests.get, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.UPDATE: + return sendHttpRequest(requests.put, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.DELETE: + return sendHttpRequest(requests.delete, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.NOTIFY: + return sendHttpRequest(requests.post, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + elif url.startswith('mqtt'): - if operation == Operation.CREATE: - return sendMqttRequest(Operation.CREATE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.RETRIEVE: - return sendMqttRequest(Operation.RETRIEVE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.UPDATE: - return sendMqttRequest(Operation.UPDATE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.DELETE: - return sendMqttRequest(Operation.DELETE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) - elif operation == Operation.NOTIFY: - return sendMqttRequest(Operation.NOTIFY, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + match operation: + case Operation.CREATE: + return sendMqttRequest(Operation.CREATE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.RETRIEVE: + return sendMqttRequest(Operation.RETRIEVE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.UPDATE: + return sendMqttRequest(Operation.UPDATE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.DELETE: + return sendMqttRequest(Operation.DELETE, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + case Operation.NOTIFY: + return sendMqttRequest(Operation.NOTIFY, url=url, originator=originator, ty=ty, data=data, ct=ct, timeout=timeout, headers=headers) + else: print('ERROR') return None, 5103 @@ -800,12 +804,11 @@ def do_POST(self) -> None: contentType = '' if (val := self.headers.get('Content-Type')) is not None: contentType = val.lower() - if contentType in [ 'application/json', 'application/vnd.onem2m-res+json' ]: - setLastNotification(decoded_data := json.loads(post_data.decode('utf-8'))) - elif contentType in [ 'application/cbor', 'application/vnd.onem2m-res+cbor' ]: - setLastNotification(decoded_data := cbor2.loads(post_data)) - # else: - # setLastNotification(post_data.decode('utf-8')) + match contentType: + case 'application/json' | 'application/vnd.onem2m-res+json': + setLastNotification(decoded_data := json.loads(post_data.decode('utf-8'))) + case 'application/cbor' | 'application/vnd.onem2m-res+cbor': + setLastNotification(decoded_data := cbor2.loads(post_data)) setLastNotificationHeaders(dict(self.headers)) # make a dict out of the headers # make a dict out of the query arguments diff --git a/tools/notificationServer/notificationServer.py b/tools/notificationServer/notificationServer.py index 736b8511..70a76ac2 100644 --- a/tools/notificationServer/notificationServer.py +++ b/tools/notificationServer/notificationServer.py @@ -82,32 +82,29 @@ def do_POST(self) -> None: self.end_headers() - - # Print JSON - if contentType in [ 'application/json', 'application/vnd.onem2m-res+json' ]: - console.print(Syntax(json.dumps(json.loads(post_data.decode('utf-8')), indent=4), - 'json', - theme='monokai', - line_numbers=False)) - - # Print CBOR - elif contentType in [ 'application/cbor', 'application/vnd.onem2m-res+cbor' ]: - console.print('[dim]Content as Hexdump:\n') - console.print(toHex(post_data), highlight=False) - console.print('\n[dim]Content as JSON:\n') - console.print(Syntax(json.dumps(cbor2.loads(post_data), indent=4), - 'json', - theme='monokai', - line_numbers=False)) - - # Print plain text formats - elif contentType in ['text/plain']: - console.print(post_data.decode(), highlight = False) - console.print() - - # Print other binary content - else: - console.print(toHex(post_data), highlight=False) + match contentType: + # Print JSON + case 'application/json', 'application/vnd.onem2m-res+json': + console.print(Syntax(json.dumps(json.loads(post_data.decode('utf-8')), indent=4), + 'json', + theme='monokai', + line_numbers=False)) + # Print CBOR + case 'application/cbor', 'application/vnd.onem2m-res+cbor': + console.print('[dim]Content as Hexdump:\n') + console.print(toHex(post_data), highlight=False) + console.print('\n[dim]Content as JSON:\n') + console.print(Syntax(json.dumps(cbor2.loads(post_data), indent=4), + 'json', + theme='monokai', + line_numbers=False)) + # Print plain text formats + case 'text/plain': + console.print(post_data.decode(), highlight = False) + console.print() + # Print other binary content + case _: + console.print(toHex(post_data), highlight=False) # Print HTTP Response # This looks a it more complicated but is necessary to render nicely in Jupyter @@ -196,38 +193,35 @@ def _constructResponse(frm:str, to:str, jsn:dict) -> dict: _frm = 'non-onem2m-entity' _to = 'unknown' encoding = 'json' - - # Print JSON + responseData = None - if encoding.upper() == 'JSON': - console.print(Syntax(json.dumps((jsn := json.loads(data)), indent=4), - 'json', - theme='monokai', - line_numbers=False)) - to = jsn['to'] if 'to' in jsn else _to - frm = jsn['fr'] if 'fr' in jsn else _frm - responseData = cast(bytes, serializeData(_constructResponse(to, frm, jsn), ContentSerializationType.JSON)) - console.print(responseData) - - - - # Print CBOR - elif encoding.upper() == 'CBOR': - console.print('[dim]Content as Hexdump:\n') - console.print(toHex(data), highlight=False) - console.print('\n[dim]Content as JSON:\n') - console.print(Syntax(json.dumps((jsn := cbor2.loads(data)), indent=4), - 'json', - theme='monokai', - line_numbers=False)) - to = jsn['to'] if 'to' in jsn else to - frm = jsn['fr'] if 'fr' in jsn else frm - responseData = cast(bytes, serializeData(_constructResponse(to, frm, jsn), ContentSerializationType.CBOR)) - - # Print other binary content - else: - console.print('[dim]Content as Hexdump:\n') - console.print(toHex(data), highlight=False) + match encoding.upper(): + # Print JSON + case 'JSON': + console.print(Syntax(json.dumps((jsn := json.loads(data)), indent=4), + 'json', + theme='monokai', + line_numbers=False)) + to = jsn['to'] if 'to' in jsn else _to + frm = jsn['fr'] if 'fr' in jsn else _frm + responseData = cast(bytes, serializeData(_constructResponse(to, frm, jsn), ContentSerializationType.JSON)) + console.print(responseData) + # Print CBOR + case 'CBOR': + console.print('[dim]Content as Hexdump:\n') + console.print(toHex(data), highlight=False) + console.print('\n[dim]Content as JSON:\n') + console.print(Syntax(json.dumps((jsn := cbor2.loads(data)), indent=4), + 'json', + theme='monokai', + line_numbers=False)) + to = jsn['to'] if 'to' in jsn else to + frm = jsn['fr'] if 'fr' in jsn else frm + responseData = cast(bytes, serializeData(_constructResponse(to, frm, jsn), ContentSerializationType.CBOR)) + # Print other binary content + case _: + console.print('[dim]Content as Hexdump:\n') + console.print(toHex(data), highlight=False) # TODO send a response if responseData: From 421e07a44788dc11d2dbfbfc357eafe979d6e275 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 20 Jul 2023 12:20:19 +0200 Subject: [PATCH 056/165] A bit more time between checks for CRS --- tests/testCRS.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testCRS.py b/tests/testCRS.py index 2e56f7e0..eda30f53 100644 --- a/tests/testCRS.py +++ b/tests/testCRS.py @@ -770,7 +770,7 @@ def test_updateCRSPeriodicWindowSize(self) -> None: self.assertIsNone(notification := getLastNotification()) # wait second half - testSleep(crsTimeWindowSize) + testSleep(crsTimeWindowSize * 1.2) self.assertIsNotNone(notification := getLastNotification()) self.assertIsNotNone(findXPath(notification, 'm2m:sgn')) self.assertEqual(findXPath(notification, 'm2m:sgn/sur'), toSPRelative(findXPath(self.crs, 'm2m:crs/ri'))) From 5b82cb3f812318b074f0644a28019c66c8690839 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 20 Jul 2023 12:20:53 +0200 Subject: [PATCH 057/165] Small tweaks --- init/testCaseEnd.as | 2 ++ init/testCaseStart.as | 4 ++++ init/utReset.as | 7 +++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/init/testCaseEnd.as b/init/testCaseEnd.as index 406977c2..86866c3a 100644 --- a/init/testCaseEnd.as +++ b/init/testCaseEnd.as @@ -12,6 +12,8 @@ (if (< argc 2) ( (log-error "Wrong number of arguments: testCaseEnd ") (quit-with-error))) +(if (== (get-loglevel) "OFF") + (quit)) ;; Print start line to the debug log (log-divider "End of ${(argv 1)}") diff --git a/init/testCaseStart.as b/init/testCaseStart.as index 4f89b7d0..48648944 100644 --- a/init/testCaseStart.as +++ b/init/testCaseStart.as @@ -13,6 +13,10 @@ ( (logError "Wrong number of arguments: testCaseStart ") (quit-with-error))) +(if (== (get-loglevel) "OFF") + (quit)) + ;; Print start line to the debug log (log-divider "Start of ${(argv 1)}") +;;(tui-notify (argv 1) "Running Test Case") diff --git a/init/utReset.as b/init/utReset.as index 2bd71dd0..df78f307 100644 --- a/init/utReset.as +++ b/init/utReset.as @@ -14,9 +14,12 @@ (print "Resetting CSE") +(if (runs-in-tui) + (tui-notify "Resetting CSE" "CSE Reset" "warning")) + (reset-cse) +(print "CSE Reset Complete") (if (runs-in-tui) - (print "[green3 b]CSE Reset Complete") - (print "CSE Reset Complete")) + (tui-notify "CSE Reset Complete" "CSE Reset" "warning")) From 1798707044df9565cb0c3c530bbd540d536933fe Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 25 Jul 2023 17:10:15 +0200 Subject: [PATCH 058/165] Changed enumeration definition format --- CHANGELOG.md | 3 + acme/etc/Types.py | 10 +- acme/etc/Utils.py | 4 +- acme/helpers/TextTools.py | 2 +- acme/resources/SCH.py | 2 +- acme/services/Importer.py | 52 +++- acme/services/TextUI.py | 25 +- acme/services/Validator.py | 55 +++- acme/textui/ACMEContainerRequests.py | 22 +- acme/textui/ACMEContainerTree.py | 4 +- acme/textui/ACMETuiApp.py | 1 + docs/Importing.md | 9 +- init/attributePolicies.ap | 46 +++- init/enumTypesPolicies.ep | 369 ++++++++++++++++++++++++--- 14 files changed, 532 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a971ae8c..4a1c4a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,11 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [CSE] Added automatic pip install of missing dependencies during startup. - [CSE] Added support for <schedule> resource type. - [SCRIPTS] Added "dotimes", "tui-notify", and "get-loglevel" functions to the script interpreter. +- [TUI] Improved resource view in the text UI. Enumeration interpretations are now shown. ### Experimental ### Changed - [CSE] Changed the *operationResult* of <request> according to SDS-2022-0010R02. +- [CSE] Changed the oneM2M enumeration definition format. Each enumeration type is now a dictionary of enumeration values and their interpretations. + ### Fixed diff --git a/acme/etc/Types.py b/acme/etc/Types.py index c4375e8d..f69b3473 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -56,6 +56,8 @@ class ResourceTypes(ACMEIntEnum): """ CSEBase resource type. """ GRP = 9 """ Group resouce type. """ + LCP = 10 + """ LocationPolicy resource type. """ MGMTOBJ = 13 """ ManagementObject resource type. """ NOD = 14 @@ -154,6 +156,8 @@ class ResourceTypes(ACMEIntEnum): """ Announced CSEBase resource type. """ GRPAnnc = 10009 """ Announced Group resouce type. """ + LCPAnnc = 10010 + """ Announced LocationPolicy resource type. """ MGMTOBJAnnc = 10013 """ Announced ManagementObject resource type. """ NODAnnc = 10014 @@ -426,6 +430,8 @@ class ResourceDescription(): ResourceTypes.GRP : ResourceDescription(typeName = 'm2m:grp', announcedType = ResourceTypes.GRPAnnc, fullName='Group'), ResourceTypes.GRPAnnc : ResourceDescription(typeName = 'm2m:grpA', isAnnouncedResource = True, fullName='Group Announced'), ResourceTypes.GRP_FOPT : ResourceDescription(typeName = 'm2m:fopt', virtualResourceName = 'fopt', fullName='Fanout Point'), # not an official type name + ResourceTypes.LCP : ResourceDescription(typeName = 'm2m:lcp', announcedType = ResourceTypes.LCPAnnc, fullName='LocationPolicy'), + ResourceTypes.LCPAnnc : ResourceDescription(typeName = 'm2m:lcpA', isAnnouncedResource = True, fullName='LocationPolicy Announced'), ResourceTypes.MGMTOBJ : ResourceDescription(typeName = 'm2m:mgo', announcedType = ResourceTypes.MGMTOBJAnnc, fullName = 'ManagementObject'), # not an official type name ResourceTypes.MGMTOBJAnnc : ResourceDescription(typeName = 'm2m:mgoA', isAnnouncedResource = True, fullName = 'ManagementObject Announced'), # not an official type name ResourceTypes.NOD : ResourceDescription(typeName = 'm2m:nod', announcedType = ResourceTypes.NODAnnc, fullName='Node'), @@ -530,7 +536,7 @@ def addResourceFactoryCallback(ty:ResourceTypes, clazz:Resource, factory:Factory _ResourceTypesAnnouncedResourceTypes.sort() -_ResourceTypesSupportedResourceTypes = [ t +_ResourceTypesSupportedResourceTypes:list[ResourceTypes] = [ t for t, d in _ResourceTypeDetails.items() if not d.isMgmtSpecialization and not d.virtualResourceName and not d.isInternalType and t != ResourceTypes.CSEBaseAnnc] """ Sorted list of supported resource types (without MgmtObj spezializations and virtual resources). """ @@ -2035,7 +2041,7 @@ class AttributePolicy: fname:str = None # Name of the definition file ltype:BasicType = None # sub-type of a list lTypeName:str = None # sub-type of a list as writen in the definition - evalues:list[Any] = None # List of enum values + evalues:dict[int, str] = None # Dict of enum values and interpretations ptype:Type = None # Implementation type of the enum values # TODO support annnouncedSyncType diff --git a/acme/etc/Utils.py b/acme/etc/Utils.py index 791abd26..3e6ca5fc 100644 --- a/acme/etc/Utils.py +++ b/acme/etc/Utils.py @@ -152,7 +152,7 @@ def isCSERelative(uri:str) -> bool: return uri is not None and not uri.startswith('/') -def isStructured(uri:str) -> bool: +def isStructured(uri:str) -> bool: # type: ignore[return] """ Test whether a URI is in structured format. Args: @@ -171,7 +171,7 @@ def isStructured(uri:str) -> bool: return False -def localResourceID(ri:str) -> Optional[str]: +def localResourceID(ri:str) -> Optional[str]: # type: ignore[return] """ Test whether an ID is a resource ID of the local CSE. Args: diff --git a/acme/helpers/TextTools.py b/acme/helpers/TextTools.py index ccc8d78d..0a345f65 100644 --- a/acme/helpers/TextTools.py +++ b/acme/helpers/TextTools.py @@ -102,7 +102,7 @@ def commentJson(data:Union[str, dict], elif previousKey and value: # when the value is on the next line, w/o a key - lines.append(f'// {value}') + lines.append(f'// {getAttributeValueName(key, value)}') lines.append(line) _m = len(lines[-2]) + maxLineLength maxLength = _m if _m > maxLength else maxLength diff --git a/acme/resources/SCH.py b/acme/resources/SCH.py index 6249a0f7..5194ae10 100644 --- a/acme/resources/SCH.py +++ b/acme/resources/SCH.py @@ -1,4 +1,4 @@ - # +# # SCH.py # # (c) 2023 by Andreas Kraft diff --git a/acme/services/Importer.py b/acme/services/Importer.py index f2f98b76..6c1439f8 100644 --- a/acme/services/Importer.py +++ b/acme/services/Importer.py @@ -11,7 +11,7 @@ """ Import various resources, scripts, policies etc into the CSE. """ from __future__ import annotations -from typing import cast, Sequence, Optional +from typing import cast, Sequence, Optional, Tuple import json, os, fnmatch, re from copy import deepcopy @@ -46,7 +46,7 @@ class Importer(object): # List of "priority" resources that must be imported first for correct CSE operation _firstImporters = [ 'csebase.json'] - _enumValues:dict[str, list[int]] = {} + _enumValues:dict[str, dict[int, str]] = {} def __init__(self) -> None: """ Initialization of an *Importer* instance. @@ -243,10 +243,38 @@ def importEnumPolicies(self, path:Optional[str] = None) -> bool: return False for enumName, enumDef in enums.items(): - if not (evalues := enumDef.get('evalues')): - L.logErr(f'Missing or empty enumeration values (evalues) in file: {fn}') + if not isinstance(enumDef, dict): + L.logErr(f'Wrong or empty enumeration definition for enum: {enumName} in file: {fn}') return False - self._enumValues[enumName] = self._expandEnumValues(evalues, enumName, fn) + + enm:dict[int, str] = {} + for enumValue, enumInterpretation in enumDef.items(): + s, found, e = enumValue.partition('..') + if not found: + # Single value + try: + value = int(enumValue) + except ValueError: + L.logErr(f'Wrong enumeration value: {enumValue} in enum: {enumName} in file: {fn} (must be an integer)') + return False + if not isinstance(enumInterpretation, str): + L.logErr(f'Wrong interpretation for enum value: {enumValue} in enum: {enumName} in file: {fn}') + return False + enm[value] = enumInterpretation + + else: + # Range + try: + si = int(s) + ei = int(e) + except ValueError: + L.logErr(f'Error in evalue range definition: {enumValue} (range shall consist of integer numbers) for enum attribute: {enumName} in file: {fn}', showStackTrace=False) + return None + for i in range(si, ei+1): + enm[i] = enumInterpretation + + self._enumValues[enumName] = enm + return True @@ -518,6 +546,7 @@ def _parseAttribute(self, attr:JSON, # Check and determine the list type lTypeName:str = None ltype:BasicType = None + evalues:dict[int, str] = None if checkListType: # TODO remove this when flexContainer definitions support list sub-types if lTypeName := findXPath(attr, 'ltype'): if not isinstance(lTypeName, str) or len(lTypeName) == 0: @@ -529,15 +558,14 @@ def _parseAttribute(self, attr:JSON, if not (ltype := BasicType.to(lTypeName)): # automatically a complex type if not found in the type definition. Check for this happens later ltype = BasicType.complex if ltype == BasicType.enum: # check sub-type enums - evalues:Sequence[int|str] if (etype := findXPath(attr, 'etype')): # Get the values indirectly from the enums read above evalues = self._enumValues.get(etype) else: - evalues = findXPath(attr, 'evalues') - if not evalues or not isinstance(evalues, list): + evalues = findXPath(attr, 'evalues') # TODO? + if not evalues or not isinstance(evalues, dict): L.logErr(f'Missing, wrong of empty enum values (evalue) list for attribute: {tpe} in file: {fn}', showStackTrace=False) return None - evalues = self._expandEnumValues(evalues, tpe, fn) + # evalues = self._expandEnumValues(evalues, tpe, fn) # TODO this is perhaps wrong, bc we changed the evalue handling to a different format if typ == BasicType.list and lTypeName is None: L.isDebug and L.logDebug(f'Missing list type for attribute: {tpe} in file: {fn}') @@ -547,11 +575,11 @@ def _parseAttribute(self, attr:JSON, if (etype := findXPath(attr, 'etype')): # Get the values indirectly from the enums read above evalues = self._enumValues.get(etype) else: - evalues = findXPath(attr, 'evalues') - if not evalues or not isinstance(evalues, list): + evalues = findXPath(attr, 'evalues') # TODO? + if not evalues or not isinstance(evalues, dict): L.logErr(f'Missing, wrong of empty enum values (evalue) list for attribute: {tpe} etype: {etype} in file: {fn}', showStackTrace=False) return None - evalues = self._expandEnumValues(evalues, tpe, fn) + # evalues = self._expandEnumValues(evalues, tpe, fn) # Check missing complex type definition if typ == BasicType.dict or ltype == BasicType.dict: diff --git a/acme/services/TextUI.py b/acme/services/TextUI.py index be471ed3..7c1aa93d 100644 --- a/acme/services/TextUI.py +++ b/acme/services/TextUI.py @@ -11,7 +11,7 @@ from __future__ import annotations -from typing import Optional, Any +from typing import Optional, Any, Literal import asyncio from . import CSE @@ -156,6 +156,10 @@ def refreshResources(self) -> None: def scriptPrint(self, scriptName:str, msg:str) -> None: """ Print a line to the script output. + + Args: + scriptName: Name of the script. + msg: Message to print. """ if self.tuiApp: self.tuiApp.scriptPrint(scriptName, msg) @@ -163,6 +167,10 @@ def scriptPrint(self, scriptName:str, msg:str) -> None: def scriptLog(self, scriptName:str, msg:str) -> None: """ Print a line to the script log output. + + Args: + scriptName: Name of the script. + msg: Message to print. """ if self.tuiApp: self.tuiApp.scriptLog(scriptName, msg) @@ -170,6 +178,10 @@ def scriptLog(self, scriptName:str, msg:str) -> None: def scriptLogError(self, scriptName:str, msg:str) -> None: """ Print a line to the script log output. + + Args: + scriptName: Name of the script. + msg: Message to print. """ if self.tuiApp: self.tuiApp.scriptLogError(scriptName, msg) @@ -177,16 +189,22 @@ def scriptLogError(self, scriptName:str, msg:str) -> None: def scriptClearConsole(self, scriptName:str) -> None: """ Clear the script console. + + Args: + scriptName: Name of the script. """ if self.tuiApp: self.tuiApp.scriptClearConsole(scriptName) - def scriptShowNotification(self, msg:str, title:str, severity:str, timeout:float) -> None: + def scriptShowNotification(self, msg:str, title:str, severity:Literal['information', 'warning', 'error'], timeout:float) -> None: """ Show a notification. Args: msg: Message to show. + title: Title of the notification. + severity: Severity of the notification. + timeout: Timeout in seconds. """ if self.tuiApp: self.tuiApp.scriptShowNotification(msg, title, severity, timeout) @@ -194,6 +212,9 @@ def scriptShowNotification(self, msg:str, title:str, severity:str, timeout:float def scriptVisualBell(self, scriptName:str) -> None: """ Visual bell. + + Args: + scriptName: Name of the script. """ if self.tuiApp: self.tuiApp.scriptVisualBell(scriptName) \ No newline at end of file diff --git a/acme/services/Validator.py b/acme/services/Validator.py index 64568504..695488e3 100644 --- a/acme/services/Validator.py +++ b/acme/services/Validator.py @@ -54,6 +54,9 @@ complexTypeAttributes:dict[str, list[str]] = {} # TODO doc +attributesComplexTypes:dict[str, list[str]] = {} +# TODO doc + # TODO make this more generic! _valueNameMappings = { @@ -61,8 +64,8 @@ 'bts': lambda v: BatteryStatus(int(v)).name, 'chty': lambda v: ResourceTypes.fullname(int(v)), 'cst': lambda v: CSEType(int(v)).name, - 'nct': lambda v: NotificationContentType(int(v)).name, - 'net': lambda v: NotificationEventType(int(v)).name, + #'nct': lambda v: NotificationContentType(int(v)).name, + #'net': lambda v: NotificationEventType(int(v)).name, 'op': lambda v: Operation(int(v)).name, 'rcn': lambda v: ResultContentType(int(v)).name, 'rsc': lambda v: ResponseStatusCode(int(v)).name, @@ -488,9 +491,21 @@ def addAttributePolicy(self, rtype:ResourceTypes|str, attr:str, attrPolicy:Attri else: complexTypeAttributes[attrPolicy.ctype] = [ attr ] + if (ctypes := attributesComplexTypes.get(attr)): + ctypes.append(attrPolicy.ctype) + else: + attributesComplexTypes[attr] = [ attrPolicy.ctype ] + def getAttributePolicy(self, rtype:ResourceTypes|str, attr:str) -> AttributePolicy: """ Return the attributePolicy for a resource type. + + Args: + rtype: Resource type. + attr: Attribute name. + + Return: + AttributePolicy or None. """ # Search for the specific type first if (ap := attributePolicies.get((rtype, attr))): @@ -533,24 +548,46 @@ def getShortnameLongNameMapping(self) -> dict[str, str]: return result - def getAttributeValueName(self, key:str, value:str) -> str: + def getAttributeValueName(self, attr:str, value:int, rtype:Optional[ResourceTypes] = None) -> str: """ Return the name of an attribute value. This is usually used for enumerations, where the value is a number and the name is a string. Args: - key: String, attribute name. - value: String, attribute value. + attr: Attribute name. + value: Attribute value. Return: String, name of the attribute value. """ try: - if key in _valueNameMappings: - return _valueNameMappings[key](value) # type: ignore [no-untyped-call] + if attr in _valueNameMappings: + return _valueNameMappings[attr](value) # type: ignore [no-untyped-call] + from ..services import CSE + return CSE.validator.getEnumInterpretation(rtype, attr, value) except Exception as e: return str(e) - return '' - + + + def getEnumInterpretation(self, rtype: ResourceTypes, attr:str, value:int) -> str: + """ Return the interpretation of an enumeration. + + Args: + rtype: Resource type. May be None. + attr: Attribute name. + value: Enumeration value. + + Return: + String, interpretation of the enumeration, or the value itself if no interpretation is available. + """ + if rtype is not None: + if (policy := self.getAttributePolicy(rtype, attr)) and policy.evalues: + return policy.evalues.get(int(value), str(value)) + + if (ctype := attributesComplexTypes.get(attr)): + if (policy := self.getAttributePolicy(ctype[0], attr)) and policy.evalues: # just any policy for the complex type + return policy.evalues.get(int(value), str(value)) + return str(value) + # # Internals. diff --git a/acme/textui/ACMEContainerRequests.py b/acme/textui/ACMEContainerRequests.py index d8a1a01a..5bdb12f3 100644 --- a/acme/textui/ACMEContainerRequests.py +++ b/acme/textui/ACMEContainerRequests.py @@ -61,7 +61,8 @@ class ACMEViewRequests(Vertical): BINDINGS = [ Binding('r', 'refresh_requests', 'Refresh'), Binding('D', 'delete_requests', 'Delete ALL Requests', key_display = 'SHIFT+D'), - Binding('e', 'enable_requests', '') + Binding('e', 'enable_requests', ''), + Binding('t', 'toggle_list_details', 'List Details'), ] DEFAULT_CSS = """ @@ -132,6 +133,7 @@ def __init__(self) -> None: # Request List self.requestList = ListView(id = 'request-list-list') + self.listDetails = False # Request view: request + response self.requestListRequest = Static(id = 'request-list-request') @@ -237,6 +239,13 @@ def action_enable_requests(self) -> None: def action_disable_requests(self) -> None: CSE.request.enableRequestRecording = False self.updateBindings() + + + def action_toggle_list_details(self) -> None: + self.listDetails = not self.listDetails + self.updateRequests() + + # TODO def updateBindings(self) -> None: @@ -278,11 +287,18 @@ def rscFmt(rsc:int) -> str: # _to = _to if _to else '' _srn = r.get('srn', '') # _srn = _srn if _srn else '' - self.requestList.append(_l := ACMEListItem( - Label(f' {i:4} - {_ts[1]} {Operation(r["op"]).name:10.10} {str(r.get("org", "")):30.30} {str(_to):30.30} {rscFmt(r["rsc"])}\n [dim]{_ts[0]}[/dim] [dim]{_srn}[/dim]'))) + match self.listDetails: + case True: + _l = ACMEListItem(Label(f' {i:4} - {_ts[1]} {Operation(r["op"]).name:10.10} {str(r.get("org", "")):30.30} {str(_to):30.30} {rscFmt(r["rsc"])}\n [dim]{_ts[0]}[/dim] [dim]{_srn}[/dim]')) + case False: + _l = ACMEListItem(Label(f' {i:4} - {_ts[1]} {Operation(r["op"]).name:10.10} {str(r.get("org", "")):30.30} {str(_to):30.30} {rscFmt(r["rsc"])}')) + _l._data = i if r['out']: _l.set_class(True, '--outgoing') + self.requestList.append(_l) + # self.requestList.append(_l := ACMEListItem( + # Label(f' {i:4} - {_ts[1]} {Operation(r["op"]).name:10.10} {str(r.get("org", "")):30.30} {str(_to):30.30} {rscFmt(r["rsc"])}\n [dim]{_ts[0]}[/dim] [dim]{_srn}[/dim]'))) def deleteRequests(self) -> None: diff --git a/acme/textui/ACMEContainerTree.py b/acme/textui/ACMEContainerTree.py index 38576a23..f4a3b7e4 100644 --- a/acme/textui/ACMEContainerTree.py +++ b/acme/textui/ACMEContainerTree.py @@ -212,8 +212,8 @@ def updateResource(self, resource:Optional[Resource] = None) -> None: if resource: jsns = commentJson(resource.asDict(sort = True), explanations = self.app.attributeExplanations, # type: ignore [attr-defined] - getAttributeValueName = CSE.validator.getAttributeValueName, # type: ignore [attr-defined] - width = (self.resourceView.size[0] - 2) if self.resourceView.size[0] > 0 else 9999) # type: ignore [attr-defined] + getAttributeValueName = lambda a, v: CSE.validator.getAttributeValueName(a, v, resource.ty if resource else None), # type: ignore [attr-defined] + width = (self.resourceView.size[0] - 2) if self.resourceView.size[0] > 0 else 9999) # type: ignore [attr-defined] # Update the requests view self._update_requests(resource.ri) diff --git a/acme/textui/ACMETuiApp.py b/acme/textui/ACMETuiApp.py index f1ab9780..813fcf08 100644 --- a/acme/textui/ACMETuiApp.py +++ b/acme/textui/ACMETuiApp.py @@ -99,6 +99,7 @@ def __init__(self, textUI:TextUI.TextUI): self.quitReason = ACMETuiQuitReason.undefined self.attributeExplanations = CSE.validator.getShortnameLongNameMapping() + # Add the resource types to the attribute explanations for n in ResourceTypes: self.attributeExplanations[ResourceTypes(n).tpe()] = f'{ResourceTypes.fullname(n)} resource type' diff --git a/docs/Importing.md b/docs/Importing.md index 0afa69c1..abe49d0c 100644 --- a/docs/Importing.md +++ b/docs/Importing.md @@ -273,12 +273,15 @@ The format for enumeration data type definitions is a bit simpler: // The attributePolicy.ep file contains a dictionary of enumeration data types { - // Each enumeration definition is identified by its name + // Each enumeration definition is identified by its name. It is a dictionary. "enumerationType": { - // Each definition can only contain a the following attribute (definition see above) + // A single enumeration definition is key value pair. The key is the enumeration + // value, the value is the interpretation of that value. + "" : "" - "evalues" : ... + // This defines a range of values. Each one gets the same interpretation assigned. + ".." : "" } } ``` diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index f946d4d0..900ca0ff 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -1575,6 +1575,20 @@ "annc": "OA" } ], + "lit": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationInformationType", + "ns": "m2m", + "type": "enum", + "etype": "m2m:locationInformationType", + "car": "1", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], "ln": [ { "rtypes": [ "ALL" ], @@ -1619,6 +1633,35 @@ "annc": "MA" } ], + "los": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationSource", + "ns": "m2m", + "type": "enum", + "etype": "m2m:locationSource", + "car": "1", + "oc": "M", + "ou": "NP", + "od": "NP", + "annc": "OA" + } + ], + "lou": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationUpdatePeriod", + "ns": "m2m", + "type": "list", + "ltype": "duration", + "car": "01L", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], + // TODO Align later // EXPERIMENTAL "ma": [ @@ -2062,7 +2105,8 @@ "rtypes": [ "ALL" ], "lname": "notificationContentType", "ns": "m2m", - "type": "nonNegInteger", + "type": "enum", + "etype": "m2m:notificationContentType", "car": "1", "oc": "O", "ou": "O", diff --git a/init/enumTypesPolicies.ep b/init/enumTypesPolicies.ep index fff64224..450ca744 100644 --- a/init/enumTypesPolicies.ep +++ b/init/enumTypesPolicies.ep @@ -7,101 +7,402 @@ { "m2m:batteryStatus" : { - "evalues": [ "1..7" ] + "1": "Normal", + "2": "Charging", + "3": "Charging complete", + "4": "Damaged", + "5": "Low battery", + "6": "Not installed", + "7": "Unknown" }, "m2m:contentStatus" : { - "evalues": [ 1, 2 ] + "1": "Partial content", + "2": "Full content" }, "m2m:evalCriteriaOperator" : { - "evalues": [ "1..6" ] + "1": "equal", + "2": "not equal", + "3": "greater than", + "4": "less than", + "5": "greater than or equal", + "6": "less than or equal" }, "m2m:evalMode" : { - "evalues": [ "0..3" ] + "0": "off", + "1": "once", + "2": "periodic", + "3": "continuous" }, "m2m:eventCat" : { // m2m:stdEventCat + user defined range - "evalues" : [ "2..4", "100..999"] + "2": "Immediate", + "3": "Best Effort", + "4": "Latest", + "100..999": "User defined" }, // EXPERIMENTAL "m2m:eventEvaluationMode" : { - "evalues" : [ "1..5" ] + "1": "All events present", + "2": "All or some events present", + "3": "All or some events missing", + "4": "All events missing", + "5": "Some events missing" }, "m2m:filterOperation" : { - "evalues" : [ "1..3" ] + "1": "Logical AND", + "2": "Logical OR", + "3": "Logical XOR" }, "m2m:contentFilterSyntax" : { - "evalues" : [ 1 ] + "1": "JSONPath Syntax" }, "m2m:desIdResType" : { - "evalues": [ 1, 2 ] + "1": "Structured", + "2": "Unstructured" }, "m2m:logTypeId" : { - "evalues": [ "1..5" ] + "1": "System", + "2": "Security", + "3": "Event", + "4": "Trace", + "5": "Panic" }, "m2m:filterUsage" : { - "evalues" : [ "1..4" ] + "1": "Discovery", + "2": "Conditional Operation", + "3": "IPE On-demand Discovery" }, "m2m:geometryType" : { - "evalues" : [ "1..6" ] + "1": "Point", + "2": "LineString", + "3": "Polygon", + "4": "MultiPoint", + "5": "MultiLineString", + "6": "MultiPolygon" }, "m2m:geoSpatialFunctionType" : { - "evalues": [ "1..3" ] + "1": "Within", + "2": "Contains", + "3": "Intersects" + }, + "m2m:locationInformationType" : { + "1": "Position fix", + "2": "Geofence event" + }, + "m2m:locationSource" : { + "1": "Network based", + "2": "Device based", + "3": "User based" }, "m2m:logStatus" : { - "evalues": [ "1..5" ] + "1": "Started", + "2": "Stopped", + "3": "Unknown", + "4": "Not present", + "5": "Error" }, "m2m:mgmtDefinition" : { // Adapt to supported MgmtObj types - "evalues" : [ "1001..1010", 1021, 1023, 1028 ] + "0": "Self-defined", + "1001": "firmware", + "1002": "software", + "1003": "memory", + "1004": "areaNwkInfo", + "1005": "areaNwkDeviceInfo", + "1006": "battery", + "1007": "deviceInfo", + "1008": "deviceCapability", + "1009": "reboot", + "1010": "eventLog", + "1011": "cmdhPolicy", + "1012": "activeCmdhPolicy", + "1013": "cmdhDefaults", + "1014": "cmdhDefEcValue", + "1015": "cmdhEcDefParamValues", + "1016": "cmdhLimits", + "1017": "cmdhNetworkAccessRules", + "1018": "cmdhNwAccessRule", + "1019": "cmdhBuffer", + "1020": "registration", + "1021": "dataCollection", + "1022": "authenticationProfile", + "1023": "myCertFileCred", + "1024": "trustAnchorCred", + "1025": "MAFClientRegCfg", + "1026": "MEFClientRegCfg", + "1027": "OAuth2Authentication", + "1028": "wifiClient" }, "m2m:multicastCapability" : { - "evalues" : [ 1, 2 ] + "1": "MBMS", + "2": "IP" + }, + "m2m:notificationContentType" : { + "1": "m2m:", + "2": "m2m:", + "3": "m2m:URI", + "4": "m2m:triggerPayload", + "5": "m2m:timeSeriesNotification" }, "m2m:notificationEventType" : { - "evalues": [ "1..8", 9, 10 ] // EXPERIMENTAL 9, 10 experimental + "1": "Update of Resource", + "2": "Delete of Resource", + "3": "Create of Direct Child Resource", + "4": "Delete of Direct Child Resource", + "5": "Retrieve of Container Resource with No Child Resource", + "6": "Trigger Received for AE Resource", + "7": "Blocking Update", + "8": "Report on Missing Data Points", + + "9": "blockingRetrieve (EXPERIMENTAL)", // EXPERIMENTAL + "10": "blockingRetrieveDirectChild (EXPERIMENTAL)" // EXPERIMENTAL }, "m2m:operation" : { - "evalues": [ "1..5" ] + "1": "Create", + "2": "Retrieve", + "3": "Update", + "4": "Delete", + "5": "Notify" }, "m2m:responseType" : { - "evalues" : [ "1..5" ] + "1": "Non-blocking Request Synch", + "2": "Non-blocking Request Asynch", + "3": "Blocking Request", + "4": "FlexBlocking", + "5": "No Response" }, "m2m:resourceType" : { // Adapt to supported resource types - "evalues" : [ "1..5", 9, "13..18", 23, 24, "28..30", 48, 58, 60, 65, 66, - "10001..10005", 10009, "10013..10014", 10016, 10018, 10021, "10028..10030", 10060, 10065, 10066 ] + "1": "accessControlPolicy", + "2": "AE", + "3": "container", + "4": "contentInstance", + "5": "CSEBase", + "9": "group", + "13": "locationPolicy", + "14": "mgmtCmd", + "15": "mgmtObj", + "16": "node", + "17": "pollingChannel", + "18": "remoteCSE", + "23": "schedule", + "24": "serviceSubscribedAppRule", + "28": "flexContainer", + "29": "timeSeries", + "30": "timeSeriesInstance", + "48": "crossResourceSubscription", + "58": "flexContainerInstance", + "60": "timeSyncBeacon", + "65": "state", + "66": "action", + + "10001": "accessControlPolicyAnnc", + "10002": "AEAnnc", + "10003": "containerAnnc", + "10004": "contentInstanceAnnc", + "10005": "CSEBaseAnnc", + "10009": "groupAnnc", + "10013": "locationPolicyAnnc", + "10014": "mgmtObjAnnc", + "10016": "nodeAnnc", + "10018": "remoteCSEAnnc", + "10021": "scheduleAnnc", + "10028": "flexContainerAnnc", + "10029": "timeSeriesAnnc", + "10030": "timeSeriesInstanceAnnc", + "10060": "timeSyncBeaconAnnc", + "10065": "stateAnnc", + "10066": "actionAnnc" }, "m2m:responseStatusCode" : { - "evalues": [ "1000..1002", - "2000..2002", 2004, - 4000, 4001, 4004, 4005, 4008, 4015, "4101..4133", "4135..4143", - 5000, 5001, 5103, "5105..5107", "5203..5222", "5230..5232", - 6003, 6005, 6010, "6020..6026", "6028..6034"] + "1000": "ACCEPTED", + "1001": "ACCEPTED for nonBlockingRequestSynch", + "1002": "ACCEPTED for nonBlockingRequestAsynch", + + "2000": "OK", + "2001": "CREATED", + "2002": "DELETED", + "2004": "UPDATED", + + "4000": "Bad Request", + "4001": "Release Version Not Supported", + "4004": "Not Found", + "4005": "Operation Not Allowed", + "4008": "Request Timeout", + "4015": "Unsupported Media Type", + "4101": "Subscription Creator Has No Privilege", + "4102": "Contents Unacceptable", + "4103": "Originator Has No Privilege", + "4104": "Group Request Identifier Exists", + "4105": "Conflict", + "4106": "Originator Has Not Registered", + "4107": "Security Association Required", + "4108": "Invalid Child Resource Type", + "4109": "No Members", + "4110": "Group Member Type Inconsistent", + "4111": "ESPRIM Unsupported Option", + "4112": "ESPRIM Unknown Key ID", + "4113": "ESPRIM Unknown Orig RAND ID", + "4114": "ESPRIM Unknown Recv RAND ID", + "4115": "ESPRIM Bad MAC", + "4116": "ESPRIM Impersonation Error", + "4117": "Originator Has Already Registered", + "4118": "Ontology Not Available", + "4119": "Linked Semantics Not Available", + "4120": "Invalid Semantics", + "4121": "Mashup Member Not Found", + "4122": "Invalid Trigger Purpose", + "4123": "Illegal Transaction State Transition Attempted", + "4124": "Blocking Subscription Already Exists", + "4125": "Specialization Schema Not Found", + "4126": "App Rule Validation Failed", + "4127": "Operation Denied By Remote Entity", + "4128": "Service Subscription Not Established", + "4130": "Discovery Limit Exceeded", + "4131": "Ontology Mapping Algorithm Not Available", + "4132": "Ontology Mapping Policy Not Matched", + "4133": "Ontology Mapping Not Available", + "4135": "Bad Fact Inputs For Reasoning", + "4136": "Bad Rule Inputs For Reasoning", + "4137": "Discovery Limit Exceeded", + "4138": "Primitive Profile Not Accessible", + "4139": "Primitive Profile Bad Request", + "4140": "Unauthorized User", + "4141": "Service Subscription Limits Exceeded", + "4142": "Invalid Process Configuration", + "4143": "Invalid SPARQL Query", + + "5000": "Internal Server Error", + "5001": "Not Implemented", + "5103": "Target Not Reachable", + "5105": "Receiver Has No Privilege", + "5106": "Already Exists", + "5107": "Remote Entity Not Reachable", + "5203": "Target Not Subscribable", + "5204": "Subscription Verification Initiation Failed", + "5205": "Subscription Host Has No Privilege", + "5206": "Non Blocking Synch Request Not Supported", + "5207": "Not Acceptable", + "5208": "Discovery Denied By IPE", + "5209": "Group Members Not Responded", + "5210": "ESPRIM Decryption Error", + "5211": "ESPRIM Encryption Error", + "5212": "SPARQL Update Error", + "5214": "Target Has No Session Capability", + "5215": "Session Is Online", + "5216": "Join Multicast Group Failed", + "5217": "Leave Multicast Group Failed", + "5218": "Triggering Disabled For Recipient", + "5219": "Unable To Replace Request", + "5220": "Unable To Recall Request", + "5221": "Cross Resource Operation Failure", + "5222": "Transaction Processing Is Incomplete", + "5230": "Ontology Mapping Algorithm Failed", + "5231": "Ontology Conversion Failed", + "5232": "Reasoning Processing Failed", + + "6003": "External Object Not Reachable", + "6005": "External Object Not Found", + "6010": "Max Number Of Member Exceeded", + "6020": "Mgmt Session Cannot Be Established", + "6021": "Mgmt Session Establishment Timeout", + "6022": "Invalid Cmdtype", + "6023": "Invalid Arguments", + "6024": "Insufficient Arguments", + "6025": "Mgmt Conversion Error", + "6026": "Mgmt Cancellation Failed", + "6028": "Already Complete", + "6029": "Mgmt Command Not Cancellable", + "6030": "External Object Not Reachable Before RQET Timeout", + "6031": "External Object Not Reachable Before OET Timeout", + "6033": "Network QoS Configuration Error", + "6034": "Requested Activity Pattern Not Permitted" }, "m2m:resultContent" : { - "evalues": [ "0..12" ] + "0": "Nothing", + "1": "Attributes", + "2": "Hierarchical address", + "3": "Hierarchical address and attributes", + "4": "Attributes and child resources", + "5": "Attributes and child resource references", + "6": "Child resource references", + "7": "Original resource", + "8": "Child resources", + "9": "Modified attributes", + "10": "Semantic content", + "11": "Semantic content and child resources", + "12": "Permissions" }, "m2m:semanticFormat" : { - "evalues" : [ "1..7" ] + "1": "IRI", + "2": "Functional-style", + "3": "OWL/XML", + "4": "RDF/XML", + "5": "RDF/Turtle", + "6": "Manchester", + "7": "JSON-LD" }, "m2m:stationaryIndication" : { - "evalues" : [ 1, 2 ] + "1": "Stationary", + "2": "Mobile (Moving)" }, + "m2m:status" : { - "evalues" : [ "0..3" ] + "0": "Uninitialized", + "1": "Successful", + "2": "Failure", + "3": "In Process" }, "m2m:suid" : { - "evalues" : [ "10..15", "21..25", "32..35", "40..45" ] + "10": "A pre-provisioned symmetric key intended to be shared with a MEF", + "11": "A pre-provisioned symmetric key intended to be shared with a MAF", + "12": "A pre-provisioned symmetric key intended for use in a Security Associated Establishment Framework (SAEF)", + "13": "A pre-provisioned symmetric key intended for use in End-to-End Security of Primitives (ESPrim)", + "14": "A pre-provisioned symmetric key intended for use with authenticated encryption in the Encryption-only or Nested Sign-then-Encrypt End-to-End Security of Data (ESData) Data classes", + "15": "A pre-provisioned symmetric key intended for use in Signature-only ESData Security Class", + + "21": "A symmetric key, provisioned via a Remote Security Provisioning Framework (RSPF), and intended to be shared with a MAF", + "22": "A symmetric key, provisioned via a RSPF, and intended for use in a SAEF", + "23": "A symmetric key, provisioned via a RSPF, and intended for use in ESPrim", + "24": "A symmetric key, provisioned via a RSPF, and intended for use with authenticated encryption in the Encryption-only or Nested Sign-then-Encrypt ESData) Data classes", + "25": "A symmetric key, provisioned via a RSPF, and intended for use in Signature-only ESData Security Class", + + "32": "A MAF-distributed symmetric key intended for use in a SAEF", + "33": "A MAF-distributed symmetric key intended for use in ESPrim", + "34": "A MAF-distributed symmetric key intended for use with authenticated encryption in the Encryption-only or Nested Sign-then-Encrypt ESData Data classes", + "35": "A MAF-distributed symmetric key intended for use in Signature-only ESData Security Class", + + "40": "A certificate intended to be shared with a MEF", + "41": "A certificate intended to be shared with a MAF", + "42": "A certificate intended for use in a Security Associated Establishment Framework (SAEF)", + "43": "A certificate intended for use in End-to-End Security of Primitives (ESPrim)", + "44": "A certificate intended for use with authenticated encryption in the Encryption-only or Nested Sign-then-Encrypt End-to-End Security of Data (ESData) Data classes", + "45": "A certificate intended for use in Signature-only ESData Security Class" }, "m2m:timeWindowType" : { - "evalues" : [ 1, 2 ] + "1": "Periodic Window", + "2": "Sliding Window" }, "dcfg:wifiConnectionStatus" : { - "evalues" : [ "0..6" ] + "0": "Disconnected", + "1": "Connected", + "2": "Idle", + "3": "No SSID available", + "4": "Scan completed", + "5": "Failed", + "6": "Lost" }, "dcfg:wifiEncryptionType" : { - "evalues" : [ "1..8" ] + "1": "None", + "2": "WEP", + "3": "WPA Personal", + "4": "WPA2 Personal", + "5": "WPA3 Personal", + "6": "WPA Enterprise", + "7": "WPA2 Enterprise", + "8": "WPA3 Enterprise" } } + From caafeb1f819750592f7245075c3828ce0643bbda Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 26 Jul 2023 11:49:27 +0200 Subject: [PATCH 059/165] Start of LocationPolicy implementation --- acme/resources/AE.py | 1 + acme/resources/AEAnnc.py | 3 +- acme/resources/CSEBaseAnnc.py | 1 + acme/resources/CSR.py | 3 +- acme/resources/CSRAnnc.py | 1 + acme/resources/Factory.py | 4 + acme/resources/LCP.py | 71 ++++++++++++++++++ init/attributePolicies.ap | 134 +++++++++++++++++++++++++++++++++- init/enumTypesPolicies.ep | 9 +++ 9 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 acme/resources/LCP.py diff --git a/acme/resources/AE.py b/acme/resources/AE.py index 4ff43236..c8f4b1ff 100644 --- a/acme/resources/AE.py +++ b/acme/resources/AE.py @@ -28,6 +28,7 @@ class AE(AnnounceableResource): ResourceTypes.CRS, ResourceTypes.FCNT, ResourceTypes.GRP, + ResourceTypes.LCP, ResourceTypes.PCH, ResourceTypes.SMD, ResourceTypes.SUB, diff --git a/acme/resources/AEAnnc.py b/acme/resources/AEAnnc.py index 4187500e..1d93eb4d 100644 --- a/acme/resources/AEAnnc.py +++ b/acme/resources/AEAnnc.py @@ -25,7 +25,8 @@ class AEAnnc(AnnouncedResource): ResourceTypes.FCNT, ResourceTypes.FCNTAnnc, ResourceTypes.GRP, - ResourceTypes.GRPAnnc, + ResourceTypes.GRPAnnc, + ResourceTypes.LCPAnnc, ResourceTypes.TS, ResourceTypes.TSAnnc ] diff --git a/acme/resources/CSEBaseAnnc.py b/acme/resources/CSEBaseAnnc.py index f57d51b6..0502279a 100644 --- a/acme/resources/CSEBaseAnnc.py +++ b/acme/resources/CSEBaseAnnc.py @@ -23,6 +23,7 @@ class CSEBaseAnnc(AnnouncedResource): ResourceTypes.CNTAnnc, ResourceTypes.FCNTAnnc, ResourceTypes.GRPAnnc, + ResourceTypes.LCPAnnc, ResourceTypes.NODAnnc, ResourceTypes.SCHAnnc, ResourceTypes.SUB, diff --git a/acme/resources/CSR.py b/acme/resources/CSR.py index 35d2fc8b..3e44cc7a 100644 --- a/acme/resources/CSR.py +++ b/acme/resources/CSR.py @@ -36,7 +36,8 @@ class CSR(AnnounceableResource): ResourceTypes.FCNTAnnc, ResourceTypes.FCI, ResourceTypes.GRP, - ResourceTypes.GRPAnnc, + ResourceTypes.GRPAnnc, + ResourceTypes.LCPAnnc, ResourceTypes.MGMTOBJAnnc, ResourceTypes.NODAnnc, ResourceTypes.PCH, diff --git a/acme/resources/CSRAnnc.py b/acme/resources/CSRAnnc.py index cd6a83e6..009f42dd 100644 --- a/acme/resources/CSRAnnc.py +++ b/acme/resources/CSRAnnc.py @@ -31,6 +31,7 @@ class CSRAnnc(AnnouncedResource): ResourceTypes.FCNTAnnc, ResourceTypes.GRP, ResourceTypes.GRPAnnc, + ResourceTypes.LCPAnnc, ResourceTypes.MGMTOBJAnnc, ResourceTypes.NODAnnc, ResourceTypes.SCHAnnc, diff --git a/acme/resources/Factory.py b/acme/resources/Factory.py index 10a7d1b9..1f9adb81 100644 --- a/acme/resources/Factory.py +++ b/acme/resources/Factory.py @@ -49,6 +49,8 @@ from ..resources.GRP import GRP from ..resources.GRPAnnc import GRPAnnc from ..resources.GRP_FOPT import GRP_FOPT +from ..resources.LCP import LCP +# TODO from ..resources.LCPAnnc import LCPAnnc from ..resources.NOD import NOD from ..resources.NODAnnc import NODAnnc from ..resources.PCH import PCH @@ -126,6 +128,8 @@ addResourceFactoryCallback(ResourceTypes.GRP, GRP, lambda dct, tpe, pi, create : GRP(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.GRPAnnc, GRPAnnc, lambda dct, tpe, pi, create : GRPAnnc(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.GRP_FOPT, GRP_FOPT, lambda dct, tpe, pi, create : GRP_FOPT(dct, pi = pi, create = create)) +addResourceFactoryCallback(ResourceTypes.LCP, LCP, lambda dct, tpe, pi, create : LCP(dct, pi = pi, create = create)) +# TODO addResourceFactoryCallback(ResourceTypes.LCPAnnc, LCPAnnc, lambda dct, tpe, pi, create : LCPAnnc(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.NOD, NOD, lambda dct, tpe, pi, create : NOD(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.NODAnnc, NODAnnc, lambda dct, tpe, pi, create : NODAnnc(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.PCH, PCH, lambda dct, tpe, pi, create : PCH(dct, pi = pi, create = create)) diff --git a/acme/resources/LCP.py b/acme/resources/LCP.py new file mode 100644 index 00000000..4febcea4 --- /dev/null +++ b/acme/resources/LCP.py @@ -0,0 +1,71 @@ + # +# LCP.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# ResourceType: LocationPolicy +# + +""" LocationPolicy (LCP) resource type. """ + +from __future__ import annotations +from typing import Optional + +from ..etc.Constants import Constants as C +from ..etc.Types import AttributePolicyDict, ResourceTypes, JSON +from ..services.Logging import Logging as L +from ..services import CSE +from ..resources.Resource import Resource +from ..resources.AnnounceableResource import AnnounceableResource + +# TODO add annc +# TODO add to supported resources of CSE + +class LCP(AnnounceableResource): + """ Schedule (SCH) resource type. """ + + # Specify the allowed child-resource types + _allowedChildResourceTypes:list[ResourceTypes] = [ ResourceTypes.SUB ] + """ The allowed child-resource types. """ + + # Attributes and Attribute policies for this Resource Class + # Assigned during startup in the Importer + _attributes:AttributePolicyDict = { + # Common and universal attributes + 'rn': None, + 'ty': None, + 'ri': None, + 'pi': None, + 'ct': None, + 'lt': None, + 'lbl': None, + 'acpi':None, + 'et': None, + 'daci': None, + 'cstn': None, + 'at': None, + 'aa': None, + 'ast': None, + + # Resource attributes + 'los': None, + 'lit': None, + 'lou': None, + 'lot': None, + 'lor': None, + 'loi': None, + 'lon': None, + 'lost': None, + 'gta': None, + 'gec': None, + 'aid': None, + 'rlkl': None, + 'luec': None + } + """ Attributes and `AttributePolicy` for this resource type. """ + + + def __init__(self, dct:Optional[JSON] = None, pi:Optional[str] = None, create:Optional[bool] = False) -> None: + super().__init__(ResourceTypes.LCP, dct, pi, create = create) + diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index 900ca0ff..4b0f363e 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -365,6 +365,19 @@ "annc": "OA" } ], + "aid": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "authID", + "ns": "m2m", + "type": "string", + "car": "01", + "oc": "O", + "ou": "NP", + "od": "NP", + "annc": "OA" + } + ], "air": [ { "rtypes": [ "ACTR", "ACTRAnnc", "REQRESP" ], @@ -1360,6 +1373,20 @@ "annc": "OA" } ], + "gec": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "geofenceEventCriteria", + "ns": "m2m", + "type": "enum", + "etype": "m2m:geofenceEventCriteria", + "car": "01", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], "gn": [ { "rtypes": [ "ALL" ], @@ -1386,6 +1413,19 @@ "annc": "NA" } ], + "gta": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "geographicalTargetArea", + "ns": "m2m", + "type": "any", + "car": "01", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], "hael": [ { "rtypes": [ "ALL" ], @@ -1633,6 +1673,32 @@ "annc": "MA" } ], + "loi": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationContainerID", + "ns": "m2m", + "type": "anyURI", + "car": "1", + "oc": "NP", + "ou": "NP", + "od": "NP", + "annc": "OA" + } + ], + "lon": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationContainerName", + "ns": "m2m", + "type": "string", + "car": "01", + "oc": "O", + "ou": "NP", + "od": "NP", + "annc": "OA" + } + ], "los": [ { "rtypes": [ "LCP", "LCPAnnc" ], @@ -1640,13 +1706,52 @@ "ns": "m2m", "type": "enum", "etype": "m2m:locationSource", - "car": "1", + "car": "01", "oc": "M", "ou": "NP", "od": "NP", "annc": "OA" } ], + "lost": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationStatus", + "ns": "m2m", + "type": "string", + "car": "1", + "oc": "NP", + "ou": "NP", + "od": "NP", + "annc": "OA" + } + ], + "lor": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationServer", + "ns": "m2m", + "type": "anyURI", + "car": "01", + "oc": "O", + "ou": "NP", + "od": "NP", + "annc": "OA" + } + ], + "lot": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationTargetID", + "ns": "m2m", + "type": "string", + "car": "01L", + "oc": "O", + "ou": "NP", + "od": "NP", + "annc": "OA" + } + ], "lou": [ { "rtypes": [ "LCP", "LCPAnnc" ], @@ -1661,6 +1766,20 @@ "annc": "OA" } ], + "luec": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "locationUpdateEventCriteria", + "ns": "m2m", + "type": "enum", + "etype": "m2m:locationUpdateEventCriteria", + "car": "01", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], // TODO Align later // EXPERIMENTAL @@ -2578,6 +2697,19 @@ "annc": "NA" } ], + "rlkl": [ + { + "rtypes": [ "LCP", "LCPAnnc" ], + "lname": "retrieveLastKnownLocation", + "ns": "m2m", + "type": "boolean", + "car": "01", + "oc": "O", + "ou": "O", + "od": "O", + "annc": "OA" + } + ], "rms": [ { "rtypes": [ "ALL" ], diff --git a/init/enumTypesPolicies.ep b/init/enumTypesPolicies.ep index 450ca744..ecd7fac8 100644 --- a/init/enumTypesPolicies.ep +++ b/init/enumTypesPolicies.ep @@ -74,6 +74,12 @@ "2": "Conditional Operation", "3": "IPE On-demand Discovery" }, + "m2m:geofenceEventCriteria" : { + "1": "Entering", + "2": "Leaving", + "3": "Inside", + "4": "Outside" + }, "m2m:geometryType" : { "1": "Point", "2": "LineString", @@ -96,6 +102,9 @@ "2": "Device based", "3": "User based" }, + "m2m:locationUpdateEventCriteria": { + "0": "Location_Change" + }, "m2m:logStatus" : { "1": "Started", "2": "Stopped", From 972ffad69abd7a4feedac2b9f769a12e55c2c501 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 28 Jul 2023 11:11:03 +0200 Subject: [PATCH 060/165] Improved request sending --- acme/helpers/Interpreter.py | 1 - acme/services/ScriptManager.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index af3fc2b9..4eeea634 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -2253,7 +2253,6 @@ def _doIn(pcontext:PContext, symbol:SSymbol) -> PContext: # Get symbol (!) to check pcontext, _s = pcontext.resultFromArgument(symbol, 2, (SType.tString, SType.tList, SType.tListQuote)) - # check return pcontext.setResult(SSymbol(boolean = _v in _s)) diff --git a/acme/services/ScriptManager.py b/acme/services/ScriptManager.py index 90d27895..c797d859 100644 --- a/acme/services/ScriptManager.py +++ b/acme/services/ScriptManager.py @@ -396,7 +396,7 @@ def doGetConfiguration(self, pcontext:PContext, symbol:SSymbol) -> PContext: # config value if (_v := Configuration.get(_key)) is None: - raise PUndefinedError(pcontext.setError(PError.undefined, f'undefined key: {_key}')) + raise PUndefinedError(pcontext.setError(PError.undefined, f'undefined configuration key: {_key}')) return pcontext.setResult(SSymbol(value = _v)) @@ -1423,7 +1423,7 @@ def _handleRequest(self, pcontext:PContext, symbol:SSymbol, operation:Operation) if operation == Operation.CREATE: if (ty := ResourceTypes.fromTPE( list(content.keys())[0] )) is None: # first is tpe raise PInvalidArgumentError(pcontext.setError(PError.invalid, 'Cannot determine resource type')) - req['ty'] = ty + req['ty'] = ty.value # Add primitive content when content is available req['pc'] = content From 1ec5ed11c542077f8589186f3b8305aa7ac5c060 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 28 Jul 2023 12:48:20 +0200 Subject: [PATCH 061/165] Prevent crash when no originator is available for a resource --- acme/textui/ACMEContainerDelete.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/acme/textui/ACMEContainerDelete.py b/acme/textui/ACMEContainerDelete.py index 7aa3a46d..36534e2a 100644 --- a/acme/textui/ACMEContainerDelete.py +++ b/acme/textui/ACMEContainerDelete.py @@ -85,7 +85,10 @@ def on_show(self) -> None: def updateResource(self, resource:Resource) -> None: self.requestOriginator = resource.getOriginator() - self.fieldOriginator.update(self.requestOriginator, [CSE.cseOriginator, self.requestOriginator]) + if self.requestOriginator: + self.fieldOriginator.update(self.requestOriginator, [CSE.cseOriginator, self.requestOriginator]) + else: # No originator, use CSE originator + self.fieldOriginator.update(CSE.cseOriginator, [CSE.cseOriginator]) self.resource = resource self.response.update('') From 74f3a1adef1834c77a13aaf9dbb991b7c3880fcc Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 28 Jul 2023 14:28:16 +0200 Subject: [PATCH 062/165] Corrected resource types --- init/enumTypesPolicies.ep | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/init/enumTypesPolicies.ep b/init/enumTypesPolicies.ep index ecd7fac8..bdc25acb 100644 --- a/init/enumTypesPolicies.ep +++ b/init/enumTypesPolicies.ep @@ -190,22 +190,23 @@ "4": "contentInstance", "5": "CSEBase", "9": "group", - "13": "locationPolicy", - "14": "mgmtCmd", - "15": "mgmtObj", - "16": "node", - "17": "pollingChannel", - "18": "remoteCSE", - "23": "schedule", - "24": "serviceSubscribedAppRule", + "10": "locationPolicy", + "13": "mgmtObj", + "14": "node", + "15": "pollingChannel", + "16": "remoteCSE", + "17": "request", + "18": "schedule", + "23": "subscription", + "24": "semanticDescriptor", "28": "flexContainer", "29": "timeSeries", "30": "timeSeriesInstance", "48": "crossResourceSubscription", "58": "flexContainerInstance", "60": "timeSyncBeacon", - "65": "state", - "66": "action", + "65": "action", + "66": "dependency", "10001": "accessControlPolicyAnnc", "10002": "AEAnnc", @@ -213,17 +214,18 @@ "10004": "contentInstanceAnnc", "10005": "CSEBaseAnnc", "10009": "groupAnnc", - "10013": "locationPolicyAnnc", - "10014": "mgmtObjAnnc", - "10016": "nodeAnnc", - "10018": "remoteCSEAnnc", - "10021": "scheduleAnnc", + "10010": "locationPolicyAnnc", + "10013": "mgmtObjAnnc", + "10014": "nodeAnnc", + "10016": "remoteCSEAnnc", + "10018": "scheduleAnnc", + "10024": "semanticDescriptorAnnc", "10028": "flexContainerAnnc", "10029": "timeSeriesAnnc", "10030": "timeSeriesInstanceAnnc", "10060": "timeSyncBeaconAnnc", - "10065": "stateAnnc", - "10066": "actionAnnc" + "10065": "actionAnnc", + "10066": "dependencyAnnc" }, "m2m:responseStatusCode" : { "1000": "ACCEPTED", From d920a5c421ff4354288750d1483b5b818bc8f162 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 28 Jul 2023 14:28:42 +0200 Subject: [PATCH 063/165] Added locationPolicy enum types --- acme/etc/Types.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/acme/etc/Types.py b/acme/etc/Types.py index f69b3473..a92bf5d4 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -1477,10 +1477,48 @@ class SemanticFormat(ACMEIntEnum): SemanticFormat.FF_RdfTurtle: 'ttl', SemanticFormat.FF_Manchester: 'manchester', SemanticFormat.FF_JsonLD: 'json-ld', +} + + +############################################################################## +# +# LocationPolicy related +# + +class LocationSource(ACMEIntEnum): + """ Location Source. + """ + Network_based = 1 + """ Network based. """ + Device_based = 2 + """ Device based. """ + Sharing_based = 3 + """ Sharing based. """ -} +class GeofenceEventCriteria(ACMEIntEnum): + """ Geofence Event Criteria. + """ + + Entering = 1 + """ Entering. """ + Leaving = 2 + """ Leaving. """ + Inside = 3 + """ Inside. """ + Outside = 4 + """ Outside. """ + + +class LocationUpdateEventCriteria(ACMEIntEnum): + """ Location Update Event Criteria. + """ + + Location_Change = 0 + """ Location Change. """ + + ############################################################################## # # Result and Argument and Header Data Classes From ca0515caede4ef1d3f859737e76cf80ddc4ee55e Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 28 Jul 2023 17:02:04 +0200 Subject: [PATCH 064/165] Added default configurations for LCP containers --- acme.ini.default | 83 ++++++++++++++++++++++++++-------- acme/services/Configuration.py | 8 ++++ docs/Configuration.md | 14 ++++++ init/configurations.docmd | 24 ++++++++++ 4 files changed, 111 insertions(+), 18 deletions(-) diff --git a/acme.ini.default b/acme.ini.default index 80f18903..d0b12d74 100644 --- a/acme.ini.default +++ b/acme.ini.default @@ -248,6 +248,41 @@ caCertificateFile=${basic.config:dataDirectory}/certs/m2mqtt_ca.crt allowedCredentialIDs= +; +; CoAP client settings +; + +[coap] +; Enable the CoAP binding. +; Default: false +enable=false +serverPort=5683 +; Interface to listen to. Use 0.0.0.0 for "all" interfaces. +; Default: +listenIF=${basic.config:networkInterface} + + +; +; CoAP security settings +; + +[coap.security] +; Enable DTLS for communications with the CoAP server. +; Default: False +useDTLS=false +; TLS version to be used in connections. +; Allowed versions: TLS1.1, TLS1.2, auto . Use "auto" to allow client-server certificate +; version negotiation. +; Default: auto +dtlsVersion=auto +; Verify certificates in requests. Set to False when using self-signed certificates. +; Default: False +verifyCertificate=False +; Path and filename of the certificate file. Default: ${basic.config:dataDirectory}/certs/coap_cert.pem +certificateFile=${basic.config:dataDirectory}/certs/coap_cert.pem +; Path and filename of the private key file. Default: None +privateKeyFile=${basic.config:dataDirectory}/certs/coap_key.pem + ; ; Database settings ; @@ -405,6 +440,36 @@ mni=10 mbs=10000 +; +; Resource defaults: LocationPolicy +; + +[resource.lcp] +; Default for maxNrOfInstances for the LocationPolicy's container. +; Default: 10 +mni=10 +; Default for maxByteSize for the LocationPolicy's container. Default: 10.000 bytes +mbs=10000 + + +; +; Resource defaults: Request +; + +[resource.req] +; A resource's expiration time in seconds. Must be >0. Default: 60 +expirationTime=60 + + +; +; Resource defaults: Subscription +; + +[resource.sub] +; Default for batchNotify/duration in seconds. Must be >0. Default: 60 +batchNotifyDuration=60 + + ; ; Resource defaults: TimeSeries ; @@ -435,24 +500,6 @@ bcni=PT1H bcnt=10.0 -; -; Resource defaults: Request -; - -[resource.req] -; A resource's expiration time in seconds. Must be >0. Default: 60 -expirationTime=60 - - -; -; Resource defaults: Subscription -; - -[resource.sub] -; Default for batchNotify/duration in seconds. Must be >0. Default: 60 -batchNotifyDuration=60 - - ; ; Web UI settings ; diff --git a/acme/services/Configuration.py b/acme/services/Configuration.py index 7f09ed4e..5361e5a5 100644 --- a/acme/services/Configuration.py +++ b/acme/services/Configuration.py @@ -405,6 +405,14 @@ def init(args:argparse.Namespace = None) -> bool: 'resource.cnt.mbs' : config.getint('resource.cnt', 'mbs', fallback = 10000), + # + # Defaults for LocationPolicy Resources + # + + 'resource.lcp.mni' : config.getint('resource.lcp', 'mni', fallback = 10), + 'resource.lcp.mbs' : config.getint('resource.lcp', 'mbs', fallback = 10000), + + # # Defaults for Request Resources # diff --git a/docs/Configuration.md b/docs/Configuration.md index 87f09936..a6899d01 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -76,6 +76,7 @@ The following tables provide detailed descriptions of all the possible CSE confi [[resource.acp] - Resource defaults: Access Control Policies](#resource_acp) [[resource.actr] - Resource defaults: Action](#resource_actr) [[resource.cnt] - Resource Defaults: Container](#resource_cnt) +[[resource.lcp] - Resource Defaults: LocationPolicy](#resource_lcp) [[resource.req] - Resource Defaults: Request](#resource_req) [[resource.sub] - Resource Defaults: Subscription](#resource_sub) [[resource.ts] - Resource Defaults: TimeSeries](#resource_ts) @@ -376,6 +377,19 @@ The following tables provide detailed descriptions of all the possible CSE confi --- + + +### [resource.lcp] - Resource Defaults: + +| Setting | Description | Configuration Name | +|:--------|:--------------------------------------------------------------------------------------|:-------------------| +| mni | Default for maxNrOfInstances for the LocationPolicy's container.
Default: 10 | resource.lcp.mni | +| mbs | Default for maxByteSize for the LocationPolicy's container.
Default: 10.000 bytes | resource.lcp.mbs | + +[top](#sections) + +--- + ### [resource.req] - Resource Defaults: Request diff --git a/init/configurations.docmd b/init/configurations.docmd index 42807d42..708af284 100644 --- a/init/configurations.docmd +++ b/init/configurations.docmd @@ -1157,6 +1157,30 @@ The default value is `10000 bytes`. +# resource.lcp + +This section specifies the CSE's defaults for LCP (LocationPolicy) resources. + +Settings in this section are listed under the `[resource.lcp]` section. + + + +# resource.lcp.mni + +This setting specifies the value of the *mni* (maxNrOfInstances) attribute for the "locations" CNT resource that is created by the CSE when the LCP is created. + +The default value is `10`. + + + +# resource.lcp.mbs + +This setting specifies the value of the *mbs* (maxByteSize) attribute for the "locations" CNT resource that is created by the CSE when the LCP is created. + +The default value is `10000 bytes`. + + + # resource.req This section specifies the CSE's defaults for REQ (Request) resources. From 802b64e946c8a23b1a03d156dfc0036b51a1e51e Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 4 Aug 2023 14:54:34 +0200 Subject: [PATCH 065/165] Added utilities to work with geo locations, positions, and geo-fences --- acme/etc/GeoUtils.py | 75 ++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 36 +++++++++++---------- setup.py | 2 ++ 3 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 acme/etc/GeoUtils.py diff --git a/acme/etc/GeoUtils.py b/acme/etc/GeoUtils.py new file mode 100644 index 00000000..53ff8d98 --- /dev/null +++ b/acme/etc/GeoUtils.py @@ -0,0 +1,75 @@ +# +# GeoUtils.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# Various helpers for working with geo-coordinates, shapely, and geoJSON +# + +""" Utility functions for geo-coordinates and geoJSON +""" + +from typing import Union, Optional, cast +import json +from shapely import Point, Polygon + + +def getGeoPoint(jsn:Optional[Union[dict, str]]) -> Optional[tuple[float, float]]: + """ Get the geo-point from a geoJSON object. + + Args: + jsn: The geoJSON object as a dictionary or a string. + + Returns: + A tuple of the geo-point (latitude, longitude). None if not found or invalid JSON. + """ + if jsn is None: + return None + if isinstance(jsn, str): + try: + jsn = json.loads(jsn) + except ValueError: + return None + if cast(dict, jsn).get('type') != 'Point': + return None + if coordinates := cast(dict, jsn).get('coordinates'): + return coordinates[0], coordinates[1] + return None + + +def getGeoPolygon(jsn:Optional[Union[dict, str]]) -> Optional[list[tuple[float, float]]]: + """ Get the geo-polygon from a geoJSON object. + + Args: + jsn: The geoJSON object as a dictionary or a string. + + Returns: + A list of tuples of the geo-polygon (latitude, longitude). None if not found or invalid JSON. + """ + if jsn is None: + return None + if isinstance(jsn, str): + try: + jsn = json.loads(jsn) + except ValueError: + return None + if cast(dict, jsn).get('type') != 'Polygon': + return None + if coordinates := cast(dict, jsn).get('coordinates'): + return coordinates[0] + return None + + +def isLocationInsidePolygon(polygon:list[tuple[float, float]], location:tuple[float, float]) -> bool: + """ Check if a location is inside a polygon. + + Args: + polygon: The polygon as a list of tuples (latitude, longitude). + location: The location as a tuple (latitude, longitude). + + Returns: + True if the location is inside the polygon, False otherwise. + """ + return Polygon(polygon).contains(Point(location)) + diff --git a/requirements.txt b/requirements.txt index 57c34c3b..194b5f86 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,19 +10,19 @@ cbor2==5.4.6 # via ACME-oneM2M-CSE (setup.py) certifi==2023.7.22 # via requests -charset-normalizer==3.1.0 +charset-normalizer==3.2.0 # via requests -click==8.1.3 +click==8.1.6 # via flask flask==2.3.2 # via # ACME-oneM2M-CSE (setup.py) # flask-cors -flask-cors==3.0.10 +flask-cors==4.0.0 # via ACME-oneM2M-CSE (setup.py) idna==3.4 # via requests -importlib-metadata==6.7.0 +importlib-metadata==6.8.0 # via textual inquirerpy==0.3.4 # via ACME-oneM2M-CSE (setup.py) @@ -36,7 +36,7 @@ jinja2==3.1.2 # via flask linkify-it-py==2.0.2 # via markdown-it-py -markdown-it-py[linkify,plugins]==2.2.0 +markdown-it-py[linkify,plugins]==3.0.0 # via # mdit-py-plugins # rich @@ -49,43 +49,47 @@ mdit-py-plugins==0.4.0 # via markdown-it-py mdurl==0.1.2 # via markdown-it-py +numpy==1.25.2 + # via shapely paho-mqtt==1.6.1 # via ACME-oneM2M-CSE (setup.py) pfzy==0.3.4 # via inquirerpy plotext==5.2.8 # via ACME-oneM2M-CSE (setup.py) -prompt-toolkit==3.0.38 +prompt-toolkit==3.0.39 # via inquirerpy pygments==2.15.1 # via rich -pyparsing==3.1.0 +pyparsing==3.1.1 # via rdflib -rdflib==6.3.2 +python3-dtls==1.3.0 + # via ACME-oneM2M-CSE (setup.py) +rdflib==7.0.0 # via ACME-oneM2M-CSE (setup.py) requests==2.31.0 # via ACME-oneM2M-CSE (setup.py) -rich==13.4.2 +rich==13.5.2 # via # ACME-oneM2M-CSE (setup.py) # textual +shapely==2.0.1 + # via ACME-oneM2M-CSE (setup.py) six==1.16.0 - # via - # flask-cors - # isodate -textual==0.28.1 + # via isodate +textual==0.32.0 # via ACME-oneM2M-CSE (setup.py) tinydb==4.8.0 # via ACME-oneM2M-CSE (setup.py) -typing-extensions==4.6.3 +typing-extensions==4.7.1 # via textual uc-micro-py==1.0.2 # via linkify-it-py -urllib3==2.0.3 +urllib3==2.0.4 # via requests wcwidth==0.2.6 # via prompt-toolkit werkzeug==2.3.6 # via flask -zipp==3.15.0 +zipp==3.16.2 # via importlib-metadata diff --git a/setup.py b/setup.py index b5549688..c9259c2b 100644 --- a/setup.py +++ b/setup.py @@ -34,9 +34,11 @@ 'isodate', 'paho-mqtt', 'plotext', + 'python3-dtls', 'rdflib', 'requests', 'rich', + 'shapely', 'textual', 'tinydb', ], From 3c6ab463f33b07cdeba1f31e166062b4a5e069c2 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 4 Aug 2023 14:55:19 +0200 Subject: [PATCH 066/165] Added option to limit durations to ISO only --- acme/etc/DateUtils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/acme/etc/DateUtils.py b/acme/etc/DateUtils.py index 4e86ddd4..9e6b195a 100644 --- a/acme/etc/DateUtils.py +++ b/acme/etc/DateUtils.py @@ -77,11 +77,12 @@ def fromAbsRelTimestamp(absRelTimestamp:str, return default -def fromDuration(duration:str) -> float: +def fromDuration(duration:str, allowMS:bool = True) -> float: """ Convert a duration to a number of seconds (float). Args: duration: String with either an ISO 8601 period or a string with a number of ms. + allowMS: If True, the function tries to convert the string as if it contains a number of ms. Return: Float, number of seconds. Raise: @@ -93,7 +94,9 @@ def fromDuration(duration:str) -> float: try: # Last try: absRelTimestamp could be a relative offset in ms. Try to convert # the string and return an absolute UTC-based duration - return float(duration) / 1000.0 + if allowMS: + return float(duration) / 1000.0 + raise except Exception as e: #if L.isWarn: L.logWarn(f'Wrong format for duration: {duration}') raise From bbb1df1011edaf028c250295e6ec8ce9b1f9cda1 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 4 Aug 2023 14:57:25 +0200 Subject: [PATCH 067/165] Added first support for LocationPolicy --- acme.ini.default | 3 +- acme/etc/Types.py | 9 + acme/resources/CNT.py | 29 ++- acme/resources/CNT_LA.py | 33 ++- acme/resources/CSEBase.py | 1 + acme/resources/LCP.py | 137 ++++++++++- acme/services/CSE.py | 8 +- acme/services/Dispatcher.py | 15 +- acme/services/LocationManager.py | 336 +++++++++++++++++++++++++++ acme/services/Validator.py | 2 +- tests/init.py | 4 +- tests/testLCP.py | 383 +++++++++++++++++++++++++++++++ 12 files changed, 945 insertions(+), 15 deletions(-) create mode 100644 acme/services/LocationManager.py create mode 100644 tests/testLCP.py diff --git a/acme.ini.default b/acme.ini.default index d0b12d74..023f1c99 100644 --- a/acme.ini.default +++ b/acme.ini.default @@ -448,7 +448,8 @@ mbs=10000 ; Default for maxNrOfInstances for the LocationPolicy's container. ; Default: 10 mni=10 -; Default for maxByteSize for the LocationPolicy's container. Default: 10.000 bytes +; Default for maxByteSize for the LocationPolicy's container. +; Default: 10.000 bytes mbs=10000 diff --git a/acme/etc/Types.py b/acme/etc/Types.py index a92bf5d4..06c243ef 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -1518,6 +1518,15 @@ class LocationUpdateEventCriteria(ACMEIntEnum): Location_Change = 0 """ Location Change. """ + +class LocationInformationType(ACMEIntEnum): + """ Location Information Type. + """ + + Position_fix = 1 + """ Position fix. """ + Geofence_event = 2 + """ Geofence event. """ ############################################################################## # diff --git a/acme/resources/CNT.py b/acme/resources/CNT.py index 5599baea..65cad4da 100644 --- a/acme/resources/CNT.py +++ b/acme/resources/CNT.py @@ -76,10 +76,6 @@ def __init__(self, dct:Optional[JSON] = None, create:Optional[bool] = False) -> None: super().__init__(ResourceTypes.CNT, dct, pi, create = create) - # TODO optimize this - if Configuration.get('resource.cnt.enableLimits'): # Only when limits are enabled - self.setAttribute('mni', Configuration.get('resource.cnt.mni'), overwrite = False) - self.setAttribute('mbs', Configuration.get('resource.cnt.mbs'), overwrite = False) self.setAttribute('cni', 0, overwrite = False) self.setAttribute('cbs', 0, overwrite = False) self.setAttribute('st', 0, overwrite = False) @@ -89,7 +85,13 @@ def __init__(self, dct:Optional[JSON] = None, def activate(self, parentResource:Resource, originator:str) -> None: super().activate(parentResource, originator) - + + # Set the limits for this container if enabled + # TODO optimize this + if Configuration.get('resource.cnt.enableLimits'): # Only when limits are enabled + self.setAttribute('mni', Configuration.get('resource.cnt.mni'), overwrite = False) + self.setAttribute('mbs', Configuration.get('resource.cnt.mbs'), overwrite = False) + # register latest and oldest virtual resources L.isDebug and L.logDebug(f'Registering latest and oldest virtual resources for: {self.ri}') @@ -249,3 +251,20 @@ def _validateChildren(self) -> None: # End validating self.__validating = False + + def setLCPLink(self, lcpRi:str) -> None: + """ Set the link to the resource. This is called from the resource. + This also sets the link in the resource. + + Args: + lcpRi: The resource id of the resource. + """ + + self.setAttribute('li', lcpRi) + + # Also, set in the resource + if (latest := CSE.dispatcher.retrieveLocalResource(self.getLatestRI())) is not None: + latest.setLCPLink(lcpRi) + latest.dbUpdate() + + self.dbUpdate() \ No newline at end of file diff --git a/acme/resources/CNT_LA.py b/acme/resources/CNT_LA.py index c07173e2..38ba0eea 100644 --- a/acme/resources/CNT_LA.py +++ b/acme/resources/CNT_LA.py @@ -13,7 +13,7 @@ from __future__ import annotations from typing import Optional -from ..etc.Types import AttributePolicyDict, ResourceTypes, Result, JSON, CSERequest +from ..etc.Types import AttributePolicyDict, ResourceTypes, Result, JSON, CSERequest, LocationSource from ..etc.ResponseStatusCodes import ResponseStatusCode, OPERATION_NOT_ALLOWED, NOT_FOUND from ..services import CSE from ..services.Logging import Logging as L @@ -24,6 +24,9 @@ class CNT_LA(VirtualResource): """ This class implements the virtual resource for resources. """ + _li = '__li__' + """ Link to LCP from the parent resource. """ + _allowedChildResourceTypes:list[ResourceTypes] = [ ] """ A list of allowed child-resource types for this resource type. """ @@ -39,6 +42,9 @@ def __init__(self, dct:Optional[JSON] = None, pi:Optional[str] = None, create:Optional[bool] = False) -> None: super().__init__(ResourceTypes.CNT_LA, dct, pi, create = create, inheritACP = True, readOnly = True, rn = 'la') + + # Add to internal attributes to ignore in validation etc + self._addToInternalAttributes(self._li) def handleRetrieveRequest(self, request:Optional[CSERequest] = None, @@ -55,6 +61,13 @@ def handleRetrieveRequest(self, request:Optional[CSERequest] = None, The latest for the parent , or an error `Result`. """ L.isDebug and L.logDebug('Retrieving latest CIN from CNT') + + # Handle the request when the parent container's locationID is set + # This might create a new CIN + if (li := self.getLCPLink()) is not None: + if (result := self.retrieveLatestOldest(request, originator, ResourceTypes.CIN, oldest = False)) is not None: + CSE.location.handleLatestRetrieve(result.resource, li) + return self.retrieveLatestOldest(request, originator, ResourceTypes.CIN, oldest = False) @@ -107,3 +120,21 @@ def handleDeleteRequest(self, request:CSERequest, id:str, originator:str) -> Res raise NOT_FOUND('no instance for ') CSE.dispatcher.deleteLocalResource(resource, originator, withDeregistration = True) return Result(rsc = ResponseStatusCode.DELETED, resource = resource) + + + def getLCPLink(self) -> str: + """ Retrieve a `LocationPolicy` resource's resource ID. + + Return: + The resource ID. + """ + return self[self._li] + + + def setLCPLink(self, lcpRi:str) -> None: + """ Assign a resource ID of a `LocationPolicy` resource to the latest resource. + + Args: + ri: The resource ID of an `LocationPolicy` resource. + """ + self.setAttribute(self._li, lcpRi, overwrite = True) diff --git a/acme/resources/CSEBase.py b/acme/resources/CSEBase.py index b5e4230d..24655f64 100644 --- a/acme/resources/CSEBase.py +++ b/acme/resources/CSEBase.py @@ -32,6 +32,7 @@ class CSEBase(AnnounceableResource): ResourceTypes.CNT, ResourceTypes.FCNT, ResourceTypes.GRP, + ResourceTypes.LCP, ResourceTypes.NOD, ResourceTypes.REQ, ResourceTypes.SCH, diff --git a/acme/resources/LCP.py b/acme/resources/LCP.py index 4febcea4..389040bf 100644 --- a/acme/resources/LCP.py +++ b/acme/resources/LCP.py @@ -13,17 +13,23 @@ from typing import Optional from ..etc.Constants import Constants as C -from ..etc.Types import AttributePolicyDict, ResourceTypes, JSON +from ..etc.Types import AttributePolicyDict, ResourceTypes, JSON, LocationSource, GeofenceEventCriteria, LocationUpdateEventCriteria, LocationInformationType from ..services.Logging import Logging as L from ..services import CSE +from ..services.Configuration import Configuration from ..resources.Resource import Resource from ..resources.AnnounceableResource import AnnounceableResource +from ..resources import Factory +from ..etc.ResponseStatusCodes import BAD_REQUEST, NOT_IMPLEMENTED +from ..etc.GeoUtils import getGeoPolygon # TODO add annc # TODO add to supported resources of CSE class LCP(AnnounceableResource): - """ Schedule (SCH) resource type. """ + """ LocationPolicy (LCP) resource type. """ + + _gta = '__gta__' # Specify the allowed child-resource types _allowedChildResourceTypes:list[ResourceTypes] = [ ResourceTypes.SUB ] @@ -69,3 +75,130 @@ class LCP(AnnounceableResource): def __init__(self, dct:Optional[JSON] = None, pi:Optional[str] = None, create:Optional[bool] = False) -> None: super().__init__(ResourceTypes.LCP, dct, pi, create = create) + # Add to internal attributes to ignore in validation etc + self._addToInternalAttributes(self._gta) + + + def activate(self, parentResource: Resource, originator: str) -> None: + super().activate(parentResource, originator) + + # Creating extra resource + # Set the li attribute to the LCP's ri afterwards + _cnt:JSON = { + 'mni': Configuration.get('resource.lcp.mni'), + 'mbs': Configuration.get('resource.lcp.mbs'), + } + if self.lon is not None: # add container's resourcename if provided + _cnt['rn'] = self.lon + + container = Factory.resourceFromDict(_cnt, + pi = parentResource.ri, + ty = ResourceTypes.CNT) + try: + container = CSE.dispatcher.createLocalResource(container, parentResource, originator) + except Exception as e: + L.isWarn and L.logWarn(f'Could not create container for LCP: {e}') + raise BAD_REQUEST(f'Could not create container for LCP. Resource name: {self.lon} already exists?') + # set internal attributes afterwards (after validation) + container.setLCPLink(self.ri) + + # Set backlink to container in LCP + self.setAttribute('loi', container.ri) + + + # Register the LCP for periodic positioning procedure + CSE.location.addLocationPolicy(self) + + + + # If the value of locationUpdatePeriod attribute is updated to 0 or NULL, + # the Hosting CSE shall stop periodical positioning procedure and perform the procedure when + # Originator retrieves the resource of the linked resource. See clause 10.2.9.6 and clause 10.2.9.7 for more detail. + + # TODO add event for latest + location retrieval + + # If the value of locationUpdatePeriod attribute is updated to bigger than 0 (e.g. 1 hour) from 0 or NULL, + # the Hosting CSE shall start periodical positioning procedure. + + + def updated(self, dct: JSON | None = None, originator: str | None = None) -> None: + super().updated(dct, originator) + + # update the location policy handling + CSE.location.updateLocationPolicy(self) + + + def deactivate(self, originator:str) -> None: + # Delete the extra resource + if self.loi is not None: + CSE.dispatcher.deleteResource(self.loi, originator) + CSE.location.removeLocationPolicy(self) + super().deactivate(originator) + + + def validate(self, originator: str | None = None, dct: JSON | None = None, parentResource: Resource | None = None) -> None: + + def validateNetworkBasedAttributes() -> None: + """ Validate the Network_based attributes. """ + + if self.getFinalResourceAttribute('lot', dct) is not None: # locationTargetID + raise BAD_REQUEST(f'Attribute lot is only allowed if los is Network_based.') + if self.getFinalResourceAttribute('aid', dct) is not None: # authID + raise BAD_REQUEST(f'Attribute aid is only allowed if los is Network_based.') + if self.getFinalResourceAttribute('lor', dct) is not None: # locationServer + raise BAD_REQUEST(f'Attribute aid is only allowed if los is Network_based.') + if self.getFinalResourceAttribute('rlkl', dct) is not None: # retrieveLastKnownLocation + raise BAD_REQUEST(f'Attribute rlkl is only allowed if los is Network_based.') + if self.getFinalResourceAttribute('luec', dct) is not None: # loocationUpdateEventCriteria + raise BAD_REQUEST(f'Attribute luec is only allowed if los is Network_based.') + + super().validate(originator, dct, parentResource) + + # Error for unsupported location source types + los = self.getFinalResourceAttribute('los', dct) # locationSource + if los in [ LocationSource.Network_based, LocationSource.Sharing_based]: + raise NOT_IMPLEMENTED(L.logWarn(f'Unsupported LocationSource: {LocationSource(self.los)}')) + + + # Check the various locationSource types + match los: + case LocationSource.Network_based | LocationSource.Sharing_based: + raise NOT_IMPLEMENTED(L.logWarn(f'Unsupported LocationSource: {LocationSource(los)}')) + case LocationSource.Device_based: + validateNetworkBasedAttributes() + + # Always set the lost to an empty string as long as the locationSource is not Network_based + self.setAttribute('lost', '') + + # Validate the polygon + if (gta := self.gta) is not None: + if (_gta := getGeoPolygon(gta)) is None: + raise BAD_REQUEST('Invalid geographicalTargetArea. Must be a valid geoJSON polygon.') + self.setAttribute(self._gta, _gta) # store the geoJSON polygon in the internal attribute + + + + # TODO store lou to _lou + + + + # TODO more warnings for unsupported attributes (mainly for geo server) + + +# TODo geographicalTargetArea : What if not closed? +# TODO geofenceEventCriteria should be a list of GeofenceEventCriteria +# TODO retrieveLastKnownLocation: Indicates if the Hosting CSE shall retrieve the last known location when the Hosting CSE fails to retrieve the latest location WTF`????? +# TODO: locationUpdateEventCriteria Not supported + + + +#Procedure for resource that stores location information + +# After the resource that stores the location information is created, each instance of location information shall be stored +# in the different resources. In order to store the location information in the resource, +# the Hosting CSE firstly checks the defined locationUpdatePeriod attribute. +# If a valid period value is set for this attribute, the Hosting CSE shall perform the positioning procedures as defined by locationUpdatePeriod +# in the associated resource and stores the results (e.g. position fix and uncertainty) in the resource +# under the created resource. However, if no value (e.g. null or zero) is set and locationUpdateEventCriteria is absent, +# the positioning procedure shall be performed when an Originator requests to retrieve the resource of the +# resource and the result shall be stored as a resource under the resource. \ No newline at end of file diff --git a/acme/services/CSE.py b/acme/services/CSE.py index 625d1f7b..a23a8aad 100644 --- a/acme/services/CSE.py +++ b/acme/services/CSE.py @@ -30,6 +30,7 @@ from ..services.GroupManager import GroupManager from ..services.HttpServer import HttpServer from ..services.Importer import Importer +from ..services.LocationManager import LocationManager from ..services.MQTTClient import MQTTClient from ..services.NotificationManager import NotificationManager from ..services.RegistrationManager import RegistrationManager @@ -73,6 +74,9 @@ importer:Importer = None """ Runtime instance of the `Importer`. """ +location:LocationManager = None +""" Runtime instance of the `LocationManager`. """ + mqttClient:MQTTClient = None """ Runtime instance of the `MQTTClient`. """ @@ -189,7 +193,7 @@ def startup(args:argparse.Namespace, **kwargs:Dict[str, Any]) -> bool: Return: False if the CSE couldn't initialized and started. """ - global action, announce, console, dispatcher, event, groupResource, httpServer, importer, mqttClient, notification, registration + global action, announce, console, dispatcher, event, groupResource, httpServer, importer, location, mqttClient, notification, registration global remote, request, script, security, semantic, statistics, storage, textUI, time, timeSeries, validator global aeStatistics global supportedReleaseVersions, cseType, defaultSerialization, cseCsi, cseCsiSlash, cseCsiSlashLess, cseAbsoluteSlash @@ -274,6 +278,7 @@ def startup(args:argparse.Namespace, **kwargs:Dict[str, Any]) -> bool: remote = RemoteCSEManager() # Initialize the remote CSE manager announce = AnnouncementManager() # Initialize the announcement manager semantic = SemanticManager() # Initialize the semantic manager + location = LocationManager() # Initialize the location manager time = TimeManager() # Initialize the time mamanger script = ScriptManager() # Initialize the script manager action = ActionManager() # Initialize the action manager @@ -361,6 +366,7 @@ def _shutdown() -> None: textUI and textUI.shutdown() console and console.shutdown() time and time.shutdown() + location and location.shutdown() semantic and semantic.shutdown() remote and remote.shutdown() mqttClient and mqttClient.shutdown() diff --git a/acme/services/Dispatcher.py b/acme/services/Dispatcher.py index 971a151c..19ba36c8 100644 --- a/acme/services/Dispatcher.py +++ b/acme/services/Dispatcher.py @@ -719,12 +719,12 @@ def createResourceFromDict(self, dct:JSON, def createLocalResource(self, resource:Resource, - parentResource:Resource = None, + parentResource:Resource, originator:Optional[str] = None, request:Optional[CSERequest] = None) -> Resource: L.isDebug and L.logDebug(f'CREATING resource ri: {resource.ri}, type: {resource.ty}') - if parentResource: + if parentResource: # parentResource might be None if this is the root resource L.isDebug and L.logDebug(f'Parent ri: {parentResource.ri}') if not parentResource.canHaveChild(resource): if resource.ty == ResourceTypes.SUB: @@ -1079,8 +1079,17 @@ def deleteLocalResource(self, resource:Resource, def deleteResource(self, id:str, originator:Optional[str] = None) -> None: - # TODO doc + """ Delete a resource from the CSE. + + Args: + id: The resource ID to delete. + originator: The originator of the request. Defaults to None. + Raises: + OPERATION_NOT_ALLOWED: If the resource is a CSEBase resource. + NOT_FOUND: If the resource is not found. + ORIGINATOR_HAS_NO_PRIVILEGE: If the originator has no DELETE access to the resource. + """ # Update locally if (rID := localResourceID(id)) is not None: diff --git a/acme/services/LocationManager.py b/acme/services/LocationManager.py new file mode 100644 index 00000000..f4302a28 --- /dev/null +++ b/acme/services/LocationManager.py @@ -0,0 +1,336 @@ +# +# LocationManager.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# + +""" This module implements location service and helper functions. +""" + +from __future__ import annotations + +from typing import Tuple, Optional, Literal +from dataclasses import dataclass + +from ..helpers.BackgroundWorker import BackgroundWorkerPool, BackgroundWorker +from ..etc.Types import LocationInformationType, LocationSource, GeofenceEventCriteria, ResourceTypes +from ..etc.DateUtils import fromDuration +from ..etc.GeoUtils import getGeoPoint, getGeoPolygon, isLocationInsidePolygon +from ..services.Logging import Logging as L +from ..services import CSE +from ..resources.LCP import LCP +from ..resources.CIN import CIN +from ..resources import Factory + +GeofencePositionType = Literal[GeofenceEventCriteria.Inside, GeofenceEventCriteria.Outside] +""" Type alias for the geofence position.""" + +LocationType = Tuple[float, float] +""" Type alias for the location type.""" + +@dataclass +class LocationInformation(object): + """ Location information for a location policy. + """ + worker:BackgroundWorker = None + """ The worker for the location policy. """ + location:Optional[LocationType] = None + """ The current location. """ + targetArea:Optional[list[LocationType]] = None + """ The polygon. """ + geofencePosition:GeofencePositionType = GeofenceEventCriteria.Inside + """ The current position type (inside, outside). """ + eventCriteria:GeofenceEventCriteria = GeofenceEventCriteria.Inside + """ The event criteria. """ + locationContainerID:Optional[str] = None + """ The location container resource ID. """ + + +class LocationManager(object): + """ The LocationManager class implements the location service and helper functions. + + Attributes: + locationPolicyWorkers: A dictionary of location policy workers + """ + + __slots__ = ( + 'locationPolicyInfos', + 'deviceDefaultPosition' + ) + + + def __init__(self) -> None: + """ Initialization of the LocationManager module. + """ + + self.locationPolicyInfos:dict[str, LocationInformation] = {} + + self.deviceDefaultPosition:GeofencePositionType = GeofenceEventCriteria.Inside # Default event criteria + # Add a handler when the CSE is reset + CSE.event.addHandler(CSE.event.cseReset, self.restart) # type: ignore + L.isInfo and L.log('LocationManager initialized') + + +# TODO rebuild the list of location policies when the CSE is reset or started. OR create a DB + + def shutdown(self) -> bool: + """ Shutdown the LocationManager. + + Returns: + Boolean that indicates the success of the operation + """ + L.isInfo and L.log('LocationManager shut down') + return True + + + def restart(self, name:str) -> None: + """ Restart the LocationManager. + """ + L.isDebug and L.logDebug('LocationManager restarted') + + + ######################################################################### + + def addLocationPolicy(self, lcp:LCP) -> None: + """ Add a location policy. + + Args: + lcp: The location policy to add. + """ + L.isDebug and L.logDebug('Adding location policy') + lcpRi = lcp.ri + gta = getGeoPolygon(lcp.gta) + loi = lcp.loi + + # Remove first if already running + if lcpRi in self.locationPolicyInfos: + self.removeLocationPolicy(lcp) + + # Check whether the location source is device based (only one supported right now) + if lcp.los != LocationSource.Device_based: + L.isDebug and L.logDebug('Only device based location source supported') + return # Not supported + + # Add an empty entry first. + self.locationPolicyInfos[lcpRi] = LocationInformation(targetArea = gta, + geofencePosition = self.deviceDefaultPosition, + eventCriteria = lcp.gec, + locationContainerID = loi) + + # Check if the location information type / position is fixed + if (lit := lcp.lit) is None or lit == LocationInformationType.Position_fix: + L.isDebug and L.logDebug('Location information type not set or position fix. Ignored.') + return # No updates needed + + # Get the periodicity + if (lou := lcp.lou) is None or len(lou) == 0: # locationUpdatePeriodicity + L.isDebug and L.logDebug('Location update periodicity not set. Ignored.') + return # No updates needed. Checks are done when the location is requested via + if (_lou := fromDuration(lou[0], False)) == 0.0: # just take the first duration + L.isDebug and L.logDebug('Location update periodicity is 0. Ignored.') + return + + # Create a worker + L.isDebug and L.logDebug(f'Starting location policy worker for: {lcpRi} Intervall: {_lou}') + self.locationPolicyInfos[lcpRi] = LocationInformation(worker = BackgroundWorkerPool.newWorker(interval = _lou, + workerCallback = self.locationWorker, + name = f'lcp_{lcp.ri}', + startWithDelay = True).start(lcpRi = lcpRi), + targetArea = gta, + geofencePosition = self.deviceDefaultPosition, + eventCriteria = lcp.gec, + locationContainerID = loi + ) + # # Immediately update the location + # self.getNewLocation(lcpRi) + + + + def removeLocationPolicy(self, lcp:LCP) -> None: + """ Remove a location policy. This will stop the worker and remove the LCP from the internal list. + + Args: + lcp: The LCP to remove. + """ + L.isDebug and L.logDebug('Removing location policy') + + # Stopping the worker and remove the LCP from the internal list + if (ri := lcp.ri) in self.locationPolicyInfos: + L.isDebug and L.logDebug('Stopping location policy worker') + if (worker := self.locationPolicyInfos[ri].worker) is not None: + worker.stop() + del self.locationPolicyInfos[ri] + + + def updateLocationPolicy(self, lcp:LCP) -> None: + """ Update a location policy. This will remove the old location policy and add a new one. + """ + L.isDebug and L.logDebug('Updating location policy') + self.removeLocationPolicy(lcp) + self.addLocationPolicy(lcp) + + + def handleLatestRetrieve(self, latest:CIN, lcpRi:str) -> None: + """ Handle a latest RETRIEVE request for a CNT with a location policy. + + Args: + latest: The latest CIN + lcpRi: The location policy resource ID + """ + if lcpRi is None: + return + + # Check if the location policy is supported + if (lcp := CSE.dispatcher.retrieveResource(lcpRi)) is not None: + if lcp.los == LocationSource.Network_based and lcp.lou is not None and lcp.lou == 0: + L.isDebug and L.logDebug(f'Handling latest RETRIEVE for CNT with locationID: {lcpRi}') + # Handle Network based location source + # NOT SUPPORTED YET + L.isWarn and L.logWarn('Network-based location source not supported yet') + + if (lit := lcp.lit) is None or lit == LocationInformationType.Position_fix: + L.isDebug and L.logDebug('Location information type not set or position fix. Ignored.') + return # No updates needed + + + if (locations := self.getNewLocation(lcpRi, content = latest.con)) is None: + return + + # check if the location is inside the polygon and update the location event + self.updateLocationEvent(locations[0], locations[1], lcpRi) + + # TODO do something with the result + + + def locationWorker(self, lcpRi:str) -> bool: + """ Worker function for location policies. This will be called periodically to update the location. + + Args: + lcpRi: The resource ID of the location policy + + Returns: + True if the worker should be continued, False otherwise. + """ + + if (locations := self.getNewLocation(lcpRi)) is None: + return True # something went wrong, but still continue + + self.updateLocationEvent(locations[0], locations[1], lcpRi) + + return True + + + + + ######################################################################### + + + + def getNewLocation(self, lcpRi:str, content:Optional[str] = None) -> Optional[Tuple[LocationType, LocationType]]: + """ Get the new location for a location policy. Also, update the internal policy info if necessary. + + Args: + lcpRi: The resource ID of the location policy + cntRi: The resource ID of the location policy's container resource + content: The content of the latest CIN of the location policy's container resource + + Returns: + The new and old locations as a tuple of (latitude, longitude), or None if the location is invalid or not found + """ + + # Get the location policy info + if (info := self.locationPolicyInfos.get(lcpRi)) is None: + L.isWarn and L.logWarn(f'Internal location policy info for: {lcpRi} not found') + return None + + # Get the content if not provided + if not content: + # Get the location from a location instance + if not (cin := CSE.dispatcher.retrieveLatestOldestInstance(info.locationContainerID, ResourceTypes.CIN)): + return None # No resource found, still continue + content = cin.con + + # Check whether the content is a valid location or an event + if content in ('', '1', '2', '3', '4'): # This could be done better... + return None # An event, so return + + # From here on, content is a location + if (newLocation := getGeoPoint(content)) is None: + L.isWarn and L.logWarn(f'Invalid location: {content}. Must be a valid GeoPoint') + + # Check if the location has changed, or there was no location before + oldLocation = info.location + if oldLocation != newLocation: + # Update the location in the location policy + self.locationPolicyInfos[lcpRi].location = newLocation + + return (newLocation, oldLocation) + + + def updateLocationEvent(self, newLocation:LocationType, oldLocation:LocationType, lcpRi:str) -> None: + """ Update the location event for a location policy if the location has changed and/or the event criteria is met. + + Args: + newLocation: The new location + oldLocation: The old location + lcpRi: The resource ID of the location policy + """ + + def addEventContentInstance(info:LocationInformation, eventType:GeofenceEventCriteria) -> None: + """ Add a new event content instance to the location policy's container resource. + + Args: + info: The location policy info + eventType: The type of the event + """ + L.isDebug and L.logDebug(f'Position: {eventType}') + cnt = CSE.dispatcher.retrieveResource(info.locationContainerID) + cin = Factory.resourceFromDict({ 'con': f'{eventType.value}' }, + pi = info.locationContainerID, + ty = ResourceTypes.CIN) + CSE.dispatcher.createLocalResource(cin, cnt) + + + if (info := self.locationPolicyInfos.get(lcpRi)) is None: + L.isWarn and L.logWarn(f'Internal location policy info for: {lcpRi} not found') + return + previousGeofencePosition = info.geofencePosition + currentGeofencePosition = self.checkGeofence(lcpRi, newLocation) + + match currentGeofencePosition: + case GeofenceEventCriteria.Inside if previousGeofencePosition == GeofenceEventCriteria.Outside and info.eventCriteria == GeofenceEventCriteria.Entering: + # Entering + addEventContentInstance(info, GeofenceEventCriteria.Entering) + case GeofenceEventCriteria.Outside if previousGeofencePosition == GeofenceEventCriteria.Inside and info.eventCriteria == GeofenceEventCriteria.Leaving: + # Leaving + addEventContentInstance(info, GeofenceEventCriteria.Leaving) + case GeofenceEventCriteria.Inside if previousGeofencePosition == GeofenceEventCriteria.Inside and info.eventCriteria == GeofenceEventCriteria.Inside: + # Inside + addEventContentInstance(info, GeofenceEventCriteria.Inside) + case GeofenceEventCriteria.Outside if previousGeofencePosition == GeofenceEventCriteria.Outside and info.eventCriteria == GeofenceEventCriteria.Outside: + # Outside + addEventContentInstance(info, GeofenceEventCriteria.Outside) + case _: + # No event + L.isDebug and L.logDebug(f'No event for: {previousGeofencePosition} -> {currentGeofencePosition} and event criteria: {GeofenceEventCriteria(info.eventCriteria)}') + + # update the geofence position + info.geofencePosition = currentGeofencePosition + info.location = newLocation + + + def checkGeofence(self, lcpRi:str, location:tuple[float, float]) -> GeofencePositionType: + """ Check if a location is inside or outside the polygon of a location policy. + + Args: + lcpRi: The resource ID of the location policy + location: The location to check + + Returns: + The geofence position of the location. Either *inside* or *outside*. + """ + result = GeofenceEventCriteria.Inside if isLocationInsidePolygon(self.locationPolicyInfos[lcpRi].targetArea, location) else GeofenceEventCriteria.Outside + # L.isDebug and L.logDebug(f'Location is: {result}') + return result # type:ignore [return-value] + diff --git a/acme/services/Validator.py b/acme/services/Validator.py index 695488e3..92380cfc 100644 --- a/acme/services/Validator.py +++ b/acme/services/Validator.py @@ -740,7 +740,7 @@ def _validateType(self, dataType:BasicType, try: isodate.parse_duration(value) except Exception as e: - raise BAD_REQUEST(f'must be an ISO duration: {str(e)}') + raise BAD_REQUEST(f'must be an ISO duration (e.g. "PT2S"): {str(e)}') return (dataType, value) case BasicType.base64: diff --git a/tests/init.py b/tests/init.py index 028c2e20..c1cf703b 100755 --- a/tests/init.py +++ b/tests/init.py @@ -222,8 +222,9 @@ def isRaspberrypi() -> bool: crsRN = 'testCRS' csrRN = 'testCSR' deprRN = 'testDEPR' -grpRN = 'testGRP' fcntRN = 'testFCNT' +grpRN = 'testGRP' +lcpRN = 'testLCP' nodRN = 'testNOD' pchRN = 'testPCH' reqRN = 'testREQ' @@ -247,6 +248,7 @@ def isRaspberrypi() -> bool: csrURL = f'{cseURL}/{csrRN}' fcntURL = f'{aeURL}/{fcntRN}' grpURL = f'{aeURL}/{grpRN}' +lcpURL = f'{aeURL}/{lcpRN}' # under the nodURL = f'{cseURL}/{nodRN}' # under the pchURL = f'{aeURL}/{pchRN}' pcuURL = f'{pchURL}/pcu' diff --git a/tests/testLCP.py b/tests/testLCP.py new file mode 100644 index 00000000..9cbcb423 --- /dev/null +++ b/tests/testLCP.py @@ -0,0 +1,383 @@ +# +# testLOC.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# Unit tests for LocationPolicy functionality +# + +import unittest, sys +if '..' not in sys.path: + sys.path.append('..') +from typing import Tuple +from acme.etc.Types import ResourceTypes as T, ResponseStatusCode as RC, TimeWindowType +from acme.etc.Types import NotificationEventType, NotificationEventType as NET +from init import * + + +pointInside = { + 'type' : 'Point', + 'coordinates' : [ 52.520817, 13.409446 ] +} + +pointInsideStr = json.dumps(pointInside) + +pointOutside = { + 'type' : 'Point', + 'coordinates' : [ 52.505033, 13.278189 ] +} +pointOutsideStr = json.dumps(pointOutside) + +targetPoligon = { + 'type' : 'Polygon', + 'coordinates' : [ + [ [52.522423, 13.409468], [52.520634, 13.412107], [52.518362, 13.407172], [52.520086, 13.404897] ] + ] +} +targetPoligonStr = json.dumps(targetPoligon) + +# TODO wrong poligon, wrong point + + +class TestLCP(unittest.TestCase): + + ae = None + aeRI = None + ae2 = None + nod = None + nodRI = None + crs = None + crsRI = None + + + originator = None + + @classmethod + @unittest.skipIf(noCSE, 'No CSEBase') + def setUpClass(cls) -> None: + testCaseStart('Setup TestLCP') + + # Start notification server + startNotificationServer() + + dct = { 'm2m:ae' : { + 'rn' : aeRN, + 'api' : APPID, + 'rr' : True, + 'srv' : [ RELEASEVERSION ] + }} + cls.ae, rsc = CREATE(cseURL, 'C', T.AE, dct) # AE to work under + assert rsc == RC.CREATED, 'cannot create parent AE' + cls.originator = findXPath(cls.ae, 'm2m:ae/aei') + cls.aeRI = findXPath(cls.ae, 'm2m:ae/ri') + + testCaseEnd('Setup TestLCP') + + + @classmethod + @unittest.skipIf(noCSE, 'No CSEBase') + def tearDownClass(cls) -> None: + if not isTearDownEnabled(): + stopNotificationServer() + return + testCaseStart('TearDown TestLCP') + DELETE(aeURL, ORIGINATOR) # Just delete the AE and everything below it. Ignore whether it exists or not + testCaseEnd('TearDown TestLCP') + + + def setUp(self) -> None: + testCaseStart(self._testMethodName) + + + def tearDown(self) -> None: + testCaseEnd(self._testMethodName) + + ######################################################################### + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPMissingLosFail(self) -> None: + """ CREATE invalid with missing los -> Fail""" + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'lou': [ 'PT5S' ], + 'lon': 'myLocationContainer' + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createMinimalLCP(self) -> None: + """ CREATE minimal with missing lou""" + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.CREATED, r) + self.assertIsNotNone(findXPath(r, 'm2m:lcp/lost')) + self.assertEqual(findXPath(r, 'm2m:lcp/lost'), '') + + _, rsc = DELETE(lcpURL, self.originator) + self.assertEqual(rsc, RC.DELETED) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithSameCNTRnFail(self) -> None: + """ CREATE with assigned container RN as self -> Fail """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'lon': lcpRN + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithLOS2LotFail(self) -> None: + """ CREATE with los=2 (device based) and set lot -> Fail """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'lot': '1234' # locationTargetID + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithLOS2AidFail(self) -> None: + """ CREATE with los=2 (device based) and set aid -> Fail """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'aid': '1234' # authID + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithLOS2LorFail(self) -> None: + """ CREATE with los=2 (device based) and set lor -> Fail """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'lor': '1234' # locationServer + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithLOS2RlklFail(self) -> None: + """ CREATE with los=2 (device based) and set rlkl -> Fail """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'rlkl': True # retrieveLastKnownLocation + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithLOS2LuecFail(self) -> None: + """ CREATE with los=2 (device based) and set luec -> Fail """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'luec': 0 # locationUpdateEventCriteria + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithWrongGtaFail(self) -> None: + """ CREATE with wrong gta -> Fail""" + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'gta': 'wrong' # geoTargetArea + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithGta(self) -> None: + """ CREATE with gta """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'gta': targetPoligonStr # geoTargetArea + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.CREATED, r) + self.assertIsNotNone(findXPath(r, 'm2m:lcp/gta')) + + _, rsc = DELETE(lcpURL, self.originator) + self.assertEqual(rsc, RC.DELETED) + + + # + # Periodic tests + # + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createLCPWithLit2Lou0(self) -> None: + """ CREATE with lit = 2, lou = 0s""" + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'lit': 2, # locationInformationType = 2 (geo-fence) + 'lou': [ 'PT0S' ] # locationUpdatePeriod = 0s, + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.CREATED, r) + self.assertIsNotNone(findXPath(r, 'm2m:lcp/lost')) + self.assertEqual(findXPath(r, 'm2m:lcp/lost'), '') + + _, rsc = DELETE(lcpURL, self.originator) + self.assertEqual(rsc, RC.DELETED) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_testPeriodicUpdates(self) -> None: + """ CREATE with lit = 2, lou = 1s""" + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'lit': 2, # locationInformationType = 2 (geo-fence) + 'lou': [ 'PT1S' ], # locationUpdatePeriod = 1s + 'lon': cntRN, # containerName + 'gta': targetPoligonStr,# geoTargetArea + 'gec': 2 # geoEventCategory = 2 (leaving). Assuming that the initial location is inside the target area + + + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.CREATED, r) + self.assertIsNotNone(findXPath(r, 'm2m:lcp/lost')) + self.assertEqual(findXPath(r, 'm2m:lcp/lost'), '') + self.assertIsNotNone(findXPath(r, 'm2m:lcp/loi'), '') + + # Add a location ContentInstance + dct = { 'm2m:cin': { + 'con': pointOutsideStr + }} + r, rsc = CREATE(f'{aeURL}/{cntRN}', self.originator, T.CIN, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Just wait a moment + testSleep(2) + + # Retrieve the latest location ContentInstance to check the event + r, rsc = RETRIEVE(f'{aeURL}/{cntRN}/la', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:cin/con')) + self.assertEqual(findXPath(r, 'm2m:cin/con'), '2', r) # leaving + + + _, rsc = DELETE(lcpURL, self.originator) + self.assertEqual(rsc, RC.DELETED) + + +# TODO add test: move from inside to inside -> no notification +# TODO add test: move from inside to outside -> notification +# TODO add test: move from outside to inside -> notification +# TODO add test: move from outside to outside -> no notification + +# TODO test with invalid location format + + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_testManualUpdates(self) -> None: + """ CREATE with lit = 2, lou = None """ + + dct = { 'm2m:lcp': { + 'rn': lcpRN, + 'los': 2, # device based + 'lit': 2, # locationInformationType = 2 (geo-fence) + 'lon': cntRN, # containerName + 'gta': targetPoligonStr,# geoTargetArea + 'gec': 2 # geoEventCategory = 2 (leaving). Assuming that the initial location is inside the target area + }} + r, rsc = CREATE(aeURL, self.originator, T.LCP, dct) + self.assertEqual(rsc, RC.CREATED, r) + self.assertIsNotNone(findXPath(r, 'm2m:lcp/lost')) + self.assertEqual(findXPath(r, 'm2m:lcp/lost'), '') + self.assertIsNotNone(findXPath(r, 'm2m:lcp/loi'), '') + + # Add a location ContentInstance + dct = { 'm2m:cin': { + 'con': pointOutsideStr + }} + r, rsc = CREATE(f'{aeURL}/{cntRN}', self.originator, T.CIN, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # Retrieve + r, rsc = RETRIEVE(f'{aeURL}/{cntRN}/la', self.originator) + self.assertEqual(rsc, RC.OK, r) + latest = findXPath(r, 'm2m:cin/con') + print(latest) + + + # Just wait a moment + testSleep(2) + + # TODO receive result? + + _, rsc = DELETE(lcpURL, self.originator) + self.assertEqual(rsc, RC.DELETED) + + + + ######################################################################### + + + +def run(testFailFast:bool) -> Tuple[int, int, int, float]: + suite = unittest.TestSuite() + + # basic tests + addTest(suite, TestLCP('test_createLCPMissingLosFail')) + addTest(suite, TestLCP('test_createMinimalLCP')) + addTest(suite, TestLCP('test_createLCPWithSameCNTRnFail')) + addTest(suite, TestLCP('test_createLCPWithLOS2LotFail')) + addTest(suite, TestLCP('test_createLCPWithLOS2AidFail')) + addTest(suite, TestLCP('test_createLCPWithLOS2LorFail')) + addTest(suite, TestLCP('test_createLCPWithLOS2RlklFail')) + addTest(suite, TestLCP('test_createLCPWithLOS2LuecFail')) + addTest(suite, TestLCP('test_createLCPWithWrongGtaFail')) + addTest(suite, TestLCP('test_createLCPWithGta')) + + # periodic tests + addTest(suite, TestLCP('test_createLCPWithLit2Lou0')) + addTest(suite, TestLCP('test_testPeriodicUpdates')) + addTest(suite, TestLCP('test_testManualUpdates')) + + + result = unittest.TextTestRunner(verbosity = testVerbosity, failfast = testFailFast).run(suite) + return result.testsRun, len(result.errors + result.failures), len(result.skipped), getSleepTimeCount() + + +if __name__ == '__main__': + r, errors, s, t = run(True) + sys.exit(errors) \ No newline at end of file From 2dbf5be4e3168d53c92c769eee88854077067102 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 4 Aug 2023 14:57:47 +0200 Subject: [PATCH 068/165] Fixed potential crash when determining the size of a dict --- acme/etc/Utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/etc/Utils.py b/acme/etc/Utils.py index 3e6ca5fc..16c18e9e 100644 --- a/acme/etc/Utils.py +++ b/acme/etc/Utils.py @@ -781,7 +781,7 @@ def getAttributeSize(attribute:Any) -> int: for e in attribute: size += getAttributeSize(e) case dict(): # recurse a dictionary - for _,v in attribute: + for _,v in attribute.items(): size += getAttributeSize(v) case _: # fallback for not handled types size = sys.getsizeof(attribute) From 8a7aa44ac2338e25fc076c1e4a7833e5594af071 Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 7 Aug 2023 15:25:15 +0200 Subject: [PATCH 069/165] Added dolist function to interpreter --- CHANGELOG.md | 3 +- acme/helpers/Interpreter.py | 53 ++++++++++++++++++++++++++++++++++++ docs/ACMEScript-functions.md | 43 +++++++++++++++++++++++++---- 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a1c4a9b..eec6b369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - [CSE] Added automatic pip install of missing dependencies during startup. - [CSE] Added support for <schedule> resource type. -- [SCRIPTS] Added "dotimes", "tui-notify", and "get-loglevel" functions to the script interpreter. +- [SCRIPTS] Added "dolist", "dotimes", "tui-notify", and "get-loglevel" functions to the script interpreter. - [TUI] Improved resource view in the text UI. Enumeration interpretations are now shown. ### Experimental @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - [CSE] Changed the *operationResult* of <request> according to SDS-2022-0010R02. - [CSE] Changed the oneM2M enumeration definition format. Each enumeration type is now a dictionary of enumeration values and their interpretations. +- [TUI] Simplified the request list view in the text UI. ### Fixed diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index 4eeea634..70d5a61d 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -1979,6 +1979,58 @@ def _doDefun(pcontext:PContext, symbol:SSymbol) -> PContext: return pcontext +def _doDolist(pcontext:PContext, symbol:SSymbol) -> PContext: + pcontext.assertSymbol(symbol, 3) + + # arguments + pcontext, _arguments = pcontext.valueFromArgument(symbol, 1, SType.tList, doEval = False) # don't evaluate the argument + if 2 <= len(_arguments) <= 3: + # get loop variable + _loopvar = cast(SSymbol, _arguments[0]) + if _loopvar.type != SType.tSymbol: + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'dolist "var" must be a symbol, got: {pcontext.result.type}')) + + # get list to loop over + pcontext = pcontext._executeExpression(_arguments[1], _arguments) + if pcontext.result.type not in (SType.tList, SType.tListQuote): + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'dolist "list" must be a (quoted) list, got: {pcontext.result.type}')) + _looplist = pcontext.result + else: + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'dolist first argument requires 2 or 3 arguments, got: {len(_arguments)}')) + + # Get result variable name + if len(_arguments) == 3: + _resultvar = cast(SSymbol, _arguments[2]) + if _resultvar.type != SType.tSymbol: + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'dolist "result" must be a symbol, got: {pcontext.result.type}')) + + # if the variable does not exist, create it as a nil symbol + if not str(_resultvar) in pcontext.variables: + pcontext.variables[str(_resultvar)] = SSymbol() + else: + _resultvar = None + + # code + pcontext, _code = pcontext.valueFromArgument(symbol, 2, SType.tList, doEval = False) # don't evaluate the argument (yet) + _code = SSymbol(lst = _code) # We got a python list, but must have a SSymbol list + + # execute the code + pcontext.variables[str(_loopvar)] = SSymbol(number = Decimal(0)) + for i in _looplist.value: # type:ignore[union-attr] + pcontext.variables[str(_loopvar)] = i # type:ignore[assignment] + pcontext = pcontext._executeExpression(_code, symbol) + + # set the result + if _resultvar: + pcontext.result = pcontext.variables[str(_resultvar)] + else: + pcontext.result = SSymbol() + + # return + return pcontext + + + def _doDotimes(pcontext:PContext, symbol:SSymbol) -> PContext: """ This function executes a code block a number of times. @@ -3395,6 +3447,7 @@ def _doWhile(pcontext:PContext, symbol:SSymbol) -> PContext: 'datetime': _doDatetime, 'dec': lambda p, a: _doIncDec(p, a, False), 'defun': _doDefun, + 'dolist': _doDolist, 'dotimes': _doDotimes, 'eval': _doEval, 'evaluate-inline': _doEvaluateInline, diff --git a/docs/ACMEScript-functions.md b/docs/ACMEScript-functions.md index 1aac2c13..4bbb2d4b 100644 --- a/docs/ACMEScript-functions.md +++ b/docs/ACMEScript-functions.md @@ -20,7 +20,8 @@ The following built-in functions and variables are provided by the ACMEScript in | | [datetime](#datetime) | Return a timestamp | | | [defun](#defun) | Define a function | | | [dec](#dec) | Decrement a variable | -| | [dotimes](#dotimes) | Simple loop over an s-expression | +| | [dolist](#dolist) | Loop over a list | +| | [dotimes](#dotimes) | Loop over a numeric value | | | [eval](#eval) | Evaluate and execute a quoted list | | | [evaluate-inline](#evaluate-inline) | Enable and disable inline string evaluation | | | [get-json-attribute](#get-json-attribute) | Get a JSON attribute from a JSON structure | @@ -380,29 +381,59 @@ Example: --- + + +### dolist + +`(dolist ( []) (+))` + +The `dolist` function loops over a list. +The first arguments is a list that contains a loop variable, a list to iterate over, and an optional +`result` variable. The second argument is a list that contains one or more s-expressions that are executed in the loop. + +If the `result variable` is specified then the loop returns the value of that variable, otherwise `nil`. + +See also: [dotimes](#dotimes), [while](#while) + +Example: + +```lisp +(dolist (i '(1 2 3 4 5 6 7 8 9 10)) + (print i)) ;; print 1..10 + +(setq result 0) +(dolist (i '(1 2 3 4 5 6 7 8 9 10) result) + (setq result (+ result i))) ;; sum 1..10 +(print result) ;; 55 +``` + +[top](#top) + +--- + ### dotimes `(dotimes ( []) (+))` -The `dotimes` function provides a simple loop functionality. +The `dotimes` function provides a simple numeric loop functionality. The first arguments is a list that contains a loop variable that starts at 0, the loop `count` (which must be a non-negative number), and an optional `result` variable. The second argument is a list that contains one or more s-expressions that are executed in the loop. If the `result variable` is specified then the loop returns the value of that variable, otherwise `nil`. -See also: [while](#while) +See also: [dolist](#dolist), [while](#while) Example: ```lisp (dotimes (i 10) - (print i)) ;; print 1..10 + (print i)) ;; print 0..9 (setq result 0) (dotimes (i 10 result) - (setq result (+ result i))) ;; sum 1..10 + (setq result (+ result i))) ;; sum 0..9 (print result) ;; 45 ``` @@ -1316,7 +1347,7 @@ A `while` loop continues to run when the first *guard* s-expression evaluates to The `while` function returns the result of the last evaluated s-expression in the *body*. -See also: [dotime](#dotimes), [return](#return) +See also: [doloop](#doloop), [dotime](#dotimes), [return](#return) Example: From 175bb29a441b1d2c3ee6f00e58bb1fa7744b74e1 Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 7 Aug 2023 15:25:32 +0200 Subject: [PATCH 070/165] Ignore shapely for the moment (no stubs) --- mypy.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mypy.ini b/mypy.ini index 1df759ec..ca43e87f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -26,5 +26,8 @@ ignore_missing_imports = True [mypy-InquirerPy.*] ignore_missing_imports = True +[mypy-shapely.*] +ignore_missing_imports = True + [mypy-plotext.*] ignore_missing_imports = True From 619829b9c4e121f1ff8b40c6b6f56872d77adf1c Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 8 Aug 2023 11:16:08 +0200 Subject: [PATCH 071/165] Updated documentation --- docs/CSE.uxf | 1317 ++++++++++++++++++------------- docs/Supported.md | 2 + docs/images/cse_uml.png | Bin 91402 -> 107640 bytes docs/images/resources_uml.png | Bin 42458 -> 46715 bytes docs/images/resources_uml.uxf | 1396 ++++++++++++++++++--------------- 5 files changed, 1522 insertions(+), 1193 deletions(-) diff --git a/docs/CSE.uxf b/docs/CSE.uxf index 8c65525e..2a568376 100644 --- a/docs/CSE.uxf +++ b/docs/CSE.uxf @@ -1,14 +1,14 @@ - + - 12 + 6 UMLGeneric - 780 - 480 - 192 - 96 + 480 + 660 + 96 + 48 HTTP Server REST @@ -24,10 +24,10 @@ drawLine(21.8,11.4,20,15) Relation - 852 - 348 - 36 - 156 + 516 + 594 + 18 + 78 lt=()- @@ -36,10 +36,10 @@ drawLine(21.8,11.4,20,15) Relation - 792 - 564 - 84 - 84 + 486 + 702 + 42 + 42 lt=)- fontsize=8 @@ -50,10 +50,10 @@ requests Relation - 900 - 672 - 108 - 84 + 540 + 756 + 54 + 42 lt=()- handle resource @@ -65,10 +65,10 @@ fontsize=8 UMLGeneric - 780 - 732 - 192 - 96 + 480 + 786 + 96 + 48 Dispatcher symbol=component @@ -79,10 +79,10 @@ transparency=0 UMLGeneric - 780 - 912 - 192 - 96 + 480 + 876 + 96 + 48 Resource Storage symbol=component @@ -97,10 +97,10 @@ drawLine(21.8,11.4,20,15) Relation - 864 - 864 - 60 - 72 + 522 + 852 + 30 + 36 lt=()- BREAD @@ -111,10 +111,10 @@ fontsize=8 Relation - 852 - 816 - 60 - 72 + 516 + 828 + 30 + 36 lt=)- 20.0;30.0;20.0;10.0 @@ -122,10 +122,10 @@ fontsize=8 UMLGeneric - 1020 - 912 - 192 - 96 + 600 + 876 + 96 + 48 oneM2M Resource Classes @@ -137,10 +137,10 @@ transparency=0 UMLGeneric - 540 - 912 - 192 - 96 + 360 + 876 + 96 + 48 Importer symbol=component @@ -151,10 +151,10 @@ transparency=0 Relation - 612 - 852 - 96 - 84 + 396 + 846 + 48 + 42 lt=)- add @@ -166,10 +166,10 @@ fontsize=8 UMLGeneric - 300 - 912 - 192 - 96 + 240 + 876 + 96 + 48 Configuration symbol=component @@ -180,10 +180,10 @@ transparency=0 Relation - 384 - 864 - 72 - 72 + 282 + 852 + 36 + 36 lt=()- set/get @@ -193,10 +193,10 @@ fontsize=8 UMLNote - 744 - 528 - 72 - 36 + 462 + 684 + 36 + 18 Flask Requests @@ -211,10 +211,10 @@ fontsize=10 UMLNote - 924 - 984 - 72 - 36 + 552 + 912 + 36 + 18 TinyDB transparency=0 @@ -228,10 +228,10 @@ fontsize=10 UMLGeneric - 60 - 912 - 192 - 96 + 120 + 876 + 96 + 48 Logging symbol=component @@ -242,10 +242,10 @@ transparency=0 Relation - 144 - 852 - 96 - 84 + 162 + 846 + 48 + 42 lt=()- info/debug @@ -256,10 +256,10 @@ fontsize=8 UMLGeneric - 792 - 1092 - 168 - 36 + 486 + 966 + 84 + 18 Resources symbol=artifact @@ -272,10 +272,10 @@ fontsize=10 UMLGeneric - 792 - 1140 - 168 - 36 + 486 + 990 + 84 + 18 Identifiers symbol=artifact @@ -288,10 +288,10 @@ fontsize=10 UMLGeneric - 780 - 1056 - 192 - 276 + 480 + 948 + 186 + 138 DB Tables transparency=0 @@ -300,10 +300,10 @@ transparency=0 Relation - 864 - 996 - 36 - 84 + 522 + 918 + 18 + 42 lt=- fontsize=8 @@ -312,12 +312,12 @@ fontsize=8 UMLGeneric - 540 - 1068 - 192 - 36 + 360 + 990 + 96 + 18 - Initial Resources + Scripts symbol=artifact halign=center layer=1 @@ -328,22 +328,22 @@ fontsize=10 Relation - 624 - 996 - 36 - 96 + 402 + 918 + 18 + 42 lt=- fontsize=8 - 10.0;60.0;10.0;10.0 + 10.0;50.0;10.0;10.0 UMLGeneric - 72 - 1068 - 168 - 36 + 126 + 954 + 84 + 18 Rotating Logs symbol=artifact @@ -356,10 +356,10 @@ fontsize=10 UMLGeneric - 156 - 1032 - 24 - 24 + 168 + 936 + 12 + 12 lw=0 fontsize=12 @@ -369,10 +369,10 @@ fontsize=12 Relation - 144 - 996 - 36 - 84 + 162 + 918 + 18 + 42 lt=- fontsize=8 @@ -381,10 +381,10 @@ fontsize=8 UMLGeneric - 864 - 384 - 60 - 36 + 522 + 612 + 30 + 18 lw=0 fontsize=8 @@ -396,10 +396,10 @@ via http UMLGeneric - 300 - 1068 - 192 - 36 + 240 + 954 + 96 + 18 Configuration File symbol=artifact @@ -412,10 +412,10 @@ fontsize=10 Relation - 384 - 996 - 36 - 96 + 282 + 918 + 18 + 48 lt=- fontsize=8 @@ -424,10 +424,10 @@ fontsize=8 Relation - 1116 - 852 - 120 - 84 + 648 + 846 + 60 + 42 lt=)- access resources @@ -438,10 +438,10 @@ fontsize=8 UMLPackage - 24 - 420 - 2424 - 948 + 102 + 630 + 1488 + 474 ACME CSE -- @@ -453,10 +453,10 @@ transparency=0 UMLGeneric - 72 - 1116 - 168 - 36 + 126 + 978 + 84 + 18 Console symbol=artifact @@ -469,10 +469,10 @@ fontsize=10 UMLGeneric - 60 - 1056 - 192 - 108 + 120 + 948 + 96 + 54 transparency=0 @@ -480,10 +480,10 @@ fontsize=10 UMLGeneric - 540 - 732 - 192 - 96 + 360 + 786 + 96 + 48 Security Manager symbol=component @@ -494,10 +494,10 @@ transparency=0 Relation - 588 - 660 - 96 - 96 + 384 + 750 + 48 + 48 lt=()- check @@ -508,10 +508,10 @@ fontsize=8 Relation - 1104 - 672 - 84 - 84 + 642 + 756 + 42 + 42 lt=()- handle @@ -525,10 +525,10 @@ fontsize=8 UMLGeneric - 396 - 1044 - 24 - 24 + 288 + 942 + 12 + 12 lw=0 fontsize=12 @@ -538,10 +538,10 @@ fontsize=12 UMLGeneric - 636 - 1044 - 24 - 24 + 408 + 936 + 12 + 12 lw=0 fontsize=12 @@ -551,10 +551,10 @@ fontsize=12 UMLGeneric - 876 - 1032 - 24 - 24 + 528 + 936 + 12 + 12 lw=0 fontsize=12 @@ -564,10 +564,10 @@ fontsize=12 Relation - 960 - 924 - 84 - 48 + 570 + 882 + 42 + 24 lt=- stores @@ -578,10 +578,10 @@ fontsize=8 UMLGeneric - 996 - 948 - 24 - 24 + 588 + 894 + 12 + 12 lw=0 fontsize=12 @@ -591,10 +591,10 @@ fontsize=12 UMLNote - 924 - 1044 - 96 - 36 + 552 + 942 + 48 + 18 In memory or file system @@ -609,10 +609,10 @@ layer=1 Relation - 672 - 672 - 96 - 84 + 426 + 756 + 48 + 42 lt=)- fontsize=8 @@ -623,10 +623,10 @@ resources UMLGeneric - 300 - 732 - 192 - 96 + 240 + 786 + 96 + 48 Notification Manager symbol=component @@ -641,10 +641,10 @@ drawLine(21.8,11.4,20,15) Relation - 900 - 348 - 60 - 156 + 540 + 594 + 30 + 78 lt=)- 20.0;20.0;20.0;110.0 @@ -652,10 +652,10 @@ drawLine(21.8,11.4,20,15) UMLGeneric - 924 - 384 - 72 - 36 + 552 + 612 + 36 + 18 lw=0 fontsize=8 @@ -667,10 +667,10 @@ via http Relation - 396 - 660 - 72 - 96 + 288 + 750 + 36 + 48 lt=()- add/del @@ -681,10 +681,10 @@ fontsize=8 Relation - 456 - 672 - 96 - 84 + 318 + 756 + 48 + 42 lt=)- fontsize=8 @@ -695,10 +695,10 @@ resources UMLGeneric - 1260 - 732 - 192 - 96 + 720 + 786 + 96 + 48 remoteCSE Manager symbol=component @@ -713,10 +713,10 @@ drawLine(21.8,11.4,20,15) UMLGeneric - 792 - 1188 - 168 - 36 + 486 + 1014 + 84 + 18 Subscriptions symbol=artifact @@ -726,26 +726,13 @@ transparency=0 fontsize=10 - - UMLGeneric - - 552 - 1080 - 192 - 36 - - halign=left -layer=-1 -transparency=0 - - Relation - 1068 - 852 - 72 - 84 + 624 + 846 + 36 + 42 lt=()- fontsize=8 @@ -757,10 +744,10 @@ set/get Relation - 1032 - 852 - 60 - 36 + 606 + 846 + 30 + 18 lt=..> fontsize=8 @@ -769,10 +756,10 @@ fontsize=8 Relation - 768 - 348 - 60 - 156 + 474 + 594 + 30 + 78 lt=)- 20.0;20.0;20.0;110.0 @@ -780,10 +767,10 @@ fontsize=8 UMLGeneric - 792 - 384 - 48 - 36 + 486 + 612 + 24 + 18 lw=0 fontsize=8 @@ -795,10 +782,10 @@ via http Relation - 924 - 564 - 72 - 84 + 552 + 702 + 36 + 42 lt=()- send @@ -809,10 +796,10 @@ fontsize=8 Relation - 312 - 672 - 108 - 84 + 246 + 756 + 54 + 42 lt=)- fontsize=8 @@ -822,10 +809,10 @@ send NOTIFY UMLGeneric - 1056 - 216 - 192 - 96 + 618 + 528 + 96 + 48 WebUI symbol=component @@ -836,10 +823,10 @@ transparency=0 Relation - 948 - 240 - 132 - 60 + 564 + 540 + 66 + 30 lt=)- 20.0;20.0;90.0;20.0 @@ -847,10 +834,10 @@ transparency=0 Relation - 960 - 300 - 240 - 252 + 570 + 570 + 120 + 126 lt=- serves @@ -861,10 +848,10 @@ fontsize=8 UMLGeneric - 960 - 216 - 60 - 36 + 570 + 528 + 30 + 18 lw=0 fontsize=8 @@ -876,10 +863,10 @@ via http UMLGeneric - 1740 - 732 - 192 - 96 + 960 + 786 + 96 + 48 Announcement Manager @@ -895,10 +882,10 @@ drawLine(21.8,11.4,20,15) UMLGeneric - 1500 - 732 - 192 - 96 + 840 + 786 + 96 + 48 Group Manager @@ -910,10 +897,10 @@ transparency=0 Relation - 1620 - 660 - 84 - 96 + 900 + 750 + 42 + 48 handle group @@ -925,10 +912,10 @@ fontsize=8 Relation - 1524 - 672 - 96 - 84 + 852 + 756 + 48 + 42 lt=)- access @@ -939,10 +926,10 @@ fontsize=8 UMLGeneric - 1500 - 912 - 192 - 96 + 840 + 876 + 96 + 48 Statistics symbol=component @@ -953,10 +940,10 @@ transparency=0 UMLGeneric - 792 - 1236 - 168 - 36 + 486 + 1038 + 84 + 18 CSE Statistics symbol=artifact @@ -969,10 +956,10 @@ fontsize=10 Relation - 1572 + 876 840 - 72 - 96 + 36 + 48 lt=)- store @@ -983,10 +970,10 @@ fontsize=8 Relation - 1512 - 852 - 60 - 84 + 846 + 846 + 30 + 42 lt=()- recv @@ -997,10 +984,10 @@ fontsize=8 UMLGeneric - 1260 - 912 - 192 - 96 + 720 + 876 + 96 + 48 Event Manager symbol=component @@ -1015,10 +1002,10 @@ drawLine(21.8,11.4,20,15) Relation - 1356 - 852 - 60 - 84 + 768 + 846 + 30 + 42 lt=()- recv @@ -1029,10 +1016,10 @@ fontsize=8 Relation - 1272 - 852 - 96 - 84 + 726 + 846 + 48 + 42 lt=()- manage @@ -1044,10 +1031,10 @@ fontsize=8 Relation - 1392 - 852 - 72 - 84 + 786 + 846 + 36 + 42 lt=)- fwd @@ -1058,10 +1045,10 @@ fontsize=8 Relation - 1632 - 852 - 72 - 84 + 906 + 846 + 36 + 42 lt=()- statistics @@ -1071,10 +1058,10 @@ fontsize=8 UMLNote - 516 - 984 - 96 - 60 + 348 + 912 + 48 + 30 *TODO* @@ -1095,10 +1082,10 @@ drawLine(21.8,11.4,20,15) UMLGeneric - 60 - 732 - 192 - 96 + 120 + 786 + 96 + 48 Registration Manager symbol=component @@ -1113,10 +1100,10 @@ drawLine(21.8,11.4,20,15) Relation - 96 - 672 - 72 - 84 + 138 + 756 + 36 + 42 lt=()- register @@ -1126,10 +1113,10 @@ fontsize=8 Relation - 168 - 672 - 96 - 84 + 174 + 756 + 48 + 42 lt=)- fontsize=8 @@ -1140,10 +1127,10 @@ resources UMLNote - 516 - 804 - 108 - 48 + 348 + 822 + 54 + 24 *TODO* @@ -1159,10 +1146,10 @@ fontsize=8 UMLGeneric - 1740 - 912 - 192 - 96 + 960 + 876 + 96 + 48 Validation Manager @@ -1174,10 +1161,10 @@ transparency=0 Relation - 1824 - 852 - 84 - 84 + 1002 + 846 + 42 + 42 lt=()- handle @@ -1188,10 +1175,10 @@ fontsize=8 UMLGeneric - 1020 - 732 - 192 - 96 + 600 + 786 + 96 + 48 Request Manager @@ -1203,10 +1190,10 @@ transparency=0 Relation - 1020 - 660 - 108 - 96 + 600 + 750 + 54 + 48 lt=()- handle @@ -1219,10 +1206,10 @@ fontsize=8 Relation - 1752 - 660 - 108 - 96 + 966 + 750 + 54 + 48 handle resource @@ -1234,10 +1221,10 @@ fontsize=8 Relation - 1296 - 660 - 108 - 96 + 738 + 750 + 54 + 48 lt=()- handle @@ -1249,10 +1236,10 @@ fontsize=8 Relation - 1836 - 648 - 120 - 108 + 1008 + 744 + 60 + 54 remote resource @@ -1264,10 +1251,10 @@ fontsize=8 Relation - 780 - 672 - 96 - 84 + 480 + 756 + 48 + 42 lt=)- security & @@ -1279,10 +1266,10 @@ fontsize=8 UMLNote - 72 - 804 - 168 - 24 + 126 + 822 + 84 + 12 incl. resource expirations transparency=0 @@ -1296,10 +1283,10 @@ fontsize=10 UMLPackage - 1020 - 24 - 264 - 324 + 600 + 432 + 132 + 162 Web UI transparency=0 @@ -1309,10 +1296,10 @@ layer=-2 UMLGeneric - 1056 - 84 - 192 - 96 + 618 + 462 + 96 + 48 HTTP Server REST @@ -1328,10 +1315,10 @@ drawLine(21.8,11.4,20,15) Relation - 1092 - 168 - 60 - 72 + 636 + 504 + 30 + 36 lt=- serves @@ -1341,10 +1328,10 @@ fontsize=8 Relation - 948 - 108 - 132 - 60 + 564 + 474 + 66 + 30 lt=)- 20.0;20.0;90.0;20.0 @@ -1352,10 +1339,10 @@ fontsize=8 UMLGeneric - 960 - 84 - 60 - 36 + 570 + 462 + 30 + 18 lw=0 fontsize=8 @@ -1367,10 +1354,10 @@ Mca Relation - 1176 - 168 - 96 - 72 + 678 + 504 + 48 + 36 lt=- Mca via proxy @@ -1380,10 +1367,10 @@ fontsize=8 UMLGeneric - 792 - 1284 - 168 - 36 + 486 + 1062 + 84 + 18 Batch Notifications symbol=artifact @@ -1396,10 +1383,10 @@ fontsize=10 Relation - 1164 - 660 - 96 - 96 + 672 + 750 + 48 + 48 lt=)- send @@ -1411,10 +1398,10 @@ fontsize=8 Relation - 1380 - 672 - 96 - 84 + 780 + 756 + 48 + 42 lt=)- fontsize=8 @@ -1425,10 +1412,10 @@ resources UMLClass - 0 - 0 - 2472 - 1392 + 90 + 420 + 1512 + 696 lw=0 bg=white @@ -1439,10 +1426,10 @@ layer=-2 UMLNote - 204 - 984 - 72 - 36 + 192 + 912 + 36 + 18 rich transparency=0 @@ -1456,10 +1443,10 @@ fontsize=10 UMLGeneric - 1980 - 732 - 192 - 96 + 1080 + 786 + 96 + 48 TimeSeries Manager @@ -1471,10 +1458,10 @@ transparency=0 Relation - 1992 - 660 - 84 - 96 + 1086 + 750 + 42 + 48 lt=()- handle new @@ -1486,10 +1473,10 @@ fontsize=8 UMLGeneric - 1260 - 480 - 192 - 96 + 720 + 660 + 96 + 48 Console symbol=component @@ -1500,10 +1487,10 @@ transparency=0 Relation - 1344 - 360 - 84 - 144 + 762 + 600 + 42 + 72 lt=()- m1=handle\nuser input @@ -1513,10 +1500,10 @@ fontsize=8 Relation - 1368 - 564 - 84 - 84 + 774 + 702 + 42 + 42 lt=)- various @@ -1526,10 +1513,10 @@ fontsize=8 Relation - 2076 - 660 - 108 - 96 + 1128 + 750 + 54 + 48 lt=)- send @@ -1541,10 +1528,10 @@ fontsize=8 UMLGeneric - 540 - 480 - 192 - 96 + 360 + 660 + 96 + 48 MQTT Client REST @@ -1560,10 +1547,10 @@ drawLine(21.8,11.4,20,15) Relation - 612 - 348 - 36 - 156 + 396 + 594 + 18 + 78 lt=()- @@ -1572,10 +1559,10 @@ drawLine(21.8,11.4,20,15) Relation - 552 - 564 - 84 - 84 + 366 + 702 + 42 + 42 lt=)- fontsize=8 @@ -1586,10 +1573,10 @@ requests UMLNote - 504 - 528 - 72 - 36 + 342 + 684 + 36 + 18 Paho transparency=0 @@ -1603,10 +1590,10 @@ fontsize=10 UMLGeneric - 624 - 384 - 60 - 36 + 402 + 612 + 30 + 18 lw=0 fontsize=8 @@ -1618,10 +1605,10 @@ via MQTT Relation - 684 - 348 - 60 - 156 + 432 + 594 + 30 + 78 lt=)- 20.0;20.0;20.0;110.0 @@ -1629,10 +1616,10 @@ via MQTT UMLGeneric - 708 - 384 - 72 - 36 + 444 + 612 + 36 + 18 lw=0 fontsize=8 @@ -1644,10 +1631,10 @@ via MQTT Relation - 528 - 348 - 60 - 156 + 354 + 594 + 30 + 78 lt=)- 20.0;20.0;20.0;110.0 @@ -1655,10 +1642,10 @@ via MQTT UMLGeneric - 552 - 384 - 60 - 36 + 366 + 612 + 30 + 18 lw=0 fontsize=8 @@ -1670,10 +1657,10 @@ via MQTT Relation - 684 - 564 - 72 - 84 + 432 + 702 + 36 + 42 lt=()- send @@ -1684,10 +1671,10 @@ fontsize=8 UMLGeneric - 1980 - 912 - 192 - 96 + 1080 + 876 + 96 + 48 Script Manager symbol=component @@ -1699,10 +1686,10 @@ customelement= Relation - 2052 + 1116 840 - 60 - 96 + 30 + 48 lt=()- recv @@ -1713,10 +1700,10 @@ fontsize=8 Relation - 1968 + 1074 840 - 96 - 96 + 48 + 48 lt=)- call service @@ -1728,10 +1715,10 @@ fontsize=8 Relation - 2100 + 1140 840 - 60 - 96 + 30 + 48 lt=()- run @@ -1742,10 +1729,10 @@ fontsize=8 Relation - 948 - 348 - 72 - 156 + 564 + 594 + 36 + 78 lt=()- @@ -1754,10 +1741,10 @@ fontsize=8 UMLGeneric - 996 - 384 - 72 - 36 + 588 + 612 + 36 + 18 lw=0 fontsize=8 @@ -1769,10 +1756,10 @@ Tester UMLGeneric - 2220 - 732 - 192 - 96 + 1200 + 786 + 96 + 48 Time Manager @@ -1784,24 +1771,25 @@ transparency=0 Relation - 2304 - 672 - 84 - 84 + 1242 + 756 + 36 + 42 lt=()- -handle -validations +time +related +functions fontsize=8 10.0;10.0;10.0;50.0 UMLGeneric - 1980 - 1068 - 192 - 36 + 1080 + 954 + 96 + 18 Scripts symbol=artifact @@ -1818,10 +1806,10 @@ drawLine(21.8,11.4,20,15) lw=1 Relation - 2064 - 996 - 36 - 96 + 1122 + 918 + 18 + 48 lt=- fontsize=8 @@ -1831,10 +1819,10 @@ fontsize=8 UMLGeneric - 2076 - 1044 - 24 - 24 + 1128 + 942 + 12 + 12 lw=0 fontsize=12 @@ -1844,10 +1832,10 @@ fontsize=12 UMLGeneric - 1500 - 480 - 192 - 96 + 960 + 660 + 96 + 48 Upper Tester symbol=component @@ -1858,10 +1846,10 @@ transparency=0 Relation - 1584 - 360 - 96 - 144 + 1002 + 600 + 48 + 72 lt=()- m1=Upper Tester\nrequests @@ -1871,10 +1859,10 @@ fontsize=8 Relation - 1524 - 564 - 84 - 84 + 972 + 702 + 42 + 42 lt=)- requests @@ -1884,10 +1872,10 @@ fontsize=8 Relation - 1620 - 564 - 72 - 84 + 1020 + 702 + 36 + 42 lt=)- scripts @@ -1897,10 +1885,10 @@ fontsize=8 Relation - 2304 - 852 - 72 - 84 + 1362 + 756 + 36 + 42 lt=()- handle @@ -1912,10 +1900,10 @@ fontsize=8 UMLGeneric - 2220 - 912 - 192 - 96 + 1320 + 786 + 96 + 48 Semantic Manager @@ -1924,4 +1912,255 @@ valign=center transparency=0 + + UMLGeneric + + 840 + 660 + 96 + 48 + + Text UI +symbol=component +valign=center +transparency=0 + + + + Relation + + 906 + 702 + 36 + 42 + + lt=()- +logging +events +fontsize=8 + 10.0;50.0;10.0;10.0 + + + Relation + + 852 + 702 + 48 + 42 + + lt=)- +requests +resources +scripts +fontsize=8 + 20.0;40.0;20.0;10.0 + + + Relation + + 882 + 600 + 42 + 72 + + lt=()- +m1=handle\nuser input +fontsize=8 + 10.0;10.0;10.0;100.0 + + + UMLGeneric + + 576 + 966 + 84 + 18 + + Actions +symbol=artifact +halign=center +layer=1 +transparency=0 +fontsize=10 + + + + UMLGeneric + + 576 + 1014 + 84 + 18 + + Schedules +symbol=artifact +halign=center +layer=1 +transparency=0 +fontsize=10 + + + + UMLGeneric + + 576 + 990 + 84 + 18 + + Requests +symbol=artifact +halign=center +layer=1 +transparency=0 +fontsize=10 + + + + UMLGeneric + + 354 + 948 + 108 + 138 + + Imports +transparency=0 + + + + UMLGeneric + + 360 + 966 + 96 + 18 + + Attribute Policies +symbol=artifact +halign=center +layer=1 +transparency=0 +fontsize=10 + + + + UMLGeneric + + 360 + 1014 + 96 + 18 + + Attribute Policies +symbol=artifact +halign=center +layer=1 +transparency=0 +fontsize=10 + + + + UMLGeneric + + 1200 + 876 + 96 + 48 + + Onboarding +symbol=component +valign=center +transparency=0 + + + + Relation + + 1242 + 846 + 42 + 42 + + lt=()- +handle +onboarding +fontsize=8 + 10.0;10.0;10.0;50.0 + + + UMLGeneric + + 1320 + 876 + 96 + 48 + + Location +Manager +symbol=component +valign=center +transparency=0 + + + + Relation + + 1362 + 846 + 36 + 42 + + lt=()- +handle +location +updates +fontsize=8 + 10.0;10.0;10.0;50.0 + + + UMLGeneric + + 1434 + 786 + 96 + 48 + + Action +Manager +symbol=component +valign=center +transparency=0 + + + + Relation + + 1494 + 756 + 36 + 42 + + lt=()- +handle +action +updates +fontsize=8 + 10.0;10.0;10.0;50.0 + + + Relation + + 1440 + 750 + 48 + 48 + + lt=)- +call service +functions +fontsize=8 + + 20.0;20.0;20.0;60.0 + diff --git a/docs/Supported.md b/docs/Supported.md index 035fbff8..d02ceb8b 100644 --- a/docs/Supported.md +++ b/docs/Supported.md @@ -41,6 +41,7 @@ The ACME CSE supports the following oneM2M resource types: | FlexContainer & Specializations | ✓ | Any specialization is supported and validated. See [Importing Attribute Policies](Importing.md#attributes) for further details.
Supported specializations include: TS-0023 R4, GenericInterworking, AllJoyn. | | FlexContainerInstance | ✓ | Experimental. This is an implementation of the draft FlexContainerInstance specification. | | Group (GRP) | ✓ | The support includes requests via the *fopt* (fanOutPoint) virtual resource. Groups may contain remote resources. | +| LocationPolicy (LCP) | ✓ | Only *device based* location policy is supported. | | Management Objects | ✓ | See also the list of supported [management objects](#mgmtobjs). | | Node (NOD) | ✓ | | | Polling Channel (PCH) | ✓ | Support for Request and Notification long-polling via the *pcu* (pollingChannelURI) virtual resource. *requestAggregation* functionality is supported, too. | @@ -83,6 +84,7 @@ The following table presents the supported management object specifications. | Blocking requests | ✓ | | | Delayed request execution | ✓ | Through the *Operation Execution Timestamp* request attribute. | | Discovery | ✓ | | +| Location | ✓ | Only *device based, and no *network based* location policies are supported. | | Long polling | ✓ | Long polling for request unreachable AEs and CSEs through <pollingChannel>. | | Non-blocking requests | ✓ | Non-blocking synchronous and asynchronous, and flex-blocking, incl. *Result Persistence*. | | Notifications | ✓ | E.g. for subscriptions and non-blocking requests. | diff --git a/docs/images/cse_uml.png b/docs/images/cse_uml.png index d4ae671943100b51fd65aaf6e106f67336ecf95b..648f4878a0a6be811c0fc16cd1f6d7bf828e1c7a 100644 GIT binary patch literal 107640 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 qDZpSMk{O#dSzl?$O%T3^6pU7k|B z&b+`UY{2#^D)fio4W9q1g>&>p5y`kEsQjaXB&vL_F76GXW)@FCx-bkd6yq7rY`ll3 zU)J=azfE^6Jg)Hv=WE8|eS}iw)Jn({IorLoDdktZfeQq`Zl0kKGP8DjI$4<*d19^W zxjlBr(211u;~{f!2^9WrBFnH}sIiA^>lmnJsz8k54koL~P^2RHhZ@f6p@tm$B_z)R z%9v4Ck1z@GQrXKxT$X1M_q(?I0J=d35xO3yN$kpc>zzfEt&oCbLCy|Hl-``A@F8my zOe6dP1KC*m(N)IVH1$3{9^1C(uQ(7Pj#zP-B2BKsCfXBUAE8es03ngF{D%4i!aIuU z9+&@Zl}7?;O15cixV*#8=n=mnr)|!-CS~i^+fiSUoa0xe%L=2u$$@m$vldjPPUn>5 z(3t`{Aluvox=hHR)bW9;K^obz3fidNEh`KkLNB4S0htE&7{^^gTuzo3V2tE|JBR|9 zI#R5pLYHYQ3bOC~jEnVSv!2I&bYi_5hMz!xR1d6R;48qC^ScRZGJM6sB=ZT9$~b=U zet{da^?pDMMWv-=ItN)Y)En4zpc8_36zP5Yj5oFQUT|=*mp{t!k-Q=VB+_o9hJa7m z5d4>?ve~(zQud9ogo<(HjJ$HiPtUJ3@$gge+V!^_@X^OoooB?pK>f zrloZND1qxf^JxbH2-S__%C%5m`)~mS=4fpvGTgFTHKq!=Au4A3et`-nkL`=Q^Ijw5 zlC?>eC1SGghwV(cW-R8JsAP3nkDYW34+CfzF28PE-=#Wd_n&5F-n8${QOB-RZzXJL zS!;c2|L2AMZLrQP<;l<*UwV(TRZ{BDFVegxLlIoaXD<=bO#$i_QYv$bLui1nvDw^m z=5m$Pwz-BAmoNP~jU2m)<+lqtHAj^KT@FjkH}|Et&69fD8{)R0w|zO9%d1uLllgJS@9ai3r$#MJ!Ha89Azoumuk+ zn*#{PFVA%)nD9$K{;u0Bh3xiz=WqUhy1UXqD%Z8WB1#%XTV&{%GBwChDnpYYG*THt zh%$?mp(UyIZjdO-loAbyWC)d^Qi~LJTZpJAMHvz+8P4^rH$%Jn&e`9$&yVx@bL@__ zo_Bbjd${iFzHZ}!BA~A-7|wzO2lH~&yDF`S{w0nx$MJ!1`>R|PX^zjj;2}&?t#yDL zLKOjjz2<|N?tmff(c{O?-#{wyhCh7}kQ1^1aL!}&2>2SL4vx)+x*C}(X_o0^=H&DN z5}Ny!kfKafhF}X3W=-jZ=Q-&eR!-;h!(huYm=HixW_v$3qPVM<&}B#~WCmJj3pKGK z*MQ&1UWkl-S__pz#EI8oS(&FEFaG#YBFvP>484j>l%QS?ggYY|LL|T^hp28Vq;%lZ zCrt}(UDuP$)&@mgO!Vk1QUmt0>gOxKv{eJmdFGUp1Wj0`2XHBbW+uqCAt;5BhduQb zNSmQ9kps)^?|b;j5rlbP3b^Np*ToUzihdy>Xxf!c8V9upv83vaqfRFZ)rkdEpA#s# zF#GN|;Wl>Bvt}nU4;g7T@Hm8XJIi&XR;XX_h6)22lM8G$7gfz8M~+}R@J1jRO2ik4NKmKHt zrj&2-+QoP2+!V$t-oR-`EB3t{ki5i}((q*Yk29hbs-xvMmC-HN(n2G{wW<6PkBN6g zQj(;72=V0H6t!j=Oftba&mHtE#jnpPkRkE5KvPi+eyGNywJxI4SWIT?V!R!g9DUx_$Iw8Y0^e(+jlhs zNKly}Rg%q4$B5DytYiSn!{-s%aM@G?DBp?=YFM*OPPzSUHUc7|J`E^ewxO8 z3VPhY$hBs;-d(FDq=ZmZV2P2;{c)(;;OC0Yi1ZTcuRfy?2Yz0D0q_z;Z|0v1Q91#s zVsn-X#MG2_3n5z*^0(9iUyU#3;4IrKGqgLgrZfQaUzhjqRyD~M*YwV|4{U)vOU z8kPaf1*pFih&XDTa^|-ehsKdSscNjzhv&BzsVM_gFIjn%e=Wc>JY#r(+1c^u%~n`# zj@?t7!RFGo6}s$6gPE9l7t?5>5h;k1`E#W;WfA_YT(IL7kHD;ZRtHdD!_ObU28Z^^ zHunpej?Z?XK?DeUFFK&d?a%yyP2!3Q}% z%Xm*nK_U@$*}C1n{7c4KrzD8Fl_~MPmk}ryC^5-^Lm0}oZq$vQQd^L0-?%1Ms? z?Tt&><8~*mA@oZW1V^S*ddE9qopF^AaG8%#^~`+RLbaXKk=$k@6i15LE)RiAY9XaY(&AFw>ROu;s#+c*nCM)%eUr6hzQVnTgS%7%B^h=T) z_4{wyE`7LL^K@ai%W^OD5W)RLQt1sM->}AD?mSGLxY<>0zbd5v>KiC2a}L7cuO5|> z1VENX+Gtw%ca4 zQzMhj1%N3c{s^Ds08ZWZP3M5zA+S_JnZx=OGrCOd1yr3=kv&^jb~G?x7>Y5jtfa&j z@;CdH%04wt^Mf!{I(7w$nVqe5L>p{^Q#Hy)mWPBgAP!ybTPG$2hmT$S9Su%^(8>8@HE76D8O;3CJ`)B z5Z2R#D84fPJa}tWf^vWO;l3D^>gw~j=|me)<78wCxK;E-HDo^blHG(?dKx&K`UmV;c$jSx_uoS44`iX{W}F=U)T04H$wbw<2kz%*Jk35-Mv(npS>M!Ta5M zB3zJ9x((Ee<ncS}_d#KMt@fqwf)Mh8RCoZfTG0iy2Vu(592R~KGwO~7 z02HpQ<76Sb*p~2=t|#GHqlgAdcgB zv~@U-(4IwO=dYWBk}P!tL9jG)M+Dk4aZoHqBm_&Bj^fW78LkGf&Gl;+0;{2v1%r*{ zf(v}LXgGu68JG7jF90V{c;=Idf9R|kD$5{Ilg6d~=bxDh&$i{NTlAXRB>buK)7a)JMNx&W;p=^T<3!4(OHHJIvH8W7 zx|QSd_^NKWS^=(7RdJ6Om#Qs1rfa+_H{1gv8ekP{H@>%wu=#0zJJzvwci)<00Z1$o zz-dEzV`YE%@Zln}K-92EKo!dk+1{%~GlM;YErx=maM=NV03`{EB9uWxa^#|KNZsHy!y z29*IDuN}G=LSe_npci~f9}>Q#e6cEw04i|Pb9HTpSurwh)YZ#cq?3XnoE8xX6$!Pi zsHDUUnP7+VwLABOHT(}(h+MgTip?bzt)b}coxiCPw*e!EK2=q|+qw#q&)(r(E}_KG zIXnGHW=0}g3Kt1MG(xHK-WPN<;D3vN^N^D4Uq`?RTjZw*_<^(k4gzkA-Un+iM4U0H z17LJ)uH?nI8dHf(3%4Apfce>zb3>Rj3biC=ZYztxoK5~2vmcN|eUk3prQnX(RRt&? zRn0@gqo_FYj-M#N}yY{u(4c zrLWY|!M9*7rp1&axwbf4#^b@l90?tVgp?fmz2HxVMufWUJXPw?c)#V8OW~Wj8Jn}_ zaVzJ%4!8GLLdDwmV~NVhhm)?2hf}(m)Syx8k%Jfpq*e73S{xByyQh$!CldE(Gzv&!p@yJ+j>v8cezS3(-e+ET@k1xW= zT-?sx4?HGkeNI{oKmdp|DWwFZlCv!VEB+Lib3@vOlL^deiI-|+C)t8eqPulk6OKWr zcX7hmvktRp_cL)b5^36<_Qncdu9H2^3N9 zKPc$*>+Du=0wCHt@X3529Z?$Ev&rD5{Gv*?6-pVTFt|BY{Dw0+popBEMI-JUoK%2G zthjD^2|Uc+L&$1X0bIq3lsyCgt>pw*8BehvU>mQR=c-j2B2fc(y0)Qu3ovMxM2eZ< z`MD7FoH&cK2>Y>TN#-QI$OIG{ZUG6q%Y!lPDuY#7S-L2fR^RHNHn7Az*}jcY9Wf13 zogDQ6q55{Q*jIU6eVwc6J?mlPQC08nd78^sU9lo;{{a|PbB{j^A(E#G;w(y9 zv4Z2jV;%$u5e_>(@tzvRWp!u^st>5zx_k>3#nLmCcGXnwhcv1ljtHV_)RcH~)%s_B z7rw@|I6xqz1@ZBu>RDr-1C#5mFmXN=5!ygZ-ir6aBEj{SB?9J$-eKl!;Jse%>rn(I znaIiBfZHINq_#VrEOm^X3?GkZD41m2K*wIAk3!Z~Dd^b$I9V zZ%aF?&Saw@Aym;0x?f(7#D4zs@#fzV@M_RBy!pH&K5^O*P8y zNJV#u=97<#CXTq6x21RUlRzAtmC2d-_2UIdE=UN^`f7X}gie7J&T=YzNAm*--^uk^ zt17y-RM<^e(BC@BUX-%mE??7w%avJr@yn)a1>1dH(|W|3+bh9V_b0cj!5ndGzo-1h z=f^?H!hUUt-AI;kx$^V5B5O^}d{{yh0Jho_J$3w?`eNi;Jl=6DhXu9l@ZaxwH$Uq~ zHH_TvB1wv7@q3FoU|+r*=Yr7ua8%QD1Nm-wAWY5QCsP&E;kd32mrB8BFJcE@*GFF4 z)bAO=A%RxpV_rTbarAC-y@?&V_h9 ze9&m{Q=m2U{-qx^z|jAWrb>CsM)(clwJ{D5qvVdn4am?iy51di>|AKm%T~ETn=(HX z^AV+I&z>zK(*sIoE__@wsL9~e2IhL8F&UnGBJ?s-FJO)*P<9qi_X`R-=b!QN@)CX} z5RI}))1_)8l=Ks6iByeCjmi+k_!g~pl^o=LCKI|ZVll8#;tiq%64iPM5Y+R&I*b~KTY$;UKHM}zy z-#Y{0rEOq!BTV15X}Y#oKS=BV=S~U|8>NM>7vLfr#8m z6I%3F4C^=_{ih4mfRyu<#0c_!{ZnY_qk(`(-lGV+v#l8n(HxIvBjCoC#h@Ap@2a#! z;dp#SZWYMeP`Q~o_cIv7U{z!NCN57M{U7eR zvka)15+QiRD2S96qR5Q5?FpjZQ)u<$Ws=EeCeF9g0ch2)g@jXDl^Ucb_qN>TGU2p$ z8pejmTL^ERkLEVOk1JYnr0`<%Z=F8`QmLy;DfjFfF+kP(KOh%7;xv_?a?WjI(Q6*; z6UO_am(aV$#tmgmfKb=WzXCf~YUb@JMp3V|Hb-SsB@(){l_qP}1o_x+NSQ&m@8DdE z9fo6^y-Hu@2XEC>a(%_!FDuxoU@hS2(8;qYkJ;CSzCD^K#|WGCcN^^~<IUO-0{Y((VJ&QcLy6~NoL`os;5^K#|einlQYqN-YdH%R)p$hPZC|K4Zazwb?MedX%$=uPBD zFM4Emb6@tJjAby7w`1qI3oT&Tve7eP!t2WTvnNKftcw3+8vE&1yYXgY9k^%UAp#XH2I4kv8;?Ugf2E{cLzEL2RSu`h5;8)Q-Io zt*DriODxs}4QlQ3Sy!bdOlYCJE4AKH#ly`y%h8|p=6@s7<8S*Ah2Mw zpnViNc4DRLl7-VU7VJq!4Ld8VigWvTvcP#B*2R-AV_ZwQ$4CGha=HS0h9-3{^Qe1S zDRA#o!=}oYEeXrEyH0s-QA|61TV}ZD~??` zd`tK1r*Zo!agu*-AF&dD&DK#Vh2&pFg$Ea5ZfasgM@Iu%?bp8tMoKuZGiDq&H-Dj5 z;g6|XT|BsCdzo=|T$~Mtr^Us^LGoT-Uw_%{ix)52K4?x#N&=^jKi+Na+A?^I)YXCl z0`Hoe4<(=F#W)nVu6C4x1JQQY)A=@EcAzZCqnupK_ut=h=g=gb%g>)bkB^Usk>uNU ztG2e5A&AnkA+zcQd%Z&=ES==Nkc(&mO-@LV^)Yek)06xNZ`Se&!-8^sJ}Yz=we@e1 zDv_T?Tg2K}cg3iUwVhX_md5OEtwZ|&a_8+GF*!Nj@Z6AihG``HXE>~270jU_gEJXl zfEpXGUq2s3I-p55?Da6%@E6xJGL+|t8^{2?^F;bRVZwyIg`-^7uV3%BI6~ zzOJq=@VUi*!CK}UHYkF_i8_RJa)W%7X9)eu`WvQQaW&os{S0_$-Do2tqrf)rA71Qk z)N&ANVWg?oK;9&2%x=rc%=APSg;n3bf6ovkodgW)?Cfm7DZ2@{rcTdDPfyRtkemZs z`xe_32~0M|e{N~Ka^(u_G+vr-FLRzC4fCu~>iC3ogP|47htEZu(A!oY4(WkY@j?)S z+DGcfj?hOrjQ)MrnJES@+zxd2f(3wC^aH5Up%^9B8_khNZRg=lrlmr2a&R!cw)Gwm zguVT}RqB)Dk!Z!m$G?Vy%TiDB0UaqnH}Sm)lZ%i#Tjne#wTLfL1zD#Ou3QNo10Pri z#T8|m!Sn0D;#&j>4H_6jO;1*?G)g5c;6_c@!Z1;7mHaZ$ClxbB-SCF#izT)SXWqSg z*U*lSYcZE_`$e%#$jd2%r|@{sY7I|!_miQa7z5f2vLjwP2m#edh_}#uKOE*UwINs{Ujh?R`o&m|sx-JF#2L1c}_h<{8-`)VIF_xAQijF*jaT|+*~q{ha^!78{yv=Ct^ zK{CDm>8XLOv`Fn8k_k`#_`?^IQ{fOD9UXBR@a&<5tm)Ke$yR?e?xT}J)&lFOg2r*J zS@s%|A)C<-3W;Z}jvqaRigI~*d7aCM7;sj=iEPCVowMcUX(bCdRv;F1l&XNp$;!%J zA6VvMW@ZMw6QK-J&F9tCs_67|h^nrxMySzfC_LWJ+siBXKEpQTJ}MR%rMRP`6FT~5 z^dz$sHmPS?q%K#c3EvFPaetxTf7#$U_Ky#63be|Au8O*>YS!ZuK9AWfD7(FT$k5W8 z%VCC!R-VJ^RkBf0Q5Zf@1zE>lCPXf@-%0VM4pg{zL%cEd8}ZZBZ^VOBzmXfFej~R^{r2Dgg}uwzEvS3(3+i_rd_1(+RsSDv e_P_J}w$Dkpq@DRPfJd0TD4i7s%dalA@c$=Kn+0P4 literal 91402 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⪈89ZTmzT{}Z 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 VEI~_O2oX;@d_ZY`+`co{{s%j5WEB7a diff --git a/docs/images/resources_uml.png b/docs/images/resources_uml.png index ac2a7be6ef72bc1a4a0a9ac5f575516a7322566d..4a46aa03b931986c3a458734e5dd8fad9434e23b 100644 GIT binary patch literal 46715 zcmeFZ2{e>{{69LaDwQH6q9n>5357`rCHuaYHT#le9i>vCu}fLXlHFt{%TU?3$Sx#n zvW_i`ao^9V@A6yD`QLl)z2|@L`E`81$9U#>p3nMTUa!~t^Hf<;_Q1Yl`%oy<0eLxT zRTOGh8Va?2X3uu`O9gGgCltyDB`+CP}) z?)jx8)%uY9GYuR(y%M+QZTV=!1J>qVCoTq$N_jmy`tpkZ!vLXUCj*jhj;)Z2MeqwH zk~75ZRFqdpa1XiC6Vd`i}}mqFn(g>#y+piiBm_R9!Rb3fYB5}r=n0r zOruofHh*&pL&GmjzIx1ZyHF=Y$e*+qLieG38dOMI9psfoZmc->%%$PuOCf8zxcoam z+BVi#WSu!tsMkp6zcmYC!Z|glo1Z;>DzBixg!cAqe+y$sem!rV$I8r$7cXR)-A39| z)wr~>6ciPoW3e1OD4!IWtw%Qw66WeS;y!))#I61AGBZ6Low=#0{@ot4-rT+dTSjsB zJ2lUb!_TX@IGn9fo@Hx_N`mS7Yf0aI`}Vb0i+e1K%~kA(ii(0(XyJ!vzxEX^b>wH2@i(V5OD6C92^{?qQ!8x@9qPf8fonltqC2= zD}xxcgam28V|JqkO>+F1S(p}R@=FDnR(G^q;|3+y}?^zZQ@jq#NDaxwxsKu z8!Lm8W=lW2X4_Lk-HDBYEwv3+of$pQ5|5c;*}kVtKMVTo7=^6bzg7Ak`0!5r@3tlJ z8od8hRn_mc((f|aE-xo{^wQl7&9sTnZ+yklnG3Bt(%crtCLR0VyXfUvz8S1*O_1d) z9tnATfH5sj+>KzWprC+d79Vkh;eB<%Hq9_1E{=tTW%IKXRqE#Qk1w(Z#cg}fFAbiO zeXgFRUwq}!zBIaodHUTb)SJtHO__4iweU2J^a9(y3+(J;&6j4|3;2x6^$X5hwWqA; zz=D|U&Q5Solf^O<)JyJ8cV-!+7c{;Ux?a#2EvmLYpJUc=_h)D3yLazAR|y{XsRX&tm+*VxlT=`lWQ7cyCQgBTuhf?PQhI6+b+N}Oaj$1r@?BPv&SnP}{zIA?^{3dB*bpHMhigvZ(>cG%V@1+i{ zT#J?n3g~ZW;QGoeEXScLdZP@(T$>&NH>f~o1k!3%pnE`#ZKt&Erghy9_}B)k6i8mX!>>$4uSW%M*Of~K{H z+&tG7f1ER^hJ8{`w_14fAkhUlO?79Nd91kOf8p??`lcpv=dpmAyVL8^jUw)g6VH!d z+DA#bdU2_|*R#z7~4TgIAi5frN1Pcp+B=LEHV z`NHD;>!#Z~uu<2-Is07)y+(GT4n>id?uny3dIbvd@^uXjYMWj&#p9}KYVwq*69QW| zXWI^x7MnM{RC}w?T5#mZk;J5=a6Y5h5fqB`5P39?=)p_d+S(MOMcxb+!GeGpg$)$S zg1Vge25|>I-&eo;^QEb$3V1Br%qBlRc>HBh(B)?+RPbZ6hkTaoJl=fc<@vK`&dhmF zg7Ku~RF_jy;<%3Td2$7@E~u)xZWF9VhPGo*jv2}Dox{)vu$Bv4{C|JpbBXJ0W23@w zSDyW##DxnH`rA;f6y)Z;=qPntIQj)e5yD2N(G!yjI4M zv3YU+5bZe{S{2949UGMHgQLZ)yHF_h1JW{$(lYA$KQC`S7;tsVf>HC4-=T6?P~|Xf z*Tv8H*DD6GTo5f8w_X0{BP0Lkk^f6$S+UEWc=|y~t)NP=NSZ8*mpZeUdjutO3i~B4 z^I-|68(rOm3a?Y0^}d#jqKaZAQeAk&Hq7v0q`yf%eI`Z|?wpV#QTJHJyq?8$ZvfL; z;XQhcz#f#tMLL(m%(QOooHa7Lx^NE;hK&mpfA<>jQ4Q#2Yu z`C$FgXHh5-Qd5A8fvK!a?9+NjmF#6+N6*JpR}b3EN+71U#zcjFNF~@J-br8GVS{sz z$|e4zqOG3aoqtfEL}J6k6}-Jk)q#x8&dy44633fm3=Y+ZL%0Kx2mIf0tdW5+fk6K> z>SM9-JkwXy9u;*-Y&G@S^Gp(++EwkN=2@Tfjw3PpBp1dCS*V!UG98(|v_~1HPo{(!?w& zzk_R9^%3hcB{*%V)(AokM1Eyd^AOl@8Iwqo>zMW0c_a2<}zV`mDj~!4#IgV{x}eq+66zb#ljoPD<&28UDJl4dU;im1&!{ zM0u;#Jjday8_Toh>sjilsySC*f=dS9XDZ5J-3sQsPOZzp)nI9|W6*6p1|pNUiHWr> z_1u+NGBK5oOMcA!#y+K2H678!IbR#J*Vch4|n zzV~h0Y9)G;_+>do>}m{tsxI|T( z7DW*6F-kRPZ;?X?u8Uhk^$d%bL+qL}-@u1oEI!sQVUK|rZ`4dlZ2qBv|3zO|Te=51 zanTZ9@5KyrOrMIyihC3UR&Do{BOY(|I6HH^KrAAzzt}msoZgdHa>I79+pdf_BxvqC z=9fnta<*i^R`Z9UJ)a}XXw=Kb#s<=&g3(YViQu?n!gkSt#R5kcsz<4O-)Lu~G&a8D zP>epa9^CP$z-+WWJjbjxUPi_OZMNj9*7%f$qdtO%3(dSJqqN(H$&xTqN4Q7MSb}Al zMO`Mtb_PX9GeLN-(GloV41PHzF$IbdB`4US5|Wsmd6%D;j$2BeW;qZ+#X&7$Flg?q$Ke5@>J0M$_IXgxo?W*Ic;&PUx|bkA75I zqf|@xz;wV2N_S-$xXyfaYv3uYr7E!N*DW)L7>aQD>Vw{v-#KGoM-)WU$Ar=#%!IUH z63H2s%^D)=m?OA#;=|+UxV2y3s;#Xpn#yTtZ-6X_J?cky_AqSkg@uLuT6!K`w)L=S zBt<-PMwxMCs^?U~bwVc|gUJ`JIw6LATPJiEqTQUL$)4Qgof)_%tNx#^(Z9&R$~|5T+G+P8Di0-qO}+SOrLK<$ozok+XjoQ0ND@3)rB~(WgWL` ziVV{d+#4t6i}k?BPgV|#J3R{zJQT>}x}WysrQ($Vk4vsI+&nQNj`5{cDcfkIvFlTI zm`twDeAWr(@tHrXe+R`uNYIfC^~vE=i};T@=1s?ZTRtsvo$2u{CG^?j^UYK&JoPu; zP%(*oj+dd_Yp7Q$WcO9uTl4LnHS9~@=bXlD5>y;R9a#n*3vRH1Itqksx|QE@^OY@z zWCU)`V=*by5Reusyh3tep;uqCP5hSExNxPaV>iKi%_PTprzPo*#pF;#?XOcDxHla5 zIUF7EiY1odcOh^4^J?9EtIRJ9{Kl7It_KpDOKLRoay}Ef>mc_tD`^?T?0rwM5$HBd z@N0Qd62G!MtSkIRRr+ zVxD!EA~T+}_H#F^0@aE#x5Ne`SOpKFlc6VSgSB4@Z1rX(zdIL_t|}hgjjgFYYj@#_ z3dx~KIk<3d4=+Uw|IG)-s2La-CR4vJ7q!e>u#8!?eiY7}43RrHli_J@G}>rD$1L*< zHa9nSRH$&!yTsk9Ia-u3n32Jap=^fs--s4b87Lk#1M8V*z0Jl`-(`X@+8}(pO`82C zWR-794Lv3y>yEkP8o?47b$@xt^RvpXfKTH??e;X5rng4Q%1mmW!=Mtn`}pugR;4oV zl#}>KeJ6Qvj7xEowK+O3Gy2U=2^L(i?d{eeeXbF+?#%FMqPo5M(Zg~VQ{cJsZavbR z@MNinzD#;s%1XPMQr$9)X`eRybu6%frCO@hQ{CLW8?s0NNiF&a@C8NyCn3wuJq_X# zEq%vF%i2zt0OXjaFf%vGz9ln_&oQ*^%}c#I6tYw;MJ1j{O7GvDp1dx!R9DSsp^>R8boQ+LMsPDM z^2ucEKzik;PttA(4)w*KuYAIO7wxC`=3hmVpU<8>3(!pH>a)g^Vx+ay#uoF&7bgY# zySueFjtM<2)6VVj2TKa!|@UcSX?XJ09hV-XU${?aJn>VcAamK@Zt7Ps}~Y07S5Pz1WvPqU)+ z3=aMJRH##50}JVDj4L+1?}w4cv=BB%;RU~B!EA4SLp8wC>0cir7-yR+C? z@o;C^V_bYi3(c20sv$gVz#y>OdIrQbE>qnq;z{M!Zw*TBT6bk>=(Y2SHvxim$r7^D z5|^KJ$n@sqJW_Pgm)B_*Bpvr`D4cROi2pS?YR&)#PGR3(nAyhFCAILHD zU%MgiJ6&m3H;9;V5v^^q4}z@yqidO!R{Jtrf@xwahaFXS1|RT{eQy1@{=Li3MZaaz zz?WM~MXs)ldCkQ954W(+D*z-A{IG?BItKSJ=2;RZk@AUR<-^Y}5qU(!N$n{Hl*Yru zlTpN(2k#9Z z@LH8>jJc%cEq!6Ysey+uxJevGPtNBp6Uw=)sL4@ySwb?5kH? zI0gyNq8dd3K|!-ywkBEot4kLZL)mXE@+q&h|6+VrdAvOlyobT9KCt&0qH*49xAW%# z*X-px|AU1qr#z%8d@*5d7BNj5qL+=GM1FRB+inPCf2I9CQS_U$xv>Z3vGF zR=%@*Ft~xYBm-t1Cg*D=KA7fl<*=9P5iZ7pNc7k9>W}v`u=eFsakQ_*rp3i)mhNGqPK*A`Pq4B<`($t!&sv9r(Z`QaCVm8Z4rP7u3TVM_0y}fkq*XD3Wk5ZAR zDmP0rQ@8G+)aHuNd9cxdi)qI1JNmTcW7JuUCxD2P5e7@N*0W_RhOMhquk&yVpK~1h zYzyU+Yp;3F4wPkQXIpjJ&3^B(XoM+pUOfd-lOEX2_SME%8*Z(vXbB&PCgjk)zpQUn zo0J1E573K<*cYyxFawM z9bIPaE6G`Ug^J9xgLtFr0R+dSbx$0!ifYAUHm~y*fAZK|n-z#gW6GDkvL~7E8(}s& zj7B*3=hKD6SQ}BFgzS)GGkoVUzwy@dOD-20BKcNClq=-8@7_m4kb(;c1vfbsom?)2 ze$4AmoO?K!T4?DZ(wsV%mA^b%MZ? zPlH44d?DOjtHW6Ivu2F=`1dM*GcD?OQ_HaKHE7tQJT}$`H)jA87t@UMsypWK>B(VA zdfu-9!Q0><99|1d+_qzPEwygvGNfT2g$F$o`n4J3(Vo_yiTsgh2z?w#qx2go3thuG za_}$cNTy-g8f>BKk-XvI7zj_=`RICR!z*8C6&gakx$qN*k2pGV|I?|Db%Vtm)Y?B&x z4vsFIk&zeE^eiCmJz$U6+1c#|%MEyBOldhR;_p~%YDTPG*7m-O1PW_)p&SiM%3j<= zGWa}t1{j8rX(#UKyKKJ5MqHEG9zkrd8V7*6v&*&gRJI|B|6V2JizDu=({1+ZE5fNQ zq2dqhAw&oC{6GOkjA1brjaY1d9l;8)rekZ9MpGYYS%J>@t9?ogRD=Xoi#=grzes5^_- zVlXMvSSv$YrR1({{+(d65blU}j*~pPd6w-dDiCPyu33^;%x#eXD~I+bel2zm4GC$- zs;6t-Fl1SbTWH5u7_pHKatyhBWzHxMQtdM=_grNbcefn$?}1J6F!yS}(bh5#*Cp{~ ztIpHTXIJeZFUUi`a9x~eg=DjsuF3_sE&5$tPe3{ZfcG`SbQ)O^_wh za43d#O>CZsFH^al(EPf_j6oB8rftGvWDB2oJ~d1^8kR1?Lmi@q6s5zCn*jUxNj;m3 zMQF{+!qRk~)_T4ChRJCN?lwm0f0(V#0t7J{5VZOB?c4fWQk!P?+bBcyM9+?>hn&tT z9ZOA)C3GieI4t7tE&`)f*8o<|X$$7B#ikz&Ihk4BkpwSt+S8+rGZde`;Idaff=OV` zv?S4B`{xtLMu*L{DjmYKq8?kjVWIX6Cu>I-vS|eTaHp?xKotRb``cz!z zQlWSeH^Wqu#e3pt{h-{$mt-Ya&RKYpyG#E>Tav%#qjr|wal4H`CSU%kV`2jwGTAvd z?N>jVNr8FtE?#WAZe{|}6*GqD=$7F%d)v|DtHocA5($2kNqb1sQx*4j^z`&dps!P@ ztez?wrlX@H!QN!NV57{k)^jwxMNWa4_jPHh*iwIJPTbe8`nL807{V&OCp6}?+L?{6 zrd~oiO#zk9AsO05%l%4OVp9WUdUSgM&JM4_bDGzgYV{v7y{=yEI|s>#sHiBTu-*C9 z`wor?IV20&0AOpbZ`NQvy{$7Y`nKO-Wwr>C=TM3bqq(*|eDK+n0~qiDJ`!x&kwcSx zPs$R6+@wVZU8lN-jwiRsT`Qn`U~)Yo;LE6>mW1qsoe8|}oJ7NGB=h z%>M89U=hG_IR3#;Ey*Xn_bY3kj?u(jN5h@k?K1b&N1{@g=xHh0lKGra6*X0H07)}# znpem3=0JO-M~hOlJt9RUK2n^bC~###4EjHD%>Ud>8H4{T%`{ZldlKn^UEsAFIUQoJ zC{8a`G;%(Xp08Nnc8cTWdL+Us_)zH;*qq$^gc_JdCjJPxbj}W^P_@$s+2+mipuhlK&0Z_Z9G@IwXaK9r-c%_QtD_eS!!o?_&w5 z20BV$d8#K=yYw={ojGz2ykVs;MJQd7 zDy$|oRn@#!(pTpe#~pHu5Eh>BIn7SJ3>gIda_wL=xRB)#{`ocpaX#^^@x?hQ_9uXVeYV#x zv@-zHU^*k`{f!-V)PiU)nBoHaK}GNQf)z-=Yq9?8t^kpZ)Uc3m)~!`ICnV$o!#SW7 z$RK!iW4F+^gJ1mq zhL!@QcAvZQ_(j(l8~$D{^_0)0Zq7MJ^2?CyF=ymE2|_Dw9m3Ys?{jl=0|s2Tz$OY$ z?@3x6&p9!oW&CYB83eIEaCQB|BT8`FO}L7pHn0^fQ4H6w{NcVbbKD`X5NIz6iCBFZ+u4QVQH%CR$TxhoZ;0SI_ZEi5 zKcKSM^tP#Y-hx(~!CT-C$2{;V30F-UbpT{-T(Qs8&98kyK^~xM;%gHLY};A|eqe9* zD$V+m0@w6|LA`M(<_yMX7xjeKXS_-n!tDSjeR+Es)mqmUCv@VX;|hiLiTeW-8;mk! zmgvMq*yEIJ}w$K83fogqP zg5G+@;Fd@ieagp`-CMqyK7W#dm^yEAxU6g_uL80Ye;?)kjA}xPy%1uYOc#=q=VX$V3B|Kkz#~QM*g`@X4SMS`(ep% z4avn5$RK7Q8k^taxPv@uf3Ck5BVYf9V0T-?emn@7C{2Xf{ykUQ$TRcj`c{uM9gx&< zYibYHB4fmbSf1Yv_xUeIm8)p|->vqcMf(5Gj{SS)d}tAN;-9Uq*oUmPe|GHK+HIKd zyEonTuOaqS`qT``SogBOCEs*XnW-<`VZjL_@(3aCdO`P@wfDNmZN8q@GN1OZb!~O) z?XJ8h%Wa0e*DqKG>*;;csdm{hj*L*nzW;gD|88StbUyR|rZ=j0wk^ z$AGK62dEgRYPc##rP*BL68adW$8~LR=WjeO0%#M&We#cgHZ~b+&OYG#e*E|`nHs^P$A0>>EhJYSh(zl9 z_wS%n(g6{ch|w`2tC88FoWgWmdGELy&=P@I?8-`R)k{ymOwmd01INxQQ-sh%y1ld( z!)yI`3xs0g z)T(b4q7-8;dd*c*3>1eX3kV3nA8tA5W1b%9)}EDmp>+Fm*=#?n@4(8$a|#wuFN9+d%y;clTRg+8~Uo@!>IDYAFEm5@-h2YsOhTswj-)H(utZ_!b zvDpM`HOE|9@y!KM38x-ADEmkymaVLqn(#725F-&6!lD)03)HY~l&4>=DD~tndVOU` z3|1G$8@N5k8lwcT_=$LbZ&2R(O!Le*UAY_n^5xq7=WB`Wugi7^hSyZt;w1I9-;JZ` zvrcmM)#FWjdzype=c`U|_0tXzwPD6%TzBt3`nod|VM=S%5Vk~KK3JU3;C=fyrbWe! zsA_kX51!Y2C%oc#1ZKRjGxxsTrx>BwBrYiV4zGy?%?q*d@MJ;&v=&nv0jj({4Q~G0 zNzk4_WT5(#?b4-792^!9?*LP<|NAZLv4{ED#G6|F+_52H=*ye1dO%fK>PEjb(JDn+ zhnQ~dl~lS8!6MFUtSnL8^cbfe07$fRMWnaMz{diFsT{ZMISYEAe&kYg6TC$U7F%O) zJ`W`wcTgORw9T1%7716g&xV45LW73B1v{rCI10M1jqfhhSHH0iG9Kcn(YopSQ;ygm zEyXyA{`J%@BaA_82>p?CmZtNa-wth4J0?B5E*9-LOQA)YlR)~{4y0v9+PoO3e+22> z{5diLN2yA5P3OZBiQCAQ{zUI!=>maZdd2PI0pX*KZ*aw9FCRX92q9sth$E{s@Q$}@ znFri|W#AuU_ax9##^f&8}~8-*R)EfgFby0<3maU_j~YTKT84L4X2ZPm02lPtjGAE z7trYd=t&wbgH-8J3h|u7w~CRG5!n5KN6&p2&s=Bc7#y;Bez6lkgP+-6pkK@Jf&}m8 zng6221F*0tnBH5x@%~nEA7lq|AM*L6T5&^3e5PoCqcT^$&-ZBiNO&$nBBf`biP-GYs z7YE3DASfnm`U;ws34T+hpn}NJ46cFjI42!IEAgT_SCLl<&xL^8#ZD`UFNN7`j=i$C z;lq5INEtqc41H!Ky75ljaIbZ?v#%vC>Wv-%AFwY$n`&@x=n=tvLk$RlS0awLfOj?H z0LJM2j4bxkOPh?!lR!(&N$6J$VCPR8uYMf9yzNrFX#mDh}q@_BGquY*Ba+$qvgtCGf>*IOsJvN(w~o8Sf1{qe^+LH%({$t^X)oQm!W?Q zqqK+Dh3U+~fl1(EJKu+eg#lMx$C(Y7*c!TuQz;dH_)DrHztC$Lj#3G zq+vr1_)&JR6V`TIOpwK1P$SI&x@j#}7XhH4w{s0}c_5~sXJkyrJ?QcH@HmrrGe@RV zNxq(i>7mbeZfrZ@TLY&XZ8b zv2;Lqb48-;;|1~>TZ}YnTq&=bCGoq-+jPIXi6HoT?Hi|Va?v3;9>|@;<|C`LMQ?u8 zA@P4<{rQ(I_aVsU;oU&S@z#4|4V;<4(*B~vuaLLM&>JMaZMYkbzB}D}nzD2+4f~YH zEGP#ee`GYa`o8e=D;DiC^soghz4$X7fv4xn+?NCl?{{k)M}z$^LH6d&n;`b4rlHA4-@gfVGAH}AA!$$ddO;X^yP?#3*IC0iS9UG+zn8JV zxJns{fl`{Eo>Kbi)vH!kR!EXu^ija$5t(om5^%#KvY#D@WV~FWz|&^gXAh!&@ZQGfIt#zvc&&c1_8~ zA>oLx)J}!lhstJW9d?(^w({y|HCUU**hC+QqYx;191UV3$k`no9VdaKYbc+uW_)vo z?w*AUSQbPARs=RFG$;>GGFjitc%LWd-nBnr*%{2-*v6))!?(+K?-tDaB^|!hH|H%W z@+ueBk|;zteph-~O@hCAJ-4$z6l0xT)^N>oH$_D z5|KX(3boXG?mAqblbW^2X_y?f=FAIexk6)#y&jD@Y2)$K&+r)rkfZb7hf8Bn|!6^r}u6 zxwPs#kSt^8_5WH_PsKP`dfbmr^!T)kt4H1LE-vBxC!^;kj35CeS1J{9A(s_WleqcMFo)j_}(ct z&Un^PX&L{cTA=-gzZd@nIK|)MlwyxcQzf0?jQ=ji$~aO_Mr4e4jZ*zLmns-ff>u0= zjZ^ni5i2|wZXx4{#=BfGhiKIp1nSho2^8>^@xwhQwEo&s|J|j5Jx`yd)Uv6xvR2c= zO>oO!TgNx$MSYO`+q5wBlV>R+*xKF2PJf`c|L)Qp41}}{`_pH1=d$1?I_BPuNew~zr)#K4^3d@EoYlFA9= z#LC%xDn~Ml=?`Z2-(4y_Oiim2EFT{)6Tk&G!7ch^tvv$cshkTx8LFUh7KTv8kvaH) z)97Do;lH_brm`@7&ylEeG~r(oSVM=NPPXQL*u1<;Q&o0l#RqAwMvm5QE~-S8=l-IJ zk=*qxkf*;M(Q&JJR#C_$NOKA48AG;;%-Duyu$!Z~{$Y(p>0Ob0KNwJB2*`UPQP74i z9$78|HYzG4{MGQ7RAc?av|781j0JS0R}-`m>wYLZU1G=T>MGE1jZhZkwRWDS#D{N>%zN&uqkM^w zs)0+s2`G+5bF||Ps>c^YH3jI#6m*%MH9B4=?(_ULcay>1DfY3OK7$d%74fW6BjfpN z8V|;e*^EqlpF>ILD3y~OKsl@%#~Z#)TLBO9U+L3-@{;xGh``*jJViXurbl(75cKIl zVyr<3`GG%sp=&44(Wd#~8Y$ua#VFn-b$>dZyg9ZssH*{b#u{A&wNy`S$D3mcWX%q# zLh$wQO~G{w9;5P7?S6~XoL+dj{RqPXveg~SFC_{B0s_vey}ef|k$CPpJEYdfctU}M z0@ClmQ6KyZ+r~swoc4yD09zluD5&amO7CT7>beP5d>K+H zkDU%6FCjxKm`Nn_2Mor5-U4g7yL68n)dkDXmtEp)5H;cw-vtR@?&n-_TDylGN5v0< z;$HIUG-wk#LG6#d_Tk}Pnnga%+ZhlPXH(PO$qE=e5Qxhr`iWu_-|t z-!Ti}6aX~~pJj`({l~#QLITm?O zJa@Z|O^Bkm$T7it9ms#JH1)GlSDJt}>fA3dA0Dm>MS^3^G3%8&pv@0QP@+wPJj-BW zyGxgW79h=8Amdus(J`x|!#;{yO4)zq;%~(~qL~B^sycunA>#E|Nz1h?b$qSvB1mLPL5u*3 z3RYYxKn(#NH~eiO*9UbS;grC(14@mYp>=h2YYM%7yAHpxgo$ck zNsBbon97!m2!sVuIDc^!#O~JEO-Lv}wsRdT7dpyR`t1Q_A`Kce+|#=N_L%^gYRksr z&n~6r;6osv<=gIbJX9G#B{2+%2oFKTN1AE$26tK^W3(IFpCgb(G++p*311QM1i;F4 z979@7u4=VAgsp$G_?l!vLB-6?_!AP{4C!572alC99y2mI9h+rPQje9B1ElHA8O+YZ zuSj0?^(BcdUxq%vk#Z8KuBmCLu!B$=5-uB?Daf>McGb;bD@B=i{=hxVRX^+r5q;~0 zs6-;di7+|sU0%3*nuafmDMjhN*g&nv;>1ZtJQOiv*2CnBODl#yaz*vsX4xSz|A~gM z-2-jQrAgz-dk|!Q{ECsTt5TlJJr*Z&qnj=W^x@{L7D~yYqoCL+$sCr zZ?N=1e#VQ7-1Ctpw|)eY_aU4pY5N?#vm9A8YAUzf>I%xEZ9zzjX2_x?5HXVm~BFruphS*BYOQHyTeX%Vz9>tFjosXd{hm_NVI z#`7?U8L%1q%?#4Yyel8W_388%XhwLpTX+K6U;n*2+mV*G)p@>UUv1P zLVO0E#SPE+vbZ{p{o(g={I^?_2CE5MDm}sl&;;B6mCyi*QC2S}~Wl+2FEf?UU( zUr2aH!h?P~&Xe%8^d6|0*kPr9fdxh4|HSmp`2aGm69PIJmItnI<}A%nb>Jfn-BXtt zrQ+=e%O{sZMV-e|AmO+9qfPgS2n0MzwGZ^-{9t{JyjMq|3?)RSD8o%LR_v^aw&CYw zB^Hf{knlR5!mq3z*8T7EOSS35(vBoe>$^cEqv`O2U6jw7N%0TGV$m29KyQ7SdcvZ| z^ZK~jZBIdZ`|juWDFf1+>)~G@g@PJcFeKlzDSjaGOlMPmry7(xAFjN znjHGtm9>>woTAe7r^tp6ux~t;CYL~oo^F&(kmxbn>Z>0I z2f}Ro3IdtM--Bm@pQe_UE<>nTcP|dPGu^B4mdF($*0oxEa?L++*v+)M(&QKn%ir+be>soBs{o~HG+Msexsr!=Eu)?vO-}uHp zn+X`>U+u3OAow-5@|FL(#jp%+%4*w*t^+#6K`1YFI-(O)8!v;sdLZBsqS=6 zmBmSONdJ57%-+=vwbR??+4gCc*??GdO-~R(B3%zFi~pL+ZE>Hv`3J1s)^fssxPCmm z@}9oD7=Veo$%4ieD8O4t-&>u|bZLm@^|DfgFU@U|W;c&PDGxj65rDp}Gd^)!x5G+5 z%gZ~%viZwzdRA7lhAzGY%{J~NU)jd?n@Q3!9#Q6ed`|qd<_+8nDQZ6ff##=~hqE!V=>#c%Qd?UHtb61TDtxiX+-k zk@o7m=iIkSsJ9mRhizWE8y*+OXCEhErfyB;)&%0ctk{=Gxw$YG7uOSl>?&dO)%n}3 zG$oXLrJsirwE8&i?!IxRt^asttu))#y5Yshjeu`DqxLqS(N{_ZR!tlDFQh637u*v< zvfCBfuVuP|v(bl*uVv_#B#EoLtHY=S)duDdbIL4QVuzlJ8ngd4E4SHo1yA-|s+k6P zn8J6nr>yHl0NP-Gyb`j$0LUd^HJT~Pl3pwKx#dQfkTNK-2#lGwRr(_duQfI{wo*WX z3HPApBkc$)$8+8JYt0=Erqzg+!)xP8=YHm3g*XX=0&s)lR)tPW%3P;)-UxwrrsJG6 z>45mEwgJJ6(}U zFK#7Z$6OK7SMZ4j6{JQ*8n}a@*<PdtEBQSc|*R)MF6jV;7oEx!GuR_W7kRqZKsvXzBrFVzsncumY+fRWhJ9mEKy4< zziaAnBUC+cYGrZLD{1(*{-T6v3vf~gw!T?X(4(ZaU*98h;c$zWVPT1++V4=Kz2yAl zRyZ0YCZ;z}g>t&l3;$|(QlK!k|K;lkb+xtKZiW@;b?Q6n+rEq%kb)@SoPmhA`o_ln zQ0JvURiEPwaZ}Fr0I2Hpzq`0?2 zk0YgT60WQ|x=lhjpt)HF5p2G_OSsw;)C#C$!57=c4Nmrg4oj!7_KkmOIgJW6t9VLT zBXK2`ZkNZ3UyHxbwMS`76RCPK?MC(d622K&u35yLhukxjKK>c%74r;6sp94>MZdmx zNng9xzl)Ntwjx4`f*W&>gA*7oOjkFEa_YFurVYwH-E?M-eT_H@UZhsK99ZwdlK@FQ;TE;LRK4c>b#MTVj^M@q zt7@UF9Kvnpu5(c)a*=gEXG<3pZA_;BW zm!P*pUD~QtijlEqVSb+;;MBhLpl*XgiX`v!LC=+*63fjYKbDay(0bbk7tfOR*wyOP zC3c$CLscuV-P?En!#O~C$69#>u(wF8JL$heO-(IXA&LOuFVy4cdd>cTGNaK{DD6vU z3GVPGuHZK}aAFeo=3bK$hT#AZZ2{c1Xvpq@lQM`ltv$Fa3UYFv%Xfc=czm(^;&CYR zqVuql|CZec#3)i91Qi|{n=mc~q?VkAS;Qf)!?jIK;S^~q?$mmoo~3J_x_|A@`FSiI z4?8>kv)2kl35%joefxAVGVz=s6gpZ%B}!BZ)T`~!%9a^#Sjn;E3nuuv+MIm9J`7Ak zB#RCR!dmDXtc+qi$}>$MrxR$F4LmmL_&9AH+rF~RBYMiI6HZ)6N={CPEf$$NIMF&= zX>32)4z{%|VcZLXPG%fmo7Zfrm|bT^lss3d{1milstMtk^L+N5efO}KPVf>8i~=O7RV5d%#R8P@5>^pXlr+;X>hqUbZu^I_u#l&ePfTs>M7&*#BlWT>>ydg zn-*9$N+l?s0^2-@_ICFBmTH-j3^6c<=6f}6j6LARJUj#XScJPFrFr*#fKs#Lmwz>| zY+LV#IhLP%OT{qI)@sF|415k$A*9Y4sk2;KnH@A@PPbJ@gYQngurjEU^bJnrjXEuFu2qax~HK;aug4wCh$Of zm!Hqf*QJ$d84vsW=<@;D;2;ftVrUDnir8W zE72Ek@3&&+DEY*FLphSNr|)#6ems;78ov{E`B)2wm842(J1A>rh zsdGeEOvjsL0_>hCg`x*I=dBmkxl9c?Ww33##zGKp66XA*g$-uj_!) zWN3JOF0PdDY%QS~xYL(nZca{4Iq&Oghd=!;Zs;(K>b1EA*7~(vXDZ;J&iBIyb zzr>L&^YF`b|M7GmI6Y{oGUa+)p6>28h^5u{b{idC=KHZ_0XQPirq3L{+z@uNQD=#= z!aMOb_yD7s?pEB`W}5YOQ_XGi)bS+^&zGP}fP%w6Q25<&U*V}OF4umj_3wf#zcvy#cR)H))ZN<*x^;VL$I0zMN)EC&mx1Z+BG-6}Z znXlikgfYs)8!kNId2-U?TqsDObmemBI?P*kZ~Nunv#ekRw4pc|1_AOLT;Z2;D@nTL zUfd}5=(#KF17E;$H{80a`539O&1m9%KfyX(h+bQsPWN(qv!)mXVYvLp3amOlSoUjb z0)PfiVkIqC9@kE?o;oGf5RlQ*5m&1Lr46V2M<5n3OiBS+ZZCaI8v40j#;iDx9TZW? zT&7FUk?VY550;+vc-KD`ikujTu0GGoYIYHhZLrWC;N|oMkDw3GlCECbwuB8Yv(TqG zB=3<8J4=T^4Mr$+8cdL-nw>4m2cdz)g^l&FGA+oYFUTcZdYw#;U5q)#>@-&Y%-}-3 z8g&O!glbi@Mq^YfGz0;gCRSYWZgDqM-mtT=ErMy}J(06PF4XX?92ofyx#!UyU*c;b z7ef#aGft~38@PJQ&2X;FQt#TJlcePneGovvfmgWlG@bQuI?OaJNJTVsuHCqSoEo)U zmOM~dD`O9VKhUOx@32syWVP+Yeh-e&Ff_mjK2p4ubOTujnM8tOVx||IOM@P(v)X5O zChQ~Qse)`ejtDLxz@_n1scX?#&Lo2A8hfE_-&>M|+`~9ZI=@@;E`xBoLF%T{ms=o- zkIdj*)B{y^E}S3)S&myN0Suz5k?2{zpqjO0h*>)zYLF}1KHFcS<^K87ub}7YK3b2J zA)=<&#;b<6iyp1((&ozEWpK2qp#cycD4RSrZfs{~mz|Zq7HY7ZF|#vxtk(O1WjjDJ zg?9aE8>b^rfPs}xB5CKZE?XI$r4G`sgfjP`R&BupZ^#%_bmcNm!e#MFSPDSX5jN^{ zhGm18^kDbHe4z7bA~@2K!Z^pX$nJZ{?R-(w!Qbxf@i*qE&S4wa@k zki=yp>e&;ww7e9ip41-)(t+OSe9?Bn-lv>|gm~iNp);6=v!*9^p_El;wYjCAa$W;| zh}(&E?t5bqYud!qeY{3(f~ZF1(4Wh47Txd-;8IMxv5r||mBgufFh+UergG^(9ru_#vCmCu~wY2QXHhM~zFBP*Y!ix(H~M0t|yGEnbi0LvdqQ-xwq z)tgojF@GeFG#t4Y8W9l@76v8zSy0FhM{YcNhw@FOCtMQ{Foqq4$(!^vKn%`nn zDpV`CxcCMH#}O3ZJ#laYz(2KnQCy(JT+|8&3@#`Xb=@6i4Pjcy6mq;KDE^`RfR>`QgKzsykn6L*i`VpixXDG{^@Jo)!o0 zW{ZkH69F+DoFb6;`gO&6Drl!5e}CgNT5r17$4%UQk-t2x6z(ytTlvSVfxKS&6(q?} zy7!+B3sb0XZa#QV13J4OiYq++?iIkzm-Bw~m$;gnzn!;+GiF#A8LwxdUd*F67tq^q zoWfrnqyUJl4afeYb^NKItUUMD09XLi{XW@nHpokS8iF~SHX^9n)|`_EtYU-_rA7M> zCJx2Fy1PQ-k-jKd&yPYWly7s;IQchWxdZjWbH}%|D}PggaN^b=swgdp>vxHOPn|F- zi}N3hDGC*8kJ8up2N#L@*K1LK-uX9Wi!vr0@JZkIHyQdLUi;TO$)y)t0{-7E{fmwL z_iKSo{@vBTiyi*7^e;O2->-%9#kRWoH--79rCXyzu6Fp>YyW!ZUmWURE&Z!J0QIle zLfOghuKq>h{?*dI=+S?__W$dXa^gdaa`z)HeniCv#g3>7hRLxPPwVH$wSMKBW|_I73u*(?R^B#AG-yomc>#2DS&|`@y}L}*SCn&)2I}E@2ysWB_Ox{*a4un zhg)|2se}0stspPJ=r_sp7|IiBZ3Tak`Rz+^L3MspR0em>b z&2>1KA)Ll!Y@Y(~Sjep~3&HmyqRyTc;Z+HalFzERIk^YgLteKQI*wG=R!>pC$8tHG z`%|ofT=C&*5vXT*J|U4+QT2CGPN;oFxCK=G$=+en`gUupUmjZ7 zHp!deCtjadcc^gn49&xe$e6*87ht}-!A!)NZsp^=MXZB076Jz;sr>r0w`^ib$c!teS2V>hz>yW^6=bvmR4_+p`8u>xs4?e zjSRXEyzOCHBvi$5ojfVoISH^Ma=Z~Fr_eYrkWZ&20PKO+Z-dZ$nh4Q&EwiXI(3TDM z0-nDddIVi2)tAPh=AHcBEY+lXr{j}n_xf&U4AU%Vq8B;ygUT1%(9~20CvPM+!l?}a zXfs{9I|Zsw`88j7_vM@Slc6E7^t%Te+?S`$UV_t6zt4MyD<6V`ZB14lKDY%3z~)#$ z!W2UTCtDIxv@TaW;RJ2nRtvJY{yZpHn7f%%IZzOaRF=U^jRUt;_%8?d624(rjn}tg^5AE zs3k(D8W&o|OWc6pEL?2i)BN?*Cpe8A82tM+u^(`iY)cB=0>i-xU?+t?ruC&HoynE* zU7b)!dK300^w;?sFR>0?CuIc%L1*f1+rM97)v{xdw&BY7&}|eF8e0Ftv|wt=S?|V- z3hykrS{1*Vwz2GT9ZEqq9-u+n3$l+I3J4TqH^ly4^S@32!;EVK>OVX^!oe+-48LW< zuL|l6E4Z+_N=fQwSgAW#bIyOFrSTH5%!)%VXQ(Atbnjkk$QvBVfRdP9{dX*!X=htc zYd`3{)F7#OSix_BL2wTV{V%!xWAFwC0{$cP@4kjTSEaBKwUB=hmPbLY}~07R~^ z+aoFd21~HDd{W!wV8Y>3ZJyCjB$^}$2p`Z~$}A3C$VcNGN1lHz=iiILbvKs};+5^& zJ7A75amCi!P2$oLmeXN}N5{teI7HHcO*A+t{QWT(FYH64!riRiP$KnMiT9ZKPHKB_ zw+FmN>A~c>QTK>XAk9H)eD&X##-BqZV=l0rx_Wi3{NMYoij)3OW79(eXuiJv+yA)~ z4Di$9RH76t|Hse&*{7=>?VgAd1WEh#7|9lh}L6PgQTobt2FR(}^Ke)$z5f9{j(w#Ud7(&Z9!?`%*M zWMI!g$K@=w_;nEuxo>Z#Fn$T-p1$kgpG3lrIH`Q~8=Mcpr278nhn5M1RC5BNFqk93 zV>pPqh;d~$9KIIqCeS{e=Z4dxB?5Cp_M{;0^YF=z5h5s9+jjj-;o=QlM6AzssgK}( z1I~{`%@1V=@#$`Oky$4R2FzLL~@r8Mo zd!bJge${0Hb?eIsq)tH?a`;dxdi6n6G+ACasBXFpJ4+9oEARsPBzmh=B{FxO`0{!U zkCICGp`xBJytk+enfD$%a*?;o>^*SsDWV3%Oiir!wuoxyGB5W15JNn>k9RW3Ut#yU zBVgEzRotN=k1*-ZGc8~o@4-?*DpHNk8MIwOyLQP>5P0MS8vZ99`$tC7I)hYcxNXwK z5S?1HthJ5bbU-fv7hLw-$d1pOu^7Hc|-M7_8<~VIhw?av1w#a1MS4%_W0Fb}gMr zW5B}7V2&U*UUSN{$k=~AZZUi@GdIVMw!{0joSec6L%v_6V?zH`VmDde=|QVXG2R-% ztm6?M8u3FTkOobp+=rjJ!pT!V5I-NRmQ4^rD2sS@wKHuIhcUNr}Q6)0AhUZ-B z3yk37j5q4M0I`lp4ziE_wdStH!MoGlx{BEvAbyK|a~>A{@K&g_3w-n_RS0TKp2S?p z7lzG+vLDHdIJ$#K1&)iA!u)}L3AAA!56|bR2)M`)n5eVtVwM>C!GD?{u}3q>0UKg= zgdeTE*a_>ARVP$cL)E^=D+Y-R@5V{Qo~|XFxRN<;-@A9>#EDvsHT}u{kd1um_zG@- z=KXzo=`ue~#mX$tF?2hFfdMsTxPOYzjrEl4kPwlx(GsJ~Kn^>VJK4g1^iop9YqYCF z53o0tKH?C`1Ch$~Uppn1{c!TZA~+{G+?~JnNbhnd$$(}W*K(lR4jcl&@ZR|ji8dBR zF}(TW>{phdMhJsWOgOu4y8v>Lqo%9DS41q(cI5oFKAV#<1z~X%zk(=}-TUS}5I^C@ zIOT(r47SL#qnG=Wu8<1P!Di5L_pAKoW5pLlqW;{XzUv-JuJ$NsL%ftR>=hnsIW>Oc zGZwHZjb9_a5Ka}Htq7gxX9#cnYvWSPR~T71l5!*C+PXXLjP_tZ4v#+3YKZdpcc4-( zH$bKHmV;2?)x^Nr3Y~c!9<@$8C>JjY{CQu5CnRde*OhyCEaONbp3a4s)^Dz=Xj*Xp z0CTVbIv?;E@ph2;21Z62VZsHtQH9G1JVE@51E>{&Jj#$IL4ird(MB>nhb)2fYG(k> zq{jXCkh?jKg6;GcyecGcJ9v1|mM-jC>kW61L)LXFwmA6)EMgF$I$BP@0uz-uDsPKU zbdDnV=D zbPeJaRxkG*wFm8oP}?DQ!{bX?oBrw*hAq^-FqIw}1rLhg3H7_~#}}N3*${P5usY;`~}Z?46vfr_;&(pk#3gr`5$7@8mf5_GFZw^w6A1Dd8-o)Q}L zt8bg~+w6uN&HPHH+l&7`xZk3EKdh!d&I`SH=&FMIQB$7za0gN(WN_Fb7bVC53N0-x zy1Tl<@_t|w0*T2GP2DAl^rTC?zt2yt1mPL^h}!q=4>Bgd2C+9*QIZ#)Uq!#e8CD-D z&P8PB?fmPm2spf8(Y6I~}Z`KRdZ24~D#VoX<=Vth|4n}sH7pCsg z7HZ!l6iN7(NDDOaHVB^#Tnm@h5&`uHj^=1q9(4%FR4YhYNFP0&dkAGNG?`NoFa&D? zWu&{D^{2egvOkFqgYSpR5q!WK8}jN}fM{T$L7K3IlpL4jj_*TFIR|~clW|){iMxG^ zI@^715jn4YP1`eirVsk{#=FQ;uK)U}Lph6B0Mc^*?px5eKQqW;oYXPtDuadP;Un$H zElKC#74iGzEl)_N0>l zToP$4k_$^1^@WEYJ@d&IiufdHWdB{ zMBv=x8cQcRZiA zb+h`etjLlV5^)qo#P|#d1iNR@!zD^ZAwXcS(VP{SfqQURaBvM6G}vuGWT5Mmdprk_ zA5}+5B%a${)UU{j8c*O%n7e{$jUx~wjpH8h7zkzz&8tR)LJwZb*MgBoO%U~3%kUUH zYO|~=rD*F!9BJN68^~E2=Bb*=RVbjqwAnKwG1az+nkbCB7nvCm|7@oXFB49b$a{bU zlQCBV^F>tOF8uR^7_nyp!fAzNWdvW~Q#Jr^OEWX88Jvqp5fM)VnLq;t2lFllZQMf~ zO9Cd;9{~Pk zyh_Dq>3_T>eGVzyzMkGJCOg`WQuqQPf2D!Upz}5{YX)bKQ21hZU!1dg&`@b~*b$US zsCjdmsB00-ljqvmMfNO#F(5CZ82sbT>Clq|+!n_vi@1JPa{?*|QpkdC-g9o|sD>%_ zQgrO#wR8YTj1`{Py$PkJ)4nm zpTX(rJ-!D!CVX#Q6U9>zY6-v^NS&V`9cPwq6@M(GW5=IrKyL?3a}}=kd!HFQA^2B9hd@ z)=##qX(y@yA;%Yv*&Fs211!4Er)muEh6OW#D0)89g9#|QDr z?r~r=Rv(5;As=V56A#{x{?j(u+lB7aSrF~_%f{nCZxpi8g*(HbfO_mFV!o9nz@LoC zheQ-CgKIVC5eE}utWX9K1ta2Yi3}hsCuZxS-%e4uOOdaG#(5>@8Am^|-6a3G*eggZ z!T-8EEhCG(Jj(g!@=R1j1H^`jhw&{k~< zR8+LEyMRs}MWQuyrQiZA((3ooBpV`9gTuZrtE#ZLSi{eva5)}S%gM1@`ED>D4uFN~ z?>*|=yLu35=eVZV@pGAB=jwT-uz&m)qeO6w6&`V!*f zF~Fg12fb9r_q=rIr|ehn0E>D&8Qq7Y_ac#;#mV%KiAgjgSq zJ^dRVy2;M}@+ud1vz^q~qa@3kpjjgzICI+(PYMg&t+6K}UNiXiR;6UH04LbySML9i z5AN;o@Df)U-@(7vch8%F4GKi^$G~t?aVgtwKJ$M)*SPoY9#>Cuj}p&har~WM5 z?I7=0|4YA(xNmyHD=B1dQHgqd-NnwX|6= zC$VW7M;@tyj7i;>Zv!8Q)Rn>HVa-K^*zD|V7=C0A+T(UPbLJ<&*i137olsGVxzA=F z-R#OFG8E}LtOuT!lLacXCi{(_mM3H$@JZ4PXM^eq$zLN#vgNVGVJ{7jOZ_ z>7k-OrkkK~B@zz#?uoaTql6NCQQ4VE3n}J0Uc?%(*u-APKQ8M|o!E7amY z=G+{C7i*%B`!Xpd-?jegn2)5HXJEfx3o$H%@4>Apka3XWwy1^bX&*_ag4)tsPJ$Q= zhKEmIpMYVI_6CLDciAJmG?KUR*CW}tG~AfeiI2>pPANEh6`qo6pg>&yh}8y24O5KsZpiqG#Q4#HPL^w#=)&|QI zpbp`V1z`fNSt5wCM1ef<=-lNmjtd8wb}~i#t!!zGp+#`X{k`}dg1ZlDhP9^7iLpz# zzndwRoI+$3dm=#|z}OX)u;dc;Fn{l(RMVVqG0G&Xxwz*mqPGg|-G>pk$wJEoH8M#lJi{w*H6OLDLek5$6CkQVQ zl+G6vNzUX?-!kjardz0ql)j^*gLTW%9Y0WDg$){`XbW3EtnT@n$K&hK(D2dqjQEkg z%qFP78c(1g*)C5MVjq+fnhai^@%GlXKk5!gEVS8bk5IX<0YrfUZdcUG%aj7gFk1x@+5&I_ zO7;o4cjvq%r69rSDI4G3y~;butm0GAws$Pu!e+(?;AHxA(+qrtP(>xBg*+6{T~*E5 zeaG@5AQZugLuT=VDK9H$2F0lt0-CrP%WxB#nUqVX@bkts+!GaUVhzLg4S_0N(0OEfgI&uSHS@< zOL%hZWm)0a1FB8^f^e_M#E_SJQ+{Zk2(!*>?iwl9q`#l<<1g=#*I)T zUTi`?B7ApBX&;r;m8Kq$W6iF`Hkbkh71o!bdkUYVFSmiQex< zxzs?&y_OiI0C>igt2MEUho>*9`By{#H{oN}Mq#_JE8}%0)qDQ2sd{fJhV_;%%Gq@q&e{!p#7#{4XNv>f%p?k*?$s2qQLsc%gcS)R+Bd4B4J-U{vpiL0ZXpsuaktpRttAGlj8CE0dg=)NX^V7Ahug zko|T{T;L_gz`NSuuYyS}J%~7lp)*N6#u+*ujJhd^*5?$EDWHw0zoNjB*jrB@6ss&S ztm54I*3apr!*)S`8hSSj4BmDR2QkCM)~iJuJTsu@Ri0N`!l!h7%*#?hVlJkVJbH}IMHbU zVL#n~$@6qIe)pkf;wFlq{n{A44qdWfF-raKDVz4;HP?*Rm^{6hw%gBC5JD0mvc!Va zUMT)BrhuaU$Yfk?Tr|Hib^o>3N5`1U^^?D?`;aD(_mBOOeX|H2@t@Elekj=fo&P;` z!XPZLxbeH&6B=r3VqC_vAjCAsix<&*YR5Cg|F|#Ry2(&tBTXUZ0)$=!)4%ssk`n@7 z=Gh#R^sUy=&^5_~Y!c#&Jgu*pUpm+E4EM-9sHW*z)atopKh^ZTvngWGUKQOcPDGVc z#=QzBPH36KLppd?531|Xtvh@`b|sp{R95fBJk1UI@Ps*Wy%*6wS2b6-xk4Tnx_rJZbh@jH2oO`N?XkSe*XV{4`T^ngP;|X zPdRrb|JjC-K-wURh7!JD*g|*>ZlDzbE0_RP%t}K>Pky5l6U6lMG*&NCNPZK5cOi#d}d1 z@)7HiL~^PLE=Rr%0+;$&wRYT_qfO?0@xG8pjPZ*(iOPC5fmMWr5;n-z$HMP-{L)NQK5=!W zSNa^3aQGv+{Mpd_3U+6U0!lk1-R9PXvRHr;xy~E<;-T19Np6hwIZ? zPhV*^w}}h0rt&|yaJ|a4i)K*6h-`g~F;w^d<(BhA__Bd?9Mwg5Zn|N8WY~%q?mEx( zF(Nw24$KvE^+eL5`xodTY*H<1NrgP4{e#{$VxDXsATH5s^M{wz0zPV3D47RBpM3Z3 zO6sB^mff!|({#9V7EPG~bVx;3s@CZ;a?ri<5^!{nq{`CL1-?=VzMCIrz%aE|Y zyA^EB<4o}qM2m?2b>K4~hPD-Pf=xh$cv2 zEc0J7oq>^LpR4r#2Jtss+y3Vx{Ld=P?~VVB_d@EjXD0I>lEGnTyhNdWJLC8rBXaBp zVTVPzHR5eMTq^O$Mx#AX<4t3h<@U-7xLjByTycl}$SvZhax0&Ol9w_w$r9hFyu+UD zVbt_Nk@!aMJ;&U52`gP*DdHRWTW0c4#*@T1WbK0oc&L&EQmg5&{gcry8t=A-{@QVd z0)4ET*Wa%A`!i&F1kqocy~{Aj8qwx5}s zjtkiR`P_0jMpc}L_}0Y`Z;QWW#E0KS{6ew9#p8jr=>PaDsXzZe>sQKJ!ket7{=Lap zJhz6+rBeCY7iO|tR?7zapBc=%Ums+$cqjRm{t#PytJxb7-cqeDZ>Nwoys)IdHwpJL z&020&TTR|hEWfwVEU~O?>;HZ!id4ZA%F;Ih zu{#*m1^WD=fkV+Z!gkFxzmkHfhC_VhSo*dj9$uJ>`k(jd&E)=1Z`6fXK;b&SVPag| z4DIGDdpbL>!BGJS1dMPG@qK+GN!eb-{_Jq>>PE3e$;S490{`S7@2_EdJ;jl;h5N(% zwoW0{p&*)XFZnBKmjqH#;hMwLLg(PSWcPT9{Pr=A7v-BcdQ~gN*O!UJoYfNDM-(%E z^*T8lQHn!HgT_;hK;5UWU+a@k?ZUjDy0!f*K+&~H>j9dfjf3u~@Z5!yR=Tt|?b~v| z9s=Hde_?Jo?>&H5GB>u_*Y3zTU#qnD^)eL|m18dCB&T+yr83=MxeTR=TDvd@Z%#Tm zoaeM6OD&Hv5d09C^hN=({bFLx>g|Q$MsC9EE@O0QIcABtIceCh-W}U4=&QgO&=8Gb zSIb!J*<;@TdkXId(nJt3#P=R`l;68MCxeu?@V)dW#sfnjs{KH|YrbvZz}p9p;kRS1 zQ1Xs6=e|4zZsdek)NqBM$%pG`-~H&&TFSx!INQtJPbJ^H9Hgs(r0J~k<<~P((E~`9 zJj7IFIUOc>#wq5z^7Obe{Mv$F`w3-3_b$np>oQ7F3j}0%QDTL&19Hi-tkVmQ~;{^UduvbcI?`&3B>hT~hdX~?{7Kk@W}nNV+sxTt4Pvswx!*^pDv z=1soZ^(=P#m5ji^w*onCHzjxZ@-J)aW3BBn&+kj81wRHoWaXLV1gFt<26QCUCGm&Y zFQ_t>q7+e9Cd80VM28is){fuee#b_AZ=9hn}uv1 z4#Lwda=Q3(H;1BMK7(bOA9bwdI9b0UiE&AC13302JQ_LRsI1=K!zz>MKD73WzLPdZ zdXLdUk7Blaz&1%qfEm(>PJGMoPHE4Gw!mXSuM-k(6T4AFGD`UH)R9n)CGT6M3evl8 z8W97C`4eybQm7xe@O)GDHhUk>gu?8~H;EEA2-Ball!CH1ucGU9lT|ObP|_ZMq&_oV zRHW1l^mrx3!nr_QE#w;RicIVJkZLoR^A(D@vhg39e`Ogjtz@=8b8q{ra-+6q+rMw> z<(u>dc>K*^a#Fi;!F}!g^9=b)q^Xr&CpU)kCrZGLDtR;bgc46#7XlFf@_FJ8SWF?j z>Sr*Cy>VL(Kz+hHT4HWmHM0kdjGtN{=5(q|pQ_6p?ITR0oU_p6Hdgl)&IJXpAA zL;tY1{^i9TL3Z zD9;gd2Qh6Bh2k12Cj=EQ_P9sUJdCYcN)ug^D^$TIqPrWRVpT~;I=!jxTcaU=25 zrTvB>a!z%R^IY8aj3<|IQB5B?0>A2353anFKg9Hf&DJzyh3ukbO2)rRd<0oaLlWImUX?Is zrX606KG`YNbddiwhD4lywV?~r45Mzn*^)9KRH;;W(QqBXkevoYc8~FsX2aj1d9$)kwA9MTLe%L2Zo)vTO-d9{t{G^OBMgdh)(?20FDck0}SBvnbyr-lM) z%XIif_fq%60Ulj>L?-30brPH3*!~dmTAUX~mWSW9%%+^2y4NekPx`lk8)OLl0UR>) zweo`EG#I_8OR^$$UE%kI6`27sE9Y8A4gFBQ7cJUCzS^~{HdTfg`aQ=FFwIOaAU~#U z&uD`c_8d;;6bv9c1Hrr~ayw^c**u+EpQsVlzp{$+_43J7cQ+-1oD{)T{SzRch#!MwYw4|gwU51+tc8#wOc$#AxW9;*h*{_fFA3K2d<~LY#GlR62J&hl&*lshf3Vv_0 zbLCM}t9>shWFNk9(%7R`1@?WQ!{B;1^UKiCdkKnEwPOLnVvU4RCMes5>$G>qA$Ft< z!ffOD8)8Vvs3l^PSzFM@5+^V&uvD#hf{2DGp9z1IRt!BC*>UqO20GVefaV!2wsq}( zG}f!^_6Ep{pUmA72Npwr23mguQHcl!mZ0p4j=Xz?IVBBw&cDwL-R$3afCEDuQo%n< zCp7cygB!%>W-tCWj*U{u2d2dzxpa!`lhqd8SPS-x;qwPBp;Yt|CN_oH4ye&4w)@4! zm-Ff)|JZVrk#*}}AngVJrHAzc>dD$8RUc%68@D?G*_Z_2)WSt=*T^Z0*gl=9G1l!@ zy2u#F6XQjxWGXCk2;0szkJ0tpDEDuBgqI2q$M!SvW2usw%hh}^jcqsSNkW320x6hJ zFa7FRchw8KJgO6Qj(g*^2uJNN;=%pJb?)Sa^h01MS5+_CF$(j^mcOM zWSvJZF`^E4q(Z$xw5yETOIVtg+!)E#1rcsiu~_f@$?}8qZq5-Q@O(*E51-;OIx}z_Ssw z3VOoj{;cL0A~AJ0p;&vm%`!W~XB)L${ZEiqqewp~Ntbt_k?b@Oy)Ykq ze62BX_h4!JcfW6XljV^%ncrAH0F%U+xp6)(zf)z!nffVO%0;d}(!QLVymCU%PB*04 z|7UX6aEXdJkZb)bw+(ZZG|@r!Yn7t*#2l7wVOerIpj>VslIDL83tgfGL)8ED{4KTx zs$_4#l#c*02nG44Bi7#cd`MfxzvleA%Nkz3W~I!enajRS{;VlYg_eiEOSI}uJfHRq|3W60u$9%O$xn50$$z7O`l#D#U0_OFk9TV z)_A&iVb)~i?X0SA8Pyq+kxIPUX+ha=wt=LEFO=w|zE!e|&2=Kf9JgQ5#*3PMByU`4zCUPBd1eqHFG+7WJO(|J!t8Ujfl*rO@^B}J(gNBo%*hp-LC$$ym0W< z2Is+LLQ9WYyGaH;nd;!2+sMv7@G6%7u6B$S10Jcx$LO^;Ev!{ZNt2P!@zg<7O#l6n zNdTq^<~U=x+^oS3shxZ-f1hKw&pxAfN&5guJe!>c&S~(vJW3*MPR?tcYPe5$hf=#a z|E~J=P1i=w!Q^XV>kV-4vsqVc*k|Y;b_u9Pv;Ct-kC4obNkFWsLqhm7;1TEy1V8od z5xzUjZ=Zgv;57M_<{?l6m8nQf z$TDXzG(IMX$@H;asikD9ZnSRL-RB4BCn+jV-02R1%z3`0kQgfKK*8U%;j^T5AM2)` zKM>4emrbMym^^{g_weDy(ALqHO)nN+DCpqW8$0g8!N9k7)QLCy_P0a&_I-YYD&$gM z)t(i;`(_5pjB~{wpcL)R6JkQRE|uAF57|etM!tGGFR!w=p3Sk@Uj=78^%-?flBT+D zdhfR3ZTiJqXK6Okz|abcBO1;Ep z!K-zZ86a}WmmRy*>pydXm`=V>HarXIj!`qj7+Y_XeOw*pfZDOIps6=*E< zq@1mARFR#>O-|j5>{A+35N>oL`F%_6hRhq_wX!ElkRJ?miUelLrgy9AGK#wbuI#z7 zMA&#&{ClGf`-Zqrkb1SOt)qU`rJt&TEe|CnyY0Ygj1+!5sA;bfAgQ29piX?^j-9?M z!~RG@i^x7NKz}8`@@Q1aT2Y)m_BE?8k&KHpF;G9!3fKv?PK|ZZv%^o1H~l*D_4cLQ ze)X|STl(|-_;?Q-StldAqC;+gpQ`RK5Mz9J=4IY6U+-b<^U=gH4$4i9c#eDnW{Fe(sF{(T<2jX+o z5EmA|AO?SOFU>-b$AuJl&hFM4p3R4>qW7tftST)l( zn-n69bbSMHgEoK-00KKfWvyD@D7?MK7QFZ<^oigoLhv3ErB+;ml~$^mI2F{B{;s5@ z0X2SoZ-Sp8D}&GM!S87gazH4q8Wpx}jCU0F_~voXs{m*>cgg86{%de&?J186wE$^3 z6Gb=3xgS)BlRaJQ_wF8XS4&a~x+_dI(mW7|6BgVt8+u6std~`Md5WW`b6Z24gz2U! zsZg5W!?j8^f#DC7cRW{OpTY;h+3xLQwNJ^+RCT7GZBW2y6IpqeMI^gunIrd$ou9am z==*DLvz49mI@e|0eEvkHwpPR1!^zQRgR;f9{2@*QO#Bdc#bZW>cABRAvnEku>1SwMIL*Q-7m&PZ@&il7{QZIXk;yK-U7Nst9?|r!3r8 zjXFYR2lgl*$$Zwbd=w<9REemo-C;N`$x(q|5UA(P#)`40BaOrTb$(|_l@3+rIpIc~ zu4Dt2w&Pnu{=k21NtQc@4vh$vcVAJ2%K+Y%ZXRdb8k)XbV-O;xk0yCs30u(n=>7$w z_QGp`uO0jsmK;zVi$Qu!No&C|Kw4%@E&M@LZ*LYG=@}P^^e??-%d%zX_-5YP`Wd|a zYnz7H@eM;g3Cw%yr!Vff(q!qfmAbs-!-l7qt}EW76!Yt)6L?`Q`~89hu@98PY5E2; zH?l$=j~8TFK+b_qQ-#FRUS60iM1+Nb{Qr}Sk!9%{(^oCav^u}Z4V*Z~<+7zkjQNJa zb7Q|ia~gc61HiOT^7dGidc@JyN+!E~_Wr6B!ZK+|(C@v-O43k}v)onV&NV)8v3WQM z{=2esj;Hb|Erd~Vxst?r!{gD;(2$Lvtn=0ANryIVp0Wi@BVRr_A@(HQ0%XUaxY zYAw!-j6y)IWNKg|6J>OMRciB~vA^`Jv5t5fQ~cA3uFY+mo8P6t~EBuKJGnX;YNpLU!MxGbw3=ol4r7h-10 zoriY_+~23O4W3d%e|vt?zOBO9O78+AlWua`K*kr$pf!1Xhl( zv*+uc-dYZr)oC8jJ)Q)%MYnBMa~;~|PeDTXMaU`eB}F)C*}WZl4HAwC(D54^2!ko8`FtN-MHVyMhmw);|JP3=YL>v z6jAN*8wi1WNS#It2VLGh-~agoVMy&-ul77fgT8J8Lb^Cy?6SAl<#T|O4;-3!7p_8B zSjFJ)A*!eyX?l599&BecIDg|a4Ep?eqaea4KS|9LzT(3^Hz9cAia;OARV{{JPAW;X55r?|=3gTrB{&^WI zBrIT&LH7%}E6Ag5(ARulKEK?{SnwI%l^y~t!g(JObXz+2oZ-6KNn!i5AMqGiHu@1f zeJuBp<{boU>g50Y#s93w|AOuCq5YH`ePMks={6F(koI+@aD}q9pSy69v5ToOc4GUw zUs#mZySvwX`P#0V*eWl*OxNUQIGyL5rvtEwEy?9Gjcr=R$H3(zs1hq|=z`Ha@8Lv( zQL&IaOZ}8{=BaK3Vc^TKpxmn$U$4#NzH$Ej6LL@FR2-5n2^kvQ^WIgJ8)DrfPxaPa zf=W$c@CbwshK7b{YZ^|#+2`o~{l5VZG)6+B{Q1k5trG5rTkhq0M>xTHfzL2oP4pGG zbi=~r3Ca7Lg`VcLKd(Reay|KcEokpe=!gO@rtrOs*fg-5dhT`dgmT&i17?{~#1+gj zSig2{ocjZQaq;)ihfWB4ndCsA`j`GC4HH8D(!a>1FB)f*b#@lbUXttb@;1=rjU~a# zpVsEZ##T4JBAod#_7Z&4nzGfWp&LNAQ-=f^B7}_Tjoye=162tn=w}opG>%#&>`=j6 zYYWdEzd%-Wi{ixvciFY&~Pu&8)L}Mwbj{xF^4u)SHE9KrV@!>*iZYjIU zav4Vuxr2>FVz3l0SzFkr0a?eJCY`y{0y{3ftVg$2evuP7c;I2^6V8%%haE$Imo4`a zr_<;q?{7gTO-phJSTlWs%#67uO2rH@b|D*ovGWJGh4oiNq@ztFwST7K^OOGA;s(8X4m++wa z)Nyrbmihbv^Qo@DW_M3z4h%A0B)vYSpRAq?5S{WELe-$h(6t==^|doI;6TV#_Hb06 zJJ`1=i3a1rgYb{x?zNVO#(E!JJmb#Q@&2pOh^-RWp4;e+LKTG;2aGr`fx!;DCa7@4 zFLvJzl7EA}E;^hoow9TLcEWKUTbj_+o9BfUn%D%N-R`A$j`H%GfkQ=9hrc8&G9U#t z`NFcP-E3jJV<%=zT#3?JMod%qO7?Moj#6Xys}&-9@+&*e=eK#0dz|;a%aU%HFnFY< z?^Gb0w&~@1nN3q?O*h9=Rnr2WY(2aGlyewE5qIcPIh*HaNE;LwZEMpzam0$tz#iSm zoBq|`B!~Or&aggl4AO3_Bo3vFie>_{lrVQE#Oj1-hI7$8emtl4@Nvo_71A=Od+#-FXPTLZN{?x$ zhFK5U_rs{D*(Wc4MjqkE{OSZmu zOV|r@g;Q^f&_L+yB_?}!2As_F00^Y(OKfeS0}q3{1Bos@x#x&k#V$hL#r3d{b@};p zjNO_(Fp>pW)m?=`IHZu@?)?a`qIbR9_J?>7)PkXvCLVl0M@@_z_;0PEx-Q0 zMM?QF@C8>5J0QDa{e}%!zHYg~r~877cdb&1U3Tx`Y(HUj4_{`7&B1|z+pcJ{if!J| zas)5{+tTdgRasirfv?ij%~F12*4*acd?9JiUOBL!1A~Gxzx?{6xe>TN6>JmJz|OHE zq~(M6qmj0A>VMr^IX^DeYrHJiD_9-XUyza{ZuNXbg*ESM(;_1u+b)D|}S zv3A(~&^=lN{FFD-JknVKO3byb8s)vk5f(_*VT2U2jt{n9|5)lwA?Cn^oG6+BK8Egx zFYYai74{Ld7q4zFGUV#R?kv(c*>DgVa<2p5EfiKYt8MZ?1W1;)ZOrIHb5F5oeRytu zj7cA}VfHl3aD>&)GeEX-=hg#`sN+*F2|Smlzesa4tpnAZTMazvTYY}n{nzh1+XUwX zGwwvO{+|cKbFxy8pK0Z*uIw>@AK`D9%5bct#*BY7)BntW5F275}&?SjKZhtyv zfH30GT(`W|q}SG0ng<6;HqWib> z2T-QzMYg5yk5@Hfff3+>#b8|5vce>hTk?{$z7*=En$R0CV%W{Zcp~nlnRRK)j~K50 zzt;H4NZUssxEXUsV>_$kB%HKUyC$CMq->qg)~W-Z+b~sX@BYf6{UhPwK-dUOqY&ME z#3A|(AqfDK8@jC-!xXCC6(k(}0A%@O=M6L^@ZX6WaFU|yf%x;kWljiR#yZ+dU(7FE zx}e5@T`Tv|%C_O5J^l|lV`Ex9by^zVeckO5_;h?X^X?crfERJB>hwIcnJ!<~Ls|Ov znjGVzIF7BGzA0RUu!1SFpQC3bg-(QC%unUU#G~B@Bj=jpQ8wa^Z)AOxtmjQPLp+HH z{~Izh(D? z5<7*8*9_V;0g_B=B*h&O+`I#l2416gQ)s5vdYhMoY}tS2M#ciYgT18EN{d=3D-ch2 z>{5w5b`|>(m3dKFnG+VsW;x_)dC3MRNzUxyY{|*V`&_RvI!rpT(eD!b{Jr+JEgiwf zb44Ks&DocwRo<9XfBvXb{NC*870deu(`Ec_-Kx~sD8AjxL!HHMrA2&Byu>j^)uDf` zdL>$4Kbk7$EThAJB;1<+cx~xQ_b;CxsrZF{*Lx zCU4Eq9=TL=Ci>~HS;6v?H8=H z2^Padudzzo!M`tH`>Rb??8mkyE(TF5J!VcsOBQW1m~Pdu_8xJHuU5-*dYb#;qotXk zU~zju{@p`)?`MBT{7D?z;w9h#v6`J-)b!(xvsqIhH<&8~`F4w^P9cPVT=p#X(O0rO z`nDiXad2|Z;eXjOx*e=oy~v}N_w3yHh3a#8(I%mVm!zu)#>8o59^ZVK1*yN`(os?g znIux>EW_(mEsny4imlrG${|&VlxlX{PVJpk&1yg{5i?Pkk&NyR=z|YbH5KGg?a{ zX%EF*UvKvNqgwirX?m_7Yun^wfn=nE!KkfEOETmv{SxhXul#6Ih(|3(Ktx2(CCG`e zYrOp&Ixf*`@zvYgPkKfc6}FY~_Q*3H{I|$;q|cAgE_sH!-&S)N;4$q#p-$2BegVoz zC|Mo}VRdyi>{KM+IfO=5NetlP9QpZGTS3sXB%q?5Bm|sD$-qTQY} zV-R{#9L7o_kUkj&4WY+J_el+oih>cG=n(hO@kduZWxo6u?Nd!`;_szmFw1n@>&8*D zeg~ruaTkUqC`LSZ%%c=MH5N6;(U!q=T=-C~_MH7v$ z9JF21n9rl|a@cUjmw@anpFZEG@H zdPU(-^_|*^yxH}MptXDl=I?aJvGOE_i!LcP3X3FB+OWph~_O;N071i_DsM;_*J z_7~5x?4ZVQKEP(mGA~)5$v@!pcKA(6HqX>MA-)sMjk*?shXxEhX9U7snc5hKiuH+y zfXmd+w&h3g@s~JTNPy1|$%njeTzm2UmOd;PA)Z?m6V4!nb{4(=Z`cXGZhQfPrY&NprGIkmJm%%L80U65tdoc9* zarxdmKy;fDCkRczPe!B4Qx#}EP?-PUZ#{(8og9iolm9dGyV%6&P#%?FcF!dYpIB}x zlB*h%-3SB$tu698a{7&1j?%D{`<^d&%Sc}|(~oGS3;((u>1h-qsBX6mjWW@VJ1hU& zFID`1|4W%1a9X!##Uu9r(m3I_v77G=6HPkbJ%5X>jt3LYI`dz#9P=%B{ApF!-#>kw z_fNwce=S3Ij-~%JS25wPI{&54@~8Wcmv;RB^rdG>m#q8>&ufIjOP+y2QTC+Fb14(g F{|h(hM>YTe literal 42458 zcmdSBXH-<%wk?coGb(}`6v09TM6#lQND&NxNR%KMR6sIFkerODfJl~%l5@^UBorAX z=bUp!3aa?VDs<~T=iK|g_TKyRTH9?y;aan17<0@qdhcVtm5~xXdieBVA|j%rVs~!K z5)ti#--+iB62o8gC5tGCh+K%oZr_l%>7MPgwftMBd&4Z_IsMt=67;vV4;>OGQWidb zyy78=+v4K$=fh{K6C%#i)kX}yiA0;c;5@8HKlx?P!zWKp`R_TSLqbM>?cKp=FVFsU zV(;Fsk8(Dy=aW92o|;ccS@oCN*jPCb8$WNUz9HMT!O=FAB8zm1Na$PQ&6CJ4L_+BO z$PX8rBghY;rBj3-!Qvmz&cq}pB~>z))01VNnVA_Y8QdN#9Zq%S@!x;{ zt=Ge2(tGXcw0swSGSZWhk}|IIfl zz4;bxb4f|8`7>Xh{k1aDM0@FhkeOM!5!w-(rM-6U*6SlQ*Pe{m2afC5S8qi?*}zl=8QNb#E^*Gs&gjGaj>O>m8lidvoPQS8Hy z(C%C$HSEnZ+ghKab>23c6Vuj&cfEh`!zg_?kts(OY4IZ-x8?f!wZM9(RznaM2`Op7 z)kNX0{$l&O;%Tc~lfLWL^GPN=ru{|Y0qj`De9NikooZ6rG}`R!zLm=GVq0qVv-LrC@DN zpFF9Us=^r9$Gk=ex7TQR^M0cm-VUy-1N;uE*M(==u58=R=%OD6A|mx2nsFH->I)9}Ra{ep@gDs|$<$ z&fVSp&6~~=$3i17B39B8o2AcoU*4Wz{QC84j8v%IkHv>wC6-gN*^b{o3omQtEAqeX z)ku5r?0)@djgL+ptZ;1Vx2Pj9)r4(|zO|FDHq)N$)6?zOFG}kukKY>c6g-Wag$d8r z;|D9tcU+%Ue%etVcx9|q(`j?8){kXO9pN&Xn++Q($o+*Jb(LC%Y zOYztlT8G6Oj~;#Q&Dm6AXS)~BZ{uo(d4Gn-xSM{+wkt#Df{snmZ%els{PGNTZnrbE zaI5C?2*xv@M_5?6C005hi&b@WfZMpcnzE}u+pr@o?NV1~hK~50ddt3j`@A3Ax_MLE zUS7TxEXjIpTA|Bnb@JZZ-i*uV&fU0~4I>kAKlMHB;Vs9@uU@?xsq*ltX7fzdD8~9S z$w|~A+YB-9FV-pQg%`*aw^$9wmdEO1r9v~D7dc9|y?uNlOG=cax9lIYu&WpN1_!Ik z$_{6N6D7QLh&{0Rrw2QG;@5BBq!522Q_CP%K)mZewQv6V?WYT_eNy9_vTI*NfggVT zw!7_JWPi8%jdN&4C$G$5_fZ#Ga3fbje?Io0sY)S1o{HC;{-@`38T9k@rC-X^sq?qo zw`)VsDR6T2tF-T;`5 zs)~xyiHXFW66|#1p+ko(Nq)9iJyh;W&0|vZWay@`_``<}A3b^mn`(;Y=c{h8{qCMB zv0F=U(t7;(vAn!5y)O2^?sq<0SZNaUbaPY9H;=}tpSU1#kd!pmZFjnk|G(`rGg)rAh&!eK~?GF8XclOmujX1g3#`a`Y4^-O2^4DOD%an#czcvH@5FHZ}Bp3re z;@r7&s}#H4C6)s}oT$VNp2zOUZc8^Z2jjka|F!#p_1Z7*h8!P+!|s<&5LOX6D3RSr zI1LeZKsYOZZBgWT!g2Y3p^>wT{%c@5ekbdAp?MotlpF<@9EE~e%3mWSBH_@5qPVol z*vAQ4)>v*=bFH#!1_pk2| zv0~!`6XM=koH?a=35$&8KQ%$+_%Ch#Kl%sa^;e&6%241W6!xgKf$?$hgmIi8;*s}Z zQEV93`uozY5ijazJX(GEWJXtFIZ=K6)S58)^7RXn$*igI@0?;1?yC50xn0Vuxr>eo z_)`DS;VlN4V;c!UIfXGk{V%EaYtEux!0J?JeLlGK5hgYf6QxOXBmYqp37CfR>IqL+ zjyITRCq1d?E?JyHnopZ#M71=@D=<}8T%7!?R`EF zzPl_B`R={8XaP7Pl$4af-_I>ve=;Zpr#tw{*4(0(uC9y>3^nVp%U}D)-%nOCH(&nx ziiE##lBYYhAqAe>TEfs83i^eGp(rS#oo>(fujT zb9~nGtKl|A@|j{Qg_cvpPrl}9`qi{@26|Zcnz`b|!W`F?ZC_hVG}=Zo2P^Bg+RGZl zk){xzaSvq$2aTs^#P^VZl$pN5T)bDtSPf%1Ylr;m9W)$qxj1(G*1(QQcb3?5$H$My z!LJlq_fvBjAdaEcxa)fir)rK7FPwBADzs)c^Yij-R>m6!99ATSN{FX*pRW~u;Fg~}`i^_Pq4A%pqed;?p@#lai6b==m5sEA1I>FaE4Y&<-Q{#)J(*Pl+) zTlYQfP(OP5s?82=0}d?e6DQ~dSyIo3sjLRiDwfV%)~c%3()Qb#%N?j)2j2>QtFD}r zlXHEphsP1SdWO#`k4jjA`EqxtZ@`BSi|OwRVq$BZMm%`-A31fli58+5I6@egI^!Pu zj1SmOgc^5eEr}?I$kjP;cPE*I+m2IhmW4axyG9hc>aR!k``ns}2{w7>;h~;u5*jr; zRu>@tCK>#oGk%4ZbltBeL|Q^(wkZ-D?Hb z{kEbgZfCrMG#gfO-7wRzD;{k=)f{bZ56fe24?zm*h|dMTQ*(_130(nfxnsK0A=f_9 z?sFxp)QfDe+89YM50VU8XtSApVi4FQLR+Ebn%Nk)L63j+^5q@vwj2kD(U;|}=r*oC z&j;M`w@I&(ij`m0D1r#~FvwGcZIyZU{onviR_tUPjHtehwH;kFvH6lMLq8FYTRbjc+k^ zTA}8uVU!Gt8cr{8gaB-FWrFn7B_Z;yzev$%1v`uFt=B$=@>$m&4Lme$>(8zZ0lu1X z3cF^B!*px>Uk8qMQ(jQWd|x7I3PKPVinsj12BvkJCTva-rqm4Mj% zas9`8GBObg?0YoAJe(Lf~JF&ZM;crG$=tSSMY82T_e)|MwCFh*^AgeE1gVUMY zwQnpfwO;pY!P6NH+cAoK@nckTjx<=Y;YEGP$M16)J#qC+il#RD<9n;*$(HG*(Mz{W zop4dnnYvB>0jIbgs+}@(EjMm0Fb`!>LlJz&%#sbn&ELO&2X7pPw#~rvH`}kzUfv8X zaC*hobk=hlvMK(INuGgJrw-kwu({@Fx4%=_=6iBYIRsfa&SAwB*cHdJ$XyG4mV>yB z?S8z5>`1?dLAqAFJJ_^@scXu*bStzQ->KzUQ#ak6ZP;c&2hPae!Htq_^eOfQslbn( z0esBU>?H9~s?kc>u+b)GeCEb*y6uYN3D4~KF-vZ4{bqVO-TIH0HQr&?GaH>M6tXjp z=h}KR$;DJ#xh|wy+3xiiFS12jU87Kst9|FZvo)}cPA$PEU>6{-pns-T%!TuG-j3{L zP*3PH>CH>engstJ5I)_R9%N064pBAG=sK;?zRM9U*8hPt%RQDHi-LtM)W$< z9xl@%yQ0ksw^DbGE>{}z?A!sz4fTAso!U9bS8Md{YSt8Rb8^Z)^|~LM6c+7_g47^; zsg9#G36ig3yEW~L)K~S+N}m@^DX5D=2gk%SdO3QW=DKTu;xP%a`*@GRBqQUlzj~d- zhhxe)MuEOT{{At2(eDBd(XGV7oSScjTK8Y>npffAb>t47=bUC)2|SC@hcLdCk+>HU zgLR*Zp_bDPG_(?^;6SaPhto^ODOm1bGcHja zNyoLCU#CvIHr!=nbaiG{R81kV;NC)XfQI*6q4mQ012lKg%J@>J1G7qMZSCQtv@{vY zC({+?;DCEf3g^->C^_q+v;svCICmRQ^)QJ0>j_?DDTM=2Z^sW!_e`13tj2CNe$S)6W(PJlKBj0JYWEID|IOh0kcchN!xGCC#z2^KwJ)?cH7)|L$LK!v+%$N zF!P#wOf4F?K}g!PHD4ScwNeQy(Lc$d;qKv)tdQuh;xI#{FpNL`k%fuLi|Jh5((v%3 zHK)Be1J!o{Yzb_f4(6&jfl9qX!(#1KBauFxR>ccfZfH!&tN1^z9ntBSZVqwszUH73 z894HyM$1CRHhT3j#3sOz()MrZ7+H*XHwDolbCrZz2lnDy1_;dfJHML&N02zh+% zOkhz}r$eDN6a(W*lQ+WDL){;p~wP2^fS$z8&U=RF;Y3 z*;(sIGDqWTd>HEzL-Dz-93?+KZ|6GUu(B>HRmKA)>N(Y?vf1exOk3l;V1ZeW@7R`4 zwZw*U4p^dkBvw z<`}`p>&b~U7{tqM(JSFZ&)3XBO4YsH>bTJtn|<#ttYnE{2RkmitT`k1G=7Z3+3Pl2 zPqwvcrvrNc4PUd8?H4*v^k;69o*YR$1dcrqb*9^sdt#+&p9Wx6H-14Y_e>Mu49L%E znQluYrMdP^-xeDa9UEJx4!$^Z>b%e0UK-+9(hoI}_yo;H;nMA8x6x;S$rx_jU3#(q zsBEq$4ew_>IYxVTX^$1w6{UtW?mag_$@{^0p3_dn^$Lc^2Qwb=wmyR=cro(h^+24$)J|em&-b962|X-Q>A=B+TTjLHr(f7@xJ2eR&Z+=lqPR zvP0dqgdLQCgVK(f9{BxcfqN2R_r0BEWxcU(a&uZ=R-57-+u#4fpxQW?T6_9nxBbMQ z?uWTY_?{snOLyF|>N?M&Vw<);k(9B4044FZw%n3(&gIK})&pIZUSbR;oSF>bg3bcm z-0kBGLjDWWDnA4m=dCqxjLZa4u)hU;8_I#}@TH z9=|8O;EB6&FQtFc27N3lp3`aDrYi-vtOEELwpa9|NUu|#q>3(1ZD{-rq^Do}Y*-m_ z-@biQ&U`%9zX)MXHIur z(6k%%JJ4dZVl%(%!p>>uo#pRRiWE8OxJUFojhET#VEDO@O`8CPg8vr38G~H_w;Z)W z$)RC%l|sEIzLKIdF|~wQn!&K4$Fx+PMQq3G4mYhQ{rY@gV$y_d@41cMWI`t7; z-?9NVnqrUDw{3Xy_AS7po9SAZk{$dO8~}0Rvi)wD@vZExH2`@MA=LaVOrmU2 zm)H%VHSF^b;o%&Yg~F5zrN+z^kPu#~_?~TWBN9(EQ`6FB>$hC6WoA+eTL*agK1bYg z_cjFB#nRyd;K=9II6EQc<}vF05E{zXqot+Q#@CN=r=lYh<!Za4H11H0PnJfqlg2 zGrn~V#TV*PvOQ{mOS*E}O!@X*8b9{#V*8DJiwSVm=WEiyq^3P)!V+MoCFZV+o!*&F zOpV%~e{QYXk~8R?h6iTn2S%_p`vT5drpuWvTqQIDVDUHf?t7+oxL@>g&_? zBR)5^0%v2i>;fzRZ}^?URqD1ay4JOe!NU+vc9l5V`^=@RdDmp}&ay|AaE2d(!0&;S z)cYfe5>9gsaaeUeL9ht+SfcCuU~8Z8dfly7?#nY(tAR z$kyIsGVEkDQ$Ocg&8gsMhl~bFiuGG!4D?y*^pqtM2coPXJu~jff#~tVR`$ReOKQ>Q zl62d(>9*;fU||P`B6R;)YT|dnFLC6NePXuUrh;;`AES&@l{3c&%U)zR8K362v$UbA zDMx&JczL{r-&pp2d{4ns!#R?3faG1ZnpLt*GYaT1n&Y_A=Z=vLo{d)f>wCV1E=Q%7 zPrME%wehs#Kz8EJSJI~Y(GAyq$O3fifPcvH;M6!K=vx<>ATjP>X10{2-_rUwIb?Gk z{U0B|mW``Dzt(s#xXF|1@}rmQUU#rPcW>Nyh8;Da(%FP)Np$SZhn)-b^wp2#cOG(c z9&XMxm#O|lPj&~j(k#x=g^o>fSu-`<0jDzd5a4DGX67IP$Aay=(`N&lr&DVSt>%~u zKX|aI=Ct0zc{V2?I*i z8jQ%@{fKMFaM9nM>OHAZwP4aZIy&)g`%y`?4;d(}C*+-+O0Y)swqYS5G;>{}Yi&b3 z^6I5d>Kc#MXkEv>a>wl)h;8DT19T%zv9(nYu;`F6@!tRX39IAE`nr|+Fk04clfKAi zX&B=FV<@!V(sNncMDrajpN6rqu_?i#zMSqrS@f@oyAU0ax!U;cxq zpD+AYLfaps;%4tV!q3CgQ6lB)CqH{UuZpf%yI=j-R_&0)hexf4?n}j*_q?hZ-0{J@ z3Oq3?C%k@9SL&qGA)Or~k#gha0&}k9n<61LM_g>w9`Z#qV|i^7u*-EE2aeHnCadyn zaY-`FvFy}8zEO#jPT7COHSomC5plpmiOB(>+y2b#+QH4DuX%1z@gMAt@Gt-S*xkpA zH_-?TJAHZTq>xO?sVQQ>11WJ8T7cFLn+;ZKRUNFSsR{s?kQMtDvwB5Aq1I>rwt*jT zJ%Ahwp)b5Uj=+R@b`scq$9-8u3INsqC@e_86K!a0mOfr4zTpApUJBTW^7;r zaW-@ILAjYrCi1OESrWCczoTB~0l*7gfyzw{ZYMc?%+Dnb_!mwoWi$&n7^e3f#r(TFut@WB_Ng7Y$&>Fx_)osxs8)>&}7Tfq{@8+uGWKi`K||eDut7BC8H{ zYv6BcjF6pp^Cs~ba-%LWYFu94wd?d>t{SwyL54##$SziCF<$RQjq#)f9IL*mDOURU zmpx*^b3jR`r#;x?NS+nBQ%&djjd2gUr07)`OrcP|ac9~Me=-$%jl3sAd1fP%lzXEf zy2Mme=w;&C6g9_)%@-%TvsogWemsZgT|Pm4$2XsM>wJ2Hf0N)&NaGC`UWigKGZWejULQWBnUCGwDvXg1 zPr*MYIe3JMiVBFXW4c=}51sg0UOx4iXdRBp(C?%VI5T!9)Vlb`u-lpImQQT_el$mm zzkT~wP5#E0$&S?U8lzN&#QPBWO+`nX!TtDQW5?uz*%%KtjR`;~h>tZ*O{uE6^8}7$ z5mNeF#DAHlixAnYH1N!{#%po*!1*haZJ^Nq=*$NQN%VDAH;o@Ka6SCGmt~|*H3`6N zqHD^WF$j_Jcy;nSgrc8QWx4=_s-H4iXiGd9K767+l9rtOrsidWp*{cZMJJk|iKEeJ34c%t1&ld-$(FNso|5L^fB=Reve`u&O!RSN3G?@qw}`lsy&Nur)8<|}B;*VMfSe^3+s z^ACeW&-M`g(f03u`2rcu?{33%{n4S{ZxDv?yAKFI^?Q7OObIdsqTk&H&+xl#qTjC| zL-^}=9}pMudwhTNz}FmtiQnBun2g`k{ri=OBZ@#x{WB#9x$)1)k4JET{KMw((jmms zh0+cdC1Rd!WRMwgyvzz%Z>G+ALwWfyVPW22ii{R--m%x`D8AvQM;1v$Sk?dVw}_ih z);>~x*XS0vpE{~?8@->fUc;B!J{SLN(&os|cfXy7hX@UVaUiRYn2gG8_|sf8j~?05 zDZ=)9gUNcc-VE)Temg&WnP8ra8PErKsCach+j$hh^pHtw`PcS@&@sZumulF(pd|Ao z*sDtbbn0Ke;cmX2#J{!&4*%ZtaOp+&Iri{=0OJ!Vq$)c@D^;=b6Z zMU#IP_B%6obQTsMB$YT`xPp=JT_L?JW@UuAuzRuIeBRlHj9wu9Iq}jd7dPg`>C0|c z2m>p=VXse?n)Y+iSQ`mlUfn+zEnM{VIkHIyUsevg6UJuxO+%%Eg_SU+J7sNJl)8KO zch?zse~(3mXA9;&sJWCE5itm1B`+^893OkquC3@;u&d>L@bi1Rxjc4Q%>=u$s4%@= za4F9uM=|Jvef=&uwsa}Ky;JCnW5C96+a;#Rk*%pAMYiQ?R)*cxav7?@f$nCmCztfl zp?jLvWGGaauW!^mg13Tn0OI@X1EzfiTvxBIR=hpIWzc%?X%s}nB3pjCg(L6zX!@Um zH-vmTGMuerxL&v6-j+dML3Xzn`p10XLc3tTn0;MguWxJy(=2E(#^<>7lX}@r?IpX1 zEq^d;+4oVY1&q9$O;<0pQfWDV{yYdROj^!C7K)(#5V{);D4Hwoa&W+yw5Cf@8bCF_8ON)+o zVnWn7^gI7aMz;AEyGtnh1Yr}D_fOmLa}iYI?=N+B2H8;3gXZRDa|;U{+&RMvsnF~3 z_6r3w?6!*$H(q36K*6J&Q%wg;N+SU9WW6C|M=j+$LZx{29K zo@U9d>(TJZxptzmHO!3k%3 zv*25!KyKrp*soDysgx8HZK;}?%r({3fOrf5l>$Prp|3UVwR>jDY-Zyr^KI{t@9x~= zo@}AL2S`z@JGfyt4)=B?1Bx|Rhc3}$!v0JdI?z$64Z|!4CeTp;a#!$r5yWPNnTWl< zCZ$D@lb=t#Zd?bj@0cz?^Ftmsvy1T(-GH{vR!GeI~ychroDMfkU|yz z?Wx1S+_d-)d)1HU_{PfK=+H%@Yy}#x^@V|f$AwqZfI%vGiy5EGZdUTF(1Ko1bj~_p}GIe94qN;t4P;pi` z&=_BDf21X81qNqS$`>1{SxFYV)cIIc>cHnmBTGMth#rOk15?_?sy#EAHb&{f^R&NQ z#1kj;0oY)@PBY25^(#@momu)mXihG!wvnYDpfdEPb>8T+cD^VF0;;&Ui@_XoX?cjj z=a}>{liF|1djbLa?cNU`j^j+l(Dq-jtSD6@uPs;l-7{T?L1swN6I7gI>*b_^vsb0x z9YEO#p_2OR$IEOSfU4@!TuINj(=sy~uNxm79gQoM&*~C*@Wlm4VStLVTenP8xJ^_2 zstRa5laFXd8>R-on7L`M(2|AqgR=wni$SuS>0kaY&w4s6Ri0csnu~YjUe{4IN~&Qm z!j2ovL@taEpj6QeIe@!>))31!jm>QO8f6ajWp(wP6~X(~6E15y=$z*B8>C`CgO1_! z6(4)Z6hN70W0S`!86xr?Snt*pbyc=lG=(P!5$TEQgSc2WUV|-&`157Fws>eZJ~G}5 zngTe%zNMA|%znO-^vv~EhgZ|_d3yaCEFMo`@6r5py06ynwy5M7F)axV2il@R?>Yr} z!|mHI*U3sus? zdMpu*h;mBT-j4t@SBu~nvz0VMHZyoN9oPJl+Vc5@fK44u&`NQdssv1t4IxN5aDkui&C4mL%2Rcp)IJV_cF|kMMOG-2X4tXls z2B3?OIoAMyTy#1G$}{wRiRf^8i^Usk=OT!4@5G8(*BqU?j^p4eI(Y%n_QECu3F%ou zK~3WPUX}w90mqaEDn`uz@V61?d&l)(F%c4x)cl4r2{>g29VQiJ-Jiq9%(z1rK` z8#1E0aw*U|7)wY?cL>l_hMI!%-@Q`Th`d-56{$#44U7(lm?D=X1${wH~KJ{EU& z@iuzt1bdi5Jdh$?)DN3W6d@)^E=f|Vz+lA1x?=bS@0^ijOc(i2hfAP zu=_y>b*75fGgom(JbV|&s`0q%xLwe4-m_lz=LY#PIm({s&;33xGia%QIO%4PTsqsb zqn5rY5zUL$*R(kux{@ScDapdWAbF!aQE7qQEK^6S=1w))%0lo`&sa83&`7S?cX5yC z;yq7}G*dfuYJ_vK9aO#TK#b%NLg2#LwRd=_1itfoFt-9zjk1t+Ry0^Vgpd zNb1+qpoHChZ#RV%+Fdw;uK8yU1K;!WH$pP}>jRRM{(2fD=DY9hrq#bD;y;fV62^aq z|DUH#2hfWD#fW|Xm(y1J1XAt)dBk8dzbE29Pumcr|NrxdWs%JP*F=DM{d)S3X**%0 z=$QzgcgnrpZ6uIwF)sWnf!JADNV@s_IEBJxt*N8fcVO8?aWWKj#CG14ms8{3Q9<^s z2vg{{M-f3bC&k{Ibc-v<3-A8*EQnhbzWdPhWd(p@vOgjfK|0(HE8L^tulX)%kG!zk zCfFe0s-GEo3-meQUyE;O?(RTnN!r}9MFa3vyB8(rVGtb5^1=$M^rAd-(eMJ#Bnynj z7bf7}+a#c}cHWV}>$1=Z>QxGuJezx;{_SfbV442CO#)E+FT)vx35k0bHaZzojJ&Yl zQ5d}+F{b~MuA8qRV$%QGCK&d=568)IdgB&e88GGb1maZ2aE_5J>V&j+-G$$ezTcN{ zI85tXD0Oq(UBygA>0=dI!M%Tg{>-yOVATLf zBMU_4$y;X5Dno%etGj>rGR)E&WImqt_$;n8jvj=}8Zif#+ST)SWj-+vUlw0^aE-%SddKm`Lnv*DMRL{26qs1vD`>Cs68eZ>8gA&XdMT;83d zpm)UpSR492s3ZqMNQkt%(fx^B;?g;W9a1wjZ{EBCP?V4DOWfmB_6DSQ3D{DxgWTyi zxaT|5%M&L8ffcGNvKxAJQUE(i7fdYTaT*W;7FWSFsQNJJFY1zGR0?jiw9BihhHzWu zXrfW+)_ma_DR+1xdlQSRXn0N`BnMNpNrz3KxFY|qxou8x_)tR=^>QX?>ALbQVgnk} z*;``-H|&_s=;-RELJWl8-b{r`jn_x1M=vg}lh{G+2+eqy9rx3!l*0ZrNKubl%ROgkIbyaz)_Pi|R2GH;-kZNOefP+3O$RL=4FEkqw*^Doo zFGvK!eW7#*|M9X`tL!*1>uXkMZiDB4`w+CTE#imS6?IzGr~CO9iZVf(sG_h;4?JSC z)BTKRM(J-)%q!~1%7RD-IIuA*5s@mOo)9Gcxz8F*d}?dno#%EStwlL+F9Ee+JL)4X zpf6ycV*C>>p!KQ@F*Uf)PX*Y*LQP{RpW!}giU;zUpq`p;(hYaKUwRMELT$e`Jyw=k z3saHMhX=XA1<4?KCWH>*wqrsC)0&u9eUJqz zyCYeZG2RC7DeqK+juc$@&Nil2?WwT_)fq>X>}Ex5_!*F~85y-FDetaJ2(m7+O)h)> z8WoL-^7bZ&+~%Y~OSpX>?+Rat%|ugpfIclH@`=aj zc29ATWVFOcj+K$~Ns35n_P|onc?KMIl*^p10YarWj#p&gskYf-zb@bm+hz+uO|Zt# z%3`ziSA`A|FhaEr*n4&nW7!r^6?j;rR)1ihc$q;-$KdI^g9k<+V`Co)-eNAGRGDgU7D+g&{#x5x zTxHY?ugsFU7b-`73VXzxGIZ)z4-5+KY)X65+`j-!X{58~i!ELEfgSt?G(C{6C2DDz z6~aQ$;x9&8WO8X$A6(;EwD^?-)D%+U1|YRS3;CbTh%tNk15NFz^YCvfs`!4a;Z*}HL*9`B zt5*#@T);K<6fsI3zLd~qzoc~EKeyN1O(G4Hh}lMdbn;(6y?of^#u}+3E+QfVnv9W= z5x}3O0JE*JQa+JLyMt73?ihDvMAvr$rlM#f2=$+U#AYn}yCjo$L$&JyAS<=%&F7W- z?p7DAwX5p5l}A8cwzq=jEHOSbrJgX9&|aB$Z2oI z%f*H?Dg;dUl}Ab^-fyIk47z$x6~Hk%+o>H~>5eYB=NbP|0GrJFKLjz97j2FQfBN*P zbgORx?ezX>J~xLtzrR3${y>U4UnItPb1VR$i8#g7Fo{@z6slLiV(;@&MnzRrRDie; z%3S+xG5ECW&iJBw2`WpmPe>Kw93n1WI}jgYEDnHCNtv3Scj?vPwuXaegy*x@4OAK|&R{&pF8v$d*Bt z{8Y)^j=T%%SR$i_z>2Rhm1l}`Ac2n1VrU~xD3;{#;nC(i|R%Jh8>D-GNyebFU%IQ4JF&xgKyR}^Q@gyNfH-?Cx#M{1q%rs4&coT@Y6 zIkl&tX;l+iD9ses3H;0)D>lhE0*=_Y_^~aU*)(pB$`RzS7|negz^?AS$O`pDGFA6b zXeb|f%33`)5oRxCqM2_lY+_g{`Hl8|w2pITgvp)m0M}x$+3p(&INQvc%(eMGReRmr zP;A_)%TPTsb~RhOh8#zAD+dil$(&`QodFjU$CO-~&H~5M4?&#U9K}Yjh3ERr6<+eO zPQ`sae_JdKS9W$P@k+)grCeqv#IQ2@@}Cni91JH6#G5tK50;ME%xJ|Q6v!~0`tRp-nVq>$sY&I7`i8fmCPw4+0*Qr$QtuCK4} zt$Oq3P5HP?0SA;PCPVb8Seag{b|JP%&$bg3#-G^Aiq$jA!yFchH=s;)D2pgEs81~@ z_>G&JPjqa7Ig6QmM)+tV98#s;{cUZ+YXZ7+0mlr+aNUNf@jKIk%c4$d^H86e+UmSe zvSl(CIj+q)0CmLE>u})FIE{89p-MJeA$#@~{kf^5Q!H~2+XVv>TiX&b$4GhFD5kCV z*iFl3Vz(cz@KGs_!zWohBChOz zkvKFbxd7Z!bIQw}zkR~O*sVd|BCQ!FMkSy`_sr(V*~#JbB67v_$Q19I#Ef1zA6?bo zV5a+`GA4cPAtDk^W%rf{l9?_q<+tiqKQ@vs<7^buUq1yhdY!#~fX)hwh$NIIXA3xl zd0-Io3H<|(-iz7mxtJ75UTZs9YEGT)A4}lnq)5PJi!UiT);2=9iPQFy=$aWH)XI{a zWZ#B5EASQx0s#QviJ2o60Ii2%vy00uO+w91e5Wo&(iGYXU5(`7N%y z(gq|XByt^P^%DtOnCJM(7R4U$O;c1%O3Ovt9YCG1*1%eg7Jb6i%HC9e1weXtk>%Qt z><$ed+OC49iRA~xPy50vu#Cc-WSB6+P-l0JPIVkdk3cK=UX{apHGu;TbTZUXmON5 z61T2$zZ_ilvH{!7k&amQ2YY$lQ&~4r>MCqlJKq#GQ%}mndx@cH z2ZG=35d^BAI6n8;e^YI(x=Vr&yV{np5kj)Y)qMdf1?f#OTCNiw%^8BUZKajDH=zs! z+^N>n_?F3q2W|Ia@Mxv_5MDVv3%`~HYqb@xR9o;}Sh#rQpon7p@EWdqB@MP2_8Z$efS4-3xDw za6FJ#35BW%pd+M{Z?_F%yE<(D1@EI!EykTNAX_)%pQNVs=#ln0^V!bJ%CsEH8Bws4 zC`ZinyHY(sNqm9DXzf`#N|T<~`1#*0kVhF`pH2<~p9rq=A`0#XFiqeo>8y#OSEgtP z}< zLiHsiAP0_~)~5BGBUxN((^|_{o6c#CNTG*spo_z*v9IA|k;+c+2@HbrsT%5EOR;1) zQT!V?5#~5H+kzR}6c5ytn%l$9oc@}@iW&3bGHmZnd-x)0JToJsmGO|isi>m#*5C_L z;EC?s4yZ^e=Fcb!uTz-rZ`0Z;p92NC_X8%(H~{@kp?r+4_0VCb7D*ocIx}#w-9?<& zVP&Bj%C`kCUqQ%QAZ8vdI#TZGr}qec)!nV$miG{H=yIyUX@9Q8 zX0+OCy3T)RHC8Y_^#C7~7(Cxc@;NfHjoQ}eSUN;dIi>>|eL1{INYw@;HMZQgzvxGI?`BenFMT~6ww2>c(K{O!`J@JZlA&(M{)(As;f zg6HSF#}he@T{SSB@~EpsaDoH04j(>#yqLQdUKh~5#_2_mPSmWT@*8@=MGhd| zb9Pi+-5gR^80;~9^TWF9n+zL;fjB1ZO1c>ouH$i>W_TiL+IP4a1Q8r}JGY7M<=xIv z*f=RB6&z7=UbtzL#kn`NHmVT0N`&ouR04$K27(N32ytRR6@1g76F*M!L@VkAl(8_qOuM(23ZvxArILT!w&Wp!ywaQ70%_`Eq zQitM2fk%pkIjn}mqodl%77%wu@4TM`p}crjAs{hrZvYq3+&as?HYPoWRZCfhjLrT;C39Pwkhw)=f_eaaoMt5eHImR~HVuDTed+ZJ8ds;I2g z-$D4KF<>w~dF=Khmuo1lF-#K_&z#1s8fI`~M4LFAXWoe*I?mt_k>c3BKZ6v6kh}M1 zEdFSALr}ou4zX8wq;TLP8>*|nmF|4wVNj^9^V?FejV$a1^|6@u2}nXNu%P{dh#&7Q zR>@rbw5koIq4=3>mi0wTj2lNu+|FXMc}-ni<|eVMSSxrSKtXbCN8u8V&jN8`sI$3i z){w3ygQO1sBn3_yU-u-(C5<4(d0Iz`r!TQ9w&kJSYIq6-Cnr2qkDm!!)XA9`9rc}2 z`y`k&8rbHit^XeFN#cRXlB4A2eB#sx7pbC{*RplndlGSCnR4bS)eQ~V`bmDT2{7fh z;uyAnq?TGf?p?z3SVsP*_n80;>@VH|jR9~}XxMr{?Mr>URJJjvo^9YqzXEU)H$8m? zo-Va-_CJBCq1Qm&0BJvDTVa%JDo0LTdH{$~Sn&~)@9!vB=8IMuw-!n@9ZN|7Hhn&E z>ZLP#F~Q@OSzo6EXnpA;i(<&kM@4yIKxZw;FQER4yFH-?uqzMXyw?I8%l~H&_^Mb|^ z?@S26;dUW^fZe_w5@N^(5cv8t#HwVLY^dp7zEoFbXcs|zJ9#*Q^RX7M6A})$P!I+L zW6Fc-K{0=Ku-YgVm%Fi$f17W|L`8kueSpYC6$oe$=h3AiCB z!h%Q;Y9tw4F$<7lPtp)_&dR~*%PzHvu!S-{{_FKAi@MMiOPg&#CdAESoCz}ylb&V( zQ939D;LOe{HT?SW5Nv!)vcX(nyN%7a`<=GtEXfE<3<72-a=vakDWjyM=iCu39ss4h z6x7t>9DDHki-Tq3m<5OtggFSkcJKgtia8RL3V^NY!j=;ug%aOe07l!oaSgh{O$Q;U zQus8&*8Xb^u7RvL4XW5zFssvTEcEm*ONh4{cD9>#uJJoTYSRy)elxH^uv$befw0W- z>9dV|3sD%Nopf028O#Fg9TCsnZ+C@nSK=fd{1J_?Lcm6R@g^g}fGQYBN}>lf2VF)2 z5W)xHO!uT|@BOkQzRL+6abch%dap^KqKJOKLm>5je?*80e|Ms5PE!a$L_{PmLPS$a zpnwpj@{cB9dReLVJlRJ24bT042O*XI_z1@EyBDBt{-@`Nw)BrCU@QFYJQ30FcL@CF z?~lmt{m%nIxVk@@06yk-=ZXG!2O%K;`1s!s1d;RpZUQFzcjrMJ{LkPK$>|>-|NDU; zwC3+7{`-|f@AttKIPe~UCI0{SB(j9(_k2kgY5;G#AF+u)J`tTIC!!%~l>m=OL?Dg-__zPz zNo1C86T4_VBlH{bpMQKJ5~3%%Mj00fH^C4@$AA9Y|L`QjufN&rLfbfTcqm*!p(=>C z1NcucIj|D>_Dv%OgZl;Vmz%;y)kKlq0ZW5yGQuo6S3-X(4!LYffPW)+?ISMN_FM{o z3*MSA$!5e41WtgL`Zdboy)yCR7Jo8x%)+g!8f75=`|}zaLSsLBur15gkS z*UH2f!V!C#g5oJ$?=n{g{Ke6uNBPZ$Z$tgk%`^HvImx1*V`jU@15?%VA8DkCWt;cd zm7zdlSST(n9U!ZK1J44W0QIil0wZEJ7g!i56@<$JGSpO5R7^}#=$jy$wwi7g>$8U< zlN!$>r?1|z-v)L~#|>=`2q|qq;H8JpjRN_sQ?LtF9@PBgTsj>P#?a2hB_>_i7c9?` zn|yNC%f=|9aQFHa@dl{L0HsVYpj8m25ZvoCcVzA{E;~&V5RuhYLDlxxp%I0_zr!8Z z104tD{W&z*RjuZGrjI7eH6U=GmIHMBuqS4GpK5$N?eG#*+3VB+Yd}~ZPy$>Y zJh@`q6vm&S1Gkr4So)C*mytmE_vk~8qQ#r9Fj_0mljUbfmz1&~{^veMb8QjGq1}vH z0CpP~hLb=zqD@q725sVq*?-X{*4d~D>%#pcQY1OD$>9|v0AH*^YKvUw1~q=Kk&_td z{_tfPuN7OU{K*)Dn*caHG3HtT$uC!f-`A`f0S*zr@)`g`KDN%g*-qr1!jPuJdLhex z^CtZ|Vrt+=E3_iMj$1(FXJiXGSZI~j(wDzTt6#)Q1j;@GjU5NPi$CG{6NL%d-sC~- z;Oue42`rsjT-j7pQwssU425Dp*PuU$+;aztsW#P67G_lNNuy{LXEzKS*SzP>5dw(c z-G;(+Aki*gzU(~=E4&Wk;-4w%`ZGCJ)tsb9Bk8tf_Y>A!ZMNaA8^FE(NI~u4Zap0W z0ghCQ$S*qdgAf#kuHfxE+65J|b9}CF!6PSBInH@O1OYfefNSVAvzt910i05`n(H3x zieoB-cp|$TLrnplWZ7rWjp|u2fh7lKGXW2EJWO~Hjw3tMMF_M+$TX2ROaSQhSX+A= z0D=<1H)?#fMT%(~2JNC7KLt@o1$obyrO*+U7aaMea2A_bCmY{Q-G-lAeCZSjEv_Il z7|{&1X+*<&zc?WK7ghphMks{nV-xZuxKyG)eFMRf{^1_Ldd^A_)$78ONW4h&=ZgfP z%K!ZAwz+`f!JVW8&i4BJ_clkaEkgJwKYKJ*tK#-P=*FM|RA+xWaSico^4q2tch8}? zP9s)PJq-<%dKvOk1{HE5JsadQAHBnS)Ab0|O~ofLa=3V_6h&Nh5IM&BeF6i4<_!%9 z=z!}lAZ!12E@TE8ga6?8KPck;ZlXSrj^{nCU>Mxy5FRcaPH4dh{HD0b**pkfKEV?G zQB>CWv_k8+XB`A&{K5ECfatme0L%y3K)#%;mDM^#|Km;JJ8=0+9n;q0P7--3XpUh{4a_-~Z!B8@TZh zS5;qM4?=A*sTgNu8p$lx+%zD;^`+YZy?S;R9whN~>p-vOErys=&ci(-^mA}U#gz3b z+$7VI9aEDJ@#Zubn-#Vi3fds}4XN3k?sF{BFJHbyxZnXUN37*qNA)1$%$7Ryr3G>F z@3-M%k8Yy@01%A^ifl8nrZ6o~qzkF$6|E}w=g*0|mZ8$N#vd*ojXz)nUVVAAMz=A< zs|VDae&KN2(UtD0|F69_52tc{--ngbD3wqNX&^&{Xq%NWX&_^Sjg>N_hzuDTb`x6W zF)2fa2+Nd=4O*rWnTJ&7u`;YOzvn}H`20Tm_#Mald*9>z<9odOFI&sAp67n<`x?&c zyv_?38m|(oj(hz09yAyb&sv%5>Ndn34!pNbl7d!Ks5ZQdJn$6)oBVq6-%995ZxH8e zh1%5H>S|Q!CXtRIuYcAzpL_XZjb_jw*MYeA0h{lRAMxkkzH*szgx8MUH@4qS+-|fX z{*LduyW81rR8Fi=pIp_~y>Z~6a+mA+7y5gwl%8K3f3JBcP>6d{miO!L?`E7XpRu&O z%*#GHIpZ%TZ5#b{tmv!Q*8`c-((Y5x=1P*kPNAw;iUq{dIuP3;b`JLQdnjkj`Hkn( zhzQ+2(lReEF{qB=V3RN30asQZ{g&tn4%ltQLEBs+ZCCL9rq2xM6QL8}ng>ku_xDo@ zT*i7JLxuf?7Gek31v5cXHy)d`VuFP&`}*^G!8a7`BrS*}9&n{16E1!II!NfWD^#06 ziEpgRbEN`MKsvAnf7wLxvXW2-TK&W(eH#lKTQ0~h-(TVFo|Hzp z>;O-61tE?)Bxn~`q;_SvMZ8%ONnBy9Ac_yCLJ%43s015EPxTHNEZsz>05ImWFCd<1 z9!7D`k=)#6!`WRnhwM30%yJ#aDmpK|oeDx`P@_Xex341s7y%p+uU?(hkccU{@C3!8 z901K%PrHkdeakj>d!wVSemCeQHoar{W9%^~uxUnRtR*(x?!&m!rOd5H&iF6Qe6!dJI_fDbEL}p+k>K-zIY?2MrmaVMpJc z*`-P&_HBBkS3Yb|&<47tw1cJ?j2L-$~kI=Gl=d9?P*kRFdZqGMn|kpPT*M^YAjEddv8Brz zK*KohM3fGJ3!)CXnS-M})AD%eX&uBPhY)!2`5p5=B$Z)b;QlA*8Bpb zlPIQQPo{bl8_(Z6_eBw*27M0XoU+$h=)LfP8#O{TYqeH;w`|eUhKGp?}1T?_Ava3 zOAV~JTOVE@)djC4Nq+9O%|;SBw?P}cB)PNKrBfC7Zj|g_V!Mn^W6Kgc;_c9taGE2r zz}xDYxv`A+&BIOCm}P=|C$CK%-BtmG%^P+2?##Jw<<^xPeD-Z?T>!oXMh8&-RJ3w#A%T0#<$UB zuX9_QyI0O76J9JXF7C?YXg!+vAriwdkC>i*vj%J@;wv+EE`jODhn@`C(Fy&>K3Wlp+xH z{Q32MWBXwPY?>(}#WDpr+lami>j~lQV-QztOzR+cO^R?vZ4V$&rkM74oEiEo#VRG=o=T};ZbS0^!4LuK@$2UvI65E=|pK7g4Hj344z9_1>yDQ zRjjyXN1-__Zf;cIe9-`8udnP;sy0_Z)##2d^PffCBSAj+ZYn^2!YG;)6{O6A@-_a| z7?u*I3G1vC((PXV7+KuSTepf)-GH(8gsfw~9v!hAy-uxYGJh5|oUyr#;E03?LvGv3 zDme$rRZq?Mf+yiA&!DsFwLry7)QD>ljAtLuJoDC~P;dr|y<0s9<#TDLp|H6w`v4%T z$=!dt50`w@8ynrZW+`tV8<54dPDGO2G!dih{k(VrGKNQj`FAtuyPutbzq8kd_EJt{ z!8TdfEJ1>m5K_AK$x$r$acnS-f9`n&wF|Jrkn<`}C;R%s43SUuj~YEeSOBz+=*uF) z{K0^uFhVHZ?lcSx3?vo))DIQQzu$#27fuK(mw+^UUadRVk6Y&$VGEq*AWR-_(*Iq? z%nJ$h@LU60;{4hXZl>AtYbTz7i^?%1{Lm=akQUWwO?eV(_NW^%-Ciss@1mKfd)Tmq zw%MMGR?tjnC3VfChojuy%}5s(tpk)s1a?>HS5!-@aXtY@6jkw2|z2A=f6D4(T=|xCr9j6lAjzVl3R_=nYvYIXv>2L1maU zJ5MF8stgql4NXc)8n_7+Jh#*LbWBVk%2Sh87afNf9aUI8&TO0y7zX%1`ArAfn{})PaS&fzRF;;sKxC9BZ#NwYc%gVL&!`|UDmcF1X;KJ$9r6N z0j?90o$52+aIOj2>(wm$Sx|Hm6B8S3pnviJtgEc7EO;i}r$z?uw_VEvc2kN#p)uPE zI`7Vp6Ug8Ua^*@;EqsMr^zNNOm5?$w?~rqsO<2ef5Gj;WKOw}!_8ROc>&`O*MMaz3 zz7>1*onctCB?4s1JfxF601bzJD1wip2u{N_yM1|m$3AGYUGS3G^M~+`A(i;hcH?Jk zfIvlb+7iMQX6OY76I}{hnojkrOz(TErKPo+P3VfM1lDMvJ^u$xVBKR#yH-TNGFLpt ztAkvv_aE;nijt-)1k#isgX=npy>@!yVG7U)^u?;$#H9#pC%(rXC6RD)vwCfay-6c4 z_^DgZ&NtfvaTc0BTL}!~`71_8_U1krcs$+XPoElOKCV1R3JkN=Gg)821NitaFTY2KDP`$D56WoFfmZ09f`Kw#d=*J95w9gWddc3Du_Ryp z=bs{&`P*?giviR4)BCj@qy2nqqhI!7`QN|8KNI^L#uC;z0cT*Nx$4 z!j?a5_??itSa_!=2Ga@Tc>D|`SZ8{sX<4nvB{+JzsS;DL<``G{24`wJRbP(=kc|_vv@Wk796U%lD zdoN!)#ej#|^Il20N=aE;Ntx%IiQxM82XL1PS9$o&cyK*_(ZkbbSG|g^;^|iJ*W4h6 zFV^%w+`_hI**GJX{lvpNcf;=7#U(Xri}bfYv@=EMvGl0+YYkja+aiEgx^&6^JGVH! z@z+ED<5|v{{L4cRANlXEj&c8&d(0C4mxrP4{_m%*Df(ZQ=acpp#)F5%u;hXG7oQU` z&kI}G7GIA~55>=Z*I)l4>e8Akye|#$C><%Nd<&KicdW7p@9wG#v6J@1YP`eR;n_qE zmG2r}Q$_3%g)Jm|m4ziNUAY@?^dEn}@B(%QVX;dJ5N^>0G4NO_v7CUCZvhe-ST#(p z@VnbAv#%vI(cVql_|}6`#rxG=z#~8ihCPb+vebIVXDNqy5FlG6l{oYE;A}9^c~vx~8fCrH&-9(>#Uz&EkVkQ!YAAKCyx0_ZMGv~okL8qn|>rz*tf z@bPfqH$CPN7GGBw+x)XNF$&7@?_^-9XVrC>Q6nP-sCZMSwgw-* z8gO)niOhqXb6`^=Hz(w$TVDC@3fuO@GS$J3FHm&P@5o+MOSelSlGKh~BJXw@5nh~j zlu?ZO;lmrKXX6{gE5Gyd4=(8wxURYLxi(or$8<5`7PTrPdL#ZIhGR@kK=tim3iO6! zb9x{X86*rw6`3IEr?S^@ZB?{!?{ViIj%p`u{y1eRbfcW(^w~y$*bL>I zZaxW!JOH?9OW!tJhoLa^aEGxCmnu|zA2P|$q@x31fNMYK-D}mflecRpYacGAKogKc zL5B;^?}joBjfonogML~V_>EadqF9TdbP7%RF2kQWcZYF(Ii5P!q@_YSQE>#w_mQAI zM8)tTAxO-2z%WkDA2B+^>)RLuq^ktxCGKo8SkJhbAnfGU4pw#llw=iTc$l_UW%fkz zaXNv0rsNFbpiKBwnOe*x%uQpjp7;?S{|$koTNZW7#R+}nRb3WOjMA^$l5MHmWZY8P z{ZB4QjI8f@#BV{_6KQn*w-v7w!?NxTmw<6@(G7YJ`z{C4rpzi~(#=^QS!WHQXU^@= zOf)jcE$>N?&EYWqHk6UyD9*&wL~EMuGJoT2F`IKSqJPK(iEBw)3%C&4v_8@c{M~wkh?pub4Ffiy6>m=n8 zI#kZC4e(_9oILAjOeM9C_+lI*EvLsoFot=HqQyV;Ae{o<_nKi_Q2^a8Q^3#G$ zZ}|6`xc(Tys3>=YW#QeG=fO0qslI`x^=5RhmrsGAgovmfO@5@tp31O>2hGyB;J?h+tG1^_%M&UqY{z)k!=f2n1Ic1=DFwX3ht* zsSx>Ucko6KJK&yAC6=d_`kJwb)?!^DK0Eu>b3s<{w?3ex>Y+lZm-&Dneo>3JwEFnb z(@k*s!2l#Iq-ICT^GX+nb=&p~VjF+<)COD5FBm2l0&PE%8V$ZWOT*I zm+NxZ=xD?Fp=RlC^NiS@17 z?-Ju>LJqRrY+&9`w6_hSnayoJ7Cj1A5cygZh8fNlnxqxJbERg63J5HD)?^<0IhMA{ zW;w%<$bzfp^tOk_XSgo3r{%>zJGLG&rOgy2+oe7o^zVn z;oHzfgvGB9uj7(4;xQy-3JyH1Qgr;tX`N~=oDMr%7D1%3huu<21&L@{v^c2w8v&gG z{aI7Qzn*L5d|Av^L>ERo|`IW~Fz_%F3X7PFNz}UzR9n zhlofG$+wYnt*J98704ltKL*JIvg)dTo5D3O1%qj4y4OAmF@NH#Da zz-Ae%zMG9mfgrWm&1R#|7}rx)q;N@F)lzQ%wk2cr=qSLc#s?qce7Aqsq}T(3!EU3h z=i)Mfzf4;7MXfvDTUKIfB%{&Xw$+@xT)hTygh8z5HZMH2(2GHCj|Qn9A*1>XA8KnY z4$d>H-bHSW+;`%{h=T8l32FTnJ%>V?)F4N-v9|8gZ~d?8ucnqPpl5WXx?nK%l1(OL zu6PqO6oQls0;e?=D8*WFw?{QOhc6;sHz&)oVwfFLLx$?xQ(H3WY0faHlxkF+@bq!S z(+`(e6d8lTD0xRikz=2pkSxEzG^ZvyT9ftCII-q)OFmnhF3F-cysF&bX0u7-UK2r< zPGnlk%fpl0^ySMJQ2dXDZrBwGu?$wUD0#lWs4(;$>?Ccz6UDZJfwB^k4P0(R#t(gl zGVs*jy}$R|`e`uOW>{gqMLS9!z|oYKQvjf|!!L+Ycc!X|+c}J%7nT}Z7Irp!SE$p; zHFuWi{u%KMRG<%Zuf$>by_L@=OgB&XFwbfnL)#LQ(a=Q{qEBjR>0(qB93{9yWslL} zWAL3mhj*LD8~9@**tYWP`W3j*WXl0(MsYaRM8}y`LkO2PIpVJaQz6>a2E^^Yq{Ela zPJ}dX&`o1O6wb4Ic_%l8Mbny`oL^*8`FHt(4E;)G^AAOtxfvVN;fuPoz`LY*M6pSs zK>2FqFX(?wL9^h=hYCx!s1Ce%2hW3^j#|Ym;SD(E0buqDMVkUYgD-$4JI)mr|jL=g0XvoK>nUx{)+K>fKB1% zthv^nly})tR*xR)QE+R)PfN{l08Do6ccob$Nj1%LNw5%``!f&&kI=i61=aI|>Ldl%ty1NH-q*B5}j%WYvfKf5vrhN!;CPRZ1j(46TDJ znq9%8xa-VV2@<}FVNDkfS5~Hra)Brq`0dN$qG!_P0kp%!Df0}v=>`v%c1DFY0y3jZ zrz$D`l{S7qRy9b6jH$^y=I?Cq&-7y;q;3&rd&GNj&<*7GnMWUZ<-&h{dk6#O?j_ zPVT$!uA_dXi&@j&LP?B%PZ_tT4+JT!TP$17wU zXK3Uc`|8^&&Yl}>d}@7RA0Ee7<~LTao2J`{PhbHu-XB@Ba4(b>`emc8t;{K=-6JBM z3%5-RTeX6ImGZCGM1U4a8N~l&P&IzrthV^T5@jk+qZpPjzW5E#pC@EW?$>WJC%y{1 zVCV6^?vaZMU`8yw^o!{qr;#uK^#)>FSyuMY{0|$6bZTDVhn5eKM{Xs^4_oh=gpvk| zs`86?Q(j$n_SAj171yH8xr3%5uw!gVud1U!pK@DdvjT50>+J4$Hq z2OK6zZ0R&!ap?9BMooyZyB~G5u;>B>q%-@GLuGx%ZDJrVDoCOk4&lS)707j#iT8k| zqvpQ_D~O2y^wPe?s_NkPL{KP0V;Mrurnl;~7LDHo2p@ zKufaWMGAQ*xpB?WG88MuRg<*ql65MdH~AIbv8{?8}-vQ8-G}7;ef?EcQTA0-#tx9}y zYybPX>P;1QH6ji>f4aH?eN9hR1--(fCvpg!WHpG5{_)%6x|_xh@jL@n)U?2t?zSe( z<)Cxo%Y%`P)&Z_8hQxu@(`{1SWyYSmuTDJfJc*!X|Jf6$-;kp9Ox)|Q_-)mRGm2u| znb8y6Y~ka^vGUP<#&{_ysZ(jsF`w$xQ=hF;xkjlz=iQRI_gU5LnN@e;69DCtrDP@e zU&6MNcny&858bam(6vJ=-SlAj3~E8y94L0yp$D1V>w@8`@%#IUNxiBusV2om^o^Sa zFO%P{@E%{Gc{)bv4k$d{0uSUpi>lJSH}Tm+s!kxdB4gc|i`%xG7S57UyFNf3_>e$O zNzshG>6r1zg|XgTi|*)X=S)EvSE0oIT>?FTsQuso|7vc9Iy65SU1Q zcXmdKudR_L?w?Y(*MRcc!luVYl7cM#)xN!ZQvs$@BAd7aiH$!~eKW07%By+bMr-5n z!h1_&uYR(4lqt(vG}*%*``NDUy@zq?()uMs&7A2@vqJAuf!w zy!?sVjO@#B1eg-o8WwV?9MAql$o{eD<1Rme31)i+ZQ6UPu9P#!r)EM^2h!y%6oUgj14PLm5^47Tu6LA#9E8G8nq9)2d?QLuNqP3qZ0aLkpDnxq5DM+4(4%a(cLXbS2pP=GkUov_ zC^Y5WH}rgWTNUyIYO>$ekXWwk4^<&EGFtw*Hf-Sj`t*#X&`C?))^~b^#=)*SmS$uQ z=8==r5l0BU+@Urk^nn@SncaJ~)AWB1Wma3{LO4SofahHcVRnf(j{7K|HB%ZZh8#C- zW?aU*=CmpGnu}uVvs24s>6;=MlHLhhevnfUx&%s0JA(;SG)`sWSce+BCLzr*TSw|9 z2uTh2RUxW&*j^}f6AFZ{=}hvSj;h}p+D(@$2hgxKH99t)mnKY~m3JutEr@2%rxoOwve{dnh z4bm`%y$-GPHJHKZ(gEqlpJKfGw+Nc`gFDm+YCxztN#@O)H>Z-c?>X-C?kwwbeRN|# zW<%!O!!W^A19WZyFSN5Dx0l}R5wDxl__#}c9ow5v&E^6QI5ARcrY*V7_Zv9@s>HRF za`tKq+zOEe>F6?~Xi*8aFddY)Bm|lwW)$o0${qu#%fFMiaVn&|b(MVv8Mv9ZH#>TO zI&U`LMcBDJ_^{GJZL++O;ckY&2^5RJj{-4HK}w4(P^jZuMoq>LNm2fO*V?Ih=^m{@ zb#0V9ezbi7%#p(qZnHXNM_N=vDX0AOP$nUaq4?$=$Do{}6J_{H{h&^go+A&fN#wI! zcA8!}0o!p{L^uP?ST9H*ruL68RaPr{r})-56_#>i2^*h5Q;S!zvb0lwoxm}1c47iu zBXI&Hp6b5nqoa**db1P^KFyCix^w4FNC$IU$JM{Cxw9pN`R=VCaa8DW6a)C8G~SIA z&wCh{6uLS({sb!7MFN7=b!fYPlB%knDXsm9%Zpw1MhR9sDC? z#bIaA^6MkeGAwTQaR#0J`!c9PW{P852R>+g-1F<@2TESgngcxo#d4llNALn96Jn5kQgXpr)5kx#uWYjlh@A>-rlF6a>9DAcK56t&kjXLpX z+DA=;4P;>uaWseM7qplKo2Ywl){3#89T_&3in}UHiMCz<79s8M(?P8hXZq#L;t*pz zvQ<`zT>7-knlQI9iCXdMh*bQn5~HQ0+nkC9CX(xnqRM|dYCStXJg75@Vja|nc&F72 z8ao8m_}g#V<0c5Q$Rp6ORIb;>?P#tftgc$~g$;ychd^-i;GS>4t=}eeX$jXF9!A?v zD;A%1rq!G%_!S{w`tqUc8^jOxiiw@vb`H&BQ?y|L2hoXg+3$W@U1=u8Pe)FviLYl$ zSx1mQm88wztTiBp_EIWp1%j3bC?&N&a2v@(L^Uv*>Z~2AhAO|nVDJUW)VLAs2px$e z_*F0f20SVWk(pe0WD?0U{X~GU!^!5jk#8OTVLy zbFlR0ka%4}cmZ|hH=%{DqV=$Re z*3=~r5mMJSu25pNZR#Wf6_z7f9cYc4wX+E;h^k20_GDJz*fiVY8dt}5dr#W+;d>-$ z_Kgo*trCs$fV?I%CbT`#yOWIOS%1UCNG95*Ew-zl5l6b6v+^bUT6ZEWPG7>ib-7V# zu7&*KdX}OdQJY}KPbH~|E+I;F+B6hHNvjSeY+L@G)0>q~{6F5T-+^TPj4yh*CqiCX zlW0<_W!5dbwdS) z+mfm!t><~QPG%8fd<*O36^^V6JqjnICrreSvW+L6ZU9cHTR9++HrI3K3*e}rI}ZyS zLs}j3c4Y)O*&Z0qGt!Ev3+)2q!xfATNrKkgqG`WzI71&DHYcNP_8Sn~(n`>%Do2@s zBwtKVOiA_>=+n%NK;5u2n+Q&?Y*ig;T(g%6is5?4y$m5e;}Z(pIgO^z%uZ=M5q)Nq zbFA~q?`xA~B_hNSx*nlH)YsX@#s+1vIx59p$!*_IhU%|Sb!D@89z)ybn^V!_IMEh) zsBTFNWlRl)Y&zL2g*h++c9yYLWor+IKO88V_Sj2xHKkN7%d|=UXAiO9F-y+q$=fomj-Fig0Fo-mqPn~14WDjg4 zEUtdq-zSgu<{Hn@?;y7JR)$tv|LJg_xura)L>KHR!ug7q@NJsNKQPOc&=kQT*XwW? z>?kg_Aw-g-EMUw=M^Ty!>;@fqK;B;{jY-8${@+Wz3)59 zvDp`yKeln^GldYMe2$Wo`TN&XxAD^!=&HT#>pIL}%(aH<=8q~H zfvSN+rTp>cdvzWt}6mA0EHs*15OTj7%Dx#koJcu`a7A zN0!FNF2R%bqU@$>hOt0qqvj{M_4kD@NxV4(k7jWuR&KM68Ts_qmXG!Pn>k8u%IY5Q z-7T?YdCX}gou@{Vz&Ke+;AmvkeuC%bS5;;uD5R*tn{uA~Bv-?2m zCD5ue$qI1RUlygAuO0yidTuNDpUILNZ20kJqF9Ap!EH!RzpHi&Gc#r@Z5b{hV0k9M z@|nTv-TEssEqSvB|Bz!Exc>dxRPzO9w-h$^ipCReJnEIl?`=}CM6@lbs7q^{u5a98 zukLnYIKwzw;=sFR-E}N(GZ;L2gw=>=HQMPYTJGkCcPWYdpisp9#K;c&RTz$V3}Si8 zZi28l38?Ll+xTnQD@ny~b=U71Q#xm>qdK8-M>?l#_PHB3ufJDfVE@p+(^V zrUY!;bBai^bEsCEAx_{5DBQCLwZT!Gg1|leZygG{QcgpC=l_1qZ}zLnh924Lz0HwVaL6vrVygUFdO)!7um%H=+kYv#KNz=M?d`&JaMWvKUFHM^rFS3 z!uP!gw`$)Eqh)BZx!LTJc%Al$^=J@vrc3SjwGW6hS+*x6Bv>)-of;RB@;Iowl?-S2 zTiKq!pGlm}XJ}x9kXmBpx&hDlmdlQDMNc{$nBzj#OlsuU8@W#_%Z43{MTrI&1@w3m z&aQ>t{9vo>XxUEt6uHdgw#}*46?65=k_>8XC|W`{2(>Yr%YvSi&pVnIZ0wXRy{u?& zdP14SNjjEuVV7D!3pq#A|I!Z@vy(Q`S(u@EyEdF++IYksaZUI{4%YLr?!LQ(4RbVR zRiZI|Z;L@v(QS4l_z#S2H{aFY;)MVvI4ONvmvJJFk<3!=v>5IxeoO>aoAby1q$*?8 ztJ4bU?FVewq*~1%f#7=TgSTEyL7=8$YyLvk^UUpjSXiRFzfM~Va{2mEXmw`w+LJ6S zZf6GbMf}tWU&+d+r8KRUuJP`5Z6awRstXPCNug2k0$@BV!d{v>uA>KMI)n&;i)4QU%x zi8++;N_j(kV)7GRoXrPACWs0o4b-FM;)SCP7$qT*8Bq=e+#w}1i(rc zQ%FhAxfxhSN8=WNjiuq_t>XNGf>pErjV_P4H>(l=XOysNlerWtEsPZ)HW2D(wi7Mb z7Y`oDf&2mqk2=hO{)Rd>`EA-GwBV(nv(Rg#vgezu#x*{}$6bF3-+3noo$ zH80^5y9bOH^nkE?f5<*w4k#RO@o2;Tm1kEHe^TkV#7%?xlJmng+y(G*mqWsr$@EJv zlNa%LVC;fJB71Kej|VVPz_1)z#8vXWRxd*n2v7c!9^15J=ac*;)?}qEe`qcLfy&la*}Rp zL{ldYq5o<+I)ao-r$BJK`3Wu|#_K9F$!b$94sTqFiWyOoDf32m(=dXe&_4jevIrZ` zAPn8bONSaA{?)5M89jC2{Sy?ViBV|rS^`E?TSL^j@XylgFA)%)TtSnajdfE%pV`u|m+k5H~0vr`-VnQya z{>XD>U@qh0_1_`gh{uEVOlMiuV@vuNE%|k4=a2L2J-u>^#OeHLi72%B7gs~=K5J_!unQ@DYjkYyL<4_6RR8 z`@gSe^LD*HlAtNs?#H^Qgfi=e^=VLDtZuUGTwdt17c}@!^Ri3UyG4@d3&)*KaV?fD z^?WvsoBfW?xsI3=ysl$|g#Fyjt}(Egudno_Dfw!VvcW+t_OFYD)rn}m_x|GrC%wBj zxEpLateT(ld>?&Tu(W&G@-(SAhZ@*&S5IL*4pN7=K7bnI6SFy8)l zpW~CnIDdxsrM3c>4@Qp{$lFueEQ$&8`FlVHC!t%re%+T45{>5;ayQk~{vhAvs}L9e z9Y9W<{7WdEgzrqYHA$`0K|`4ORIvNhxPLITocEQJOU)fBv#a`Q1?3D_m4b5kL_{+1 zG61H8Hd^(F*gZNqHk&u%HZ-Pka;c^!XolDm=n6D4`aUx`R_cH-x(ljkkFLZs=VL0A z!MTXPt9Izx^_$S;zwQ!qC`VL3=y7q#7M2yWa@_VcR8*m(Mhvx7g?kD&D zWO3(dgT-y+Xz^q%Cpq(Q;DOq&=9>y?cw#4LJlk?IKgTrQ)n|{<7&Y${O={=Us;#rV zAn>Acxt2s;+Xbg*cif{CBFA-F0}vxG(7UZ;%EE78ZS9W+hrh>+!B|_!+1#jFJBMt} z(0|A>_drWm!bh)_xgsSFIT0@7&wE6Dhce9HZuOf>dD{dDdPuI2BzNyt4jzF9L)nq= z`O?zTqtSAwP3SvZ2lqA~oPV^DS7-X%pJeY&y+yKjh5=|&SM+ji$-Y8wK?B9fYJ!-& zpeGvWDaJMrf{Ddlk~F!bV0_x4{c=Cn_ZrdRbzt*Q{t~> z>;Z1d`j2JIq1N`3X7Sd$pX(!+nmw@JqN@&!P@%oxnf^d4+uPxcZ%~pbZ$Qz=Ig6@d zSBI<{MUW#ebhwt+FzpLG3JrZObV@E`<>WL*2eza`&oYabze}?VxgLFIzN>;O!^4;d zEb;A@x?IU3OaPLkn_qhzdpO}<|DX#U%$4zds0d5kd698r**J{=OHXv$p zrb9Xu-}819z7wvqZ0Fs*ZXFXBi}(>%wqNRX#u>(kvU5;CwumO0Q$u()!iaW%fM=+H zs2QJc$vP~7K^99|vMjiB88#f~mp_$>7;8%$<>Oshnrq6~>GqG= zRb91$%7!6?x_$VC%JV27=jxZVkM$gX%TycR5j|LR1U2W~L}&5Z`)H!ezppFG43cq$ z-gCr_W%j_zZ5B+24Jm!kTVA(qQf%&s^qbDP5#?=IHZYybw`n@*14}Ln0GEEDlrw=! z=Y$oRWLl;HwNzcs;fxyg@{+**Q$|K@=)faL0L|m5Gr6|M$tMmANi$|_202^73o5rI~~h}WO= z4=oL2?A}a}>m5o5vL{?X#(!|^HMwkbNMC@UT_5ci(Ct?WDmI6rti!PJ3Qh^O$a6!sOY+ z5>Bal4f&EkkmFd*KR6K3FCu?T;>qw_ie9puMq)$?dJ8BQAaC_HO8N&-nd)fy;xbIA zbp*0lcNOyf-@kv~SQ~ZuBVW68f;Nl?X_Q|Y4eZ@$F5S`fi`4FRE!aHj>p%`RQ@laD zn)Y!Pt>N<-Uim)+Jgq$Zgk~k|Np-!l$5sVd*lL_pyFq~)DQ4;xX#Z77vVL9MFTeRq zwrJQc{?`K6SBn_FiQqIiK$MnNH2d3F`Jgz8uhv3zs;22G)|jz@ll%G+3=A> z0T%t`2j-^=g7NXE-CeDNG?dK}8$CZatZ?qr7IR7aMTIX)^w%IyUZfb<{^&)5=;7!M51llD&VBza zO=uXXPgsotsG@;BXgJ8`d*4PTtP|8t!T-+2F_qodnz!D)?g{M!C-lfnoHoPx;Uef~ z#<!d!90smR@1XwuWty8Xx>(mnOIImJ~Gyv?X{a)YziLGjwV zO@i}9*W;|(2+BNz9ryv~&Ey#$5k}$4r;;TGAE2M{Yfb$f2V0fwNx=klUQj)!aoAU4 z`H8gom#IwO-Y1>mAwQA`=>NW+H&Wbdeq;kdq<{WI!+{hc7^v)$Bv+R3Kr*ggA$+#7 z0=lxjL00m^8G;xW2=Or;Aq}&2-G6cEu`{718r?GGDfm=G8So8qWgs{_0s*@*mXR2;KZX9&EQmArj!(f6elnbJ4h>;47pL`2q#*PkEFFVzt zXL}^%EF=l`^7m0@F0pEvJVgQTJQTRz5^q;z_8$ASi zfMjj`Y!-DS^vq5-8{?A9wVXPp`8_N8Ywf0=f35BHeY`Ar9Kt|lXKRm9>^kIX>M^!y zeQ|BSzdf&VwHaxjx^|x)gFx*S3iXl0Vhoi@#Z=9lOXp{I?;31S6s!weJFds^`8t!_ zg$$x+MC0&b1JolhbR}Z{xgC+{-RzZHTrZaAm%j=LqpV1SCJ*>NM8x3{!XlsfsDHwh z(jeV;m?NqBZvuan?$;H+2>eMm&=2MKOl-w#&V=~*@{&5a<~{^Mv}I}39yPrm=zubb zK-}iCOT+~!FBzk z7?BcsVy`YktdcL6yxh3Jb-i;%bLl5rT<+tVq3&{%UChnHFyj8*SXgwH z8hTbgBI>2L_Se}RS}AH@5h_gROMx0-B@LA@HTKgzcH?C`$z@67ol24E5jN;1x@J^L z+E!747OPoIg84Q^`D`h%ztFgPA?%VEw|dLQ&K`+b=bYuA#GRGVluFaTci+k^dOU&% za#>LH%$^!HamLS3m(geXaUGix|LKhV_Zn6J59Lw(W4(YVVH2)6Ca}1T|R>-U@n<}vxajI zlRR9YTpjjfap7|>?-k42Kj?OCT9~$l~HtQM`*qsRTHngo@y5x(e3>9vYa01 zBkRjsj=#9&(Oq3t1#z2C2>D1?EBso8rwELc*2JBWYasFdh+V=2fzAdn+L!(_jqe}M zk|Qu+%8R&r{@08J*U~ok83wV8*S4N>Uu0Sz<^brRzj;XHeeNCsU^pM92i*;UJdXdL;t>Dx07K)c1pZ6FP`@7T;8S2AZ>^hufsKD6ser{6@kRXb zC&T_1_K4s7$-e&uyaBmggp3y$_xN}G_a_ - 9 + 15 UMLClass - 585 + 1215 0 - 81 - 27 + 135 + 45 /Resource/ lt=.. @@ -16,10 +16,10 @@ lt=.. UMLClass - 360 - 72 - 36 - 27 + 840 + 120 + 60 + 45 ACP @@ -27,10 +27,10 @@ lt=.. Relation - 369 - 18 - 279 - 72 + 855 + 30 + 465 + 120 lt=<<- 290.0;10.0;290.0;40.0;10.0;40.0;10.0;60.0 @@ -38,10 +38,10 @@ lt=.. UMLClass - 990 - 72 - 135 - 27 + 1890 + 120 + 225 + 45 /AnnouncedResource/ lt=.. @@ -50,10 +50,10 @@ lt=.. UMLClass - 81 - 522 - 63 - 27 + 135 + 870 + 105 + 45 ACPAnnc @@ -61,21 +61,21 @@ lt=.. Relation - 108 - 90 - 963 - 450 + 180 + 150 + 1845 + 750 lt=<<- - 1050.0;10.0;1050.0;460.0;10.0;460.0;10.0;480.0 + 1210.0;10.0;1210.0;460.0;10.0;460.0;10.0;480.0 UMLClass - 819 - 72 - 162 - 27 + 1605 + 120 + 270 + 45 /AnnounceableResource/ lt=.. @@ -84,10 +84,10 @@ lt=.. UMLClass - 405 - 252 - 36 - 27 + 765 + 420 + 60 + 45 AE @@ -95,10 +95,10 @@ lt=.. UMLClass - 153 - 522 - 54 - 27 + 255 + 870 + 90 + 45 AEAnnc @@ -106,32 +106,32 @@ lt=.. Relation - 171 - 90 - 900 - 450 + 285 + 150 + 1740 + 750 lt=<<- - 980.0;10.0;980.0;460.0;10.0;460.0;10.0;480.0 + 1140.0;10.0;1140.0;460.0;10.0;460.0;10.0;480.0 Relation - 414 - 90 - 513 - 180 + 780 + 150 + 1005 + 300 lt=<<- - 550.0;10.0;550.0;160.0;10.0;160.0;10.0;180.0 + 650.0;10.0;650.0;160.0;10.0;160.0;10.0;180.0 UMLClass - 639 - 252 - 81 - 27 + 1230 + 420 + 135 + 45 /MgmtObj/ lt=.. @@ -140,10 +140,10 @@ lt=.. UMLClass - 297 - 432 - 45 - 27 + 735 + 720 + 75 + 45 ANDI @@ -151,32 +151,32 @@ lt=.. Relation - 315 - 270 - 378 - 180 + 765 + 450 + 555 + 300 lt=<<- - 400.0;10.0;400.0;160.0;10.0;160.0;10.0;180.0 + 350.0;10.0;350.0;160.0;10.0;160.0;10.0;180.0 Relation - 666 - 90 - 261 - 180 + 1275 + 150 + 510 + 300 lt=<<- - 270.0;10.0;270.0;160.0;10.0;160.0;10.0;180.0 + 320.0;10.0;320.0;160.0;10.0;160.0;10.0;180.0 UMLClass - 81 - 612 - 72 - 27 + 375 + 1020 + 120 + 45 ANDIAnnc @@ -184,10 +184,10 @@ lt=.. UMLClass - 666 - 522 - 99 - 27 + 1230 + 870 + 165 + 45 /MgmtObjAnnc/ lt=.. @@ -196,32 +196,32 @@ lt=.. Relation - 108 - 540 - 621 - 90 + 420 + 900 + 915 + 150 lt=<<- - 670.0;10.0;670.0;60.0;10.0;60.0;10.0;80.0 + 590.0;10.0;590.0;60.0;10.0;60.0;10.0;80.0 Relation - 702 - 90 - 369 - 450 + 1290 + 150 + 735 + 750 lt=<<- - 390.0;10.0;390.0;460.0;10.0;460.0;10.0;480.0 + 470.0;10.0;470.0;460.0;10.0;460.0;10.0;480.0 UMLClass - 351 - 432 - 36 - 27 + 825 + 720 + 60 + 45 ANI @@ -229,21 +229,21 @@ lt=.. Relation - 360 - 270 - 333 - 180 + 840 + 450 + 480 + 300 lt=<<- - 350.0;10.0;350.0;160.0;10.0;160.0;10.0;180.0 + 300.0;10.0;300.0;160.0;10.0;160.0;10.0;180.0 UMLClass - 162 - 612 - 63 - 27 + 510 + 1020 + 105 + 45 ANIAnnc @@ -251,21 +251,21 @@ lt=.. Relation - 189 - 540 - 540 - 90 + 555 + 900 + 780 + 150 lt=<<- - 580.0;10.0;580.0;60.0;10.0;60.0;10.0;80.0 + 500.0;10.0;500.0;60.0;10.0;60.0;10.0;80.0 Relation - 621 - 18 - 297 - 72 + 1275 + 30 + 495 + 120 lt=<<- 10.0;10.0;10.0;40.0;310.0;40.0;310.0;60.0 @@ -273,10 +273,10 @@ lt=.. Relation - 621 - 18 - 450 - 72 + 1275 + 30 + 750 + 120 lt=<<- 10.0;10.0;10.0;40.0;480.0;40.0;480.0;60.0 @@ -284,10 +284,10 @@ lt=.. UMLClass - 396 - 432 - 36 - 27 + 900 + 720 + 60 + 45 BAT @@ -295,21 +295,21 @@ lt=.. Relation - 405 - 270 - 288 - 180 + 915 + 450 + 405 + 300 lt=<<- - 300.0;10.0;300.0;160.0;10.0;160.0;10.0;180.0 + 250.0;10.0;250.0;160.0;10.0;160.0;10.0;180.0 UMLClass - 234 - 612 - 63 - 27 + 630 + 1020 + 105 + 45 BATAnnc @@ -317,21 +317,21 @@ lt=.. Relation - 261 - 540 - 468 - 90 + 675 + 900 + 660 + 150 lt=<<- - 500.0;10.0;500.0;60.0;10.0;60.0;10.0;80.0 + 420.0;10.0;420.0;60.0;10.0;60.0;10.0;80.0 UMLClass - 450 - 252 - 36 - 27 + 840 + 420 + 60 + 45 CIN @@ -339,21 +339,21 @@ lt=.. Relation - 459 - 90 - 468 - 180 + 855 + 150 + 930 + 300 lt=<<- - 500.0;10.0;500.0;160.0;10.0;160.0;10.0;180.0 + 600.0;10.0;600.0;160.0;10.0;160.0;10.0;180.0 UMLClass - 216 - 522 - 63 - 27 + 360 + 870 + 105 + 45 CINAnnc @@ -361,21 +361,21 @@ lt=.. Relation - 243 - 90 - 828 - 450 + 405 + 150 + 1620 + 750 lt=<<- - 900.0;10.0;900.0;460.0;10.0;460.0;10.0;480.0 + 1060.0;10.0;1060.0;460.0;10.0;460.0;10.0;480.0 UMLClass - 891 - 342 - 36 - 27 + 1725 + 570 + 60 + 45 CNT @@ -383,10 +383,10 @@ lt=.. Relation - 900 - 270 - 81 - 90 + 1740 + 450 + 135 + 150 lt=<<- 70.0;10.0;70.0;60.0;10.0;60.0;10.0;80.0 @@ -394,10 +394,10 @@ lt=.. UMLClass - 342 - 162 - 54 - 27 + 810 + 270 + 90 + 45 CNT_LA @@ -405,10 +405,10 @@ lt=.. UMLClass - 405 - 162 - 54 - 27 + 915 + 270 + 90 + 45 CNT_OL @@ -416,10 +416,10 @@ lt=.. Relation - 360 - 90 - 414 - 90 + 840 + 150 + 690 + 150 lt=<<- 440.0;10.0;440.0;60.0;10.0;60.0;10.0;80.0 @@ -427,10 +427,10 @@ lt=.. Relation - 423 - 90 - 351 - 90 + 945 + 150 + 585 + 150 lt=<<- 370.0;10.0;370.0;60.0;10.0;60.0;10.0;80.0 @@ -438,10 +438,10 @@ lt=.. UMLClass - 288 - 522 - 63 - 27 + 480 + 870 + 105 + 45 CNTAnnc @@ -449,21 +449,21 @@ lt=.. Relation - 315 - 90 - 756 - 450 + 525 + 150 + 1500 + 750 lt=<<- - 820.0;10.0;820.0;460.0;10.0;460.0;10.0;480.0 + 980.0;10.0;980.0;460.0;10.0;460.0;10.0;480.0 UMLClass - 450 - 72 - 54 - 27 + 990 + 120 + 90 + 45 CSEBase @@ -471,10 +471,10 @@ lt=.. Relation - 468 - 18 - 180 - 72 + 1020 + 30 + 300 + 120 lt=<<- 180.0;10.0;180.0;40.0;10.0;40.0;10.0;60.0 @@ -482,10 +482,10 @@ lt=.. UMLClass - 495 - 252 - 36 - 27 + 915 + 420 + 60 + 45 CSR @@ -493,10 +493,10 @@ lt=.. UMLClass - 360 - 522 - 63 - 27 + 600 + 870 + 105 + 45 CSRAnnc @@ -504,21 +504,21 @@ lt=.. Relation - 387 - 90 - 684 - 450 + 645 + 150 + 1380 + 750 lt=<<- - 740.0;10.0;740.0;460.0;10.0;460.0;10.0;480.0 + 900.0;10.0;900.0;460.0;10.0;460.0;10.0;480.0 UMLClass - 495 - 432 - 36 - 27 + 1065 + 720 + 60 + 45 DVC @@ -526,21 +526,21 @@ lt=.. Relation - 504 - 270 - 189 - 180 + 1080 + 450 + 240 + 300 lt=<<- - 190.0;10.0;190.0;160.0;10.0;160.0;10.0;180.0 + 140.0;10.0;140.0;160.0;10.0;160.0;10.0;180.0 UMLClass - 387 - 612 - 63 - 27 + 885 + 1020 + 105 + 45 DVCAnnc @@ -548,32 +548,32 @@ lt=.. Relation - 414 - 540 - 315 - 90 + 930 + 900 + 405 + 150 lt=<<- - 330.0;10.0;330.0;60.0;10.0;60.0;10.0;80.0 + 250.0;10.0;250.0;60.0;10.0;60.0;10.0;80.0 Relation - 549 - 270 - 144 - 180 + 1155 + 450 + 165 + 300 lt=<<- - 140.0;10.0;140.0;160.0;10.0;160.0;10.0;180.0 + 90.0;10.0;90.0;160.0;10.0;160.0;10.0;180.0 UMLClass - 540 - 432 - 36 - 27 + 1140 + 720 + 60 + 45 DVI @@ -581,10 +581,10 @@ lt=.. UMLClass - 459 - 612 - 63 - 27 + 1005 + 1020 + 105 + 45 DVIAnnc @@ -592,21 +592,21 @@ lt=.. Relation - 477 - 540 - 252 - 90 + 1035 + 900 + 300 + 150 lt=<<- - 260.0;10.0;260.0;60.0;10.0;60.0;10.0;80.0 + 180.0;10.0;180.0;60.0;10.0;60.0;10.0;80.0 UMLClass - 585 - 432 - 36 - 27 + 1215 + 720 + 60 + 45 EVL @@ -614,21 +614,21 @@ lt=.. Relation - 594 - 270 - 99 - 180 + 1230 + 450 + 90 + 300 lt=<<- - 90.0;10.0;90.0;160.0;10.0;160.0;10.0;180.0 + 40.0;10.0;40.0;160.0;10.0;160.0;10.0;180.0 UMLClass - 531 - 612 - 63 - 27 + 1125 + 1020 + 105 + 45 EVLAnnc @@ -636,21 +636,21 @@ lt=.. Relation - 558 - 540 - 171 - 90 + 1170 + 900 + 165 + 150 lt=<<- - 170.0;10.0;170.0;60.0;10.0;60.0;10.0;80.0 + 90.0;10.0;90.0;60.0;10.0;60.0;10.0;80.0 UMLClass - 513 - 72 - 36 - 27 + 1095 + 120 + 60 + 45 FCI @@ -658,10 +658,10 @@ lt=.. Relation - 522 - 18 - 126 - 72 + 1110 + 30 + 210 + 120 lt=<<- 120.0;10.0;120.0;40.0;10.0;40.0;10.0;60.0 @@ -669,10 +669,10 @@ lt=.. Relation - 954 - 270 - 27 - 90 + 1830 + 450 + 45 + 150 lt=<<- 10.0;10.0;10.0;80.0 @@ -680,10 +680,10 @@ lt=.. UMLClass - 936 - 342 - 45 - 27 + 1800 + 570 + 75 + 45 FCNT @@ -691,10 +691,10 @@ lt=.. UMLClass - 468 - 162 - 63 - 27 + 1020 + 270 + 105 + 45 FCNT_LA @@ -702,10 +702,10 @@ lt=.. UMLClass - 540 - 162 - 63 - 27 + 1140 + 270 + 105 + 45 FCNT_OL @@ -713,10 +713,10 @@ lt=.. Relation - 639 - 90 - 135 - 90 + 1305 + 150 + 225 + 150 lt=<<- 130.0;10.0;130.0;60.0;10.0;60.0;10.0;80.0 @@ -724,10 +724,10 @@ lt=.. Relation - 720 - 90 - 54 - 90 + 1440 + 150 + 90 + 150 lt=<<- 40.0;10.0;40.0;60.0;10.0;60.0;10.0;80.0 @@ -735,10 +735,10 @@ lt=.. UMLClass - 513 - 522 - 72 - 27 + 855 + 870 + 120 + 45 FCNTAnnc @@ -746,21 +746,21 @@ lt=.. Relation - 540 - 90 - 531 - 450 + 900 + 150 + 1125 + 750 lt=<<- - 570.0;10.0;570.0;460.0;10.0;460.0;10.0;480.0 + 730.0;10.0;730.0;460.0;10.0;460.0;10.0;480.0 UMLClass - 630 - 432 - 36 - 27 + 1290 + 720 + 60 + 45 FWR @@ -768,10 +768,10 @@ lt=.. UMLClass - 675 - 432 - 36 - 27 + 1365 + 720 + 60 + 45 MEM @@ -779,21 +779,21 @@ lt=.. Relation - 639 - 270 - 54 - 180 + 1275 + 450 + 75 + 300 lt=<<- - 40.0;10.0;40.0;160.0;10.0;160.0;10.0;180.0 + 10.0;10.0;10.0;160.0;30.0;160.0;30.0;180.0 UMLClass - 603 - 612 - 63 - 27 + 1245 + 1020 + 105 + 45 FWRAnnc @@ -801,10 +801,10 @@ lt=.. UMLClass - 594 - 252 - 36 - 27 + 1080 + 420 + 60 + 45 GRP @@ -812,21 +812,21 @@ lt=.. Relation - 603 - 90 - 324 - 180 + 1095 + 150 + 690 + 300 lt=<<- - 340.0;10.0;340.0;160.0;10.0;160.0;10.0;180.0 + 440.0;10.0;440.0;160.0;10.0;160.0;10.0;180.0 UMLClass - 612 - 162 - 72 - 27 + 1260 + 270 + 120 + 45 GRP_FOPT @@ -834,10 +834,10 @@ lt=.. UMLClass - 594 - 522 - 63 - 27 + 990 + 870 + 105 + 45 GRPAnnc @@ -845,32 +845,32 @@ lt=.. Relation - 666 - 270 - 45 - 180 + 1275 + 450 + 150 + 300 lt=<<- - 10.0;10.0;10.0;160.0;30.0;160.0;30.0;180.0 + 10.0;10.0;10.0;160.0;80.0;160.0;80.0;180.0 Relation - 630 - 540 - 99 - 90 + 1290 + 900 + 45 + 150 lt=<<- - 90.0;10.0;90.0;60.0;10.0;60.0;10.0;80.0 + 10.0;10.0;10.0;80.0 UMLClass - 675 - 612 - 72 - 27 + 1365 + 1020 + 120 + 45 MEMAnnc @@ -878,21 +878,21 @@ lt=.. Relation - 702 - 540 - 27 - 90 + 1290 + 900 + 165 + 150 lt=<<- - 10.0;10.0;10.0;80.0 + 10.0;10.0;10.0;60.0;90.0;60.0;90.0;80.0 Relation - 747 - 90 - 63 - 90 + 1485 + 150 + 105 + 150 lt=<<- 10.0;10.0;10.0;60.0;50.0;60.0;50.0;80.0 @@ -900,10 +900,10 @@ lt=.. UMLClass - 729 - 252 - 36 - 27 + 1380 + 420 + 60 + 45 NOD @@ -911,21 +911,21 @@ lt=.. Relation - 738 - 90 - 189 - 180 + 1395 + 150 + 390 + 300 lt=<<- - 190.0;10.0;190.0;160.0;10.0;160.0;10.0;180.0 + 240.0;10.0;240.0;160.0;10.0;160.0;10.0;180.0 UMLClass - 774 - 522 - 63 - 27 + 1410 + 870 + 105 + 45 NODAnnc @@ -933,32 +933,32 @@ lt=.. Relation - 621 - 90 - 450 - 450 + 1035 + 150 + 990 + 750 lt=<<- - 480.0;10.0;480.0;460.0;10.0;460.0;10.0;480.0 + 640.0;10.0;640.0;460.0;10.0;460.0;10.0;480.0 Relation - 801 - 90 - 270 - 450 + 1455 + 150 + 570 + 750 lt=<<- - 280.0;10.0;280.0;460.0;10.0;460.0;10.0;480.0 + 360.0;10.0;360.0;460.0;10.0;460.0;10.0;480.0 UMLClass - 720 - 432 - 45 - 27 + 1440 + 720 + 75 + 45 NYCFC @@ -966,10 +966,10 @@ lt=.. UMLClass - 756 - 612 - 81 - 27 + 1500 + 1020 + 135 + 45 NYCFCAnnc @@ -977,21 +977,21 @@ lt=.. Relation - 702 - 540 - 108 - 90 + 1290 + 900 + 300 + 150 lt=<<- - 10.0;10.0;10.0;60.0;100.0;60.0;100.0;80.0 + 10.0;10.0;10.0;60.0;180.0;60.0;180.0;80.0 UMLClass - 774 - 432 - 36 - 27 + 1530 + 720 + 60 + 45 RBO @@ -999,32 +999,32 @@ lt=.. Relation - 666 - 270 - 99 - 180 + 1275 + 450 + 240 + 300 lt=<<- - 10.0;10.0;10.0;160.0;90.0;160.0;90.0;180.0 + 10.0;10.0;10.0;160.0;140.0;160.0;140.0;180.0 Relation - 666 - 270 - 144 - 180 + 1275 + 450 + 315 + 300 lt=<<- - 10.0;10.0;10.0;160.0;140.0;160.0;140.0;180.0 + 10.0;10.0;10.0;160.0;190.0;160.0;190.0;180.0 UMLClass - 846 - 612 - 63 - 27 + 1650 + 1020 + 105 + 45 RBOAnnc @@ -1032,21 +1032,21 @@ lt=.. Relation - 702 - 540 - 198 - 90 + 1290 + 900 + 450 + 150 lt=<<- - 10.0;10.0;10.0;60.0;200.0;60.0;200.0;80.0 + 10.0;10.0;10.0;60.0;280.0;60.0;280.0;80.0 UMLClass - 648 - 72 - 36 - 27 + 1320 + 120 + 60 + 45 SUB @@ -1054,10 +1054,10 @@ lt=.. Relation - 612 - 18 - 36 - 72 + 1260 + 30 + 60 + 120 lt=<<- 20.0;10.0;20.0;40.0;10.0;40.0;10.0;60.0 @@ -1065,10 +1065,10 @@ lt=.. UMLClass - 819 - 432 - 36 - 27 + 1605 + 720 + 60 + 45 SWR @@ -1076,21 +1076,21 @@ lt=.. Relation - 666 - 270 - 189 - 180 + 1275 + 450 + 390 + 300 lt=<<- - 10.0;10.0;10.0;160.0;190.0;160.0;190.0;180.0 + 10.0;10.0;10.0;160.0;240.0;160.0;240.0;180.0 UMLClass - 918 - 612 - 63 - 27 + 1770 + 1020 + 105 + 45 SWRAnnc @@ -1098,21 +1098,21 @@ lt=.. Relation - 702 - 540 - 270 - 90 + 1290 + 900 + 570 + 150 lt=<<- - 10.0;10.0;10.0;60.0;280.0;60.0;280.0;80.0 + 10.0;10.0;10.0;60.0;360.0;60.0;360.0;80.0 Relation - 621 - 18 - 63 - 72 + 1275 + 30 + 105 + 120 lt=<<- 10.0;10.0;10.0;40.0;50.0;40.0;50.0;60.0 @@ -1120,10 +1120,10 @@ lt=.. UMLClass - 603 - 72 - 36 - 27 + 1245 + 120 + 60 + 45 REQ @@ -1131,10 +1131,10 @@ lt=.. UMLClass - 990 - 342 - 36 - 27 + 1890 + 570 + 60 + 45 TS @@ -1142,10 +1142,10 @@ lt=.. UMLClass - 864 - 252 - 36 - 27 + 1680 + 420 + 60 + 45 TSI @@ -1153,10 +1153,10 @@ lt=.. Relation - 954 - 270 - 72 - 90 + 1830 + 450 + 120 + 150 lt=<<- 10.0;10.0;10.0;60.0;60.0;60.0;60.0;80.0 @@ -1164,10 +1164,10 @@ lt=.. Relation - 873 - 90 - 54 - 180 + 1695 + 150 + 90 + 300 lt=<<- 40.0;10.0;40.0;160.0;10.0;160.0;10.0;180.0 @@ -1175,10 +1175,10 @@ lt=.. UMLClass - 918 - 522 - 63 - 27 + 1770 + 870 + 105 + 45 TSAnnc @@ -1186,10 +1186,10 @@ lt=.. UMLClass - 1062 - 522 - 63 - 27 + 2010 + 870 + 105 + 45 TSIAnnc @@ -1197,10 +1197,10 @@ lt=.. Relation - 1044 - 90 - 72 - 450 + 1980 + 150 + 120 + 750 lt=<<- 10.0;10.0;10.0;460.0;60.0;460.0;60.0;480.0 @@ -1208,10 +1208,10 @@ lt=.. Relation - 945 - 90 - 126 - 450 + 1815 + 150 + 210 + 750 lt=<<- 120.0;10.0;120.0;460.0;10.0;460.0;10.0;480.0 @@ -1219,10 +1219,10 @@ lt=.. Relation - 567 - 18 - 81 - 72 + 1185 + 30 + 135 + 120 lt=<<- 70.0;10.0;70.0;40.0;10.0;40.0;10.0;60.0 @@ -1230,10 +1230,10 @@ lt=.. UMLClass - 558 - 72 - 36 - 27 + 1170 + 120 + 60 + 45 PCH @@ -1241,10 +1241,10 @@ lt=.. UMLClass - 819 - 252 - 36 - 27 + 1605 + 420 + 60 + 45 TSB @@ -1252,10 +1252,10 @@ lt=.. UMLClass - 990 - 522 - 63 - 27 + 1890 + 870 + 105 + 45 TSBAnnc @@ -1263,10 +1263,10 @@ lt=.. UMLClass - 693 - 162 - 63 - 27 + 1395 + 270 + 105 + 45 PCH_PCU @@ -1274,10 +1274,10 @@ lt=.. Relation - 747 - 90 - 126 - 90 + 1485 + 150 + 210 + 150 lt=<<- 10.0;10.0;10.0;60.0;120.0;60.0;120.0;80.0 @@ -1285,10 +1285,10 @@ lt=.. UMLClass - 405 - 72 - 36 - 27 + 915 + 120 + 60 + 45 CRS @@ -1296,10 +1296,10 @@ lt=.. Relation - 414 - 18 - 234 - 72 + 930 + 30 + 390 + 120 lt=<<- 240.0;10.0;240.0;40.0;10.0;40.0;10.0;60.0 @@ -1307,10 +1307,10 @@ lt=.. UMLClass - 774 - 252 - 36 - 27 + 1530 + 420 + 60 + 45 SMD @@ -1318,10 +1318,10 @@ lt=.. Relation - 783 - 90 - 144 - 180 + 1545 + 150 + 240 + 300 lt=<<- 140.0;10.0;140.0;160.0;10.0;160.0;10.0;180.0 @@ -1329,10 +1329,10 @@ lt=.. UMLClass - 846 - 522 - 63 - 27 + 1650 + 870 + 105 + 45 SMDAnnc @@ -1340,10 +1340,10 @@ lt=.. Relation - 873 - 90 - 198 - 450 + 1695 + 150 + 330 + 750 lt=<<- 200.0;10.0;200.0;460.0;10.0;460.0;10.0;480.0 @@ -1351,10 +1351,10 @@ lt=.. UMLClass - 864 - 432 - 45 - 27 + 1680 + 720 + 75 + 45 WIFIC @@ -1362,21 +1362,21 @@ lt=.. Relation - 666 - 270 - 243 - 180 + 1275 + 450 + 480 + 300 lt=<<- - 10.0;10.0;10.0;160.0;250.0;160.0;250.0;180.0 + 10.0;10.0;10.0;160.0;300.0;160.0;300.0;180.0 UMLClass - 990 - 612 - 72 - 27 + 1890 + 1020 + 120 + 45 WIFICAnnc @@ -1384,21 +1384,21 @@ lt=.. Relation - 702 - 540 - 342 - 90 + 1290 + 900 + 690 + 150 lt=<<- - 10.0;10.0;10.0;60.0;360.0;60.0;360.0;80.0 + 10.0;10.0;10.0;60.0;440.0;60.0;440.0;80.0 UMLClass - 441 - 432 - 45 - 27 + 975 + 720 + 75 + 45 DATC @@ -1406,21 +1406,21 @@ lt=.. Relation - 459 - 270 - 234 - 180 + 1005 + 450 + 315 + 300 lt=<<- - 240.0;10.0;240.0;160.0;10.0;160.0;10.0;180.0 + 190.0;10.0;190.0;160.0;10.0;160.0;10.0;180.0 UMLClass - 306 - 612 - 72 - 27 + 750 + 1020 + 120 + 45 DATCAnnc @@ -1428,21 +1428,21 @@ lt=.. Relation - 333 - 540 - 396 - 90 + 795 + 900 + 540 + 150 lt=<<- - 420.0;10.0;420.0;60.0;10.0;60.0;10.0;80.0 + 340.0;10.0;340.0;60.0;10.0;60.0;10.0;80.0 UMLClass - 693 - 72 - 117 - 27 + 1395 + 120 + 195 + 45 /VirtualResource/ lt=.. @@ -1451,10 +1451,10 @@ lt=.. UMLClass - 828 - 162 - 54 - 27 + 1620 + 270 + 90 + 45 TS_OL @@ -1462,10 +1462,10 @@ lt=.. UMLClass - 765 - 162 - 54 - 27 + 1515 + 270 + 90 + 45 TS_LA @@ -1473,10 +1473,10 @@ lt=.. Relation - 567 - 90 - 207 - 90 + 1185 + 150 + 345 + 150 lt=<<- 210.0;10.0;210.0;60.0;10.0;60.0;10.0;80.0 @@ -1484,10 +1484,10 @@ lt=.. Relation - 621 - 18 - 153 - 72 + 1275 + 30 + 255 + 120 lt=<<- 10.0;10.0;10.0;40.0;150.0;40.0;150.0;60.0 @@ -1495,10 +1495,10 @@ lt=.. UMLClass - 351 - 252 - 45 - 27 + 675 + 420 + 75 + 45 ACTR @@ -1506,21 +1506,21 @@ lt=.. Relation - 369 - 90 - 558 - 180 + 705 + 150 + 1080 + 300 lt=<<- - 600.0;10.0;600.0;160.0;10.0;160.0;10.0;180.0 + 700.0;10.0;700.0;160.0;10.0;160.0;10.0;180.0 Relation - 495 - 90 - 279 - 90 + 1065 + 150 + 465 + 150 lt=<<- 290.0;10.0;290.0;60.0;10.0;60.0;10.0;80.0 @@ -1528,10 +1528,10 @@ lt=.. UMLClass - 540 - 252 - 45 - 27 + 990 + 420 + 75 + 45 DEPR @@ -1539,21 +1539,21 @@ lt=.. Relation - 558 - 90 - 369 - 180 + 1020 + 150 + 765 + 300 lt=<<- - 390.0;10.0;390.0;160.0;10.0;160.0;10.0;180.0 + 490.0;10.0;490.0;160.0;10.0;160.0;10.0;180.0 UMLClass 0 - 522 - 72 - 27 + 870 + 120 + 45 ACTRAnnc @@ -1561,21 +1561,21 @@ lt=.. Relation - 27 - 90 - 1044 - 450 + 45 + 150 + 1980 + 750 lt=<<- - 1140.0;10.0;1140.0;460.0;10.0;460.0;10.0;480.0 + 1300.0;10.0;1300.0;460.0;10.0;460.0;10.0;480.0 UMLClass - 432 - 522 - 72 - 27 + 720 + 870 + 120 + 45 DEPRAnnc @@ -1583,10 +1583,10 @@ lt=.. Relation - 1017 - 90 - 54 - 450 + 1935 + 150 + 90 + 750 lt=<<- 40.0;10.0;40.0;460.0;10.0;460.0;10.0;480.0 @@ -1594,21 +1594,21 @@ lt=.. Relation - 459 - 90 - 612 - 450 + 765 + 150 + 1260 + 750 lt=<<- - 660.0;10.0;660.0;460.0;10.0;460.0;10.0;480.0 + 820.0;10.0;820.0;460.0;10.0;460.0;10.0;480.0 UMLClass - 180 - 369 - 117 - 36 + 420 + 615 + 195 + 60 /FlexContainer/ /Specializations/ @@ -1619,32 +1619,32 @@ valign=center Relation - 288 - 360 - 684 - 45 + 600 + 600 + 1260 + 75 lt=<<.. - 740.0;10.0;740.0;30.0;10.0;30.0 + 820.0;10.0;820.0;30.0;10.0;30.0 Relation - 288 - 90 - 261 - 315 + 600 + 150 + 555 + 525 lt=<<.. - 270.0;10.0;270.0;40.0;20.0;40.0;20.0;330.0;10.0;330.0 + 350.0;10.0;350.0;40.0;30.0;40.0;30.0;330.0;10.0;330.0 UMLClass - 909 - 252 - 126 - 27 + 1755 + 420 + 210 + 45 /ContainerResource/ lt=.. @@ -1653,21 +1653,21 @@ lt=.. Relation - 504 - 90 - 423 - 180 + 930 + 150 + 855 + 300 lt=<<- - 450.0;10.0;450.0;160.0;10.0;160.0;10.0;180.0 + 550.0;10.0;550.0;160.0;10.0;160.0;10.0;180.0 Relation - 900 - 90 - 81 - 180 + 1740 + 150 + 135 + 300 lt=<<- 10.0;10.0;10.0;160.0;70.0;160.0;70.0;180.0 @@ -1675,12 +1675,100 @@ lt=.. Relation - 828 - 90 - 99 - 180 + 1620 + 150 + 165 + 300 lt=<<- 90.0;10.0;90.0;160.0;10.0;160.0;10.0;180.0 + + UMLClass + + 1155 + 420 + 60 + 45 + + LCP + + + + UMLClass + + 1455 + 420 + 60 + 45 + + SCH + + + + Relation + + 1170 + 150 + 615 + 300 + + lt=<<- + 390.0;10.0;390.0;160.0;10.0;160.0;10.0;180.0 + + + Relation + + 1470 + 150 + 315 + 300 + + lt=<<- + 190.0;10.0;190.0;160.0;10.0;160.0;10.0;180.0 + + + UMLClass + + 1530 + 870 + 105 + 45 + + SCHAnnc + + + + Relation + + 1575 + 150 + 450 + 750 + + lt=<<- + 280.0;10.0;280.0;460.0;10.0;460.0;10.0;480.0 + + + UMLClass + + 1110 + 870 + 105 + 45 + + LCPAnnc + + + + Relation + + 1155 + 150 + 870 + 750 + + lt=<<- + 560.0;10.0;560.0;460.0;10.0;460.0;10.0;480.0 + From b339a47e37c08bca0ea62ede5be311b8f7814601 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 8 Aug 2023 11:16:30 +0200 Subject: [PATCH 072/165] Added LCPAnnc --- CHANGELOG.md | 1 + acme/resources/Factory.py | 4 +-- acme/resources/LCPAnnc.py | 66 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 acme/resources/LCPAnnc.py diff --git a/CHANGELOG.md b/CHANGELOG.md index eec6b369..53552762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - [CSE] Added automatic pip install of missing dependencies during startup. - [CSE] Added support for <schedule> resource type. +- [LCP] Added (limited) support for <locationPolicy> resource type and location management for *device based* location policies. - [SCRIPTS] Added "dolist", "dotimes", "tui-notify", and "get-loglevel" functions to the script interpreter. - [TUI] Improved resource view in the text UI. Enumeration interpretations are now shown. diff --git a/acme/resources/Factory.py b/acme/resources/Factory.py index 1f9adb81..98ebd332 100644 --- a/acme/resources/Factory.py +++ b/acme/resources/Factory.py @@ -50,7 +50,7 @@ from ..resources.GRPAnnc import GRPAnnc from ..resources.GRP_FOPT import GRP_FOPT from ..resources.LCP import LCP -# TODO from ..resources.LCPAnnc import LCPAnnc +from ..resources.LCPAnnc import LCPAnnc from ..resources.NOD import NOD from ..resources.NODAnnc import NODAnnc from ..resources.PCH import PCH @@ -129,7 +129,7 @@ addResourceFactoryCallback(ResourceTypes.GRPAnnc, GRPAnnc, lambda dct, tpe, pi, create : GRPAnnc(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.GRP_FOPT, GRP_FOPT, lambda dct, tpe, pi, create : GRP_FOPT(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.LCP, LCP, lambda dct, tpe, pi, create : LCP(dct, pi = pi, create = create)) -# TODO addResourceFactoryCallback(ResourceTypes.LCPAnnc, LCPAnnc, lambda dct, tpe, pi, create : LCPAnnc(dct, pi = pi, create = create)) +addResourceFactoryCallback(ResourceTypes.LCPAnnc, LCPAnnc, lambda dct, tpe, pi, create : LCPAnnc(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.NOD, NOD, lambda dct, tpe, pi, create : NOD(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.NODAnnc, NODAnnc, lambda dct, tpe, pi, create : NODAnnc(dct, pi = pi, create = create)) addResourceFactoryCallback(ResourceTypes.PCH, PCH, lambda dct, tpe, pi, create : PCH(dct, pi = pi, create = create)) diff --git a/acme/resources/LCPAnnc.py b/acme/resources/LCPAnnc.py new file mode 100644 index 00000000..6a4a5a35 --- /dev/null +++ b/acme/resources/LCPAnnc.py @@ -0,0 +1,66 @@ +# +# LCPAnnc.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# ResourceType: LocationPolicy Announced +# + +""" LocationPolicy Announced(LCPA) resource type. """ + +from __future__ import annotations +from typing import Optional + +from ..etc.Types import AttributePolicyDict, ResourceTypes, JSON +from .AnnouncedResource import AnnouncedResource + + +class LCPAnnc(AnnouncedResource): + """ LocationPolicy Announced (LCPA) resource type. """ + + # Specify the allowed child-resource types + _allowedChildResourceTypes:list[ResourceTypes] = [ ] + """ The allowed child-resource types. """ + + # Attributes and Attribute policies for this Resource Class + # Assigned during startup in the Importer + _attributes:AttributePolicyDict = { + # Common and universal attributes + 'rn': None, + 'ty': None, + 'ri': None, + 'pi': None, + 'ct': None, + 'lt': None, + 'et': None, + 'lbl': None, + 'acpi':None, + 'daci': None, + 'lnk': None, + 'ast': None, + + # Resource attributes + 'los': None, + 'lit': None, + 'lou': None, + 'lot': None, + 'lor': None, + 'loi': None, + 'lon': None, + 'lost': None, + 'gta': None, + 'gec': None, + 'aid': None, + 'rlkl': None, + 'luec': None + + } + """ Attributes and `AttributePolicy` for this resource type. """ + + + def __init__(self, dct:Optional[JSON] = None, + pi:Optional[str] = None, + create:Optional[bool] = False) -> None: + super().__init__(ResourceTypes.LCPAnnc, dct, pi = pi, create = create) + From 496a4e1f2d672ff337af027c64eed54644da7529 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 8 Aug 2023 15:15:15 +0200 Subject: [PATCH 073/165] Updated input field --- acme/textui/ACMEContainerDelete.py | 9 +- acme/textui/ACMEContainerTools.py | 4 +- acme/textui/ACMEFieldOriginator.py | 127 ++++++++++++++++++++++++----- 3 files changed, 118 insertions(+), 22 deletions(-) diff --git a/acme/textui/ACMEContainerDelete.py b/acme/textui/ACMEContainerDelete.py index 36534e2a..745dce4a 100644 --- a/acme/textui/ACMEContainerDelete.py +++ b/acme/textui/ACMEContainerDelete.py @@ -1,4 +1,4 @@ - # +# # ACMEContainerDelete.py # # (c) 2023 by Andreas Kraft @@ -61,6 +61,8 @@ class ACMEContainerDelete(Container): """ def __init__(self) -> None: + """ Initialize the view. + """ super().__init__(id = idRequestDelete) self.requestOriginator = 'CAdmin' self.response = Static('', id = 'request-delete-response-response') @@ -70,6 +72,11 @@ def __init__(self) -> None: def compose(self) -> ComposeResult: + """ Compose the view. + + Returns: + The ComposeResult + """ with Vertical(id = 'request-delete-view'): yield self.fieldOriginator with Center(): diff --git a/acme/textui/ACMEContainerTools.py b/acme/textui/ACMEContainerTools.py index 5e4ef9bb..c3c596ae 100644 --- a/acme/textui/ACMEContainerTools.py +++ b/acme/textui/ACMEContainerTools.py @@ -14,7 +14,7 @@ from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Container, Vertical, Center, Middle -from textual.widgets import Button, Tree as TextualTree, Markdown, TextLog +from textual.widgets import Button, Tree as TextualTree, Markdown, RichLog from textual.widgets.tree import TreeNode from ..services import CSE from ..services.ScriptManager import PContext @@ -263,7 +263,7 @@ def __init__(self, tuiApp:ACMETuiApp.ACMETuiApp) -> None: self.toolsExecButton = Button('Execute', id = 'tool-execute', variant = 'primary') self.toolsExecButton.styles.visibility = 'hidden' - self.toolsLog = TextLog(id = 'tools-log-view', markup=True) + self.toolsLog = RichLog(id = 'tools-log-view', markup=True) def compose(self) -> ComposeResult: diff --git a/acme/textui/ACMEFieldOriginator.py b/acme/textui/ACMEFieldOriginator.py index b80bab03..28398105 100644 --- a/acme/textui/ACMEFieldOriginator.py +++ b/acme/textui/ACMEFieldOriginator.py @@ -6,6 +6,7 @@ # from __future__ import annotations +from typing import Optional from textual.app import ComposeResult from textual.containers import Container, Vertical from textual.widgets import Input, Label @@ -13,17 +14,11 @@ from textual.validation import Function from textual import on -# TODO This may has to be turned into a more generic field class - -idFieldOriginator = 'field-originator' - -def validateOriginator(value: str) -> bool: - return value is not None and len(value) > 1 and value.startswith(('C', 'S', '/')) -class ACMEFieldOriginator(Container): +class ACMEField(Container): DEFAULT_CSS = """ - ACMEFieldOriginator { + ACMEField { width: 1fr; height: 4; layout: horizontal; @@ -33,18 +28,18 @@ class ACMEFieldOriginator(Container): margin: 1 1 1 1; } - #field-originator-label { + #field-label { height: 1fr; content-align: left middle; align: left middle; } - #field-originator-input { + #field-input { height: 1fr; width: 1fr; } - #field-originator-pretty { + #field-pretty { height: 1fr; width: 1fr; margin-left: 1; @@ -52,18 +47,21 @@ class ACMEFieldOriginator(Container): } """ - def __init__(self, originator:str, suggestions:list[str] = []) -> None: + def __init__(self, label:str = 'a label', + suggestions:list[str] = [], + placeholder:str = '', + validators:Function = None, + id:str = None) -> None: # TODO list of originators as a suggestion - super().__init__(id = idFieldOriginator) - self.originator = originator + super().__init__(id = id) self.suggestions = suggestions - self.label = Label('[b]Originator[/b] ', id = 'field-originator-label') + self.label = Label(f'[b]{label}[/b] ', id = f'field-label') self.input = Input(str(self.suggestions), - placeholder = 'Originator', + placeholder = placeholder, suggester = SuggestFromList(self.suggestions), - validators = Function(validateOriginator, 'Wrong originator format: Must start with "C", "S" or "/", and have length > 1.'), - id = 'field-originator-input') - self.msg = Label('jjj', id = 'field-originator-pretty') + validators = validators, + id = 'field-input') + self.msg = Label('jjj', id = 'field-pretty') def compose(self) -> ComposeResult: yield self.label @@ -82,9 +80,100 @@ def show_invalid_reasons(self, event: Input.Changed) -> None: self.originator = event.value + + + + +# TODO This may has to be turned into a more generic field class + +idFieldOriginator = 'field-originator' + +def validateOriginator(value: str) -> bool: + return value is not None and len(value) > 1 and value.startswith(('C', 'S', '/')) and not set(value) & set(' \t\n') + +class ACMEFieldOriginator(ACMEField): + def __init__(self, originator:str, suggestions:list[str] = []) -> None: + super().__init__(label = 'Originator', + suggestions = suggestions, + placeholder = 'Originator', + validators = Function(validateOriginator, + 'Wrong originator format: Must start with "C", "S" or "/", contain now white spaces, and have length > 1.') + ) + self.originator = originator + self.suggestions = suggestions + def update(self, originator:str, suggestions:list[str] = []) -> None: self.originator = originator self.suggestions = suggestions self.input.value = originator self.input.suggester = SuggestFromList(self.suggestions) + +# class ACMEFieldOriginator(Container): + +# DEFAULT_CSS = """ +# ACMEFieldOriginator { +# width: 1fr; +# height: 4; +# layout: horizontal; +# overflow: hidden hidden; +# # background: red; +# content-align: left middle; +# margin: 1 1 1 1; +# } + +# #field-originator-label { +# height: 1fr; +# content-align: left middle; +# align: left middle; +# } + +# #field-originator-input { +# height: 1fr; +# width: 1fr; +# } + +# #field-originator-pretty { +# height: 1fr; +# width: 1fr; +# margin-left: 1; +# color: red; +# } +# """ + +# def __init__(self, originator:str, suggestions:list[str] = []) -> None: +# # TODO list of originators as a suggestion +# super().__init__(id = idFieldOriginator) +# self.originator = originator +# self.suggestions = suggestions +# self.label = Label('[b]Originator[/b] ', id = 'field-originator-label') +# self.input = Input(str(self.suggestions), +# placeholder = 'Originator', +# suggester = SuggestFromList(self.suggestions), +# validators = Function(validateOriginator, 'Wrong originator format: Must start with "C", "S" or "/", and have length > 1.'), +# id = 'field-originator-input') +# self.msg = Label('jjj', id = 'field-originator-pretty') + +# def compose(self) -> ComposeResult: +# yield self.label +# with Vertical(): +# yield self.input +# yield self.msg + + +# @on(Input.Changed) +# def show_invalid_reasons(self, event: Input.Changed) -> None: +# # Updating the UI to show the reasons why validation failed +# if not event.validation_result.is_valid: +# self.msg.update(event.validation_result.failure_descriptions[0]) +# else: +# self.msg.update('') +# self.originator = event.value + + +# def update(self, originator:str, suggestions:list[str] = []) -> None: +# self.originator = originator +# self.suggestions = suggestions +# self.input.value = originator +# self.input.suggester = SuggestFromList(self.suggestions) + From 054f68186780fd10ef97fc2a80910fa0b35d0e1e Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 8 Aug 2023 18:30:03 +0200 Subject: [PATCH 074/165] Improved input field. Added as optional to tools as argument field --- acme/textui/ACMEContainerDelete.py | 3 ++ acme/textui/ACMEContainerTools.py | 31 ++++++++++++++---- acme/textui/ACMEFieldOriginator.py | 51 ++++++++++++++++++++++++++---- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/acme/textui/ACMEContainerDelete.py b/acme/textui/ACMEContainerDelete.py index 745dce4a..aa45de88 100644 --- a/acme/textui/ACMEContainerDelete.py +++ b/acme/textui/ACMEContainerDelete.py @@ -122,3 +122,6 @@ def buttonExecute(self) -> None: self.response.update(f'Response Status: {e.rsc}\n\n[red]{e.dbg}[/red]') + @on(ACMEFieldOriginator.Submitted) + def inputFieldSubmitted(self, value:str) -> None: + self.buttonExecute() diff --git a/acme/textui/ACMEContainerTools.py b/acme/textui/ACMEContainerTools.py index c3c596ae..d435c735 100644 --- a/acme/textui/ACMEContainerTools.py +++ b/acme/textui/ACMEContainerTools.py @@ -14,13 +14,14 @@ from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Container, Vertical, Center, Middle -from textual.widgets import Button, Tree as TextualTree, Markdown, RichLog +from textual.widgets import Button, Tree as TextualTree, Markdown, RichLog, Label from textual.widgets.tree import TreeNode from ..services import CSE from ..services.ScriptManager import PContext from ..helpers.ResourceSemaphore import CriticalSection from ..helpers.BackgroundWorker import BackgroundWorkerPool, BackgroundWorker from ..helpers.Interpreter import SSymbol +from ..textui.ACMEFieldOriginator import ACMEInputField # TODO Add editing of configuration values @@ -118,6 +119,11 @@ def _showTool(self, node:TreeNode) -> None: {description} """) + # Add input field if the meta tag "tuiInput" is set + if ctx.hasMeta('tuiInput'): + self.parentContainer.toolsInput.styles.visibility = 'visible' + self.parentContainer.toolsInput.setLabel(ctx.getMeta('tuiInput')) + # configure the button according to the meta tag "tuiExecuteButton" self.parentContainer.toolsExecButton.styles.visibility = 'visible' self.parentContainer.toolsExecButton.label = 'Execute' @@ -155,6 +161,7 @@ def _showTool(self, node:TreeNode) -> None: else: self.parentContainer.toolsHeader.update('') self.parentContainer.toolsExecButton.styles.visibility = 'hidden' + self.parentContainer.toolsInput.styles.visibility = 'hidden' def printLogs(self) -> None: @@ -259,8 +266,11 @@ def __init__(self, tuiApp:ACMETuiApp.ACMETuiApp) -> None: self.toolsTree = ACMEToolsTree('Tools & Commands', id = 'tree-view') self.toolsTree.parentContainer = self - - self.toolsExecButton = Button('Execute', id = 'tool-execute', variant = 'primary') + + self.toolsInput = ACMEInputField(id = 'tools-argument') + self.toolsInput.styles.visibility = 'hidden' + + self.toolsExecButton = Button('Execute', id = 'tool-execute-button', variant = 'primary') self.toolsExecButton.styles.visibility = 'hidden' self.toolsLog = RichLog(id = 'tools-log-view', markup=True) @@ -273,8 +283,11 @@ def compose(self) -> ComposeResult: with Center(id = 'tools-top-view'): yield self.toolsHeader with Middle(id = 'tools-arguments-view'): + with Center(): + yield self.toolsInput with Center(): yield self.toolsExecButton + yield self.toolsLog @@ -287,11 +300,16 @@ def leaving_tab(self) -> None: self.toolsTree.stopAutoRunScript() - @on(Button.Pressed, '#tool-execute') + @on(Button.Pressed, '#tool-execute-button') def buttonExecute(self) -> None: - _executeScript(str(self.toolsTree.cursor_node.label)) + _executeScript(str(self.toolsTree.cursor_node.label), argument = str(self.toolsInput.value)) + @on(ACMEInputField.Submitted) + def inputFieldSubmitted(self) -> None: + self.buttonExecute() + + def action_clear_log(self) -> None: # Clear the log view self.toolsLog.clear() @@ -410,7 +428,7 @@ def _getContext(name:str) -> Optional[PContext]: return None -def _executeScript(name:str, autoRun:Optional[bool] = False) -> bool: +def _executeScript(name:str, autoRun:Optional[bool] = False, argument:Optional[str] = '') -> bool: """ Executes the given script context. Args: @@ -418,6 +436,7 @@ def _executeScript(name:str, autoRun:Optional[bool] = False) -> bool: """ if (ctx := _getContext(str(name))) and not ctx.state.isRunningState(): return CSE.script.runScript(ctx, + arguments = argument, background = True, environment = { 'tui.autorun': SSymbol(boolean = autoRun), } diff --git a/acme/textui/ACMEFieldOriginator.py b/acme/textui/ACMEFieldOriginator.py index 28398105..b6846f60 100644 --- a/acme/textui/ACMEFieldOriginator.py +++ b/acme/textui/ACMEFieldOriginator.py @@ -6,6 +6,7 @@ # from __future__ import annotations +from dataclasses import dataclass from typing import Optional from textual.app import ComposeResult from textual.containers import Container, Vertical @@ -13,12 +14,13 @@ from textual.suggester import SuggestFromList from textual.validation import Function from textual import on +from textual.message import Message -class ACMEField(Container): +class ACMEInputField(Container): DEFAULT_CSS = """ - ACMEField { + ACMEInputField { width: 1fr; height: 4; layout: horizontal; @@ -47,7 +49,18 @@ class ACMEField(Container): } """ + + @dataclass + class Submitted(Message): + input: ACMEInputField + """The `Input` widget that is being submitted.""" + value: str + """The value of the `Input` being submitted.""" + + + def __init__(self, label:str = 'a label', + value:str = '', suggestions:list[str] = [], placeholder:str = '', validators:Function = None, @@ -56,12 +69,13 @@ def __init__(self, label:str = 'a label', super().__init__(id = id) self.suggestions = suggestions self.label = Label(f'[b]{label}[/b] ', id = f'field-label') - self.input = Input(str(self.suggestions), + self.input = Input(value = value, placeholder = placeholder, suggester = SuggestFromList(self.suggestions), validators = validators, id = 'field-input') - self.msg = Label('jjj', id = 'field-pretty') + self.msg = Label('', id = 'field-pretty') + def compose(self) -> ComposeResult: yield self.label @@ -73,13 +87,36 @@ def compose(self) -> ComposeResult: @on(Input.Changed) def show_invalid_reasons(self, event: Input.Changed) -> None: # Updating the UI to show the reasons why validation failed - if not event.validation_result.is_valid: + if event.validation_result and not event.validation_result.is_valid: self.msg.update(event.validation_result.failure_descriptions[0]) else: self.msg.update('') self.originator = event.value + @on(Input.Submitted, '#field-input') + async def submit(self, event: Input.Submitted) -> None: + self.post_message(self.Submitted(self, self.input.value)) + + + def setLabel(self, label:str) -> None: + """ Set the label of the field. + + Args: + label: The label to set. + """ + self.label.update(f'[b]{label}[/b] ') + + + @property + def value(self) -> str: + return self.input.value + + + @value.setter + def value(self, value:str) -> None: + self.input.value = value + @@ -91,13 +128,13 @@ def show_invalid_reasons(self, event: Input.Changed) -> None: def validateOriginator(value: str) -> bool: return value is not None and len(value) > 1 and value.startswith(('C', 'S', '/')) and not set(value) & set(' \t\n') -class ACMEFieldOriginator(ACMEField): +class ACMEFieldOriginator(ACMEInputField): def __init__(self, originator:str, suggestions:list[str] = []) -> None: super().__init__(label = 'Originator', suggestions = suggestions, placeholder = 'Originator', validators = Function(validateOriginator, - 'Wrong originator format: Must start with "C", "S" or "/", contain now white spaces, and have length > 1.') + 'Wrong originator format: Must start with "C", "S" or "/", contain now white spaces, and have length > 1.') ) self.originator = originator self.suggestions = suggestions From e1a7c362f232652b770c28aa5bd7e3f2184e7d47 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 8 Aug 2023 18:30:36 +0200 Subject: [PATCH 075/165] Added LCPAnnc to "lnk" attribute --- init/attributePolicies.ap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index 4b0f363e..7c5f237a 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -1660,7 +1660,7 @@ { "rtypes": [ "ACPAnnc", "ACTRAnnc", "AEAnnc", "ANDIAnnc", "ANIAnnc", "BATAnnc", "CINAnnc", "CNTAnnc", "CSEBaseAnnc", "CSRAnnc", "DATCAnnc", "DEPRAnnc", "DVCAnnc", "DVIAnnc", "EVLAnnc", - "FCNTAnnc", "FWRAnnc", "GRPAnnc", "MEMAnnc", "NODAnnc", "NYCFCAnnc", "RBOAnnc", + "FCNTAnnc", "FWRAnnc", "GRPAnnc", "LCPAnnc", "MEMAnnc", "NODAnnc", "NYCFCAnnc", "RBOAnnc", "SCHAnnc", "SMDAnnc", "SWRAnnc", "TSAnnc", "TSBAnnc", "TSIAnnc", "WIFIC", "WIFICAnnc", "REQRESP" ], "lname": "link", From 1603d9ad4f334150885eb064f36d5259bc026a71 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 8 Aug 2023 18:34:52 +0200 Subject: [PATCH 076/165] Added soundex match function --- acme/helpers/TextTools.py | 70 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/acme/helpers/TextTools.py b/acme/helpers/TextTools.py index 0a345f65..a5de876b 100644 --- a/acme/helpers/TextTools.py +++ b/acme/helpers/TextTools.py @@ -12,7 +12,7 @@ from typing import Optional, Any, Dict, Union, Callable, List -import base64, binascii, re, json +import base64, binascii, re, json, unicodedata _commentRegex = re.compile(r'(\".*?(? bool: return True + +_soundexReplacements = ( + ('BFPV', '1'), + ('CGJKQSXZ', '2'), + ('DT', '3'), + ('L', '4'), + ('MN', '5'), + ('R', '6'), + ) + +def soundex(s:str) -> str: + """ Convert a string to a Soundex value. + + Args: + s: The string to convert. + + Return: + The Soundex value as a string. + """ + + if not s: + return '' + + s = unicodedata.normalize('NFKD', s).upper() + + result = [s[0]] + count = 1 + + # find would-be replacement for first character + for lset, sub in _soundexReplacements: + if s[0] in lset: + last = sub + break + else: + last = None + + for ch in s[1:]: + for lset, sub in _soundexReplacements: + if ch in lset: + if sub != last: + result.append(sub) + count += 1 + last = sub + break + else: + if ch != 'H' and ch != 'W': + # leave last alone if middle letter is H or W + last = None + if count == 4: + break + + result += '0' * (4 - count) + return ''.join(result) + + +def soundsLike(s1:str, s2:str) -> bool: + """ Compare two strings using the soundex algorithm. + + Args: + s1: First string to compare. + s2: Second string to compare. + + Return: + Boolean indicating the result of the comparison. + """ + return soundex(s1) == soundex(s2) + + def toHex(bts:bytes, toBinary:Optional[bool] = False, withLength:Optional[bool] = False) -> str: """ Print a byte string as hex output, similar to the "od" command. From d44bde6693e507f4fbfa029c85b880405997a865 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 10 Aug 2023 10:45:30 +0200 Subject: [PATCH 077/165] Added @tuiInput metatag for scripts --- acme/textui/ACMEContainerTools.py | 9 ++++--- docs/ACMEScript-functions.md | 8 +++--- docs/ACMEScript-metatags.md | 43 +++++++++++++++++++++++++------ 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/acme/textui/ACMEContainerTools.py b/acme/textui/ACMEContainerTools.py index d435c735..1093d939 100644 --- a/acme/textui/ACMEContainerTools.py +++ b/acme/textui/ACMEContainerTools.py @@ -101,6 +101,7 @@ def _showTool(self, node:TreeNode) -> None: # This is a category node, so set the description, clear the button etc. self.parentContainer.toolsHeader.update(f'## {node.label}\n{CSE.script.categoryDescriptions.get(str(node.label), "")}') self.parentContainer.toolsExecButton.styles.visibility = 'hidden' + self.parentContainer.toolsInput.styles.visibility = 'hidden' self.parentContainer.toolsLog.clear() @@ -120,9 +121,11 @@ def _showTool(self, node:TreeNode) -> None: """) # Add input field if the meta tag "tuiInput" is set - if ctx.hasMeta('tuiInput'): + if (_l := ctx.getMeta('tuiInput')): self.parentContainer.toolsInput.styles.visibility = 'visible' - self.parentContainer.toolsInput.setLabel(ctx.getMeta('tuiInput')) + self.parentContainer.toolsInput.setLabel(_l) + else: + self.parentContainer.toolsInput.styles.visibility = 'hidden' # configure the button according to the meta tag "tuiExecuteButton" self.parentContainer.toolsExecButton.styles.visibility = 'visible' @@ -223,7 +226,7 @@ class ACMEContainerTools(Container): display: block; overflow: auto auto; min-width: 100%; - height: 1fr; + height: 1.5fr; margin: 0 0 0 0; } diff --git a/docs/ACMEScript-functions.md b/docs/ACMEScript-functions.md index 4bbb2d4b..796e9066 100644 --- a/docs/ACMEScript-functions.md +++ b/docs/ACMEScript-functions.md @@ -125,12 +125,14 @@ In addition more functions are provided in the file [ASFunctions.as](../init/ASF Concatenate and return the stringified versions of the symbol arguments. -See also: [to-string](#to-string) +Note, that this function will not add spaces between the symbols. One can use the [nl](#nl) and [sp](#sp) functions to add newlines and spaces. + +See also: [nl](#nl), [sp](#sp), [to-string](#to-string) Example: ```lisp -(. "Time: " (datetime)) ;; Returns "Time: 20230308T231049.934630" +(. "Time:" sp (datetime)) ;; Returns "Time: 20230308T231049.934630" ``` [top](#top) @@ -1095,7 +1097,7 @@ Example: `(round [])` The `round` function rounds a number to *precision* digits after the decimal point. The default is 0, meaning to round to nearest integer. - + Example: ```lisp diff --git a/docs/ACMEScript-metatags.md b/docs/ACMEScript-metatags.md index 44b35ddb..6d2c1226 100644 --- a/docs/ACMEScript-metatags.md +++ b/docs/ACMEScript-metatags.md @@ -28,6 +28,7 @@ Meta tags are keyword that start with an at-sign "@". They can appear anywhere i | [Text UI](#_textui) | [@category](#meta_category) | Add a category to the script for the text UI's *Tools* section | | | [@tuiAutoRun](#meta_tuiAutoRun) | Automatically run scripts when selecting them, and optionally repeat | | | [@tuiExecuteButton](#meta_tuiExecuteButton) | Configure the script's `Execute` button in the text UI | +| | [@tuiInput](#meta_tuiInput) | Add an input field for script arguments in the text UI | | | [@tuiSortOrder](#meta_tuiSortOrder) | Specify the sort order for scripts in a category in the text UI's *Tools* section | | | [@tuiTool](#meta_tuiTool) | Tag a script for listing in the text UI's *Tools* section | @@ -434,9 +435,11 @@ Example: ### @tuiExecuteButton -`@tuiExecuteButton [] ` +`@tuiExecuteButton []` -This meta tag configures the script's `Execute` button of the text UI. The following configurations are possible +This meta tag configures the script's `Execute` button of the text UI. + +The following configurations are possible: - Not present in a script: The button displays the default text "Execute". - Present in a script with an argument: The argument is used for the button's label. @@ -452,19 +455,24 @@ Example: --- - + -### @tuiTool +### @tuiInput -`@tuiTool` +`@tuiInput []` -This meta tag categorizes a script as a tool. Scripts marked as *tuiTools* are listed in the Text UI's *Tools* -section. +This meta tag adds an input field to text UI. Text entered in this field is passed as +arguments to the script that can be access using the [argv](ACMEScript-functions.md#argv) function. + +The following configurations are possible: + +- Not present in a script or without a label: No input field is added. +- Present in a script with an argument: The argument is used for the input field's label. Example: ```lisp -@tuiTool +@tuiInput A Label ``` [top](#top) @@ -492,5 +500,24 @@ Example: --- + + +### @tuiTool + +`@tuiTool` + +This meta tag categorizes a script as a tool. Scripts marked as *tuiTools* are listed in the Text UI's *Tools* +section. + +Example: + +```lisp +@tuiTool +``` + +[top](#top) + +--- + [← ACMEScript](ACMEScript.md) [← README](../README.md) From 5519762510fd5e5676c062db7f869d48bb578687 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 10 Aug 2023 13:47:57 +0200 Subject: [PATCH 078/165] Added the comparison for shorter strings to soundex (similar to "startswith") --- acme/helpers/TextTools.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/acme/helpers/TextTools.py b/acme/helpers/TextTools.py index a5de876b..47ba48f1 100644 --- a/acme/helpers/TextTools.py +++ b/acme/helpers/TextTools.py @@ -299,7 +299,7 @@ def isNumber(string:Any) -> bool: ('R', '6'), ) -def soundex(s:str) -> str: +def soundex(s:str, maxCount:Optional[int] = 4) -> str: """ Convert a string to a Soundex value. Args: @@ -337,23 +337,32 @@ def soundex(s:str) -> str: if ch != 'H' and ch != 'W': # leave last alone if middle letter is H or W last = None - if count == 4: + if count == maxCount: break result += '0' * (4 - count) return ''.join(result) -def soundsLike(s1:str, s2:str) -> bool: +def soundsLike(s1:str, s2:str, maxCount:Optional[int] = 4) -> bool: """ Compare two strings using the soundex algorithm. Args: s1: First string to compare. s2: Second string to compare. + maxCount: Maximum number of soundex result characters to compare. Return: Boolean indicating the result of the comparison. """ + # Remove 0 characters from the soundex result because they indicate a too short string + _s1 = soundex(s1, maxCount).replace('0', '') + _s2 = soundex(s2, maxCount).replace('0', '') + + # Only take the smaller number of characters of the soundex result into account + _l = min(len(_s1), len(_s2)) + return _s1[:_l] == _s2[:_l] + return soundex(s1) == soundex(s2) From cc92e9a42b98af473ce860f7c7e34db9c69dc3f1 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 10 Aug 2023 13:48:09 +0200 Subject: [PATCH 079/165] Corrected act attribute --- init/attributePolicies.ap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index 7c5f237a..cd4ec538 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -315,7 +315,7 @@ "act": [ { "rtypes": [ "ALL" ], - "lname": "accessControWindow", + "lname": "activate", "ns": "m2m", "type": "boolean", "car": "01", From edaeb91bba5852265ebfe9fa9818e2c6ceeeba0b Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 10 Aug 2023 13:48:50 +0200 Subject: [PATCH 080/165] Added etype attribute to store enum name in attributePolicies --- acme/etc/Types.py | 1 + acme/services/Importer.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/acme/etc/Types.py b/acme/etc/Types.py index 06c243ef..93fb0516 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -2087,6 +2087,7 @@ class AttributePolicy: typeName:str = None # The type as written in the definition fname:str = None # Name of the definition file ltype:BasicType = None # sub-type of a list + etype:str = None # name of the enum type lTypeName:str = None # sub-type of a list as writen in the definition evalues:dict[int, str] = None # Dict of enum values and interpretations ptype:Type = None # Implementation type of the enum values diff --git a/acme/services/Importer.py b/acme/services/Importer.py index 6c1439f8..fc8cca07 100644 --- a/acme/services/Importer.py +++ b/acme/services/Importer.py @@ -546,6 +546,7 @@ def _parseAttribute(self, attr:JSON, # Check and determine the list type lTypeName:str = None ltype:BasicType = None + etype:str = None evalues:dict[int, str] = None if checkListType: # TODO remove this when flexContainer definitions support list sub-types if lTypeName := findXPath(attr, 'ltype'): @@ -611,6 +612,7 @@ def _parseAttribute(self, attr:JSON, ctype = ctype, fname = fn, ltype = ltype, + etype = etype, lTypeName = lTypeName, evalues = evalues ) From 333e454c575248fde5ade59e8460d4813ef0ece3 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 10 Aug 2023 15:25:18 +0200 Subject: [PATCH 081/165] Added fuzzy attribute info search --- acme/services/ScriptManager.py | 74 +++++++++++++++++++++++++++++++++- acme/services/Validator.py | 43 +++++++++++++++++++- docs/ACMEScript-functions.md | 30 ++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/acme/services/ScriptManager.py b/acme/services/ScriptManager.py index c797d859..b854c83c 100644 --- a/acme/services/ScriptManager.py +++ b/acme/services/ScriptManager.py @@ -18,7 +18,7 @@ from ..helpers.KeyHandler import FunctionKey -from ..etc.Types import JSON, ACMEIntEnum, CSERequest, Operation, ResourceTypes, Result +from ..etc.Types import JSON, ACMEIntEnum, CSERequest, Operation, ResourceTypes, Result, BasicType, AttributePolicy from ..etc.ResponseStatusCodes import ResponseException from ..etc.DateUtils import cronMatchesTimestamp, getResourceDate, utcDatetime from ..etc.Utils import runsInIPython, uniqueRI, isURL, uniqueID, pureResource @@ -119,6 +119,7 @@ def __init__(self, symbols = { 'clear-console': self.doClearConsole, 'create-resource': self.doCreateResource, + 'cse-attribute-infos': self.doCseAttributeInfos, 'cse-status': self.doCseStatus, 'delete-resource': self.doDeleteResource, 'get-config': self.doGetConfiguration, @@ -322,6 +323,77 @@ def doCreateResource(self, pcontext:PContext, symbol:SSymbol) -> PContext: return self._handleRequest(cast(ACMEPContext, pcontext), symbol, Operation.CREATE) + def doCseAttributeInfos(self, pcontext:PContext, symbol:SSymbol) -> PContext: + """ Return a list of CSE attribute infos for the given attribute name. + The search is done over the short and long names of the attributes using + a fuzzy search when searching the long names. + + The function has the following arguments: + + - attribute name. This could be a short name or a long name. + + The function returns a quoted list where each entry is another quoted list + with the following symbols: + + - attribute short name + - attribute long name + - attribute type + + Example: + :: + + (cse-attribute-info "acop") -> ( ( "acop" "accessControlOperations" "nonNegInteger" ) ) + + Args: + pcontext: `PContext` object of the running script. + symbol: The symbol to execute. + + Return: + The updated `PContext` object with the operation result. + """ + + def _getType(t:BasicType, policy:AttributePolicy) -> str: # type:ignore [return] + match t: + case BasicType.list | BasicType.listNE if policy.lTypeName != 'enum': + return f'{policy.typeName} of {policy.lTypeName}' + case BasicType.list | BasicType.listNE if policy.lTypeName == 'enum': + return f'{policy.typeName} of {_getType(BasicType.enum, policy)}' + case BasicType.complex: + return policy.typeName + case BasicType.enum: + return f'enum ({policy.etype})' + case _: + return policy.typeName + + + pcontext.assertSymbol(symbol, 2) + + # get attribute name + pcontext, _name = pcontext.valueFromArgument(symbol, 1, SType.tString) + + result = CSE.validator.getAttributePoliciesByName(_name) + resultSymbolList = [] + if result is not None: + for policy in result: + # Determine exact type + _t = _getType(policy.type, policy) + # match policy.type: + # case BasicType.list | BasicType.listNE: + # _t = f'{policy.typeName} of {policy.lTypeName}' + # case BasicType.complex: + # _t = policy.typeName + # case BasicType.enum: + # _t = f'enum ({policy.etype})' + # case _: + # _t = policy.typeName + + resultSymbolList.append(SSymbol(lstQuote = [ SSymbol(string = policy.sname), + SSymbol(string = policy.lname), + SSymbol(string = _t) ])) + + return pcontext.setResult(SSymbol(lstQuote = resultSymbolList)) + + def doCseStatus(self, pcontext:PContext, symbol:SSymbol) -> PContext: """ Retrieve the CSE status. diff --git a/acme/services/Validator.py b/acme/services/Validator.py index 92380cfc..f5168604 100644 --- a/acme/services/Validator.py +++ b/acme/services/Validator.py @@ -20,7 +20,7 @@ from ..etc.Types import CSEType, ResourceTypes, Permission, Operation, NotificationContentType, NotificationEventType from ..etc.ResponseStatusCodes import ResponseStatusCode, BAD_REQUEST, ResponseException, CONTENTS_UNACCEPTABLE from ..etc.Utils import pureResource, strToBool -from ..helpers.TextTools import findXPath +from ..helpers.TextTools import findXPath, soundsLike from ..etc.DateUtils import fromAbsRelTimestamp from ..helpers import TextTools from ..resources.Resource import Resource @@ -519,6 +519,47 @@ def getAttributePolicy(self, rtype:ResourceTypes|str, attr:str) -> AttributePoli return None + def getAttributePoliciesByName(self, attr:str) -> Optional[list[AttributePolicy]]: + """ Return the attribute policies for an attribute name. + + Args: + attr: Attribute name. + + Return: + List of AttributePolicy or None. + """ + result = { } + keys = attributePolicies.keys() + _attrlower = attr.lower() + + # First search for the specific attribute name + for each in keys: + s = each[1] + if s == _attrlower: + result[s] = attributePolicies[each] + break + + # If it couldn't be found, search for similar full attribute names + if not result: + for each in keys: + s = each[1] + v = attributePolicies[each] + if soundsLike(_attrlower, v.lname, 99): + if s not in result: + result[s] = v + + # If it couldn't be found, search for parts of the attribute name + for each in keys: + s = each[1] + v = attributePolicies[each] + if _attrlower in v.lname.lower(): + if s not in result: + result[s] = v + + + return [ each for each in result.values() ] + + def getComplexTypeAttributePolicies(self, ctype:str) -> Optional[list[AttributePolicy]]: if (attrs := complexTypeAttributes.get(ctype)): return [ self.getAttributePolicy(ctype, attr) for attr in attrs ] diff --git a/docs/ACMEScript-functions.md b/docs/ACMEScript-functions.md index 796e9066..e37b4d0c 100644 --- a/docs/ACMEScript-functions.md +++ b/docs/ACMEScript-functions.md @@ -67,6 +67,7 @@ The following built-in functions and variables are provided by the ACMEScript in | | [Logical Operations](#logical-operations) | List of supported logical operations | | | [Mathematical Operations](#mathematical-operations) | List of supported mathematical operations | | [CSE](#_cse) | [clear-console](#clear-console) | Clear the console screen | +| | [cse-attribute-info](#cse-atribute-info) | Return information about one or more matching attributes | | | [cse-status](#cse-status) | Return the CSE's current status | | | [get-config](#get-config) | Retrieve a CSE's configuration setting | | | [get-loglevel](#get-loglevel) | Retrieve the CSE's current log level | @@ -1487,6 +1488,35 @@ Example: [top](#top) +--- + + + +### cse-attribute-info + +`(cse-attribute-info )` + +Return a list of CSE attribute infos for the attribute `name``. +The search is done over the short and long names of the attributes applying +a fuzzy search when searching the long names. + + +The function returns a quoted list where each entry is another quoted list +with the following symbols: + +- attribute short name +- attribute long name +- attribute type + +Example: + +```lisp +(cse-attribute-info "acop") ;; Returns ( ( "acop" "accessControlOperations" "nonNegInteger" ) ) +``` + +[top](#top) + + --- From b268fd23df3b55e53c345d9478df79921fbef5f3 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 10 Aug 2023 15:28:18 +0200 Subject: [PATCH 082/165] Updated --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53552762..f0caa734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [CSE] Added automatic pip install of missing dependencies during startup. - [CSE] Added support for <schedule> resource type. - [LCP] Added (limited) support for <locationPolicy> resource type and location management for *device based* location policies. -- [SCRIPTS] Added "dolist", "dotimes", "tui-notify", and "get-loglevel" functions to the script interpreter. +- [SCRIPTS] Added "dolist", "dotimes", "tui-notify", "cse-attribute-info", sand "get-loglevel" functions to the script interpreter. - [TUI] Improved resource view in the text UI. Enumeration interpretations are now shown. +- [TUI] Added utility "Attribute Info Search". ### Experimental From 41b20efcb48a88aa3be1892bdf40593211864829 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 10 Aug 2023 15:28:38 +0200 Subject: [PATCH 083/165] Corrected some attribute names --- init/complexTypePolicies.ap | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/init/complexTypePolicies.ap b/init/complexTypePolicies.ap index 16802cc6..42c8465b 100644 --- a/init/complexTypePolicies.ap +++ b/init/complexTypePolicies.ap @@ -112,10 +112,10 @@ { "rtypes": [ "COMPLEX" ], "ctype": "m2m:requestPrimitive", - "lname": "resourceType", + "lname": "desiredIdentifierResultType", "ns": "m2m", "type": "enum", - "etype": "m2m:resourceType", + "etype": "m2m:desIdResType", "car": "01" } ], @@ -360,17 +360,17 @@ { "rtypes": [ "COMPLEX" ], "ctype": "m2m:filterCriteria", - "lname": "accessControlOperations", + "lname": "operations", "ns": "m2m", - "type": "nonNegInteger", // m2m:accessControlOperations. Not just an enum, but a bitmap + "type": "nonNegInteger", "car": "01" }, { "rtypes": [ "COMPLEX" ], "ctype": "m2m:operationMonitor", - "lname": "accessControlOperations", + "lname": "operations", "ns": "m2m", - "type": "nonNegInteger", // m2m:accessControlOperations. Not just an enum, but a bitmap + "type": "nonNegInteger", "car": "01" } ], From bc42d577fa58cd6452ba6852604d9e6497f335af Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 10 Aug 2023 15:29:03 +0200 Subject: [PATCH 084/165] Updated onboarding templates --- acme/services/Onboarding.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/acme/services/Onboarding.py b/acme/services/Onboarding.py index f060d6a7..de6ca4a7 100644 --- a/acme/services/Onboarding.py +++ b/acme/services/Onboarding.py @@ -336,6 +336,13 @@ def csePolicies() -> InquirerPySessionResult: allowedCSROriginators=id-in,id-mn,id-asn """ + cnfRegular = \ +""" +[scripting] +scriptDirectories=${cse:resourcesPath}/utilities +""" + + cnfDevelopment = \ """ [textui] @@ -347,6 +354,9 @@ def csePolicies() -> InquirerPySessionResult: [http] enableUpperTesterEndpoint=true enableStructureEndpoint=true + +[scripting] +scriptDirectories=${cse:resourcesPath}/utilities """ cnfIntroduction = \ @@ -358,7 +368,7 @@ def csePolicies() -> InquirerPySessionResult: enable=true [scripting] -scriptDirectories=${cse:resourcesPath}/demoLightbulb,${cse:resourcesPath}/demoDocumentationTutorials +scriptDirectories=${cse:resourcesPath}/demoLightbulb,${cse:resourcesPath}/demoDocumentationTutorials,${cse:resourcesPath}/utilities """ cnfHeadless = \ @@ -371,14 +381,15 @@ def csePolicies() -> InquirerPySessionResult: jcnf = '[basic.config]\n' + '\n'.join(cnf) + cnfExtra # add more mode-specific configurations - if cseEnvironment in ('Development'): - jcnf += cnfDevelopment - - if cseEnvironment in ('Introduction'): - jcnf += cnfIntroduction - - if cseEnvironment in ('Headless'): - jcnf += cnfHeadless + match cseEnvironment: + case 'Regular': + jcnf += cnfRegular + case 'Development': + jcnf += cnfDevelopment + case 'Introduction': + jcnf += cnfIntroduction + case 'Headless': + jcnf += cnfHeadless # Show configuration and confirm write _print('\n[b]Save configuration\n') From f0f22397b364c92eecb52a22618ab2cdf375474f Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 10 Aug 2023 15:37:58 +0200 Subject: [PATCH 085/165] Added "Attribute Info Search" script --- init/utilities/utilAttributeInfo.as | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 init/utilities/utilAttributeInfo.as diff --git a/init/utilities/utilAttributeInfo.as b/init/utilities/utilAttributeInfo.as new file mode 100644 index 00000000..2ed682d1 --- /dev/null +++ b/init/utilities/utilAttributeInfo.as @@ -0,0 +1,19 @@ +@name Attribute Info Search +@tuiTool +@category Utilities +@tuiInput Attribute +@tuiExecuteButton Search +@description ## Attribute Info Search\n\nThis tool provides fuzzy searches for an attribute name or short name, and prints out the attribute(s) information.\n\n*Note, that some scalar types are mapped to a more general type, such as "string"*. + + +(clear-console) + +(if (!= argc 2) + ((print "[red]Add a single identifier without spaces") + (quit))) + +(dolist (attribute (cse-attribute-infos (argv 1))) + ((print "[dodger_blue2]attribute = " (nth 1 attribute)) + (print "[dark_orange]short name = " (nth 0 attribute)) + (print "type = " (nth 2 attribute) nl))) + From d1c3a9ae4bdd49f29f362ee8a8cf9c12be3a2d39 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 11 Aug 2023 11:54:05 +0200 Subject: [PATCH 086/165] Added possibility to automatically import scripts from special sub-directories in init --- CHANGELOG.md | 1 + acme/services/Importer.py | 10 +++++++++- acme/services/Onboarding.py | 8 ++------ acme/services/ScriptManager.py | 8 ++++---- docs/ACMEScript.md | 15 +++++++++------ docs/Importing.md | 4 ++-- init/{ => system.scripts}/utReset.as | 0 init/{ => system.scripts}/utStatus.as | 0 init/{ => testing.scripts}/testCaseEnd.as | 0 init/{ => testing.scripts}/testCaseStart.as | 0 .../testsDisableShortRequestExpiration.as | 0 .../testsDisableShortResourceExpiration.as | 0 .../testsEnableShortRequestExpiration.as | 0 .../testsEnableShortResourceExpiration.as | 0 .../utilAttributeInfo.as | 0 15 files changed, 27 insertions(+), 19 deletions(-) rename init/{ => system.scripts}/utReset.as (100%) rename init/{ => system.scripts}/utStatus.as (100%) rename init/{ => testing.scripts}/testCaseEnd.as (100%) rename init/{ => testing.scripts}/testCaseStart.as (100%) rename init/{ => testing.scripts}/testsDisableShortRequestExpiration.as (100%) rename init/{ => testing.scripts}/testsDisableShortResourceExpiration.as (100%) rename init/{ => testing.scripts}/testsEnableShortRequestExpiration.as (100%) rename init/{ => testing.scripts}/testsEnableShortResourceExpiration.as (100%) rename init/{utilities => utilities.scripts}/utilAttributeInfo.as (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0caa734..af36df76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - [CSE] Changed the *operationResult* of <request> according to SDS-2022-0010R02. - [CSE] Changed the oneM2M enumeration definition format. Each enumeration type is now a dictionary of enumeration values and their interpretations. +- [SCRIPTS] Moved utilities and system scripts to sub-directories. Now all scripts from directories "*.scripts" in the "init" directory are automatically imported. - [TUI] Simplified the request list view in the text UI. diff --git a/acme/services/Importer.py b/acme/services/Importer.py index fc8cca07..5c107b18 100644 --- a/acme/services/Importer.py +++ b/acme/services/Importer.py @@ -37,6 +37,7 @@ class Importer(object): __slots__ = ( 'resourcePath', + 'extendedResourcePath', 'macroMatch', 'isImporting', @@ -52,6 +53,7 @@ def __init__(self) -> None: """ Initialization of an *Importer* instance. """ self.resourcePath = Configuration.get('cse.resourcesPath') + self.extendedResourcePath = None self.macroMatch = re.compile(r"\$\{[\w.]+\}") self.isImporting = False L.isInfo and L.log('Importer initialized') @@ -95,7 +97,7 @@ def removeImports(self) -> None: # Scripts # - def importScripts(self, path:Optional[str] = None) -> bool: + def importScripts(self, path:Optional[str|list[str]] = None) -> bool: """ Import the ACME script from a directory. Args: @@ -110,6 +112,12 @@ def importScripts(self, path:Optional[str] = None) -> bool: if (path := self.resourcePath) is None: L.logErr('cse.resourcesPath not set') raise RuntimeError('cse.resourcesPath not set') + path = [ path ] + for _e in os.scandir(self.resourcePath): + if _e.is_dir() and _e.name.endswith('.scripts'): + path.append(_e.path) + self.extendedResourcePath = path # save for later use + self._prepareImporting() try: L.isInfo and L.log(f'Importing scripts from directory(s): {path}') diff --git a/acme/services/Onboarding.py b/acme/services/Onboarding.py index de6ca4a7..03bdf2b4 100644 --- a/acme/services/Onboarding.py +++ b/acme/services/Onboarding.py @@ -338,8 +338,7 @@ def csePolicies() -> InquirerPySessionResult: cnfRegular = \ """ -[scripting] -scriptDirectories=${cse:resourcesPath}/utilities + """ @@ -354,9 +353,6 @@ def csePolicies() -> InquirerPySessionResult: [http] enableUpperTesterEndpoint=true enableStructureEndpoint=true - -[scripting] -scriptDirectories=${cse:resourcesPath}/utilities """ cnfIntroduction = \ @@ -368,7 +364,7 @@ def csePolicies() -> InquirerPySessionResult: enable=true [scripting] -scriptDirectories=${cse:resourcesPath}/demoLightbulb,${cse:resourcesPath}/demoDocumentationTutorials,${cse:resourcesPath}/utilities +scriptDirectories=${cse:resourcesPath}/demoLightbulb,${cse:resourcesPath}/demoDocumentationTutorials """ cnfHeadless = \ diff --git a/acme/services/ScriptManager.py b/acme/services/ScriptManager.py index b854c83c..5f0f1a3a 100644 --- a/acme/services/ScriptManager.py +++ b/acme/services/ScriptManager.py @@ -1779,11 +1779,11 @@ def checkScriptUpdates(self) -> bool: del self.scripts[eachName] # Read new scripts - if CSE.importer.resourcePath: # from the init directory - if self.loadScriptsFromDirectory(CSE.importer.resourcePath) == -1: + if CSE.importer.extendedResourcePath: # from the init directory + if self.loadScriptsFromDirectory(CSE.importer.extendedResourcePath) == -1: L.isWarn and L.logWarn('Cannot import new scripts') - if CSE.script.scriptDirectories: # from the extra script directories - if self.loadScriptsFromDirectory(CSE.script.scriptDirectories) == -1: + if self.scriptDirectories: # from the extra script directories + if self.loadScriptsFromDirectory(self.scriptDirectories) == -1: L.isWarn and L.logWarn('Cannot import new scripts') return True diff --git a/docs/ACMEScript.md b/docs/ACMEScript.md index 098dd9a9..4e4accf8 100644 --- a/docs/ACMEScript.md +++ b/docs/ACMEScript.md @@ -10,6 +10,7 @@ The \[ACME] CSE supports a lisp-based scripting language, called ACMEScript, tha - Update CSE configuration settings. - Call internal CSE functions. - Run scheduled script jobs. +- Implement tool scripts for the [Text UI](TextUI.md). **Table of Contents** @@ -113,17 +114,19 @@ Meta tags are described in [a separate document](ACMEScript-metatags.md). ## Loading and Running Scripts -Scripts are stored in the *init* directory, and ind a list of directories that [can be specified](Configuration.md#scripting) in the configuration file. +Scripts are stored in and are imported from the *init* directory and in sub-directories, which names end with *.scripts*, of the *init* directory. +One can also specify a [list of directories](Configuration.md#scripting) in the configuration file with additional scripts that will be imported. All files with the extension "*.as*" are treated as ACMEScript files and are automatically imported during CSE startup and also imported and updated during runtime. There are different ways to run scripts: -- They can be run from the console interface with the `R` (Run) command. -- They can be run by a keypress from the console interface (see [onKey](ACMEScript-metatags.md#meta_onkey) meta tag). -- They can be scheduled to run at specific times or dates. This is similar to the Unix cron system (see [at](ACMEScript-metatags.md#meta_at) meta tag). -- They can be scheduled to run at certain events. Currently, the CSE [init](ACMEScript-metatags.md#meta_init), [onStartup](ACMEScript-metatags.md#meta_onstartup), [onRestart](ACMEScript-metatags.md#meta_onrestart), and [onShutdown](ACMEScript-metatags.md#meta_onshutdown) events are supported. -- They can be run as a receiver of a NOTIFY request from the CSE. See [onNotification](ACMEScript-metatags.md#meta_onnotification) meta tag. +- Scripts can be run from the console interface with the `R` (Run) command. +- They can also be run by a keypress from the console interface (see [onKey](ACMEScript-metatags.md#meta_onkey) meta tag). +- Scripts can be scheduled to run at specific times or dates. This is similar to the Unix cron system (see [at](ACMEScript-metatags.md#meta_at) meta tag). +- It is possible to schedule scripts to run at certain events. Currently, the CSE [init](ACMEScript-metatags.md#meta_init), [onStartup](ACMEScript-metatags.md#meta_onstartup), [onRestart](ACMEScript-metatags.md#meta_onrestart), and [onShutdown](ACMEScript-metatags.md#meta_onshutdown) events are supported. +- Scrips can be run as a receiver of a NOTIFY request from the CSE. See [onNotification](ACMEScript-metatags.md#meta_onnotification) meta tag. - They can also be run as a command of the [Upper Tester Interface](Operation.md#upper_tester). +- Scripts can be integrated as tools in the [Text UI](TextUI.md). See also the available [meta-tags](ACMEScript-metatags.md#_textui) for available tags. diff --git a/docs/Importing.md b/docs/Importing.md index abe49d0c..95531663 100644 --- a/docs/Importing.md +++ b/docs/Importing.md @@ -2,14 +2,14 @@ # CSE Startup, Importing Resources and Other Settings -[Resources](#resources) +[Initial Resources](#resources) [Attribute and Hierarchy Policies for FlexContainer Specializations](#flexcontainers) [Attribute Policies for Common Resources and Complex Types](#attributes) [Help Documentation](#help-documentation) -## Resources +## Initial Resources During CSE startup and restart it is necessary to import a first set of resources to the CSE. This is done automatically by the CSE by running a script that has the [@init](ACMEScript-metatags.md#meta_init) meta tag set. By default this is the [init.as](../init/init.as) script from the [init](../init) directory. diff --git a/init/utReset.as b/init/system.scripts/utReset.as similarity index 100% rename from init/utReset.as rename to init/system.scripts/utReset.as diff --git a/init/utStatus.as b/init/system.scripts/utStatus.as similarity index 100% rename from init/utStatus.as rename to init/system.scripts/utStatus.as diff --git a/init/testCaseEnd.as b/init/testing.scripts/testCaseEnd.as similarity index 100% rename from init/testCaseEnd.as rename to init/testing.scripts/testCaseEnd.as diff --git a/init/testCaseStart.as b/init/testing.scripts/testCaseStart.as similarity index 100% rename from init/testCaseStart.as rename to init/testing.scripts/testCaseStart.as diff --git a/init/testsDisableShortRequestExpiration.as b/init/testing.scripts/testsDisableShortRequestExpiration.as similarity index 100% rename from init/testsDisableShortRequestExpiration.as rename to init/testing.scripts/testsDisableShortRequestExpiration.as diff --git a/init/testsDisableShortResourceExpiration.as b/init/testing.scripts/testsDisableShortResourceExpiration.as similarity index 100% rename from init/testsDisableShortResourceExpiration.as rename to init/testing.scripts/testsDisableShortResourceExpiration.as diff --git a/init/testsEnableShortRequestExpiration.as b/init/testing.scripts/testsEnableShortRequestExpiration.as similarity index 100% rename from init/testsEnableShortRequestExpiration.as rename to init/testing.scripts/testsEnableShortRequestExpiration.as diff --git a/init/testsEnableShortResourceExpiration.as b/init/testing.scripts/testsEnableShortResourceExpiration.as similarity index 100% rename from init/testsEnableShortResourceExpiration.as rename to init/testing.scripts/testsEnableShortResourceExpiration.as diff --git a/init/utilities/utilAttributeInfo.as b/init/utilities.scripts/utilAttributeInfo.as similarity index 100% rename from init/utilities/utilAttributeInfo.as rename to init/utilities.scripts/utilAttributeInfo.as From 11a8015e0c17daba8fc323673253f3ba24a34eeb Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 14 Aug 2023 15:49:44 -0400 Subject: [PATCH 087/165] Clear input field value if tool changes --- acme/textui/ACMEContainerTools.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/acme/textui/ACMEContainerTools.py b/acme/textui/ACMEContainerTools.py index 1093d939..3159e2e5 100644 --- a/acme/textui/ACMEContainerTools.py +++ b/acme/textui/ACMEContainerTools.py @@ -96,6 +96,8 @@ def _showTool(self, node:TreeNode) -> None: # Stop a currently running autorun worker when the node is different # from the previous autorun node self.stopAutoRunScript(str(node.label)) + self.parentContainer.toolsInput.value = '' + if node.children: # This is a category node, so set the description, clear the button etc. From c9eef9dc7b6c757bc6e729ccb5ea0625f205b6ed Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 24 Aug 2023 13:31:20 +0200 Subject: [PATCH 088/165] Added LCP to supported and statistics --- acme/services/Console.py | 3 ++- docs/ACMEScript.md | 2 +- docs/Supported.md | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/acme/services/Console.py b/acme/services/Console.py index 36eb7e62..42959ae5 100644 --- a/acme/services/Console.py +++ b/acme/services/Console.py @@ -1260,6 +1260,7 @@ def _stats() -> Table: resourceTypes += f'FCNT : {CSE.dispatcher.countResources(ResourceTypes.FCNT)}\n' resourceTypes += f'FCI : {CSE.dispatcher.countResources(ResourceTypes.FCI)}\n' resourceTypes += f'GRP : {CSE.dispatcher.countResources(ResourceTypes.GRP)}\n' + resourceTypes += f'LCP : {CSE.dispatcher.countResources(ResourceTypes.LCP)}\n' resourceTypes += f'MgmtObj : {CSE.dispatcher.countResources(ResourceTypes.MGMTOBJ)}\n' resourceTypes += f'NOD : {CSE.dispatcher.countResources(ResourceTypes.NOD)}\n' resourceTypes += f'PCH : {CSE.dispatcher.countResources(ResourceTypes.PCH)}\n' @@ -1274,7 +1275,7 @@ def _stats() -> Table: resourceTypes += '\n' resourceTypes += _markup(f'[bold]Total[/bold] : {int(stats[Statistics.resourceCount]) - _virtualCount}\n') # substract the virtual resources # Correct height - resourceTypes += '\n' * (tableWorkers.row_count + 5) + resourceTypes += '\n' * (tableWorkers.row_count + 4) result = Table.grid(expand = True) diff --git a/docs/ACMEScript.md b/docs/ACMEScript.md index 4e4accf8..f4c7bcac 100644 --- a/docs/ACMEScript.md +++ b/docs/ACMEScript.md @@ -178,7 +178,7 @@ In the following example the s-expression `(+ 1 2)` is evaluated when the string Evaluation can be locally disabled by escaping the opening part: ```lisp - (print "1 + 2 = \\${ + 1 2 }") ;; Prints "1 + 2 = [(+ 1 2)]" + (print "1 + 2 = \\${ + 1 2 }") ;; Prints "1 + 2 = ${ + 1 2 )}" ``` Evaluation can also be disabled and enabled by using the [evaluate-inline](ACMEScript-functions.md#evaluate-inline) function. diff --git a/docs/Supported.md b/docs/Supported.md index d02ceb8b..a1e364f9 100644 --- a/docs/Supported.md +++ b/docs/Supported.md @@ -41,7 +41,7 @@ The ACME CSE supports the following oneM2M resource types: | FlexContainer & Specializations | ✓ | Any specialization is supported and validated. See [Importing Attribute Policies](Importing.md#attributes) for further details.
Supported specializations include: TS-0023 R4, GenericInterworking, AllJoyn. | | FlexContainerInstance | ✓ | Experimental. This is an implementation of the draft FlexContainerInstance specification. | | Group (GRP) | ✓ | The support includes requests via the *fopt* (fanOutPoint) virtual resource. Groups may contain remote resources. | -| LocationPolicy (LCP) | ✓ | Only *device based* location policy is supported. | +| LocationPolicy (LCP) | ✓ | Only *device based* location policy is supported. The LCP's *cnt* stores geo-coordinates and geo-fencing results. | | Management Objects | ✓ | See also the list of supported [management objects](#mgmtobjs). | | Node (NOD) | ✓ | | | Polling Channel (PCH) | ✓ | Support for Request and Notification long-polling via the *pcu* (pollingChannelURI) virtual resource. *requestAggregation* functionality is supported, too. | From 003b948b889947e9d6909d71ebc4d968ae510f4c Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 24 Aug 2023 14:24:33 +0200 Subject: [PATCH 089/165] Changed resource ID attribute types to propper ID type. Fixed location attribute definition --- acme/etc/Types.py | 1 + acme/resources/GRPAnnc.py | 1 - acme/resources/NODAnnc.py | 1 - acme/resources/SMDAnnc.py | 1 - acme/resources/TSI.py | 1 - acme/resources/TSIAnnc.py | 1 - acme/services/Validator.py | 5 +++++ init/attributePolicies.ap | 29 ++++++++++++++--------------- init/complexTypePolicies.ap | 18 +++++++++--------- 9 files changed, 29 insertions(+), 29 deletions(-) diff --git a/acme/etc/Types.py b/acme/etc/Types.py index 93fb0516..8cbd14c2 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -620,6 +620,7 @@ class BasicType(ACMEIntEnum): schedule = auto() # scheduleEntry time = timestamp # alias type for time date = timestamp # alias type for date + ID = auto() # m2m:ID @classmethod def to(cls, name:str|Tuple[str], insensitive:Optional[bool] = True) -> BasicType: diff --git a/acme/resources/GRPAnnc.py b/acme/resources/GRPAnnc.py index feb3bd21..f5c087fd 100644 --- a/acme/resources/GRPAnnc.py +++ b/acme/resources/GRPAnnc.py @@ -36,7 +36,6 @@ class GRPAnnc(AnnouncedResource): 'acpi':None, 'daci': None, 'ast': None, - 'loc': None, 'lnk': None, # Resource attributes diff --git a/acme/resources/NODAnnc.py b/acme/resources/NODAnnc.py index 110ff123..d8edbcb3 100644 --- a/acme/resources/NODAnnc.py +++ b/acme/resources/NODAnnc.py @@ -38,7 +38,6 @@ class NODAnnc(AnnouncedResource): 'acpi':None, 'daci': None, 'ast': None, - 'loc': None, 'lnk': None, # Resource attributes diff --git a/acme/resources/SMDAnnc.py b/acme/resources/SMDAnnc.py index a35301f0..740e36f9 100644 --- a/acme/resources/SMDAnnc.py +++ b/acme/resources/SMDAnnc.py @@ -33,7 +33,6 @@ class SMDAnnc(AnnouncedResource): 'acpi':None, 'daci': None, 'ast': None, - 'loc': None, 'lnk': None, # Resource attributes diff --git a/acme/resources/TSI.py b/acme/resources/TSI.py index e3aeb3f7..9d85469c 100644 --- a/acme/resources/TSI.py +++ b/acme/resources/TSI.py @@ -37,7 +37,6 @@ class TSI(AnnounceableResource): 'aa': None, 'ast': None, 'cr': None, - 'loc': None, # Resource attributes 'dgt': None, diff --git a/acme/resources/TSIAnnc.py b/acme/resources/TSIAnnc.py index 7685783c..c7063eb1 100644 --- a/acme/resources/TSIAnnc.py +++ b/acme/resources/TSIAnnc.py @@ -31,7 +31,6 @@ class TSIAnnc(AnnouncedResource): 'et': None, 'lbl': None, 'ast': None, - 'loc': None, 'lnk': None, # Resource attributes diff --git a/acme/services/Validator.py b/acme/services/Validator.py index f5168604..ec2bd472 100644 --- a/acme/services/Validator.py +++ b/acme/services/Validator.py @@ -727,6 +727,9 @@ def _validateType(self, dataType:BasicType, case BasicType.string | BasicType.anyURI if isinstance(value, str): return (dataType, value) + case BasicType.ID if isinstance(value, str): # TODO check for valid resourceID + return (dataType, value) + case BasicType.list | BasicType.listNE if isinstance(value, list): if dataType == BasicType.listNE and len(value) == 0: raise BAD_REQUEST('empty list is not allowed') @@ -775,6 +778,8 @@ def _validateType(self, dataType:BasicType, raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: float') case BasicType.geoCoordinates if isinstance(value, dict): + + # TODO geoJSON validation return (dataType, value) case BasicType.duration: diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index cd4ec538..a37c4a07 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -21,7 +21,7 @@ "rtypes": [ "ALL" ], "lname": "resourceID", "ns": "m2m", - "type": "string", + "type": "ID", "car": "1", "oc": "NP", "ou": "NP", @@ -61,7 +61,7 @@ "rtypes": [ "ALL" ], "lname": "parentID", "ns": "m2m", - "type": "string", + "type": "ID", "car": "1", "oc": "NP", "ou": "NP", @@ -75,7 +75,7 @@ "lname": "accessControlPolicyIDs", "ns": "m2m", "type": "list", - "ltype": "string", + "ltype": "ID", "car": "01L", "oc": "O", "ou": "O", @@ -244,11 +244,10 @@ ], "loc": [ { - "rtypes": [ "ALL" ], + "rtypes": [ "AE", "AEAnnc", "CSEBase", "CSEBaseAnnc", "CSR", "CSRAnnc", "CNT", "CNTAnnc", "FCNT", "FCNTAnnc", "TS", "TSAnnc", "REQRESP" ], "lname": "location", "ns": "m2m", - "type": "list", - "ltype": "m2m:geoCoordinates", + "type": "m2m:geoCoordinates", "car": "01L", "oc": "O", "ou": "O", @@ -256,7 +255,7 @@ "annc": "OA" }, { - "rtypes": [ "DVI" ], + "rtypes": [ "DVI", "DVIAnnc" ], "lname": "location", "ns": "m2m", "type": "string", @@ -272,7 +271,7 @@ "rtypes": [ "ALL" ], "lname": "custodian", "ns": "m2m", - "type": "string", + "type": "ID", "car": "01", "oc": "O", "ou": "O", @@ -357,7 +356,7 @@ "rtypes": [ "ALL" ], "lname": "AE-ID", "ns": "m2m", - "type": "string", + "type": "ID", "car": "1", "oc": "NP", "ou": "NP", @@ -569,7 +568,7 @@ "rtypes": [ "TSB", "TSBAnnc", "REQRESP" ], "lname": "beaconRequester", "ns": "m2m", - "type": "string", + "type": "ID", "car": "01", "oc": "O", "ou": "NP", @@ -858,7 +857,7 @@ "rtypes": [ "ALL" ], "lname": "creator", "ns": "m2m", - "type": "string", + "type": "ID", "car": "01", "oc": "O", "ou": "NP", @@ -906,7 +905,7 @@ "rtypes": [ "CSEBase", "CSRAnnc" ], "lname": "CSE-ID", "ns": "m2m", - "type": "string", + "type": "ID", "car": "1", "oc": "M", "ou": "NP", @@ -917,7 +916,7 @@ "rtypes": [ "CSR" ], "lname": "CSE-ID", "ns": "m2m", - "type": "string", + "type": "ID", "car": "1", "oc": "O", "ou": "NP", @@ -1445,7 +1444,7 @@ "rtypes": [ "ALL" ], "lname": "hostedCSELink", "ns": "m2m", - "type": "string", + "type": "ID", "car": "01", "oc": "O", "ou": "O", @@ -2457,7 +2456,7 @@ "rtypes": [ "ALL", "REQRESP" ], "lname": "originator", "ns": "m2m", - "type": "string", + "type": "ID", "car": "1", "oc": "NP", "ou": "NP", diff --git a/init/complexTypePolicies.ap b/init/complexTypePolicies.ap index 42c8465b..aaec7e6e 100644 --- a/init/complexTypePolicies.ap +++ b/init/complexTypePolicies.ap @@ -219,7 +219,7 @@ "ctype": "m2m:operationResult", "lname": "from", "ns": "m2m", - "type": "string", + "type": "ID", "car": "01" }, { @@ -227,14 +227,14 @@ "ctype": "m2m:requestPrimitive", "lname": "from", "ns": "m2m", - "type": "anyURI", + "type": "ID", "car": "01" }, { "rtypes": [ "REQRESP" ], "lname": "from", "ns": "m2m", - "type": "anyURI", + "type": "ID", "car": "01", "oc": "M", "ou": "NP", @@ -634,7 +634,7 @@ "ctype": "m2m:operationResult", "lname": "to", "ns": "m2m", - "type": "anyURI", + "type": "ID", "car": "01" }, { @@ -642,14 +642,14 @@ "ctype": "m2m:requestPrimitive", "lname": "to", "ns": "m2m", - "type": "anyURI", + "type": "ID", "car": "1" }, { "rtypes": [ "REQRESP" ], "lname": "to", "ns": "m2m", - "type": "string", + "type": "ID", "car": "1", "oc": "M", "ou": "NP", @@ -964,7 +964,7 @@ { "rtypes": [ "COMPLEX" ], "ctype": "m2m:actionInput", - "lname": "resourceID", + "lname": "ID", "ns": "m2m", "type": "anyURI", "car": "01" @@ -1680,7 +1680,7 @@ "lname": "ontologyMappingResources", "ns": "m2m", "type": "list", - "ltype": "string", // m2m:listOfM2MID + "ltype": "ID", // m2m:listOfM2MID "car": "01" } ], @@ -1738,7 +1738,7 @@ "ctype": "m2m:operationMonitor", "lname": "originator", "ns": "m2m", - "type": "string", // m2m:ID + "type": "ID", "car": "01" } ], From e0165d3fc1b4374a31665947e1ce4774dd3d0ab9 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 25 Aug 2023 02:10:10 +0200 Subject: [PATCH 090/165] Improved if and while functions to support empty lists or strings, and nil values in expression part --- acme/helpers/Interpreter.py | 34 +++++++++++++++++++++++++--------- acme/services/ScriptManager.py | 2 +- docs/ACMEScript-functions.md | 6 ++++++ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index 70d5a61d..097e5b6a 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -903,6 +903,16 @@ def setError(self, error:PError, self.state = state self.error = PErrorState(error, msg, expression, exception) return self + + + def clearError(self, state:Optional[PState] = PState.running) -> None: + """ Clear the error status. + + Args: + state: `PState` to indicate the state of the script. Default is "running". + """ + self.state = state + self.error = PErrorState(PError.noError, 0, '', None) def copyError(self, pcontext:PContext) -> None: @@ -2271,7 +2281,10 @@ def _doIf(pcontext:PContext, symbol:SSymbol) -> PContext: """ pcontext.assertSymbol(symbol, minLength = 3) - pcontext, _e = pcontext.valueFromArgument(symbol, 1, SType.tBool) + pcontext, _e = pcontext.valueFromArgument(symbol, 1, (SType.tBool, SType.tNIL, SType.tList, SType.tListQuote, SType.tString)) + if isinstance(_e, (list, str)): + _e = len(_e) > 0 + if _e: _p = pcontext._executeExpression(symbol[2], symbol) elif symbol.length == 4: @@ -2786,6 +2799,7 @@ def _doOperation(pcontext:PContext, symbol:SSymbol, op:Callable, tp:SType) -> PC """ pcontext.assertSymbol(symbol, minLength = 2) r1 = pcontext._executeExpression(symbol[1], symbol).result + result = deepcopy(r1) for i in range(2, symbol.length): try: @@ -2793,11 +2807,11 @@ def _doOperation(pcontext:PContext, symbol:SSymbol, op:Callable, tp:SType) -> PC r2 = pcontext._executeExpression(symbol[i], symbol).result # If the first operant is a list, then we have to perform a bit different - if r1.type in (SType.tList, SType.tListQuote): + if result.type in (SType.tList, SType.tListQuote): # If both operants are list then do a raw comparison if r2.type in (SType.tList, SType.tListQuote): - r1.value = op(r1.raw(), r2.raw()) + result.value = op(result.raw(), r2.raw()) # If the second operant is NOT a list, then iterate of the first and do the # operation. If any succeeds, then the operation is true. @@ -2806,14 +2820,14 @@ def _doOperation(pcontext:PContext, symbol:SSymbol, op:Callable, tp:SType) -> PC if tp != SType.tBool: raise PInvalidTypeError(pcontext.setError(PError.invalidType, f'if the first operant is a list then iterating over it is only allowed for boolean operators: {symbol}')) _v1 = None - for s in cast(list, r1.value): + for s in cast(list, result.value): if _v1 := op(s.value, r2.value): # True if any break - r1.value = _v1 + result.value = _v1 # Otherwise just apply the operator else: - r1.value = op(r1.value, r2.value) + result.value = op(result.value, r2.value) except ZeroDivisionError as e: raise PDivisionByZeroError(pcontext.setError(PError.divisionByZero, str(e))) except TypeError as e: @@ -2823,8 +2837,8 @@ def _doOperation(pcontext:PContext, symbol:SSymbol, op:Callable, tp:SType) -> PC raise PDivisionByZeroError(pcontext.setError(PError.divisionByZero, str(e))) raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'invalid arguments in expression: {str(e)}')) - r1.type = tp - return pcontext.setResult(r1) + result.type = tp + return pcontext.setResult(result) def _doParseString(pcontext:PContext, symbol:SSymbol) -> PContext: @@ -3399,7 +3413,9 @@ def _doWhile(pcontext:PContext, symbol:SSymbol) -> PContext: while True: # evaluate while expression - pcontext, _e = pcontext.valueFromArgument(symbol, 1, SType.tBool) + pcontext, _e = pcontext.valueFromArgument(symbol, 1, (SType.tBool, SType.tNIL, SType.tList, SType.tListQuote, SType.tString)) + if isinstance(_e, (list, str)): + _e = len(_e) > 0 if not _e: break diff --git a/acme/services/ScriptManager.py b/acme/services/ScriptManager.py index 5f0f1a3a..75df005d 100644 --- a/acme/services/ScriptManager.py +++ b/acme/services/ScriptManager.py @@ -241,7 +241,7 @@ def errorMessage(self) -> str: Return: String with the error message. """ - return f'{self.error.error.name} error in {self.scriptFilename} - {self.error.message}' + return f'"{self.error.error.name}" error in {self.scriptFilename} - {self.error.message}' @property diff --git a/docs/ACMEScript-functions.md b/docs/ACMEScript-functions.md index e37b4d0c..bfd84b00 100644 --- a/docs/ACMEScript-functions.md +++ b/docs/ACMEScript-functions.md @@ -530,12 +530,16 @@ Examples: --- + + ### if `(if [])` The `if` function works like an “if-then-else” statement in other programing languages. The first argument is a boolean expression. If it evaluates to *true* then the second argument is executed. If it evaluates to *false* then the third (optional) argument is executed, if present. +The boolean expression can be any s-expression that evaluates to a boolean value or *nil*, or a list or a string. *nil* values, empty lists, or zero-length strings evaluate to *false*, or to *true* otherwise. + Example: ```lisp @@ -1348,6 +1352,8 @@ The `while` function implements a loop functionality. A `while` loop continues to run when the first *guard* s-expression evaluates to *true*. Then the *body* s-expression is evaluated. After this the *guard* is evaluated again and the the loops continues or the `while` function returns. +The boolean guard can be any s-expression that evaluates to a boolean value or *nil*, or a list or a string. *nil* values, empty lists, or zero-length strings evaluate to *false*, or to *true* otherwise. + The `while` function returns the result of the last evaluated s-expression in the *body*. See also: [doloop](#doloop), [dotime](#dotimes), [return](#return) From d1e8b871d6a0c1092e770ce044c3570a1cda305e Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 25 Aug 2023 23:57:01 +0200 Subject: [PATCH 091/165] Added missing description for vrq --- init/attributePolicies.ap | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index a37c4a07..7759547e 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -3429,6 +3429,15 @@ "annc": "OA" } ], + "vrq": [ + { + "rtypes": [ "UNKNOWN" ], + "lname": "verificationRequest", + "ns": "m2m", + "type": "boolean" + } + ], + "wcrds": [ { "rtypes": [ "WIFIC", "WIFICAnnc", "REQRESP" ], From 5f09612086507a766007e9b29fd277a20505e592 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 25 Aug 2023 23:58:34 +0200 Subject: [PATCH 092/165] Improved JSON formatting for text UI --- acme/helpers/TextTools.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/acme/helpers/TextTools.py b/acme/helpers/TextTools.py index 47ba48f1..f3801bc1 100644 --- a/acme/helpers/TextTools.py +++ b/acme/helpers/TextTools.py @@ -66,7 +66,7 @@ def commentJson(data:Union[str, dict], """ if isinstance(data, dict): - data = json.dumps(data, indent=4, sort_keys=True) + data = json.dumps(data, indent=2, sort_keys=True) # find longest line maxLineLength = 0 @@ -120,7 +120,6 @@ def commentJson(data:Union[str, dict], result.append(line) else: if width is not None and maxLength > width: # Put comment above line - result.append('') result.append(f'{" " * (len(line) - len(line.lstrip()))}{comment}') result.append(line) else: From 45928af0c555106d614c70a12e4195977ef86bfb Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 25 Aug 2023 23:58:59 +0200 Subject: [PATCH 093/165] Corrected typos --- acme/helpers/Interpreter.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index 097e5b6a..5346f7dc 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -1935,10 +1935,6 @@ def _doDatetime(pcontext:PContext, symbol:SSymbol) -> PContext: format = _format return pcontext.setResult(SSymbol(string = _utcNow().strftime(_format))) - if symbol.length == 2: - pcontext, _format = pcontext.valueFromArgument(symbol, 1, SType.tString) - return pcontext.setResult(SSymbol(string = _utcNow().strftime(_format))) - def _doDefun(pcontext:PContext, symbol:SSymbol) -> PContext: """ This function defines a new function. @@ -2022,7 +2018,7 @@ def _doDolist(pcontext:PContext, symbol:SSymbol) -> PContext: # code pcontext, _code = pcontext.valueFromArgument(symbol, 2, SType.tList, doEval = False) # don't evaluate the argument (yet) - _code = SSymbol(lst = _code) # We got a python list, but must have a SSymbol list + _code = SSymbol(lst = _code) # We got a python list, but need a SSymbol list # execute the code pcontext.variables[str(_loopvar)] = SSymbol(number = Decimal(0)) From bbb206168a878e64a41347f0c0c9c87364c32507 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sat, 26 Aug 2023 13:23:56 +0200 Subject: [PATCH 094/165] Changed the default network interface for http and mqtt to 0.0.0.0 --- CHANGELOG.md | 2 ++ acme.ini.default | 5 +++-- acme/services/Configuration.py | 6 +++--- init/configurations.docmd | 4 ++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af36df76..e5003cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - [CSE] Changed the *operationResult* of <request> according to SDS-2022-0010R02. - [CSE] Changed the oneM2M enumeration definition format. Each enumeration type is now a dictionary of enumeration values and their interpretations. +- [HTTP] The default network interface has been changed from "127.0.0.1" to "0.0.0.0". +- [MQTT] The default network interface has been changed from "127.0.0.1" to "0.0.0.0". - [SCRIPTS] Moved utilities and system scripts to sub-directories. Now all scripts from directories "*.scripts" in the "init" directory are automatically imported. - [TUI] Simplified the request list view in the text UI. diff --git a/acme.ini.default b/acme.ini.default index 023f1c99..a8cbd949 100644 --- a/acme.ini.default +++ b/acme.ini.default @@ -132,7 +132,8 @@ size=200 [http] ; Port to listen to. Default: 8080 port=${basic.config:httpPort} -; Interface to listen to. Use 0.0.0.0 for "all" interfaces. Default:127.0.0.1 +; Interface to listen to. Use 0.0.0.0 for "all" interfaces. +; Default:0.0.0.0 listenIF=${basic.config:networkInterface} ; Own address. Should be a local/public reachable address. ; Default: http://127.0.0.1:8080 @@ -211,7 +212,7 @@ port=1883 ; Default: 60 seconds keepalive=60 ; Interface to listen to. Use 0.0.0.0 for "all" interfaces. -; Default: 127.0.0.1 +; Default: 0.0.0.0 listenIF=${basic.config:networkInterface} ; Optional prefix for topics. ; Default: empty string diff --git a/acme/services/Configuration.py b/acme/services/Configuration.py index 5361e5a5..61d39ab7 100644 --- a/acme/services/Configuration.py +++ b/acme/services/Configuration.py @@ -302,7 +302,7 @@ def init(args:argparse.Namespace = None) -> bool: 'http.allowPatchForDelete' : config.getboolean('http', 'allowPatchForDelete', fallback = False), 'http.enableStructureEndpoint' : config.getboolean('http', 'enableStructureEndpoint', fallback = False), 'http.enableUpperTesterEndpoint' : config.getboolean('http', 'enableUpperTesterEndpoint', fallback = False), - 'http.listenIF' : config.get('http', 'listenIF', fallback = '127.0.0.1'), + 'http.listenIF' : config.get('http', 'listenIF', fallback = '0.0.0.0'), 'http.port' : config.getint('http', 'port', fallback = 8080), 'http.root' : config.get('http', 'root', fallback = ''), 'http.timeout' : config.getfloat('http', 'timeout', fallback = 10.0), @@ -346,7 +346,7 @@ def init(args:argparse.Namespace = None) -> bool: 'mqtt.address' : config.get('mqtt', 'address', fallback = '127.0.0.1'), 'mqtt.enable' : config.getboolean('mqtt', 'enable', fallback = False), 'mqtt.keepalive' : config.getint('mqtt', 'keepalive', fallback = 60), - 'mqtt.listenIF' : config.get('mqtt', 'listenIF', fallback = '127.0.0.1'), + 'mqtt.listenIF' : config.get('mqtt', 'listenIF', fallback = '0.0.0.0'), 'mqtt.port' : config.getint('mqtt', 'port', fallback = None), # Default will be determined later (s.b.) 'mqtt.timeout' : config.getfloat('mqtt', 'timeout', fallback = 10.0), 'mqtt.topicPrefix' : config.get('mqtt', 'topicPrefix', fallback = ''), @@ -367,7 +367,7 @@ def init(args:argparse.Namespace = None) -> bool: # 'coap.enable' : config.getboolean('coap', 'enable', fallback = False), - 'coap.listenIF' : config.get('coap', 'listenIF', fallback = '127.0.0.1'), + 'coap.listenIF' : config.get('coap', 'listenIF', fallback = '0.0.0.0'), 'coap.port' : config.getint('coap', 'port', fallback = None), # Default will be determined later (s.b.) # diff --git a/init/configurations.docmd b/init/configurations.docmd index 708af284..22459a76 100644 --- a/init/configurations.docmd +++ b/init/configurations.docmd @@ -735,7 +735,7 @@ The default value is `False`. This setting specifies the network interface on which the CSE's HTTP server is listening. Use `0.0.0.0` to listen on all available interfaces. -The default value is `127.0.0.1`. +The default value is `0.0.0.0`. @@ -980,7 +980,7 @@ The default value is `60 seconds`. This setting specifies the network interface on which the CSE's MQTT client is binding to. Use `0.0.0.0` to listen on all available interfaces. -The default value is `127.0.0.1`. +The default value is `0.0.0.0`. From eab6544a68082adceaf4fc5983caba5cf6b756d7 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 27 Aug 2023 14:15:35 +0200 Subject: [PATCH 095/165] Removed double defined RSET assignment --- acme/etc/Types.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/acme/etc/Types.py b/acme/etc/Types.py index 8cbd14c2..dba1560b 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -2024,8 +2024,6 @@ def fillOriginalRequest(self, update:bool = False) -> None: self.originalRequest['rset'] = self.rset if self.rtu: self.originalRequest['rtu'] = self.rtu - if self.rset: - self.originalRequest['rset'] = self.rset # TODO is the content serialization type necessary to store? An "ct" is not the right shortname # if self.ct: # self.originalRequest['ct'] = self.ct From 7603739ce8ed0b91eb49b9a70829c28750248c15 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 27 Aug 2023 14:35:45 +0200 Subject: [PATCH 096/165] Fixed type errors introduced in latest TinyDB release --- acme/services/Storage.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/acme/services/Storage.py b/acme/services/Storage.py index 9e272145..100df764 100644 --- a/acme/services/Storage.py +++ b/acme/services/Storage.py @@ -927,7 +927,7 @@ def searchResources(self, ri:Optional[str] = None, with self.lockResources: if ri: _r = self.tabResources.get(doc_id = ri) # type:ignore[arg-type] - return [_r] if _r else [] + return [_r] if _r else [] # type:ignore[list-item] # return self.tabResources.search(self.resourceQuery.ri == ri) elif csi: return self.tabResources.search(self.resourceQuery.csi == csi) @@ -1034,16 +1034,17 @@ def searchIdentifiers(self, ri:Optional[str] = None, Return: A list of found identifier documents (see `insertIdentifier`), or an empty list if not found. """ + _r:Document if srn: - if (_r := self.tabStructuredIDs.get(doc_id = srn)): # type:ignore[arg-type] - ri = _r['ri'] if _r else None + if (_r := self.tabStructuredIDs.get(doc_id = srn)): # type:ignore[arg-type, assignment] + ri = _r['ri'] if _r else None else: return [] # return self.tabIdentifiers.search(self.identifierQuery.srn == srn) if ri: with self.lockIdentifiers: - _r = self.tabIdentifiers.get(doc_id = ri) # type:ignore[arg-type] + _r = self.tabIdentifiers.get(doc_id = ri) # type:ignore[arg-type, assignment] return [_r] if _r else [] # return self.tabIdentifiers.search(self.identifierQuery.ri == ri) return [] @@ -1064,12 +1065,13 @@ def addChildResource(self, resource:Resource, ri:str) -> None: # Then add the child ri to the parent's record if pi: # ATN: CSE has no parent - _r = self.tabChildResources.get(doc_id = pi) # type:ignore[arg-type] + _r:Document + _r = self.tabChildResources.get(doc_id = pi) # type:ignore[arg-type, assignment] _ch = _r['ch'] if ri not in _ch: _ch.append( [ri, ty] ) _r['ch'] = _ch - self.tabChildResources.update(_r, doc_ids = [pi])# type:ignore[arg-type, list-item] + self.tabChildResources.update(_r, doc_ids = [pi]) # type:ignore[arg-type, list-item] def removeChildResource(self, resource:Resource) -> None: @@ -1083,7 +1085,7 @@ def removeChildResource(self, resource:Resource) -> None: self.tabChildResources.remove(doc_ids = [ri]) # type:ignore[arg-type, list-item] # Remove (ri, ty) tuple from parent record - _r = self.tabChildResources.get(doc_id = pi) # type:ignore[arg-type] + _r:Document = self.tabChildResources.get(doc_id = pi) # type:ignore[arg-type, assignment] _t = [ri, resource.ty] _ch = _r['ch'] if _t in _ch: @@ -1094,7 +1096,7 @@ def removeChildResource(self, resource:Resource) -> None: def searchChildResourcesByParentRI(self, pi:str, ty:Optional[int] = None) -> Optional[list[str]]: - _r = self.tabChildResources.get(doc_id = pi) #type:ignore[arg-type] + _r:Document = self.tabChildResources.get(doc_id = pi) #type:ignore[arg-type, assignment] if _r: if ty is None: # optimization: only check ty once for None return [ c[0] for c in _r['ch'] ] @@ -1110,7 +1112,7 @@ def searchSubscriptions(self, ri:Optional[str] = None, pi:Optional[str] = None) -> Optional[list[Document]]: with self.lockSubscriptions: if ri: - _r = self.tabSubscriptions.get(doc_id = ri) # type:ignore[arg-type] + _r:Document = self.tabSubscriptions.get(doc_id = ri) # type:ignore[arg-type, assignment] return [_r] if _r else [] # return self.tabSubscriptions.search(self.subscriptionQuery.ri == ri) if pi: @@ -1222,7 +1224,7 @@ def searchActionReprs(self) -> list[Document]: def getAction(self, ri:str) -> Optional[Document]: with self.lockActions: - return self.tabActions.get(doc_id = ri) # type:ignore[arg-type] + return self.tabActions.get(doc_id = ri) # type:ignore[arg-type, return-value] def searchActionsDeprsForSubject(self, ri:str) -> Sequence[JSON]: @@ -1387,7 +1389,7 @@ def getSchedule(self, ri:str) -> Optional[Document]: The schedule, or *None* if not found. """ with self.lockSchedules: - return self.tabSchedules.get(doc_id = ri) # type:ignore[arg-type] + return self.tabSchedules.get(doc_id = ri) # type:ignore[arg-type, return-value] def searchSchedules(self, pi:str) -> list[Document]: From f000fc81dc1577d0c3a28126e66e6823a68e1388 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 27 Aug 2023 14:36:12 +0200 Subject: [PATCH 097/165] Added attribute definition for rset --- init/complexTypePolicies.ap | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/init/complexTypePolicies.ap b/init/complexTypePolicies.ap index aaec7e6e..946aff5b 100644 --- a/init/complexTypePolicies.ap +++ b/init/complexTypePolicies.ap @@ -535,7 +535,15 @@ "ns": "m2m", "type": "absRelTimestamp", "car": "01" + }, + { + "rtypes": [ "REQRESP" ], + "lname": "resultExpirationTimestamp", + "ns": "m2m", + "type": "absRelTimestamp", + "car": "01" } + ], "rt": [ { From adb99bc3b51aa737fb94377bd753fa7a5dea061c Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 5 Sep 2023 18:29:39 +0200 Subject: [PATCH 098/165] Added support for Result Expiration Timestamp --- acme/etc/RequestUtils.py | 4 ++ acme/etc/Types.py | 9 +++-- acme/resources/REQ.py | 2 +- acme/resources/Resource.py | 2 +- acme/services/Dispatcher.py | 62 +++++++++++++++++++++++-------- acme/services/GroupManager.py | 40 +++++++++++++++++--- acme/services/HttpServer.py | 3 ++ acme/services/RequestManager.py | 60 ++++++++++++++++++++++++------ docs/Supported.md | 49 ++++++++++++------------ tests/testLCP.py | 2 +- tests/testRequests.py | 66 +++++++++++++++++++++++++++------ 11 files changed, 225 insertions(+), 74 deletions(-) diff --git a/acme/etc/RequestUtils.py b/acme/etc/RequestUtils.py index ca76b842..7a49fa69 100644 --- a/acme/etc/RequestUtils.py +++ b/acme/etc/RequestUtils.py @@ -222,6 +222,10 @@ def requestFromResult(inResult:Result, # Result Content if inResult.request.drt: req['drt'] = int(inResult.request.drt) + + # Result Expiration Timestamp + if inResult.request.rset: + req['rset'] = inResult.request.rset diff --git a/acme/etc/Types.py b/acme/etc/Types.py index dba1560b..75aa7cfb 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -1915,15 +1915,18 @@ def __post_init__(self) -> None: _rqetUTCts:float = None # X-M2M-RET as UTC based timestamp """ Request Expiration Timestamp as UTC-based timestamp (internal). """ + rset:str = None + """ Result Expiration Time in ISO8901 format or as ms (X-M2M-RST). """ + + _rsetUTCts:float = None # X-M2M-RET as UTC based timestamp + """ Result Expiration Timestamp as UTC-based timestamp (internal). """ + ot:str = None """ Originating Timestamp in ISO8901 format. """ oet:str = None """ Operation Execution Time in ISO8901 format or as ms (X-M2M-OET). """ - rset:str = None - """ Result Expiration Time in ISO8901 format or as ms (X-M2M-RST). """ - rtu:list[str] = None """ The notificationURI element of the Response Type parameter(X-M2M-RTU). """ diff --git a/acme/resources/REQ.py b/acme/resources/REQ.py index 75743ad8..6107d26e 100644 --- a/acme/resources/REQ.py +++ b/acme/resources/REQ.py @@ -75,7 +75,7 @@ def createRequestResource(request:CSERequest) -> Resource: The created REQ resource. """ - # Check if a an expiration ts has been set in the request + # Check if a request expiration ts has been set in the request if request.rqet: et = request.rqet # This is already an ISO8601 timestamp diff --git a/acme/resources/Resource.py b/acme/resources/Resource.py index f49a38f2..a5cdc42f 100644 --- a/acme/resources/Resource.py +++ b/acme/resources/Resource.py @@ -699,7 +699,7 @@ def getFinalResourceAttribute(self, key:str, dct:Optional[JSON]) -> Any: dct: The dictionary with updated attributes. Return: - The either updated attribute, or old value if the attribute is not updated. The methon returns *None* if the attribute does not exists. + The either updated attribute, or old value if the attribute is not updated. The method- returns *None* if the attribute does not exists. """ value = self.attribute(key) # old value if dct is not None: diff --git a/acme/services/Dispatcher.py b/acme/services/Dispatcher.py index 19ba36c8..423fd728 100644 --- a/acme/services/Dispatcher.py +++ b/acme/services/Dispatcher.py @@ -116,9 +116,10 @@ def processRetrieveRequest(self, request:CSERequest, CSE.validator.validateAttribute('atrl', attributeList) # Handle operation execution time , and check CSE schedule and request expiration - self._handleOperationExecutionTime(request) + self.handleOperationExecutionTime(request) self._checkActiveCSESchedule() - self._checkRequestExpiration(request) + self.checkRequestExpiration(request) + self.checkResultExpiration(request) # handle fanout point requests if (fanoutPointResource := self._getFanoutPointResource(srn)) and fanoutPointResource.ty == ResourceTypes.GRP_FOPT: @@ -132,7 +133,6 @@ def processRetrieveRequest(self, request:CSERequest, L.isDebug and L.logDebug(f'Redirecting request : {pollingChannelURIRsrc.getSrn()}') return pollingChannelURIRsrc.handleRetrieveRequest(request, id, originator) - # EXPERIMENTAL # Handle latest and oldest RETRIEVE if (laOlResource := self._latestOldestResource(srn)): # We need to check the srn here @@ -572,9 +572,10 @@ def processCreateRequest(self, request:CSERequest, raise NOT_FOUND(L.logDebug('resource not found')) # Handle operation execution time, and check CSE schedule and request expiration - self._handleOperationExecutionTime(request) + self.handleOperationExecutionTime(request) self._checkActiveCSESchedule() - self._checkRequestExpiration(request) + self.checkRequestExpiration(request) + self.checkResultExpiration(request) # handle fanout point requests if (fanoutPointRsrc := self._getFanoutPointResource(srn)) and fanoutPointRsrc.ty == ResourceTypes.GRP_FOPT: @@ -806,9 +807,10 @@ def processUpdateRequest(self, request:CSERequest, raise NOT_FOUND(L.logDebug('resource not found')) # Handle operation execution time , and check CSE schedule and request expiration - self._handleOperationExecutionTime(request) + self.handleOperationExecutionTime(request) self._checkActiveCSESchedule() - self._checkRequestExpiration(request) + self.checkRequestExpiration(request) + self.checkResultExpiration(request) # handle fanout point requests if (fanoutPointResource := self._getFanoutPointResource(fopsrn)) and fanoutPointResource.ty == ResourceTypes.GRP_FOPT: @@ -975,9 +977,10 @@ def processDeleteRequest(self, request:CSERequest, raise NOT_FOUND(L.logDebug('resource not found')) # Handle operation execution time , and check CSE schedule and request expiration - self._handleOperationExecutionTime(request) + self.handleOperationExecutionTime(request) self._checkActiveCSESchedule() - self._checkRequestExpiration(request) + self.checkRequestExpiration(request) + self.checkResultExpiration(request) # handle fanout point requests if (fanoutPointRsrc := self._getFanoutPointResource(fopsrn)) and fanoutPointRsrc.ty == ResourceTypes.GRP_FOPT: @@ -1150,9 +1153,10 @@ def processNotifyRequest(self, request:CSERequest, srn, id = self._checkHybridID(request, id) # overwrite id if another is given # Handle operation execution time, and check CSE schedule and request expiration - self._handleOperationExecutionTime(request) + self.handleOperationExecutionTime(request) self._checkActiveCSESchedule() - self._checkRequestExpiration(request) + self.checkRequestExpiration(request) + self.checkResultExpiration(request) # get resource to be notified and check permissions targetResource = self.retrieveResource(id) @@ -1384,7 +1388,7 @@ def deleteChildResources(self, parentResource:Resource, # Request execution utilities # - def _handleOperationExecutionTime(self, request:CSERequest) -> None: + def handleOperationExecutionTime(self, request:CSERequest) -> None: """ Handle operation execution time and request expiration. If the OET is set then wait until the provided timestamp is reached. @@ -1399,7 +1403,7 @@ def _handleOperationExecutionTime(self, request:CSERequest) -> None: waitFor(delay) - def _checkRequestExpiration(self, request:CSERequest) -> None: + def checkRequestExpiration(self, request:CSERequest) -> None: """ Check request expiration timeout if a request timeout is give. Args: @@ -1409,7 +1413,25 @@ def _checkRequestExpiration(self, request:CSERequest) -> None: `REQUEST_TIMEOUT`: In case the request is expired """ if request._rqetUTCts is not None and timeUntilTimestamp(request._rqetUTCts) <= 0.0: - raise REQUEST_TIMEOUT(L.logDebug('request timed out')) + raise REQUEST_TIMEOUT(L.logDebug('request timed out reached')) + + + def checkResultExpiration(self, request:CSERequest) -> None: + """ Check result expiration timeout if a result timeout is given. + + Args: + request: The request to check. + + Raises: + `REQUEST_TIMEOUT`: In case the result is expired + `BAD_REQUEST`: In case the request expiration timestamp is greater than the result expiration timestamp. + """ + if not request.rset: + return + if timeUntilTimestamp(request._rsetUTCts) <= 0.0: + raise REQUEST_TIMEOUT(L.logDebug('result timed out reached')) + if request.rqet is not None and request._rsetUTCts < request._rqetUTCts: + raise BAD_REQUEST(L.logDebug('result expiration timestamp must be greater than request expiration timestamp'), data = request) def _checkActiveCSESchedule(self) -> None: @@ -1488,7 +1510,17 @@ def _resourceTreeReferences(self, resources:list[Resource], drt:Optional[DesiredIdentifierResultType] = DesiredIdentifierResultType.structured, tp:Optional[str] = 'm2m:rrl') -> Resource|JSON: """ Retrieve child resource references of a resource and add them to - a new target resource as "children" """ + a **new** target resource instance as "children" + + Args: + resources: A list of resources to retrieve the child resource references from. + targetResource: The target resource to add the child resource references to. + drt: Either structured or unstructured. Defaults to structured. + tp: The type of the target resource. Defaults to 'm2m:rrl'. + + Return: + The target resource with the added child resource references. + """ if not targetResource: targetResource = { } diff --git a/acme/services/GroupManager.py b/acme/services/GroupManager.py index 9853724e..ac899cd2 100644 --- a/acme/services/GroupManager.py +++ b/acme/services/GroupManager.py @@ -13,10 +13,11 @@ from typing import cast, List from ..etc.Types import ResourceTypes, Result, ConsistencyStrategy, Permission, Operation -from ..etc.Types import CSERequest, JSON +from ..etc.Types import CSERequest, JSON, ResponseType from ..etc.ResponseStatusCodes import MAX_NUMBER_OF_MEMBER_EXCEEDED, INVALID_ARGUMENTS, NOT_FOUND, RECEIVER_HAS_NO_PRIVILEGES -from ..etc.ResponseStatusCodes import ResponseStatusCode, GROUP_MEMBER_TYPE_INCONSISTENT, ORIGINATOR_HAS_NO_PRIVILEGE +from ..etc.ResponseStatusCodes import ResponseStatusCode, GROUP_MEMBER_TYPE_INCONSISTENT, ORIGINATOR_HAS_NO_PRIVILEGE, REQUEST_TIMEOUT from ..etc.Utils import isSPRelative, csiFromSPRelative, structuredPathFromRI +from ..etc.DateUtils import utcTime from ..resources.FCNT import FCNT from ..resources.MgmtObj import MgmtObj from ..resources.Resource import Resource @@ -200,6 +201,8 @@ def foptRequest(self, operation:Operation, `Result` instance. """ + L.isDebug and L.logDebug(f'Performing fanOutPoint operation: {operation} on: {id}') + # get parent / group and check permissions if not (groupResource := fopt.retrieveParentResource()): raise NOT_FOUND('group resource not found') @@ -210,10 +213,15 @@ def foptRequest(self, operation:Operation, #check access rights for the originator through memberAccessControlPolicies if not CSE.security.hasAccess(originator, groupResource, requestedPermission = permission, ty = request.ty): raise ORIGINATOR_HAS_NO_PRIVILEGE('insufficient privileges for originator') + + # Determine expiration timestamp + expirationTimestamp = None + if request.rqet is not None: + expirationTimestamp = request._rqetUTCts + if request.rset is not None: + expirationTimestamp = request._rsetUTCts # check whether there is something after the /fopt ... - - # _, _, tail = id.partition('/fopt/') if '/fopt/' in id else (None, None, '') _, _, tail = id.partition('/fopt/') L.isDebug and L.logDebug(f'Adding additional path elements: {tail}') @@ -222,14 +230,26 @@ def foptRequest(self, operation:Operation, resultList:List[Result] = [] tail = '/' + tail if len(tail) > 0 else '' # add remaining path, if any - for mid in groupResource.mid.copy(): # copy mi because it is changed in the loop + _mid = groupResource.mid.copy() # copy mi because it is changed in the loop + for mid in _mid: # Try to get the SRN and add the tail if srn := structuredPathFromRI(mid): mid = srn + tail else: mid = mid + tail # Invoke the request - resultList.append(CSE.request.processRequest(request, originator, mid)) + _result = CSE.request.processRequest(request, originator, mid) + # Check for RSET expiration + if request.rset is not None and request._rsetUTCts < utcTime(): + # Check for blocking request. Then raise a timeout + if request.rt == ResponseType.blockingRequest: + raise REQUEST_TIMEOUT(L.logDebug('Aggregation timed out')) + # Otherwise just interrupt the aggregation + break + # Append the result + resultList.append(_result) + # import time + # time.sleep(1.0) # construct aggregated response if len(resultList) > 0: @@ -245,9 +265,17 @@ def foptRequest(self, operation:Operation, items.append(item) rsp = { 'm2m:rsp' : items} agr = { 'm2m:agr' : rsp } + + # if the request is a flexBlocking request and the number of results is not equal to the number of members + # then the request must be marked as incomplete. This will be removed later when adding to the resource. + if len(_mid) != len(resultList) and request.rt == ResponseType.flexBlocking: + agr['acme:incomplete'] = True # type: ignore + else: agr = {} + L.logWarn(agr) + return Result(rsc = ResponseStatusCode.OK, resource = agr) # Response Status Code is OK regardless of the requested fanout operation diff --git a/acme/services/HttpServer.py b/acme/services/HttpServer.py index b22ec399..a7cf4e45 100644 --- a/acme/services/HttpServer.py +++ b/acme/services/HttpServer.py @@ -573,6 +573,7 @@ def _prepareResponse(self, result:Result, result.request.rvi = originalRequest.rvi result.request.vsi = originalRequest.vsi result.request.ec = originalRequest.ec + result.request.rset = originalRequest.rset # # Transform request to oneM2M request @@ -596,6 +597,8 @@ def _prepareResponse(self, result:Result, headers[Constants().hfRVI] = rvi if vsi := findXPath(cast(JSON, outResult.data), 'vsi'): headers[Constants().hfVSI] = vsi + if rset := findXPath(cast(JSON, outResult.data), 'rset'): + headers[Constants().hfRST] = rset headers[Constants().hfOT] = getResourceDate() # HTTP status code diff --git a/acme/services/RequestManager.py b/acme/services/RequestManager.py index 8c011ed2..f2c62784 100644 --- a/acme/services/RequestManager.py +++ b/acme/services/RequestManager.py @@ -21,7 +21,7 @@ from ..etc.Types import CSERequest, ContentSerializationType, RequestResponseList, RequestResponse from ..etc.ResponseStatusCodes import ResponseException, exceptionFromRSC from ..etc.ResponseStatusCodes import BAD_REQUEST, NOT_FOUND, REQUEST_TIMEOUT, RELEASE_VERSION_NOT_SUPPORTED -from ..etc.ResponseStatusCodes import UNSUPPORTED_MEDIA_TYPE, OPERATION_NOT_ALLOWED +from ..etc.ResponseStatusCodes import UNSUPPORTED_MEDIA_TYPE, OPERATION_NOT_ALLOWED, REQUEST_TIMEOUT from ..etc.DateUtils import getResourceDate, fromAbsRelTimestamp, utcTime, waitFor, toISO8601Date, fromDuration from ..etc.RequestUtils import requestFromResult, determineSerialization, deserializeData from ..etc.Utils import isCSERelative, toSPRelative, isValidCSI, isValidAEI, uniqueRI, isURL, isAbsolute, isSPRelative @@ -277,7 +277,7 @@ def processRequest(self, request:CSERequest, originator:str, id:str) -> Result: Request result """ return self.requestHandlers[request.op].dispatcherRequest(request, originator, id) - + def handleReceivedNotifyRequest(self, id:str, request:CSERequest, originator:str) -> Result: """ Handle a NOTIFY request to resource. @@ -532,16 +532,37 @@ def _runNonBlockingRequestAsync(self, request:CSERequest, reqRi:str) -> bool: def _executeOperation(self, request:CSERequest, reqRi:str) -> REQ: """ Execute a request operation and fill the respective request resource accordingly. + + Args: + request: The request to execute. + reqRi: The resource id. + + Return: + The resource. """ # Execute the actual operation in the dispatcher pc = None try: - operationResult = self.requestHandlers[request.op].dispatcherRequest(request, request.originator) + try: + operationResult = self.requestHandlers[request.op].dispatcherRequest(request, request.originator) + except REQUEST_TIMEOUT: + pass + except ResponseException as e: + raise e + # attributes set below in the request rs = RequestStatus.COMPLETED rsc = operationResult.rsc if operationResult.resource: - pc = operationResult.resource.asDict() + if isinstance(operationResult.resource, Resource): + pc = operationResult.resource.asDict() + else: + # Handle and remove the internal incomplete indicator + if operationResult.resource.get('acme:incomplete'): + rs = RequestStatus.PARTIALLY_COMPLETED + del operationResult.resource['acme:incomplete'] + pc = operationResult.resource + except ResponseException as e: # attributes set below in the request @@ -705,6 +726,10 @@ def queuePollingRequest(self, request:CSERequest, reqType:RequestType=RequestTyp L.isDebug and L.logDebug(f'Request must have a "requestExpirationTimestamp". Adding a default one: {ret}') request.rqet = ret request._rqetUTCts = fromAbsRelTimestamp(ret) + + # Why don't we handle the Result Expiration Timestamo request parameter here? Because it must be + # greater than the Request Expiration Timestamp, so the reqeust expires at that timestamp first anyway. + if not request.rqi: L.logErr(f'Request must have a "requestIdentifier". Ignored. {request}', showStackTrace=False) return @@ -1163,7 +1188,7 @@ def gget(dct:dict, raise BAD_REQUEST(L.logDebug('error in provided Request Expiration Timestamp'), data = cseRequest) else: if _ts < utcTime(): - raise REQUEST_TIMEOUT(L.logDebug(f'request timeout: rqet {_ts} < {utcTime()}'), data = cseRequest) + raise REQUEST_TIMEOUT(L.logDebug(f'request timeout reached: rqet {_ts} < {utcTime()}'), data = cseRequest) else: cseRequest._rqetUTCts = _ts # Re-assign "real" ISO8601 timestamp cseRequest.rqet = toISO8601Date(_ts) @@ -1174,9 +1199,14 @@ def gget(dct:dict, raise BAD_REQUEST(L.logDebug('error in provided Result Expiration Timestamp'), data = cseRequest) else: if _ts < utcTime(): - raise REQUEST_TIMEOUT(L.logDebug('result timeout'), data = cseRequest) + raise REQUEST_TIMEOUT(L.logDebug(f'result timeout reached: rset {_ts} < {utcTime()}'), data = cseRequest) else: - cseRequest.rset = toISO8601Date(_ts) # Re-assign "real" ISO8601 timestamp + cseRequest._rsetUTCts = _ts # Re-assign "real" ISO8601 timestamp + # Re-assign "real" ISO8601 timestamp + try: + cseRequest.rset = int(rset) # type: ignore [assignment] + except ValueError: + cseRequest.rset = toISO8601Date(_ts) # OET - operationExecutionTime if (oet := gget(cseRequest.originalRequest, 'oet', greedy=False)): @@ -1572,6 +1602,16 @@ def recordRequest(self, request:CSERequest, result:Result) -> None: else: rid = 'unknown' + # Map the response + response = { 'rsc': result.rsc, + 'pc': pc, + 'dbg': result.dbg, + 'ot': result.request.ot if result.request and result.request.ot else getResourceDate(), + } + if request.rset: + response['rset'] = request.rset + + request.fillOriginalRequest(update = True) CSE.storage.addRequest(request.op, @@ -1581,9 +1621,5 @@ def recordRequest(self, request:CSERequest, result:Result) -> None: request._outgoing, request.ot if request.ot else toISO8601Date(request._ot), # Only convert now to ISO8601 to avoid unnecessary conversions request.originalRequest, - { 'rsc': result.rsc, - 'pc': pc, - 'dbg': result.dbg, - 'ot': result.request.ot if result.request and result.request.ot else getResourceDate(), - }) + response) \ No newline at end of file diff --git a/docs/Supported.md b/docs/Supported.md index a1e364f9..c204d33c 100644 --- a/docs/Supported.md +++ b/docs/Supported.md @@ -78,30 +78,31 @@ The following table presents the supported management object specifications. ## oneM2M Service Features -| Functionality | Supported | Remark | -|:------------------------------|:---------:|:------------------------------------------------------------------------------------------| -| AE registration | ✓ | | -| Blocking requests | ✓ | | -| Delayed request execution | ✓ | Through the *Operation Execution Timestamp* request attribute. | -| Discovery | ✓ | | -| Location | ✓ | Only *device based, and no *network based* location policies are supported. | -| Long polling | ✓ | Long polling for request unreachable AEs and CSEs through <pollingChannel>. | -| Non-blocking requests | ✓ | Non-blocking synchronous and asynchronous, and flex-blocking, incl. *Result Persistence*. | -| Notifications | ✓ | E.g. for subscriptions and non-blocking requests. | -| Partial Retrieve | ✓ | Support for partial retrieve of individual resource attributes. | -| Remote CSE registration | ✓ | | -| Request expiration | ✓ | Through the *Request Expiration Timestamp* request attribute | -| Request forwarding | ✓ | Forwarding requests from one CSE to another. | -| Request parameter validations | ✓ | | -| Resource addressing | ✓ | *CSE-Relative*, *SP-Relative* and *Absolute* as well as hybrid addressing are supported. | -| Resource announcements | ✓ | Under the CSEBaseAnnc resource (R4 feature). Bi-directional update sync. | -| Resource expiration | ✓ | | -| Resource validations | ✓ | | -| Semantics | ✓ | Basic support for semantic descriptors and semantic queries and discovery. | -| Standard oneM2M requests | ✓ | CREATE, RETRIEVE, UPDATE, DELETE, NOTIFY | -| Subscriptions | ✓ | Incl. batch notification, and resource type and attribute filtering. | -| Time Synchronization | ✓ | | -| TimeSeries data handling | ✓ | Incl. missing data detection, monitoring and notifications. | +| Functionality | Supported | Remark | +|:------------------------------|:---------:|:-------------------------------------------------------------------------------------------------------------------------------------------| +| AE registration | ✓ | | +| Blocking requests | ✓ | | +| Delayed request execution | ✓ | Through the *Operation Execution Timestamp* request attribute. | +| Discovery | ✓ | | +| Location | ✓ | Only *device based, and no *network based* location policies are supported. | +| Long polling | ✓ | Long polling for request unreachable AEs and CSEs through <pollingChannel>. | +| Non-blocking requests | ✓ | Non-blocking synchronous and asynchronous, and flex-blocking, incl. *Result Persistence*. | +| Notifications | ✓ | E.g. for subscriptions and non-blocking requests. | +| Partial Retrieve | ✓ | Support for partial retrieve of individual resource attributes. | +| Remote CSE registration | ✓ | | +| Request expiration | ✓ | The *Request Expiration Timestamp* request attribute | +| Request forwarding | ✓ | Forwarding requests from one CSE to another. | +| Request parameter validations | ✓ | | +| Resource addressing | ✓ | *CSE-Relative*, *SP-Relative* and *Absolute* as well as hybrid addressing are supported. | +| Resource announcements | ✓ | Under the CSEBaseAnnc resource (R4 feature). Bi-directional update sync. | +| Resource expiration | ✓ | | +| Resource validations | ✓ | | +| Result expiration | ✓ | The *Result Expiration Timestamp* request attribute. Result timeouts for non-blocking requests depend on the resource expiration interval. | +| Semantics | ✓ | Basic support for semantic descriptors and semantic queries and discovery. | +| Standard oneM2M requests | ✓ | CREATE, RETRIEVE, UPDATE, DELETE, NOTIFY | +| Subscriptions | ✓ | Incl. batch notification, and resource type and attribute filtering. | +| Time Synchronization | ✓ | | +| TimeSeries data handling | ✓ | Incl. missing data detection, monitoring and notifications. | ### Additional CSE Features diff --git a/tests/testLCP.py b/tests/testLCP.py index 9cbcb423..39d1c8fb 100644 --- a/tests/testLCP.py +++ b/tests/testLCP.py @@ -1,5 +1,5 @@ # -# testLOC.py +# testLCP.py # # (c) 2023 by Andreas Kraft # License: BSD 3-Clause License. See the LICENSE file for further details. diff --git a/tests/testRequests.py b/tests/testRequests.py index a1045904..e1a98a40 100644 --- a/tests/testRequests.py +++ b/tests/testRequests.py @@ -11,7 +11,7 @@ if '..' not in sys.path: sys.path.append('..') from typing import Tuple -from acme.etc.Types import ResourceTypes as T, ResponseStatusCode as RC +from acme.etc.Types import ResourceTypes as T, ResponseStatusCode as RC, ResponseType from init import * # TODO transfer requests @@ -34,6 +34,7 @@ def setUpClass(cls) -> None: cls.ae, rsc = CREATE(cseURL, 'C', T.AE, dct) # AE to work under assert rsc == RC.CREATED, 'cannot create parent AE' cls.originator = findXPath(cls.ae, 'm2m:ae/aei') + enableShortResourceExpirations() testCaseEnd('Setup TestRequests') @@ -46,6 +47,7 @@ def tearDownClass(cls) -> None: testCaseStart('TearDown TestRequests') DELETE(aeURL, ORIGINATOR) # Just delete the AE and everything below it. Ignore whether it exists or not stopNotificationServer() + disableShortResourceExpirations() testCaseEnd('TearDown TestRequests') @@ -102,14 +104,14 @@ def test_OETfutureSeconds(self) -> None: @unittest.skipIf(noCSE, 'No CSEBase') - def test_RETnow(self) -> None: + def test_RETnowFail(self) -> None: """ RETRIEVE with RQET absolute now -> FAIL """ r, rsc = RETRIEVE(aeURL, TestRequests.originator, headers={ C.hfRET : DateUtils.getResourceDate()}) self.assertEqual(rsc, RC.REQUEST_TIMEOUT, r) @unittest.skipIf(noCSE, 'No CSEBase') - def test_RETpast(self) -> None: + def test_RETpastFail(self) -> None: """ RETRIEVE with RQET absolute in the past -> FAIL """ r, rsc = RETRIEVE(aeURL, TestRequests.originator, headers={ C.hfRET : DateUtils.getResourceDate(-10)}) self.assertEqual(rsc, RC.REQUEST_TIMEOUT, r) @@ -123,8 +125,8 @@ def test_RETfuture(self) -> None: @unittest.skipIf(noCSE, 'No CSEBase') - def test_RETpastSeconds(self) -> None: - """ RETRIEVE with RQET seconds in the past """ + def test_RETpastSecondsFail(self) -> None: + """ RETRIEVE with RQET seconds in the past -> Fail""" r, rsc = RETRIEVE(aeURL, TestRequests.originator, headers={ C.hfRET : f'{-expirationCheckDelay*1000}'}) self.assertEqual(rsc, RC.REQUEST_TIMEOUT, r) @@ -144,12 +146,47 @@ def test_OETRETfutureSeconds(self) -> None: @unittest.skipIf(noCSE, 'No CSEBase') - def test_OETRETfutureSecondsWrong(self) -> None: - """ RETRIEVE with OET > RQET seconds in the future """ + def test_OETRETfutureSecondsWrongFail(self) -> None: + """ RETRIEVE with OET > RQET seconds in the future -> Fail""" r, rsc = RETRIEVE(aeURL, TestRequests.originator, headers={ C.hfRET : f'{expirationCheckDelay*1000/2}', C.hfOET : f'{expirationCheckDelay*1000}'}) self.assertEqual(rsc, RC.REQUEST_TIMEOUT, r) + @unittest.skipIf(noCSE, 'No CSEBase') + def test_RSETsmallerThanRETFail(self) -> None: + """ RETRIEVE with RET < RSET - Fail """ + r, rsc = RETRIEVE(aeURL, TestRequests.originator, headers={ C.hfRET : f'{expirationCheckDelay*2000}', C.hfRST : f'{expirationCheckDelay*1000}'}) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_RSETpastFail(self) -> None: + """ RETRIEVE with RSET < now - Fail """ + r, rsc = RETRIEVE(aeURL, TestRequests.originator, headers={ C.hfRST : f'-{expirationCheckDelay*2000}'}) + self.assertEqual(rsc, RC.REQUEST_TIMEOUT, r) + + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_RSETNonBlockingSynchFail(self) -> None: + """ Retrieve non-blocking synchronous with short RSET -> Fail""" + + _rset = expirationCheckDelay * 1000 + r, rsc = RETRIEVE(f'{aeURL}?rt={int(ResponseType.nonBlockingRequestSynch)}', + TestRequests.originator, + headers={ C.hfRST : f'{_rset}'}) + headers = lastHeaders() + self.assertEqual(rsc, RC.ACCEPTED_NON_BLOCKING_REQUEST_SYNC, r) + self.assertIsNotNone(findXPath(r, 'm2m:uri')) + self.assertIn(C.hfRST, headers) + self.assertEqual(headers[C.hfRST], f'{_rset}') + requestURI = findXPath(r, 'm2m:uri') + + # get and check resource + testSleep(requestExpirationDelay * 2) + r, rsc = RETRIEVE(f'{csiURL}/{requestURI}', TestRequests.originator) + self.assertEqual(rsc, RC.NOT_FOUND, r) + + def run(testFailFast:bool) -> Tuple[int, int, int, float]: suite = unittest.TestSuite() @@ -158,13 +195,20 @@ def run(testFailFast:bool) -> Tuple[int, int, int, float]: addTest(suite, TestRequests('test_OETfuture')) addTest(suite, TestRequests('test_OETfuturePeriod')) addTest(suite, TestRequests('test_OETfutureSeconds')) - addTest(suite, TestRequests('test_RETnow')) - addTest(suite, TestRequests('test_RETpast')) + + addTest(suite, TestRequests('test_RETnowFail')) + addTest(suite, TestRequests('test_RETpastFail')) addTest(suite, TestRequests('test_RETfuture')) - addTest(suite, TestRequests('test_RETpastSeconds')) + addTest(suite, TestRequests('test_RETpastSecondsFail')) addTest(suite, TestRequests('test_RETfutureSeconds')) + addTest(suite, TestRequests('test_OETRETfutureSeconds')) - addTest(suite, TestRequests('test_OETRETfutureSecondsWrong')) + addTest(suite, TestRequests('test_OETRETfutureSecondsWrongFail')) + + addTest(suite, TestRequests('test_RSETsmallerThanRETFail')) + addTest(suite, TestRequests('test_RSETpastFail')) + + addTest(suite, TestRequests('test_RSETNonBlockingSynchFail')) result = unittest.TextTestRunner(verbosity=testVerbosity, failfast=testFailFast).run(suite) From 30fa79ba652f66fef5aa7cff0b3b7680db6e6610 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 5 Sep 2023 19:18:28 +0200 Subject: [PATCH 099/165] Added configuration for a CSE default for fanoutPoint aggregation timeout --- CHANGELOG.md | 2 ++ acme.ini.default | 6 ++++ acme/services/Configuration.py | 11 ++++++ acme/services/GroupManager.py | 51 +++++++++++++++++++++++++--- acme/services/RegistrationManager.py | 10 +++--- docs/Configuration.md | 19 +++++++++-- init/configurations.docmd | 15 ++++++++ 7 files changed, 100 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5003cb9..6a9150ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,11 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - [CSE] Added automatic pip install of missing dependencies during startup. - [CSE] Added support for <schedule> resource type. +- [CSE] Added support for *Result Expiration Timestamp* request parameter for handling timeouts in fanoutPoint request aggregations. - [LCP] Added (limited) support for <locationPolicy> resource type and location management for *device based* location policies. - [SCRIPTS] Added "dolist", "dotimes", "tui-notify", "cse-attribute-info", sand "get-loglevel" functions to the script interpreter. - [TUI] Improved resource view in the text UI. Enumeration interpretations are now shown. - [TUI] Added utility "Attribute Info Search". + ### Experimental ### Changed diff --git a/acme.ini.default b/acme.ini.default index a8cbd949..0b743a92 100644 --- a/acme.ini.default +++ b/acme.ini.default @@ -441,6 +441,12 @@ mni=10 mbs=10000 +[resource.grp] +; Set the time for aggregating the results of a group request before interrupting. +; The format is the time in ms. A value of 0 ms means no timeout. +; Default: 0 ms +resultExpirationTime=0 + ; ; Resource defaults: LocationPolicy ; diff --git a/acme/services/Configuration.py b/acme/services/Configuration.py index 61d39ab7..929e572e 100644 --- a/acme/services/Configuration.py +++ b/acme/services/Configuration.py @@ -405,6 +405,13 @@ def init(args:argparse.Namespace = None) -> bool: 'resource.cnt.mbs' : config.getint('resource.cnt', 'mbs', fallback = 10000), + # + # Defaults for Group Resources + # + + 'resource.grp.resultExpirationTime' : config.getint('resource.grp', 'resultExpirationTime', fallback = 0), + + # # Defaults for LocationPolicy Resources # @@ -720,6 +727,10 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: except Exception as e: return False, f'Configuration Error: [i]\[resource.tsb]:bcni[/i]: configuration value must be an ISO8601 duration' + # Check group resource defaults + if Configuration._configuration['resource.grp.resultExpirationTime'] < 0: + return False, f'Configuration Error: [i]\[resource.grp]:resultExpirationTime[/i] must be >= 0' + # Everything is fine return True, None diff --git a/acme/services/GroupManager.py b/acme/services/GroupManager.py index ac899cd2..fa889fa3 100644 --- a/acme/services/GroupManager.py +++ b/acme/services/GroupManager.py @@ -10,7 +10,7 @@ """ This module implements the group service manager functionality. """ from __future__ import annotations -from typing import cast, List +from typing import cast, List, Optional, Any from ..etc.Types import ResourceTypes, Result, ConsistencyStrategy, Permission, Operation from ..etc.Types import CSERequest, JSON, ResponseType @@ -22,10 +22,10 @@ from ..resources.MgmtObj import MgmtObj from ..resources.Resource import Resource from ..resources.GRP_FOPT import GRP_FOPT -from ..resources.GRP import GRP from ..resources.Factory import resourceFromDict from ..services import CSE from ..services.Logging import Logging as L +from ..services.Configuration import Configuration class GroupManager(object): @@ -37,19 +37,49 @@ def __init__(self) -> None: """ # Add delete event handler because we like to monitor the resources in mid CSE.event.addHandler(CSE.event.deleteResource, self.handleDeleteEvent) # type: ignore + + # Add handler for configuration updates + CSE.event.addHandler(CSE.event.configUpdate, self.configUpdate) # type: ignore + + # Add a handler when the CSE is reset + CSE.event.addHandler(CSE.event.cseReset, self.restart) # type: ignore + L.isInfo and L.log('GroupManager initialized') def shutdown(self) -> bool: - """ Shutdown the Group Manager. + """ Shutdown the GroupManager. Returns: - *True* when shutdown complete. + *True* when shutdown is complete. """ L.isInfo and L.log('GroupManager shut down') return True + def _assignConfig(self) -> None: + """ Assign the configuration values. + """ + self.resultExpirationTime = Configuration.get('resource.grp.resultExpirationTime') + + + def configUpdate(self, name:str, + key:Optional[str] = None, + value:Any = None) -> None: + """ Handle configuration updates. + """ + if key not in ( 'resource.grp.resultExpirationTime' ): + return + self._assignConfig() + + + def restart(self, name:str) -> None: + """ Restart the registration services. + """ + self._assignConfig() + L.isDebug and L.logDebug('GroupManager restarted') + + ######################################################################### def validateGroup(self, group:Resource, originator:str) -> None: @@ -231,6 +261,17 @@ def foptRequest(self, operation:Operation, tail = '/' + tail if len(tail) > 0 else '' # add remaining path, if any _mid = groupResource.mid.copy() # copy mi because it is changed in the loop + + # Determine the timeout for aggregating requests. + # If Result Expiration Timestamp is present in the request then use that one. + # Else use the default configuration, if set to a value > 0 + if request.rset is not None: + _timeoutTS = request._rsetUTCts + elif self.resultExpirationTime > 0: + _timeoutTS = utcTime() + self.resultExpirationTime + else: + _timeoutTS = 0 + for mid in _mid: # Try to get the SRN and add the tail if srn := structuredPathFromRI(mid): @@ -240,7 +281,7 @@ def foptRequest(self, operation:Operation, # Invoke the request _result = CSE.request.processRequest(request, originator, mid) # Check for RSET expiration - if request.rset is not None and request._rsetUTCts < utcTime(): + if _timeoutTS and _timeoutTS < utcTime(): # Check for blocking request. Then raise a timeout if request.rt == ResponseType.blockingRequest: raise REQUEST_TIMEOUT(L.logDebug('Aggregation timed out')) diff --git a/acme/services/RegistrationManager.py b/acme/services/RegistrationManager.py index 3ac03164..647d7c40 100644 --- a/acme/services/RegistrationManager.py +++ b/acme/services/RegistrationManager.py @@ -8,11 +8,9 @@ # from __future__ import annotations -from typing import List, cast, Any, Optional +from typing import Any, Optional -from copy import deepcopy - -from ..etc.Types import Permission, ResourceTypes, JSON, CSEType +from ..etc.Types import ResourceTypes, JSON, CSEType from ..etc.ResponseStatusCodes import APP_RULE_VALIDATION_FAILED, ORIGINATOR_HAS_ALREADY_REGISTERED, INVALID_CHILD_RESOURCE_TYPE from ..etc.ResponseStatusCodes import BAD_REQUEST, OPERATION_NOT_ALLOWED, CONFLICT, ResponseException from ..etc.Utils import uniqueAEI, getIdFromOriginator, uniqueRN @@ -88,11 +86,11 @@ def configUpdate(self, name:str, value:Any = None) -> None: """ Handle configuration updates. """ - if key not in [ 'cse.checkExpirationsInterval', + if key not in ( 'cse.checkExpirationsInterval', 'cse.registration.allowedCSROriginators', 'cse.registration.allowedAEOriginators', 'cse.enableResourceExpiration', - 'resource.acp.selfPermission']: + 'resource.acp.selfPermission'): return self._assignConfig() self.restartExpirationMonitor() diff --git a/docs/Configuration.md b/docs/Configuration.md index a6899d01..905f6188 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -76,6 +76,7 @@ The following tables provide detailed descriptions of all the possible CSE confi [[resource.acp] - Resource defaults: Access Control Policies](#resource_acp) [[resource.actr] - Resource defaults: Action](#resource_actr) [[resource.cnt] - Resource Defaults: Container](#resource_cnt) +[[resource.grp] - Resource Defaults: Group](#resource_grp) [[resource.lcp] - Resource Defaults: LocationPolicy](#resource_lcp) [[resource.req] - Resource Defaults: Request](#resource_req) [[resource.sub] - Resource Defaults: Subscription](#resource_sub) @@ -165,7 +166,7 @@ The following tables provide detailed descriptions of all the possible CSE confi | Setting | Description | Configuration Name | |:--------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:-------------------------------| | port | Port to listen to.
Default: 8080 | http.port | -| listenIF | Interface to listen to. Use 0.0.0.0 for "all" interfaces.
Default:127.0.0.1 | http.listenIF | +| listenIF | Interface to listen to. Use 0.0.0.0 for "all" interfaces.
Default:0.0.0.0 | http.listenIF | | address | Own address. Should be a local/public reachable address.
Default: http://127.0.0.1:8080 | http.address | | root | CSE Server root. Never provide a trailing /.
Default: empty string | http.root | | enableRemoteConfiguration | Enable an endpoint for get and set certain configuration values via a REST interface.
**ATTENTION: Enabling this feature exposes configuration values, IDs and passwords, and is a security risk.**
Default: false | http.enableRemoteConfiguration | @@ -214,9 +215,9 @@ The following tables provide detailed descriptions of all the possible CSE confi | Setting | Description | Configuration Name | |:------------|:------------------------------------------------------------------------------------------|:-------------------| | enable | Enable the MQTT binding.
Default: False | mqtt.enable | -| address | he hostname of the MQTT broker.
Default; 127.0.0.1 | mqtt.address | +| address | The hostname of the MQTT broker.
Default; 127.0.0.1 | mqtt.address | | port | Set the port for the MQTT broker.
Default: 1883, or 8883 for TLS | mqtt.port | -| listenIF | Interface to listen to. Use 0.0.0.0 for "all" interfaces.
Default:127.0.0.1 | mqtt.listenIF | +| listenIF | Interface to listen to. Use 0.0.0.0 for "all" interfaces.
Default:0.0.0.0 | mqtt.listenIF | | keepalive | Value for the MQTT connection's keep-alive parameter in seconds.
Default: 60 seconds | mqtt.keepalive | | topicPrefix | Optional prefix for topics.
Default: empty string | mqtt.topicPrefix | | timeout | Timeout when sending MQTT requests and waiting for responses.
Default: 10.0 seconds | mqtt.timeout | @@ -377,6 +378,18 @@ The following tables provide detailed descriptions of all the possible CSE confi --- + + +### [resource.grp] - Resource Defaults: Group + +| Setting | Description | Configuration Name | +|:---------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------------|:----------------------------------| +| resultExpirationTime | Set the time for aggregating the results of a group request before interrupting. The format is the time in ms. A value of 0 ms means no timeout.
Default: 0 ms | resource.grp.resultExpirationTime | + +[top](#sections) + +--- + ### [resource.lcp] - Resource Defaults: diff --git a/init/configurations.docmd b/init/configurations.docmd index 22459a76..95b45cb5 100644 --- a/init/configurations.docmd +++ b/init/configurations.docmd @@ -1156,6 +1156,21 @@ This setting specifies the CSE's default for the CNT's *mbs* (maxByteSize) attri The default value is `10000 bytes`. +# resource.grp + +This section specifies the CSE's defaults for GRP (Group) resources and the GroupManager service. + +Settings in this section are listed under the `[resource.grp]` section. + + +# resource.grp.resultExpirationTime + +Set the time for the GroupManager for aggregating the results of a group request before interrupting. The format is the time in ms. + +A value of 0 ms means no timeout. + +The default is `0` ms. + # resource.lcp From 5008422796c44921a8b25588015702f038126f1f Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 5 Sep 2023 19:19:00 +0200 Subject: [PATCH 100/165] Added m2m:uri attribute --- init/attributePolicies.ap | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index 7759547e..b713c9f8 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -3390,6 +3390,14 @@ "annc": "OA" } ], + "m2m:uri": [ + { + "rtypes": [ "UNKNOWN" ], + "lname": "URI", + "ns": "m2m", + "type": "any" + } + ], "url": [ { "rtypes": [ "ALL" ], From c4e96e2faf50c0f0bd1a1bcf37831dd0334c4c79 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 5 Sep 2023 19:37:33 +0200 Subject: [PATCH 101/165] Added key shortcut to toggle the comment style for request details --- acme/textui/ACMEContainerRequests.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/acme/textui/ACMEContainerRequests.py b/acme/textui/ACMEContainerRequests.py index 5bdb12f3..0734c1fc 100644 --- a/acme/textui/ACMEContainerRequests.py +++ b/acme/textui/ACMEContainerRequests.py @@ -63,6 +63,7 @@ class ACMEViewRequests(Vertical): Binding('D', 'delete_requests', 'Delete ALL Requests', key_display = 'SHIFT+D'), Binding('e', 'enable_requests', ''), Binding('t', 'toggle_list_details', 'List Details'), + Binding('ctrl+t', 'toggle_comment_style', 'Comments Style'), ] DEFAULT_CSS = """ @@ -138,6 +139,7 @@ def __init__(self) -> None: # Request view: request + response self.requestListRequest = Static(id = 'request-list-request') self.requestListResponse = Static(id = 'request-list-response') + self.commentsOneLine = True @property @@ -199,9 +201,9 @@ def _showRequests(self, item:ACMEListItem) -> None: """ # Get the request's json jsns = commentJson(self._currentRequests[cast(ACMEListItem, item)._data]['req'], - explanations = self.app.attributeExplanations, # type: ignore [attr-defined] - getAttributeValueName = CSE.validator.getAttributeValueName, # type: ignore [attr-defined] - width = self.requestListRequest.size[0] - 2) # type: ignore [attr-defined] + explanations = self.app.attributeExplanations, # type: ignore [attr-defined] + getAttributeValueName = CSE.validator.getAttributeValueName, # type: ignore [attr-defined] + width = None if self.commentsOneLine else self.requestListRequest.size[0] - 2) # type: ignore [attr-defined] _l1 = jsns.count('\n') # Add syntax highlighting and explanations, and add to the view @@ -209,9 +211,9 @@ def _showRequests(self, item:ACMEListItem) -> None: # Get the response's json jsns = commentJson(self._currentRequests[cast(ACMEListItem, item)._data]['rsp'], - explanations = self.app.attributeExplanations, # type: ignore [attr-defined] - getAttributeValueName = CSE.validator.getAttributeValueName, # type: ignore [attr-defined] - width = self.requestListRequest.size[0] - 2) # type: ignore [attr-defined] + explanations = self.app.attributeExplanations, # type: ignore [attr-defined] + getAttributeValueName = CSE.validator.getAttributeValueName, # type: ignore [attr-defined] + width = None if self.commentsOneLine else self.requestListRequest.size[0] - 2) # type: ignore [attr-defined] _l2 = jsns.count('\n') # Make sure the response has the same number of lines as the request @@ -245,7 +247,10 @@ def action_toggle_list_details(self) -> None: self.listDetails = not self.listDetails self.updateRequests() - # TODO + + def action_toggle_comment_style(self) -> None: + self.commentsOneLine = not self.commentsOneLine + self.updateRequests() def updateBindings(self) -> None: From 8cfafad89cc8317738976c0a0e3d3f9af7745e78 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 10 Sep 2023 20:43:07 +0200 Subject: [PATCH 102/165] Added support for "location" attribute and geoJsonCoordinates complex type and validation --- acme/etc/Constants.py | 3 + acme/etc/Types.py | 102 ++++++--- acme/resources/Resource.py | 17 +- acme/services/Validator.py | 116 +++++++++- init/complexTypePolicies.ap | 3 +- init/demoLightbulb/init.as | 2 +- tests/testLocation.py | 444 ++++++++++++++++++++++++++++++++++++ 7 files changed, 647 insertions(+), 40 deletions(-) create mode 100644 tests/testLocation.py diff --git a/acme/etc/Constants.py b/acme/etc/Constants.py index 187e61fe..60f1a45c 100644 --- a/acme/etc/Constants.py +++ b/acme/etc/Constants.py @@ -118,6 +118,9 @@ class Constants(object): attrRiTyMapping = '__riTyMapping__' """ Constant: Name of the 'Resource internal *__riTyMapping__* attribute. This attribute holds the mapping of resourceID's to resource types. """ + attrLocCoordinage = '__locCoordinate__' + """ Constant: Name of the 'Resource internal *__locCoordinate__* attribute. This attribute holds the location coordinate of a resource. """ + # # Supported URL schemes diff --git a/acme/etc/Types.py b/acme/etc/Types.py index 75aa7cfb..9cb24356 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -595,32 +595,32 @@ class BasicType(ACMEIntEnum): """ Basic resource types. """ - positiveInteger = auto() - nonNegInteger = auto() - unsignedInt = auto() - unsignedLong = auto() - string = auto() - timestamp = auto() - absRelTimestamp = auto() - list = auto() - listNE = auto() # Not empty list - dict = auto() - anyURI = auto() - boolean = auto() - float = auto() - geoCoordinates = auto() - integer = auto() - void = auto() - duration = auto() - any = auto() - complex = auto() - enum = auto() - adict = auto() # anoymous dict structure - base64 = auto() - schedule = auto() # scheduleEntry - time = timestamp # alias type for time - date = timestamp # alias type for date - ID = auto() # m2m:ID + positiveInteger = auto() + nonNegInteger = auto() + unsignedInt = auto() + unsignedLong = auto() + string = auto() + timestamp = auto() + absRelTimestamp = auto() + list = auto() + listNE = auto() # Not empty list + dict = auto() + anyURI = auto() + boolean = auto() + float = auto() + geoJsonCoordinate = auto() + integer = auto() + void = auto() + duration = auto() + any = auto() + complex = auto() + enum = auto() + adict = auto() # anoymous dict structure + base64 = auto() + schedule = auto() # scheduleEntry + time = timestamp # alias type for time + date = timestamp # alias type for date + ID = auto() # m2m:ID @classmethod def to(cls, name:str|Tuple[str], insensitive:Optional[bool] = True) -> BasicType: @@ -1483,7 +1483,7 @@ class SemanticFormat(ACMEIntEnum): ############################################################################## # -# LocationPolicy related +# LocationPolicy and GeoQuery related # class LocationSource(ACMEIntEnum): @@ -1528,6 +1528,51 @@ class LocationInformationType(ACMEIntEnum): """ Position fix. """ Geofence_event = 2 """ Geofence event. """ + + +class GeometryType(ACMEIntEnum): + """ Geometry Type. + """ + Point = 1 + """ Point.""" + LineString = 2 + """ LineString. """ + Polygon = 3 + """ Polygon. """ + MultiPoint = 4 + """ MultiPoint. """ + MultiLineString = 5 + """ MultiLineString. """ + MultiPolygon = 6 + """ MultiPolygon. """ + +Coordinate = Tuple[float, float, Optional[float]] +""" Coordinate type. """ +ListOfCoordinates = list[Coordinate] +""" List of coordinates type. """ + + +class GeoSpatialFunctionType(ACMEIntEnum): + """ Geo Spatial Function Type. + """ + Within = 1 + """ Within.""" + Contains = 2 + """ Contains.""" + Intersects = 3 + """ Intersects.""" + + +class GeoQuery: + """ Geo Query. + """ + geometryType:GeometryType = None + """ Geometry Type. """ + geometry:ListOfCoordinates = [] + """ Geometry. """ + geoSpatialFunction:GeoSpatialFunctionType = None + + ############################################################################## # @@ -1724,6 +1769,9 @@ class FilterCriteria: lbl:list = None """ List of labels. Default is *None*. """ + gq:GeoQuery = None + """ Geo query. Default is *None*. """ + aq:str = None """ Advanced query. Default is *None*. """ diff --git a/acme/resources/Resource.py b/acme/resources/Resource.py index a5cdc42f..ae0df7e4 100644 --- a/acme/resources/Resource.py +++ b/acme/resources/Resource.py @@ -11,10 +11,11 @@ # The following import allows to use "Resource" inside a method typing definition from __future__ import annotations from typing import Any, Tuple, cast, Optional, List, overload +import json from copy import deepcopy -from ..etc.Types import ResourceTypes, Result, NotificationEventType, CSERequest, JSON +from ..etc.Types import ResourceTypes, Result, NotificationEventType, CSERequest, JSON, GeometryType from ..etc.ResponseStatusCodes import ResponseException, BAD_REQUEST, CONTENTS_UNACCEPTABLE, INTERNAL_SERVER_ERROR from ..etc.Utils import isValidID, uniqueRI, uniqueRN, isUniqueRI, removeNoneValuesFromDict, resourceDiff, normalizeURL, pureResource from ..helpers.TextTools import findXPath, setXPath @@ -36,6 +37,7 @@ _createdInternallyRI = Constants.attrCreatedInternallyRI _imported = Constants.attrImported _isInstantiated = Constants.attrIsInstantiated +_locCoordinate = Constants.attrLocCoordinage _originator = Constants.attrOriginator _modified = Constants.attrModified _remoteID = Constants.attrRemoteID @@ -64,7 +66,8 @@ class Resource(object): # ATTN: There is a similar definition in FCNT, TSB, and others! Don't Forget to add attributes there as well internalAttributes = [ _rtype, _srn, _node, _createdInternallyRI, _imported, - _isInstantiated, _originator, _modified, _remoteID, _rvi] + _isInstantiated, _locCoordinate, + _originator, _modified, _remoteID, _rvi ] """ List of internal attributes and which do not belong to the oneM2M resource attributes """ def __init__(self, @@ -516,6 +519,16 @@ def validate(self, originator:Optional[str] = None, if not (et := parentResource.et): et = getResourceDate(CSE.request.maxExpirationDelta) self.setAttribute('et', et) + + # check loc validity: geo type and number of coordinates + if (loc := self.getFinalResourceAttribute('loc', dct)) is not None: + # crd should have been already check as valid JSON before + # Let's optimize and store the coordinates as a JSON object + crd = CSE.validator.validateGeoLocation(loc) + if dct is not None: + setXPath(dct, f'{self.tpe}/{_locCoordinate}', crd, overwrite = True) + else: + self.setAttribute(_locCoordinate, crd) ######################################################################### diff --git a/acme/services/Validator.py b/acme/services/Validator.py index ec2bd472..de96080f 100644 --- a/acme/services/Validator.py +++ b/acme/services/Validator.py @@ -11,13 +11,13 @@ from typing import Any, Dict, Tuple, Optional from copy import deepcopy -import re +import re, json import isodate from ..etc.Types import AttributePolicy, ResourceAttributePolicyDict, AttributePolicyDict, BasicType, Cardinality from ..etc.Types import RequestOptionality, Announced, AttributePolicy, ResultContentType -from ..etc.Types import JSON, FlexContainerAttributes, FlexContainerSpecializations -from ..etc.Types import CSEType, ResourceTypes, Permission, Operation, NotificationContentType, NotificationEventType +from ..etc.Types import JSON, FlexContainerAttributes, FlexContainerSpecializations, GeometryType +from ..etc.Types import CSEType, ResourceTypes, Permission, Operation from ..etc.ResponseStatusCodes import ResponseStatusCode, BAD_REQUEST, ResponseException, CONTENTS_UNACCEPTABLE from ..etc.Utils import pureResource, strToBool from ..helpers.TextTools import findXPath, soundsLike @@ -361,6 +361,102 @@ def validateCSICB(self, val:str, name:str) -> None: # fall-through + def validateGeoPoint(self, geo:dict) -> bool: + """ Validate a GeoJSON point. A point is a list of two or three floats. + + Args: + geo: GeoJSON point. + + Return: + Boolean, indicating whether the point is valid. + """ + if not isinstance(geo, list) or 2 > len(geo) > 3: + return False + for g in geo: + if not isinstance(g, float): + return False + return True + + + def validateGeoLinePolygon(self, geo:dict, isPolygon:Optional[bool] = False) -> bool: + """ Validate a GeoJSON line or polygon. + A line or polygon is a list of lists of two or three floats. + + Args: + geo: GeoJSON string line or polygon. + isPolygon: Boolean, indicating whether the coordinates describe a polygon. + + Return: + Boolean, indicating whether the line or polygon is valid. + """ + if not isinstance(geo, list) or len(geo) < 2: + return False + for g in geo: + if not self.validateGeoPoint(g): + return False + if isPolygon and geo[0] != geo[-1]: + return False + return True + + + def validateGeoMultiLinePolygon(self, geo:dict, isPolygon:Optional[bool] = False) -> bool: + """ Validate a GeoJSON multi line or polygon. + A line or polygon is a list of list of lists of two or three floats. + + Args: + geo: GeoJSON string multi line or polygon. + isPolygon: Boolean, indicating whether the coordinates describe a polygon. + + Return: + Boolean, indicating whether the line or polygon is valid. + """ + if not isinstance(geo, list): + return False + + for g in geo: + if not isinstance(g, list) or len(g) < 2: + return False + if not self.validateGeoLinePolygon(g, isPolygon): + return False + return True + + + def validateGeoLocation(self, loc:dict) -> dict: + """ Validate a GeoJSON location. A location is a dictionary with a type and coordinates. + + Args: + loc: GeoJSON location. + + Return: + The validated location dictionary. + + Raises: + BAD_REQUEST: If the location definition is invalid. + """ + crd = json.loads(loc.get('crd')) # was validated before + match (typ := loc.get('typ')): + case GeometryType.Point: + if not self.validateGeoPoint(crd): + raise BAD_REQUEST(L.logWarn(f'Invalid GeoJSON point: {crd}')) + case GeometryType.LineString: + if not self.validateGeoLinePolygon(crd): + raise BAD_REQUEST(L.logWarn(f'Invalid GeoJSON LineString: {crd}')) + case GeometryType.Polygon: + if not self.validateGeoLinePolygon(crd, True): + raise BAD_REQUEST(L.logWarn(f'Invalid GeoJSON Polygon: {crd}')) + case GeometryType.MultiPoint: + for p in crd: + if not self.validateGeoPoint(p): + raise BAD_REQUEST(L.logWarn(f'Invalid GeoJSON MultiPoint: {crd}')) + case GeometryType.MultiLineString: + if not self.validateGeoMultiLinePolygon(crd): + raise BAD_REQUEST(L.logWarn(f'Invalid GeoJSON MultiLineString: {crd}')) + case GeometryType.MultiPolygon: + if not self.validateGeoMultiLinePolygon(crd, True): + raise BAD_REQUEST(L.logWarn(f'Invalid GeoJSON MultiPolygon: {crd}')) + return crd + + def isExtraResourceAttribute(self, attr:str, resource:Resource) -> bool: """ Check whether the resource attribute *attr* is neither a universal, common, or resource attribute, nor an internal attribute. @@ -647,7 +743,7 @@ def _validateType(self, dataType:BasicType, value and the method will attempt to convert the value to its target type; otherwise this is an error. Return: - Result. If the check is positive then Result.data is set to a tuple (the determined data type, the converted value). + Result. If the check is positive then a tuple is returned: (the determined data type, the converted value). """ # Ignore None values @@ -777,10 +873,14 @@ def _validateType(self, dataType:BasicType, return (dataType, value) raise BAD_REQUEST(f'invalid type: {type(value).__name__}. Expected: float') - case BasicType.geoCoordinates if isinstance(value, dict): - - # TODO geoJSON validation - return (dataType, value) + case BasicType.geoJsonCoordinate if isinstance(value, str): + try: + geo = json.loads(value) + except Exception as e: + raise BAD_REQUEST(f'Invalid geoJsonCoordinate: {str(e)}') + if self.validateGeoPoint(geo) or self.validateGeoLinePolygon(geo) or self.validateGeoMultiLinePolygon(geo): + return (dataType, geo) + raise BAD_REQUEST(f'Invalid geoJsonCoordinate: {value}') case BasicType.duration: try: diff --git a/init/complexTypePolicies.ap b/init/complexTypePolicies.ap index 946aff5b..b8672f7c 100644 --- a/init/complexTypePolicies.ap +++ b/init/complexTypePolicies.ap @@ -1448,8 +1448,7 @@ "ctype": "m2m:geoCoordinates", "lname": "coordinates", "ns": "m2m", - "type": "list", // TODO m2m:listOfCoordinates -> list of list of floats or GeoJSON? - "ltype": "string", + "type": "geoJsonCoordinate", "car": "1" } ], diff --git a/init/demoLightbulb/init.as b/init/demoLightbulb/init.as index 1b9ceb60..230bd89c 100644 --- a/init/demoLightbulb/init.as +++ b/init/demoLightbulb/init.as @@ -59,7 +59,7 @@ the *lightswitch*.") "pv": { "acr": [ { ;; Allow CDemoLightbulb only to retrieve - "acor": [ "CDemoLightswitch" ], + "acor": [ "CDemoLightswitch"], "acop": 16 ;; NOTIFY }, { ;; Allow CDemoLightswitch all access diff --git a/tests/testLocation.py b/tests/testLocation.py new file mode 100644 index 00000000..81c19e3f --- /dev/null +++ b/tests/testLocation.py @@ -0,0 +1,444 @@ +# +# testLocation.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# Unit tests for geo-query functionality and queries +# + +import unittest, sys +if '..' not in sys.path: + sys.path.append('..') +from typing import Tuple +from acme.etc.Types import ResourceTypes as T, ResponseStatusCode as RC, TimeWindowType +from acme.etc.Types import NotificationEventType, NotificationEventType as NET +from init import * + + +class TestLocation(unittest.TestCase): + + ae = None + aeRI = None + + + originator = None + + @classmethod + @unittest.skipIf(noCSE, 'No CSEBase') + def setUpClass(cls) -> None: + testCaseStart('Setup TestLocation') + + # Start notification server + #startNotificationServer() + + dct = { 'm2m:ae' : { + 'rn' : aeRN, + 'api' : APPID, + 'rr' : True, + 'srv' : [ RELEASEVERSION ] + }} + cls.ae, rsc = CREATE(cseURL, 'C', T.AE, dct) # AE to work under + assert rsc == RC.CREATED, 'cannot create parent AE' + cls.originator = findXPath(cls.ae, 'm2m:ae/aei') + cls.aeRI = findXPath(cls.ae, 'm2m:ae/ri') + + testCaseEnd('Setup TestLocation') + + + @classmethod + @unittest.skipIf(noCSE, 'No CSEBase') + def tearDownClass(cls) -> None: + # if not isTearDownEnabled(): + # stopNotificationServer() + # return + testCaseStart('TearDown TestLocation') + DELETE(aeURL, ORIGINATOR) # Just delete the AE and everything below it. Ignore whether it exists or not + testCaseEnd('TearDown TestLocation') + + + def setUp(self) -> None: + testCaseStart(self._testMethodName) + + + def tearDown(self) -> None: + testCaseEnd(self._testMethodName) + + ######################################################################### + + @unittest.skipIf(noCSE, 'No CSEBase') + def test_createContainerWrongLocFail(self) -> None: + """ CREATE with invalid location -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': 'wrong', + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + # + # Point + # + + def test_createContainerLocWrongAttributesFail(self) -> None: + """ CREATE with location & wrong attributes -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 1, + 'crd': '[ 1.0, 2.0 ]', + 'wrong': 'wrong' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + def test_createContainerLocPointIntCoordinatesFail(self) -> None: + """ CREATE with location type Point & and integer values -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 1, + 'crd': '[ 1, 2 ]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + def test_createContainerLocPointWrongCountFail(self) -> None: + """ CREATE with location type Point & multiple coordinates -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 1, + 'crd': '[[ 1.0, 2.0 ], [ 3.0, 4.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + def test_createContainerLocPoint(self) -> None: + """ CREATE with location type Point """ + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 1, + 'crd': '[ 1.0, 2.0 ]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + self.assertEqual(findXPath(r, 'm2m:cnt/loc/typ'), 1, r) + self.assertEqual(findXPath(r, 'm2m:cnt/loc/crd'), '[ 1.0, 2.0 ]', r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + # + # LineString + # + + def test_createContainerLocLineStringWrongCountFail(self) -> None: + """ CREATE with location type LineString & 1 coordinate -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 2, + 'crd': '[[ 1.0, 2.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + def test_createContainerLocLineString(self) -> None: + """ CREATE with location type LineString & 2 coordinates""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 2, + 'crd': '[[ 1.0, 2.0 ], [ 3.0, 4.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + self.assertEqual(findXPath(r, 'm2m:cnt/loc/typ'), 2, r) + self.assertEqual(findXPath(r, 'm2m:cnt/loc/crd'), '[[ 1.0, 2.0 ], [ 3.0, 4.0 ]]', r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + # + # Polygon + # + + def test_createContainerLocPolygonWrongCountFail(self) -> None: + """ CREATE with location type Polygon & 1 coordinate -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 1.0, 2.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + def test_createContainerLocPolygonWrongFirstLastCoordinateFail(self) -> None: + """ CREATE with location type Polygon & not matching first and last coordinate -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 1.0, 2.0 ], [ 3.0, 4.0 ], [ 5.0, 6.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + def test_createContainerLocPolygon(self) -> None: + """ CREATE with location type Polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 1.0, 2.0 ], [ 3.0, 4.0 ], [ 5.0, 6.0 ], [ 1.0, 2.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + self.assertEqual(findXPath(r, 'm2m:cnt/loc/typ'), 3, r) + self.assertEqual(findXPath(r, 'm2m:cnt/loc/crd'), '[[ 1.0, 2.0 ], [ 3.0, 4.0 ], [ 5.0, 6.0 ], [ 1.0, 2.0 ]]', r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + # + # Multipoint + # + + def test_createContainerLocMultiPointWrongFail(self) -> None: + """ CREATE with location type MultiPoint & wrong coordinate -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 4, + 'crd': '[1.0, 2.0 ]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + def test_createContainerLocMultiPointWrongCountFail(self) -> None: + """ CREATE with location type MultiPoint & wrong count -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 4, + 'crd': '[ [ [1.0, 2.0 ], [ 3.0, 4.0 ] ] ]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + def test_createContainerLocMultiPoint(self) -> None: + """ CREATE with location type MultiPoint""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 4, + 'crd': '[[ 1.0, 2.0 ], [ 3.0, 4.0 ], [ 5.0, 6.0 ], [ 1.0, 2.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + self.assertEqual(findXPath(r, 'm2m:cnt/loc/typ'), 4, r) + self.assertEqual(findXPath(r, 'm2m:cnt/loc/crd'), '[[ 1.0, 2.0 ], [ 3.0, 4.0 ], [ 5.0, 6.0 ], [ 1.0, 2.0 ]]', r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + + # + # MultiLineString + # + + def test_createContainerLocMultiLineStringWrongFail(self) -> None: + """ CREATE with location type MultiLineString & wrong coordinate -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 5, + 'crd': '[[1.0, 2.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + def test_createContainerLocMultiLineString2WrongFail(self) -> None: + """ CREATE with location type MultiLineString & wrong coordinate -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 5, + 'crd': '[[[1.0, 2.0 ]]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + def test_createContainerLocMultiLineString(self) -> None: + """ CREATE with location type MultiLineString""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 5, + 'crd': '[[[ 1.0, 2.0 ], [ 3.0, 4.0 ]], [[ 5.0, 6.0 ], [ 7.0, 8.0 ]]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + self.assertEqual(findXPath(r, 'm2m:cnt/loc/typ'), 5, r) + self.assertEqual(findXPath(r, 'm2m:cnt/loc/crd'), '[[[ 1.0, 2.0 ], [ 3.0, 4.0 ]], [[ 5.0, 6.0 ], [ 7.0, 8.0 ]]]', r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + # + # MultiPolygon + # + + def test_createContainerLocMultiPolygonWrongFail(self) -> None: + """ CREATE with location type MultiPolygon & wrong coordinate -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 6, + 'crd': '[[1.0, 2.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + def test_createContainerLocMultiPolygonWrongFirstLastCoordinateFail(self) -> None: + """ CREATE with location type MultiPolygon & not matching first and last coordinate -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 6, + 'crd': '[[[ 1.0, 2.0 ], [ 3.0, 4.0 ], [ 5.0, 6.0 ]]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + + def test_createContainerLocMultiPolygon(self) -> None: + """ CREATE with location type MultiPolygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 6, + 'crd': '[[[ 1.0, 2.0 ], [ 3.0, 4.0 ], [ 5.0, 6.0 ], [ 1.0, 2.0 ]]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + self.assertEqual(findXPath(r, 'm2m:cnt/loc/typ'), 6, r) + self.assertEqual(findXPath(r, 'm2m:cnt/loc/crd'), '[[[ 1.0, 2.0 ], [ 3.0, 4.0 ], [ 5.0, 6.0 ], [ 1.0, 2.0 ]]]', r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + + + + + + ######################################################################### + + +def run(testFailFast:bool) -> Tuple[int, int, int, float]: + suite = unittest.TestSuite() + + # basic tests + suite.addTest(TestLocation('test_createContainerWrongLocFail')) + + # Point + suite.addTest(TestLocation('test_createContainerLocWrongAttributesFail')) + suite.addTest(TestLocation('test_createContainerLocPointIntCoordinatesFail')) + suite.addTest(TestLocation('test_createContainerLocPointWrongCountFail')) + suite.addTest(TestLocation('test_createContainerLocPoint')) + + # LineString + suite.addTest(TestLocation('test_createContainerLocLineStringWrongCountFail')) + suite.addTest(TestLocation('test_createContainerLocLineString')) + + # Polygon + suite.addTest(TestLocation('test_createContainerLocPolygonWrongCountFail')) + suite.addTest(TestLocation('test_createContainerLocPolygonWrongFirstLastCoordinateFail')) + suite.addTest(TestLocation('test_createContainerLocPolygon')) + + # MultiPoint + suite.addTest(TestLocation('test_createContainerLocMultiPointWrongFail')) + suite.addTest(TestLocation('test_createContainerLocMultiPointWrongCountFail')) + suite.addTest(TestLocation('test_createContainerLocMultiPoint')) + + # MultiLineString + suite.addTest(TestLocation('test_createContainerLocMultiLineStringWrongFail')) + suite.addTest(TestLocation('test_createContainerLocMultiLineString2WrongFail')) + suite.addTest(TestLocation('test_createContainerLocMultiLineString')) + + # MultiPolygon + suite.addTest(TestLocation('test_createContainerLocMultiPolygonWrongFail')) + suite.addTest(TestLocation('test_createContainerLocMultiPolygonWrongFirstLastCoordinateFail')) + suite.addTest(TestLocation('test_createContainerLocMultiPolygon')) + + result = unittest.TextTestRunner(verbosity = testVerbosity, failfast = testFailFast).run(suite) + return result.testsRun, len(result.errors + result.failures), len(result.skipped), getSleepTimeCount() + + +if __name__ == '__main__': + r, errors, s, t = run(True) + sys.exit(errors) \ No newline at end of file From c7d7f478fe88a7253f41a74b8b309538ddd5c0ce Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 11 Sep 2023 20:28:59 +0200 Subject: [PATCH 103/165] Fixed missing assignment --- acme/services/GroupManager.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/acme/services/GroupManager.py b/acme/services/GroupManager.py index fa889fa3..ce3c7569 100644 --- a/acme/services/GroupManager.py +++ b/acme/services/GroupManager.py @@ -44,6 +44,9 @@ def __init__(self) -> None: # Add a handler when the CSE is reset CSE.event.addHandler(CSE.event.cseReset, self.restart) # type: ignore + # Assign configuration values + self._assignConfig() + L.isInfo and L.log('GroupManager initialized') @@ -244,12 +247,6 @@ def foptRequest(self, operation:Operation, if not CSE.security.hasAccess(originator, groupResource, requestedPermission = permission, ty = request.ty): raise ORIGINATOR_HAS_NO_PRIVILEGE('insufficient privileges for originator') - # Determine expiration timestamp - expirationTimestamp = None - if request.rqet is not None: - expirationTimestamp = request._rqetUTCts - if request.rset is not None: - expirationTimestamp = request._rsetUTCts # check whether there is something after the /fopt ... _, _, tail = id.partition('/fopt/') @@ -315,8 +312,6 @@ def foptRequest(self, operation:Operation, else: agr = {} - L.logWarn(agr) - return Result(rsc = ResponseStatusCode.OK, resource = agr) # Response Status Code is OK regardless of the requested fanout operation From 5cdc025e1b27ef490fd9b1649471dd44d68f9083 Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 11 Sep 2023 20:58:39 +0200 Subject: [PATCH 104/165] Quick fix for checking Mgmt DVI location attribute, which is actually a different attribute --- acme/resources/Resource.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/acme/resources/Resource.py b/acme/resources/Resource.py index ae0df7e4..07e106ff 100644 --- a/acme/resources/Resource.py +++ b/acme/resources/Resource.py @@ -15,7 +15,7 @@ from copy import deepcopy -from ..etc.Types import ResourceTypes, Result, NotificationEventType, CSERequest, JSON, GeometryType +from ..etc.Types import ResourceTypes, Result, NotificationEventType, CSERequest, JSON, BasicType from ..etc.ResponseStatusCodes import ResponseException, BAD_REQUEST, CONTENTS_UNACCEPTABLE, INTERNAL_SERVER_ERROR from ..etc.Utils import isValidID, uniqueRI, uniqueRN, isUniqueRI, removeNoneValuesFromDict, resourceDiff, normalizeURL, pureResource from ..helpers.TextTools import findXPath, setXPath @@ -522,13 +522,18 @@ def validate(self, originator:Optional[str] = None, # check loc validity: geo type and number of coordinates if (loc := self.getFinalResourceAttribute('loc', dct)) is not None: - # crd should have been already check as valid JSON before - # Let's optimize and store the coordinates as a JSON object - crd = CSE.validator.validateGeoLocation(loc) - if dct is not None: - setXPath(dct, f'{self.tpe}/{_locCoordinate}', crd, overwrite = True) - else: - self.setAttribute(_locCoordinate, crd) + + # The following line is a hack that is necessary because the name "location" is used with different meanings + # and types in different resources (MgmtObj-DVI and normal resources). This is a quick fix for the moment. + # It only check if this is a DVI resource. If yes, then the loc attribute is not checked. + if CSE.validator.getAttributePolicy(self.ty if self.mgd is None else self.mgd, 'loc').type != BasicType.string: + # crd should have been already check as valid JSON before + # Let's optimize and store the coordinates as a JSON object + crd = CSE.validator.validateGeoLocation(loc) + if dct is not None: + setXPath(dct, f'{self.tpe}/{_locCoordinate}', crd, overwrite = True) + else: + self.setAttribute(_locCoordinate, crd) ######################################################################### From 5d0cf3e6fa3780923bdaab159789f295ef4b1e03 Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 13 Sep 2023 09:59:54 +0200 Subject: [PATCH 105/165] Added documentation --- acme/helpers/TinyDBBufferedStorage.py | 13 +++ acme/services/Storage.py | 129 +++++++++++++++++++------- 2 files changed, 109 insertions(+), 33 deletions(-) diff --git a/acme/helpers/TinyDBBufferedStorage.py b/acme/helpers/TinyDBBufferedStorage.py index ea5feb12..90fde468 100644 --- a/acme/helpers/TinyDBBufferedStorage.py +++ b/acme/helpers/TinyDBBufferedStorage.py @@ -37,6 +37,8 @@ class TinyDBBufferedStorage(JSONStorage): '_changed', '_data', ) + """ Define slots for instance variables. """ + def __init__(self, path:str, create_dirs:bool = False, encoding:str = None, access_mode:str = 'r+', write_delay:int = 1, **kwargs:Any) -> None: """ Initialization of the storage driver. @@ -50,6 +52,17 @@ def __init__(self, path:str, create_dirs:bool = False, encoding:str = None, acce create_dirs: Whether the directory structure to the database file should be created or not. write_delay: Time to wait before writing a changed database buffer, in seconds. kwargs: Any other argument. + + Attributes: + __slots__: Define slots for instance variables. + _writeEvent: Event instance to notify when a write happened. + _writeDelay: Delay before writing the data to disk. + _shutdownLock: Internal lock when shutting down the database. + _running: Indicating that the database is open and in use. + _shutting_down: Indicator that the database is closing. This is different from `_running`. + _changed: Indicator that the write buffer is *dirty* and needs to be written. + _data: The actual database data, which is also strored in memory as a buffer. + """ super().__init__(path, create_dirs, encoding, access_mode, **kwargs) diff --git a/acme/services/Storage.py b/acme/services/Storage.py index 100df764..7cf147e6 100644 --- a/acme/services/Storage.py +++ b/acme/services/Storage.py @@ -65,6 +65,7 @@ class Storage(object): 'dbReset', 'db', ) + """ Define slots for instance variables. """ def __init__(self) -> None: """ Initialization of the storage manager. @@ -661,6 +662,57 @@ def removeSchedule(self, schedule:SCH) -> bool: class TinyDBBinding(object): + """ This class implements the TinyDB binding to the database. It is used by the Storage class. + + Attributes: + path: Path to the database directory. + cacheSize: Size of the cache for the TinyDB tables. + writeDelay: Delay for writing to the database. + maxRequests: Maximum number of oneM2M recorded requests to keep in the database. + lockResources: Lock for the resources table. + lockIdentifiers: Lock for the identifiers table. + lockChildResources: Lock for the childResources table. + lockStructuredIDs: Lock for the structuredIDs table. + lockSubscriptions: Lock for the subscriptions table. + lockBatchNotifications: Lock for the batchNotifications table. + lockStatistics: Lock for the statistics table. + lockActions: Lock for the actions table. + lockRequests: Lock for the requests table. + lockSchedules: Lock for the schedules table. + fileResources: Filename for the resources table. + fileIdentifiers: Filename for the identifiers table. + fileSubscriptions: Filename for the subscriptions table. + fileBatchNotifications: Filename for the batchNotifications table. + fileStatistics: Filename for the statistics table. + fileActions: Filename for the actions table. + fileRequests: Filename for the requests table. + fileSchedules: Filename for the schedules table. + dbResources: The TinyDB database for the resources table. + dbIdentifiers: The TinyDB database for the identifiers table. + dbSubscriptions: The TinyDB database for the subscriptions table. + dbBatchNotifications: The TinyDB database for the batchNotifications table. + dbStatistics: The TinyDB database for the statistics table. + dbActions: The TinyDB database for the actions table. + dbRequests: The TinyDB database for the requests table. + dbSchedules: The TinyDB database for the schedules table. + tabResources: The TinyDB table for the resources table. + tabIdentifiers: The TinyDB table for the identifiers table. + tabChildResources: The TinyDB table for the childResources table. + tabStructuredIDs: The TinyDB table for the structuredIDs table. + tabSubscriptions: The TinyDB table for the subscriptions table. + tabBatchNotifications: The TinyDB table for the batchNotifications table. + tabStatistics: The TinyDB table for the statistics table. + tabActions: The TinyDB table for the actions table. + tabRequests: The TinyDB table for the requests table. + tabSchedules: The TinyDB table for the schedules table. + resourceQuery: The TinyDB query object for the resources table. + identifierQuery: The TinyDB query object for the identifiers table. + subscriptionQuery: The TinyDB query object for the subscriptions table. + batchNotificationQuery: The TinyDB query object for the batchNotifications table. + actionsQuery: The TinyDB query object for the actions table. + requestsQuery: The TinyDB query object for the requests table. + schedulesQuery: The TinyDB query object for the schedules table. + """ __slots__ = ( 'path', @@ -716,6 +768,7 @@ class TinyDBBinding(object): 'requestsQuery', 'schedulesQuery', ) + """ Define slots for instance variables. """ def __init__(self, path:str, postfix:str) -> None: self.path = path @@ -881,40 +934,56 @@ def backupDB(self, dir:str) -> bool: def insertResource(self, resource: Resource, ri:str) -> None: + """ Insert a resource into the database. + + Args: + resource: The resource to insert. + ri: The resource ID of the resource. + """ with self.lockResources: self.tabResources.insert(Document(resource.dict, ri)) # type:ignore[arg-type] - # self.tabResources.insert(resource.dict) def upsertResource(self, resource: Resource, ri:str) -> None: + """ Update or insert a resource into the database. + + Args: + resource: The resource to upate or insert. + ri: The resource ID of the resource. + """ #L.logDebug(resource) with self.lockResources: # Update existing or insert new when overwriting - # _ri = resource.ri - # self.tabResources.upsert(resource.dict, self.resourceQuery.ri == _ri) - self.tabResources.upsert(Document(resource.dict, doc_id = ri)) # type:ignore[arg-type] def updateResource(self, resource: Resource, ri:str) -> Resource: + """ Update a resource in the database. Only the fields that are not None will be updated. + + Args: + resource: The resource to update. + ri: The resource ID of the resource. + """ #L.logDebug(resource) with self.lockResources: self.tabResources.update(resource.dict, doc_ids = [ri]) # type:ignore[call-arg, list-item] - # self.tabResources.update(resource.dict, self.resourceQuery.ri == _ri) # remove nullified fields from db and resource for k in list(resource.dict): if resource.dict[k] is None: # only remove the real None attributes, not those with 0 self.tabResources.update(delete(k), doc_ids = [ri]) # type: ignore[no-untyped-call, call-arg, list-item] - # self.tabResources.update(delete(k), self.resourceQuery.ri == ri) # type: ignore [no-untyped-call] del resource.dict[k] return resource def deleteResource(self, resource:Resource) -> None: + """ Delete a resource from the database. + + Args: + resource: The resource to delete. + """ with self.lockResources: _ri = resource.ri self.tabResources.remove(doc_ids = [_ri]) - # self.tabResources.remove(self.resourceQuery.ri == _ri) def searchResources(self, ri:Optional[str] = None, @@ -928,7 +997,6 @@ def searchResources(self, ri:Optional[str] = None, if ri: _r = self.tabResources.get(doc_id = ri) # type:ignore[arg-type] return [_r] if _r else [] # type:ignore[list-item] - # return self.tabResources.search(self.resourceQuery.ri == ri) elif csi: return self.tabResources.search(self.resourceQuery.csi == csi) elif pi: @@ -949,6 +1017,13 @@ def searchResources(self, ri:Optional[str] = None, def discoverResourcesByFilter(self, func:Callable[[JSON], bool]) -> list[Document]: + """ Search for resources by a filter function. + + Args: + func: The filter function to use. + Return: + A list of found resources, or an empty list. + """ with self.lockResources: return self.tabResources.search(func) # type: ignore [arg-type] @@ -961,7 +1036,6 @@ def hasResource(self, ri:Optional[str] = None, with self.lockResources: if ri: return self.tabResources.contains(doc_id = ri) # type:ignore[arg-type] - # return self.tabResources.contains(self.resourceQuery.ri == ri) elif csi : return self.tabResources.contains(self.resourceQuery.csi == csi) elif ty is not None: # ty is an int @@ -974,12 +1048,24 @@ def hasResource(self, ri:Optional[str] = None, def countResources(self) -> int: + """ Return the number of resources in the database. + + Return: + The number of resources in the database. + """ with self.lockResources: return len(self.tabResources) def searchByFragment(self, dct:dict) -> list[Document]: - """ Search and return all resources that match the given dictionary/document. """ + """ Search and return all resources that match the given dictionary/document. + + Args: + dct: The dictionary/document to search for. + + Return: + A list of found resources, or an empty list. + """ with self.lockResources: return self.tabResources.search(self.resourceQuery.fragment(dct)) @@ -997,14 +1083,6 @@ def insertIdentifier(self, resource:Resource, ri:str, srn:str) -> None: 'ty' : resource.ty }, ri)) # type:ignore[arg-type] - # self.tabIdentifiers.upsert( - # { 'ri' : ri, - # 'rn' : resource.rn, - # 'srn' : srn, - # 'ty' : resource.ty - # }, - # self.identifierQuery.ri == ri) - with self.lockStructuredIDs: self.tabStructuredIDs.upsert( Document({'srn': srn, @@ -1015,7 +1093,6 @@ def insertIdentifier(self, resource:Resource, ri:str, srn:str) -> None: def deleteIdentifier(self, resource:Resource) -> None: with self.lockIdentifiers: self.tabIdentifiers.remove(doc_ids = [resource.ri]) - # self.tabIdentifiers.remove(self.identifierQuery.ri == resource.ri) with self.lockStructuredIDs: self.tabStructuredIDs.remove(doc_ids = [resource.getSrn()]) # type:ignore[arg-type,list-item] @@ -1040,13 +1117,11 @@ def searchIdentifiers(self, ri:Optional[str] = None, ri = _r['ri'] if _r else None else: return [] - # return self.tabIdentifiers.search(self.identifierQuery.srn == srn) if ri: with self.lockIdentifiers: _r = self.tabIdentifiers.get(doc_id = ri) # type:ignore[arg-type, assignment] return [_r] if _r else [] - # return self.tabIdentifiers.search(self.identifierQuery.ri == ri) return [] @@ -1114,7 +1189,6 @@ def searchSubscriptions(self, ri:Optional[str] = None, if ri: _r:Document = self.tabSubscriptions.get(doc_id = ri) # type:ignore[arg-type, assignment] return [_r] if _r else [] - # return self.tabSubscriptions.search(self.subscriptionQuery.ri == ri) if pi: return self.tabSubscriptions.search(self.subscriptionQuery.pi == pi) return None @@ -1320,17 +1394,6 @@ def insertRequest(self, op:Operation, Document({k: v for k, v in _doc.items() if v is not None}, self.tabRequests.document_id_class(ts))) # type:ignore[arg-type] - # self.tabRequests.insert( - # Document({'ri': ri, - # 'srn': srn, - # 'ts': ts, - # 'org': originator, - # 'op': op, - # 'rsc': rsc, - # 'out': outgoing, - # 'req': request, - # 'rsp': response - # }, self.tabRequests.document_id_class(ts))) # type:ignore[arg-type] except Exception as e: L.logErr(f'Exception inserting request/response for ri: {ri}', exc = e) return False From dc1927c77f06fb7bec68530486d05645f8ffd7a8 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 14 Sep 2023 12:52:37 +0200 Subject: [PATCH 106/165] Updated for shapely. Renamed --- acme/etc/GeoTools.py | 160 +++++++++++++++++++++++++++++++++++++++++++ acme/etc/GeoUtils.py | 75 -------------------- requirements.txt | 10 +-- setup.py | 2 +- 4 files changed, 166 insertions(+), 81 deletions(-) create mode 100644 acme/etc/GeoTools.py delete mode 100644 acme/etc/GeoUtils.py diff --git a/acme/etc/GeoTools.py b/acme/etc/GeoTools.py new file mode 100644 index 00000000..16577565 --- /dev/null +++ b/acme/etc/GeoTools.py @@ -0,0 +1,160 @@ +# +# GeoUtils.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +# Various helpers for working with geo-coordinates, shapely, and geoJSON +# + +""" Utility functions for geo-coordinates and geoJSON +""" + +from typing import Union, Optional, cast +import json + +from shapely import Point, Polygon, LineString, MultiPoint, MultiLineString, MultiPolygon +from shapely.geometry.base import BaseGeometry + +from ..etc.Types import GeometryType + + +def getGeoPoint(jsn:Optional[Union[dict, str]]) -> Optional[tuple[float, float]]: + """ Get the geo-point from a geoJSON object. + + Args: + jsn: The geoJSON object as a dictionary or a string. + + Returns: + A tuple of the geo-point (latitude, longitude). None if not found or invalid JSON. + """ + if jsn is None: + return None + if isinstance(jsn, str): + try: + jsn = json.loads(jsn) + except ValueError: + return None + if cast(dict, jsn).get('type') != 'Point': + return None + if coordinates := cast(dict, jsn).get('coordinates'): + return coordinates[0], coordinates[1] + return None + + +def getGeoPolygon(jsn:Optional[Union[dict, str]]) -> Optional[list[tuple[float, float]]]: + """ Get the geo-polygon from a geoJSON object. + + Args: + jsn: The geoJSON object as a dictionary or a string. + + Returns: + A list of tuples of the geo-polygon (latitude, longitude). None if not found or invalid JSON. + """ + if jsn is None: + return None + if isinstance(jsn, str): + try: + jsn = json.loads(jsn) + except ValueError: + return None + if cast(dict, jsn).get('type') != 'Polygon': + return None + if coordinates := cast(dict, jsn).get('coordinates'): + return coordinates[0] + return None + + +def isLocationInsidePolygon(polygon:list[tuple[float, float]], location:tuple[float, float]) -> bool: + """ Check if a location is inside a polygon. + + Args: + polygon: The polygon as a list of tuples (latitude, longitude). + location: The location as a tuple (latitude, longitude). + + Returns: + True if the location is inside the polygon, False otherwise. + """ + return Polygon(polygon).contains(Point(location)) + + +def geoWithin(aType:GeometryType, aShape:tuple|list, bType:GeometryType, bShape:tuple|list) -> bool: + """ Check if a shape is within another shape. + + Args: + aType: The type of the first shape. + aShape: The shape of the first shape. + bType: The type of the second shape. + bShape: The shape of the second shape. + + Returns: + True if the first shape is (fully) within the second shape, False otherwise. + """ + return getGeoShape(aType, aShape).within(getGeoShape(bType, bShape)) + + +def geoContains(aType:GeometryType, aShape:tuple|list, bType:GeometryType, bShape:tuple|list) -> bool: + """ Check if a shape contains another shape. + + Args: + aType: The type of the first shape. + aShape: The shape of the first shape. + bType: The type of the second shape. + bShape: The shape of the second shape. + + Returns: + True if the first shape (fully) contains the second shape, False otherwise. + """ + return getGeoShape(aType, aShape).contains(getGeoShape(bType, bShape)) + + +def geoIntersects(aType:GeometryType, aShape:tuple|list, bType:GeometryType, bShape:tuple|list) -> bool: + """ Check if a shape intersects another shape. + + Args: + aType: The type of the first shape. + aShape: The shape of the first shape. + bType: The type of the second shape. + bShape: The shape of the second shape. + + Returns: + True if the first shape intersects the second shape, False otherwise. + """ + return getGeoShape(aType, aShape).intersects(getGeoShape(bType, bShape)) + + +def getGeoShape(typ:GeometryType, shape:tuple|list) -> BaseGeometry: + """ Get a shapely geometry object from a geoJSON shape. + + Args: + typ: The geometry type. + shape: The geoJSON shape as a tuple or list. + + Returns: + A shapely geometry object. + """ + try: + match typ: + case GeometryType.Point: + return Point(shape) + case GeometryType.LineString: + return LineString(shape) + case GeometryType.Polygon: + return Polygon(shape) + case GeometryType.MultiPoint: + return MultiPoint(shape) + case GeometryType.MultiLineString: + return MultiLineString(shape) + case GeometryType.MultiPolygon: + # Convert to list to polygons. This is necessary because shapely does not support + # passing a list of polygons to the MultiPolygon constructor. Those polygons must + # contain "hole" definitions. So we need to create Polygons first and then + # pass them to the MultiPolygon constructor. + ps:list[Polygon] = [] + for s in shape: + if not isinstance(s, list): + raise ValueError(f'Invalid geometry shape: {shape}') + ps.append(Polygon(s)) + return MultiPolygon(ps) + except TypeError as e: + raise ValueError(f'Invalid geometry shape: {shape} ({e})') diff --git a/acme/etc/GeoUtils.py b/acme/etc/GeoUtils.py deleted file mode 100644 index 53ff8d98..00000000 --- a/acme/etc/GeoUtils.py +++ /dev/null @@ -1,75 +0,0 @@ -# -# GeoUtils.py -# -# (c) 2023 by Andreas Kraft -# License: BSD 3-Clause License. See the LICENSE file for further details. -# -# Various helpers for working with geo-coordinates, shapely, and geoJSON -# - -""" Utility functions for geo-coordinates and geoJSON -""" - -from typing import Union, Optional, cast -import json -from shapely import Point, Polygon - - -def getGeoPoint(jsn:Optional[Union[dict, str]]) -> Optional[tuple[float, float]]: - """ Get the geo-point from a geoJSON object. - - Args: - jsn: The geoJSON object as a dictionary or a string. - - Returns: - A tuple of the geo-point (latitude, longitude). None if not found or invalid JSON. - """ - if jsn is None: - return None - if isinstance(jsn, str): - try: - jsn = json.loads(jsn) - except ValueError: - return None - if cast(dict, jsn).get('type') != 'Point': - return None - if coordinates := cast(dict, jsn).get('coordinates'): - return coordinates[0], coordinates[1] - return None - - -def getGeoPolygon(jsn:Optional[Union[dict, str]]) -> Optional[list[tuple[float, float]]]: - """ Get the geo-polygon from a geoJSON object. - - Args: - jsn: The geoJSON object as a dictionary or a string. - - Returns: - A list of tuples of the geo-polygon (latitude, longitude). None if not found or invalid JSON. - """ - if jsn is None: - return None - if isinstance(jsn, str): - try: - jsn = json.loads(jsn) - except ValueError: - return None - if cast(dict, jsn).get('type') != 'Polygon': - return None - if coordinates := cast(dict, jsn).get('coordinates'): - return coordinates[0] - return None - - -def isLocationInsidePolygon(polygon:list[tuple[float, float]], location:tuple[float, float]) -> bool: - """ Check if a location is inside a polygon. - - Args: - polygon: The polygon as a list of tuples (latitude, longitude). - location: The location as a tuple (latitude, longitude). - - Returns: - True if the location is inside the polygon, False otherwise. - """ - return Polygon(polygon).contains(Point(location)) - diff --git a/requirements.txt b/requirements.txt index 194b5f86..8068a71f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,9 +12,9 @@ certifi==2023.7.22 # via requests charset-normalizer==3.2.0 # via requests -click==8.1.6 +click==8.1.7 # via flask -flask==2.3.2 +flask==2.3.3 # via # ACME-oneM2M-CSE (setup.py) # flask-cors @@ -59,7 +59,7 @@ plotext==5.2.8 # via ACME-oneM2M-CSE (setup.py) prompt-toolkit==3.0.39 # via inquirerpy -pygments==2.15.1 +pygments==2.16.1 # via rich pyparsing==3.1.1 # via rdflib @@ -77,7 +77,7 @@ shapely==2.0.1 # via ACME-oneM2M-CSE (setup.py) six==1.16.0 # via isodate -textual==0.32.0 +textual==0.36.0 # via ACME-oneM2M-CSE (setup.py) tinydb==4.8.0 # via ACME-oneM2M-CSE (setup.py) @@ -89,7 +89,7 @@ urllib3==2.0.4 # via requests wcwidth==0.2.6 # via prompt-toolkit -werkzeug==2.3.6 +werkzeug==2.3.7 # via flask zipp==3.16.2 # via importlib-metadata diff --git a/setup.py b/setup.py index c9259c2b..68bd4f6b 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name='ACME-oneM2M-CSE', - version='0.12.0', + version='0.13.0', url='https://github.com/ankraft/ACME-oneM2M-CSE', author='Andreas Kraft', author_email='an.kraft@gmail.com', From 9e1752b6b8119ab9d0a8174c998a13d15beda128 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 14 Sep 2023 13:00:57 +0200 Subject: [PATCH 107/165] Support for geoQuery --- CHANGELOG.md | 4 +- acme/etc/Types.py | 28 +- acme/resources/LCP.py | 2 +- acme/resources/Resource.py | 24 +- acme/services/Dispatcher.py | 5 + acme/services/LocationManager.py | 41 +- acme/services/RequestManager.py | 26 +- acme/services/Validator.py | 2 +- init/attributePolicies.ap | 29 + init/complexTypePolicies.ap | 60 +- tests/testLocation.py | 1207 +++++++++++++++++++++++++++++- 11 files changed, 1323 insertions(+), 105 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a9150ee..882460e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [CSE] Added automatic pip install of missing dependencies during startup. - [CSE] Added support for <schedule> resource type. - [CSE] Added support for *Result Expiration Timestamp* request parameter for handling timeouts in fanoutPoint request aggregations. -- [LCP] Added (limited) support for <locationPolicy> resource type and location management for *device based* location policies. +- [CSE] Added (limited) support for <locationPolicy> resource type and location management for *device based* location policies. +- [CSE] Added support *location* attribute and *locationQuery* request parameters and functionality - [SCRIPTS] Added "dolist", "dotimes", "tui-notify", "cse-attribute-info", sand "get-loglevel" functions to the script interpreter. - [TUI] Improved resource view in the text UI. Enumeration interpretations are now shown. - [TUI] Added utility "Attribute Info Search". - ### Experimental ### Changed diff --git a/acme/etc/Types.py b/acme/etc/Types.py index 9cb24356..b07c6fb3 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -1563,17 +1563,7 @@ class GeoSpatialFunctionType(ACMEIntEnum): """ Intersects.""" -class GeoQuery: - """ Geo Query. - """ - geometryType:GeometryType = None - """ Geometry Type. """ - geometry:ListOfCoordinates = [] - """ Geometry. """ - geoSpatialFunction:GeoSpatialFunctionType = None - - ############################################################################## # # Result and Argument and Header Data Classes @@ -1769,10 +1759,20 @@ class FilterCriteria: lbl:list = None """ List of labels. Default is *None*. """ - gq:GeoQuery = None - """ Geo query. Default is *None*. """ + gmty:GeometryType = None + """ geometryType for geo-query. Default is *None*. """ + + geom:str = None + """ geometry for geo-query. Default is *None*. """ + + _geom:list = None + """ Internal attribute to hold a parsed geometry. Default is *None*.""" + + gsf:GeoSpatialFunctionType = None + """ geoSpatialFunction for geo-query. Default is *None*. """ + - aq:str = None + aq:str = None # EXPERIMENTAL """ Advanced query. Default is *None*. """ @@ -1802,7 +1802,7 @@ def criteriaAttributes(self) -> dict: """ return { k:v for k, v in self.__dict__.items() - if k is not None and k not in [ 'fu', 'fo', 'lim', 'ofst', 'lvl', 'arp', 'attributes' ] and v is not None + if k is not None and k not in ( 'fu', 'fo', 'lim', 'ofst', 'lvl', 'arp', 'attributes', 'gmty', 'geom', '_geom', 'gsf' ) and v is not None } diff --git a/acme/resources/LCP.py b/acme/resources/LCP.py index 389040bf..e4fa1511 100644 --- a/acme/resources/LCP.py +++ b/acme/resources/LCP.py @@ -21,7 +21,7 @@ from ..resources.AnnounceableResource import AnnounceableResource from ..resources import Factory from ..etc.ResponseStatusCodes import BAD_REQUEST, NOT_IMPLEMENTED -from ..etc.GeoUtils import getGeoPolygon +from ..etc.GeoTools import getGeoPolygon # TODO add annc # TODO add to supported resources of CSE diff --git a/acme/resources/Resource.py b/acme/resources/Resource.py index 07e106ff..41029616 100644 --- a/acme/resources/Resource.py +++ b/acme/resources/Resource.py @@ -532,8 +532,8 @@ def validate(self, originator:Optional[str] = None, crd = CSE.validator.validateGeoLocation(loc) if dct is not None: setXPath(dct, f'{self.tpe}/{_locCoordinate}', crd, overwrite = True) - else: - self.setAttribute(_locCoordinate, crd) + else: + self.setLocationCoordinates(crd) ######################################################################### @@ -1084,4 +1084,22 @@ def setRVI(self, rvi:str) -> None: Args: rvi: Original CREATE request's *rvi*. """ - self.setAttribute(_rvi, rvi) \ No newline at end of file + self.setAttribute(_rvi, rvi) + + + def getLocationCoordinates(self) -> list: + """ Retrieve a resource's location coordinates (internal attribute). + + Return: + The resource's location coordinates. Might be None. + """ + return self.attribute(_locCoordinate) + + + def setLocationCoordinates(self, crd:JSON) -> None: + """ Set a resource's location coordinates (internal attribute). + + Args: + crd: The location coordinates to assign to a resource. + """ + self.setAttribute(_locCoordinate, crd) \ No newline at end of file diff --git a/acme/services/Dispatcher.py b/acme/services/Dispatcher.py index 423fd728..8b3a2cd3 100644 --- a/acme/services/Dispatcher.py +++ b/acme/services/Dispatcher.py @@ -531,6 +531,11 @@ def _matchResource(self, r:Resource, fo:int, allLen:int, filterCriteria:FilterCr if filterCriteria.aq: found += 1 if CSE.script.runComparisonQuery(filterCriteria.aq, r) else 0 + # Geo query + if filterCriteria.geom: # Just check one of the tree required attributes. If one is there, all are there + allLen += 1 # Add one more criteria to check to the required count + if r.loc: # Only check if the resource has a location + found += 1 if CSE.location.checkGeoLocation(r, filterCriteria.gmty, filterCriteria._geom, filterCriteria.gsf) else 0 # L.isDebug and L.logDebug(f'fo: {fo}, found: {found}, allLen: {allLen}') # Test whether the OR or AND criteria is fullfilled diff --git a/acme/services/LocationManager.py b/acme/services/LocationManager.py index f4302a28..78f2ac41 100644 --- a/acme/services/LocationManager.py +++ b/acme/services/LocationManager.py @@ -12,16 +12,19 @@ from typing import Tuple, Optional, Literal from dataclasses import dataclass +import json from ..helpers.BackgroundWorker import BackgroundWorkerPool, BackgroundWorker -from ..etc.Types import LocationInformationType, LocationSource, GeofenceEventCriteria, ResourceTypes +from ..etc.Types import LocationInformationType, LocationSource, GeofenceEventCriteria, ResourceTypes, GeometryType, GeoSpatialFunctionType from ..etc.DateUtils import fromDuration -from ..etc.GeoUtils import getGeoPoint, getGeoPolygon, isLocationInsidePolygon +from ..etc.GeoTools import getGeoPoint, getGeoPolygon, isLocationInsidePolygon, geoWithin, geoContains, geoIntersects +from ..etc.ResponseStatusCodes import BAD_REQUEST from ..services.Logging import Logging as L from ..services import CSE from ..resources.LCP import LCP from ..resources.CIN import CIN from ..resources import Factory +from ..resources.Resource import Resource GeofencePositionType = Literal[GeofenceEventCriteria.Inside, GeofenceEventCriteria.Outside] """ Type alias for the geofence position.""" @@ -334,3 +337,37 @@ def checkGeofence(self, lcpRi:str, location:tuple[float, float]) -> GeofencePosi # L.isDebug and L.logDebug(f'Location is: {result}') return result # type:ignore [return-value] + + ######################################################################### + # + # GeoLocation and GeoQuery + # + + def checkGeoLocation(self, r:Resource, gmty:GeometryType, geom:list, gsf:GeoSpatialFunctionType) -> bool: + """ Check if a resource's location confirms to a geo location. + + Args: + r: The resource to check. + gmty: The geometry type. + geom: The geometry. + gsf: The geo spatial function. + + Returns: + True if the resource's location confirms to the geo location, False otherwise. + """ + if (rGeom := r.getLocationCoordinates()) is None: + return False + rTyp = r.loc.get('typ') + + try: + match gsf: + case GeoSpatialFunctionType.Within: + return geoWithin(gmty, geom, rTyp, rGeom) + case GeoSpatialFunctionType.Contains: + return geoContains(gmty, geom, rTyp, rGeom) + case GeoSpatialFunctionType.Intersects: + return geoIntersects(gmty, geom, rTyp, rGeom) + case _: + raise ValueError(f'Invalid geo spatial function: {gsf}') + except ValueError as e: + raise BAD_REQUEST(L.logDebug(f'Invalid geometry: {e}')) diff --git a/acme/services/RequestManager.py b/acme/services/RequestManager.py index f2c62784..bda93c8d 100644 --- a/acme/services/RequestManager.py +++ b/acme/services/RequestManager.py @@ -19,7 +19,7 @@ from ..etc.Types import ResponseStatusCode, ResultContentType, RequestStatus, CSERequest, RequestHandler from ..etc.Types import ResourceTypes, ResponseStatusCode, ResponseType, Result, EventCategory from ..etc.Types import CSERequest, ContentSerializationType, RequestResponseList, RequestResponse -from ..etc.ResponseStatusCodes import ResponseException, exceptionFromRSC +from ..etc.ResponseStatusCodes import ResponseException from ..etc.ResponseStatusCodes import BAD_REQUEST, NOT_FOUND, REQUEST_TIMEOUT, RELEASE_VERSION_NOT_SUPPORTED from ..etc.ResponseStatusCodes import UNSUPPORTED_MEDIA_TYPE, OPERATION_NOT_ALLOWED, REQUEST_TIMEOUT from ..etc.DateUtils import getResourceDate, fromAbsRelTimestamp, utcTime, waitFor, toISO8601Date, fromDuration @@ -1302,18 +1302,34 @@ def gget(dct:dict, # Discovery and FilterCriteria # if fcAttrs: # only when there is a filterCriteria, copy the available attribute to the FilterCriteria structure - for h in [ 'lim', 'lvl', 'ofst', 'arp', + for h in ( 'lim', 'lvl', 'ofst', 'arp', 'crb', 'cra', 'ms', 'us', 'sts', 'stb', 'exb', 'exa', 'lbq', 'sza', 'szb', 'catr', 'patr', 'smf', - 'aq']: + 'aq'): if (v := gget(fcAttrs, h)) is not None: # may be int cseRequest.fc.set(h, v) - for h in [ 'lbl', 'cty' ]: # different handling of list attributes + for h in ( 'lbl', 'cty' ): # different handling of list attributes if (v := gget(fcAttrs, h, attributeType = BasicType.list, checkSubType = False)) is not None: cseRequest.fc.set(h, v) - for h in [ 'ty' ]: # different handling of list attributes that are normally non-lists + for h in ( 'ty', ): # different handling of list attributes that are normally non-lists if (v := gget(fcAttrs, h, attributeType = BasicType.list, checkSubType = True)) is not None: # may be int cseRequest.fc.set(h, v) + + # Handling of geo-query attributes + match len([a for a in ('gmty', 'geom', 'gsf') if a in fcAttrs]): + case 0: + pass + case 1 | 2: + raise BAD_REQUEST(L.logDebug('gmty, geom and gsf must be specified together'), data = cseRequest) + case 3: + if (v := gget(fcAttrs, 'gmty')) is not None: + cseRequest.fc.gmty = v + geom = fcAttrs.get('geom') + if (v := gget(fcAttrs, 'geom')) is not None: + cseRequest.fc.geom = geom + cseRequest.fc._geom = v + if (v := gget(fcAttrs, 'gsf')) is not None: + cseRequest.fc.gsf = v # Copy all remaining attributes as filter criteria! diff --git a/acme/services/Validator.py b/acme/services/Validator.py index de96080f..426c8b81 100644 --- a/acme/services/Validator.py +++ b/acme/services/Validator.py @@ -434,7 +434,7 @@ def validateGeoLocation(self, loc:dict) -> dict: BAD_REQUEST: If the location definition is invalid. """ crd = json.loads(loc.get('crd')) # was validated before - match (typ := loc.get('typ')): + match loc.get('typ'): case GeometryType.Point: if not self.validateGeoPoint(crd): raise BAD_REQUEST(L.logWarn(f'Invalid GeoJSON point: {crd}')) diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index b713c9f8..d3f6d3fb 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -1386,6 +1386,25 @@ "annc": "OA" } ], + "geom": [ + { + "rtypes": [ "REQRESP" ], + "lname": "geometryType", + "ns": "m2m", + "type": "geoJsonCoordinate", + "car": "1" + } + ], + "gmty": [ + { + "rtypes": [ "REQRESP" ], + "lname": "geometryType", + "ns": "m2m", + "type": "enum", + "etype": "m2m:geometryType", + "car": "1" + } + ], "gn": [ { "rtypes": [ "ALL" ], @@ -1412,6 +1431,16 @@ "annc": "NA" } ], + "gsf": [ + { + "rtypes": [ "REQRESP" ], + "lname": "geoSpatialFunction", + "ns": "m2m", + "type": "enum", + "etype": "m2m:geoSpatialFunctionType", + "car": "1" + } + ], "gta": [ { "rtypes": [ "LCP", "LCPAnnc" ], diff --git a/init/complexTypePolicies.ap b/init/complexTypePolicies.ap index b8672f7c..dafc1f63 100644 --- a/init/complexTypePolicies.ap +++ b/init/complexTypePolicies.ap @@ -968,7 +968,7 @@ "car": "01" } ], - "ri": [ + "rsid": [ { "rtypes": [ "COMPLEX" ], "ctype": "m2m:actionInput", @@ -1426,16 +1426,16 @@ "car": "01" } ], - "gq": [ - { - "rtypes": [ "COMPLEX" ], - "ctype": "m2m:filterCriteria", - "lname": "geoQuery", - "ns": "m2m", - "type": "m2m:geoQuery", - "car": "01" - } - ], + // "gq": [ + // { + // "rtypes": [ "COMPLEX" ], + // "ctype": "m2m:filterCriteria", + // "lname": "geoQuery", + // "ns": "m2m", + // "type": "m2m:geoQuery", + // "car": "01" + // } + // ], // @@ -1454,44 +1454,6 @@ ], - // - // m2m:geoQuery - // - "gmty": [ - { - "rtypes": [ "COMPLEX" ], - "ctype": "m2m:geoQuery", - "lname": "geometryType", - "ns": "m2m", - "type": "enum", - "etype": "m2m:geometryType", - "car": "1" - } - ], - "geom": [ - { - "rtypes": [ "COMPLEX" ], - "ctype": "m2m:geoQuery", - "lname": "geometryType", - "ns": "m2m", - "type": "list", // TODO m2m:listOfCoordinates -> list of list of floats or GeoJSON? - "ltype": "string", - "car": "1" - } - ], - "gsf": [ - { - "rtypes": [ "COMPLEX" ], - "ctype": "m2m:geoQuery", - "lname": "geoSpatialFunction", - "ns": "m2m", - "type": "enum", - "etype": "m2m:geoSpatialFunctionType", - "car": "1" - } - ], - - // // m2m:locationRegion // diff --git a/tests/testLocation.py b/tests/testLocation.py index 81c19e3f..485ab998 100644 --- a/tests/testLocation.py +++ b/tests/testLocation.py @@ -21,7 +21,6 @@ class TestLocation(unittest.TestCase): ae = None aeRI = None - originator = None @classmethod @@ -390,50 +389,1202 @@ def test_createContainerLocMultiPolygon(self) -> None: r, rsc = DELETE(cntURL, self.originator) self.assertEqual(rsc, RC.DELETED, r) + # + # geo-query + # + def test_geoQueryGmtyOnlyFail(self) -> None: + """ RETRIEVE with rcn=4, gmty only -> Fail""" + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1', self.originator) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + def test_geoQueryGeomOnlyFail(self) -> None: + """ RETRIEVE with rcn=4, geom only -> Fail""" + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&geom=[1.0,2.0]', self.originator) + self.assertEqual(rsc, RC.BAD_REQUEST, r) - ######################################################################### + def test_geoQueryGsfOnlyFail(self) -> None: + """ RETRIEVE with rcn=4, gsf only -> Fail""" -def run(testFailFast:bool) -> Tuple[int, int, int, float]: - suite = unittest.TestSuite() + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gsf=1', self.originator) + self.assertEqual(rsc, RC.BAD_REQUEST, r) - # basic tests - suite.addTest(TestLocation('test_createContainerWrongLocFail')) + + def test_geoQueryGeomWrongFail(self) -> None: + """ RETRIEVE with rcn=4, geometry wrong format -> Fail""" + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=1&geom=1.0', self.originator) + self.assertEqual(rsc, RC.BAD_REQUEST, r) # Point - suite.addTest(TestLocation('test_createContainerLocWrongAttributesFail')) - suite.addTest(TestLocation('test_createContainerLocPointIntCoordinatesFail')) - suite.addTest(TestLocation('test_createContainerLocPointWrongCountFail')) - suite.addTest(TestLocation('test_createContainerLocPoint')) + + def test_geoQueryPointWithinPolygon(self) -> None: + """ CREATE , RETRIEVE , geometry point is within polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=1&geom=[0.5,0.5]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointOutsidePolygon(self) -> None: + """ CREATE , RETRIEVE , geometry point outside polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=1&geom=[2.0,2.0]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointWithinPoint(self) -> None: + """ CREATE , RETRIEVE , geometry point is within point""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 1, + 'crd': '[ 1.0, 1.0 ]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=1&geom=[1.0,1.0]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointContainsPoint(self) -> None: + """ CREATE , RETRIEVE , geometry point contains point""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 1, + 'crd': '[ 1.0, 1.0 ]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=2&geom=[1.0,1.0]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointContainsPolygonFail(self) -> None: + """ CREATE , RETRIEVE , geometry point contains polygon -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=2&geom=[0.5,0.5]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointIntersectsPoint(self) -> None: + """ CREATE , RETRIEVE , geometry point intersects point""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 1, + 'crd': '[ 1.0, 1.0 ]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=3&geom=[1.0,1.0]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointIntersectsPointFail(self) -> None: + """ CREATE , RETRIEVE , geometry point intersects point -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 1, + 'crd': '[ 1.0, 1.0 ]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=3&geom=[2.0,2.0]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointIntersectsPolygon(self) -> None: + """ CREATE , RETRIEVE , geometry point intersects polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=3&geom=[0.0,0.5]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointIntersectsPolygonFail(self) -> None: + """ CREATE , RETRIEVE , geometry point intersects polygon -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=2&geom=[0.5,0.5]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + # LineString - suite.addTest(TestLocation('test_createContainerLocLineStringWrongCountFail')) - suite.addTest(TestLocation('test_createContainerLocLineString')) - # Polygon - suite.addTest(TestLocation('test_createContainerLocPolygonWrongCountFail')) - suite.addTest(TestLocation('test_createContainerLocPolygonWrongFirstLastCoordinateFail')) - suite.addTest(TestLocation('test_createContainerLocPolygon')) + def test_geoQueryLineStringWithinPolygon(self) -> None: + """ CREATE , RETRIEVE , geometry line strinng is within polygon""" - # MultiPoint - suite.addTest(TestLocation('test_createContainerLocMultiPointWrongFail')) - suite.addTest(TestLocation('test_createContainerLocMultiPointWrongCountFail')) - suite.addTest(TestLocation('test_createContainerLocMultiPoint')) + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) - # MultiLineString - suite.addTest(TestLocation('test_createContainerLocMultiLineStringWrongFail')) - suite.addTest(TestLocation('test_createContainerLocMultiLineString2WrongFail')) - suite.addTest(TestLocation('test_createContainerLocMultiLineString')) + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=2&gsf=1&geom=[[0.5,0.5],[0.6,0.6]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) - # MultiPolygon - suite.addTest(TestLocation('test_createContainerLocMultiPolygonWrongFail')) - suite.addTest(TestLocation('test_createContainerLocMultiPolygonWrongFirstLastCoordinateFail')) - suite.addTest(TestLocation('test_createContainerLocMultiPolygon')) + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryLineStringOutsidePolygon(self) -> None: + """ CREATE , RETRIEVE , geometry line string outside polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=2&gsf=1&geom=[[2.0,2.0],[3.0,3.0]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointWithinLineString1(self) -> None: + """ CREATE , RETRIEVE , geometry point is within LineString start point""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 2, + 'crd': '[[ 1.0, 1.0 ], [ 2.0, 2.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=1&geom=[1.0,1.0]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointWithinLineString2(self) -> None: + """ CREATE , RETRIEVE , geometry point is within LineString middle""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 2, + 'crd': '[[ 1.0, 1.0 ], [ 2.0, 2.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=1&geom=[1.5,1.5]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryLineStringContainsLineString(self) -> None: + """ CREATE , RETRIEVE , geometry line string contains line string""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 2, + 'crd': '[[ 1.0, 1.0 ], [ 2.0, 2.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=2&gsf=2&geom=[[1.0,1.0],[2.0,2.0]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryLineStringContainsPolygonFail(self) -> None: + """ CREATE , RETRIEVE , geometry point contains polygon -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=2&gsf=2&geom=[[0.5,0.5],[0.6,0.6]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointIntersectsLineString(self) -> None: + """ CREATE , RETRIEVE , geometry point intersects line string""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 2, + 'crd': '[[ 1.0, 1.0 ], [ 2.0, 2.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=3&geom=[1.5,1.5]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryLineStringIntersectsLineString(self) -> None: + """ CREATE , RETRIEVE , geometry line string intersects line string""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 2, + 'crd': '[[ 1.0, 1.0 ], [ 2.0, 2.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=2&gsf=3&geom=[[2.0,1.0],[1.0,2.0]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryLineStringIntersectsLineStringFail(self) -> None: + """ CREATE , RETRIEVE , geometry line string intersects line string -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 2, + 'crd': '[[ 1.0, 1.0 ], [ 2.0, 2.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=2&gsf=3&geom=[[3.0,3.0],[4.0,4.0]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + # Polygon + + def test_geoQueryPolygonWithinPolygon(self) -> None: + """ CREATE , RETRIEVE , geometry polygon is within polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=3&gsf=1&geom=[[0.5,0.5],[0.6,0.5],[0.6,0.6],[0.5,0.6],[0.5,0.5]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPolygonOutsidePolygon(self) -> None: + """ CREATE , RETRIEVE , geometry polygon outside polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=3&gsf=1&geom=[[2.0,2.0],[3.0,2.0],[3.0,3.0],[2.0,3.0],[2.0,2.0]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPolygonPartlyWithinPolygonFail(self) -> None: + """ CREATE , RETRIEVE , geometry polygon partly is within polygon -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=3&gsf=1&geom=[[0.5,0.5],[1.5,0.5],[1.5,1.5],[0.5,1.5],[0.5,0.5]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPolygonContainsPolygon(self) -> None: + """ CREATE , RETRIEVE , geometry polygon contains polygon """ + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=3&gsf=2&geom=[[0.0,0.0],[2.0,0.0],[2.0,2.0],[0.0,2.0],[0.0,0.0]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPolygonContainsPolygonFail(self) -> None: + """ CREATE , RETRIEVE , geometry polygon contains polygon -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 3.0, 0.0 ], [ 3.0, 3.0 ], [ 0.0, 3.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=3&gsf=2&geom=[[0.0,0.0],[2.0,0.0],[2.0,2.0],[0.0,2.0],[0.0,0.0]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPolygonIntersectsPolygon(self) -> None: + """ CREATE , RETRIEVE , geometry polygon intersects polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=3&gsf=3&geom=[[0.5,0.5],[2.0,0.5],[2.0,2.0],[0.5,2.0],[0.5,0.5]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPolygonIntersectsPolygonFail(self) -> None: + """ CREATE , RETRIEVE , geometry polygon intersects polygon -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=3&gsf=3&geom=[[1.5,1.5],[2.0,1.5],[2.0,2.0],[1.5,2.0],[1.5,1.5]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + + # MultiPoint + + def test_geoQueryMultiPointWithinPolygon(self) -> None: + """ CREATE , RETRIEVE , geometry multi point is within polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=4&gsf=1&geom=[[0.5,0.5],[0.6,0.6]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryMultiPointOutsidePolygon(self) -> None: + """ CREATE , RETRIEVE , geometry multi point outside polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=4&gsf=1&geom=[[2.0,2.0],[3.0,3.0]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryMultiPointOutsidePolygonWrongGmtyFail(self) -> None: + """ CREATE , RETRIEVE , geometry type invalid for geometry -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + # request with invalid geometry + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=3&gsf=1&geom=[[2.0,2.0],[3.0,3.0]]', self.originator) + self.assertEqual(rsc, RC.BAD_REQUEST, r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryMultiPointContainsPoint(self) -> None: + """ CREATE , RETRIEVE , geometry multi point contains Point""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 1, + 'crd': '[0.5, 0.5]', + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=4&gsf=2&geom=[[0.5,0.5],[0.6,0.6]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryMultiPointContainsPointFail(self) -> None: + """ CREATE , RETRIEVE , geometry multi point contains Point -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 1, + 'crd': '[0.5, 0.5]', + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=4&gsf=2&geom=[[0.4,0.4],[0.6,0.6]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointIntersectsMultiPoint(self) -> None: + """ CREATE , RETRIEVE , geometry point intersects multi point""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 4, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=3&geom=[0.0,0.0]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointIntersectsMultiPointFail(self) -> None: + """ CREATE , RETRIEVE , geometry point intersects multi point -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 4, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=3&geom=[2.0,2.0]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryMultiPointIntersectsMultiPoint(self) -> None: + """ CREATE , RETRIEVE , geometry multi point intersects multi point""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 4, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=4&gsf=3&geom=[[0.0,0.0],[2.0,2.0]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryMultiPointIntersectsMultiPointFail(self) -> None: + """ CREATE , RETRIEVE , geometry multi point intersects multi point -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 4, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=4&gsf=3&geom=[[3.0,3.0],[2.0,2.0]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + + # MultiLinestring + + def test_geoQueryMultiLinestringWithinPolygon(self) -> None: + """ CREATE , RETRIEVE , geometry multi line string within polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=5&gsf=1&geom=[[[0.5,0.5],[0.6,0.6]],[[0.7,0.7],[0.8,0.8]]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryMultiLinestringOutsidePolygon(self) -> None: + """ CREATE , RETRIEVE , geometry multi line string outside polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=5&gsf=1&geom=[[[1.5,1.5],[1.6,1.6]],[[1.7,1.7],[1.8,1.8]]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryMultiLineContainsPoint(self) -> None: + """ CREATE , RETRIEVE , geometry multi line contains Point""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 1, + 'crd': '[1.55, 1.55]', + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=5&gsf=2&geom=[[[1.5,1.5],[1.6,1.6]],[[1.7,1.7],[1.8,1.8]]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryMultiLineContainsPointFail(self) -> None: + """ CREATE , RETRIEVE , geometry multi line contains Point -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 1, + 'crd': '[0.5, 0.5]', + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=5&gsf=2&geom=[[[1.5,1.5],[1.6,1.6]],[[1.7,1.7],[1.8,1.8]]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointIntersectsMultiLine(self) -> None: + """ CREATE , RETRIEVE , geometry point intersects multi line""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 5, + 'crd': '[[[1.0,1.0],[2.0,2.0]],[[3.0,3.0],[4.0,4.0]]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=3&geom=[1.5,1.5]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointIntersectsMultiLineFail(self) -> None: + """ CREATE , RETRIEVE , geometry point intersects multi line -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 5, + 'crd': '[[[1.0,1.0],[2.0,2.0]],[[3.0,3.0],[4.0,4.0]]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=3&geom=[5.0,5.0]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryLineStringIntersectsMultiLine(self) -> None: + """ CREATE , RETRIEVE , geometry line string intersects multi line""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 5, + 'crd': '[[[1.0,1.0],[2.0,2.0]],[[3.0,3.0],[4.0,4.0]]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=2&gsf=3&geom=[[2.0,1.0],[1.0,2.0]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryLineStringIntersectsMultiLineFail(self) -> None: + """ CREATE , RETRIEVE , geometry line string intersects multi line -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 5, + 'crd': '[[[1.0,1.0],[2.0,2.0]],[[3.0,3.0],[4.0,4.0]]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=2&gsf=3&geom=[[5.0,5.0],[6.0,6.0]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + + # MultiPolygon + + def test_geoQueryMultiPolygonWithinPolygon(self) -> None: + """ CREATE , RETRIEVE , geometry multi polygon is within polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=6&gsf=1&geom=[[[0.5,0.5],[0.6,0.5],[0.6,0.6],[0.5,0.6],[0.5,0.5]],[[0.7,0.7],[0.8,0.7],[0.8,0.8],[0.7,0.8],[0.7,0.7]]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryMultiPolygonOutsidePolygon(self) -> None: + """ CREATE , RETRIEVE , geometry multi polygon outside polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 3, + 'crd': '[[ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ], [ 0.0, 0.0 ]]' + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=6&gsf=1&geom=[[[1.5,1.5],[1.6,1.5],[1.6,1.6],[1.5,1.6],[1.5,1.5]],[[1.7,1.7],[1.8,1.7],[1.8,1.8],[1.7,1.8],[1.7,1.7]]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryMultiPolygonContainsPoint(self) -> None: + """ CREATE , RETRIEVE , geometry multi polygon contains Point""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 1, + 'crd': '[1.55, 1.55]', + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=6&gsf=2&geom=[[[1.5,1.5],[1.6,1.5],[1.6,1.6],[1.5,1.6],[1.5,1.5]],[[1.7,1.7],[1.8,1.7],[1.8,1.8],[1.7,1.8],[1.7,1.7]]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryMultiPolygonContainsPointFail(self) -> None: + """ CREATE , RETRIEVE , geometry multi line contains Point -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 1, + 'crd': '[0.5, 0.5]', + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=6&gsf=2&geom=[[[1.5,1.5],[1.6,1.5],[1.6,1.6],[1.5,1.6],[1.5,1.5]],[[1.7,1.7],[1.8,1.7],[1.8,1.8],[1.7,1.8],[1.7,1.7]]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointIntersectsMultiPolygon(self) -> None: + """ CREATE , RETRIEVE , geometry point intersects multi polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 5, + 'crd': '[[[1.5,1.5],[1.6,1.5],[1.6,1.6],[1.5,1.6],[1.5,1.5]],[[1.7,1.7],[1.8,1.7],[1.8,1.8],[1.7,1.8],[1.7,1.7]]]', + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=3&geom=[1.55,1.5]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPointIntersectsMultiPolygonFail(self) -> None: + """ CREATE , RETRIEVE , geometry point intersects multi polygon -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 5, + 'crd': '[[[1.5,1.5],[1.6,1.5],[1.6,1.6],[1.5,1.6],[1.5,1.5]],[[1.7,1.7],[1.8,1.7],[1.8,1.8],[1.7,1.8],[1.7,1.7]]]', + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=1&gsf=3&geom=[2.0,2.0]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPolygonIntersectsMultiPolygon(self) -> None: + """ CREATE , RETRIEVE , geometry polygon intersects multi polygon""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 5, + 'crd': '[[[1.5,1.5],[1.6,1.5],[1.6,1.6],[1.5,1.6],[1.5,1.5]],[[1.7,1.7],[1.8,1.7],[1.8,1.8],[1.7,1.8],[1.7,1.7]]]', + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=3&gsf=3&geom=[[0.0,0.0],[2.0,0.0],[2.0,2.0],[0.0,2.0],[0.0,0.0]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNotNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + + def test_geoQueryPolygonIntersectsMultiPolygonFail(self) -> None: + """ CREATE , RETRIEVE , geometry polygon intersects multi polygon -> Fail""" + + dct = { 'm2m:cnt': { + 'rn': cntRN, + 'loc': { + 'typ': 5, + 'crd': '[[[1.5,1.5],[1.6,1.5],[1.6,1.6],[1.5,1.6],[1.5,1.5]],[[1.7,1.7],[1.8,1.7],[1.8,1.8],[1.7,1.8],[1.7,1.7]]]', + }, + }} + r, rsc = CREATE(aeURL, self.originator, T.CNT, dct) + self.assertEqual(rsc, RC.CREATED, r) + + r, rsc = RETRIEVE(f'{aeURL}?rcn=4&gmty=3&gsf=3&geom=[[2.0,2.0],[4.0,2.0],[4.0,4.0],[2.0,4.0],[2.0,2.0]]', self.originator) + self.assertEqual(rsc, RC.OK, r) + self.assertIsNone(findXPath(r, 'm2m:ae/m2m:cnt'), r) + + r, rsc = DELETE(cntURL, self.originator) + self.assertEqual(rsc, RC.DELETED, r) + + ######################################################################### + + +def run(testFailFast:bool) -> Tuple[int, int, int, float]: + suite = unittest.TestSuite() + + # basic tests + addTest(suite, TestLocation('test_createContainerWrongLocFail')) + + # Point + addTest(suite, TestLocation('test_createContainerLocWrongAttributesFail')) + addTest(suite, TestLocation('test_createContainerLocPointIntCoordinatesFail')) + addTest(suite, TestLocation('test_createContainerLocPointWrongCountFail')) + addTest(suite, TestLocation('test_createContainerLocPoint')) + + # LineString + addTest(suite, TestLocation('test_createContainerLocLineStringWrongCountFail')) + addTest(suite, TestLocation('test_createContainerLocLineString')) + + # Polygon + addTest(suite, TestLocation('test_createContainerLocPolygonWrongCountFail')) + addTest(suite, TestLocation('test_createContainerLocPolygonWrongFirstLastCoordinateFail')) + addTest(suite, TestLocation('test_createContainerLocPolygon')) + + # MultiPoint + addTest(suite, TestLocation('test_createContainerLocMultiPointWrongFail')) + addTest(suite, TestLocation('test_createContainerLocMultiPointWrongCountFail')) + addTest(suite, TestLocation('test_createContainerLocMultiPoint')) + + # MultiLineString + addTest(suite, TestLocation('test_createContainerLocMultiLineStringWrongFail')) + addTest(suite, TestLocation('test_createContainerLocMultiLineString2WrongFail')) + addTest(suite, TestLocation('test_createContainerLocMultiLineString')) + + # MultiPolygon + addTest(suite, TestLocation('test_createContainerLocMultiPolygonWrongFail')) + addTest(suite, TestLocation('test_createContainerLocMultiPolygonWrongFirstLastCoordinateFail')) + addTest(suite, TestLocation('test_createContainerLocMultiPolygon')) + + # geo-query + addTest(suite, TestLocation('test_geoQueryGmtyOnlyFail')) + addTest(suite, TestLocation('test_geoQueryGeomOnlyFail')) + addTest(suite, TestLocation('test_geoQueryGsfOnlyFail')) + addTest(suite, TestLocation('test_geoQueryGeomWrongFail')) + + addTest(suite, TestLocation('test_geoQueryPointWithinPolygon')) + addTest(suite, TestLocation('test_geoQueryPointOutsidePolygon')) + addTest(suite, TestLocation('test_geoQueryPointWithinPoint')) + addTest(suite, TestLocation('test_geoQueryPointContainsPoint')) + addTest(suite, TestLocation('test_geoQueryPointContainsPolygonFail')) + addTest(suite, TestLocation('test_geoQueryPointIntersectsPoint')) + addTest(suite, TestLocation('test_geoQueryPointIntersectsPointFail')) + addTest(suite, TestLocation('test_geoQueryPointIntersectsPolygon')) + addTest(suite, TestLocation('test_geoQueryPointIntersectsPolygonFail')) + + addTest(suite, TestLocation('test_geoQueryLineStringWithinPolygon')) + addTest(suite, TestLocation('test_geoQueryLineStringOutsidePolygon')) + addTest(suite, TestLocation('test_geoQueryPointWithinLineString1')) + addTest(suite, TestLocation('test_geoQueryPointWithinLineString2')) + addTest(suite, TestLocation('test_geoQueryLineStringContainsLineString')) + addTest(suite, TestLocation('test_geoQueryLineStringContainsPolygonFail')) + addTest(suite, TestLocation('test_geoQueryLineStringIntersectsLineString')) + addTest(suite, TestLocation('test_geoQueryLineStringIntersectsLineStringFail')) + + addTest(suite, TestLocation('test_geoQueryPolygonWithinPolygon')) + addTest(suite, TestLocation('test_geoQueryPolygonOutsidePolygon')) + addTest(suite, TestLocation('test_geoQueryPolygonPartlyWithinPolygonFail')) + addTest(suite, TestLocation('test_geoQueryPointContainsPolygonFail')) + addTest(suite, TestLocation('test_geoQueryPolygonContainsPolygon')) + addTest(suite, TestLocation('test_geoQueryPolygonContainsPolygonFail')) + addTest(suite, TestLocation('test_geoQueryPolygonIntersectsPolygon')) + addTest(suite, TestLocation('test_geoQueryPolygonIntersectsPolygonFail')) + + addTest(suite, TestLocation('test_geoQueryMultiPointWithinPolygon')) + addTest(suite, TestLocation('test_geoQueryMultiPointOutsidePolygon')) + addTest(suite, TestLocation('test_geoQueryMultiPointOutsidePolygonWrongGmtyFail')) + addTest(suite, TestLocation('test_geoQueryMultiPointContainsPoint')) + addTest(suite, TestLocation('test_geoQueryMultiPointContainsPointFail')) + addTest(suite, TestLocation('test_geoQueryPointIntersectsMultiPoint')) + addTest(suite, TestLocation('test_geoQueryPointIntersectsMultiPointFail')) + addTest(suite, TestLocation('test_geoQueryMultiPointIntersectsMultiPoint')) + addTest(suite, TestLocation('test_geoQueryMultiPointIntersectsMultiPointFail')) + + addTest(suite, TestLocation('test_geoQueryMultiLinestringWithinPolygon')) + addTest(suite, TestLocation('test_geoQueryMultiLinestringOutsidePolygon')) + addTest(suite, TestLocation('test_geoQueryMultiLineContainsPoint')) + addTest(suite, TestLocation('test_geoQueryMultiLineContainsPointFail')) + addTest(suite, TestLocation('test_geoQueryPointIntersectsMultiLine')) + addTest(suite, TestLocation('test_geoQueryPointIntersectsMultiLineFail')) + addTest(suite, TestLocation('test_geoQueryLineStringIntersectsMultiLine')) + addTest(suite, TestLocation('test_geoQueryLineStringIntersectsMultiLineFail')) + + addTest(suite, TestLocation('test_geoQueryMultiPolygonWithinPolygon')) + addTest(suite, TestLocation('test_geoQueryMultiPolygonOutsidePolygon')) + addTest(suite, TestLocation('test_geoQueryMultiPolygonContainsPoint')) + addTest(suite, TestLocation('test_geoQueryMultiPolygonContainsPointFail')) + addTest(suite, TestLocation('test_geoQueryPointIntersectsMultiPolygon')) + addTest(suite, TestLocation('test_geoQueryPointIntersectsMultiPolygonFail')) + addTest(suite, TestLocation('test_geoQueryPolygonIntersectsMultiPolygon')) + addTest(suite, TestLocation('test_geoQueryPolygonIntersectsMultiPolygonFail')) result = unittest.TextTestRunner(verbosity = testVerbosity, failfast = testFailFast).run(suite) return result.testsRun, len(result.errors + result.failures), len(result.skipped), getSleepTimeCount() From eedef5ea109c06bf301a1376c6eb1a3344b4b782 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 14 Sep 2023 13:01:34 +0200 Subject: [PATCH 108/165] New basic type ID --- acme/etc/Types.py | 4 +++- acme/services/Validator.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/acme/etc/Types.py b/acme/etc/Types.py index b07c6fb3..5a661ea0 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -618,9 +618,11 @@ class BasicType(ACMEIntEnum): adict = auto() # anoymous dict structure base64 = auto() schedule = auto() # scheduleEntry + ID = auto() # m2m:ID + + # aliases. Always put at the end! Seems cause confusion with python < 3.11 time = timestamp # alias type for time date = timestamp # alias type for date - ID = auto() # m2m:ID @classmethod def to(cls, name:str|Tuple[str], insensitive:Optional[bool] = True) -> BasicType: diff --git a/acme/services/Validator.py b/acme/services/Validator.py index 426c8b81..33ee4810 100644 --- a/acme/services/Validator.py +++ b/acme/services/Validator.py @@ -280,7 +280,7 @@ def validateAttribute(self, attribute:str, 'rqi' : AttributePolicy(type = BasicType.string, cardinality =Cardinality.CAR1, optionalCreate = RequestOptionality.M, optionalUpdate = RequestOptionality.M, optionalDiscovery = RequestOptionality.O, announcement = Announced.NA, sname = 'rqi', lname = 'requestIdentifier', namespace = 'm2m', tpe = 'm2m:rqi'), 'pc' : AttributePolicy(type = BasicType.dict, cardinality =Cardinality.CAR01, optionalCreate = RequestOptionality.O, optionalUpdate = RequestOptionality.O, optionalDiscovery = RequestOptionality.O, announcement = Announced.NA, sname = 'pc', lname = 'primitiveContent', namespace = 'm2m', tpe = 'm2m:pc'), 'to' : AttributePolicy(type = BasicType.string, cardinality =Cardinality.CAR01, optionalCreate = RequestOptionality.O, optionalUpdate = RequestOptionality.O, optionalDiscovery = RequestOptionality.O, announcement = Announced.NA, sname = 'to', lname = 'to', namespace = 'm2m', tpe = 'm2m:to'), - 'fr' : AttributePolicy(type = BasicType.string, cardinality =Cardinality.CAR01, optionalCreate = RequestOptionality.O, optionalUpdate = RequestOptionality.O, optionalDiscovery = RequestOptionality.O, announcement = Announced.NA, sname = 'fr', lname = 'from', namespace = 'm2m', tpe = 'm2m:fr'), + 'fr' : AttributePolicy(type = BasicType.ID, cardinality =Cardinality.CAR01, optionalCreate = RequestOptionality.O, optionalUpdate = RequestOptionality.O, optionalDiscovery = RequestOptionality.O, announcement = Announced.NA, sname = 'fr', lname = 'from', namespace = 'm2m', tpe = 'm2m:fr'), 'ot' : AttributePolicy(type = BasicType.timestamp, cardinality =Cardinality.CAR01, optionalCreate = RequestOptionality.O, optionalUpdate = RequestOptionality.O, optionalDiscovery = RequestOptionality.O, announcement = Announced.NA, sname = 'ot', lname = 'originatingTimestamp', namespace = 'm2m', tpe = 'm2m:or'), 'rset' : AttributePolicy(type = BasicType.absRelTimestamp, cardinality =Cardinality.CAR01, optionalCreate = RequestOptionality.O, optionalUpdate = RequestOptionality.O, optionalDiscovery = RequestOptionality.O, announcement = Announced.NA, sname = 'rset', lname = 'resultExpirationTimestamp', namespace = 'm2m', tpe = 'm2m:rset'), 'ec' : AttributePolicy(type = BasicType.positiveInteger, cardinality =Cardinality.CAR01, optionalCreate = RequestOptionality.O, optionalUpdate = RequestOptionality.O, optionalDiscovery = RequestOptionality.O, announcement = Announced.NA, sname = 'ec', lname = 'eventCategory', namespace = 'm2m', tpe = 'm2m:ec'), From 1b85bc7bf07e6be77e08fbdbfc5492e9e1dca9f6 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 14 Sep 2023 13:02:03 +0200 Subject: [PATCH 109/165] Added (experimental) printing of an exception during script execution --- acme/services/ScriptManager.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/acme/services/ScriptManager.py b/acme/services/ScriptManager.py index 75df005d..466fb60e 100644 --- a/acme/services/ScriptManager.py +++ b/acme/services/ScriptManager.py @@ -11,7 +11,7 @@ from typing import Callable, Dict, Union, Any, Tuple, cast, Optional, List from pathlib import Path -import json, os, fnmatch +import json, os, fnmatch, traceback import requests, webbrowser from decimal import Decimal from rich.text import Text @@ -1507,7 +1507,7 @@ def _handleRequest(self, pcontext:PContext, symbol:SSymbol, operation:Operation) try: request = CSE.request.fillAndValidateCSERequest(req) except ResponseException as e: - raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'Invalid resource: {e.dbg}')) + raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'Invalid resource: {e.dbg}', exception = e)) # Send request L.isDebug and L.logDebug(f'Sending request from script: {request.originalRequest} to: {target}') @@ -1978,6 +1978,8 @@ def runCB(pcontext:PContext, arguments:list[str]) -> None: L.logDebug(f'Script terminated with result: {pcontext.result}') if pcontext.state == PState.terminatedWithError: L.logWarn(f'Script terminated with error: {pcontext.error.message}') + if pcontext.error.exception: + L.logWarn(''.join(traceback.format_exception(pcontext.error.exception))) if not result or not cast(ACMEPContext, pcontext).nextScript: return From c18e0e044d993bcd64098d0a962519af0f4974d7 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 14 Sep 2023 13:06:09 +0200 Subject: [PATCH 110/165] Added extra non-location enabled container for the tests --- tests/testLocation.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/testLocation.py b/tests/testLocation.py index 485ab998..3ed7d85e 100644 --- a/tests/testLocation.py +++ b/tests/testLocation.py @@ -39,9 +39,16 @@ def setUpClass(cls) -> None: }} cls.ae, rsc = CREATE(cseURL, 'C', T.AE, dct) # AE to work under assert rsc == RC.CREATED, 'cannot create parent AE' + cls.originator = findXPath(cls.ae, 'm2m:ae/aei') cls.aeRI = findXPath(cls.ae, 'm2m:ae/ri') + dct = { 'm2m:cnt' : { + 'rn' : f'{cntRN}2' + }} + cls.ae, rsc = CREATE(aeURL, cls.originator, T.CNT, dct) # Extra CNT. Acts as a non-location enabled resource + assert rsc == RC.CREATED, 'cannot create CNT' + testCaseEnd('Setup TestLocation') From 021347f443b954a2d853800617596b64fffdd169 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 14 Sep 2023 13:10:19 +0200 Subject: [PATCH 111/165] Added documentation --- docs/Supported.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/Supported.md b/docs/Supported.md index c204d33c..8d55acf7 100644 --- a/docs/Supported.md +++ b/docs/Supported.md @@ -84,6 +84,7 @@ The following table presents the supported management object specifications. | Blocking requests | ✓ | | | Delayed request execution | ✓ | Through the *Operation Execution Timestamp* request attribute. | | Discovery | ✓ | | +| Geo-query | ✓ | | | Location | ✓ | Only *device based, and no *network based* location policies are supported. | | Long polling | ✓ | Long polling for request unreachable AEs and CSEs through <pollingChannel>. | | Non-blocking requests | ✓ | Non-blocking synchronous and asynchronous, and flex-blocking, incl. *Result Persistence*. | From 7d644d1a04b51a318cbf20c5651e7e32f38f35dd Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 15 Sep 2023 11:08:19 +0200 Subject: [PATCH 112/165] Added support for geometry attributes for TUI helper functions --- acme/services/Validator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/acme/services/Validator.py b/acme/services/Validator.py index 33ee4810..eb84c71a 100644 --- a/acme/services/Validator.py +++ b/acme/services/Validator.py @@ -16,7 +16,7 @@ from ..etc.Types import AttributePolicy, ResourceAttributePolicyDict, AttributePolicyDict, BasicType, Cardinality from ..etc.Types import RequestOptionality, Announced, AttributePolicy, ResultContentType -from ..etc.Types import JSON, FlexContainerAttributes, FlexContainerSpecializations, GeometryType +from ..etc.Types import JSON, FlexContainerAttributes, FlexContainerSpecializations, GeometryType, GeoSpatialFunctionType from ..etc.Types import CSEType, ResourceTypes, Permission, Operation from ..etc.ResponseStatusCodes import ResponseStatusCode, BAD_REQUEST, ResponseException, CONTENTS_UNACCEPTABLE from ..etc.Utils import pureResource, strToBool @@ -66,6 +66,8 @@ 'cst': lambda v: CSEType(int(v)).name, #'nct': lambda v: NotificationContentType(int(v)).name, #'net': lambda v: NotificationEventType(int(v)).name, + 'gmty': lambda v: GeometryType(int(v)).name, + 'gsf': lambda v: GeoSpatialFunctionType(int(v)).name, 'op': lambda v: Operation(int(v)).name, 'rcn': lambda v: ResultContentType(int(v)).name, 'rsc': lambda v: ResponseStatusCode(int(v)).name, @@ -723,6 +725,7 @@ def getEnumInterpretation(self, rtype: ResourceTypes, attr:str, value:int) -> st if (ctype := attributesComplexTypes.get(attr)): if (policy := self.getAttributePolicy(ctype[0], attr)) and policy.evalues: # just any policy for the complex type return policy.evalues.get(int(value), str(value)) + return '' return str(value) From 9525d8825fabccd216598c20cba6c507e51c6bca Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 15 Sep 2023 11:08:38 +0200 Subject: [PATCH 113/165] Corrected type --- init/attributePolicies.ap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index d3f6d3fb..4785717a 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -1389,7 +1389,7 @@ "geom": [ { "rtypes": [ "REQRESP" ], - "lname": "geometryType", + "lname": "geometry", "ns": "m2m", "type": "geoJsonCoordinate", "car": "1" From 9385deaa8468857f6ac37ae9bf43a81edbcc0c43 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 15 Sep 2023 11:33:45 +0200 Subject: [PATCH 114/165] Moved some code to match instead of cascading if...else --- acme/etc/Types.py | 99 +++++++++++++++++++++++++++++++---------------- 1 file changed, 65 insertions(+), 34 deletions(-) diff --git a/acme/etc/Types.py b/acme/etc/Types.py index 5a661ea0..8fd946ec 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -1094,53 +1094,82 @@ class ContentSerializationType(ACMEIntEnum): UNKNOWN = auto() def toHeader(self) -> str: - """ Return the mime header for a enum value. + """ Return the mime header for an enum value. + + Return: + The mime header for an enum value. """ - if self.value == self.JSON: return 'application/json' - if self.value == self.CBOR: return 'application/cbor' - if self.value == self.XML: return 'application/xml' - return None + match self.value: + case self.JSON: + return 'application/json' + case self.CBOR: + return 'application/cbor' + case self.XML: + return 'application/xml' + case _: + return None + def toSimple(self) -> str: - """ Return the simple string for a enum value. + """ Return the simple string for an enum value. + + Return: + The simple string for an enum value. """ - if self.value == self.JSON: return 'json' - if self.value == self.CBOR: return 'cbor' - if self.value == self.XML: return 'xml' - return None + match self.value: + case self.JSON: + return 'json' + case self.CBOR: + return 'cbor' + case self.XML: + return 'xml' + case _: + return None + @classmethod def toContentSerialization(cls, t:str) -> ContentSerializationType: - """ Return the enum from a string. + """ Return the enum from a string for a content serialization. + + Args: + t: String to convert. + + Return: + The enum value. """ - t = t.lower() - if t in [ 'cbor', 'application/cbor' ]: return cls.CBOR - if t in [ 'json', 'application/json' ]: return cls.JSON - if t in [ 'xml', 'application/xml' ]: return cls.XML - return cls.UNKNOWN + match t.lower(): + case 'json' | 'application/json': + return cls.JSON + case 'cbor' | 'application/cbor': + return cls.CBOR + case 'xml' | 'application/xml': + return cls.XML + case _: + return cls.UNKNOWN @classmethod - def getType(cls, hdr:str, default:Optional[ContentSerializationType] = None) -> ContentSerializationType: - """ Return the enum from a header definition. - """ - default = cls.UNKNOWN if not default else default - if not hdr: return default - hdr = hdr.lower() + def getType(cls, t:str, default:Optional[ContentSerializationType] = None) -> ContentSerializationType: + """ Return the enum from a content-type header definition. - if hdr.lower() == 'json': return cls.JSON - if hdr.lower().startswith('application/json'): return cls.JSON - if hdr.lower().startswith('application/vnd.onem2m-res+json'): return cls.JSON - - if hdr.lower() == 'cbor': return cls.CBOR - if hdr.lower().startswith('application/cbor'): return cls.CBOR - if hdr.lower().startswith('application/vnd.onem2m-res+cbor'): return cls.CBOR - - if hdr.lower() == 'xml': return cls.XML - if hdr.lower().startswith('application/xml'): return cls.XML - if hdr.lower().startswith('application/vnd.onem2m-res+XML'): return cls.XML + Args: + t: String to convert. + default: Default value to return if the string is not a valid content-type. - return cls.UNKNOWN + Return: + The enum value. + """ + if not t: + return cls.UNKNOWN if not default else default + match t.lower(): + case 'json' | 'application/json' | 'application/vnd.onem2m-res+json': + return cls.JSON + case 'cbor' | 'application/cbor' | 'application/vnd.onem2m-res+cbor': + return cls.CBOR + case 'xml' | 'application/xml' | 'application/vnd.onem2m-res+xml': + return cls.XML + case _: + return cls.UNKNOWN @classmethod @@ -1828,6 +1857,8 @@ def _fill(k:str, v:Any) -> None: return if k == 'fo' and int(v) == FilterOperation.AND: return + if k.startswith('_'): # internal attributes + return result[k] = v self.mapAttributes(_fill, False) From 3f2fa0f3ed5f3ad100bcc970d8e541f0638c040c Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 15 Sep 2023 13:56:03 +0200 Subject: [PATCH 115/165] Added documentation --- acme/helpers/TextTools.py | 2 +- acme/helpers/TinyDBBetterTable.py | 2 +- acme/resources/CNT_LA.py | 6 +- acme/services/LocationManager.py | 1 - acme/services/ScriptManager.py | 6 +- acme/services/Storage.py | 384 +++++++++++++++++++++++++++-- acme/textui/ACMEFieldOriginator.py | 4 +- 7 files changed, 377 insertions(+), 28 deletions(-) diff --git a/acme/helpers/TextTools.py b/acme/helpers/TextTools.py index f3801bc1..470a3821 100644 --- a/acme/helpers/TextTools.py +++ b/acme/helpers/TextTools.py @@ -58,7 +58,7 @@ def commentJson(data:Union[str, dict], Args: data: The JSON string or as a dictionary. explanations: A dictionary with the explanations. The keys must match the JSON keys. - getAttributeValueNae: A function that returns the named value of an attribute. + getAttributeValueName: A function that returns the named value of an attribute. width: Optional width of the output. If greater then the comment is put above the line. Return: diff --git a/acme/helpers/TinyDBBetterTable.py b/acme/helpers/TinyDBBetterTable.py index b5fd6915..a6921086 100644 --- a/acme/helpers/TinyDBBetterTable.py +++ b/acme/helpers/TinyDBBetterTable.py @@ -11,7 +11,7 @@ from tinydb.table import Table class TinyDBBetterTable(Table): - """ This class is an addon to TinyDB's *Table* class. It removes some computations that are not + """ This class is an add-on to TinyDB's *Table* class. It removes some computations that are not necessary in ACME. - Document ID's are always strings. diff --git a/acme/resources/CNT_LA.py b/acme/resources/CNT_LA.py index 38ba0eea..1254e746 100644 --- a/acme/resources/CNT_LA.py +++ b/acme/resources/CNT_LA.py @@ -123,7 +123,7 @@ def handleDeleteRequest(self, request:CSERequest, id:str, originator:str) -> Res def getLCPLink(self) -> str: - """ Retrieve a `LocationPolicy` resource's resource ID. + """ Retrieve a `LCP` (LocationPolicy) resource's resource ID. Return: The resource ID. @@ -132,9 +132,9 @@ def getLCPLink(self) -> str: def setLCPLink(self, lcpRi:str) -> None: - """ Assign a resource ID of a `LocationPolicy` resource to the latest resource. + """ Assign a resource ID of a `LCP` (LocationPolicy) resource to the latest resource. Args: - ri: The resource ID of an `LocationPolicy` resource. + lcpRi: The resource ID of an `LCP` resource. """ self.setAttribute(self._li, lcpRi, overwrite = True) diff --git a/acme/services/LocationManager.py b/acme/services/LocationManager.py index 78f2ac41..2831fdcf 100644 --- a/acme/services/LocationManager.py +++ b/acme/services/LocationManager.py @@ -235,7 +235,6 @@ def getNewLocation(self, lcpRi:str, content:Optional[str] = None) -> Optional[Tu Args: lcpRi: The resource ID of the location policy - cntRi: The resource ID of the location policy's container resource content: The content of the latest CIN of the location policy's container resource Returns: diff --git a/acme/services/ScriptManager.py b/acme/services/ScriptManager.py index 466fb60e..2834ca9d 100644 --- a/acme/services/ScriptManager.py +++ b/acme/services/ScriptManager.py @@ -1281,17 +1281,15 @@ def doSetLogging(self, pcontext:PContext, symbol:SSymbol) -> PContext: def doTuiNotify(self, pcontext:PContext, symbol:SSymbol) -> PContext: """ Show a TUI notification. - This function is only available in TUI mode. It has the following - arguments: + This function is only available in TUI mode. It has the following arguments. - message: The message to show. - title: (Optional) The title of the notification. - severity: (Optional) The severity of the notification. Can be - one of the following values: `information`, `warning`, `error`. + one of the following values: *information*, *warning*, *error*. - timeout: (Optional) The timeout in seconds after which the notification will disappear. If not specified, the notification will disappear after 3 seconds. - The function returns NIL. diff --git a/acme/services/Storage.py b/acme/services/Storage.py index 7cf147e6..6d3555e6 100644 --- a/acme/services/Storage.py +++ b/acme/services/Storage.py @@ -10,6 +10,14 @@ # """ This module defines storage managers and drivers for database access. + + Storage managers are used to store, retrieve and manage resources and other runtime data in the database. + + Storage drivers are used to access the database. Currently, the only supported database is TinyDB. + + See also: + - `TinyDBBetterTable` + - `TinyDBBufferedStorage` """ from __future__ import annotations @@ -57,6 +65,7 @@ class Storage(object): inMemory: Indicator whether the database is located in memory (volatile) or on disk. dbPath: In case *inMemory* is "False" this attribute contains the path to a directory where the database is stored in disk. dbReset: Indicator that the database should be reset or cleared during start-up. + db: The database object. """ __slots__ = ( @@ -219,6 +228,7 @@ def hasResource(self, ri:Optional[str] = None, srn:Optional[str] = None) -> bool Args: ri: Optional resource ID. srn: Optional structured resource name. + Returns: True when a resource with the ID or name exists. """ @@ -238,6 +248,7 @@ def retrieveResource(self, ri:Optional[str] = None, csi: The resource is retrieved via its CSE-ID. srn: The resource is retrieved via its structured resource name. aei: The resource is retrieved via its AE-ID. + Returns: The resource. """ @@ -273,6 +284,7 @@ def retrieveResourceRaw(self, ri:str) -> JSON: Args: ri: The resource is retrieved via its rersource ID. + Returns: The resource dictionary. """ @@ -291,6 +303,7 @@ def retrieveResourcesByType(self, ty:ResourceTypes) -> list[Document]: Args: ty: resource type to retrieve. + Returns: List of resource *Document* objects . """ @@ -303,6 +316,7 @@ def updateResource(self, resource:Resource) -> Resource: Args: resource: Resource to update. + Return: Updated Resource object. """ @@ -335,6 +349,7 @@ def directChildResources(self, pi:str, pi: The parent resource's Resource ID. ty: Optional resource type to filter the result. raw: When "True" then return the child resources as resource dictionary instead of resources. + Returns: Return a list of resources, or a list of raw resource dictionaries. """ @@ -351,6 +366,7 @@ def directChildResourcesRI(self, pi:str, Args: pi: The parent resource's Resource ID. ty: Optional resource type to filter the result. + Returns: Return a list of resource IDs. """ @@ -363,6 +379,7 @@ def countDirectChildResources(self, pi:str, ty:Optional[ResourceTypes] = None) - Args: pi: The parent resource's Resource ID. ty: Optional resource type to filter the result. + Returns: The number of child resources. """ @@ -383,6 +400,7 @@ def identifier(self, ri:str) -> list[Document]: Args: ri: Unstructured resource ID for the mapping to look for. + Return: List of found resources identifier mappings, or an empty list. """ @@ -394,6 +412,7 @@ def structuredIdentifier(self, srn:str) -> list[Document]: Args: srn: Structured resource ID for the mapping to look for. + Return: List of found resources identifier mappings, or an empty list. """ @@ -406,6 +425,7 @@ def searchByFragment(self, dct:dict, filter:Optional[Callable[[JSON], bool]] = N Args: dct: A fragment dictionary to use as a filter for the search. filter: An optional callback to provide additional filter functionality. + Return: List of `Resource` objects. """ @@ -419,6 +439,7 @@ def searchByFilter(self, filter:Callable[[JSON], bool]) -> list[Resource]: Args: filter: A callback to provide filter functionality. + Return: List of `Resource` objects. """ @@ -433,6 +454,14 @@ def searchByFilter(self, filter:Callable[[JSON], bool]) -> list[Resource]: ## def getSubscription(self, ri:str) -> Optional[Document]: + """ Retrieve a subscription representation (not a oneM2M `Resource` object) from the DB. + + Args: + ri: The subscription's resource ID. + + Return: + The subscription as a dictionary, or None. + """ # L.logDebug(f'Retrieving subscription: {ri}') subs = self.db.searchSubscriptions(ri = ri) if not subs or len(subs) != 1: @@ -441,16 +470,40 @@ def getSubscription(self, ri:str) -> Optional[Document]: def getSubscriptionsForParent(self, pi:str) -> list[Document]: + """ Retrieve all subscriptions representations (not oneM2M `Resource` objects) for a parent resource. + + Args: + pi: The parent resource's resource ID. + + Return: + List of subscriptions. + """ # L.logDebug(f'Retrieving subscriptions for parent: {pi}') return self.db.searchSubscriptions(pi = pi) def addSubscription(self, subscription:Resource) -> bool: + """ Add a subscription to the DB. + + Args: + subscription: The subscription `Resource` to add. + + Return: + Boolean value to indicate success or failure. + """ # L.logDebug(f'Adding subscription: {ri}') return self.db.upsertSubscription(subscription) def removeSubscription(self, subscription:Resource) -> bool: + """ Remove a subscription from the DB. + + Args: + subscription: The subscription `Resource` to remove. + + Return: + Boolean value to indicate success or failure. + """ # L.logDebug(f'Removing subscription: {subscription.ri}') try: return self.db.removeSubscription(subscription) @@ -459,13 +512,16 @@ def removeSubscription(self, subscription:Resource) -> bool: def updateSubscription(self, subscription:Resource) -> bool: - # L.logDebug(f'Updating subscription: {ri}') - return self.db.upsertSubscription(subscription) + """ Update a subscription representation in the DB. + Args: + subscription: The subscription `Resource` to update. - def updateSubscriptionSchedule(self, subscription:Resource, schedule:list[str]) -> bool: - # L.logDebug(f'Updating subscription schedule: {ri} - {schedule}') - return self.db.updateSubscriptionSchedule(subscription, schedule) + Return: + Boolean value to indicate success or failure. + """ + # L.logDebug(f'Updating subscription: {ri}') + return self.db.upsertSubscription(subscription) ######################################################################### @@ -474,20 +530,56 @@ def updateSubscriptionSchedule(self, subscription:Resource, schedule:list[str]) ## def addBatchNotification(self, ri:str, nu:str, request:JSON) -> bool: + """ Add a batch notification to the DB. + + Args: + ri: The resource ID of the target resource. + nu: The notification URI. + request: The request to store. + + Return: + Boolean value to indicate success or failure. + """ return self.db.addBatchNotification(ri, nu, request) def countBatchNotifications(self, ri:str, nu:str) -> int: + """ Count the number of batch notifications for a target resource and a notification URI. + + Args: + ri: The resource ID of the target resource. + nu: The notification URI. + + Return: + The number of matching batch notifications. + """ return self.db.countBatchNotifications(ri, nu) def getBatchNotifications(self, ri:str, nu:str) -> list[Document]: + """ Retrieve the batch notifications for a target resource and a notification URI. + + Args: + ri: The resource ID of the target resource. + nu: The notification URI. + + Return: + List of batch notifications. + """ return self.db.getBatchNotifications(ri, nu) def removeBatchNotifications(self, ri:str, nu:str) -> bool: - return self.db.removeBatchNotifications(ri, nu) + """ Remove the batch notifications for a target resource and a notification URI. + Args: + ri: The resource ID of the target resource. + nu: The notification URI. + + Return: + Boolean value to indicate success or failure. + """ + return self.db.removeBatchNotifications(ri, nu) ######################################################################### @@ -497,53 +589,107 @@ def removeBatchNotifications(self, ri:str, nu:str) -> bool: def getStatistics(self) -> JSON: """ Retrieve the statistics data from the DB. + + Return: + The statistics data as a JSON dictionary. """ return self.db.searchStatistics() def updateStatistics(self, stats:JSON) -> bool: """ Update the statistics DB with new data. + + Args: + stats: The statistics data to store. + + Return: + Boolean value to indicate success or failure. """ return self.db.upsertStatistics(stats) def purgeStatistics(self) -> None: """ Purge the statistics DB. + + Return: + Boolean value to indicate success or failure. """ self.db.purgeStatistics() - ######################################################################### ## ## Actions ## def getActions(self) -> list[Document]: - """ Retrieve the actions data from the DB. + """ Retrieve all action representations from the DB. + + Return: + List of *Documents*. May be empty. """ return self.db.searchActionReprs() def getAction(self, ri:str) -> Optional[Document]: - """ Retrieve the actions data from the DB. + """ Retrieve the actions representation from the DB. + + Args: + ri: The action's resource ID. + + Return: + The action's data as a *Document*, or None. """ return self.db.getAction(ri) def searchActionsForSubject(self, ri:str) -> Sequence[JSON]: + """ Search for actions for a subject resource. + + Args: + ri: The subject resource's resource ID. + + Return: + List of matching action representations. + """ return self.db.searchActionsDeprsForSubject(ri) def updateAction(self, action:ACTR, period:float, count:int) -> bool: + """ Update or add an action representation in the DB. + + Args: + action: The action to update or insert. + period: The period for the action. + count: The run count for the action. + + Return: + Boolean value to indicate success or failure. + """ return self.db.upsertActionRepr(action, period, count) def updateActionRepr(self, actionRepr:JSON) -> bool: + """ Update an action representation in the DB. + + Args: + actionRepr: The action representation to update. + + Return: + Boolean value to indicate success or failure. + """ return self.db.updateActionRepr(actionRepr) def removeAction(self, ri:str) -> bool: + """ Remove an action representation from the DB. + + Args: + ri: The action's resource ID. + + Return: + Boolean value to indicate success or failure. + """ return self.db.removeActionRepr(ri) @@ -583,6 +729,7 @@ def getRequests(self, ri:Optional[str] = None, sortedByOt:bool = False) -> list[ Args: ri: The target resource's resource ID. If *None* or empty, then all requests are returned + sortedByOt: If true, then the requests are sorted by their creation time. Return: List of *Documents*. May be empty. @@ -771,6 +918,13 @@ class TinyDBBinding(object): """ Define slots for instance variables. """ def __init__(self, path:str, postfix:str) -> None: + """ Initialize the TinyDB binding. + + Args: + path: Path to the database directory. + postfix: Postfix for the database file names. + """ + self.path = path self._assignConfig() L.isInfo and L.log(f'Cache Size: {self.cacheSize:d}') @@ -880,6 +1034,8 @@ def _assignConfig(self) -> None: def closeDB(self) -> None: + """ Close the database. + """ L.isInfo and L.log('Closing DBs') with self.lockResources: self.dbResources.close() @@ -900,6 +1056,8 @@ def closeDB(self) -> None: def purgeDB(self) -> None: + """ Purge the database. + """ L.isInfo and L.log('Purging DBs') self.tabResources.truncate() self.tabIdentifiers.truncate() @@ -914,6 +1072,14 @@ def purgeDB(self) -> None: def backupDB(self, dir:str) -> bool: + """ Backup the database to a directory. + + Args: + dir: The directory to backup to. + + Return: + Boolean value to indicate success or failure. + """ for fn in [ self.fileResources, self.fileIdentifiers, self.fileSubscriptions, @@ -963,6 +1129,9 @@ def updateResource(self, resource: Resource, ri:str) -> Resource: Args: resource: The resource to update. ri: The resource ID of the resource. + + Return: + The updated resource. """ #L.logDebug(resource) with self.lockResources: @@ -992,6 +1161,23 @@ def searchResources(self, ri:Optional[str] = None, pi:Optional[str] = None, ty:Optional[int] = None, aei:Optional[str] = None) -> list[Document]: + """ Search for resources by structured resource name, resource ID, CSE-ID, parent resource ID, resource type, + or application entity ID. + + Only one of the parameters may be used at a time. The order of precedence is: structured resource name, + resource ID, CSE-ID, structured resource name, parent resource ID, resource type, application entity ID. + + Args: + ri: A resource ID. + csi: A CSE ID. + srn: A structured resource name. + pi: A parent resource ID. + ty: A resource type. + aei: An application entity ID. + + Return: + A list of found resources, or an empty list. + """ if not srn: with self.lockResources: if ri: @@ -1021,8 +1207,9 @@ def discoverResourcesByFilter(self, func:Callable[[JSON], bool]) -> list[Documen Args: func: The filter function to use. + Return: - A list of found resources, or an empty list. + A list of found resource documents, or an empty list. """ with self.lockResources: return self.tabResources.search(func) # type: ignore [arg-type] @@ -1032,6 +1219,20 @@ def hasResource(self, ri:Optional[str] = None, csi:Optional[str] = None, srn:Optional[str] = None, ty:Optional[int] = None) -> bool: + """ Check if a resource exists in the database. + + Only one of the parameters may be used at a time. The order of precedence is: structured resource name, + resource ID, CSE-ID, resource type. + + Args: + ri: A resource ID. + csi: A CSE ID. + srn: A structured resource name. + ty: A resource type. + + Return: + True if the resource exists, False otherwise. + """ if not srn: with self.lockResources: if ri: @@ -1074,6 +1275,13 @@ def searchByFragment(self, dct:dict) -> list[Document]: # def insertIdentifier(self, resource:Resource, ri:str, srn:str) -> None: + """ Insert an identifier into the identifiers DB. + + Args: + resource: The resource to insert. + ri: The resource ID of the resource. + srn: The structured resource name of the resource. + """ # L.isDebug and L.logDebug({'ri' : ri, 'rn' : resource.rn, 'srn' : srn, 'ty' : resource.ty}) with self.lockIdentifiers: self.tabIdentifiers.upsert(Document( @@ -1091,6 +1299,11 @@ def insertIdentifier(self, resource:Resource, ri:str, srn:str) -> None: def deleteIdentifier(self, resource:Resource) -> None: + """ Delete an identifier from the identifiers DB. + + Args: + resource: The resource for which to delete the identifier. + """ with self.lockIdentifiers: self.tabIdentifiers.remove(doc_ids = [resource.ri]) @@ -1126,6 +1339,12 @@ def searchIdentifiers(self, ri:Optional[str] = None, def addChildResource(self, resource:Resource, ri:str) -> None: + """ Add a child resource to the childResources DB. + + Args: + resource: The resource to add as a child. + ri: The resource ID of the resource. + """ # L.isDebug and L.logDebug(f'insertChildResource ri:{ri}') pi = resource.pi @@ -1150,6 +1369,11 @@ def addChildResource(self, resource:Resource, ri:str) -> None: def removeChildResource(self, resource:Resource) -> None: + """ Remove a child resource from the childResources DB. + + Args: + resource: The resource to remove as a child. + """ ri = resource.ri pi = resource.pi @@ -1170,7 +1394,17 @@ def removeChildResource(self, resource:Resource) -> None: self.tabChildResources.update(_r, doc_ids = [pi]) # type:ignore[arg-type, list-item] - def searchChildResourcesByParentRI(self, pi:str, ty:Optional[int] = None) -> Optional[list[str]]: + def searchChildResourcesByParentRI(self, pi:str, ty:Optional[int] = None) -> list[str]: + """ Search for child resources by parent resource ID. + + Args: + pi: The parent resource ID. + ty: The resource type of the child resources to search for. + + Return: + A list of child resource IDs, or an empty list if not found. + """ + _r:Document = self.tabChildResources.get(doc_id = pi) #type:ignore[arg-type, assignment] if _r: if ty is None: # optimization: only check ty once for None @@ -1185,6 +1419,17 @@ def searchChildResourcesByParentRI(self, pi:str, ty:Optional[int] = None) -> Opt def searchSubscriptions(self, ri:Optional[str] = None, pi:Optional[str] = None) -> Optional[list[Document]]: + """ Search for subscription representations by resource ID or parent resource ID. + + Only one of the parameters may be used at a time. The order of precedence is: resource ID, parent resource ID. + + Args: + ri: A resource ID. + pi: A parent resource ID. + + Return: + A list of found subscription representations, or None. + """ with self.lockSubscriptions: if ri: _r:Document = self.tabSubscriptions.get(doc_id = ri) # type:ignore[arg-type, assignment] @@ -1195,6 +1440,14 @@ def searchSubscriptions(self, ri:Optional[str] = None, def upsertSubscription(self, subscription:Resource) -> bool: + """ Update or insert a subscription representation into the database. + + Args: + subscription: The `SUB` (subscription) to update or insert. + + Return: + True if the subscription representation was updated or inserted, False otherwise. + """ with self.lockSubscriptions: ri = subscription.ri return self.tabSubscriptions.upsert( @@ -1217,12 +1470,15 @@ def upsertSubscription(self, subscription:Resource) -> bool: # self.subscriptionQuery.ri == ri) is not None - def updateSubscriptionSchedule(self, subscription:Resource, schedule:list[str]) -> bool: - with self.lockSubscriptions: - return self.tabSubscriptions.update({'sce' : schedule}, doc_ids = [subscription.ri]) == 1 + def removeSubscription(self, subscription:Resource) -> bool: + """ Remove a subscription representation from the database. + Args: + subscription: The `SUB` (subscription) to remove. - def removeSubscription(self, subscription:Resource) -> bool: + Return: + True if the subscription representation was removed, False otherwise. + """ with self.lockSubscriptions: return len(self.tabSubscriptions.remove(doc_ids = [subscription.ri])) > 0 # return len(self.tabSubscriptions.remove(self.subscriptionQuery.ri == _ri)) > 0 @@ -1233,6 +1489,16 @@ def removeSubscription(self, subscription:Resource) -> bool: # def addBatchNotification(self, ri:str, nu:str, notificationRequest:JSON) -> bool: + """ Add a batch notification to the database. + + Args: + ri: The resource ID of the resource. + nu: The notification URI. + notificationRequest: The notification request. + + Return: + True if the batch notification was added, False otherwise. + """ with self.lockBatchNotifications: return self.tabBatchNotifications.insert( { 'ri' : ri, @@ -1243,16 +1509,43 @@ def addBatchNotification(self, ri:str, nu:str, notificationRequest:JSON) -> bool def countBatchNotifications(self, ri:str, nu:str) -> int: + """ Return the number of batch notifications for a resource and notification URI. + + Args: + ri: The resource ID of the resource. + nu: The notification URI. + + Return: + The number of batch notifications for the resource and notification URI. + """ with self.lockBatchNotifications: return self.tabBatchNotifications.count((self.batchNotificationQuery.ri == ri) & (self.batchNotificationQuery.nu == nu)) def getBatchNotifications(self, ri:str, nu:str) -> list[Document]: + """ Return the batch notifications for a resource and notification URI. + + Args: + ri: The resource ID of the resource. + nu: The notification URI. + + Return: + A list of batch notifications for the resource and notification URI. + """ with self.lockBatchNotifications: return self.tabBatchNotifications.search((self.batchNotificationQuery.ri == ri) & (self.batchNotificationQuery.nu == nu)) def removeBatchNotifications(self, ri:str, nu:str) -> bool: + """ Remove the batch notifications for a resource and notification URI. + + Args: + ri: The resource ID of the resource. + nu: The notification URI. + + Return: + True if the batch notifications were removed, False otherwise. + """ with self.lockBatchNotifications: return len(self.tabBatchNotifications.remove((self.batchNotificationQuery.ri == ri) & (self.batchNotificationQuery.nu == nu))) > 0 @@ -1262,6 +1555,11 @@ def removeBatchNotifications(self, ri:str, nu:str) -> bool: # def searchStatistics(self) -> JSON: + """ Search for statistics. + + Return: + The statistics, or None if not found. + """ with self.lockStatistics: stats = self.tabStatistics.all() # stats = self.tabStatistics.get(doc_id = 1) @@ -1270,6 +1568,14 @@ def searchStatistics(self) -> JSON: def upsertStatistics(self, stats:JSON) -> bool: + """ Update or insert statistics. + + Args: + stats: The statistics to update or insert. + + Return: + True if the statistics were updated or inserted, False otherwise. + """ with self.lockStatistics: if len(self.tabStatistics) > 0: doc_id = self.tabStatistics.all()[0].doc_id @@ -1291,23 +1597,53 @@ def purgeStatistics(self) -> None: # def searchActionReprs(self) -> list[Document]: + """ Search for action representations. + + Return: + A list of action representations, or None if not found. + """ with self.lockActions: actions = self.tabActions.all() return actions if actions else None def getAction(self, ri:str) -> Optional[Document]: + """ Get an action representation by resource ID. + + Args: + ri: The resource ID of the action representation. + + Return: + The action representation, or None if not found. + """ with self.lockActions: return self.tabActions.get(doc_id = ri) # type:ignore[arg-type, return-value] def searchActionsDeprsForSubject(self, ri:str) -> Sequence[JSON]: + """ Search for action representations by subject. + + Args: + ri: The resource ID of the action representation's subject. + + Return: + A list of action representations, or None if not found. + """ with self.lockActions: return self.tabActions.search(self.actionsQuery.subject == ri) - # TODO add only? def upsertActionRepr(self, action:ACTR, periodTS:float, count:int) -> bool: + """ Update or insert an action representation. + + Args: + action: The action representation to update or insert. + periodTS: The timestamp for periodic execution. + count: The number of times the action will be executed. + + Return: + True if the action representation was updated or inserted, False otherwise. + """ with self.lockActions: _ri = action.ri _sri = action.sri @@ -1325,11 +1661,27 @@ def upsertActionRepr(self, action:ACTR, periodTS:float, count:int) -> bool: def updateActionRepr(self, actionRepr:JSON) -> bool: + """ Update an action representation. + + Args: + actionRepr: The action representation to update. + + Return: + True if the action representation was updated, False otherwise. + """ with self.lockActions: return self.tabActions.update(actionRepr, doc_ids = [actionRepr['ri']]) is not None # type:ignore[arg-type] def removeActionRepr(self, ri:str) -> bool: + """ Remove an action representation. + + Args: + ri: The action's resource ID. + + Return: + True if the action representation was removed, False otherwise. + """ with self.lockActions: if self.tabActions.get(doc_id = ri): # type:ignore[arg-type] return len(self.tabActions.remove(doc_ids = [ri])) > 0 # type:ignore[arg-type, list-item] diff --git a/acme/textui/ACMEFieldOriginator.py b/acme/textui/ACMEFieldOriginator.py index b6846f60..7bca89f5 100644 --- a/acme/textui/ACMEFieldOriginator.py +++ b/acme/textui/ACMEFieldOriginator.py @@ -53,9 +53,9 @@ class ACMEInputField(Container): @dataclass class Submitted(Message): input: ACMEInputField - """The `Input` widget that is being submitted.""" + """The *Input* widget that is being submitted.""" value: str - """The value of the `Input` being submitted.""" + """The value of the *Input* being submitted.""" From d5a3019306fe8c1c3dda3f3893b1b4d569b7d086 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 15 Sep 2023 14:02:06 +0200 Subject: [PATCH 116/165] Added tool for generating documentation --- CHANGELOG.md | 1 + tools/apidocs/README.md | 37 +++++++++++++++++++++++++++++ tools/apidocs/pydoctor.ini | 9 +++++++ tools/apidocs/setup.py | 48 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+) create mode 100644 tools/apidocs/README.md create mode 100644 tools/apidocs/pydoctor.ini create mode 100644 tools/apidocs/setup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 882460e6..45fcac72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [SCRIPTS] Added "dolist", "dotimes", "tui-notify", "cse-attribute-info", sand "get-loglevel" functions to the script interpreter. - [TUI] Improved resource view in the text UI. Enumeration interpretations are now shown. - [TUI] Added utility "Attribute Info Search". +- [MISC] Added tool to generate documentation from the source code. See [tools/apidocs/README.md](tools/apidocs/README.md). ### Experimental diff --git a/tools/apidocs/README.md b/tools/apidocs/README.md new file mode 100644 index 00000000..0897a02d --- /dev/null +++ b/tools/apidocs/README.md @@ -0,0 +1,37 @@ +# Generate ACME API Documentation + +This document provides instructions how to generate API documentation for +the ACME CSE implementation. + + +## Installation + +Install the following packages via pip: + +- To generate only the API documentation: + + pip3 install pydoctor + +- To generate additionally a [Dash][1] docset: + + pip3 install doc2dash + +## Generate the API Documentation and Docset + +Run the following commands from within the *tools/apidocs* directory: + +- To generate the API documentation in the sub-directory `apidocs`. + + pydoctor + + Configuration and command arguments are read from the *pydoctor.ini* configuration file in the same directory. + +- To generate a [Dash][1] docset and automatically add it to Dash: + + doc2dash ../../docs/apidocs -a -f -n "ACME oneM2M CSE" + + + + +[1]: https://kapeli.com/dash + diff --git a/tools/apidocs/pydoctor.ini b/tools/apidocs/pydoctor.ini new file mode 100644 index 00000000..b665c3bd --- /dev/null +++ b/tools/apidocs/pydoctor.ini @@ -0,0 +1,9 @@ +[pydoctor] +add-package = ../../acme +project-base-dir = ../.. +project-name = ACME oneM2M CSE +project-version = 0.13.0-dev +project-url = https://github.com/ankraft/ACME-oneM2M-CSE +docformat = google +theme = readthedocs +html-output = ../../docs/apidocs diff --git a/tools/apidocs/setup.py b/tools/apidocs/setup.py new file mode 100644 index 00000000..365a284b --- /dev/null +++ b/tools/apidocs/setup.py @@ -0,0 +1,48 @@ +from setuptools import setup + +import pathlib + +# The directory containing this file +HERE = pathlib.Path(__file__).parent + +# The text of the README file +README = (HERE / 'README.md').read_text() + +setup( + name='ACME-oneM2M-CSE', + version='0.13.0', + url='https://github.com/ankraft/ACME-oneM2M-CSE', + author='Andreas Kraft', + author_email='an.kraft@gmail.com', + description='An open source CSE Middleware for Education', + long_description=README, + long_description_content_type='text/markdown', + license='BSD', + classifiers=[ + 'License :: OSI Approved :: BSD License', + 'Programming Language :: Python :: 3.8', + ], + #packages=find_packages(), + packages=[ 'acme' ], + exclude=('tests',), + include_package_data=True, + install_requires=[ + 'cbor2', + 'flask', + 'flask-cors', + 'InquirerPy', + 'isodate', + 'paho-mqtt', + 'plotext', + 'rdflib', + 'requests', + 'rich', + 'tinydb', + #'package1 @ git+https://github.com/CITGuru/PyInquirer.git@9d598a53fd17a9bc42efff33183cd2d141a5c949' + ], + entry_points={ + 'console_scripts': [ + 'acme-cse=acme.__main__:main', + ] + }, +) From 0eb8b3d5512d91a207106672ad20400c66035575 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 15 Sep 2023 15:02:33 +0200 Subject: [PATCH 117/165] Added *maxRuntime* configuration setting to limit the execution time of scripts --- CHANGELOG.md | 1 + acme.ini.default | 4 ++++ acme/helpers/Interpreter.py | 13 ++++++++++++- acme/services/Configuration.py | 3 +++ acme/services/ScriptManager.py | 9 ++++++++- docs/Configuration.md | 1 + init/configurations.docmd | 8 ++++++++ 7 files changed, 37 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45fcac72..eac70cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [CSE] Added (limited) support for <locationPolicy> resource type and location management for *device based* location policies. - [CSE] Added support *location* attribute and *locationQuery* request parameters and functionality - [SCRIPTS] Added "dolist", "dotimes", "tui-notify", "cse-attribute-info", sand "get-loglevel" functions to the script interpreter. +- [SCRIPTS] Added *maxRuntime* configuration setting to limit the execution time of scripts. - [TUI] Improved resource view in the text UI. Enumeration interpretations are now shown. - [TUI] Added utility "Attribute Info Search". - [MISC] Added tool to generate documentation from the source code. See [tools/apidocs/README.md](tools/apidocs/README.md). diff --git a/acme.ini.default b/acme.ini.default index 0b743a92..6fc9464f 100644 --- a/acme.ini.default +++ b/acme.ini.default @@ -576,5 +576,9 @@ verbose=False ; 0 means disable monitoring. ; Default: 2.0 seconds fileMonitoringInterval=2.0 +; Set the timeout for script execution in seconds. +; 0.0 means no timeout. +; Default: 60.0 seconds +maxRuntime=60.0 diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index 5346f7dc..7feba3f9 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -1116,6 +1116,17 @@ def scriptName(self, name:str) -> None: name: Name of the script. """ self.meta['name'] = name + + + def setMaxRuntime(self, maxRuntime:float) -> None: + """ Set the maximum runtime of the script. + + Args: + maxRuntime: Maximum runtime in seconds. + """ + if self.state == PState.running: + raise PUnsupportedError(self.setError(PError.runtime, f'Cannot set runtime while script is running')) + self.maxRuntime = maxRuntime def getMeta(self, key:str, default:Optional[str] = '') -> str: @@ -1366,7 +1377,7 @@ def _terminating(pcontext:PContext) -> None: # Start running self.state = PState.running - if self.maxRuntime is not None: # set max runtime + if self.maxRuntime: # > 0 or not None: set max runtime self._maxRTimestamp = _utcTimestamp() + self.maxRuntime if (scriptName := self.scriptName) and not isSubCall: if self.verbose: diff --git a/acme/services/Configuration.py b/acme/services/Configuration.py index 929e572e..d3125c3f 100644 --- a/acme/services/Configuration.py +++ b/acme/services/Configuration.py @@ -458,6 +458,7 @@ def init(args:argparse.Namespace = None) -> bool: 'scripting.fileMonitoringInterval' : config.getfloat('scripting', 'fileMonitoringInterval', fallback = 2.0), 'scripting.scriptDirectories' : config.getlist('scripting', 'scriptDirectories', fallback = []), # type: ignore[attr-defined] 'scripting.verbose' : config.getboolean('scripting', 'verbose', fallback = False), + 'scripting.maxRuntime' : config.getfloat('scripting', 'maxRuntime', fallback = 60.0), # # Text UI @@ -709,6 +710,8 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: # Script settings if Configuration._configuration['scripting.fileMonitoringInterval'] < 0.0: return False, f'Configuration Error: [i]\[scripting]:fileMonitoringInterval[/i] must be >= 0.0' + if Configuration._configuration['scripting.maxRuntime'] < 0.0: + return False, f'Configuration Error: [i]\[scripting]:maxRuntime[/i] must be >= 0.0' if (scriptDirs := Configuration._configuration['scripting.scriptDirectories']): lst = [] for each in scriptDirs: diff --git a/acme/services/ScriptManager.py b/acme/services/ScriptManager.py index 2834ca9d..71460bab 100644 --- a/acme/services/ScriptManager.py +++ b/acme/services/ScriptManager.py @@ -1570,6 +1570,7 @@ class ScriptManager(object): scriptDirectories: List of script directories to monitoe. scriptUpdatesMonitor: `BackgroundWorker` worker to monitor script directories. scriptCronWorker: `BackgroundWorker` worker to run cron-enabled scripts. + maxRuntime: Maximum runtime for a script. """ __slots__ = ( @@ -1582,6 +1583,7 @@ class ScriptManager(object): 'scriptDirectories', 'scriptMonitorInterval', 'verbose', + 'maxRuntime' ) """ Slots of class attributes. """ @@ -1639,6 +1641,7 @@ def _assignConfig(self) -> None: self.verbose = Configuration.get('scripting.verbose') self.scriptMonitorInterval = Configuration.get('scripting.fileMonitoringInterval') self.scriptDirectories = Configuration.get('scripting.scriptDirectories') + self.maxRuntime = Configuration.get('scripting.maxRuntime') def configUpdate(self, name:str, @@ -1653,7 +1656,8 @@ def configUpdate(self, name:str, """ if key not in [ 'scripting.verbose', 'scripting.fileMonitoringInterval', - 'scripting.scriptDirectories' + 'scripting.scriptDirectories', + 'scripting.maxRuntime' ]: return @@ -1997,6 +2001,9 @@ def runCB(pcontext:PContext, arguments:list[str]) -> None: # pcontext.setError(PError.invalid, f'Script "{pcontext.name}" is already running') return False + # Set script timeout + pcontext.setMaxRuntime(self.maxRuntime) + # Set environemt environment['tui.theme'] = SSymbol(string = CSE.textUI.theme) pcontext.setEnvironment(environment) diff --git a/docs/Configuration.md b/docs/Configuration.md index 905f6188..6d5015b6 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -497,6 +497,7 @@ The following tables provide detailed descriptions of all the possible CSE confi | scriptDirectories | Add one or multiple directory paths to look for scripts, in addition to the ones in the "init" directory. Must be a comma-separated list.
Default: not set | scripting.scriptDirectories | | verbose | Enable debug output during script execution, such as the current executed line.
Default: False | scripting.verbose | | fileMonitoringInterval | Set the interval to check for new files in the script (init) directory.
0 means disable monitoring. Must be >= 0.0.
Default: 2.0 seconds | scripting.fileMonitoringInterval | +| maxRuntime | Set the timeout for script execution in seconds. 0.0 seconds means no timeout.
Must be >= 0.0.
Default: 60.0 seconds | scripting.maxRuntime | [top](#sections) diff --git a/init/configurations.docmd b/init/configurations.docmd index 95b45cb5..123b13ce 100644 --- a/init/configurations.docmd +++ b/init/configurations.docmd @@ -1312,6 +1312,14 @@ The default value is `2.0 seconds`. +# scripting.maxRuntime + +This setting specifies the maximum runtime, in seconds, for a script execution. + +The default value is `60 seconds`. `0.0 seconds` means no timeout. + + + # scripting.scriptDirectories This setting specifies a comma-separated list of directories that contain additional CSE's script files. From 94d3a82bd6ea88236713f10608b500c2ac57ec32 Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 18 Sep 2023 11:37:59 +0200 Subject: [PATCH 118/165] Function scopes for variables --- acme/helpers/Interpreter.py | 128 +++++++++++++++++++++++------------- docs/ACMEScript.md | 8 ++- 2 files changed, 88 insertions(+), 48 deletions(-) diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index 5346f7dc..ac202d4f 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -695,9 +695,12 @@ class PCall(): Attributes: name: Function name. arguments: Dictionary of arguments (name -> `SSymbol`) for a call. + variables: Dictionary of variables (name -> `SSymbol`) for a call. """ name:str = None arguments:dict[str, SSymbol] = field(default_factory = dict) + variables:dict[str,SSymbol] = field(default_factory = dict) + class PContext(): @@ -723,10 +726,10 @@ class PContext(): script: The script to run. state: The internal state of a script. symbols: A dictionary of new symbols / functions to add to the interpreter. - variables: Dictionary of variables. + _variables: Dictionary of variables. _maxRTimestamp: The max timestamp until the script may run (internal). _callStack: The internal call stack (internal). - _symbolds: Dictionary with all build-in and provided functions (internal). + _symbols: Dictionary with all build-in and provided functions (internal). """ __slots__ = ( @@ -749,7 +752,6 @@ class PContext(): 'state', 'error', 'meta', - 'variables', 'functions', 'environment', 'argv', @@ -757,7 +759,8 @@ class PContext(): 'verbose', '_maxRTimestamp', '_callStack', - '_symbolds', + '_symbols', + '_variables', ) """ Slots of class attributes. """ @@ -820,7 +823,6 @@ def __init__(self, self.state:PState = PState.created self.error:PErrorState = PErrorState(PError.noError, 0, '', None ) self.meta:Dict[str, str] = {} - self.variables:Dict[str,SSymbol] = {} self.functions:dict[str, FunctionDefinition] = {} self.environment:Dict[str,SSymbol] = {} # Similar to variables, but not cleared self.argv:list[str] = [] @@ -828,8 +830,13 @@ def __init__(self, # Internal attributes that should not be accessed from extern self._maxRTimestamp:float = None - self._callStack:list[PCall] = [PCall()] - self._symbolds:PSymbolDict = None # builtins + provided commands + self._callStack:list[PCall] = [] + self._symbols:PSymbolDict = None # builtins + provided commands + # self._variables:Dict[str, SSymbol] = {} + + + # Add one to the callstack to add variables + self.pushCall() # Add new commands if symbols: @@ -874,7 +881,6 @@ def reset(self) -> None: This method may also be implemented in a subclass, but that subclass must then call this method as well. """ self.error = PErrorState(PError.noError, 0, '', None) - self.variables.clear() self._callStack.clear() self.pushCall(name = self.meta.get('name')) self.state = PState.ready @@ -935,7 +941,6 @@ def setResult(self, symbol:SSymbol) -> PContext: Return: Self. - """ self.result = symbol return self @@ -962,6 +967,8 @@ def pushCall(self, name:Optional[str] = None) -> None: raise PRuntimeError(self.setError(PError.maxRecursionDepth, f'Max level of function calls exceeded')) call = PCall() call.name = name + if len(self._callStack): + call.variables = deepcopy(self._callStack[-1].variables) # copy variables from the previous scope self._callStack.append(call) @@ -1033,10 +1040,35 @@ def getVariables(self, expression:str) -> list[Tuple[str, SSymbol]]: if re.match(_expr, k) ] return [ ( k, self.variables[k] ) for k in _keys ] + + @property + def variables(self) -> dict[str, SSymbol]: + """ The variables of the current scope. + + Returns: + The variables of the current scope. + """ + return self._callStack[-1].variables + + + def setVariable(self, key:str, value:SSymbol) -> None: + """ Set a variable for a name. If the variable exists in the global scope, it is updated or set in all scopes. + Otherwise, it is only updated or set in the current scope. + + Args: + key: Variable name + value: Value to store + """ + if key in self._callStack[0].variables: + for eachCall in self._callStack: + eachCall.variables[key] = value + else: + self._callStack[-1].variables[key] = value def delVariable(self, key:str) -> Optional[SSymbol]: - """ Delete a variable for a case insensitive name. + """ Delete a variable for a name. If the variable exists in the global scope, it is deleted in all scopes. + Otherwise, it is only deleted in the current scope. Args: key: Variable name @@ -1044,12 +1076,17 @@ def delVariable(self, key:str) -> Optional[SSymbol]: Return: Variable content, or None if variable is not defined. """ - key = key.lower() - if key in self.variables: - v = self.variables[key] - del self.variables[key] + try: + if key in self._callStack[0].variables: + v = self._callStack[-1].variables[key] # return latest value afterwards + for eachCall in self._callStack: + del eachCall.variables[key] + else: + v = self._callStack[-1].variables.get(key) + del self._callStack[-1].variables[key] return v - return None + except KeyError: + return None def getEnvironmentVariable(self, key:str) -> SSymbol: @@ -1061,17 +1098,17 @@ def getEnvironmentVariable(self, key:str) -> SSymbol: Return: Environment variable content, or None. """ - return self.environment.get(key.lower()) + return self.environment.get(key) def setEnvironmentVariable(self, key:str, value:SSymbol) -> None: - """ Set an environment variable for a case insensitive name. + """ Set an environment variable for a name. Args: key: Environment variable name value: Value to store """ - self.environment[key.lower()] = value + self.environment[key] = value def clearEnvironment(self) -> None: @@ -1432,6 +1469,15 @@ def _executeExpression(self, symbol:SSymbol, parentSymbol:SSymbol) -> PContext: firstSymbol = symbol[0] if symbol.length and symbol.type == SType.tList else symbol match firstSymbol.type: + case SType.tString: + return self.checkInStringExpressions(firstSymbol) + + case SType.tNumber | SType.tBool | SType.tNIL: + return self.setResult(firstSymbol) # type:ignore [arg-type] + + case SType.tJson: + return self.checkInStringExpressions(symbol) + case SType.tList: if firstSymbol.length > 0: # implicit progn @@ -1474,16 +1520,7 @@ def _executeExpression(self, symbol:SSymbol, parentSymbol:SSymbol) -> PContext: case SType.tLambda: return self._executeFunction(symbol, cast(str, firstSymbol.value)) - - case SType.tString: - return self.checkInStringExpressions(firstSymbol) - - case SType.tNumber | SType.tBool | SType.tNIL: - return self.setResult(firstSymbol) # type:ignore [arg-type] - - case SType.tJson: - return self.checkInStringExpressions(symbol) - + case _: raise PInvalidArgumentError(self.setError(PError.invalid, f'Unexpected symbol: {firstSymbol.type} - {firstSymbol}')) @@ -2012,7 +2049,7 @@ def _doDolist(pcontext:PContext, symbol:SSymbol) -> PContext: # if the variable does not exist, create it as a nil symbol if not str(_resultvar) in pcontext.variables: - pcontext.variables[str(_resultvar)] = SSymbol() + pcontext.setVariable(str(_resultvar), SSymbol()) else: _resultvar = None @@ -2021,9 +2058,9 @@ def _doDolist(pcontext:PContext, symbol:SSymbol) -> PContext: _code = SSymbol(lst = _code) # We got a python list, but need a SSymbol list # execute the code - pcontext.variables[str(_loopvar)] = SSymbol(number = Decimal(0)) + pcontext.setVariable(str(_loopvar), SSymbol(number = Decimal(0))) for i in _looplist.value: # type:ignore[union-attr] - pcontext.variables[str(_loopvar)] = i # type:ignore[assignment] + pcontext.setVariable(str(_loopvar), i) # type:ignore[arg-type] pcontext = pcontext._executeExpression(_code, symbol) # set the result @@ -2086,7 +2123,7 @@ def _doDotimes(pcontext:PContext, symbol:SSymbol) -> PContext: # if the variable does not exist, create it as a nil symbol if not str(_resultvar) in pcontext.variables: - pcontext.variables[str(_resultvar)] = SSymbol() + pcontext.setVariable(str(_resultvar), SSymbol()) else: _resultvar = None @@ -2095,9 +2132,9 @@ def _doDotimes(pcontext:PContext, symbol:SSymbol) -> PContext: _code = SSymbol(lst = _code) # We got a python list, but must have a SSymbol list # execute the code - pcontext.variables[str(_loopvar)] = SSymbol(number = Decimal(0)) + pcontext.setVariable(str(_loopvar), SSymbol(number = Decimal(0))) for i in range(0, int(cast(Decimal, _loopcount.value))): - pcontext.variables[str(_loopvar)] = SSymbol(number = Decimal(i)) + pcontext.setVariable(str(_loopvar), SSymbol(number = Decimal(i))) pcontext = pcontext._executeExpression(_code, symbol) # set the result @@ -2361,7 +2398,7 @@ def _doIncDec(pcontext:PContext, symbol:SSymbol, isInc:Optional[bool] = True) -> # Increment / decrement and Re-assign variable value.value = (cast(Decimal, value.value) + idValue) if isInc else (cast(Decimal, value.value) - idValue) - pcontext.variables[variable.value] = value + pcontext.setVariable(variable.value, value) return pcontext.setResult(deepcopy(value)) @@ -2573,7 +2610,7 @@ def _doLet(pcontext:PContext, symbol:SSymbol, sequential:bool = True) -> PContex # get value and assign variable (symbol!) pcontext, result = pcontext.resultFromArgument(cast(SSymbol, symbol.value), 1) - pcontext.variables[variablename] = result + pcontext.setVariable(variablename, result) return pcontext @@ -2794,8 +2831,7 @@ def _doOperation(pcontext:PContext, symbol:SSymbol, op:Callable, tp:SType) -> PC """ pcontext.assertSymbol(symbol, minLength = 2) - r1 = pcontext._executeExpression(symbol[1], symbol).result - result = deepcopy(r1) + r1 = deepcopy(pcontext._executeExpression(symbol[1], symbol).result) for i in range(2, symbol.length): try: @@ -2803,11 +2839,11 @@ def _doOperation(pcontext:PContext, symbol:SSymbol, op:Callable, tp:SType) -> PC r2 = pcontext._executeExpression(symbol[i], symbol).result # If the first operant is a list, then we have to perform a bit different - if result.type in (SType.tList, SType.tListQuote): + if r1.type in (SType.tList, SType.tListQuote): # If both operants are list then do a raw comparison if r2.type in (SType.tList, SType.tListQuote): - result.value = op(result.raw(), r2.raw()) + r1.value = op(r1.raw(), r2.raw()) # If the second operant is NOT a list, then iterate of the first and do the # operation. If any succeeds, then the operation is true. @@ -2816,14 +2852,14 @@ def _doOperation(pcontext:PContext, symbol:SSymbol, op:Callable, tp:SType) -> PC if tp != SType.tBool: raise PInvalidTypeError(pcontext.setError(PError.invalidType, f'if the first operant is a list then iterating over it is only allowed for boolean operators: {symbol}')) _v1 = None - for s in cast(list, result.value): + for s in cast(list, r1.value): if _v1 := op(s.value, r2.value): # True if any break - result.value = _v1 + r1.value = _v1 # Otherwise just apply the operator else: - result.value = op(result.value, r2.value) + r1.value = op(r1.value, r2.value) except ZeroDivisionError as e: raise PDivisionByZeroError(pcontext.setError(PError.divisionByZero, str(e))) except TypeError as e: @@ -2833,8 +2869,8 @@ def _doOperation(pcontext:PContext, symbol:SSymbol, op:Callable, tp:SType) -> PC raise PDivisionByZeroError(pcontext.setError(PError.divisionByZero, str(e))) raise PInvalidArgumentError(pcontext.setError(PError.invalid, f'invalid arguments in expression: {str(e)}')) - result.type = tp - return pcontext.setResult(result) + r1.type = tp + return pcontext.setResult(r1) def _doParseString(pcontext:PContext, symbol:SSymbol) -> PContext: @@ -3119,7 +3155,7 @@ def _doSetq(pcontext:PContext, symbol:SSymbol) -> PContext: # value pcontext, _value = pcontext.resultFromArgument(symbol, 2) - pcontext.variables[_var] = _value + pcontext.setVariable(_var, _value) return pcontext diff --git a/docs/ACMEScript.md b/docs/ACMEScript.md index f4c7bcac..080625b9 100644 --- a/docs/ACMEScript.md +++ b/docs/ACMEScript.md @@ -76,9 +76,13 @@ Example: ### Variables and Function Scopes -The scope of variables is global to a script execution, and variables are removed between script runs. +Variables are global to a script execution. Global variables that are updated in a function call are updated globally. Variables that are not defined globally and are set in the scope of a function call do only exist in the scope of the function call and its sub-function calls. -In addition to the normal script variables the runtime environment may pass extra environment variables to the script. +In addition to the normal script variables the runtime environment may pass extra environment variables to the script. They are mapped to the script's global variables and can be retrieved like any other global variable (but not updated or deleted). Variables that are set during the execution of a script override environment variables with the same name. + +Variables are removed between script runs. + +Variable names are case-sensitive. ### Quoting From 693396a7e37a6ddcbcfa5503df32d2447c160045 Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 18 Sep 2023 11:41:11 +0200 Subject: [PATCH 119/165] Moved to Calendar Versioning --- CHANGELOG.md | 10 ++++++---- README.md | 2 +- acme/etc/Constants.py | 2 +- setup.py | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 882460e6..46d71bf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,24 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +and this project adheres to [Calendar Versioning](https://calver.org). -## [unreleased] - xxxx-xx-xx +## [unreleased 2023.DEV] - xxxx-xx-xx ### Added -- [CSE] Added automatic pip install of missing dependencies during startup. +- [CSE] Added automatic "pip install" of missing dependencies during startup. - [CSE] Added support for <schedule> resource type. - [CSE] Added support for *Result Expiration Timestamp* request parameter for handling timeouts in fanoutPoint request aggregations. - [CSE] Added (limited) support for <locationPolicy> resource type and location management for *device based* location policies. -- [CSE] Added support *location* attribute and *locationQuery* request parameters and functionality +- [CSE] Added support *location* attribute and *geo-query* request parameters and functionality - [SCRIPTS] Added "dolist", "dotimes", "tui-notify", "cse-attribute-info", sand "get-loglevel" functions to the script interpreter. +- [SCRIPTS] Functions now have their own variable scope. - [TUI] Improved resource view in the text UI. Enumeration interpretations are now shown. - [TUI] Added utility "Attribute Info Search". ### Experimental ### Changed +- [MISC] The project now follows the [Calendar Versioning](https://calver.org) scheme. - [CSE] Changed the *operationResult* of <request> according to SDS-2022-0010R02. - [CSE] Changed the oneM2M enumeration definition format. Each enumeration type is now a dictionary of enumeration values and their interpretations. - [HTTP] The default network interface has been changed from "127.0.0.1" to "0.0.0.0". diff --git a/README.md b/README.md index 6b587a09..adce9048 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # ACME oneM2M CSE An open source CSE Middleware for Education. -Version 0.13.0-dev +Version 2023.DEV [![oneM2M](https://img.shields.io/badge/oneM2M-f00)](https://www.onem2m.org) [![Python](https://img.shields.io/badge/Python-3.8-blue)](https://www.python.org) [![Maintenance](https://img.shields.io/badge/Maintained-Yes-green.svg)](https://github.com/ankraft/ACME-oneM2M-CSE/graphs/commit-activity) [![License](https://img.shields.io/badge/License-BSD%203--Clause-green)](LICENSE) [![MyPy](https://img.shields.io/badge/MyPy-covered-green)](LICENSE) [![Mastodon](https://img.shields.io/badge/-@acmeCSE@mstdn.social-FFF?label=mastodon&logo=mastodon&style=social)](https://mstdn.social/@acmeCSE) diff --git a/acme/etc/Constants.py b/acme/etc/Constants.py index 60f1a45c..bd605cf4 100644 --- a/acme/etc/Constants.py +++ b/acme/etc/Constants.py @@ -11,7 +11,7 @@ class Constants(object): """ Various CSE and oneM2M constants """ - version = '0.13.0-dev' + version = '2023.DEV' """ ACME's release version """ logoColor = '#b42025' diff --git a/setup.py b/setup.py index 68bd4f6b..05015bbd 100644 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup( name='ACME-oneM2M-CSE', - version='0.13.0', + version='2023.DEV', url='https://github.com/ankraft/ACME-oneM2M-CSE', author='Andreas Kraft', author_email='an.kraft@gmail.com', From c358bcec51e7bbc3dfbff5fac4e226a9cacb4725 Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 18 Sep 2023 15:26:47 +0200 Subject: [PATCH 120/165] Added int() conversion --- acme/helpers/ACMEIntEnum.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/acme/helpers/ACMEIntEnum.py b/acme/helpers/ACMEIntEnum.py index d5651b27..ef76dfaf 100644 --- a/acme/helpers/ACMEIntEnum.py +++ b/acme/helpers/ACMEIntEnum.py @@ -90,6 +90,16 @@ def __str__(self) -> str: The name of an enum value. """ return self.name + + + def __int__(self) -> int: + """ Get the integer value of an enum. + + Return: + The value of an enum value. + """ + return self.value + def __repr__(self) -> str: From c0e9e876bb07d85aec972a63391b40942a8046ee Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 18 Sep 2023 15:28:12 +0200 Subject: [PATCH 121/165] Corrected enum presentation --- acme/helpers/TextTools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/helpers/TextTools.py b/acme/helpers/TextTools.py index 470a3821..80b423af 100644 --- a/acme/helpers/TextTools.py +++ b/acme/helpers/TextTools.py @@ -102,7 +102,7 @@ def commentJson(data:Union[str, dict], elif previousKey and value: # when the value is on the next line, w/o a key - lines.append(f'// {getAttributeValueName(key, value)}') + lines.append(f'// {value}') lines.append(line) _m = len(lines[-2]) + maxLineLength maxLength = _m if _m > maxLength else maxLength From b49274218d7715cf1b3b62b461a8bd90e04d6f84 Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 18 Sep 2023 15:30:49 +0200 Subject: [PATCH 122/165] Corrected comment layout for resources --- acme/textui/ACMEContainerTree.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/acme/textui/ACMEContainerTree.py b/acme/textui/ACMEContainerTree.py index f4a3b7e4..8cb8b175 100644 --- a/acme/textui/ACMEContainerTree.py +++ b/acme/textui/ACMEContainerTree.py @@ -152,6 +152,8 @@ def __init__(self) -> None: self.header = Markdown('') self.resourceView = Static(id = 'resource-view', expand = True) self.requestView = ACMEViewRequests() + self.commentsOneLine = True + def compose(self) -> ComposeResult: @@ -213,7 +215,7 @@ def updateResource(self, resource:Optional[Resource] = None) -> None: jsns = commentJson(resource.asDict(sort = True), explanations = self.app.attributeExplanations, # type: ignore [attr-defined] getAttributeValueName = lambda a, v: CSE.validator.getAttributeValueName(a, v, resource.ty if resource else None), # type: ignore [attr-defined] - width = (self.resourceView.size[0] - 2) if self.resourceView.size[0] > 0 else 9999) # type: ignore [attr-defined] + width = None if self.commentsOneLine else self.requestListRequest.size[0] - 2) # type: ignore [attr-defined] # Update the requests view self._update_requests(resource.ri) From 549625c626c5350015b4bae370e03ccfae4296dc Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 19 Sep 2023 10:34:31 +0200 Subject: [PATCH 123/165] Corrected registrar's http port setting --- acme/services/Onboarding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme/services/Onboarding.py b/acme/services/Onboarding.py index 03bdf2b4..5f33d515 100644 --- a/acme/services/Onboarding.py +++ b/acme/services/Onboarding.py @@ -221,7 +221,7 @@ def registrarConfig() -> InquirerPySessionResult: amark = '✓', invalid_message = 'Invalid IPv4 or IPv6 address or hostname.', ).execute(), - 'httpPort': inquirer.number( + 'registrarCsePort': inquirer.number( message = 'The Registrar CSE\' host http port:', default = _iniValues[cseType]['registrarCsePort'], long_instruction = 'The TCP port of the remote (Registrar) CSE.', From 593c7f961e082881009fedf32b1ac27fe627f810 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 19 Sep 2023 14:32:53 +0200 Subject: [PATCH 124/165] TS-0001 was updated. Added "loc" attribute to some resources --- acme/resources/CIN.py | 2 ++ acme/resources/CINAnnc.py | 1 + acme/resources/FCI.py | 1 + acme/resources/TSI.py | 2 ++ acme/resources/TSIAnnc.py | 1 + init/attributePolicies.ap | 3 ++- 6 files changed, 9 insertions(+), 1 deletion(-) diff --git a/acme/resources/CIN.py b/acme/resources/CIN.py index aa990241..ff483b92 100644 --- a/acme/resources/CIN.py +++ b/acme/resources/CIN.py @@ -44,6 +44,8 @@ class CIN(AnnounceableResource): 'daci': None, 'st': None, 'cr': None, + 'loc': None, + # Resource attributes 'cnf': None, diff --git a/acme/resources/CINAnnc.py b/acme/resources/CINAnnc.py index b29e36ad..fb993961 100644 --- a/acme/resources/CINAnnc.py +++ b/acme/resources/CINAnnc.py @@ -31,6 +31,7 @@ class CINAnnc(AnnouncedResource): 'et': None, 'lbl': None, 'ast': None, + 'loc': None, 'lnk': None, # Resource attributes diff --git a/acme/resources/FCI.py b/acme/resources/FCI.py index 544cb2b0..6d02c55e 100644 --- a/acme/resources/FCI.py +++ b/acme/resources/FCI.py @@ -31,6 +31,7 @@ class FCI(Resource): 'ct': None, 'et': None, 'lbl': None, + 'loc': None, # Resource attributes 'cs': None, diff --git a/acme/resources/TSI.py b/acme/resources/TSI.py index 9d85469c..bc40f044 100644 --- a/acme/resources/TSI.py +++ b/acme/resources/TSI.py @@ -37,6 +37,8 @@ class TSI(AnnounceableResource): 'aa': None, 'ast': None, 'cr': None, + 'loc': None, + # Resource attributes 'dgt': None, diff --git a/acme/resources/TSIAnnc.py b/acme/resources/TSIAnnc.py index c7063eb1..7685783c 100644 --- a/acme/resources/TSIAnnc.py +++ b/acme/resources/TSIAnnc.py @@ -31,6 +31,7 @@ class TSIAnnc(AnnouncedResource): 'et': None, 'lbl': None, 'ast': None, + 'loc': None, 'lnk': None, # Resource attributes diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index 4785717a..c68d9972 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -244,7 +244,8 @@ ], "loc": [ { - "rtypes": [ "AE", "AEAnnc", "CSEBase", "CSEBaseAnnc", "CSR", "CSRAnnc", "CNT", "CNTAnnc", "FCNT", "FCNTAnnc", "TS", "TSAnnc", "REQRESP" ], + "rtypes": [ "AE", "AEAnnc", "CSEBase", "CSEBaseAnnc", "CSR", "CSRAnnc", "CIN", "CINAnnc", + "CNT", "CNTAnnc", "FCI", "FCNT", "FCNTAnnc", "TS", "TSAnnc", "TSI", "TSIAnnc", "REQRESP" ], "lname": "location", "ns": "m2m", "type": "m2m:geoCoordinates", From 432e347f8b6f862ba066598504fc28beca16dbce Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 19 Sep 2023 14:48:36 +0200 Subject: [PATCH 125/165] Updated installation instructions --- docs/Installation.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/Installation.md b/docs/Installation.md index 405d4a6a..6f5e22f1 100644 --- a/docs/Installation.md +++ b/docs/Installation.md @@ -4,7 +4,7 @@ ### Python -ACME requires **Python 3.8** or newer. Install it with your favorite package manager. +ACME requires **Python 3.10** or newer. Install it with your favorite package manager. You may consider to use a virtual environment manager like pyenv + virtualenv (see, for example, [this tutorial](https://realpython.com/python-virtual-environments-a-primer/)). @@ -25,7 +25,13 @@ You may consider to use a virtual environment manager like pyenv + virtualenv (s python3 -m pip install cbor2 flask flask-cors InquirerPy isodate paho-mqtt plotext rdflib requests rich tinydb 1. Run the CSE for the first time. -If no configuration file is found then an interactive configuration process is started. The +You can start the CSE by simply running it from the command line: + + python3 -m acme + + Please refer to the [Running](Running.md) documentation for more detailed instructions how to start and run the ACME CSE. + + If no configuration file is found then an interactive configuration process is started. The configuration is saved to a configuration file. e.g. *acme.ini* by default.   ![](images/bootstrapConfig.gif) @@ -35,15 +41,6 @@ configuration is saved to a configuration file. e.g. *acme.ini* by default. See the [Configuration](docs/Configuration.md) documentation for further details, and the defaults configuration file [acme.ini.default](../acme.ini.default). - -## Running the CSE - -You can start the CSE by simply running it from the command line: - - python3 -m acme - -Please refer to the [Running](Running.md) documentation for more detailed instructions how to start and run the ACME CSE. - --- ## Certificates and Support for https @@ -77,6 +74,7 @@ The following third-party components are used by the ACME CSE. - [rdflib](https://github.com/RDFLib/rdflib) is a Python library for working with RDF. BSD 3-Clause License. - The CSE uses the [Requests](https://requests.readthedocs.io) HTTP Library to send requests vi http. Apache2 License - The CSE uses the [Rich](https://github.com/willmcgugan/rich) text formatter library to format various terminal output. MIT License +- [shapely](https://github.com/shapely/shapely) is a library for manipulation and analysis of geometric objects. BSD 3-Clause License - [Textual](https://github.com/textualize/textual) is a Rapid Application Development framework for to build textual user interfaces in Python. MIT License - To store resources the CSE uses the lightweight [TinyDB](https://github.com/msiemens/tinydb) document database. MIT License From b30b0f4468e37e1569dd1c1b053d515b0888e1a4 Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 20 Sep 2023 13:32:55 +0200 Subject: [PATCH 126/165] Text improvements --- docs/ACMEScript.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ACMEScript.md b/docs/ACMEScript.md index 080625b9..7c1d373c 100644 --- a/docs/ACMEScript.md +++ b/docs/ACMEScript.md @@ -76,9 +76,9 @@ Example: ### Variables and Function Scopes -Variables are global to a script execution. Global variables that are updated in a function call are updated globally. Variables that are not defined globally and are set in the scope of a function call do only exist in the scope of the function call and its sub-function calls. +Variables are global to a script execution. Global variables that are updated in a function call are updated globally. Variables that are not defined globally but are defined in a function's scope do only exist in the scope of the function and sub-functions calls. -In addition to the normal script variables the runtime environment may pass extra environment variables to the script. They are mapped to the script's global variables and can be retrieved like any other global variable (but not updated or deleted). Variables that are set during the execution of a script override environment variables with the same name. +In addition to the normal script variables the runtime environment may pass extra environment variables to the script. They are mapped to the script's global variables and can be retrieved like any other global variable (but not updated or deleted). Variables that are set during the execution of a script have precedence over environment variables with the same name. Variables are removed between script runs. From 7012688e9190b0c153f8d5e0b4b1a450db3b34cc Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 20 Sep 2023 16:57:36 +0200 Subject: [PATCH 127/165] Changed procedure for *notifictionStatsInfo* attribute for and according to SDS-2022-0010R02. - [CSE] Changed the oneM2M enumeration definition format. Each enumeration type is now a dictionary of enumeration values and their interpretations. +- [CSE] Changed procedure for *notifictionStatsInfo* attribute for <subscription> and <crossResourceSubscription; resources according to SDS-2022-0183R01 and SDS-2022-0184R01. - [HTTP] The default network interface has been changed from "127.0.0.1" to "0.0.0.0". - [MQTT] The default network interface has been changed from "127.0.0.1" to "0.0.0.0". - [SCRIPTS] Moved utilities and system scripts to sub-directories. Now all scripts from directories "*.scripts" in the "init" directory are automatically imported. diff --git a/acme/resources/CRS.py b/acme/resources/CRS.py index 99145d48..651a0f8e 100644 --- a/acme/resources/CRS.py +++ b/acme/resources/CRS.py @@ -118,10 +118,7 @@ def activate(self, parentResource:Resource, originator:str) -> None: if self.twt == TimeWindowType.PERIODICWINDOW: CSE.notification.startCRSPeriodicWindow(self.ri, self.tws, self._countSubscriptions(), self.eem) - # nsi is at least an empty list if nse is present, otherwise it must not be present - if self.nse is not None: - self.setAttribute('nsi', [], overwrite = False) - CSE.notification.validateAndConstructNotificationStatsInfo(self) + # "nsi" will be added later during the first stat recording # Set twi default if not present self.setAttribute('eem', EventEvaluationMode.ALL_EVENTS_PRESENT.value, False) diff --git a/acme/resources/SUB.py b/acme/resources/SUB.py index 911681e8..344f006b 100644 --- a/acme/resources/SUB.py +++ b/acme/resources/SUB.py @@ -121,10 +121,7 @@ def activate(self, parentResource:Resource, originator:str) -> None: if chty := self['enc/chty']: self._checkAllowedCHTY(parentResource, chty) - # nsi is at least an empty list if nse is present, otherwise it must not be present - if self.nse is not None: - self.setAttribute('nsi', [], overwrite = False) - CSE.notification.validateAndConstructNotificationStatsInfo(self) + # "nsi" will be added later during the first stat recording CSE.notification.addSubscription(self, originator) diff --git a/acme/services/NotificationManager.py b/acme/services/NotificationManager.py index d38048de..3e8eb479 100644 --- a/acme/services/NotificationManager.py +++ b/acme/services/NotificationManager.py @@ -843,7 +843,7 @@ def _sender(nu: str, originator:str, content:JSON) -> bool: # Notification Statistics # - def validateAndConstructNotificationStatsInfo(self, sub:SUB|CRS) -> None: + def validateAndConstructNotificationStatsInfo(self, sub:SUB|CRS, add:Optional[bool] = True) -> None: """ Update and fill the *notificationStatsInfo* attribute of a \ or \ resource. This method adds, if necessary, the necessarry stat info structures for each notification @@ -854,8 +854,13 @@ def validateAndConstructNotificationStatsInfo(self, sub:SUB|CRS) -> None: Args: sub: The \ or \ resource for whoich to validate the attribute. + add: If True, add the *notificationStatsInfo* attribute if not present. """ + # Optionally add the attribute + if add: + sub.setAttribute('nsi', [], overwrite = False) + if (nsi := sub.nsi) is None: # nsi attribute must be at least an empty list return nus = sub.nu @@ -893,7 +898,7 @@ def countSentReceivedNotification(self, sub:SUB|CRS, isResponse: Indicates whether a sent notification or a received response should be counted for. count: Number of notifications to count. """ - if not sub or not sub.nse: # Don't count if disabled + if not sub or not sub.nse: # Don't count if not present or disabled return L.isDebug and L.logDebug(f'Incrementing notification stats for: {sub.ri} ({"response" if isResponse else "request"})') @@ -904,6 +909,11 @@ def countSentReceivedNotification(self, sub:SUB|CRS, # We have to lock this to prevent race conditions in some cases with CRS handling with self.lockNotificationEventStats: sub.dbReloadDict() # get a fresh copy of the subscription + + # Add nsi if not present. This happens when the first notification is sent after enabling the recording + if sub.nsi is None: + self.validateAndConstructNotificationStatsInfo(sub, True) # nsi is filled here again + for each in sub.nsi: if each['tg'] == target: each[activeField] += count @@ -927,7 +937,7 @@ def countNotificationEvents(self, ri:str, # TODO check resource type? except ResponseException as e: return - if not sub.nse: # Don't count if disabled + if not sub.nse: # Don't count if not present or disabled return L.isDebug and L.logDebug(f'Incrementing notification event stat for: {sub.ri}') @@ -936,6 +946,11 @@ def countNotificationEvents(self, ri:str, # We have to lock this to prevent race conditions in some cases with CRS handling with self.lockNotificationEventStats: sub.dbReloadDict() # get a fresh copy of the subscription + + # Add nsi if not present. This happens when the first notification is sent after enabling the recording + if sub.nsi is None: + self.validateAndConstructNotificationStatsInfo(sub, True) # nsi is filled here again + for each in sub.nsi: each['noec'] += 1 sub.dbUpdate(True) @@ -962,12 +977,13 @@ def updateOfNSEAttribute(self, sub:CRS|SUB, newNse:bool) -> None: if newNse == False: pass # Stop collecting, but keep notificationStatsInfo else: # Both are True - sub.setAttribute('nsi', []) - self.validateAndConstructNotificationStatsInfo(sub) # nsi is filled here again + # Remove the nsi + sub.delAttribute('nsi') + # After SDS-2022-184R01: nsi is not added yet, but when the first statistics are collected. See countNotificationEvents() else: # self.nse == False if newNse == True: - sub.setAttribute('nsi', []) - self.validateAndConstructNotificationStatsInfo(sub) # nsi is filled here again + sub.delAttribute('nsi') + # After SDS-2022-184R01: nsi is not added yet, but when the first statistics are collected. See countNotificationEvents() else: # nse is removed (present in resource, but None, and neither True or False) sub.delAttribute('nsi') @@ -1009,7 +1025,7 @@ def _verifyNusInSubscription(self, subscription:SUB|CRS, raise SUBSCRIPTION_VERIFICATION_INITIATION_FAILED(f'Verification request failed for: {nu}') # Add/Update NotificationStatsInfo structure - self.validateAndConstructNotificationStatsInfo(subscription) + self.validateAndConstructNotificationStatsInfo(subscription, False) # DON'T add nsi here if not present ######################################################################### diff --git a/tests/testCRS.py b/tests/testCRS.py index eda30f53..4f623b1e 100644 --- a/tests/testCRS.py +++ b/tests/testCRS.py @@ -336,9 +336,7 @@ def test_createCRSwithRratSlidingStatsEnabled(self) -> None: self.assertEqual(rrats[1], self._testSubscriptionForCnt(cntRN2)) self._testSubscriptionForCnt(cntRN3, False) self.assertTrue(findXPath(TestCRS.crs, 'm2m:crs/nse')) - self.assertIsNotNone(findXPath(TestCRS.crs, 'm2m:crs/nsi')) - self.assertEqual(len(findXPath(TestCRS.crs, 'm2m:crs/nsi')), 1) - self.assertEqual(findXPath(TestCRS.crs, 'm2m:crs/nsi/{0}/tg'), TestCRS.originator) + self.assertIsNone(findXPath(TestCRS.crs, 'm2m:crs/nsi')) @unittest.skipIf(noCSE, 'No CSEBase') @@ -840,15 +838,13 @@ def test_updateCRSSlidingWindowSize(self) -> None: @unittest.skipIf(noCSE, 'No CSEBase') - def test_retrieveCRSwithNSE(self) -> None: - """ RETRIEVE """ + def test_retrieveCRSwithNSENSINone(self) -> None: + """ RETRIEVE with NSE set to True and no NSI""" TestCRS.crs, rsc = RETRIEVE(crsURL, TestCRS.originator) self.assertEqual(rsc, RC.OK, TestCRS.crs) self.assertTrue(findXPath(TestCRS.crs, 'm2m:crs/nse')) - self.assertIsNotNone(findXPath(TestCRS.crs, 'm2m:crs/nsi')) - self.assertEqual(len(findXPath(TestCRS.crs, 'm2m:crs/nsi')), 1) - self.assertEqual(findXPath(TestCRS.crs, 'm2m:crs/nsi/{0}/tg'), TestCRS.originator) + self.assertIsNone(findXPath(TestCRS.crs, 'm2m:crs/nsi')) @unittest.skipIf(noCSE, 'No CSEBase') @@ -881,11 +877,7 @@ def test_testEmptyNsi(self) -> None: TestCRS.crs, rsc = RETRIEVE(crsURL, TestCRS.originator) self.assertEqual(rsc, RC.OK, TestCRS.crs) self.assertTrue(findXPath(TestCRS.crs, 'm2m:crs/nse')) - self.assertEqual(len( nsi := findXPath(TestCRS.crs, 'm2m:crs/nsi')), 1, TestCRS.crs) - self.assertEqual(findXPath(nsi, '{0}/tg'), TestCRS.originator, TestCRS.crs) - self.assertEqual(findXPath(nsi, '{0}/rqs'), 0) - self.assertEqual(findXPath(nsi, '{0}/rsr'), 0) - self.assertEqual(findXPath(nsi, '{0}/noec'), 0) + self.assertIsNone(findXPath(TestCRS.crs, 'm2m:crs/nsi'), TestCRS.crs) @unittest.skipIf(noCSE, 'No CSEBase') @@ -898,12 +890,8 @@ def test_updateCRSwithEnableNSE(self) -> None: TestCRS.crs, rsc = UPDATE(crsURL, TestCRS.originator, dct) self.assertEqual(rsc, RC.UPDATED, TestCRS.crs) self.assertIsNotNone(findXPath(TestCRS.crs, 'm2m:crs/nse'), TestCRS.crs) - self.assertIsNotNone(findXPath(TestCRS.crs, 'm2m:crs/nsi'), TestCRS.crs) - self.assertEqual(len( nsi := findXPath(TestCRS.crs, 'm2m:crs/nsi')), 1, TestCRS.crs) - self.assertEqual(findXPath(nsi, '{0}/tg'), TestCRS.originator, TestCRS.crs) - self.assertEqual(findXPath(nsi, '{0}/rqs'), 0) - self.assertEqual(findXPath(nsi, '{0}/rsr'), 0) - self.assertEqual(findXPath(nsi, '{0}/noec'), 0) + # nsi must be empty + self.assertIsNone(findXPath(TestCRS.crs, 'm2m:crs/nsi'), TestCRS.crs) @unittest.skipIf(noCSE, 'No CSEBase') @@ -941,11 +929,9 @@ def test_updateCRSwithNseTrue(self) -> None: TestCRS.crs, rsc = UPDATE(crsURL, TestCRS.originator, dct) self.assertEqual(rsc, RC.UPDATED, TestCRS.crs) - self.assertTrue(findXPath(TestCRS.crs, 'm2m:crs/nse')) - self.assertEqual(len(findXPath(TestCRS.crs, 'm2m:crs/nsi')), 1, TestCRS.crs) - self.assertEqual(findXPath(TestCRS.crs, 'm2m:crs/nsi/{0}/rqs'), 0, TestCRS.crs) - self.assertEqual(findXPath(TestCRS.crs, 'm2m:crs/nsi/{0}/rsr'), 0, TestCRS.crs) - self.assertEqual(findXPath(TestCRS.crs, 'm2m:crs/nsi/{0}/noec'), 0, TestCRS.crs) + self.assertTrue(findXPath(TestCRS.crs, 'm2m:crs/nse', TestCRS.crs)) + # nsi must be empty + self.assertIsNone(findXPath(TestCRS.crs, 'm2m:sub/nsi'), TestCRS.crs) ######################################################################### @@ -2048,7 +2034,7 @@ def run(testFailFast:bool) -> Tuple[int, int, int, float]: # Test Notification Stats addTest(suite, TestCRS('test_createCRSwithRratSlidingStatsEnabled')) # Sliding - addTest(suite, TestCRS('test_retrieveCRSwithNSE')) + addTest(suite, TestCRS('test_retrieveCRSwithNSENSINone')) addTest(suite, TestCRS('test_createTwoNotificationOneNotification')) addTest(suite, TestCRS('test_deleteCRSwithRrat')) diff --git a/tests/testSUB.py b/tests/testSUB.py index e40daa75..72d349a6 100644 --- a/tests/testSUB.py +++ b/tests/testSUB.py @@ -1441,9 +1441,7 @@ def test_createSUBForNotificationStats(self) -> None: self.assertEqual(rsc, RC.CREATED) self.assertIsNotNone(findXPath(r, 'm2m:sub/nse')) self.assertEqual(findXPath(r, 'm2m:sub/nse'), True) - self.assertIsNotNone(findXPath(r, 'm2m:sub/nsi')) - self.assertIsInstance(findXPath(r, 'm2m:sub/nsi'), list) - self.assertEqual(len(findXPath(r, 'm2m:sub/nsi')), 1) # Verification request doesn't count + self.assertIsNone(findXPath(r, 'm2m:sub/nsi')) lastNotification = getLastNotification() # no delay! blocking self.assertTrue(findXPath(lastNotification, 'm2m:sgn/vrq')) @@ -1476,6 +1474,7 @@ def test_updateSUBNSEFalse(self) -> None: }} r, rsc = UPDATE(f'{self.aePOAURL}/{subRN}', TestSUB.originatorPoa, dct) self.assertEqual(rsc, RC.UPDATED, r) + # nsi is kept after nse update to FALSE self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/rqs'), 1, r) # Change counts if order of TC changes self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/rsr'), 1, r) self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/noec'), 1, r) @@ -1489,11 +1488,8 @@ def test_updateSUBNSETrue(self) -> None: }} r, rsc = UPDATE(f'{self.aePOAURL}/{subRN}', TestSUB.originatorPoa, dct) self.assertEqual(rsc, RC.UPDATED, r) - self.assertEqual(len(findXPath(r, 'm2m:sub/nsi')), 1, r) - # Must be empty - self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/rqs'), 0, r) - self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/rsr'), 0, r) - self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/noec'), 0, r) + # nsi must be empty + self.assertIsNone(findXPath(r, 'm2m:sub/nsi'), r) @unittest.skipIf(noCSE, 'No CSEBase') @@ -1522,10 +1518,8 @@ def test_updateSUBNSETrueAgain(self) -> None: }} r, rsc = UPDATE(f'{self.aePOAURL}/{subRN}', TestSUB.originatorPoa, dct) self.assertEqual(rsc, RC.UPDATED, r) - self.assertEqual(len(findXPath(r, 'm2m:sub/nsi')), 1, r) - self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/rqs'), 0, r) # Change counts if order of TC changes - self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/rsr'), 0, r) - self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/noec'), 0, r) + # nsi must be empty + self.assertIsNone(findXPath(r, 'm2m:sub/nsi'), r) @unittest.skipIf(noCSE, 'No CSEBase') @@ -1542,10 +1536,9 @@ def test_updateSUBcountBatchNotifications(self) -> None: r, rsc = UPDATE(f'{self.aePOAURL}/{subRN}', TestSUB.originatorPoa, dct) self.assertEqual(rsc, RC.UPDATED, r) self.assertIsNotNone(findXPath(r, 'm2m:sub/bn/num'), r) - self.assertEqual(len(findXPath(r, 'm2m:sub/nsi')), 1, r) # - self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/rqs'), 0, r) - self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/rsr'), 0, r) - self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/noec'), 0, r) + # nsi must be empty + self.assertIsNone(findXPath(r, 'm2m:sub/nsi'), r) + # Make some Updates and cause a batch notification for _ in range(numberOfBatchNotifications): @@ -1564,6 +1557,8 @@ def test_updateSUBcountBatchNotifications(self) -> None: # retrieve to get the stats r, rsc = RETRIEVE(f'{self.aePOAURL}/{subRN}', TestSUB.originatorPoa) self.assertEqual(rsc, RC.OK, r) + # nsi must now be set + self.assertIsNotNone(findXPath(r, 'm2m:sub/nsi'), r) self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/rqs'), 5, r) # Change counts if order of TC changes self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/rsr'), 5, r) self.assertEqual(findXPath(r, 'm2m:sub/nsi/{0}/noec'), 5, r) From 018eff1f42715b6c4abaa73730a39bf6e4e45201 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 22 Sep 2023 21:51:32 +0200 Subject: [PATCH 128/165] Improved help table format --- acme/services/Console.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/acme/services/Console.py b/acme/services/Console.py index 42959ae5..c170683c 100644 --- a/acme/services/Console.py +++ b/acme/services/Console.py @@ -358,9 +358,9 @@ def help(self, key:str) -> None: ] table = Table(row_styles = [ '', L.tableRowStyle]) - table.add_column('Key', no_wrap = True, justify = 'left') - table.add_column('Description', no_wrap = True) - table.add_column('Script', no_wrap = True, justify = 'center') + table.add_column('Key', no_wrap = True, justify = 'left', min_width = 10) + table.add_column('Description', no_wrap = False) + table.add_column('Script', no_wrap = True, justify = 'center', min_width = 6) for each in commands: table.add_row(each[0], each[1], '', end_section = each == commands[-1]) From 9c4d1b1d4de1d8ea51c2420d7bd839237aa1d86b Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 27 Sep 2023 13:53:26 +0200 Subject: [PATCH 129/165] Removed superfluous code when announcing resources (see issue #122). --- CHANGELOG.md | 1 + acme/services/AnnouncementManager.py | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13bc40cb..c499f926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ and this project adheres to [Calendar Versioning](https://calver.org). ### Fixed +- [CSE] Removed superfluous code when announcing resources (see issue #122). ### Removed diff --git a/acme/services/AnnouncementManager.py b/acme/services/AnnouncementManager.py index a6cd1f6b..225caf6d 100644 --- a/acme/services/AnnouncementManager.py +++ b/acme/services/AnnouncementManager.py @@ -422,17 +422,15 @@ def announceUpdatedResource(self, resource:AnnounceableResource, originator:str) # Update the annoucned remote resources announcedCSIs = [] - remoteRIs = [] for (csi, remoteRI) in resource.getAnnouncedTo(): if csi == originator: # Skip the announced resource at the originator !! continue announcedCSIs.append(csi) # build a list of already announced CSIs - remoteRIs.append(csi) # build a list of remote RIs self.updateResourceOnCSI(resource, csi, remoteRI) # Check for any non-announced csi in at, and possibly announce them for csi in CSIsFromAnnounceTo: - if csi not in announcedCSIs and csi not in remoteRIs: + if csi not in announcedCSIs: self.announceResourceToCSI(resource, csi) From 4bd2e58cfcadf3d3be530bf8e724e2620ea8756f Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 27 Sep 2023 15:13:27 +0200 Subject: [PATCH 130/165] Added support for http authorization for *basic* and *bearer* (token) methods. --- CHANGELOG.md | 1 + acme.ini.default | 16 ++++ acme/services/Configuration.py | 9 +++ acme/services/HttpServer.py | 130 ++++++++++++++++++++++++++++--- acme/services/SecurityManager.py | 110 ++++++++++++++++++++++++-- docs/Configuration.md | 4 + docs/Supported.md | 15 ++-- init/configurations.docmd | 41 ++++++++++ tests/config.py | 16 ++++ tests/init.py | 80 +++++++++++++++---- 10 files changed, 381 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c499f926..0059eb22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Calendar Versioning](https://calver.org). - [SCRIPTS] Functions now have their own variable scope. - [TUI] Improved resource view in the text UI. Enumeration interpretations are now shown. - [TUI] Added utility "Attribute Info Search". +- [HTTP] Added support for http authorization for *basic* and *bearer* (token) methods. - [MISC] Added tool to generate documentation from the source code. See [tools/apidocs/README.md](tools/apidocs/README.md). ### Experimental diff --git a/acme.ini.default b/acme.ini.default index 6fc9464f..d420f3bb 100644 --- a/acme.ini.default +++ b/acme.ini.default @@ -183,6 +183,22 @@ verifyCertificate=false caCertificateFile=${basic.config:dataDirectory}/certs/acme_cert.pem ; Path and filename of the private key file. Default: None caPrivateKeyFile=${basic.config:dataDirectory}/certs/acme_key.pem +; Enable basic authentication for the HTTP binding. +; Default: false +enableBasicAuth=false +; Enable token authentication for the HTTP binding. +; Default: false +enableTokenAuth=false +; Path and filename of the http basic authentication file. +; The file must contain lines with the format "username:password". +; Comments are lines starting with a #. +; Default: certs/http_basic_auth.txt +basicAuthFile=${basic.config:dataDirectory}/certs/http_basic_auth.txt +; Path and filename of the http bearer token authentication file. +; The file must contain lines with the format "token". +; Comments are lines starting with a #. +; Default: certs/http_token_auth.txt +tokenAuthFile=${basic.config:dataDirectory}/certs/http_token_auth.txt [http.cors] diff --git a/acme/services/Configuration.py b/acme/services/Configuration.py index d3125c3f..24ef7192 100644 --- a/acme/services/Configuration.py +++ b/acme/services/Configuration.py @@ -323,6 +323,10 @@ def init(args:argparse.Namespace = None) -> bool: 'http.security.tlsVersion' : config.get('http.security', 'tlsVersion', fallback = 'auto'), 'http.security.useTLS' : config.getboolean('http.security', 'useTLS', fallback = False), 'http.security.verifyCertificate' : config.getboolean('http.security', 'verifyCertificate', fallback = False), + 'http.security.enableBasicAuth' : config.getboolean('http.security', 'enableBasicAuth', fallback = False), + 'http.security.enableTokenAuth' : config.getboolean('http.security', 'enableTokenAuth', fallback = False), + 'http.security.basicAuthFile' : config.get('http.security', 'basicAuthFile', fallback = './certs/http_basic_auth.txt'), + 'http.security.tokenAuthFile' : config.get('http.security', 'tokenAuthFile', fallback = './certs/http_token_auth.txt'), # # Logging @@ -621,6 +625,11 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: if initial and Configuration._configuration['http.cors.enable'] and not Configuration._configuration['http.security.useTLS']: Configuration._print('[orange3]Configuration Warning: [i]\[http.security].useTLS[/i] (https) should be enabled when [i]\[http.cors].enable[/i] is enabled.') + # HTTP authentication + if Configuration._configuration['http.security.enableBasicAuth'] and not Configuration._configuration['http.security.basicAuthFile']: + return False, 'Configuration Error: [i]\[http.security]:httpBasicAuthFile[/i] must be set when HTTP Basic Auth is enabled' + if Configuration._configuration['http.security.enableTokenAuth'] and not Configuration._configuration['http.security.tokenAuthFile']: + return False, 'Configuration Error: [i]\[http.security]:httpTokenAuthFile[/i] must be set when HTTP Token Auth is enabled' # # MQTT client diff --git a/acme/services/HttpServer.py b/acme/services/HttpServer.py index a7cf4e45..9c4e4f5b 100644 --- a/acme/services/HttpServer.py +++ b/acme/services/HttpServer.py @@ -10,11 +10,13 @@ from __future__ import annotations from typing import Any, Callable, cast, Tuple, Optional -import logging, sys, urllib3, re +import logging, sys, urllib3, re, base64 from copy import deepcopy import flask from flask import Flask, Request, request + + from werkzeug.wrappers import Response from werkzeug.serving import WSGIRequestHandler from werkzeug.datastructures import MultiDict @@ -48,6 +50,12 @@ """ Type definition for flask handler. """ + +######################################################################### +# +# HTTP Server +# + class HttpServer(object): __close__ = ( @@ -63,6 +71,8 @@ class HttpServer(object): 'isStopped', 'corsEnable', 'corsResources', + 'enableBasicAuth', + 'enableTokenAuth', 'backgroundActor', 'serverID', '_responseHeaders', @@ -83,18 +93,14 @@ def __init__(self) -> None: # Initialize the http server # Meaning defaults are automatically provided. self.flaskApp = Flask(CSE.cseCsi) - self.rootPath = Configuration.get('http.root') - self.serverAddress = Configuration.get('http.address') - self.listenIF = Configuration.get('http.listenIF') - self.port = Configuration.get('http.port') - self.allowPatchForDelete= Configuration.get('http.allowPatchForDelete') - self.requestTimeout = Configuration.get('http.timeout') - self.webuiRoot = Configuration.get('webui.root') - self.webuiDirectory = f'{Configuration.get("packageDirectory")}/webui' - self.isStopped = False - self.corsEnable = Configuration.get('http.cors.enable') - self.corsResources = Configuration.get('http.cors.resources') + # Get the configuration settings + self._assignConfig() + + # Add handler for configuration updates + CSE.event.addHandler(CSE.event.configUpdate, self.configUpdate) # type: ignore + + self.isStopped = False self.backgroundActor:BackgroundWorker = None self.serverID = f'ACME {Constants.version}' # The server's ID for http response headers @@ -160,6 +166,50 @@ def __init__(self) -> None: self._eventResponseReceived = CSE.event.responseReceived # type: ignore [attr-defined] + def _assignConfig(self) -> None: + """ Assign the configuration values to the http server. + """ + self.rootPath = Configuration.get('http.root') + self.serverAddress = Configuration.get('http.address') + self.listenIF = Configuration.get('http.listenIF') + self.port = Configuration.get('http.port') + self.allowPatchForDelete= Configuration.get('http.allowPatchForDelete') + self.requestTimeout = Configuration.get('http.timeout') + self.webuiRoot = Configuration.get('webui.root') + self.webuiDirectory = f'{Configuration.get("packageDirectory")}/webui' + self.corsEnable = Configuration.get('http.cors.enable') + self.corsResources = Configuration.get('http.cors.resources') + self.enableBasicAuth = Configuration.get('http.security.enableBasicAuth') + self.enableTokenAuth = Configuration.get('http.security.enableTokenAuth') + + + def configUpdate(self, name:str, + key:Optional[str] = None, + value:Any = None) -> None: + """ Handle configuration updates. + + Args: + name: The name of the configuration section. + key: The key of the configuration value. + value: The new value. + """ + if key not in ( 'http.root', + 'http.address', + 'http.listenIF', + 'http.port', + 'http.allowPatchForDelete', + 'http.timeout', + 'webui.root', + 'http.cors.enable', + 'http.cors.resources', + 'http.security.enableBasicAuth', + 'http.security.enableTokenAuth', + 'mqtt.security.password' + ): + return + self._assignConfig() + + def run(self) -> bool: """ Run the http server in a separate thread. """ @@ -277,12 +327,16 @@ def _handleRequest(self, path:str, operation:Operation) -> Response: def handleGET(self, path:Optional[str] = None) -> Response: + if not self.handleAuthentication(): + return Response(status = 401) renameThread('HTRE') self._eventHttpRetrieve() return self._handleRequest(path, Operation.RETRIEVE) def handlePOST(self, path:Optional[str] = None) -> Response: + if not self.handleAuthentication(): + return Response(status = 401) if self._hasContentType(): renameThread('HTCR') self._eventHttpCreate() @@ -294,12 +348,16 @@ def handlePOST(self, path:Optional[str] = None) -> Response: def handlePUT(self, path:Optional[str] = None) -> Response: + if not self.handleAuthentication(): + return Response(status = 401) renameThread('HTUP') self._eventHttpUpdate() return self._handleRequest(path, Operation.UPDATE) def handleDELETE(self, path:Optional[str] = None) -> Response: + if not self.handleAuthentication(): + return Response(status = 401) renameThread('HTDE') self._eventHttpDelete() return self._handleRequest(path, Operation.DELETE) @@ -308,6 +366,8 @@ def handleDELETE(self, path:Optional[str] = None) -> Response: def handlePATCH(self, path:Optional[str] = None) -> Response: """ Support instead of DELETE for http/1.0. """ + if not self.handleAuthentication(): + return Response(status = 401) if request.environ.get('SERVER_PROTOCOL') != 'HTTP/1.0': return Response(L.logWarn('PATCH method is only allowed for HTTP/1.0. Rejected.'), status = 405) renameThread('HTDE') @@ -350,6 +410,11 @@ def handleUpperTester(self, path:Optional[str] = None) -> Response: if self.isStopped: return Response('Service not available', status = 503) + # Check, when authentication is enabled, the user is authorized, else return status 401 + if not self.handleAuthentication(): + return Response(status = 401) + + def prepareUTResponse(rcs:ResponseStatusCode, result:str) -> Response: """ Prepare the Upper Tester Response. """ @@ -541,6 +606,47 @@ def sendHttpRequest(self, request:CSERequest, url:str) -> Result: self._eventResponseReceived(resp) return res + ######################################################################### + + # + # Handle authentication + # + + def handleAuthentication(self) -> bool: + """ Handle the authentication for the current request. + + Return: + True if the request is authenticated, False otherwise. + """ + if not (self.enableBasicAuth or self.enableTokenAuth): + return True + + if (authorization := request.authorization) is None: + L.isDebug and L.logDebug('No authorization header found.') + return False + + match authorization.type: + case 'basic': + return self._handleBasicAuthentication(authorization.parameters) + case 'bearer': + return self._handleTokenAuthentication(authorization.token) + case _: + L.isWarn and L.logWarn(f'Unsupported authentication method: {authorization.type}') + return False + + + def _handleBasicAuthentication(self, parameters:dict) -> bool: + if not CSE.security.validateHttpBasicAuth(parameters['username'], parameters['password']): + L.isWarn and L.logWarn(f'Invalid username or password for basic authentication: {parameters["username"]}') + return False + return True + + + def _handleTokenAuthentication(self, token:str) -> bool: + if not CSE.security.validateHttpTokenAuth(token): + L.isWarn and L.logWarn(f'Invalid token for token authentication: {token}') + return False + return True ######################################################################### diff --git a/acme/services/SecurityManager.py b/acme/services/SecurityManager.py index 63eb090e..39e8e729 100644 --- a/acme/services/SecurityManager.py +++ b/acme/services/SecurityManager.py @@ -27,8 +27,6 @@ from ..services.Logging import Logging as L -# TODO move configurations to extra functions and support reconfigure event - class SecurityManager(object): """ This manager entity handles access to resources and requests. """ @@ -47,6 +45,10 @@ class SecurityManager(object): 'usernameMqtt', 'passwordMqtt', 'allowedCredentialIDsMqtt', + 'httpBasicAuthFile', + 'httpTokenAuthFile', + 'httpBasicAuthData', + 'httpTokenAuthData' ) @@ -54,6 +56,11 @@ def __init__(self) -> None: # Get the configuration settings self._assignConfig() + self._readHttpBasicAuthFile() + self._readHttpTokenAuthFile() + + # Add a handler when the CSE is reset + CSE.event.addHandler(CSE.event.cseReset, self.restart) # type: ignore # Add handler for configuration updates CSE.event.addHandler(CSE.event.configUpdate, self.configUpdate) # type: ignore @@ -68,6 +75,15 @@ def __init__(self) -> None: def shutdown(self) -> bool: L.isInfo and L.log('SecurityManager shut down') return True + + + def restart(self, name:str) -> None: + """ Restart the Security manager service. + """ + self._assignConfig() + self._readHttpBasicAuthFile() + self._readHttpTokenAuthFile() + L.logDebug('SecurityManager restarted') def _assignConfig(self) -> None: @@ -92,13 +108,23 @@ def _assignConfig(self) -> None: self.passwordMqtt = Configuration.get('mqtt.security.password') self.allowedCredentialIDsMqtt = Configuration.get('mqtt.security.allowedCredentialIDs') + # HTTP authentication + self.httpBasicAuthFile = Configuration.get('http.security.basicAuthFile') + self.httpTokenAuthFile = Configuration.get('http.security.tokenAuthFile') + + def configUpdate(self, name:str, key:Optional[str] = None, value:Any = None) -> None: """ Handle configuration updates. + + Args: + name: The name of the configuration section. + key: The key of the configuration value. + value: The new value of the configuration value. """ - if key not in [ 'cse.security.enableACPChecks', + if key not in ( 'cse.security.enableACPChecks', 'cse.security.fullAccessAdmin', 'http.security.useTLS', 'http.security.verifyCertificate', @@ -111,10 +137,13 @@ def configUpdate(self, name:str, 'mqtt.security.username', 'mqtt.security.password', 'mqtt.security.allowedCredentialIDs', - ]: + 'http.security.basicAuthFile' + ): return self._assignConfig() - return + self._readHttpBasicAuthFile() + self._readHttpTokenAuthFile() + ############################################################################################### @@ -473,6 +502,77 @@ def getSSLContext(self) -> ssl.SSLContext: return context + ########################################################################## + # + # User authentication + # + + def validateHttpBasicAuth(self, username:str, password:str) -> bool: + """ Validate the provided username and password against the configured basic authentication file. + + Args: + username: The username to validate. + password: The password to validate. + + Return: + Boolean indicating the result. + """ + return self.httpBasicAuthData.get(username) == password + + + def validateHttpTokenAuth(self, token:str) -> bool: + """ Validate the provided token against the configured token authentication file. + + Args: + token: The token to validate. + + Return: + Boolean indicating the result. + """ + return token in self.httpTokenAuthData + + + def _readHttpBasicAuthFile(self) -> None: + """ Read the HTTP basic authentication file and store the data in a dictionary. + The authentication information is stored as username:password. + + The data is stored in the `httpBasicAuthData` dictionary. + """ + self.httpBasicAuthData = {} + if self.httpBasicAuthFile: + try: + with open(self.httpBasicAuthFile, 'r') as f: + for line in f: + if line.startswith('#'): + continue + if len(line.strip()) == 0: + continue + (username, password) = line.strip().split(':') + self.httpBasicAuthData[username] = password.strip() + except Exception as e: + L.logErr(f'Error reading basic authentication file: {e}') + + + def _readHttpTokenAuthFile(self) -> None: + """ Read the HTTP token authentication file and store the data in a dictionary. + The authentication information is stored as a single token per line. + + The data is stored in the `httpTokenAuthData` list. + """ + self.httpTokenAuthData = [] + if self.httpTokenAuthFile: + try: + with open(self.httpTokenAuthFile, 'r') as f: + for line in f: + if line.startswith('#'): + continue + if len(line.strip()) == 0: + continue + self.httpTokenAuthData.append(line.strip()) + except Exception as e: + L.logErr(f'Error reading token authentication file: {e}') + + # def getSSLContextMqtt(self) -> ssl.SSLContext: # """ Depending on the configuration whether to use TLS for MQTT, this method creates a new `SSLContext` # from the configured certificates and returns it. If TLS for MQTT is disabled then `None` is returned. diff --git a/docs/Configuration.md b/docs/Configuration.md index 6d5015b6..97164ef9 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -190,6 +190,10 @@ The following tables provide detailed descriptions of all the possible CSE confi | verifyCertificate | Verify certificates in requests. Set to *False* when using self-signed certificates.
Default: False | http.security.verifyCertificate | | caCertificateFile | Path and filename of the certificate file.
Default: None | http.security.caCertificateFile | | caPrivateKeyFile | Path and filename of the private key file.
Default: None | http.security.caPrivateKeyFile | +| enableBasicAuth | Enable basic authentication for the HTTP binding.
Default: false | http.security.enableBasicAuth | +| enableTokenAuth | Enable token authentication for the HTTP binding.
Default: false | http.security.enableTokenAuth | +| basicAuthFile | Path and filename of the http basic authentication file. The file must contain lines with the format "username:password". Comments are lines starting with a #.
Default: certs/http_basic_auth.txt | http.security.basicAuthFile | +| tokenAuthFile | Path and filename of the http bearer token authentication file. The file must contain lines with the format "token". Comments are lines starting with a #.
Default: certs/http_token_auth.txt | http.security.tokenAuthFile | [top](#sections) diff --git a/docs/Supported.md b/docs/Supported.md index 8d55acf7..2f909b3d 100644 --- a/docs/Supported.md +++ b/docs/Supported.md @@ -109,7 +109,8 @@ The following table presents the supported management object specifications. ### Additional CSE Features | Functionality | Remark | |:----------------------|:----------------------------------------------------------------------------------------------------------| -| HTTP CORS | Support for *Cross-Origin Resource Sharing* to support http(s) redirects. | +| HTTP CORS | Support for *Cross-Origin Resource Sharing* to support http(s) redirects. | +| HTTP Authorization | Basic support for *basic* and *bearer* (token) authorization. | | Text Console | Control and manage the CSE, inspect resources, run scripts in a text console. | | Test UI | Text-based UI to inspect resources and requests, configurations, stats, and more | | Testing: Upper Tester | Basic support for the Upper Tester protocol defined in TS-0019, and additional command execution support. | @@ -167,12 +168,12 @@ The following result contents are implemented for standard oneM2M requests & dis ## Protocols Bindings The following Protocol Bindings are supported: -| Protocol Binding | Supported | Remark | -|:-----------------|:---------:|:--------------------------------------------------------------------------------------------------------| -| http | ✓ | incl. TLS (https) and CORS support.
Experimental: Using PATCH to replace missing DELETE in http/1.0 | -| coap | ✗ | | -| mqtt | ✓ | incl. mqtts | -| WebSocket | ✗ | | +| Protocol Binding | Supported | Remark | +|:-----------------|:---------:|:----------------------------------------------------------------------------------------------------------------------------------------------| +| http | ✓ | incl. TLS (https) and CORS support. *basic* and *bearer* authentication.
Experimental: Using PATCH to replace missing DELETE in http/1.0 | +| coap | ✗ | | +| mqtt | ✓ | incl. mqtts | +| WebSocket | ✗ | | The supported bindings can be used together, and combined and mixed in any way. diff --git a/init/configurations.docmd b/init/configurations.docmd index 123b13ce..8edefd29 100644 --- a/init/configurations.docmd +++ b/init/configurations.docmd @@ -850,6 +850,47 @@ The default value is `False`. +# http.security.enableBasicAuth + +This setting enables or disables the CSE's HTTP server's basic authentication support. +If enabled, the CSE's HTTP server will only accept incoming connections from clients that provide a valid username and password in the HTTP *Authorization* header. + +Can be enabled together with token authentication. + +The default value is `False`. + + + +# http.security.enableTokenAuth + +This setting enables or disables the CSE's HTTP server's token authentication support. +If enabled, the CSE's HTTP server will only accept incoming connections from clients that provide a valid token in the HTTP *Authorization* header. + +Can be enabled together with basic authentication. + +The default value is `False`. + + + +# http.security.basicAuthFile + +This setting specifies the path to the CSE's HTTP server's basic authentication file. +The file must contain lines with the format "username:password". +Comments are lines starting with a #. + +The default value is `${basic.config:dataDirectory}/certs/http_basic_auth.txt`. + + +# http.security.tokenAuthFile + +This setting specifies the path to the CSE's HTTP server's token authentication file. +The file must contain lines with the format "token". +Comments are lines starting with a #. + +The default value is `${basic.config:dataDirectory}/certs/http_token_auth.txt`. + + + # logging This section contains settings that control the CSE's logging behavior. diff --git a/tests/config.py b/tests/config.py index 40fd2aa8..2f1c91d2 100644 --- a/tests/config.py +++ b/tests/config.py @@ -73,6 +73,7 @@ MQTTREGRESPONSETOPIC= f'/oneM2M/reg_resp/{mqttClientID}{CSEID}/json' +############################################################################## # # OAuth2 authentication @@ -85,6 +86,21 @@ oauthClientSecret = '' +# +# HTTP Basic authentication +# + +doHttpBasicAuth = False +httpUserName = 'test' +httpPassword = 'testPassword' + +# +# HTTP Token authentication +# +doHttpTokenAuth = False +httpAuthToken = 'testToken' + + # # Remote CSE # For testing remote CSE registrations diff --git a/tests/init.py b/tests/init.py index c1cf703b..f538df70 100755 --- a/tests/init.py +++ b/tests/init.py @@ -11,7 +11,7 @@ from typing import Any, Callable, Tuple, cast, Optional from urllib.parse import ParseResult, urlparse, parse_qs -import sys, io, atexit +import sys, io, atexit, base64 import unittest from rich.console import Console @@ -368,8 +368,24 @@ def sendRequest(operation:Operation, url:str, originator:str, ty:ResourceTypes=N return None, 5103 +def addHttpAuthorizationHeader(headers:Parameters) -> Optional[Tuple[str, int]]: + global oauthToken + + if doOAuth: + if (token := OAuth.getOAuthToken(oauthServerUrl, oauthClientID, oauthClientSecret, oauthToken)) is None: + return 'error retrieving oauth token', 5103 + oauthToken = token + headers['Authorization'] = f'Bearer {oauthToken.token}' + elif doHttpBasicAuth: + _t = f'{httpUserName}:{httpPassword}' + headers['Authorization'] = f'Basic {base64.b64encode(_t.encode("utf-8")).decode("utf-8")}' + elif doHttpTokenAuth: + headers['Authorization'] = f'Bearer {httpAuthToken}' + return None + + def sendHttpRequest(method:Callable, url:str, originator:str, ty:ResourceTypes=None, data:JSON|str=None, ct:str=None, timeout:float=None, headers:Parameters=None) -> Tuple[STRING|JSON, int]: # type: ignore # TODO Constants - global oauthToken, httpSession + global httpSession # correct url url = RequestUtils.toHttpUrl(url) @@ -405,11 +421,19 @@ def sendHttpRequest(method:Callable, url:str, originator:str, ty:ResourceTypes=N hds.update(headers) # authentication - if doOAuth: - if (token := OAuth.getOAuthToken(oauthServerUrl, oauthClientID, oauthClientSecret, oauthToken)) is None: - return 'error retrieving oauth token', 5103 - oauthToken = token - hds['Authorization'] = f'Bearer {oauthToken.token}' + if (_r := addHttpAuthorizationHeader(hds)) is not None: + return _r + + # if doOAuth: + # if (token := OAuth.getOAuthToken(oauthServerUrl, oauthClientID, oauthClientSecret, oauthToken)) is None: + # return 'error retrieving oauth token', 5103 + # oauthToken = token + # hds['Authorization'] = f'Bearer {oauthToken.token}' + # elif doHttpBasicAuth: + # _t = f'{httpUserName}:{httpPassword}' + # hds['Authorization'] = f'Basic {base64.b64encode(_t.encode("utf-8")).decode("utf-8")}' + # elif doHttpTokenAuth: + # hds['Authorization'] = f'Bearer aRandomToken' # Verbose output if verboseRequests: @@ -664,7 +688,9 @@ def enableShortResourceExpirations() -> None: global _orgExpCheck, _maxExpiration, _tooLargeResourceExpirationDelta # Send UT request - resp = requests.post(UTURL, headers = { UTCMD: f'enableShortResourceExpiration {expirationCheckDelay}'}) + headers = { UTCMD: f'enableShortResourceExpiration {expirationCheckDelay}'} + addHttpAuthorizationHeader(headers) + resp = requests.post(UTURL, headers = headers) _maxExpiration = -1 _orgExpCheck = -1 if resp.status_code == 200: @@ -684,7 +710,9 @@ def disableShortResourceExpirations() -> None: global _orgExpCheck, _orgREQExpCheck if _orgExpCheck != -1: # Send UT request - resp = requests.post(UTURL, headers = { UTCMD: f'disableShortResourceExpiration'}) + headers = { UTCMD: f'disableShortResourceExpiration'} + addHttpAuthorizationHeader(headers) + resp = requests.post(UTURL, headers = headers) if resp.status_code == 200: _orgExpCheck = -1 _orgREQExpCheck = -1 @@ -716,7 +744,9 @@ def enableShortRequestExpirations() -> None: global _orgRequestExpirationDelta # Send UT request - resp = requests.post(UTURL, headers = { UTCMD: f'enableShortRequestExpiration {requestExpirationDelay}'}) + headers = { UTCMD: f'enableShortRequestExpiration {requestExpirationDelay}'} + addHttpAuthorizationHeader(headers) + resp = requests.post(UTURL, headers = headers) if resp.status_code == 200: if UTRSP in resp.headers: _orgRequestExpirationDelta = float(resp.headers[UTRSP]) @@ -728,7 +758,9 @@ def disableShortRequestExpirations() -> None: global _orgRequestExpirationDelta # Send UT request - resp = requests.post(UTURL, headers = { UTCMD: f'disableShortRequestExpiration'}) + headers = { UTCMD: f'disableShortRequestExpiration'} + addHttpAuthorizationHeader(headers) + resp = requests.post(UTURL, headers = headers) if resp.status_code == 200: _orgRequestExpirationDelta = -1.0 @@ -749,7 +781,9 @@ def testCaseStart(name:str) -> None: name: Name of the test case. """ if UPPERTESTERENABLED: - requests.post(UTURL, headers = { UTCMD: f'testCaseStart {name}'}) + headers = { UTCMD: f'testCaseStart {name}'} + addHttpAuthorizationHeader(headers) + requests.post(UTURL, headers = headers) if verboseRequests: console.print('') ln = '=' * int((console.width - 11 - len(name)) / 2) @@ -764,7 +798,9 @@ def testCaseEnd(name:str) -> None: name: Name of the test case. """ if UPPERTESTERENABLED: - requests.post(UTURL, headers = { UTCMD: f'testCaseEnd {name}'}) + headers = { UTCMD: f'testCaseEnd {name}'} + addHttpAuthorizationHeader(headers) + requests.post(UTURL, headers = headers) if verboseRequests: console.print('') ln = '=' * int((console.width - 9 - len(name)) / 2) @@ -1115,10 +1151,20 @@ def findXPath(dct:JSON, key:str, default:Any=None) -> Any: if UPPERTESTERENABLED: try: - if requests.post(UTURL, headers = { UTCMD: f'Status'}).status_code != 200: - console.print('[red]Upper Tester Interface not enabeled in CSE') - console.print('Enable with configuration setting: "\[http]:enableUpperTesterEndpoint=True"') - quit(-1) + headers = { UTCMD: f'Status'} + addHttpAuthorizationHeader(headers) + response = requests.post(UTURL, headers = headers) + match response.status_code: + case 200: + pass + case 401: + console.print('[red]CSE requires authorization') + console.print('Add authorization settings to the test suite configuration file') + quit(-1) + case _: + console.print('[red]Upper Tester Interface not enabeled in CSE') + console.print('Enable with configuration setting: "\[http]:enableUpperTesterEndpoint=True"') + quit(-1) except (ConnectionRefusedError, requests.exceptions.ConnectionError): console.print('[red]Connection to CSE not possible[/red]\nIs it running?') quit(-1) From 9260536f8cbd439ab747764b64dfc6d355a7df57 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 5 Oct 2023 10:50:15 +0200 Subject: [PATCH 131/165] Cosmetics: removed assigned parameter --- acme/resources/ACTR.py | 4 ++-- acme/resources/DEPR.py | 4 ++-- acme/resources/SMD.py | 2 +- acme/resources/TS.py | 2 +- acme/services/Dispatcher.py | 8 ++++---- acme/services/RequestManager.py | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/acme/resources/ACTR.py b/acme/resources/ACTR.py index b27c466f..577471a2 100644 --- a/acme/resources/ACTR.py +++ b/acme/resources/ACTR.py @@ -220,13 +220,13 @@ def _checkReferencedResources(self, originator:str, sri:str, orc:str) -> Tuple[R try: resSri = CSE.dispatcher.retrieveResourceWithPermission(sri, originator, Permission.RETRIEVE) except ResponseException as e: - raise BAD_REQUEST(dbg = e.dbg) + raise BAD_REQUEST(e.dbg) if orc is not None: try: resOrc = CSE.dispatcher.retrieveResourceWithPermission(orc, originator, Permission.RETRIEVE) except ResponseException as e: - raise BAD_REQUEST(dbg = e.dbg) + raise BAD_REQUEST(e.dbg) return (resSri, resOrc) diff --git a/acme/resources/DEPR.py b/acme/resources/DEPR.py index 0a7ec886..8353e3c2 100644 --- a/acme/resources/DEPR.py +++ b/acme/resources/DEPR.py @@ -65,7 +65,7 @@ def activate(self, parentResource: Resource, originator: str) -> None: try: resRri = CSE.dispatcher.retrieveResourceWithPermission(self.rri, originator, Permission.RETRIEVE) except ResponseException as e: - raise BAD_REQUEST(dbg = e.dbg) + raise BAD_REQUEST(e.dbg) # Check existence of referenced subject attribute in the referenced resource. sbjt = self.evc['sbjt'] @@ -87,7 +87,7 @@ def update(self, dct: JSON = None, try: resRri = CSE.dispatcher.retrieveResourceWithPermission(self.getFinalResourceAttribute('rri', dct), originator, Permission.RETRIEVE) except ResponseException as e: - raise BAD_REQUEST(dbg = e.dbg) + raise BAD_REQUEST(e.dbg) if (evc := findXPath(dct, 'm2m:depr/evc')) is not None: diff --git a/acme/resources/SMD.py b/acme/resources/SMD.py index 9c4f0b38..5218c949 100644 --- a/acme/resources/SMD.py +++ b/acme/resources/SMD.py @@ -141,7 +141,7 @@ def validate(self, originator:Optional[str] = None, try: CSE.semantic.validateDescriptor(self) except ResponseException as e: - raise BAD_REQUEST(dbg = e.dbg) + raise BAD_REQUEST(e.dbg) # Perform Semantic validation process and add descriptor if findXPath(dct, 'm2m:smd/dsp') or dct is None: # only on create or when descriptor is present in the UPDATE request diff --git a/acme/resources/TS.py b/acme/resources/TS.py index f3d1484d..824119b5 100644 --- a/acme/resources/TS.py +++ b/acme/resources/TS.py @@ -230,7 +230,7 @@ def childWillBeAdded(self, childResource:Resource, originator:str) -> None: 'pi': self.ri, 'dgt': childResource.dgt}) if len(tsis) > 0: # Error if yes - raise CONFLICT(dbg = f'timeSeriesInstance with the same dgt: {childResource.dgt} already exists') + raise CONFLICT(f'timeSeriesInstance with the same dgt: {childResource.dgt} already exists') # Handle the addition of new TSI. Basically, get rid of old ones. diff --git a/acme/services/Dispatcher.py b/acme/services/Dispatcher.py index 8b3a2cd3..77634ca3 100644 --- a/acme/services/Dispatcher.py +++ b/acme/services/Dispatcher.py @@ -713,7 +713,7 @@ def createResourceFromDict(self, dct:JSON, if res.rsc != ResponseStatusCode.CREATED: _exc = exceptionFromRSC(res.rsc) # Get exception class from rsc if _exc: - raise _exc(dbg = res.request.pc.get('dbg')) # type:ignore[call-arg] + raise _exc(res.request.pc.get('dbg')) # type:ignore[call-arg] raise INTERNAL_SERVER_ERROR(f'unknown/unsupported RSC: {res.rsc}') resRi = findXPath(res.request.pc, '{*}/ri') @@ -943,7 +943,7 @@ def updateResourceFromDict(self, dct:JSON, if result.rsc != ResponseStatusCode.UPDATED: _exc = exceptionFromRSC(result.rsc) # Get exception class from rsc if _exc: - raise _exc(dbg = result.request.pc.get('dbg')) # type:ignore[call-arg] + raise _exc(result.request.pc.get('dbg')) # type:ignore[call-arg] raise INTERNAL_SERVER_ERROR(f'unknown/unsupported RSC: {result.rsc}') updatedResource = result.resource @@ -1107,7 +1107,7 @@ def deleteResource(self, id:str, originator:Optional[str] = None) -> None: resource = self.retrieveLocalResource(rID, originator = originator) if id in [ CSE.cseRi, CSE.cseRi, CSE.cseRn ]: - raise OPERATION_NOT_ALLOWED(dbg = 'DELETE operation is not allowed for CSEBase') + raise OPERATION_NOT_ALLOWED('DELETE operation is not allowed for CSEBase') # Check Permission if not CSE.security.hasAccess(originator, resource, Permission.DELETE): @@ -1128,7 +1128,7 @@ def deleteResource(self, id:str, originator:Optional[str] = None) -> None: if res.rsc != ResponseStatusCode.DELETED: _exc = exceptionFromRSC(res.rsc) # Get exception class from rsc if _exc: - raise _exc(dbg = res.request.pc.get('dbg')) # type:ignore[call-arg] + raise _exc(res.request.pc.get('dbg')) # type:ignore[call-arg] raise INTERNAL_SERVER_ERROR(f'unknown/unsupported RSC: {res.rsc}') diff --git a/acme/services/RequestManager.py b/acme/services/RequestManager.py index bda93c8d..2ed918ab 100644 --- a/acme/services/RequestManager.py +++ b/acme/services/RequestManager.py @@ -384,7 +384,7 @@ def deleteRequest(self, request:CSERequest,) -> Result: # Don't delete the CSEBase if request.id in [ CSE.cseRi, CSE.cseRi, CSE.cseRn ]: - raise OPERATION_NOT_ALLOWED(dbg = 'DELETE operation is not allowed for CSEBase') + raise OPERATION_NOT_ALLOWED('DELETE operation is not allowed for CSEBase') match request.rt: case ResponseType.blockingRequest: From 983af010862b01e7bda7a8144a0a593cc25feabd Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 5 Oct 2023 10:50:32 +0200 Subject: [PATCH 132/165] Updated UML DB schemata --- docs/images/db_schemas.png | Bin 108273 -> 122812 bytes docs/images/db_schemas.uxf | 145 +++++++++++++++++++++++-------------- 2 files changed, 90 insertions(+), 55 deletions(-) diff --git a/docs/images/db_schemas.png b/docs/images/db_schemas.png index 1bee635fc64d0d8906e032924e9357d908a84c8b..eee5b866d7555343c31327d4219bae50b8e1dc10 100644 GIT binary patch literal 122812 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

Pmc11>}+=+ayG?%yn2IVfE*Tt?bl zE-RyYjI`M8+}zyxw=1F_>s!=9Pk0kwjL&`TE`KM~4J!9<{ti>kQ8_l6=Zj}z$9-$q zgz6Er4uv^2HI-bzTDQU1XDV7F6D8g1P#Xla<15pSXqV*?tq?SqVJ-9g3ZQ)-=%;}% zVNjDjTO{vhSBKUHCZo`x#>|h1DO`=(d+mk?wy8Q0G_X}QX3c%kjCh3qW-3KGIPYnR zMc-jcyL_)XcYA-|+t2Ul^f~f?r%&6>1fW0<_whT;%$KtQ%0L)MUS2520g?;#AL3*9 zc-c*gDl4zGO@kcca5jJ^@jRt)Yjrp~Qz7}0J+}cz{5XGyEjsfzHKB^#@qk9uduPq) zaD!A_ydlQWQ|^4dh|2=KA1S*o@CzJ;S#iIS`I`AhxPh(V&`^K4))_@N3+zqJ6QfV9 z3YL!`jn>!K>lEAxYvDlwZ6*w|_VBQTpxrooNwv*afrRBudZtTXVBghb?o>PHn3c6^ zSS$?|CX>%XvP8PG*p!~Ja0Z2Gn|$?*cIbMPdJP;Mi}PgU9=hQ*XqNK6w4jNM3#*+T zGUqqm&~1Dwb%Bm%_>QX*jq~z>?WD!qo|T=bjpeTuurFif3r#F(+Pv1Ugq+U=KXr~DwKqsGR={#Q#g*t zRr}@;@9qO4lmv5H_8Z%xcy13CetxZ_>;FN+CYlbOlpGwFhW6#);2`nveM%hLYPr@b zX3G3oE6hJ2T8A(pc(qOJOu(+GppV8eVU3afct(H#0kP+WfiwD=Dvbv>81{%|g3O7x-rFyTX!7d=TMB62&U2I8aa0039Pyz*0z>9AXnsvo?L zo_8fuqVe8nV@f2?H&d)*UHvxqC9U7o+R*oxX=t<$913Os&t-03ev`Uhd^(2KsPR*h zy<`I?C8N5d;2@l(xb}J3(qVaQxwxkO?_24383%f^y^j}FK(e}d@N}&wL&0%wb#>M5 zBNo^6%uJo3EY;jI%@>^if~Wm@2s~9QXBKy3cXh;KuZIo)Tx)&r^y>wxg9U(2kR6qtl85#qTzPa8R?`l_ll zKUoys0^zLp8caL=myS0gT4my`B+md)+0*{JFY>ChgKGJKfdNvkSX7{(Ay+#srMdILE{ve3zpowNOKJCg>W@WL0r`MD#@Xgw(xI8n>6-qJaUrXdjHyh^V41|y9W{r zt8*tno-PSV?P6IR(mqlFJQJBXnA>2Vk zI{j1T5z74cw<3}V#@`7H>-&hPR z{9*B0vKj^k>!zIW@!G+?2>JqYK_`)>;rJ>~{CzcuJpW(b_J2dd$ABM-bQ7>=|J?+D zgw}DpA>kgn59UXQbi9Yh8xeh%#^m5}=;=S7e)!Ly5M$c$+TgRl4F{X*ii({;jdipn zXa#lfK;!RNq7R+~#Fn5rD&Y>U!B?+d0skIfPfkJ-9T};4eLU~}9g;XrM@OEo;;=^e z^-gWohZ=}gAo#FW8UK8Gw9wxkPzf)148?uDb#--}ot*(z8!rR~2M0fY{z?ZZ5Vy@a zwSH(aH=)IBPd_}|ZcyQhOiav|i35vKo}`fvI?9*}7(W2i=m@4g=YW=((bT#dZ}$%{uo>bz2;$5%-Es4Q~jR#4%dMnvCgC<8|Nml^xVGhasdbN)M7kr@m~WY2;81XcesGm`nvtCuXY&*sh{A^ z{rvf}Zs%Nz#&amfYDQ1UCI;JjfufvXodoqadyDQ?h-^(*A#?)t^R{3xgQ_hrC#QA+ zL3f!GmEfAa;p33?>6FX;GWJY^%5wqDdWqq4>AlbgdXVDedv4qQy^06^s* z``q5y8L3N))oSFy0@^SSPftx?`?V+8(Ats>nG1PsF|3F+ zWf5`tZ2|X{+_g3Pc~6J~go{;li#K&xZnU8hbyoBHJP1vcU>;UX zG_cqwRB0Y7w@Ek%xJXS+O-{ZG>zJSV2)bp%;qD^9t~O^zl_YELwoD@uAJUKx$F*-a zNlu`*FarhJB_qt9@Zv?& z=yd7oSRp5~3#%UH$Qt-MQtv@a!i6twZk}!YpFcemD`huH6*Fizx?9WDwcG?Br6Wcw zbW6B+*R!j?%IQbC6&b2>8b;K0vU$GHI?_{#tWp9lXNRE;MD@V(%FgS-LW8Q<*udW_GuP{G9&y%{((SDy= z+*cGJE=yn4vO%#Dc$Pp`h8*G=_vp`;wDy7T0tz^CH3T?;Sp+0HG?6c$v&5$gE`xLm z%umUewc+2@@9{VxZU8Lfw}qXD9CQZIDM?631c0N0R$b_HBT^!Ui)Y{fQ9^GBz;b)} zYt{%D*S=3p9TyTPMGw5mnAKEIN6fcM{ZohL4V08lCtSVP2*KXZEECH|*<@J|33?~H zUhy;(kmwi_jJQYS(j%tAJ%nUrRiIghj<@z^sdO+q&2~DRtpoe-g$oyWx{FWu$(6?& zDw0Y?OAhP<8OTgGk{p+pmp9)K*5rJL1NIXPOXFM?kx}eU32|{be$;UNND+?wAQ20t z$ZOjc_iTcood7v7L)U?(3UPd{x9o%cwo zMfgFGHH!f~6H^BYL?CJT6o9lAo}?BQ78^a#3DZwztI_{7mjO&qH{JSOc0;eqF{5+* z(J8!iwMP-1>i6%bnk3nLEpH1S@i^M}`Lkhfr3Mn>lc!IgK6}=|s=f@!Is6S6Z_=aH zw7&^Xc&Dv4Ip3f^)2`mZOqh~ka6c=tN*L0szCtk}y*l}F{CK}e%|B)Fb%zw+Djh^0 z{Cenk35U2%L54jHhsolWWTYoF1p0IY$967+iSd+kObnovJI^ZHs=)J5bQ}_9kRy20 zTkN{|vVw%SR?H_GpB(M~5FZOkjbo!DkY5dtp~#|nfz&jmb3NfmO`}J(w#w=W@#qu> zg-o?8^fZBlMbVJhyP0aV)!Es(v$F%tA(i@z#m={e?HbOIcXqSRm-O*1HPQCy;I)6JHpY$>A z=16mzzRsu%I1HpYcJIyt-xz)cy6Rfc?b}{9v&2I)yKBUIS-@J=Xf)2yUhDqX-^DU< zB;V7aW{Gt!_ooY!Qv9?5P3ZzOLAQLI%)RSaRE`c-{c3!WkYRPCF*${aacIQKHsODj zj_&4DKHnk68;v*t&+w_ETL^_hgjAWFAT(~)3Vg` z^h$CaNn&2|v;>y3?KlBYC3Il}!!iGanXy1#k!IA`om1JdKMFU81 z9|`O&298UW@D01Nl+;JyMiOnyp*=5n@x;Vud#OGv15 z=kZ5~MKlQ7Hw{WQA&BC~DX~(fR8H`x>2^gi63ClJ5~W8w02`Y$PWPq%w_ltr_L@U# z^}8S~F58%~$O|}$^+J5DPU)9m!a8S8Uju2rH29uQYRTWu3fvv$r*x%dZ1Pg7*R?}s&%5TuW!$d z<#>lAa^MrXptqD;58vO-O-?q_)FiUP{j0>jp%C`m%(h;J3~HtY1riD>(#(B4u!PS3 z#*G``waWwS3(D};>cnd7uIKg&B>};=#l^31-;iMyV>2_eyLaz`N!8TK54=lQcr(+} z*=pdn9Vqzxl9-Lci{;;v21lGG2BteT;BU1pTjqE7}i!o+695P-uqW^>5m^j_V)G!%|Fxd z@o5@TUcLJcn6(T8LpTkwybnUGm;ZM8@bi06kdnf1O&LJZYg;^?n{!~c!zbrsW@XL2 z_u(meaj?M?om&c?ExpX2V0ipv1|rPlZU^CWI%92Z4SJTjxjD5onRPRN3HMdZD@Hd@ zV6ZOD!|rM7-Y&NevvG&`-p~z?6$MSFVtoPFY{~}FzSlgv-yY)&pOS2g8>@EF2)zg> zY!sx5TnT0@@FQlgvSx^}u_>Hm#4Q5VTc+>iKO_NBNl;J_(1KFm@eT&?7A$;y>qRNWeQVDIfQ=;g0K6qV^+4XE`TnQhWOcOaL&B*)Z9Gr} zO)~1*;N=EST@1Tkcyu&ll|uO>6jYC#-+qd6@-Jx;u?Y!mSeH2v`|cWI-#yl$(ni2J zM*rAmth)cwdTSURWP0EI^_Gl-nHZzl1I#}Z10jz8eYzJ40wa+5(R|Qn-~>RjifjQQ z^(y5$s81UUkQJ6rHiD-SqM&x8oo!(rAfw4BSamD>KVJob1IW)iweDdRXaC_>UOtm5 z`=9PsoPcm*h9-!Re_XO5F&G>m(!rgBa0V$UDk`h_eD#V-X%II*)O>DAn&?)UoT(cXm1swt+ymd1JaU&vaVy)>v6ppGjy|Wx+q>_%L zK4c+LT^Nk7#9TT0Mojq}o1s9&{|HnW} z4r+PdDTOEWbpZczTL|T6`e9fO&BxBJNSIS9O9}@Qd^X?VeEg18s9iB@eojw1N>`38_w(9xspA-PZr~T1ew# z9RB(9A%THYG(r71Gtl~Nv_UgkI_&Fba~BwrvuDpj=SXB%S`5K%dNdE`+R~2=nN)-e z1PGV#hj;=%`Vzbb;}NjQKHlEb?_u620UW<-s!w0T2oSj$+yr1v7bhD0zJzMRSR;sl z*Eltib_6_MpobNav*p3DhUjXIzFTrIZtdPw<_~ig@>}$Tj_zqu*L`*ogQN%>s;NR1 znxK)1>uc|@im{`y;bs7WKiL@0fuPde{{D7N8t>ittp18efOW%882TN!v`8Rc19E=L z&4vC&(3UB>i3{O~v?HaP7nOP+(j}r%8+k4BCoFG|J-tQ4#}DKUq#SaU;DhD=bg`D7 zz?`fDoAJXlff93K)fxWS- z?L&y|ComN1U}Pr%j$akh_D`S6dG`}V+@L=OUVU|HsuwJV(90|QPVH|fw%R-qSL$(Q zuQZ6zMmd0$7p+J51Kd75C{N zYEV#3zz*E@tn?q>sK(kNSU15CQ&?yRCOu#_rd>bqj=ei`<_uU3jfXw0`g0k2fr60ZArLS;`^GaLcuZ#^mM)CHjP#}WBy3MvOoqA~LG_b=Oaxtz0_ zZMUJ(R59)9=mmK7C)d>u`me!siQ`GSTLB9gGY8ufg|{mTC^QY~Jo{&B+fpWBK81OG zD!*bpd@2E0z{^|=(3mwf!Qr=o5yWk@8QrtKg+)3PDs2bT`z@D7t4b8*&ubaqzpr0m%h9r%>+|vX1(4ig(p=}ILvEPH z!k}$Pw=dZhYO!CHAVBg%YV`<&@sSGfz=CO^{WM)dv?mLUeNId?+<1H%Fdz@iE?^B5 z7%U#|7(I(t!5xqPaGs7Z>DsuQmg5K4fvcfm(&5xMbcnQia_7(=&uR_Nuuq$^VlM!% zY}4Ns)hcBf!AGyPRx_=1|M8;|$^Rjcx0wk9Ec?s>p#ksU4GG@#Au>FKMZ|FR>Kgd8 z%Yf}95)vpTmk2?vZ2#p2E#j@E2x`BOGR0kS!)Z3jSE;%{M(A}^^Re(eLEctrxup*y zOt+>jV<$g6yNJ_q-eY2Bc?dY3F4qdcEwpr?Ro@hgx<;?!2KM5!clS>ZEwiL4Uye1o zLiYxS*V-!;VmSM)UB-PVM91FBHuQM?d;F$}6$WrgKHAXm9bAnsxYu=O_-p_u+L2j3 zEPwLnc$x(BGsb_JHrv(^(1urB~% zA3nrz5hFyn9JD&*G00j!4ug*FW_6H4|fVTCyvi?_5B^vyx-i<#{QeXE$ls@zmAT75$jZ`fcSML!?^% znY?KZcL3J14+R34C76QdpTnTA%jvXr&F?P*xC*qTxWZ8z146 zC3(z}q1{(Tt3c7-{9s=F6h_^z6UfUTcBDjwGZ&x;RyJ7J{_+GTFC5lfJgu>*@j}qx zyqZCBZ97ZPz%cy!-a`r9daTK@iGt5Gx5N#x8zE{b*)#!2048aB3`c4-D$b$Y48X@) z`X6`XbUzefcTyaFYHTi7_8!CkqA4TE17~TWkc~>^XuFPTA#-r zn&XVZ9ZAQzjI9c=%)AHoe%$+~lE;4YWjoCP#1bZ*RW*YWHb{Sd>b76E9%|fFT(``! zA7TmmnQLQbV-jc{OXwONpRRR3hsVNco$Hb~U{;Q#i?yr&B_nRPTKiKO7+8a^rr6(| zv0LGa#$h8Jnke-YT%X2#xFKX2IcZu!K|}<&i?Kxqm?A)68Xj_|ulep*`YKs!r%|Ax z@fd6+-cWaR+!$-5mY2Jm4MtrUFJz#Q-vbo3vlLTs<>u4YuPFshpf2CMA8I1$ahu_5 zKtRAXgFO>~7vWF(GQWdK!Ts08USuyjIyzt?GQu)!>Vyl2QkV7*G!j?GDm0tYfwD?# zV=h_ILX+i<6_O;?k$<})W14c^jyLc-xYr%^KZMiwA8Nxua29|dxF4mzh`AwgJ$FRu zf(Lv1HRUz?yFi$@!yI0osz1T@JLqC&?0=0`Ic4x;EwH^&x&BK^;305*Yax-Tz`-e8 z2XX?$;|d7p2%yxei0qY>KSAadYmdt`!HN!f>HA^x{?WD3V@cV7q<5W=KOT-R?Rw{@ zs&&_aqWL>&Akn@%GWG1xahbXc4irca(Bh786X=hP_bu2K?RtH(Trp>V43G;2rZ`I? zI>q>q)Q0)od5$}EHKZKK7;P#u%#0CHQ5`~y0CyS&YSMw9$cyNSN=QhEjh*wfQ$e{x zGkQhJ!`QQ9@C4Rr%|b&Tm?nhtqOKDnfSJ<2tdzfU8rAS4ub(n_M)!uHWp8G%)HQy@ zcLX}VUA3hrp3druG#)a*%lvYBdKw5&n3dew+uMeRitIEI0Rr~#nRY+md||JZs|k~C zZhgHChv-5Xn!eF-O+P{1127=z{PF@vsv+=$6={`q|8Wfn6HFu-`9q@8@5pzUCm8h- z8UMivi2s_fuzLD-s{iL@B^@QZ$%U~x^AmsT&Z*!og;RT@Tycuj6CSDY=cncWAUqdf z3#)d))8RT^b%z^ej_TrePkSyb6+p4K-z)yfl1h`X-#ppC7MuY&>q{ z5LxNHUj>g5X$t|Q2UmeFjssTvf=JIqP*$nCn9k^#?9u0dbso(*qnMPue(iidM#|EJ zjzbtkLa9H|XsxU}zeoUDYP(f>eDlZd>t}#s-_zTR3##zwcAo7oatez5y@{jioSe3R z5RcGCM>FnfWGL7-co2ZtgR${TBm4yV!KqdAxmq-vD~6>=SE9>aTSKPO^qOsy7d#l2 zBc(Zux8CK}je5`F1Z0A}TO0B5=A1!T@0h6!xZk&^Jkr6|C*81&jhq;DGnWpXVT;_$ z($Gfhjg@YKZ$u|e7h-mK?5@G8(Bf9}XUHp>IwxI3KEx*!jF(kDLAh|<#)#((U=7ML<`uGElq8=Y96q{lLE4U8UMF@1G!oXB*=7Z135Zb zqqpC2aLk}44>QFxw}ATsHMI(WgH%z3K!S!>(TFvnLJ;j-Ab&8>iQ!))wfJUsg-4r- zzx8d7=*s7m$7i{)<>2`8st*9P=r^zj3!SKO^I2nc?TWg#|2kMbQnO;)JatNFNb3kM z;sbx7PT6fbq z>AGD-nRs$OJnjEs*K|>lJ;2q=goiRj*Q~6JWZ(xNqr{rD%y4fJ)h7CjaCTKq(Cm=r z`G??t@!hV7pqzf#W^qU8E`Nv;e??u`V^K06s7KAuLk90MH%IW66c_80I@S({x}l_b zbuzbCxhoHeMi&gF96xg18vhp@O)MZNu;bdM7GAYgnc0>5(h2Lsb&yK%j?vy&mIoP>p7g&%{0g^;Z7{bybj$F}ep~l|H4VrSYTGehQW8bJ#vmq{d-*qn)eS_9^4(UmNDUfSd|PMY>y( zU}A)+ed~d|xLz~|{aauGTT_Ze1vpn8cv^c7?v@KWuF*e(Ny6iikq@I_mlGPt=-M0r;@F_XE)!qVF+DqrJc7V5_xA`!kjRWPxGbTIbJWfB z)iL%Ih%6PMO0cx`Dgn`spJft3G-}Uk%mk;_LxkloI?C#NJiZo)iWMpo7p_zwQxJ5y z?(+{=dTx@TAaK`1q>uRFQmM`>{6x2kES}$|I`Gdx$Rto)L5q*{^JSD)V0Wcv4`{KNkH6+H{_;~&`xgPIf(L7C!ILo7*Tb-3tk@a` zvwBSp;amQm@%}t~Jma0aWPK8@8D_l0rx;RJdQUmPBR61J16&LV%QIRqIReuF{rE9H zcsX8U*#VZ0Y7Vy5w5R@lSj&n#DS0oycg5OyQKWR2_5n}^bP%%r>%Tmi#kPVL zcMYQOJPytp8Xbj8CG0FKZoDgzoz%X^tO9eR!%UCh0R>VoHs+?u3solHA7p4MTRGOa zM0H(!|I(pAkx7eN-1(&BkK=J_q4el?X(6#NBy|9^W4l`Y0o!#s3eUCo*bMu%=z-gg z@5aDBKa~NUO=V>z_*0eQcru=^zRs%nXi#Pub(Z3I!hwD-25x4~YsQlFdLnQSSW8}M zLE+!#fNszkna*ICXJBN^1g(aUM{Do-1-e|FLQXw3&oC49o{EoAZW`k^_LjFMZoTmS z=b-eF_@Hx`VB+$1owroF?*A*u34>@-8#vUPmiLBM@X^9-1+TaVry66hC{) zC)pHm((-fx02{%DIaNc`=3wNxy%Jq z<8#1Qt^x}L5(V%h8Lq7pzI*vZdM#t|kuTtMMXh%=_x^NV(SLo%*u^Y(mWZm0<%;%? zskv_2FRvpWdA*s-;cD~%wr8%NsAn#r^n|;j<{luh9A>xiSIlcVo zJ!fiZnoUN6_Q{Q#_+ov)BS6`d=ZQ4P622e(k>T0u0v_MqjYgP)fHTHGjw2QoAFmHu zBh)u{AVMH_h6ujz!J@?b}`S4ro!V59QuQVgnbEE5^xLAcd9E`FM$Bx#FuI zwU*gM7_VR}Yh6(+c?!-ut`CTB=^hv>{(?5W{1agP_Ve@0qTZW-(5d_f;JjzYLTJk4QxXDKV$7ak*#*=IEDlzR^z zg0lGz3m>M}3WDqel9IBV2ER{}qj;_Ovey0=Z|@yXb>GL2w>wCVz2(SCA}X2Du_L1p zLJ<)vl+3h_c}U65){v1=O3{)L5-QSCwzRB7iSP46UE{v*@9*(@{Qme|f8F=hMdy4z z@9}y)*UQC-p;&o^?lG?LzQ^Kk7HX>(=#OTuQ!9S-nZe=G>)cjeV~f{CAs?>Z|GZ?M zw|{bpcrXGb|s6y@Bb99DDq^6uU}0Z0COU%mK6(*j5I8|PhQBek;CIqMu< z`~w8iYl26X0|Lj;4?gC~`!(+&ev*^#hQ*BQh^6Fk-Kl}=gB#BF z8q*ADMt^J$0u5U+hMbN1?lWh|i0=3%TZHg7vs^QMMqS=_?y73WwYi6_Q?2%xXMDR% zOH7OI9+zgj>8z01!|`I@R*MzN&)Dg2gr<`Y-jjBfw z+L+fH^hTGJL{}%LsrXzC`t_qE{f=jMNUHEK%ocHng&#LG1l&2JI&|1(6vBqbuKC@9a!gP7IWj{B zpqT>ua62ywhOj%H94;wWJV7uk-S?mQ_5JJg5zbKfKN`B#A+Bpu`);Gu(P6VN^JCJ6 zS2JERn%uf)09?fQ%Svh(0ldyd=QdZs61``^sv=>CI?Oue-S`Un6aNR;QIe z`_3O68y$b5@)hYGFyAB5#%JaVz$v@J`Q6DI<RZgQGR=Rupw~50* z{yt0`-x{*4$P84}TN@k_D`Hbp@~7>efyBcN39inhGV{8@QK|6)t8sDpqux(XrXG>) zs71|Tb?MUd9VyXoR~A3d@2sd$efjYUQTsmE-W+v@IH+cwBq*KNStRUT%?9IVY*6bW z@ByuU!WQespBl(2-LV?fT3+hM^pdF|$PdC4p`%9`313C>D~><7_G0Q4Zr6h=OLc6M ztnOU6n%)BF8JGJjCEm-={f?%c);-bujYH_yD$1q)?mr9mAJ4P3?cz&|4bG~BM)=5L zA2n6At#j*jpGCvbn^>=2xAq^xTTVCwKK$0xy79LxqbtU(ewN4te7@7B5>RjleF2=cl1rB^eN$4Ei(E{kbTPWU^Z!_| z#B_hY;N|QMWjY;67J`*4dGoS125zNJ^- zK_z99+m@g{C~I2DcsMs++R9-{XX)62pC}<<-jhq;k7ZJ$l65|Kh}dMC&G_AFKe#Yo zZQ--Z-o1&YD{rCgrh3{j!SzET$F_iWPs?*XS$Sihb&iSY`1-5GMhu%dwWwqr#2HvIPuG+b{u#t_>uc_>7G`Fm zqM~SW2bUd;B^XLv{;OB7W><$A?sz)f1A=)n_!wzEv!7;UhP>;wS%>n)6~@L*p#Og) zU4i8mVF!*|oqe+};Izy63|{u?gdgsNsg#Weg+8kO^QVg@oSrVJMbiab0hMLWx(ZJw zdl1uA?u!^H_z!QKq7jd=w1a#V0euLhgA3vdqJR*C03u6!0t%D9C#m#%0~strs_h{X zUNszCT;p;`HoV~cGdDLE7ZlFv+ht{kk%IseP5*?`PKi!W%h zVHe8X9exi>M1RtXC>O|VYR~&$a`kwc7WUl|u5o52(x4a&jg5&?Z{NNv6Mn#~-6S%1 zCqQ68H)66^X`cutD?bN=^QZ}|$W8Z`@(xub)=iF&$XZcpcoV8W-jIuus~kQ{ctT`V z?5U--Nah228Hn^c_rZUN(!Vknu<%8mwLvq57IsCvuhuy`_oJ-msm+S3g(*omjO-y(cyTjjj^P}@s}NefoHp-Mxk zA9piU|88%t`A8XefS+IKzGr(82!I2Hg_)W2-D}gWJdOtexE!n)Uea~v*v#E8u3}Tde?7s6y?@qra97sFJ1@z4d90zM~fs>DsG({8J)RrYo+QqXhn6%uKsr@s;$5 zbov;~!>OG=npTbkzqtc#R+qD%pC9(4z1s*|h|4OjEq**fAS#<&)WMA;|%tidKA$kmxmia z2=_g2@{{S>cOsk&<>9?oLlocPrlX9M*P9tL4MlBiY`%2dag(7zeB&?)o2o^feq-I^ z$A`VV2AeYcncQnOQ8)dXc706n7-vE+{DO1;IY_{kyu) zmz0uTsNGZ8?fa04$u&K8S)Itk@GmhYAr;ZSbi5r_ zGOstWxbj34PQBiJt-amy|KY(DZqPsRoOwffzr$zk9R+gxwPa{uoqv2QJSND=`K^mC zOYP^N#2Q91Ff(fh+mw00X^}Yb^;#OEK5jHE5*EMhYpO3oBH!`dKUw;d$^fQ4)~zrj zV_x`)%O9HP=F~8!u%%yCiU@oUe#K68u|%DVw80DO-sFx-9zIYV!TH00M-HblY4t(~ zDYQE}t?Q#o0OdQtebhMnXtKs4XI@L_CyLPKF_BfZNiC-y$p2X3u;$BGu3(d6#6q8_ zV$2M5pG1gYE~yE%+HPq=lM-oG*kq~VshRg7)>uMrCc2=4W-j4kDKI3PE<|;WBNjtv z);gt=^82f@$b>4(WYd_(Rl7U6q|CPGJUrqzkY;qr5-85W5BXEj^X$5iymk*o>ja%~C5?RP z6*P-zNjaWCM^SO-&Kkx{)wz4ZVkh6uw6wI->ClN)dTD6fhuEW7nDM*U8sy%*H%nK53U%Pe)g$E^+1QOue-Z+ssc@n|EB2sZLPJ@Mn@)QpMoN%EFyD|Z=M3|m`rhP~(g zdx8F>D{7zQo2o-l-@XddrsONAE+mYKoW#?6u-Ee9AFM6ETo~*2- z3N*i3FFcMoj%3TL7M0iU@70SNUe}rY``V-LX=srP{&oij8z>yv*LCsbkYA~KK9r)Y zPk%;{wQptM<1&u;j}JX;hj*YU9N45q;-rzcT&f8R4Xw}n6q``*v4jy9NhkXjB!j^o z0cOkN`v>-#9`N~?xlC4dZXks_v~pvUT1Yt7?wBv%n~VDo6(!*FBVzkz$SLAS6;a=FfWeYY# z6HHeH*Qjial;9#ytTaR<6V}}VmeJjO7A$-@UzMqTVsY!zJc7MeUD2-rkDu_3V zdeBPQl-e~gpoo}b=`h*fkAgy>p}dH)AT1WyIn85W>2`97@s?ki3VpYpH(qC5Bf5PL zPU!mjdf$ZID!8I)*H)d2-FAMF9ko8#b)b(Z@fm$ z!ZZ`oOY$o!)Qga{PNbxo`}+Iq=<2>`GDPKz6N%!c{P1H&*`U2NV;3_Nh`&mGkBt$l z{~SFSAm6Kr{l3i0$3ow{eOoQP*k>Wi0Ea%75y?$=Iml$>Q#bycN96M2+uu0FQ7OSO z+9a^yu+XN!f$r{g$i7MK{8m&pIB_A7)QKaSOxo%{J=x!R5q~f9?M;w`UnWXKV+swj z|65Ri=;+j}Yf=R$z|y*pg~m(?K0~IGh>FGA%-mdCM`tKqFD8P!Rz4!L5ncU<9!5KL$#gA-k^j_!DElTybgamv`@WRm{u93!PpP zL8tdJEP=>>;WEj0{G2Ymy#QO#QI8A_4dA+l)8k0Kd2Kt6Ua!MH#`dLlmRj3j>DgU( zIopmn?F<0aF!I!N5$R6{4<7XK`3@ZkghG$@oE6x*;fNy!1Jt~D!A&7;3_5>HaTN|< z!ntsFxuY~~H@4Qql?pk-k1jE;yR58k!8E%FWr+aqplceKa++;M6|hZ+@5Rx-Z%_9y+_D$bVmVv{vy=spq>cA#B|2Skh&+}v#P#;&(m@AM@;71hR;7Ec`OW%pS4 z6_(W>M!I;hTGm5-t1=IYm&6j|WTws)(?d(AXhq@a*1G4+0xVY?Ns@PBd=-uE2PdQT zT%xv{sD7DG{$11e^$$aqMYIZ)xNfFEBVy%M*VGKE=1;CCmmAyV zd`=Q)_A%1^+<2&X&*HrP(!AS2#!e0@6OIg&+m!>+dqSeAL`=~j|+`D$Ydtn!_)qNd^2y0HNk(=YtTm->)NDEv6r5M46iGPakGK`{yZ; zhL35U(~#@w1lPf>e(l<|dp`zOpU-yjSht05Amy}mYQIA*<2dbw4E8N`^`pm+wbY5> zIiP1ju`2lKuT)Nzk&(fu11yD~yJ8ZXprp7{QDNp>_)?rEUQB1!U|-nvRtmOKOK-Ha(g)#lnUE4aupfgV%&7sl3*lg|-`W(Y;d`_uB%HsmT1567ekSWB5wA^DZQiuy-K!+=5mnc?3#EsYuWu7es>raWta&0(%v}DM=pn9I zj`VTnFLzC-IBCU6-(O1qEF9c?Afr6|rvVUB))rvFhO|rwKz@`@c%NH)t20r;Qij&& z2er?RG$!+zk9GEI7&3j8A|?aZ77Qs%%5=*=*#CB73cd!$s3h|lB`|TSgg`NDI9*96 z>}~@pNV<9b(RB!L4SXai6Ax7r*eks^zr=K(Ny&JwzZj!&M?g@JTh`8fb{QDQ2~n!X zYqA{~h^~OSg|FJy&hB*DOJrG9?H!o0UnkdN=tex6SK<7*)gr6dkC*qN_cIm7(?E}S zFEq3k*oWvZ?>Bk2wzhGZEBe;N4!TUamhKadsbd;3{n7Sh{n>s~PX4~bE*H1tExcqt z`*@<}s%ktQanAe$|9(8vkI!#xLd;N)&dYM2gXN@7U~$LX<}qzBwA{RTzDV%pD|VfI z3^y!D#c!b02VN9D1W2?kjZD?P?eq1(w3E{8lUgqd*qE~cIu(UY_p&4-XS^?GaMF8R z`ihz6?<{r*o*b^ersBAD>bd`wWyu;-Z{XgYaD$*XcC-_jd_AOg&vZ_^6KAsy>a%i@3J#nBdEUXV6kgg$OX&m^;I-% zjm$6)?l@8>9F5Uud^-Ge^BQE57G}M=bbiL|X_w?tH|OL>qd5`G#L~Igy6@}`y`QJO z^CwlgU&|=d#SU(JFqm<~>M@G)U2V!Gs*=oPWp5;<4&CPq^S-j?K+`{E5xOJNiOd>T` zaQ*$l^eZBe&oG=tZ0C7aeNX+73AFSM-K;Bhgs(a-bg`w8??OcbxhBPWi}cJFpL=`u z+z&ZJb>Z&GE_?Ru+3+YJC`&N&JW5^{NC}bY2L<#ZwDSzLy1#+i3kV3He-AoAC~!q+ zE~%^zBMK_reH!vhwVV^){_ls*Xy4d>tNloem9*bEMvJAT4GwnsQPm-@s*m6=UD3>| z?WE1ImTcn-k9v)p^OKYh{r%1*Ov=q=-z2@cf9`d3w2ag{>^lEyPO^kM2r#6dSq3u$ zeH1bFIDB|ab~+W5N8RW)YwL)QFJHdAg>`vGla{V@sOC$sI@Cx4yDFaQ82i5E;O1DZ zsK~SP_U+qGlGfGsObMy_l7EOsur@{y&#W|`EfB^bU0{95ebelCS?p)iLsg`pHAVe+CrS! z8)VD=I>xnGlE!>dFgChNhou}fGiq1Pgv^nXzI79mH7AHAy(sO}>wUOJHz7VhKVLeL zr44+k9r6!mJh4^ zMj0($&pzge>b~EPxxS&n^yonhs(5EKzQ;Jx_i-uQ4k;&5iog8+9w%h52bQIUv#%gG zx9WwgEUMdnPtRf0uHRPYO}X^#?nc^2mBS`(Rb-Im=ReM^$PwCVXG3ZtaP)(DvwK)H z8kOxwqQZ~h7kq}3@po&pUFVm6O%p2pHRNqlf=13U073RH}M9)&2|*Ab-MZ0GNU%`Wdmce z6@H8JKEHC3leMVcHah!N^*$N?-2yp*-SIZDMj3wi2z*vKBuxe|-Y3M${%m@WuGJ!iOjEadAp2D#Ve5T!r9~&Ji#c zOHLB;oQqKYaTR}hb8CA?QoP|p)6@!K^?MeL>x&b0&D|59Wybjw;H+j`m89grc_f8^ zcrIV=Xh&x!uTQPPI1j) zvF`ql5@^@XaMN9rGy>g7BW43MI{iN{cqkA!wp2@p<~zd)+%dr z39MUx#XC)lDh80(y&|^c{X~ z7kvGvYC?@!`6tT; zr-kb(OhFxdI*@@jCTIN1jL7QM^zWNMSfK9(W^7T^&26wkg?YU!00V#>NpQhG8PaKj zjHZ2NUs{4m+QT;NH%It-RG=pc&vu)6nXhOKUcTMbV*EbC9hQard~ zW@2f4)UKVXC^DfTdMU62uH_X(Yo_dj%wVD2yHVO~dH;tSfmxdmQ1QrGlb8fhBCrla zt$^5LQH{Xsx?X0XAR%q%0#KUlosPrd|_!&^z4!8d5mAL4O z<2U{3mV5+E-jKx@wsTVTgHz<4>LGIzVq-hm4Y-gnBGvfCR?WS&;z#WAMxmC~>?-+n zxec2;ab=W+AN}?1MO2}N&wq7c7*xTfBjam1rePSZlCLnD6uo-E663{C zIU~m58s<-67FcLV$d}hQjnmeeQ)12Z6xS#Obp~@o)=Ug~6c~g6pZk2>9tyFTM8_}6 zMJb6`dswmu4;Akk;i$Z=l7PMH>Em-R;JZxx%t*pLq#dAocr9RHZ=abyU)r*C<@$$O zBvz5T#n&)H>cX3e<9&V);@hX=cG$71!-0dwoM8{E`X4F-OHS4ER=v-O$@cHtGxgRi zcbT#=5Rze~o}<;RTdaR_yQb{LfLXod!AWlm~(S|vZr!u={mE}fy?6|8_eCJ zwV&qtvR%Kr{k3btV6RDm+SM)3_k(nV9+9H!PcxC%Y6wv-^#2-DCBnP-lJ!K4K^nC^ zhi0O>xC6XU{LIID(;s)-`_a1R*4IQPdyIgv+ytv&xO`H-wl9;5!~MEChK4eg-JE>d z($Ju7bx(?o6_wdhC)1UCJczWWnCX!zR4&sV=mtd%(^zf#Ngw{=NvbcKVJC*~2Z~$$ zFYZHnj{ESR7|Qg_P_?(F=9uGf+p?J=@@ArW-avRTnLw|sm18yBB2PK)rrx>O68Y@P z)znO7E%nlk`T9?vHUGzVX1vMt{4)yV5TMKp)u@uZSx);?U)=W5cQ`vXY{s<9t8P1q zKaRY>Mnd+^7#kmp`%qb5?0Ke4i=*a^#2h4Y37(h0%BmUNS?c0gcz;xxjaYe@TL~`Z z@%mIA?RhJn@ygu0Ir}Y-ucQB@qO~c0iPk2Uc~@d}$+q`py6>+R`wlydR-)Xw9!}2X zYCt3aP5?(X7dd-t**MdJGiBXNmn@Nbech#HfsdbBF+jiznEQ+AJOy}7HLP0+y&`iZJ5YchCiQa=J?2xIkWLUkKK zFt%BS$aJb|YHn_Zl`WzdBQDO9GF^+qwyA-OGG}R4W<#EHASCTPIC0Kv>F8v&VZ2hOJO<+&Z=e2r^IZ+|f@w0k z2)fNJEQWSd(jhM|=%r(S23LEy1)HRug|-A%!mcg{4vsDPU_ZLOW^ClXQ-C@_eq`@$H2HyuIAYGyobq;S0@fziuzUK zd5?a)9#Y!S z=JUkssrHTYef$e>%Ja31a*tUu1_xelQda)KJ!6CyNM7#Ei|h{(BU00&M`!wb5gb#s zMq`SdbU!fG7KgJYl?w0+hc}B-mGzrdJ>`#6l%6#;HOW+;mLwPYE-I9;Iqh00%6md^ zb1ONlgpowjGcaK9g!-qJ+06axhYwelDRX~63pFRLe6BYr8xpG~LiVawFCXab6$PWDpyVGh`xPEpH9;U+ZeqGZ(C@*8#+SDp>Bu z<`Sh{vh|pt=79IunM2Le| zmeI}4%rNg3XgdAoWuBEnqTt8)v_Z|UQ7eC*0!cPi3k62rx(bC6J3G7OSMEZTI4MJV zjOu)ayUG5b>V_&PcN?ZwsoPz zY5z*O(0lH*7he@vw^ozUekCi_ES3-&)^z{mUxEJ4B@}5SxYFNYRLLWqRBhXB+fEu7 z`5lX`_Q*0v7+pyqvEp3#M=}@X{{2aeN^L_2$-kaID7xhOxmb^Dby_Yt*Y2{ybD4sM zOzQv}PMP(?`b#X^P@#N_ym4+>OG)OFypD3wVm+M`bxgbDAD^MN^UypVouPoOv%TSN zweoIxCbiqk+tc&GMR`=(z3Orogzy1cl6)~?#A?%QA;^FaA-+dIdby#nMcmLH-piW# zSu&v;%wH>W)!C55UR*iqme5v~3-?D!iF~<0y@FY-v%W*I!Q6q5Vik-ijg-kOxYeRM zd6cL213Awn>|wHRYKb5eBE%nq%j8sC`@qg4WqGIf7D<{vKcOhnIziPVp?r8GK@_d+ zSXm4dTG2m#Ccf{{mU;7z&(qi1Y-S940&KQbX#+GKkYpU|tXwR8`SN9noD)y)zXJCJ zA8LjkmfBR=U0-|6CHmQ!Sy>B8<&bgeTqcEzg;zl#D2n{iQSFvd)K_iyrK|q@rTZVu zR@bj`ZHbu;f%(#kitTST0L7CBre%SA0Qo7fPAF_Aa3~>`dZ$M<=l8 zOTbEuuj)?NpAMt4W)%(N2K-Q(;+z0!g3AS#TUIA?{4*;VDm;IFCww5wS8U49xBibR zyR1Y?QPKDK39Oq{C-5Ci%JDEZVB@Y`yB5htG-4gYlQBml>;ws+O_`eh`E{7Rc<1>> z!1|k(0FsqHkaGU~$FE;yH|4$kqi~8@2FnAcxqiiXLp(jPnC}g{rU}Bm%iv9nL)G6h zsSFQj@}1Lu&&hb1fWWTe1UbL{V;cPvNP)`8QedX7GT{TEuIhq*YA zS0Am16~^D!*ANaU@D)(9n=Hqr5EC2Q^XXH?-bOw#vY@m8qu^Z-C%;LA!zVR0H34>l zTjSy43Sk~orD)wO#5V?D)d}0_H)nQR*Wrj)=)SDp@YrhhH~=AU4??NGYDuhdK;GUV zLTsdf_>yB_wu*;30=`mGQT8QpBdA0~0rQjejExatp-HifJc{5ypy))0hrb=$Cwlww z&tk;}Ph^XkW7jxp9xIa~#{dF-pyP4GLm_VH>CD!kPff`iWIWsV@ zmk=X4TmwT1e|Ofsi-#)>l}p5T@9GZXx$U-Pm(qp}oIfE;TKLum@#w@f%^}PkHi#x? zm4`nOTYyoH@(3JO8!gRcHYWXAwtKU4-6P$BBu5j;ml&K&Jt z)mq9oe0ljX(w0b=b3X)NwxC_CKcRie6HkGAZa`Eo4?GGUOJQNQsCn$TLb) zy+TvZwkc+O#xCtxZdg-vL zsYBkt1xuM!CB)(vbxFbbL>$+^9b)Dfm}$kJ{jrgy)UX8)s6cUn+7y#HIa>jb`pH4B zD{=t~$mAp{*xXzxdNF3IR!ogFbaZrdQI-%x+pjw$r0jCw9}>8z`i+)%(rtd+e72Ll zu-Dz(_e`hwF0|4RzI7VD98zDP#+|C?if)CZj&8*$y7hLC7}aIJbgs8~&|<3!?j)Pb z8*XtI|2eMPU4n5`m`uKe(0|ON*aM36s`A;2KbFCFfCf>4ftO`>kfpvp!}5z$6+T~u z9QR^T!v3q@Bdu3a2$N1I;mlz^Q5o&tA#N?uBD#Ndx(`uC1ChsE zoeSgwzWBNj#w(*)=Ok0KDQ0(iqN2sq0;T4@U~3nr=)M2(1&gply5?8I_EZ(Ca53*X zX66)h*ke|PHzQ^Fm#!{(#Pto2;v5QFJSZ}~Q~gifLyd^-B|-x}^wr;RwP}%ht6i4WKfnlwB!P>3DxX+SV0(2rLxLtmF;lx&wPZIkn0$7m^;s?3u~`1p zwIPg5`n=%`=AB`Zl-yAcX`3>#8)jWO?+J_sO85Npm<92(j)jwV@-F%FSYQNb)6+}zz0VL7Px9Tvn{d>VKH zcoRZD)bh?)@mS7iyFX5`b3H62w_T`s!&Wi&)BvN}Oj1_V$nU;?9NPeQ z6DG?@eeE|ZV1uG)oU;;AR*iO#UzaXjLSKZujx&ANlnTTxc?zl-Ov{#WP;gIzWM_Y!9V%gW zDSA`ytlVi+d`1y^z%iyM&7g02yTS`w=`3y8=W`^^cfI7(i2T9i3`A_mSahC>9s0PK z%{>Dchh0ex3=G8Ynmykq5o1PYwcR;3%^D`TV8H@nOib|(emg@#L8M#)z=gH&*_kRC zrF&IU9&DyJy4I}nuPo)kH1sn^f{_Kl|Kc%@#jKdHS6#imPE#j&TczwjYcIXXaWI2Z zfnK^LI5xw(E@4*0)GZ9K3F#E3r}t_~TYH;(E#4<)nD_7dv4zndNyq3>A%o5Nak~K} z0GdMdYTd=jH;CB!H*ai4x%Xo13o42a>QAuR&sZORKlZ(Hg^G2xB?!VV@>_IfGCdY? zWRD!r3F%K5=p1a#!y$(bu9esBkcqiRy{p~BQW@o++bk`^`0Vn&i>P#jee#vfnyVH~ zNc`dC61xo+zCoXsa2`+pDCg zD7nJiO>$1&vhj;5n_D=Yjz+ffLmO{LnSJSHa>?9haa3_h!oFI$l0eW_UQIEk3f28n zf!^}a)tKU_Ia?LC7rov*)~^#S^cnwr`2zOE7bP!&b3(6ia;`cP=){>P5;*?pB?xA= z282mK8QJjoF^9VIzbl_=%~N<6Fy9Tg^g|CZ!7mB_Uc-p2wDhMv8Te}bGP6n)1o0U# zm^EgoMx$oLq$p_5AZ^jRkNPt%8xu{45>QA;2&YkWLz|wNP+sTi4Uj%5WFyJ8Kx8)#9-Vpm+IGlVT{~Kcs9*4Of zL9`5cC@10_GES?MDoa;ycv9Z(QHFRP?ng{07 z)A51|?~Er~ronPXrcTOat&3XIQr-VH4;mN+Q+qMKWEpOW%R2DbJxCF;6$_N6snQ1jjF0PQ_^eJR5wB7vy-DTBSW}25K{hb z8Lx$@E{-xZYhJU)Y{?bPRmVX_#G~IR%FW3kHQ;P!-zhF8#z2Yvly&$L;CV>B8_GAo zJ;gXD+Xng~VTw~e;_}SXc$*grwFk^godqxb*~}kzs3^KV&%xqO3dJ!bY*!tOfQ0^m zfij?e=j^(%ptjOX8aVl*$gWN%8=}FXZhi!HmscctlxG%Jd=j=)mQ1aa+2VW~uwn)0 zb2UP7dPU#JsEtVI%L9R|1%0mR)`jZ3K4J7Vwr!}IL9bKURX6Nq7I=M9F; zwrZzSWLMi))-OsMi+Aq)Y!$i+>V{b+>qqNy-1|ki%_|eIc4KdvV4Ry8szq9WzrWPM z6>CaT>?sSF{YqgY=CB^$>39^!OC_Bu}lk(Y&&5-sJ#XbjEol)+?Z8kIxJaTYZA!Ynd znVBqcI)Lz4z8#`E+)k1-_odAQm4~IcmbTo2(2`Q_k__uRtA8|tT1{~#j2;A+j zD@0Ek59lw=X(8^}RoidP;wT{m)fjx{voLWvQD!lg**5@`2Y(LV;XmM&iA4;=*_1UT zI(tTqwDVJ^_r&7o)5)7cW`9vJ(;wg0>>-BxaQ3&)of^L{*I9grzy$HDP5ykP|DS&B zB`ZEZ0q+B1`pudCK5vQ7`~UV2ut5Lucz%BeJVfHV4l$4`cj13fbY`C&Fm-84Pq+k) zOgydM{xJ8sul{oR?5FtqCH>!D$p7ab6r6prSsE143#{AB%{h5_l|0^SaC0Z%yady_ z#j5mwh*G&b5eb;6;v+MC!?o2F&VHm=Imodw+Go$crYaeHwJZ0a7R}1V4u=(`wz?WO z(5k$QKv0_BhK3Tsui!GpG*3z;<^`X$eIGj4{loffk6$#mF7N`L4jrF=`_?Upc8vS- zglT1r<6qw$WLr+5Nm^J~IQXGWYQI!ebVdwLjz6nXZz1MpKC7#{f~Iv&{&zJuSDuTC z?*zA9@?T_`>eX0e4;P6t{JymCnfLkPPTvQH7x<7?>WX!HBa6x7gRKpL0i;f%JQx90 zRrCUdi&Z26auTH=xbgM7alN4_CM2KQgqvh5{^ZZ@*q4IAegDI4aTzP@_b=d}KAnKO z3(~I5`+Yn-NV5q0b_)!mCUE#bZr^UG539Y5jFyi#K63hK}o2**67y?Jhd z{k3thd$3_=SMBc)N_^TsPic0A{`m%f{lNv(*`FYm1HRh?jaaeib31YFcm4fli2X6U zMbFeo6EhQx))qlsNpz%(pzh?Rpr_uZpyD?oN`*A0X8N4)Y}xxkPCNq;sk4ynrdhr~41CO3{*5#qtAmxG#g#7a53O zzI;)9`zWmZ1f&);x&e=0m3CC{@#Dv#@u_FoXE^)fRgdsRlgB?i(^cKpetjRi!o=r; z$K0f85I%SxI>ZlSF}~cJ31v;(i8@9EC>s*@3C|E&goY`KExG333M>aph8lxCps%(J z5m%d;8XbBHS5nB%sQ$-E#IWaXaxWW1^#72UKWF~IvQJ?4uU|hQUM9qtnSq)E`9Aqi z&ueYGjSCHag8>U`Lx|u0OX|>FihMfE4XZ>!!78}y5R4?G6eMmUG;;VRQjQ7WYqBV( zsHiA<{_xtW8?@fOJ`Cv2PKZo7^5JRqI}K0IV$}!2f8btT_$Cv;QDIm_-NT33*b}Cv zrtQUjUkJEW6myE7Srp{@6}!aM{g0@LQ#e#?b54O)j??In38E>*N4?VILNp|OM0Mmwe>c3k9v>fd|UHThZZUJo}*Z6 z4!p}x9Str<+2Q}6*3k~JaP5yKF^ld$gJc!}W7?TB$A3gBMW!Qk%UA+Y7 zJ=e-gg`g#4?$XAIj?c4PPja4dKSVKWq$8)st?-Wc4P;u_H3pNfRBr~5csYoOb?6Qxa-due2E%%)e&j7 z%8=C~W8uLv970+L*=mBX7Wjr_OW4B}F>umnpxGWm7RZ0aWe;jOKd-Lt#dZ709>9r_VJ=`?lI% z+e+HXCVXT5)@5NY@@O<#3KVfn&=zu@50^VvL!E%mKZ-|g!QS4_pV#Q@brsyQ%?7+3g4(EZYPubiJyd^|gFRNcbEb?e_N4XD+dNJ?8yML~st-;N{%QO8Bdf_k}IL_D9 z9dlfe@N%cEKh3+Yrb-OMOD}F0{dC%*vWM!Dw)+-2xnB&);uB}j`g&QiQ#fRGO;eYN zTR9aHE3>--gpajSG>Gm&1ln4B;tFn_FtKKUUoMhoCTY(i)v4D9?`fCGH$myG?G2@O z1VAw;y-6o8drRtjhHI|cFu$XBt^uR_nXbXdE=gRAhV^rlZrxwkqJQuZk_XW5jDP!9 zkdxD#{s?Z$%`2lWD`hTy^DpsrTYQ>+B?98ft5mm|lN#CG3&;+)@ch7jvvopzmY+$k z)Fj%YAHP`Yqyjjx$}n*o^urEm2wkk|${&4&$Ixu0c@%b(H3ND14S=xcPSKrx9wkhy z^1JdFB8j0x5RO=f#&B?Nc|F&`iA({MRWyTkuDT*8{(^<4m0_Ybg_f&Wh%jlss*0WIllz5Vh9bFJp2UWM(A~ zcYrn)ok>rZZEC<{B~Tk2NbKwjqw>SaeLX!HI}4YcdVZ_^baj>(Ek|lCm!i3=t1AfY zxQgyY4-%Fv<2jR(f@=}=i%vIpU2aVl-oXO$KxZc_FRveh%8=rMDJGEG3aero29Q=j zUW%^>JDL~`HwDlDR7144`-o*n$fPH2|CUJ$fZE$?Cv8or&@C$OVluJ2D(nwhvF;fa z6*jkM?|i$XSP(~pu>;j3UxU7@boim%LQ#=PPS%Msx;8@A+#K1vF37FALH1sxP(McT zi0ShvUm1Afpqr?#p`n3PqTyGdXfU)l>C?&6-%ts0UVLyDQ@^Ycl2zG?_BZD|EPoE0=-Zrqysn)@C4SS{~psMz# zt*ze3r>p572CwHKVQK+6K+Qoz(udO(kyD(wHfA{W;_Ks)Iw)@HEsLEQ4T`{+@W?-L`*Qu!2 zv#I=B;e2X;^yxok=z1^8by=Kp9+}8~d47}a*e3?|=zBy5xSbf=&VSnD+n~8+FCu(U zwIoz8;B4XB0sDI(%Xhdm9sEuWaMZ4h8p+n}o()VcEU5_wW zZ9mtyks0mpkf{zve<^-3<_(v&Dw0TEpWZ&3M3Vbst6rVdKYF1~Vx|?)Ss}P-@OXg7 zZWm8vB|5~}xB9zjo2xy4H+QptpkP5+#>j|)6t0Ud4;4E@Bn_P^Wwr{8iut?L1GKX` zULExo$oCP!^M5{PXE!(XcOu6jaqxOj^F*qjeqkcd#?6h&n>MzyG}n~SA|O2gVuo2? zU)O6<2qa^(vsZEf)kgll-^ahYvbve*37gkQiEJy1`8Cw3xne~+JfGI3xt>@bDFEms z)q}|pKoT=7u-2MXM-73nnW-}oMfUH~{`14XK*}%3?JT=>i+RN+8!OH~*&D7r5GBs( zSp9Dl=H^C3MhYNKolZ9ZdIVZJ`2KJ7v~%PSdU_*~yC4!E>z;oY{#F|cqJ3Yt@2My# z6kIpD+S>NDCe3h7D`_<|GeBbuI5ZI($t_! zMyU~ZCy?S7YYvVL=LLbTx8+~4m-e`|i$~+~uBLGmYwd~{A%YpySSc7(HgtA;-A;Xgi9URV^(ozeT7Mk|OnvMES(;D|lq%4aL@Wga55UbuWxt zt477x#gpkKCLKp{ETQA3Kqk#W)jAPW(YAzq91Y`~wshd7(BJ~Uwef0;ky_zr_juic z$<4d}0IWx~M-YWM8;{Y5Xe(Uy5R-aADUoOKL3Nj&y4UbOsuvDxu~j;HsScU26)X0k z#u`KT0)wU^D{?0F#k49|bYk$SCJ>|2E#c7fHuO&$m#_7UKXKXWJa;NJtQW*CSTzG0 z(rSuAs3hi(u-gvq_$Hb+mM)ulXthOt^RCFSmbp9Lp%s|e*;-@(Nkg|xR17p!3@>f) zbSycDVM>OTYlzK4He0%MY3kQk!~uiW87D+0`PFt(0|AHPU_dQQgebi0v&=tw#Sjep zfB2+)VR$XhMd_?heUGZJcpOIGkqSL|+~^q9a-+OFU@mrHeTg^~;XMoUUSi6Pd0xKS zF4EZ05MseAkWKFRsW@~zgL-@|(J=sdaRYif@%UE*`5A*M{s+LK^p{?Ofh z%DnwOj!xnWF3{zJHZjZD5@S$I_a|=WW@GF6^y$srqXCa7l}#y1dv@0R8@h;+ppw1BrL+Ii-eSIc5Aca zijW+boWhZKfoIt=5dLzAOu_U*%Wt1zvBhv(5seSE-Iu^rE&x;M_7u}l4Y0Z6zrpAM z>QHyZO3ljrC=)-$FV}TX3qY6C z0JT0S;sR&xpW8MTKc5c_2$9YvORMKz0mc2yg^L%pwX{+cJs0dZ2HA@ouD!24ano+w zzPBy7~ue0xB$bwGY=usHDVNeLB4G{SLlyt>C+suJfjui z2*ae(PZ)<7FgfC+O#~o5guHTKMP>E6_3jHb12D*;s#~0$f}>eH9@Z%gygFg4wVgoHdv-s$)OX3#oiPQHyfp!9OHRjb?-EikU zqUt-8Vb1v#Kzutfsd+?oi-T$7qfE_E9H+z(%uhM$a@g~7GsvE~Yf>pz>=u`3;b}@@ z_zRp{!$es?*^4*qhgiuLCS7B}PB0B}$}dqHD8nPkL9!h}Zpuo5W2}j1&z`um2_@0? zB@i4_BA2U;Kr9k51Z!*1x7HmIWQTx(DmX*#9(b!Pd5&C(H#&pp8)Wlxp584&gDz`V ziDMl=xD0yxyinpTh!!uDhSq}3Y67DcBS#ZqTQJ9878(#{t0g2>?$TQw;?NSIz2Q_$ z*8;MH+#{|hr$&QU4qQ1112X`U4YWY42{a+N`{%wo$&Bnow|)PR6nbP(pHqJI7KY$? z7tQ`J-&=Kaj)9$>kOCpsu2UFDUMcV^XtG`Ja2G4)=_qwyR*UGY7=5CG^~%BWvgoDy z%p?L8z(VWIyFDUKCB2|>?~;D#Tr`N34u74!A|mYN?Cx)Ge``86eeDULo?-i{Pm+o{e95^H)F3zY#+njmqo?~6i%%1ny#i4Hk zGv*@}?f^LFL4iu}#>IF-VxO4o0j%!*j>xV6@P_8JqO!8~siW&=e%_ne=}gUuVDI=V0Pc+a1HhIdvXR3xHIp;{CO6BM*y3{kksMBXH5*s9vi;$(!`Q&! z#w zD4E#C`zSn|EV3xb8}WcOge9Kz&X~&skS#~@>LC)=~zTtqjCv9_U*S1KGnr~2L zuEbPnb+b1myjjS+OQl$G%qY5gGeX&7mlFWDKCZu*g0pb4(uY(q}hF{ddh#<(#55RKWyt=eVXk+ zN5v$hp+Mfd6p^g4O{hfwNrEcZxh~~n)2hqJ{vqoo>Lk(=1D`G&nYuckVFEYVWc>4XA8tB(SR&fE-_iGHT+=diuaG|UvDwn6RKb%cibGi zv-V{83FNCea<^8rTS!|M>&fRhDGsg3u?2dxpm-}3TaK5~2Y1K`pFjrJxgJwCx`hsK zI1_Qjut695;Vi-91E?QGqpihsW-n!8DNrfbZAp;mM<3W|vBvhc*@xup?aL=O3*Ua5 z9yk&nK6#ut@h~ATEpbZ%kc9>JCj0n3C5KOJ?hO{Ju{|=J!fZRvx!Qu~Sc(V8-x{i= zjg7RB>j{*Iy}=i)i*enaf1GhjHIYQAs8-avOLw(%m*gt26y-HrBQwm$Q$aIW9)HSA5IzQ_SX+l^2!h}mX}oAHRKDLFap!*;Gpc3w zkuzkK5!^Tr7T(0ofH=V6HMQ2k|M*W(qkRjWz*yNwdQ**$ALpMM02cfYs&Rz&`*NLq zlvuK}g~2T0GqQ+@Ku1T~XIq8;!`qw3Q{BGbqYY%6vSldqM#)$bLWcGZX^=TXA~Izt zLP??6#w03|Aw$YoWJ)4sD4|d&G|?nOQISSxy|?N!e$P4I*Lj`a=bz8>JlOj^-1l`~ z*SgkPR~A`YRQy$g1d0aEZ&_Nu3tmRd_`^l{z2N`9bu<67p8g*T?f>xyj9wz+r~hLs z=l|&k4h?+&*}FW*=w1F+sS<{KLUJ#Bk=~{oU54`iashz{F?j)Paknu$xD~>{486+-*M4FGBKEH9qQ}Tz&ASdf z53>A0EXiOL5ycT#Kq-L9P-^6d-ZH}CXZ!(TyfC$W#gqd7p(f%U#9l3M==#Z zcH2&3&&+lbO(n&3y##Qd07r|9i(!6-SXPhz9mW~{09zYx5LTDJzzab|Kvnw%sXoE4 zIs{~2zUd|mEa?_VHBftc^_5#!`^oI*;N*OIb#I0Q{V^Pa2WDHv#!}*Rus?!eEsgOY z__!b*aa)K801;r!I+C4COdCMDeX4O`NgAvlZpcp_obpDB9E~Kh(NCY@#FAPVm3CB$FMD2$lnSC zz^Vdi`NmPUsOHA^m_Hi#1IgPZ-^Cm4AQS%`Qso=wfVs@u{8s>}^ zb9j800m&Xtdaqq!jU;7sj^iPE*h4*+`Z3ZQnpoy9>yB^QxG^EMeO6eRBY13Lv-1ku zsv|+@NDD{uQ=S zH?feBSHNpQEyrlnlaK6=aVNJ3*TYr;ZYWU7{ShSk0n$I!Xvc76Ed0|FfY0Fy^ieO( zro5X;yLRn@!!4LaKHiNFAofIhQMQohcRvp&kg65{H);RxT z*t)0|xayRYpria!WVTEP59^2pMl^FDg@%YXavrv0%Vz7HE1?Ei+&b8;0$da=)|q>I zgU<_l6aC^0*t$(0oGrYS>7`eg*#LdG&y!E?lT2agq-GP_nuf#2pXElxewv_e20rMraV%566P2u6(K=+6fdsnMS(^-n***Zo<|zn3(&{6QLn|6r2I z00j>-PIaSMJ`y56eKa{CnDpW`_3HEeDI0Pm^ZVn@#FoE$b?IaB8yBnv#P*y)m5d1yls6 zxn4i##EQ&CY-8jlOn7yuG72>@yRv2F_NEuk_pIk2FiwcUQ^+arv; z%X+{zWIkegbs)w)4;=4X$C%k7?-?n=L>)_^d`)uGL%8;d5~xmCJaR+WrM|e?-VUwQ zsn8O4VIrwAeO*}CO`%%3sw1Orn}h+_Ltd3|tl}4p<7ywX}X-xg&}cSx9g>v$zO8FTQ$+RG26RO3yn$>e!1tNF!kGgZTow zbQUJ2&lTVq_6V9}0awsch?=>xHV00g^72pt2jc`r9c^Ffd6v|E|2{g9diI=eh96%! zUmT7YUUAKdA2Oi_;D1NgVH1cc?mRMe?F|wL^g{>)1bY>Tg%79*{247Dp{j_IU5g{) zmiJ4;d4!q^`2HYl#dE=!!8W3iwLt-a?CaC#&xZ%2mm|B6ZCsGA3L`u82?j0$(>tf( z-(owG0)hrWbj+yXHbOrF?rk19oUNi`0KTALVz&;O9*9gRgrqO8zl27XZMnSe>U5N> zlG20zej#xZfkp^ll#q)mTGV~}j75^fE_bSQFv``lX=z)i22_I27g`eYlk)8#Mjw+; z-^Cb>(EBYo-}C|iC(&r3uqprey46EBJex;e`$zUm=T~5?1lS4-3Ra`%KQ!d_*+V5i zq7<{U7rLULO?ITV%`;8j>~h8R+`$DCnZ5!%JeW=t_Usxd14CvbBU!%n8q5>fVEYNn z@MFF_*U;(p!%MiwQD?jwmdX1HRokEVW%B>*($p^eU)VBICxsq(Yk-P`?gw& zZpKF-kt#dL8k_a?BMJ*W02v@*N=66S#>U;4EZcTLj)Y+pO%phu!qx7!wl)w$tw}}( z2ALH3|N5kubRlK1c7pLpHGH)q`(scTyl zg^`>ODzVYn7<6E|#Yz%N+l^r&9X1Q9-Eg*#J*!`FH&PbgW`k#)D+$>J=iU}Egyz0pf%m9&>D_YLlpX*pc z=}F8}yirst?9!G+;eCFJdp@%`GZF6C>I)XrAuP&c4h64jT#sG#;n`S4tO-j?%MqD% z5JD&}&Pg$Qqa=+DfI0~2TGTj15N6~g7U`n^^!oOWO0lo$Tnc+L5IB~_q2S-EKr$ix zX5`8^Z1k(%SJHc~L=XSMUPupG;?L{=%7*S-Vv>?nm%3*|wTbv5czmKNu={}>rt|)I zT%4)`iI3|Hjoim;-+T=YRzgO4CS>bnvau@*!V7??rJ=QMi-9Ax?i-Elosvq zWurZGa(2$z^%IE75{@;GmUf(ooMQlIP(#D9C)djEWh-?|Ae$L)6(!B8XgGrJCCU`MQb?75I_?SvTDaXV7FAPc&6TQ zC^t@2%jT3c2?y`WvMpY092|bWz8()RayE+BLt1w`_W=|54kcMu9~%v-u1T2~8$(HP z#0>Qqc)cCN6Ns9GuplQYd8>=oJY(*DJh4WFo2{*zx0f~c_ApbUx`2l(nA*c?s6n+T zpd2GdVYDYbuQR

EY>$fsdND)kFL<@ZZ(5h}jr8Xa7UfGZ+4G^2qN(d7E}Tyh%{g zk!ht{U%8Cz!bIoy{VK%H$f5{459(hTSH>pcrGE}nMRj{V><~cDf|euGZh(eAWV+*m zZVUP(A;+hh7h7Ga-x;8u$`m*ST7D(hhW1||7*4hBDA?87-hOAt){WbQGUc#IobprW zU%Pkz{)(B1cYNQyZ;gW0Rg!$~n{te~6LW$^wxG51T_t_u?HC;Fe9FuhI=(mLd#b0a zRTKY%>QLj+=qJdA(5r7tU;D_0+%n~oICiVxS!ohk15wD)2s8>P*MLeuDh+LfB94&0 zMmP*GqsVhSqb@m6!68oSj>5+@8%wf;4SM_7+3j%FLqx%6tKKF) z`~DJfKRPeH@f;YdXj@)`Jz}^H5~@{qqRF`=4vxdnFOiVzSmmw9V$tAjJso_ePducbU!+Mh|Kx+5A_4m#D)(pQFSQ$$(6rl4~UuIU8kpeDc0zNQ4&f^M{)e4CS*oC7zlSw zn^Mg3_gpuCK8{RoMQyeCi>s`=iXe^g4#LIpUF8&lcLQoP^(a44t-Z_5^Xp|>=t_0^#gYM{Tx}8r8(cSud^38 zx>`kUH^55xIz3G}3=gi9NAHl=!luo(QK|qlYtq%0^`}}?vJ^%uc(7(sill;#E4|v# zH$den7Y9mn;FtI5=&0V4Q-+2ompRN{6HYG;_4Py?e*Yd0Qs0q0MUR8B4DYmw2aM}(7GU12+6QHC&CojV`qye`1md)?(a{Epu^w9B{Gji4ax4Xix-9iBXRV;w&bTxEN z_m$quV+qH8O5UJA8dFF+b0#tj(=WI|?_Ec=+Pc#flEjw-_t}VoK?+b1BPYMNCqCc{y-XfobJa~g4ln0z={ zr-=o@NPuuTi->9&NA02kG$4z)e-IO=T8@|D`Lx{sNbNdI}6LlxNNv2sjU2wOl^gVoXQ{JZK8ar^N zMd}yU9wV$!4q;rVrOTQ~<5BIz;P z2Q?s!|5-Q%2^U(dWjEK&n>Gn5yB+yQvT|sqbS%V)|D1MEH72mQxw*lMR(UC|5~eR` zAAMn@E0*WCe~3Sgv>q{BHx1)q(D+l5AURve_cv_p9CqRm)tp9z`TFBiyU{t#uSgoG zViL4NR4W^s;juAd94qfDw3t9d#yf7Bf6g2~ z0|VgV#0p+XkDZR4-_fp$1Y!{7ZIx*0EtKC13isZG0(S)v%r2;c=I}G-NvU__P|qPuS5g+IQ67WJzPC0ewrFE>fmLPX zDG7f8w_)r(?um7W_4V|ky(cQjH1q5GKy)B|1vc5JZ`QNKmL6?P{>bR3Xw++5U8NNm zg9H3>lUHU{)e?y@ls6WMb0=7b(hoeNc4K11aia@msODK?JTP(v$isS&N`M_U6z^Y9 zST*m9x2&}Ey4As%spj0@sEmE)NB1`&pJc(Hwf}N5r}1wxG_3U>NxJ2|Z51*NoNhp` zNgKOQojV7aLB&bIGXw6e*#Dh$Fb~r;EaZCs0xpf~SuhR-s>0p5+vt)+eoTN# z%vl>Zs3B#T9r}PT7v@-^NCD#2w&{6^Ls*pyFa;b#02t&#mRA6VMgu<4z84W671T3O z0o^x6c!+@mhhUON%H7(%iLhUI_fAIY;UOE8%RF2=fG{*g)WHxRd5@>+-!C4Rk1_;^ zOW9#QTd*8f1fW53iFp7m6mW+W4^NMyw(ow@vfn+!%ya;CLLIWrl{Mp`X1VTbRM%2= zb3FwW0bcxKn5Bv=<(8VKCh^%N?gIvAQ^Gwvjz`~81D_vWo9%!}9}km$?%csm4_zpt zq4{h2rOortFfzcj%lcirYgZ+NqR;^+-35hb6pzwAxK8!uLtJx%FfVa0$zeb>(Eh8} zRQ)%s=FatY%qIx)I8C@b+~5cA?4EM~IPZIJe3klzX#|IF9+}&dAq*$qRp3$puYnti zil(OU8W_k;tRfD$`d@@dXZZB31rY@&Vq-r~P3e>BD~{JY8D_VY-s{}Hw>JDHG0yhI z&7+R(5{eLRg9R4z5`;`t)2>cVYM#$f8bBGMi^j92mKJyFo3+jYX6&>Ho=Xb}ZcT3h ziP>QpiNDsSlC0Q0%>Y z&iz1Ke4LS(BKl4Aq^@?2yZA7^e0utt_dq=_d)?2e)N33%FuRRn*+jv32WVp1%+>0R*#e;@<{2QcdV70AxZJn8Ptm3{d*hru?I%hYzB-L6 zrw<;9H06E0@9)NRfmZ-wF8H2=w*a;RZ1;m;Ix`q(eLI#!yK;WTQfVPBWG_rqH%H!$ zmjnV#E0_nS(_{2hhi*UFnZ_bZ>K(^gnOsB%MBfiO77%JwmvQXC$I8>aj-sl*}&ficmM{Z@zMnq z*A`uYO=#{`0neRz>|M?9{%dN7Ga?=^tMvu9MP&aJHbX;0y)@GA34ldzKWL(>%3ank1GdW*#fCYSlPXFaj$PIDi$1py&mouSo%1f}<}MsOa(|G!~nvrhRKSAuH@*^qtG+~5K#Q3efq26OG%F|Nw=2I`YqCIrf*&a zaQY8dCzfcBRuLE;V&=x$lAc@JW!;=j~kvQ!+rM zFq&JXuHNf%$}F7XC)+^g`1R}8;56!ql;oo$O+LVU)Dnq|#LY{mUx#TEo?}Fj& zWVwnL-9>JTaA!>_;CBzYqVX5J!lt}Aic-E;0nL@Ej~`1jMsu5!xv2rf48=wjPaUK~ zN54^I(g7_~3C(^kG)uuh1}bXBTT()@XUN)mqRs z;a~)D8D=P{X;gqYerk8Cno6m@R_Mhodvjv5Db8EL6VJWuvC)5+&*QFO4C2wmEj45v zsCH3t>8nKR-7mi%+oo6WKRmiiZ^?aM4DTElio8)Yxkc7LCPb>YMtrqxoXZeMY61;S-KCYI-^lL|#3t}DJ zVg@Ass2T>h&vw8J6n1sA4jw$P;vwVe>(`G(e*a_4wyUFuyf1`J1h^=z;{=D0J_em^n}@X>LCo_0>hwp4^nWubzM*d3+6PPSm3>I7(M;9oTWAB_=M8e@0-eyYLJN zAbf2tv1qa{hy0k>km?;tL{U-~iaY^Z4{)OES;R?(9GZ7qS_D-+tq^S+bNR;6a*uv5 zZ?{%0DkdiIRl){uvZyEm^MTH+t_7QLc8NW`2Y+LWi>uQ4=%t~_ONz4$c04*6(^VL@ ztWdZ&Q|M)wPn?*}LbDJIt{cp?dbUM@B(g9vUz(b?eR8-A8gw?~sUB2!^?B`5htwyT8dbKZphWJ%xCs2`-!?aQI2Vlgi zb*X&sJR|r*ye2$!@~!D1`jqy$H&Lx zlr2bYgaXFI8b>~n8Dkxsu(@>5SXEn}PX@cz7UoLt5&oOV0MGH~Nfv`o7efH8|rc z`U;#&V@0$NFIkN6#k+gic6hb$b3KI&uM*D#K$pNB!eQ6`AvyqT%noNy%{S_Js{5z} zu|+KCGq>nL&AuuM3Nb&|iE}b#MV}v6`+Erg`1v6O@8*tPpC^mw-N9KLcXc*a0bi~# zl2C1ro?JYG5-01^H~8}9eNqLfSxijq`#AbhAzxP`W7ub9>g6T$>drvZkk@(pxMSk0 z$2D^=TeJ^Ty}Tegw=ct*F>>@7^U`E;&*y{EN9r z$n9|(x=N#<=4~VO{+q!+CoLPLeyt=?e&}A1PSJI65P$Y!@EcSiZX=Pnd2U6vR7o+21F`QEc(wKVB??yn<)b|QQIKZjLpAooaN29)>%%y%#i zKS+Kq`uyNqk>qbE;{CtQMa8!0)dXxr(lne?5MTN~5Zdvy5_FLDfVMMZLPIvjM-ni2qI#r{gIZacY<*@4-n)J0s#ZWmN|B;oW(1!8j!_jum9)deVits8Q?9l#sFQI!x8VL zl48sV99?!IYzpZVhbHv{&xep7x?gGhezg07E{a1Ex#LFkgb9xQ;p zpOu<4^N{D9zN57Dc##Qj&K4z8eUo@4Gh*S6*qEjVHvPTig*I`IPtNT#igoQ&or>qo zPUyZK9kE;}<3e7b-f&{a`0&2M^J1v5HC`~&4@lc-q1OSVeUa}2bkyQX^hvZGxHcJ- zTKDg(-UwN#aT>=@9VWBFdd68UrAK~C?4!(Otzn)Wh6fkC{0)*+=Q@SC73^1wm7ipJ zIzH?<`wb{jHdrlZa#%*uIPDozp0gWaTy06@r8wii4XO9R@XQ+rk-*g~XBq2u5oB1e z5&G z<+JVHG3C7GflcExLx1aGv@#Lg@P0kGn&4Ic}f~j3LnYbJ|RZ< zQ4S{+ZhDCV2CddWIBlkBOlEFBQ(e(z{dzUFEg-0e@&vGor#ZwTL%P?Ti199P{BL>q z&=--cuS&_UcV_Bp9WI?n;}bia)o?-IpBfN1cv!|9(OFG~ntxIISl?8-}s zYRO7_crUrZ7F10?$iGnggf#(HP=q*2uUGH(v%{YZzi+yR#siXI!8LoH!2AVN%qJjj zTilOGKzG>_<46JjEf7l1_P^V*!b2f@6xR-x_OW{rHEd2v8?mhNEqb$Qm3vsDgs zaI-$cv#y6<7rtvNf(dRcS6-}z_RmOT&;>SFnVX|H>xOCp+%l;0wWNV7UyvdtC|4IC z;-RI{v07h3`O2-!TgV`Ny|}gC*LOR_%lu2k#7-Q85z>VDOV$k3C_O)$CM61>M!RU` zU6rD!(E@S~kJJ`~em4;A!2oJ}U``Qc5%)|| z7qqS@(`;NM0r#MYw?di$icF7z{bnOLNtsW*Eza!l@7sSfrpx)w$=932NzN__d~6Ll(6Y*Am^kn+)pwo1vTfrPV)#z7eA=@jp{9)bJ2FCQ}UKw+1j z;mpAL4LVk-%;Mj|qh`=)w`qop>1BV{PFKF|=V5v^P>POnB=?}87`y(8ZS1tUA9pHn zbcE>bwYvB@O(ZQ^^qQ0>bRIq*kT+M9p{w$w4)k)N*i8wm^e$64$_?X1l6IKLAnKvd zT~M`PZ}&%b+AID9m_I(sH@hvA+|c7}zyE2^yt|yPtTRi4iP=WCf&aZu5X-^hwHkXK z1F=-tPP4A!Y}S0{tUa|ODVr1I&Qi>i^?ovO6NvXmv-P>GC8HMMp8Wb>U0pRvEjsr$ zkm_+k$7V7F7t`O^SpMTa2M9ymnws$kS9HSP7uD6(scA#);=^ftW#wWa2Y3)2J-6p? z&)Ku=Y`&RM6GS&YJ_ZI+)7DV4W#HW_Ehq62$sl$d3Nw7gRN~2h!by#TsZn$c%}dil z+O6)n1HB5AU=yDpIU(ynTjFj@?%QtHtZMu`)GB`&9HX|?4NK+l)2k;hd#upA?1X+X z|0Iv@5XmE+(j^jjX^B0Iwv|wd^jzA0V5G+j;1i7F_+}29#J=CZpX1uKyX;aIZPeBd z?bx2J#dJKR9?4MAE{h6 z@};%9%tD%U3mEA*1>{nZ1ILBcACyz%rKN8+*}TF%?t>r4Gxl3D)V$_o8?rVax7)3k zx66Q(QSwW1nRZp;26BvwK_-8i#%uHWzK^Rp*y^$HM0HeH;^8_^JRpL1RuzO9^VZwk zb|`=UX0b+mM5+FKKXH+dRL*Evq9f+T-}Eof>9{LTuJ;5+uz{^e1~Rp_yI9ioPJC)= z%}&RH@UpOH&d!#+b^BWOWQ(SY+$`R|raE8F<)NDJ&Asz_$ludT53<; z@~p>SdebUcU-hCmn1MNIRth>iy8!5pbUI+~oPYm01Gp45$1qLJ#MT$AGjNJ-{G96d zAI>7q;&Nv&mB8r{3n`MOT3K05XEO^gX`BD7EJ`@YBP_f5TZRUJvYrockr38p)^l3= z6oWfpIU=%m*@W-Rub)+mjZ$_~rCXCFECcF5UpSu@O1^{ejFF8o{^>~@$h4(?V|^Av znx5mQ%{bf2DdoEr28GfqBv-8fY`>ECDXJUJp5kiOEUr$-BYkRue+CMniuh3dM(L!S z5Y??ie#(fPC0S%0+Tk)(5qQ`iajn8=#4(`KhMclw{hmSlQ_dIvEGz2?xl6y7)k5sf zWzMHWVmV$M;Kx(Tb#YyCmVM_-3qGDTU8jsanX*_@NmRntu}Z;qw(N0u?lnX#PU@_EH+k{kiZY8Ho8@meuv^uxBo8$<(i?!^adeNJqI`sF$yH| z#DX*32vY-E3&kUCZ(RV(syB*q`t0T;Iq(x5Y&HFcjB^q_=i2H1+22W&asgt0E9m>Z zTqbjQ%jV4>IHZfLw>$fDb?$;%xt0}j!Olrf^8@95gXh$(r@HOk*z3e1Ie$7`PnEqP zzgIy2&Ai9#w5QQR3$b2->8Wyul38$0LBsP9%eIJ>HmMsMvxx4-%gdT@#Sq%dbKB#MVz<8oTf+=`Istg@&B*%z6-RkgI9M$I9cWE~Z3Uk@@Wybaz9 z!f=&@L~8nsPS_e!{Y1*m`Oz5x$rG0wSG(|Z!?R{Q_?ES;FANp^tMO34p5UEWv-^Rr z5RU{g2|;z~P4VH_yZcm7Me#n7lC|#TOANK-dM0SR3839kHQi~@McXPOA)wTi_~qgD z<8&G4mTgEsa5gF!a%4tI)E^RKA$pGuw(o32e?XZneKCN0{wrIjE!*m1q|9eI1m0+5 z1HHAc0Eu(RVUAxVT7Zb{_groBQXv?}RrQQkL?eQo$$3&#P+reGcQLn*tVwVrk6NzHSrB z;M?oraF!SoLpE`Cbsf1}WL6>I%RezTL3ABbuk9a8iC!|!7FN(@=di%YB60x;AL?Fa z=Rabex0jmitKM%j-iY+d;?1iYw~4=ba4(Uy%>3Z`WhmU1k-Tn{%iXqoIngNO2~yx7 z;NhGeom0mdrw0FE*Q;4GPVs79tl7VZ?>u|!;h0|Km{|2=p8OHfy#g_t5)2!SNwi5EZqY*b z`xh%#h>&%^yACXhO=q^+NY_uK@G%sH<>BQ6fS?tRC;skZ6iVuuK zglL&=uo7a*n%6t+{zDTf08sT#*V6YHi831wtX&&X)6#jbBzrVw(9)~ddD6{sP6%_{ zvz4n=jyzw0Z+}>}TDHbCEuC%}oVq0c4x1BJBkWWTGmdg_=R3H2Xw)I{(_hviSu4)e zexsy@n~1o$ns+Td?cNGZ=IisBpU-=a<$wIAi@C})09F>rr`d)ag=Sr^OL-)_zHO8y z94c_&;(Tw8j3ej^=qQOei17U>aTwn=OIaieRmOeQGMcd2)LG(Zygfns?Z9zBa6=?# ze~I8>5H;-V2O?k*yfuRDf-WPmm52grwr~3X&mYbbdH;ohLiFg~F;qq5Z84I>ohYN9 z4F2|?PU8Q$tNVZRJSaJ0#5EEW9fDpPAI#|WG5E><(``NAo@M9!ANWH&xA++r##?2p z*H}tC$9T4*v;D~5B*j1c2*%6)+a#Y|C4X22v#aD!BlCa%hb4=ch=uigDRzl77M(fE z1_PbhhZZ;c(0(#JNNF0gjL*+uc&=CrvoHT2lqSa0{=fS}`~o;w_&&T9yc3W>2qgfq zmS_L7>2=0WOt zxS~JTNBr~eOZj`{j2|;Ti}9(jT3l`6ui67<^GyQ%4 zALlR+%q%k9L44icheLeL>__0kiJSQIz!(=b`-Pz;!?1l(N17F`JBi65n7c&$*6a_v z7EuF$%7I`2tfE}-jl$BUPC%RZ_$&egHM$v*<@aOxaf+fF0eI|?>@wm{XYX1xih^Ft zTO@v;0c~y2*N1+{8`vJH2Hs-mes8;M8%?H*t=+%n`Y)hN1oCHP^%!8MHoOyom!Q*p zf@HL5Q{RoC>s4TZx?*?)wDya1oUe!r=SwcYfbPf-a4J6)TE#IT*${%64w4GMGGz9w zYDOEwyA@KIayksWOp>6)gE%!E-KLY!~lZn@?o*UrNu zW~E&YUthg)qRZRWEFq|eJ$pbKyD9TyN2wF*R*Uxbg1-+ar;nAE7ljeoLV~+c0fW4R zmgjlnXc*$0fF}gw1m`$?7un3x%QLV;^*#S$(i0tMD9T2rbJY%GoX@oK9SH>a>{Lx{|SOhwrvT==bT9y z>tVH|vPrR?L#MWEWRm_fjDug^R&y9GO%vm=im*!|REKehwE(;|O7)$j$ zpCitVq(eTK7ERTq?soHiMt5T(E1=AgP?Z*zmdpCgu~4A9mRZMljBL+4vG^a?%%&3$}20YCGW#!igXHPZ#Joh8YgM!UY`zT z-|6clI&V7YmiV;NTk-ul4kPV471GlLM$qYTB^Qn|y0c@GKb= zSmInuq{K!=l`K>b`lfuDzDeCx4<6CUUn9&?_%(b6>6a;OEiDg}qa{?2>}t-1A;e$` z-lKrE`R!l)dFRdc!yPE5Hj3Tn9rmuZPQZ251WXK~N{8VO zhDRQ5UOjT+@>W*b(=Pa>6e)1cJz);p2{S`-%k2>&yfT76A*{(`8Yr+R^t^bL>ggtS z-c#x&^=4yJ)9z#WZlUCT9@tnCwp(U+QgpdfRZvkvqtW3a%cc-%0BW@74}JkOhkv4c zfDH$E04qh;#njYv{1dEz?z^TYCqv$3_(&@r5>_7?cC~oZI z4&d5Y7pu9RVMFh+$l9}Gb;oWQB(3c%)O)AI>755!8I}9Js=Gf+L5Gl%;U7YeqpVjueq)70lG?*(>-9EiG zo>PV@)_NGc&OyoLN2DJ0=vHvkIs-8ufuoNg(NZiKy;yBlrUE_7<#)V@pMaFKh zxR?Jpl%iVYj-%NCoz>xlB!VCRpv>BWStm;alxe1fi>x#+(e&xlu8NgtO;cnIL|$G~ z@;N5x#LC_0biLjzVVPH1V_`!VH|a$2cbSut&cqQ@fsV8ahyH<&TEy;*XZsx1gL>OB z9yY^sH564<_Yc$~vV|*?kQ(H;GYufnqdr5?W>XUf0Tw!+%Lwny@}Qj{#lk}`Xt+zp zkPOS6?_!6?Z@~%;4i?IDrH*Yf^9XoH^ja^YJLomx@r0l@5(9bRTVG|;Z%=v=a48%J z>LzyRhU7kC4vBta8KT-M3RA+%{i_mQeh8U4l*4>w=A=Xcdb2oRBnu&1)K3EW>ry1w z0Pn9A)}>6j<;8KsN*b@g|EO{X9m73U+O$)l8K)OGCoinW&M;d7D--(4Qc~9-G$8WWkXK=h8WodjK8_Qu6hD_4bIh4F<-RSUq*GkY>44Gu5T-TEa z-=YGJ96`sPwU^R!KS^)0F6niNIA0Q811dzUUIcbMp)Xu~6I1X{U9C9x#;qnO0jCvkEbZro_nubU9FhYG;sag(YU>5#Xf=r1}iN-MS;}W8d+UppNa0fxM!n+L-ndwkgQZc=6nJH{KZrApCYDBKr_sR}s>((})l?*(C%GSNke^OCf4b4?#3#XXh#0CzP>eZ&^*Myg$#I zY%Dw?3qnSj-xE5HE=z~(?-M}wp>nhh+5NlxK;RJ5lfo!AN&&+D(EXVR3jIgN4HfW% zP+fN3s8O&f$HKYqN&php=Ss5s~@cS;nK{{GBYCOH!q8e5BHfY_BbH#V8S{l_UtqfB~wq zFI*P@TO12+zNq8JIk~tVbe6R)#bZ1-BY4=D zYb1$#-n-KxU+?}lWbHR8+t=T{5(&Ica1r5*&U}^^=@Ai zJf>-eGB*F_)(`V-ot4$q55j^FJtLmDEW7gdBaVB{RZ!>@?u<$bt+8B7&m5cr<}>N) zzWSC>DEtBlPt2St-BdJoF{m>1TYWo5zX(OMt%}M@9DGY8SAcwGcjDV(35pRix(9sTHshTby%#%H$>ii3ts^gS3m~s1LeE#1e)(>eR6oumUjzZ4V3z?l|7sXl*`t zjOP_o`4fER$KA84Y z5ewuf)9x%Z8m(MU+lx2ON^JwL{SJNCgkIgGm?Db%>~Ya>JN7@WYq~CpPpLC{!-40& zx`0$veDG8Sb(ix%?_n`B-3#xw*iw!?B$4TGXzw(9D%`Aa`sDkoDm#ReI<5V@Ob7C% zO|RO&wn(Thdz1aQ{OGnJ_sG*JPOD{gkmJtx+v({!iVidW=W(ly`nwd7CV5-j56IE$sVI*`pQ_% z|LAWMb=5yLj!~i!tv{k%|AVT`mcoA!m_McP|GH##2>*U7gecpJG8y&fa1upc68}n< zJi=F`PGBhpyMtnmbL0|!4L;9Hl$=QOkO{#lwzjsGI~1<| z)3ob1a&nx&DS<6$$A10r{D=U^3-e>5Pl(GHU?E1qm2@gByoWAbdofL^lOUuw$k@V{-0dpjZj}q%j!~9<<7bd2{;d7MG|Fh_`AGj} zyjTd7_*dF_hD^ZY1#Khn*Op^IfxdgFBAlg>_DdW_pDu-B-so}<={^huD$d3@CnfGC zZ-m+K#J}FO!8N8V5&@f|j0bzc}?dtRw6u1l!5uT~2=4$fVnK?S z+XS28@rSd-Ftv_72~7)}hcGEL9*jjK)G)N^b=rl@0js@!woxN_m54&ng3kZ-?NQy0 z@p|^eBwIK(ASsQtJ&1Cn>MD5or*&j!K~@t<)^e}>;OpQ`DB(v@F z_4PS3@fhSUoc~~dIci!;?IF&&xcGQk9{TNsd*_xjVBGlO=vqc#eZYNk(}n-Wq0hi< z7J}C5vvuoM=&)8RDJfG4qrinis%;M+eu`dxu0ubyIr~9Z9}IwPD%``bFIXrXfW(>` zJ)Qpu1X24p!^Yd5u(R);hmmfI=r&=|H$DUx7)XJH;@Y+Sj~-pcj|03l{;*|4EPk_P zinM=>H#9%MiY_<9g9*fZ>4~eejGT@e8U?Ke2vdmMTN!q-aBaR_$v~+NmNeeEbBr@# zKU(7=B3iEZg}GPsb8aNjeh>}F%U`CZz&qhNX5X4ETjqKF-x(`I1i>Ze5ojb`I8H3> z4mx9fL1@$c9xgV3Z`S?5xV=a7#Bay9m>Fro;bRim()#tk0Lp-ybF&F_q=tM3b*1E) zzvUWPX**~-q}#wqkD|6&x|I_f|vE@rnl5VGr1Bp)_elce>{dO}pJwG~OaYkdZp-eV^G1+~}S z&3T6A##s;n4XR<}?H9P>S<7j!Sn=D2U@Jy={C$GN@u)#FZNQ99Z-0M?(Z;|~f?A>C z;IKROG3atWu_NMz-o+BKim;i2E(X-oe%CpnAX^VE5@i{NW;`|GS=O4t3E*Dg8*pM3vh1B=u)G|WQ zamwN0{M_<>WDDwW&e1d9IV;J3WGE6!1Tu4{b~`d=X&Zkhf+>2JJfT2r<}CKmX{xM4 zX!p&FbSA#3q@HoU699Abd{>bF(fcPf6>(v+()oHqIxk7eDuPJD(#2XtD0lSgvhp~# zwsTrSXMW=KR)=;3>)2ON2)fo1A6b4^bk+EB0W^)TMq+PZ9(4-=8pq>e9f&?p90D3A z!bY94;cdVESZNY^Sw!`5qa( zaO9uh9g}JfeYvbr05%K^&2ZHJY02NWyK&eW!vBAJDGmsO>k6`Jsn&>JyAK&)x-7wc zeeyfRVG{J>T<8hjfndF!UAzRQT7=o+hGm3y z1XbLZBI(@ww7S9|2+X3M175T8btInWbVBSzEMP^9E7dc=b}tV%n%npcz)jPY-}xsn zsNcOGgfBsT7-cB81N#~^!dix^b^DjgU~&8ekx_NTOE!DatG91ouDBAIt4<-|Mf;hp zVcD0Oo_^{2i{oW)pi?eU{Ysq;_BI^>?uOL1xhwe>FOK|D17#B%DmP42z$2@JX7l@z$!d71iJLoTG zu#87UWDJ8|M%4WavSVj53xlC-4KStbn?A`KE2sk6Ax}43SpXr^TI>6UBUsh*aYs`V zGYd|yJ|~`8HS|5nSypGR2nqS9!;XdlT1(@(S#z9s;fw9Oht2(0-;nTUqwT5L?_$*Tb$xK;(pB;_!AC?mkYCFJk9rJqvGqwib?PDN9Y7|LUP~MUpr(e{ z@*|8Sz-*ms%B(nN&XGAAb*bj*50o-fS5;7WbtcqaxbEAhq{aTHzgJ%^M-qq9YDnDg z9x*KQGcWMNg7n$bz}wfi?AfQ=!b2U$5(_()sW24Gj|~YXEyQ?3@7ei|$|E&emdBV>lGHYH9}>i)$xFc?jf#?z4hK13k=epuC6xehRTM>-=Mp9@BAD5^gnSE1mCi zSy_}zj3!RQ61#!zW!w=ObgW%H>|>Pjm~ zZ5~7ms!D3a8orHTl zW@(^-h##fM{F5~M%0MU~&A!kqe;CFz!n69Lk&U@hN#zL@#KZN!?RScNepSWPf_Kan zniwvgy6ph8J%`|z6M-WHLG{r0B8_s82xG>=D|Vb_{kthNWJyo~O=L0yp&mHK@9A<- zz+v`e?Q6>etL`zAY<+L(5?;DmL=C~3Bc@O6Kh95&mj+R+`>D@XpG^_96XCa`qNBNW z0M?E%X9Zmtva!m$$grJx>L<5vINCdT)%T@4Eq=Syr1in2W}m2t$nlqO{6wi5Jr06e zKRk_mS$T+KT6JLI>Wlnzavox@Z`C>S-?H3lG4lX^wcXmSBl8w=4R5F)nIGn>UC-O{ z-wOqw0;qMYX)lN}0c-a9aH%vKkds*x8rV@DkBz0r7F`Qfl#^rE`o*O^eKX&TZS*+T z>xYm6Bf-^$8F_z!g`8xlv5SK(flI*S2MptLYCaO^e4p6h)J?ci=_6J91Q zh9ki|)?Da_Tjf{Z$n<{l5JP&`LWY~PDirTbOWR4AHbY4o)&=4#RHotmn{Ntxbb-%h_oNM9>HRCjWI@()#{I^A3 zXU2FrzGBWmh3nXvy(5n_OTYl8r>UOZ!lFvK1 zQ_BR_J%HU`<(UaSw^fiP?P7Ake5+RZnOgp}4{D|dkM)Flg9S@~IogM(QK$`@e^*@9 zo6@EB!W{LX)5>I3K?;Q9``m6f2;z;{dp6T#q!ko6+1Uv#9)@M0cYa1;yHq`%MNm*s zr)3!TpUU#Nt-x}_l+T#hG`JV#0Sm{H%%DU6DXf59XVab1lN+~e(N*X-ntLW(Xs_N* zC}<$F<+}r19M9u^b2B-VkQ<7CTnRHX9wW^x&S(?eWjX&|7W;DUtEaOP-4K{PXhPoG zG1Y3i=FrnTc^i4OSn}&9L2)}!(wiD^^rrl5qC=uqO}-+ZZE_Mg)g9G$QmIu= z388Ipx%|7)P-s_p=_;q8XRFyho`%_gP#6^acxXgY*g|3+2wOBO7*`$o8Xd;FVeoxbv2aJNY zqjIBvWh}^M9D~%iO0#nD9bSKu7r&GQCXG0iAp47gY=U)_otF~4UtY@Fa^uE4Ch<5z zt2iRQyklo$og%LZs^(kwu6p(o6N<1>x!BkqTr^Var%dQdCjP^~?~ddQ=8&m-PF0&w zZLd{Tt#i=V)kUFq5eI7Dbz0A;Va+D1Jb zU2|FVFK_t59i(=t)g#PvG_A~8SBzKOkzV~+#K4Jb(+hy+e2-vAbSkvuBlUGm&?(rX z^vCdql1*XAQ8jwPe=$X&%Y^#rg_2j#p|j4Mdwe-f4;%43{{b}X=pl6I-j(!@=rfPy z+utD$SeH-Hm?m!8N~a2rWbKvmf#194)=jzYbURu}twiw!CaC}8E& zuP*mh4M?<0eo-9|P$6ndF%1x+4d;IrZI~(_;#o;{So2qP;c!^*U+;<`2X~%b*%~)S zOB|OCvs^Xszi1#+R6~q~9SfHRX2xR%j=hT;&SH$v@IAGspv-t1~g8>8XHisW;E1=IUWW zg{?p$roN7q5{wBk9XpZkSw*(JIG4!3S^$cZnxZz0}d!v`H^tNo(8wM4-U+N4MKoPX?9Pj5h zg$@mc5&2eM#D>l7w;r$<8@`sK%It8Ec>p!8O}X0uyo8;!?0X|>5+zCKe%O~EneX}m z-7_h{@=4xI+$Rrs`yPs+-<#%bhz2 zmsk1eZc80|pFw3r@87%GjVIAfT<6#HuCHPmqLM9(9t^0r&Pk4MMO(>MrYa-Er) z>1*%_eiF6ESe-e`(3+mM9p3CdFXfb^3c10)O*)XgDl(KT$oG_2l6|oWgo)b5Rh1>I zymBUMD-8U*1?}NXK&o$%Ipl0pwGj1ch^5wt2 zWyl;!gNhxJp^#Z*j%`SVN;BI`Q8K2?nM0;XWlS^}GDIY$WGWhI`mMV&E< z>pItSp3}YW`#XF-Ypu^(UmZtTd?NM#8LMWx=}gpWL5r^${Wc7wc0j)Uw+qjvi6#?x z@I+hFS0J55z?Elpid;s)K1g+aV1DZ>)`#?F&06yM$IFA-Uh2JVjpMksDW4{T1yrR|UjGH4B!B|k z05lls4SB%2@^D|c6Mvjs@MQ70FLPS;QFD`q3*{S|lsc;2HDrv}l#Ge^@qGVcc*B}q5HDX%5?N0LbT_s4)w-XCCAOmW#8lnW zVMnDKiR@$vEUrVh{vi^3x| zG*dzV_oAEqqnJyoTOdm}$3YZBBmBdoqgir?Mmz%P+cn0~bMxn3gXO#Z^>{5)#6pKq zsKcD)qpExlh8o#k64r#Zp&@t237Y@E?zhtf1pV)V2eEk7YPJd*jvFh7!+L2N-$*)^8Jmg%pq$SKQ#aVLS ztD7Y}DLNWSJ63vHn`v80FK$b=Ftv`u5cRltxya&rS}BeMN=R!CCKt5#fBfT-#Qf@S zk0cdiR>C9cdjgT)!LG1RCP1)V#EWAMtOneY`IJP~wZOpm>1eD|IivBy{YN1wIP)}p z|9tB9z&pWU{iSX_7}>Qr;vTV9Rg~J=l+1RczPETBVLnmhowUsYeP%E zFN@w-npfc2{$t4M4Ag!Ph|*=f8~=p9iYf9x0#Jt#}ppvhYqM<`o^d9Bni# z1hB3PG^ZYmbt}Et=j;qdumG9X;PDAw0|Nrre?2U8ORsE2G+$zKJK%)pX<1>3?J@0bl57H4pd^zfT0ICvP6 za{i%>JP->B=HJJS$HIhlHTN{`%*GcDLzn8n`Kw_xzq!ibtYrll0S4lS?*u zh8;6!I7hj+Mkz>VSZfF7)2x&Kegd7M#{tDjvbhF-TTCl`OZA@_UyKo%s=Nx19iEoK zeAqmzZX#bwD~&rIigQy|074IuC0T%O!NSeM7< zTwp*5_vGa{Yh?nTXU-R+yRO>$9+JAjn@4vqd;7b`?Y0-+wXwc>C8^t;`Ohv0w=uOo z{6C-E<+#uHG}alOl&@%P2MT_sS+7=NlQDBMGp`LZGr`)b7{2b1==#K}z0^d%V7=V+ zRm;^&t~2w-SUAkryuEeEmETgxIalhwtAnX0;r}3Q74uh>Q$H?F>acjOM{7LxlV~m9 zv3s$aZfny~_wiY4dnm^%@OgQ5TF-(%re8N>uLeQa(qZJ)2bM5xT0Sw7e!ozK*xVYe*L)TH#&-{;cVFPNF< zGyPhfoAbbeHJ{e|fw&kpI6a0o#`=QrqM^%wChggSucw)9bGj}7E(bnpKl&Wc!DD?& z<88Rcb}*g1kI(-4%I0%o-Fqr85#0R<=^s`Q5wFWP`}fIS5+Re@djl$#?GCy?z|R2> zX16Q-9}bplSW^E2pPQciV<4uJ^Bd1Lia+pQ9$@|PmyoU-xm2|XJv-ni2&C@XfMYu@ zE0fsRsQI|``<{K4Rl6T-`CriJ^4-;iBo3v&T-7TRJMEr@Kjtt!Q^3)tBznx4XzS*G z_I>@7lb2WiePyebhL3+Oa^#`u>{?jdOKQ`aqeAm+PN^S4)&sGr+T$tXz zzq{Q1fp6m5OYc(--Fjr}zWGqyf=nOK#_QHqR8?8rbAU1k4tWkWSO`i=vmo^Rj*(lY zP?*3A*D$f_ImG6A=4=L}rlNMn-mI^8c_=*8V8^<={yh!sERO;`gUDjGjo*&l0Vng2E#V%j(A7Khyp-<~xK9-m^fgz&x+q z3buw9S#|o}IDs(%tE0GKKov)YhX*{mQ%+J?@CL-U9?0Sc4@5Q`T;-LOQ0f+eudPLbkqkcy;i3jG|I^_X6yHPU>~7V4HW!EuVqo@HIxrE^vMby>2f zCCpk%=IfK?SBg1ihMJc&jjPw+M5;D?@3k)}167rvKU^ei?#tn7S#4hltK{{k!QqOW zKgy-H@T3w(GE1z@%>~Hh)MDX8DBNq6^2QtJjHQ#>II1QJUdlqpWs7CS{jb_u${P7=t+rcV$)LwkcUzmy%Mj~I_745Gm1 ze?|9v(j7peH!UHYw(8arp)65A=R!YV%@EI=q&;IH_r`zl7Or3j z2BoBQa*`Q{;IR7<3*8rNOrX$>;fu+tKx3zreYCG<=)Nr0v$qx4NPO?($99L2u4sh> zmnBKCHbsTkefOjf@tO(h)$ai3CN&=F6E)In-qtd>7JUN@qM06@Uw-@<(MajIbtt^( z?(T+OzzwA$UN{hh?Td9EKZ^vP-hVqf+PSi0wDp1+{$@#BeuV!XeDN%S!9AV!fL4AB5;ye}Z zdK|&YRafOGWl@ocZk1heHFVz=eNsvoYJmKcRuX!>6QAr1M8ol}2(}4J;=s1H@=TA~ zF&Lw*w}+xPi{J28u=tmYYpAXh4P}fKo3(C+=T=o))DRQBJ;UI_Wr)v+)&nA|+%{=B zx}oc{7o3yt^-0d}hv9?xycSFXirhZHZb!7Vf-bI{f>Xob;0fd#`+e-PvHYUCb?b^0 z=I?7ICAaHHIV$;M5Mtl`gy%W$Qm4k2^XNXd(#&hR;tKY%;z{sG{^`X7J9k{kw{N&`Mc7@Jvy^Kod)gHu-k0sB4`4W0>79;!)u7@Nj$d#SF za{o^7ko)Tg$suRDX9qH8XAkl+nmkySU}n?4TY%EIE~g$^9<6x@@)F4#h3r&7Qk6$d zIbqS*YF-GfZghhnx-SagBVn!)kmaYt@2UAY!7#l?%2;kC zy_FUE$NQ{6B{_%Wuz~pq}1XpLpDv{2E=vX_OiI;-_hRA;2uM`?!Q>*yg}4*y^KtT zZ0xBYbFZ-_*UX~=J+n=T;v<0@?fI4cH)n#IeVDwK+~O4$7KRPVG31VmOG$-F(8W4> zdkl=U@o4l+?^ja1Pu!U>n1 zM6xJxXj<{(-J+K{1!YC%VM&VW-4SC?-BurC31m%UOtDe4N)HW9?yXMIA;|pg56CmB)=Ub1zgQWK6eSHbQ#j1x1YnYhgrUspy z8+B9+X{z!LiCCBr-U=hAcmuCu!8uNst?oHmaVObt)dJ+$CdUX#@7YT-1N zPMC?mzMtsmRxEq?!d#)6leCwhprF)3mr&Z#$&$MT8%5HcX7;Z=y`HHskiX>kkc>7f zSx1hqiN}8FD*D!+-;_7X-ZZaTyuh$A+k2Iq-u>S%`}H?Qf526Ofpv?;`GQT&%sT0Y z5-Vl9cC9No_LN&g%E+BJcbz(^u0}E4fz<7qImlmfQTyk>vmX*$I3X7LT0&%yr><|E z%=lm4Gvf0aZDF14w)&8|jg3hC1mPbT&p2|FGkf9M2UszUgPpAEg7mrl(4Gi)Eq(DR z;Wk&L+fmxpRE#udW@hH)>ApC1hchIPQFE7uSZhx8?4~iH>Fizxr>NGl^wG0*(io{ulE z4l6zMsX>3{q!8}y2fIjXIx-UKJ{(w^7lr=vQ0}SEZ z!Oe=4GBJAGGxB1K@K*gqCFU9V^dar$XY``-d@d?dL<+Aw&q$X}}T;9oTW3OLdRm*S}qmGS^IxDedLDuN_LgCb$9-4Q9`HMNMOis3%FTFY? zoFAbGddYlLh#mD1y?uuM4v53=g1L8gaT)*fz1BAFwTjt!I8VW%1w0TvqZ_WTozSyu zolxgyFH4*NzF{WMkkhp#)h8VgZu-!j`)<@weq{I&-U@z!Qx&1w)SO={@|Bh!Ynsik zt^aoc=A%Yg``vn-BOpLO&kL+SJF9#A)67kRCFtel<$xn0c7KV{go~q(O`Yj(-<>W{ z24C+?&1nFvz^XPPH?Ik zOxTvEo6!}ke~r8RK0~6w6LGrIUr(|~6e0@+g>w^Wp6R!4ZGk6q*qO;})r@UD-QD&{ z%QpM_n3y~!+{WHEoNXON?E)MHLeBxDtUEf4WV1+bFy|1|J1XX3KqsFqlugc6zxg_w zOckY2>K;6JFkQM|BJABY8yj~UoTi1m_Fz`?LWcW5q*gsgv93wH&-Pss;mf&sUpnrUuxB3TN$k6%9~<02fKLNmZuNpY?N<#Q2( ze+R;B#cA~nrV8nYB-hpw3s3Gc`SPa2oQ`x0?}8hrb2(Q!vMD7zS3m7w@4xLjP5*Gf zK-)4U)Ew9CP=HJ5$u#)P6*q8norUw#L#X4JxTt4POQZ)EI;6TRQ}@`Z_Q~oVmQ?AL9_m|f@SQeHUngNKXJ})?ajm^+mBA}>{R5`gzoliH zhgq@I+CJvaniNu7qC|N-v&6D(o}pGwC#PIQ6qhyIF=S({%~ri8ATB9+x3RIz^)sd& zD&@-SxlCP;4vj1e471L?RntVYG1|!s(lkBwGn3NT3f?@hqxLCD)WdD>QOYKP+dx=^ zwj8hOKJ|%I0n$K#m-pq=l*rPh_-{SKES4V(J0YtJG=)Wuu#>i_=|y`c1WK_ar?uEV zX@VLpB&CeS zU}zJN7`Ex{&A@Hz?H}hDJ#|c809l>u0~Cc(Z>pc(N=swZOh~;gck!1;V`#3DVNTzxV&Mg0i>9iPgRO{=@j?W#OwEB@;G^VuaU6g{8fPL`!YuXkx`2o3z zyt?%~uVwO?(9=L(M^Xv+(;sF&N><%Prj8sBBKM!nedI0M9-8CSwrIUiwQc31USkVq z_>XczpJxy;Ft9Q6v_8e~BX@e&YQ(2AJjASh^!fBQ#q`s8-3x5#9Cb+^l$xbR4S6g) zDSB@UNmLRB&-(@jz5>4KgRf~XaVD9WNs!Vo?*66XDqw=6T0LIZ_EB5t(dc&t^-0!h zl*ot(o&iD~z@7^$VN@=zA5E(pSkvrtxT%(wCLBMYPHSqo$LT`iC_;cJn}EC>f$xu$ zp=m^I|Js_H+88U=d&dclC~b0;jaH0wru?T2hkmq`Y;-eK)#c&l^<0Jlx!X zBcS%6^V~vEIxck^^=?{jVPR7<<}*Fs>OYMdi~RF@T&Yr;t}ucRpIWs1$S@KTxZa4; z!+)m!&=5wa>(-D!R-!ne4QAn~H=~k%;^?<(c4`$+!e{{2t4VFEGizFbY#z%M%ovA3 zrY%#>&}WBIe$ysI#tof z;mOOp25+5Mx+e=%DhXqI{P%0| zP%j)6DuISjN0uQ~wuSLmBYNq@+qYNFpQkQurbn~d>uE0CTM}lGlp|y=M`N>X)C*dJ zjc7t8;<6@Bf^MlWzZf6?f}CLdf}`qpU#8u~MKul7`ZkY*h$lnK>OP|rlbc|qN3Y*Y zp?`jQXvyc!>;|;95Jb3t<pyJHqy6&aOoWRgz_dYWv5|07(`^v zf25&F9aOA7T}rE)gB}3D$@OaG1FA_)PlWY->5O2gPOFlyuW7jHE^%-l& z*M|)OFnOtz;KVjiKpPLGwgu(Jfc|M(Fy z_tD!xnJ_&Y85s!;7Q-8Yovf@>6(cOf8oR=CA}5ba(08i4xvh2yfaTCO$LckeyLRr> z?)uX=!IaWF8D$B#t>*g7NHRBZ$QH+s*86}l*HtWGo%Kr7t-uKPsRigF*fsvdGE0)l~MBGL?XE&;Apf(MAItET{;zO zxj1UuWU-%!h%f34=@s<3t(o?9d`}kWRGWDy?a?-8DOnry$uz=AMZaJd)1qynXPz?` zR$$2cai(D3z6VJcItSJn`EIy+2nLMYb#&)&i_LS8S?Bn5 z`}*w3>s6-szjo3@ab0{V5AAYB02L6?9a)jZ;*^(R z#h%<$2RZ%=PMay+Sz(hdnTHd_>0d>e@?tj6OfRctIRA++Ir3tMvxclerhB(ow{Ujv zRa744nPE-+)PQs^9!Hj8hcU|n4dD#%PbUH0a3K_03Eo&5!Y&fGrv zT}@35wZj@)nH8361S9!5G%3(f*}@RRbYp$_zAGo z{z7qa)gal-(9mwqCrN_dxIjnP+~c5?P|B0f&U!p=tjcxDgE2%)-_9B%ePQmcOA@(L zZ&2_MYe-e?{fM>I;c}GNLP<^8>zP*C)qg)0nwjCSXjTfdKYo3`KeaR~EUEq`rex*i zLnd;Y?9L~3F&zCoF zQdMYZlTEz8f24f>GuyeiNDJ?vo|>Lp4i`$4swG0R!afvPWm+{f&aZ_-iqGN0S0JP9 zd(hfyOtLpN?t~gna~=h()&uvwsyIGy~%VfH`PB;yj!W$*?#WuIF?=Y*Kh{9skCz@_>ND02wIjwiq8V{yj!2eRskQnp?*L@T--IX)y)F8z#o! z-oCw!N(A15r#G#pdqYz+d=fP*))&5}8SU~vE7Y6siFhOJPNR}usS>xdJ+vS8%>E@p-G8=N&3*2K(}vzQf;a~nQrxA{6=3WmA_-u5dtU&`TUUnCfqlqHMTLd3R$ z(Ykl9E=9$*JCdc+5KUESS)sjUHP2I9;zy0q4u>qw7J(&0fdcqkhkH@p%buT1rrCdVTl=p+3wLorlzLB z9DpPRJ%Y1B-NUeT`TYL9yYt&G#rcY;{l5HZ!nkH$ zzkI2wsVU04oAZn&q~E_gDKRn4Z$t`JpdEZ%tlyRVX4-tCOrpTaZ8jj;>tkvMA@W%X z-NdCMt^WJc=|Wk}TbfxZ3vMunhn@_u0?ZCD9n&9sG`%C+AerKF4z~ur*e@5FS68Y{ zVK?)X%|vSKGt4Q?s6G@>7+ihDzfGQta*SZ~RzfeA3%Ut_a!-~vO@VdZ&(x{h`M$FB*`dXpSk|%Hhwyi=C2i{~T8|45*zB z2#ei5eVW0Wsbu1g`QvCCx{%m2ZEh-~jIX1jI{k!F_SJk@QX^lhk%fipjZbIqIG)@5 z2_lX0!iClwT)MSmg!I>%9Xawv`H|YnPB0}`$<*o-8l=K4FZ@JOhvQ`W`VG(n`S5fV zImo>7>J|4;u)KAALuv8xac7y}lf!oyFCBv1A!>`qMK&XK%ktCf&zOEHWvD8udPxWw zkGz1i6IkRs%%ye?X_?#_GS{xt0+rAd-T+r#f8cKPlPO0;)I;C$2?)HPMqAW)Jt9C5 zg2H^K)~C&C4-eVCEhpy7xQ*HP>{0`Ts8iBgwhTR|w+s4k)gv$b&JT<@>`Go&n3{TL z{Hw>5vqolwGSRMi85nT*K}_Ali>AlOiKR_=0@Yn#U#WA%dN~D!T@<>{*8sBEW&Q&d z`HUAa=2o~FyQ#}yav^p7fssZGLJaI5!Q9y2U7lt`?O$Dly*<{Q%YRiI=edzdsHw^ z>PRmJT)$8HFjhnM)vHL|HSy$4tSfDnmXW!B{Kv}6%_OQ$r2^^s;NfRe0pC|NGPwkH zyX%7m^x@fbhAbf=frG7UybNluz#+^+ye_O9l7=j6ja;vDr?a39J|&q>_L%?*nE-2r zVh#PFGW7~cwi)5iG>)2ACTCZKII3>|6rbiW+&GC3hf`gpI7-@evnWh>kpUOxj ziRvI7o4{9*Xzp(T)>vX9PlXs!x+okM76+v!g&t^@-DcI}}`p#Z4{W%&- zGLxQ>Q2Gg*3*l+(tNe_chE)AB4k1!g6I)!~{P&ySjFIyQV%PMc#*h(& z)&QE>{ev?iGP2QI_nEG#%3#8`8pg`QXAdVXlZ*kPiknNOeJhIeoP|9t69lLB86FFm zH6Br3%AlsZVIdL!AS=rr!~d_A^^1OQ5biGm+V;jwl$f}(^83R-StDVOdOswMr>2;n zsS8(kfmc?oELL%|;wrD{*RLgcMdf0mqf>1z>~>M?kWvbCjxt4ayGG>Y2K2T(+?)s% z?)3UfaDBcLw3HP+>AciPO+7TWSCjiHUO7kUR4qJl4~%FBzu0E@{Wo#|7-x{wTrAO# zQL_QvAFisAQmF-2z!C5#OYyN{FE0v<+Z^#uzQ5Kv5 zPu&f*&dtkSdf}An@TZ3}@)usLDizWvfl+7w`0*n=D^M|+S;@+=`TF{jHecIwV1=L`s{vcUeK~!?brE$=y~CU(L+S9K0-Q{0f^t#0@pN#$f!?qIAz%vOev8 z*OAu(?Hh)Is+<6G$E^-^LG6#w#-NQRQQ=ne#m)5g64A`V&f(?Fsd}bg5sqB?O^Kg~ znnuo(fwr$tdCPX|Fu{rM-d1K8%6uvgfs&Zu&NVCCQT+56ls~%ES z{8(Lkp|)`ZQyOuZY5SyG1s5c3Q(YNX~Zz3yB!}94i ztT5t?tK;a=MAu@a4$U*Kd&+4L?RDp&R2L@@w&O2deywSPP{9c?x~4&A=aYwwzX&g@ za*41cfsdkF9QiLO8Iay+uQKWK<)Siqy>)u4w1Fy?T!&_a%JSmp3#?io{SAsDf`Xx8 zcgG{Pvt(#7X%OkdfN*FR&kBx05-^HJF|B!PGU<&?qC+8%=oaBUw@=pg)dDXjZr=8s zH6k{qbFNm676O!2wDUg{hgiXSeGE6r@4?o>DAD@No6WPh$aA>sf%O~7`87p5=e?;W zrC~djVmnb{9t}1uoYro9RL8-lC650Zjqk>-e(WY{d z(2j~1>OXe6es_Y-U2Ypg>rPrl`vK4#p!@D@eXbga*%njukxr}R6siOMEhC42%Av&! zf9}g?994gRl}CYpz-;2$`HC;k+GNE98VYdfBmhF-^(Q}en-?$C#;|2Kv7S|&u4+Yo z)Rk$Yl^%WJ`-^8QFTnc-q*{t#Ck&QA+$6fHsxb{EVW7k4>(>IX;+h_`&gCB=7KQEH zVSI>%_z)()KSaG5n|{>y2ZD-rrzbB#ThO*-CM68XzZg@FTE2Osv@{Ku;6v3^p|)xL z$GyD`JRb2c&u6JB6bLi(yoQls)gr*8)h%i|I&Znd%UM#*O$o4@lpQqY1vAnu^J6P( z%!H!PWot$H?!Z%b7Ww#H2ml2oYAlz(BmETyX&Fjab>58>${^Q|kJB{S9G+Z;!H-lo z^=Y`7L9h5vuRBvI!!{Bjx=z1EM1rtM;uPu5GV@t2BXbP$n>8QE-2pKRm~^klu0c>N6Uma--Xp6=e)?YelNyDZLiQ&@3M~3Pky$znfvn9-Y04$*C&cuZ5RVlXh+r zJSKo9EO{l}-!w9XrQ~=NTO3jwH%Lfi^Yz}8VNgc!MS|Sb^I4nnW<|QCnn`JnHucWO z$WT`3JqRqywHO%p^${VVs<70|^Mu?YKqRv6y&e90kYh!jJ^KkH?cY3;<~&3qMD#JD zj$RId+0t+^F*Oa#_=nh12GsLAF;{3^YI?duu~3|tRdB--wDwC>UVvPy_;#2EjL*R) z1vFccRk^O7kSVk>#pp7hW@5GubJtBxO|5Al>Rueo%us47oD6Qs3w~0rYc`kX9Ab)p zrHPOr1}|CRH77`fNnxqOq=AsO;U5mzqoausWX61m(FQp5LZ$HbQ*ccnE#ov9k+Kq? zTL%V?VG=a%?YH|$Dl&3%1|IS8@$eGHP;S6O5!={bWZu~V`_s)!B$M)quTvpQ9ZL!? zFSOlW$@k)Se|BibT)MALP;E)Y25Q3P%WzKphTVdJL=|c2#UIpbTn>^uo46GYIeAyub=W$b&)WzK!}PJcSI4^L2#(6xI&BE!WG8LDDITwKm3 z%mKa#odU@R1!FLsP6Uq39h!kmtZEsTTEM{r`K_(G)x3YdAtEvJzzV}ys2YIrYEHFM z%4$HdjfowEYMyB)L9$sjN}U!qGrQV-e&|T0>QfPurOb5lXSAmp8G$X&b7uF4j>pgV zOsCS`-GgNn;2I201@KF!ACm72M7cRB13-!6GQFx0x>$SgyOIh+?9ru6Ep>dnKVKE!qf#*0NWOp5b2!_ygL}iJxuPjKH zd6qXRDyYM?_x{_bH+Tf3Ke9{D-dBDUI)zz^a$aiaMkAX;W^vQ%!~Cy?#yBeUmuKUe9uUI>eg__*YK}IJpV9!a{TEBuUM@jx%mADXC?7@9$c{lGAwqn? zr+4qF5Xy3B?o5&Qr~fIC4D4C{kT8v2ue@^Qw#lA9YQ|tFuLKQ9VXc+obQp;_V8Wwo zv2!&-KT9+DqT}OxjY$+;UZM>eJma;$YEvIK6ihlLQQDtBehgv~TuC2UMp?LsG*bzF zp?C7nV=h$Of%j%_;a)9E5~sou1@v|AQXtt4QmiP$JY8>Zd*CF@xxNj`;un|!m zO3KPy7eT-3Sw=PR-8>6zMzU<`G!h7`_dJF(33>^90?m=%?bhg_r!H@P|2h$7rtw=v3M5(WS&5Xe z-@i|ceXOq}!xqM-WjgwEf`@BL}t2^laspoY3H|x4DQxo0l<~SejnnC zvD!M$qT?%i;fs%3w1q2NB?cH)<1}`Dq1X$^5;ITB^=aR!kck#?qctQ2FInb<4cR*E_%-NucWE)F3jO%EszJ4$O! z4hj6!y@{!NfA>;Lf1I_rShv^RS+S<*=QD%dA+K;G6+Q4KLj6H_y3GbGB3hwj0klFC-1DhQ7l(72d?&MCt?w54B#gUb?$A{$h$K z9aNiIYbl6Gq9bDHT|s`HmEcc|^F+PjJbbKnP@_kRYQo~N>^(V0wu0|QKT!`1|6LCZ zl-7nm#dJ1?V8eC@e7@Jx(k|vYv}bcDR+18Kn3;FT83hjC}yefMXA*@Xgx zx;}B(S{k(8EDBq8u8v;JtWOVpPhPf?t?CEdWIfB*AT^|@5< z8j58z>S2P#wRcYz#|6tQtGUurM_1fU>C{WnC*KWglvtzdIZ7%J{hQHy)0(ms4yxIU z&n%Ps`>n(KkSwvaU3zBf3b%Ert0QoIozEXWTtcj#^RaseVPP#yQ$N0$G+YrEUuCt# z0jIeERjO-o+>S|yZ|v;sldxw*VWsE~fya}bi=uuqJwDA{o<~x;azcrBs)wfY5zBx?5@!gz~!fomX>Fbc@{R6Z3RwtM4IugTJ>p$|0R(p2Q;za`WnjXh=%q}Qha~Heo z2o=5%4`rU)CAa7M?UN8EjS`yZ73J*ZSMP1dDlLnw&|4M(DGWlgN2A=C)OST!kaw z&}LYEa;>>BX4<_jTeI+vF4fX6Om^+n(I8VwK7XRRDrp;twu^>8K=JaV-LU`ev;dph zkJ!|JQ|2+6NT_Q1_$NMgVAUC`E#y(ygOE)bh0Waluari;E%}>i+?)(~tACVU0(?1o z_@bZ4t_*Kg4bsPrmycx5;hOGWZd|w{u{Tke@{|xSL87ib&`5MnM_2sSIr%`oSmYs> zZ6Eon^>(ITmk|49w2R|n^gOFNrLaL*=CQD_Y<#(2V*$1dE30!YJvvUnEQm(z7r?gl z6|?oXzi_kv=kpVj38+sDnIB(2A{n>N!t8=5UH4Yb+Wq_AVnE&M(VpyO@@@6r1_8o^ zS*)D_d^kZ%g_zO>gvirSmDt|Rhj3%GVhOji%If#k%_gW%asFxRHGxq0`f(yekn~=) z**X6l1qzy7bkA^dYgvG)xF6_oG#qc}x?NeC26|FZE}M-s({C0_o8)7u5i^tCB(59l z7w*PNv)F3M0_lIePWh-zhIiZW@G#IClNy^qQ+IAUJi*O2+8yCSGZS@K9MjX&zz zJ)oq*hYc^Ef8rmr=DAn>1pE5S@T;4(cvSS_-pz-UZs1G*=wGBMW%;o#t4m~J6QPM- zYI8$ycX!2u!Bs||v&3Va1MbkPN@NcU{dzF|N8a9t4(P;uRxG$8%k-=H>>poBrSx8c zHR+&TThc(-Zv<(;SypMXW1nqO~`rU4C7cA zv^7S#9kn<8wLQ;9MAW=Ey9v%tc6TrvnoEDx{NaKrFrNv&T&VFycf#tPFfVyPoQ%f* zMwH@0#fQVj&vQ}JOV}SfAxI&VXD5;KsgV=i1`NfSc;e*f$LofVAfZV7$O}-)kSX}X z05bLf_Jt-+%ZDXr?_$~NlAae>UN11SIn0bk@{=mlw&KZUKE5B`yR5lZRK(dZ1~DD1 zs6V}Zc5NolWo=au)RoIGrxwK9sS?#ww}sWRPNyGzO!8@33AgwdE?>!KiFBE)2Ksww zd8`{V58g;jkeMPG0;Swc>N|0pU)O^ht8=%W(@3))_G_Az99>;Ck1DIj;QtP@X!cBX zwYgmL_bPsO=z%-$B;fWEcZLPJ&fM2$KX(es=I|m`#Ctk+t5S}RKR0#yo zarLcyxF~JGrgV`em_kAAIw{mLvOzao*Jjko@!AavUh{Of zh?^!?ntRI1ak|kQ^`1E-eO|YX&O7uoc$8*E2scL!gsdDY^YX?CHA7tO0lVouMTxWm zX)g~8TZ?il(it!Ieu!lAetuUm-kOM(SJv3zr)4g!m}U?_M$vMQ_v**-#IC5UN{Lf* zqXHJpBbgnQB4fT$2h*20Y*clVVnIky(B_#&9^oz#JyO&Lr(`kuVt!eRJNs`cQ#{_#qdU^E_Ec+hVE_G8 z8v-qAd;&>B@o%CH^2_|2-N7wiZvU9LoxSVI5N>2t9ueD(p@l%IJ@!vh?^L9eg z=-Jlm0=y$WA;I%q%Dv@xe^gYPJ=sNEcc2zuWf}r%F8CT;%U$T&eu2;WE!_#L% z1rI|+A@&Wr*Ee3ujrp8#DJ5}oUdG*1b+e+Pf|y^?3$miV-TrAUg}&yGa^JL{e%fpY!fZCwB@u;|APk{G4B7Gs1rE1Y(qdBm+eXyzRJ9Uf--BeDNf?c>jdrB zu>UieN8Oj!ssghnm@>5MT~Zi&wtIcpBzK+8Cg1Ue!gG&z@A5=-4&k%B%Hq`Ei&-w^ znW{rmVa;O8xGS|rfzgoVCC&DAQc0#VS6pQikF_1Y$e+9WK}ybFFO0gEuqF5{)Vx~= zS74`tNkVM#OASbXT;&T&c&GQzh181OioC;>mUiUCad>d8FHBFTE<0aaUImJA|Ni|Y z6>nkkJA~2!wQ)bgQ{{U0(N|Fv`U1rPC8zBPp`ymkAAD-hgsEGmEraTz${PnqoTS<( z94%YsbB4@{l@fA}ng$+_cxcZ5JU2g|jYJ~nvjl~NY=z&S*a`f`#gY_xu7W$CGQ0&C z7Z=Gk<%{E}yVh@^6teIbi1Y7}LduQPC?Bq-kgZI&3|c6Cj+@J~p}G!yC>>AdP~KrH zbTNv1MB~brPZ{uq6}UA&p5NU1@E^91faN8Ai8gcQyOkE){cE~_L^oWgdzvgQ7kX2jP;qj^rVyZSA8zv3rS)){T||PA-ouVjlP(dO^0L`S$=*`PgOt`BlE%6dmPw3bv=_& zvj-W_f9pSEwT4RX#E2R}ZgCuA9P}!`E{-*TVSjhU!C_?cs1;j>1S!019ejgu~5b!TYfrSm9q$!hY zz=N}g=F#fS{-{fX{jMWGC1?-%BW)FZG0xhM=L56KlIgN$FprE23AsyW@7XP(lH?b!iDj_z%{%A`>GeI{4q>b2BehLDAum^g?#Ncqx^ZckA zrh1a}f|-Z!?c62kh&B~ja2LOZpO+WX0Az=2%)Ji`jr*&%rqdKh zj==H=ZH~hoFv{RMzW^bL{sCUE!ERv>&>%adILDGn?s#!J<_mxW?Y6BiO9nm>ufqR4 z8pzb)rw6v{J2aP=anKr(b*q7_sFvT^6QLBcV1Q)3}l5sdOufleQ)?jlPds+OuGI~bag~kCF znORt*JGlgzaoFWFK3xxsIgQagAi=0ZD6ZS)F-b1BU+RPMt#7k>QE4|?ggY+FUq?5- zQcTSI*Hn!zeBs~z;1PTneU`*6vJ}*#<0Yl5wAB%O@_TPZt~PtRjOkK(c@BeerJuph z#5$h``NGHXp5dlB6U29#g)bh%p>P=w%!f&`9q0}W5r!wX$TPxB_e`~K`NJjdMvDiu z4muN%nI)bvlmJ&HT( z?M9*_$*uI`U0q$TA6>bP3J5JQpxL?k`Sf84-9 zsUsi5&6iHk5(Q}(Na{3>}(o}p0$hlvfrmJrLR)wbB zjfktd6AvG0NxAqcVQ4Sbv<{p+Owl`hYoN^=Rds)Vzl7)B`da5(Rz^l?qfuT_-OhI+ zJhrozXurn~iw>I7bWi>LVo202{e6`ml+e1fP$mI-U)nQwYVjFaPer*PPd(BUjhT($ z;RTR$)RL(Y{zT=^p|NX$X>V76S}Q_X|K@e(PB`aA;W)ZY)i#6j+X3ywuSv{ z!ZB3$)NR|=$d$JTn3$WdtSb4IpS*KlS(Oo$Of6agiTdS5ZgZ}8xv8}b6lmFbiAs~@ z2~(FSdxZ?~wUB3^At=WGXE`r0AVbC}}B4uV0( zOcoEzz{UCs49-#ZHr5U6{4*xcCG?^U6-M$}sr@%Z0Rh*Fbz@w%wLx6y7Uc3zcOM;l zX+d>W|D+EFHEAew`|GDKWy_~PZY(pl<(+?dVV4POTIVT_2y?{rc$!+Q5GcZ_{W$Rb*{FQ``RCp7Sd%;r{hL z$0&rTn0%4QCGrNwy2C>`cVPKU&gAMY#laW!FQtfNN=C&AIP;NpVz#*4hmy5*{u)+iEphB)IOYP= zZjoT+C`m|7IYp|N<>DJtOWe97*hk}(ynL$jH)PuH^)-=yz&3pj=z#a~RWCE5a%VR# zR(fMoZ^vZZmq)$T*L=e2!v!+P&KR+70R`KI;@9_l{eYBZ4ZSl9RB{iNAF@k*!+OI_ zGRM6hX4ZvgoNhc0c#re`rq9zF?=Ze z7j4SDYppggmB;K_)w*_=UHupFDSvtErQb7(BT+#hl&=unTT`FOGKp;%ftlIZ3q9DMrT~R!wkPKK}Lev{(MEps%79_wU^c3k_ATTAd^EP{3*c z7P5n$!L!p*1z%7+0tnaCjL>T|XB<&>O|RA@Kh51O+1>)rQ5xjzXoW|Y5AZU`7@FDk zoCo}J8|2wA3!t{PhYulD+$w(qe!ElAO7T#3hXkg*{X_e1f1c%sYm)7YVC#ZgNr{O< z+}xd*w3`X3q#aJBO!Sw^>K_bZvYgfw3e$dmxd|)!m)ZmDKPyZh$S$j8#zo(8kVuAR z57#7)UWBhU0+ra&kj{j%TRmkc4`Yhg>CLZWDs78i;tHi zr45c#`xZa+{yy(Jonx@5?4XZN*(aavH#U>f4xU=SwaS4;%j1Wi3b$zD-z0ARc~td} z(5mIDVSGDu@Xt_T#Z&po;D{qWL`88_>?RS-yk z>SB`hsM$td{h9+jJz9AjEGCJ3;9-)Adu${A!V+IsBQs`IY^u@{$9KD+;Qp*o=#g8D zRM*OCas`4l2cPOCbAlP&&VyhKSAF!mfn?Z~N!OqTh7JzKHy8EbF1EC>^q(<7kBU7b zI}|7dMMcf`bi*z7n->Io*cB>v7a2I&U(~~v6sN2@SSh!1P>R?z>sErAG1}8$R?eB_ z&;ZOb8T`r_gJ+MYCRzbDf(mpci&Y$ZlNE1rj_9y4gK!EmWOyn>8qGp2%>M?nne-jc zj=zuE=JBVi!^~hk$kNGxemB`IBOlR2Tg3^`ql@G(IIT7fW?F;XQxFaPx6&b4A$}2I z2qQ}meFB?{axzsWp3D|A`toJyAaF2iOdsM%Sa1x}Jreo}qJB#-Qs~ar1DZR}Cb?yS z&Tsiq-w@DQl)ZU8m2KNSt_*FYwkbtomrPMAQzC3brpzRgNM)=@6h zAyTFk%9x0dD3UUzN#;b9q>L3J75T0U4bStw-`{Zm(|v#3WM9{L9>Y4;S_j3ow8tkt5l6_hQz1CIf(Y_N;gi{2iqQlyUU7cQ*4 z-#QQTU3g2Vrmo_u_=MzB<%o596-W)i6BdIYfb;AR(S=G&(oFO#RZ(glnbB#J@+?7f z;k&dH9k1_(D1veLU*yk`#SI4vDfO^@XC7d{LW9N zm7HFfi3b_*c#>PtTC#+5|O36PK z(NZrXQxsn1_2vXN+Xad)k;Tr0U8^WUrpILZ^By-9S~rhC~GYH>Cj#o;iny`sD<0cxx^-gY!pFWB-XEq~1#qk-qvnvVya z3J2VMc7)ZVEt&glgrpuORoqmEl}9|bxu18U|5ta1j({Fll5*cWIoo(Ojfr9AQ7*#C zoOj+Sj(E4*6jn!B?+CU$_=#cls($sG?N6P~3e)nd?Pg^ipfmYxtn_KlS-fy1z*5Is zRr^WcNTv*ttsV6~dKPKSi0l$wy)iUTfAL3RcO@ZAq}<$q#LRC!&y4t;VB#eDs-lAa zAFk+AfTPc!c-cM;xD@Cf-<(-41QPP!#9HD+Y_2mnoww+(rcITH)#NiES60%p{B z@l%B7BiHrJGW(jJr{9V0+DTrvdr8qY_>Dheh)YZi*KRtn4eDsPKP_sCITWrJkhc@ue{ft{pM%Qn3>|GiOH$!BQKQY~sh6TN ztjKrrh)kQYYC|FwDjJ_{DT`rptPHWUFHD>xo|H7Us!=Jo`U z7T@FI;sTR^1bwb)l(tAZcv?_D=59Nd=ld^owo3D?{E_~s$gEJ?4a((If>`CK2kB7e zw&Q;_bRWO{*3i+ig4Cdg;2Pk6IAxw~sqrR+uFD!w^T!Q;^mOKT-%7t;P{k8_QqLC( zhF0>LCe~RWkn>ZhLLtgzGjDHUG*A9e4I+q30*&zf(0rF+lmf_tF#a%?)oAfn&R_5s zW#R(II7jL+90C8EoH#iuqAL<|PJTr!_!V5A$dA}LnB>8h(&CV+OUzw^7u~3Byd@2ShQi!8y zrOMIROrOTPyJ11O_{1X}moK10%5yDS7JP{-7Q$TA8mr{xkHR$YT|9sz&?@g^o5kQ6 zq70nT!7|$ph|u6x${}DCDiegtVR@p=r)9Q*)t66`JwZX6Wfop#KOd?V)WN;Q(840) zMv{k{YF zSj@38{_52~)$+S|qFRp5${L$DwtPmSb+eswo6FOir0w$vpW!Hk^$HEtQ4-3RCh1Nw7{Bcz`oa=;Q&6(pKZWy25}i1)!S?D3N))M&8b>UGwa z!Tw|OW~+Ob1&xX77$2#*r6r^TY&NzMEN3~wcf$a@jTEgTR$tbZKWr@_D6|?>huvYk zBII&3ePShWP}J4emyIi1bS6OO zu+(hl&a>nAKLv1cEtOds*%#TZ_mn6>6`o%00{G64HV_pNWY5gt-fo#Ryh^-H)8* zV0}$LUg+W=L?hW_+yOS>0hX-FeA-`TqWlo5TY1{Bb&Nijje7G^(d_&s^@lKaZzYti zoKz9|_)Lrp9ZbUDwU~I|9Z}T-E;ZR?^Fh4u!C;V~+V^{PQ%221rS`50#gK>(- zHYB5t@%Gdp2(k%hA<$cGUxwp>Fzq|MeiB=+V=iCLikAh99T5=$8xHn%JaYBY1SKDH z+8{>A?6C!}A`*}R*BB4^JDfdwJI#xIQFSL#BSmwT1BvwV`UYYA2)G?jesa{>%0rE{ zuQvYJunifAtUQ|I0Pi#r#nneI-T#3PjN72G;b9VJ11}}unnNnduwLS z%GwvTwVgmh&CIyK!Ic>`7Su+;ckxN>G+Ay~vaBi50YYVoTG}s+Y@)ojHs zcn`s%eW%p^=}*e1`d*J*07#9BzO9W;JSY+lO}cZBGk(hnZy=k;Pn?*O3$L+0X&A4m zpkFQ8_36{1(I`Lvid2>ya3EIEE>Qx87_u`8t~zSeIwxB31q6NKEI zv_aHD@&e;U&*v_W>wshd_Apoh+HS1OVy0&F?;f;XIBVDN{A_=JKU`I^7=?m80_okL zNaWddbRK;@mXh3&MLA?nxDwlrJ=g~S$M?tCUxhZJthjcbEX!n0+3Zk(DZpkJy&(A& zmz0FQD8|1q=}IVzeH|SoJk-={!aE=;2n-0A08K0} ziP>vCgH<_{s5CWT4;?BWcQX{qA0u|y&b@tqp9x`MQQo3Q`h54}iY)cGvtPg`hQt%ZoRyx~c?{DF(7zzx+@3g% z7rd$oGpfAS+sD^_tyruq8u}bf`<#$!xdM35M{{q|2I*w|PoHkLBrYRt^$Lm0c(dvX^*%SZOJk`MmA)Orkz5UIvCGe8-oBrkp z5)d&3KOTa4Ya!6;)=|_}D;XXo!7U?14dw=)5a16S_ypPK5u~YOK0Y>3S#7YN1_PAF z<|;zGm#|>dEaJULj>ZrKYUe)$YAg0=H-F+a(Pj@kXanL8v}WCrYiff-L!EPO-x}!e zmpU8%zsjhhRvW{^aL;qjwO#SmPQ^RPepd^DQ%QOr?7SD;0lYV$l+Z*{?Gt|3+_ zl;H~dSH!R11Kfe;TCArz0VViVhLvuZ3G*^1tu-40BYY>xhXiHc8iTwQ0RAg`R`RQE zx_H{X8Qu@Tk^^!N&1tfPfF6H`WOsWlHm!4ogFIap_LXO5fN(prh#bWF_!qIt+JarL)Ux zg#YPL<>{22V>s1<31>E7&grlsN=zTk{|C%Ig6>`Z#7F&_j`8USC=ty2dJoj_R~kn@ zyIOsyroZeWF&!utQ_tKR(nu=;b~|IvarNl!XC)3qRY2H5^@@`RhUDLyhkakvz69A3 z!`z&GW@{?&zTsq>c=R1JbQk`4nr>0Lcdi!I(F|{}hn)QI>*wbMHW~~S#B?Mpe!=A^ z@&?`)$Lt}4Uupcjvz*ZAR0U({$G^ja1K%TT|A#5O@Th<_nh3)E1~{Jg_~2cq=FIA) zhDJu~TNmoGOIFYh5za8rk77g-tseJqmXK!%X~3bZKLd!GqSgE9PV{7cwc5V}H*+mC5AE%IOvmv{X`#Q`k1YtF{>~~Khi#j8Itav=h1ya{!05S4 zAk|D}PTI3z3Bi(9V0+^?t+cbv=w_UhrCh5OL8i-*XD618-%-}mz~Xatbp|_~r)q_3 z-tYE-{ng6Bc2sb5YyEMBxKwJ9cQtQk$hp%qhD*Lmw9O8q36QL-2y^$+1_Y z&Fk0XcW`1s5NOMXGwSrBAIC$lg!g)mrvT}g*%ri=k$S7LbM!Gi}Fz)J4Ow}(AG zT+{rwromdHG-AFx`p1A4o^{`sN}c^;n{3IeMw#TU@O(zuxPz8w;olN>uV9~3-9lcOe>V;GJkweyZ^yX;fW zxyHtHj~J8)e75N_%F#F+@(vQWpG|32CYtnhk`m`hha8qxwCwn zqmE+lMtG%EnJ=7z&`16w0weElj^sZ3ev!DIst8SKvX$6Jy|=;?cx|e;j$7OaB{wjEg+`bW);_DL$vqngYm0ps$sB?8 zw7QClZU{#a$((*d_oE7x#UeZ=LVw1gG3R19WBY)PX+2gkqn0q=5B)#DXjG-B&w@qz zF_ur9N7dqqSjx7LTn<}Q1QA(5LArgRJT{KhUKV=R8Se@8H9+o<7Ci!m0|-^CO^^eQLX%Raj^~NTBZbw7%^W4QBq*he)nKRk-}O20fAfIeXN$SHP;` zy(^Q`Tl9}hf>GeBo>|1NsH6nb8T$G2CDtsckT-@E2ApcneTB2!%KAr-7PPM`o6ML0 zNIN_UG;I?%?kMIrXrBA@%)fQcGlK>vkD&0dd0#;hmUVgXA3Cts(|1g^JLEVW zf>FkSw7s?ka0fkh^r$FnF6ba69y=Dtg_Njenii|ARCe%C7*wRtwulw-tlinVl0?y> z!i&uHO>$J)z!7vDj@#Wx6fU;m=W=f>>Va9jcM~6753l>Qb)Kr(fkky;M8-W_}8-h8H%{1|nagd%kqIN^`PUOev;rDn0 zE7yK*NDIA_`n*OUT(W?9jEun@1~+E!d8BsS?mtr-tY)2; zd*^?3TpWXb3q3lhZ18X%J_n~McsTnpg<>!S2?H21ixw{yfNp&EpdbxO6p7rEM+^H} zPX!QT`C>tngE7HxumoHNYokj!N}1{Nt8d1JU3apo>Vbxs>iS@J`(Z$0`=7PV`ecl~ zOHz90UO9}QYp2~H%NL8n6o~fT&iUIP+K1#V64NSLt~lm>ex9TQH7!o+86{j~R6&*W zYUT!3s|P)B{_EZ->dD>=^*XOu@3Y?iCrMa7epbF!gKGspeZO8-zI4++UY2=S zU%Y&okDdJ#>!UC}5o4)$ljx!zYK1-o?7WzjwZbU=@UO{ZD;o)kn8y-1xI>vledUHm zOf>(Brz)TDEvM?U%S7)qXWJuV6s4y#jgpTZ(b6K-P-Z+aqBr~L=&Q4L&T~Km# z$(QwCRzBNG($@@M&Kzsl=~g$O!BlXSIeK5Y)EW82X9Elr`x#-_dd{Q{Kk0#ifou-c zy5x1cK>AOc2;0L}eM0JHySSfpi7k_0qHs1L4hwwPw$~~*KcD09Gw5t&(o6I?`P9tJ znlS>E*zpO}L0gU~hLXE1FoI)~YpHr{eQPs1ofz1^5TDLmF@$|_9_kvmA*_MP+`C2Q zYNjh+>@@ZzFmS4*gCKJeg!++J?LhZvm4L#YX03pUDSK>ebadBo^2Nes#DM~d*{qZ? zML_f&>~Nx!B#<1;T%Uz5bWp{Vo$YWC^ho*?n(OPCL&Z$7FEGc><%3w`ffe5<4Vl^4 zu;(UqjTP&YQ^$HzM+P@&dc0CHkw{k`jR0`fNYu}&VxyU0S z=fI_H_pc})&ooHLyw!9wv-VN4KH~y{GX}Z9;8-@;7baV;ml-?k*|QN+0NJ&E$v^mz zS|5SdJYM0pX}_>jg7Ed0rILdO2jj%pxi%II!lr&JSDzeDfd%_y$TFcXmC5?#a^Dq4 z>8K_)I-}K&Y8FB`r5MqEy~GGw{dd#VkAY>IRg8hJh)-AVGeD~0RwWX!e&`F2$y@JfR0r2gvN zj+b3B$&INS{JuZ6LHb3rI$d=2>Tc}o={Lv&M*NtJ$%g)wE!($WqrYpz;B|-Ao|X=FXv0%}LW|pKjV{G_YR#wnc$q^6lG4(CTPtF!Emt3JL-& zJHVsi{fzN>Tw0vTdNszx>I_t2Q&L~(Xu0b;NPRkaC||d5&Fe7fLc%Wk`A%`TsBCHo zz5CV8Jx&f&)g9h(L|eX317T4y)`ooKXii2g07=>eN*JITU>k;n|EjKM~FRE7P+%d6Bo#Nu=))wo)p-K)xyK()S;@*@g z!(bOq+00)k;542N>h>%YKY{i+{uA8ApyaR<*qmGp{e3{%=$b+_Zm3sRY&m{0JTxGn z3O4cZRu<1nQ>j7-ho1Y4Gvqf*%gLvW?Htfp%Hge1isV;&@=xVSjM zebVu1W|*`G7PU3*jNBj z5Vi+*`I~(J$7*^;FoFTUE?gE8aC^TSH}i%D$9jpu5twg~;tkOv^d7gi9<5K-NDYm$ z1?;_eh2q;-{kj)elj{^9^+=lVL)8)KSShgQG8~eof}LbP;`?Bed~vU{iwj|2fW~W& zz8Ap1yx^x$?qoayCwhlMnVs~+3aQ<6N8SRrECIX#{})zD((n<_yaP>b$EU6yVBgZV zW_77YMw*SCjk7JnYeyW9;nFhDuHYX99G}jUrp;fC+n}bw)(x#X$I&I4aj7ZjF2GQvd zdk0?BFgYAodcE%c1k|HJ{S|#{H>iL{w|)U<)>39lTBB%LN{#~;HSa)F>+yuEQ=}{J zqAzqd8&x`%tk@sXDjeV2&{olqqU*x>Yq)tVm2=JCw@<<+`u6bG8XCr~E_buD!>aAe z=|y?Ze4m)e?E!G3CE)5M7SGaH;|uRT9C}dD2CsJmMVQ#r2s@O!J3EKdT*F*>gbjBU zIGj&O=?1RENV#|7r>x{wb@kkBARzXB3}oY5xR&OU9FCs3e&nFv&vr~D6W&+@MH3bl z#%Sg0E*Qc&e=C&RO4_?&S);xfRkS6{nnE*Ouz2y}zCLSyjrhU=Hzvo|Gy&@lK^o4m z8_sY*V|C{-ia!F#JUTiR(v=mI<2}*aX;(m~w1No9@9UdxYDQkU^bHY#rD#!Le}j9a z<(pC7*Vi|h*9Y}5G%_;bPd67@lgr<|v&+wavE`>8xxBbo!E%Xa(V$6gxyXWrqBL4j z(Kqmks?*oSZg5uaS;)%Dhkl?3d$q&WDaVJac)oWS8yX7IXclt3J4vG>BTZ$&sWPQ} z$w7^tO0|Mvm%pTB@c~#1`+fQQc@NMjmb!y7Y&F2}&suT*<4 z^PBwxiEb7rPE56nJ0xx7-shicC~8sI+v_lv=Lki|l-{;&u0=)AH5mRtMmxem^3kOwq_pT{m@&W?;Uc~rKYOZRi2=(~arLu$=A+=IS&kKYjW%#YjeyHS*_?7xH>#+ItnuFET|ff@PzxFLXp` zI&pyd_cX@DEWzQ0nG=fhB|JRJ-e0wr);CDOUOgBdOF~RQ)9el}b>c3^Y%te}^)nfZ z1M3 z3SOz-&bFF1*~^i2aYg;=!e9%&vCVyzI_%D?h>uPo-W_GIoUaQf1Ixbj6IIeKUb^(D zHBZe#W7S<;4clkimVxE4aM!%?h*RKS_1XtKKJIW-r%yU+XNjk4iK7uQVC(JG`k>N> z5{>cYj#<-e2xPYYm!LAhohSbKKL=tQvl>y;1wrg2ng)lwM9lIq+!WePXgtyse@_ZR#gWdFHsaaFPW z<)GU}=RR@#kI>;OPCE+{BW4S?N8ix*%2tZMZfSul&<(OpH7ph_=6}O5Io*)nR%iAe(~xW=*^6`j4bkI#u_DP zJTVa9Xq@)|8FMCPs*<`I4fgI$gDmpL)-OVKd-lL);`E}yHIQvxzId^&E9PS7$N3y| zB+94L_=^N-ci8*&P_!O-`8ZOfp4(tOe1*fq!_l_HUJO6U@Xs&uK5Pfll&iC|v!f#- z)ed9hl{eb%9UBD@y;xuiq#s9FSwm4KF1zQEX1r;a2A1taO=77Gs1tCZzVO%{HHTH9 zi)|l%V?68H+S(>-!j#&?$jU0~HU$J&PBVg#t%XhAeVB*$y}?8ZYrZs&mLGHJanz%1f5Sp;%OSGE-Hsag1{vg_jzZRz*?!Fm$zx1Hm7}Z>ZYQ3%v)G zwy$Az_PkdO4f$C15z+~u(zqU&WIy*nVC15=nzK*(ry%Xin|~{VPZ;SV7@-q2`j(6x zoDtK+t`zW_;xM>JM(zHv-!v*VR`>Chs3`R~AbJ?fK|uhgB1SXa*^(pMy3F@PF-zCw z?)X5%LKF@-Oi-#Y2V>rpHdSY_Gs*ByadE@R#I&1O0`dBF{Lb?sQZh1Wia_BWIKFxw zA$Go6*Fa9I6<+bn`1lquGOiXD_V)3K%`Eb1PS7NEyku8wm9ygX)L{@K0&k6{3yL3hN;AQ z`@A&czD)lmsqaPTQ}gcZ_8ubtWL3&$OQIazOi2Y-I7TFMt4e`nR$^%AQyAu0Bt}G# zNw(kA7Q_}$!5569k$4l#T#E@|f%4@`mzWl>&?{^?Pz0HJf$f0wQA8g(+1WW#lr|aY!9~TU zsKVntPq_E-0`n>Wed9p`pgRh!V=+JPOqOP1CK%A;%iB#EH3TKm-PC6SD+c}nrW7oe zZYg5a(arE4*IR7$%9Z|8E)EV?ukgvb^gb8i|8uP#UNa|Q>BioJ2iFzGAmiSjusg`= z4-q(|F{nC>U%-xHYchF+ufd1}OVH-MZ!rXceQE#V!>y5OBwMcw@Z39*2;_WyhoP;F zg@xA7H=t=;Cw^(%5&I5ao! zE$<2-+9=AFE%!GYDEt$S%BTI*bt@Xdkxoxb6N`QVYRGlJjoEzF>BaiuoIE@P<-p3y zYUj>DOupRT*MKO_;%sGUDIp;tIt7e3stf$g$v|l^HZrefqcC<%g0N>5O~7{|35^O| z(Og_4HVM&kH;YM1{!W4(6@f~tEv>9@9Wby#2cw~`zFgTiey|pHzAs*!>&GG3?d;sH zPm*DJk8!P723bgr=< zhlkr+zNo~=$WisNYM!uP&!0WR{nJ_`R+^hjzP9%pnTt!kqjukq^B1t9yv*zK3hv6z z6+#YHOE@sWdQXd@?ke&eY6~BI=&`T{ge4e96bkV1@r^O-mwZg6|0fu`7`VC)L5*;P z-|QO+d(DI#F=Ot(&XW645lJmgZ4@~6yMjTc9Gf!z72q&?2KQNWjU!{A_9%${;i6fv z6C1Gs9Loo?sHQlRMJA@-2_qhA{`HgKaBc5&0Pr3uqAosNrpG35^K?{`GV5`~EYFCVMeP>GT}sMkL^n zgRy5q1|jgUY&SMH#{>8I z?D_i!F=au>2EEI&Z2MidM_|p?)>~Jvs@N6l+rF7id_F!uiOlE11CnjnN_`b$*|u|V z0Bu)~V9VQ9shL5P4vMRQ9d2!gs3yOd8;W*+ zzlJ`yUn4{M-7-3R40y-IANJXXZ4dUF8n7{a4KaFvB6wBcdHv~yO1d>c^)+o%X4Bzf z3CPM?!xl(0LxF(y4MvryW>AModjeBflm-*vKkfP7BwSxIUz*EDQ{&jNyOrA-8XFPn zamN7Br{9*8c1W27=ALeblFDHFFwm~y_B&3^;^;eg6B~rFsiI&AyHDi52e1!;8R>9x zf|xaeT?M{?{GO8eyxNidS_Uu027zDnlGj-lhzbi=*VZ1G>-x8zw@|sjQ!no2<(0X# z{XM3Blf(mqWlo5;&Z}9ZL9{wU$Fjm|j~Fjs#%-(0x}%yv?asS_G8fZBxs|L4kAlfa z{d1$+7uNpA6CQ;`{;B7MDXI0^y1Gv}o2L^@cIc#y1otc7 z6(g)wB>2L<5Gl5T$1?ov*;JSQ=_^wc!+T2Cs8rp}+})#dCiA5cNv5zTxJKZ#TU+nD zclq{@IbyH_xIEB1ezM%~oO^=>r-;)_)dlAjzR%?EBP>4tn*IJ}`|R!OG651DN)FvG)2Hqy>FJz7VPNu zsg$o+7A5c_SI!>Zua3u1U8FhXzMR}QQ$4)tPP$6G4-Jov^+P`>>L&l>% zPTP!c;6S~T7*f!Z>LsV9rD^NElo`Z?$6-fM)`G<=B4lZ2&UjrWJ1G+G05#X4mM!(0 z{snt&Ct8{Qf zpNog1zl@Lj4&8o<$$N7N>z}uCQ(Sk+(*UfqS?99g#e{rjfj7t@suN7#JYPU{K#3}hFzX5zS5 zatjE&2wqvil2jcTs_jor+N}(Ia65C-*N|bpj4blV3sU|=fM*BW3P7;dIVy1O*32Qw zbLJ`&VRMgu`%UVj_``H{&?-7%Vh%(TJrhyQlFL^tp~@c0D=Z8T3CYA>6>;&9pdfjO z(fLg4gZ9t%?>cGjXlE0?yT|54KA9;vP`Z)W*wPrg_VVeYCb1MPgOx490nkMOCGX0E z-EgaOaC8JG7ge`zw`13kVBRFT{J_)*0k-#7zXVe0#H!NJEq#9Hjw;poarNaD6`-MV3cP8m_g zgt4))NjpiSGr4su#TGjgBqZK{V`g8ze6YViKR4~6;9U&yg(#S8)?ru=T_ zJ$o7tFm;3*RWfT;qyBAwF8B0}diUdKbV9;vV)i^B-VVvH3I0k13FXt$<;tsYWVZza zx;2)q@1E=t^bC9VEmbv#W0L%C=V~oR$4CS~97Up^22kFs*@{zxf2^$9k?#(0uLfz# z?AocLpVe=K4YEH3&$W+CXabCXFnF+o1$piKH!wn1r+ZyM4TMp{*xP5?{03`TWHT?c zoY|w)prTY8^l4MD^sbbz1KQR%IlRB-78Mm;zHGIw?DzF`TQ$gIzi%J5_hCPc4d)@K(P+CPkmVIH*xw$K^mF*dch3Rhi}#fw1R~_dpE@G zT>A}iz>g8t&O)DP>_O^hU3V`R;B%+ns=V0oRKhFIpO$l~r#sb<#n8=RZ)fG#L^f`9o|E|U%-@PuM|HO0U-3qxATJMDRCU2PH=0mD zYQ2Ya_2dM-X(6h~x}RH_=88NZMC&lCGW5a@2@dOFuEw^u{@z}B%qN5Uf?VgOXs8IE zV=8w8t4=yQv9IS8I08h!?2PND==JF}&L*fco#o|r(49mb{jU}ss8L=)f$3)!(dZ|` z;+V>&zCSoKK)59BctKA%#R*E%fHaJW(O3|J46m@cL5Act%0jpR1&hPHgza(P&}eYJYItkt2J#)jAE=;lAgOR7xaCf6>S z(mxMtdMmV0d3nSC*&S=k@!ndrRO%!X(DR&q&ssl6Bx<7cTdFlY$v|a4#U}dgUQOMsrkAG+XI7lZrHm0qi0U=Ql5lgLy zYHDlK&G4os@f&D3x6{ihc! zc38^0VnxoD4ZeWFo8_)&W*Yl3JWM?3af>TDHWrRaVK7z;Nt{8;U0#y2)jZL<6z()O zrv6k!je5|*M_B~>&wnVwGT*K^seG4&sZby-s@FT&TRA4)6ZB9FOV^2uCx58R{QA{H z-(|EtS!VEWXL&Q=3%F<-;s&uG$xa#9_xcU^pO2j%#(MKMR}K0WxS{0r051114(T5l zcw?^0T6}TK7mewKb38z3>%VyXVz4@264ra}@z)Svqc)v~snO zkmhzUyCm0eu@s6cC|FvPWU6J$BrPjesQiwjQguw}oN55qQg<1QPb&qM z$^B>3zR_L|H&Y)75%02PLmegC?TT$H@XX{XNzNYUJ2uJ7gNkkx%E7_m;_BKQU?_o` zd5VZfM6{=)8UCbKK)ai6FDolcfNeH>Gr6bjKxAMb6Ynk9O8`Kscep4E=%A@&{@`^I z+6KyMKhUxfO(x&dVYw3$%g~19ibw0}>(93sCnEj0@lw%2Dvs&`1r$vP>_8dmf6x$% zD#9POFY;I^DjHOr@Vsi}>W*CE;>AbQ$rLAM2-mL`N#z?Y$#BRj|M z+b>A7rs8uTU+W12x`6#c|2i9`v9BKs>1hoA#5+7(!MQRC4;%mW`|gsdtyy1&hhb@| zsjOH9*j+?eI3_w;Tko?Iu{O8IE|BtvPeCP>#G@ozwJK$B&?Rpn-VtCAB{PLyrnxj@ zp{4iP7)Ok0(Q>VieOIO@_b=zAqr9IU8j-!4>kp{riE&5Ppxi&**kWZ=$!1-R0c}+7_$}{ztrT;yz+1 z9^GvQn=*&zKse?a=o1@$H_%k3Ke;6>46kAqFrwk784ok~$KDke5l;oeM68-fV7#gYAd4H_<1#f{U(c?qlS~YZZ5F1<32c}D>{rx#OIkEN2@g!8Z{5l3YI?2xv06-}L zyn+q7mbx!nttT6_<;8}hx9@_cf^Xx#h0(vDD0!>b$TCKWEBoqknl=TdBbc(l1HSdv zuezYdF_E4wTo_zhIt5uKXfzH73z7`ULyum+1~?ndB}sJO;{fC0Gw5-G@o7~^J}M-~ zlO?zsm?G;WogaX{Yx9R^$TacDG)y;78(;S@Q)T6ltN*d}GF+P-L}vnxi z3x5`MdOM>+#{DYb_G#G)iWF&u!~Xq6a6+{`47?IXJGnhTPlaj!{GugRByBAR%G$NC zK1@srz;FzZZ~krR_kUYRpCB(7xpp2B^5CZC$;L!Q<$A_tO08d?v=`f@Eu*hoF;L!m zcByLjPgGQeg|gXc&Vy@x7e|u8?8xl_+TCy#=j*Lr_eNx%q{h@#KB+-4(nqswrb3MI zx&lgpaTNnXffL%YpBrkEY-2^XUu5G-qGE);Kd^VWS%i;<&_<9bu8f6B45#IwlTnONf z&F?`^v#87?@6)@eT3TIWqe<&y)K)d3htl1^MM()y`xJci(q`aOHP(UXpZvNAIx?yz@`G*}c{csX)Yf%J{5^nuton5_}-xV+{W zFl~!-+S+ovTQR=v#o{tgH@8>d7*$oEvAZ()as6A&ER4-y@*j8UQofu%Gsn~4X;_L9 zwe$#%C5mb~lC1#?B`lkNrf&lW0Gk|Z~^6C>SK>+(syoVXQxe>sKSrc{@{<`=(R1@ zmH`p@*%o;#*R4AUq77P)0=0NYJHt1|JipVZFBm%o&5)s?@An0H2&oXN;Q@FLU0cYt zsl1lhF%C{(A(71ygfF~*R9%f~R^_6pro`W;eSo_hf2$(6eFawyMIBD+vikjKyn<4( z)pEm1-sL8J(u;Nx6X0cBY>Ym_YIScdewYJxz$`$B?J64#RjN5aZBP8Z8e>!dktJU1 zkUvWwRc%=vI|F#zT|_445-+c)ShaDZiE|%sNvMqib5cpk=1t5#DTBb{svN6Ng>#Kj znD174&<83p^GQbh%2Px?aU3drZB~fMYNL#a?G;ohz<&`|$ofWNLT93OSyP6L_#!!3AoVxa{mRSN`=H?eNX>kY*yj!k z3gJmUW#uwr6Y|uT0wCAjgM$*0kKeoB#bz*^+B%Wn@ouiKo%swXeDLR7Af(VZc3|d- zH@fh45$cuo;??2&q4$T>kdzzqaK-PfYF3)lh&^AC;!2GW_|Q0=vM*OHlT^pEPc6W`;Fq zHWJsHLVx6sq=a%Na=hVt5~{oe;s1gIEHRt*a_!`w~O6KSEjF z#DIGa@iT@ZMVog_qzxE#b$Z(NJu|~;HkkM&G?UDUYQ!gjyutHxXPEvcH3UEf1qIKA z^m!g?$hWF)s0y?b3N3$g>eRkwUG#t>`mX`p-+d-`w|IXQUB5mrGR^BqK*_uE`I`_B*VQwS)n zcEFM}JeR|sp6OHPe)qNQmA`3rx7P&Z*ZYd@ZWYEixR%>H&Jy>yx=iPdzYMfHb!}^v z{I%272fpJvJA4FD_)}|Ge8X5qZY$%N)2+vzY`Sm9-M!Poqg3bh>@)!N80LqISXocc zC4gO$w3*>Dw%cx-oi3BNSrE8P*2;e-)H@W|+Ww;=kn%u90O**$a(1ZAMSY?OXT?ko zwkk}77$Q^ic+6bDg+mbb^z;N_aPJH0%7#s9N_nz@XU<@H-H&Pc>>%17eHz=Zf^i`! z3F!&p=JSa;CaunAmnb{|bwEPD<)+P*oOckn*j=teXSgBY9U%NB-A!JfmS?+rd0h+1 z+lIoq_DyP1lIq&n#hAt--f``l9mIDrB(C7@j$dTgK^=p`RVC~UkAYV_*5-Jd= z(rRkUZ4W)Z8Xx6{Qgd*>iAf`1#&3hKQoI;58u)XiwKFjs`^i{<@7$3hk{TiuX3Kzv9A5Pl9Gs%JZl-JlCknD~6>qjV&!PQ;(Dh?OHa@K3^VMuZBj94SWkcPLolr&J6HdJU4AR zbhixc_plveR^HylQHcypr?%CrDy>^@ z{WACCR$aN7Wgej8weRg-kf_hyEoZ2+&<`rIsGMI9?(OOG-hQW>@eKV(OQ9CBepTgK ziQ-+WiRI}!JiFRb6Vym-?g9)~vrgew3eqg4p_)TXLuQ#@~-lmLrRiaONd1Hr~#?Jf4cX+?;GhOdXziGwl!6DG@hkog% z?deOnk^6)3%wh7Tmn-uE4~YwV61V1I;zQ%9R|b0G^b`i-SkZD9vtpw*;1OfQS2wpj zVCCSd3v4+)p)Pa!%5US|3u&ln+nga?A{8|jy=n!I|OXCQ;<8MR?+TN`>%&VElYY`V@3 z7o-pH;5GreO1k2)7T%yS`XQ)m^WI)JRn7WMBUnd^-_|Q3XnR@S&Cb%2*YPo07EsB) zpStE;CC|n+umt$`1x*EZg7LpWyANA^-$v!w1XQh+UtXD`vAl~XYwnIU!0rS~i0{6C z{~r4RA+lRR%u8mU%63)?+r9bo=hIHfL`O$PR`@nd1Xia{?~Cwz_f3~~2XY=QHb2{_l6T-3TnqYk?ztSHS9sY~1E<>5 zV$i=rbx^TqxlLY(+svu%HLkv&W$WUi2#ZcjERI-7z|%xQMzXy!DRqKCJ+48}(ClW0 zyoWI-80jr{1&aM=Ie2E>G@5BU*w5jO?m=mX>smD})2DLx^ImPoHo{_HJF~bwhf;D? zu3?iAEz2s$mfqkc)0U%OPWG#1uCl&2mLc`gKV{%prcISR&k}3hn>TKNZaA1(Vx+vJ z9PK`&69>Hy!d~hZM3%-vqqh<^dmShVG1&4w_27;$Y7!A*RXxYB+IE@K_TLAZLRU(g z0awm?3Pik5rT-%RChSKZx%$x~?Eg0S{QPm}b>6S*l&#?A4X!8>)fJ!2W$f(dqoem` zSi=x-d zG^etlOkeU9qrh-3JCMAv_rYw}u1*wygieu#s&zSH@;D9@woOlT3ryEEJT_~|vA!G? zg$*>D0;sADBR~>z%?iHb$;1P&_<2pypPZ@eBGjGiaU`rslxCL}BttV$Q>y@IS#uBu zNbnBUmU%CC#fGIv(eIa{Ss#=a5&@8thDB!Gp=sy#Hf6ROm!7m;pN-zWSGc*ZpX^}?^KR{=grJTq?ix~WbV^}Z= zPpi}UC#I*qeU0P*-Sxpp-J-`gwWzJ#stws-?-lkV?s>CPu5slB8iwT4%ZO)$5k!n4 zWjuf;@|bJYrFG^Z3Z`nRq-CHwB1R!57D(PMvgX@l=zE0nKI%SVfBu$2y%61K5!)r3 zEoP1**)O^-S9f)opBg}==q-C95#F~v?~`)f z2#?M2haN#OFXA^<$P&Apa*D|A-~!j+rB4fNBUn*NcN*p2T(=ml(&=JN+I?&A#a{GV zH?ELFqIX$YJxyHBa!#)AI z7VW(Zp(Wm9;*t@irkP2m;$jA1pk#CG%32j!FG`c(vmtFF-r!(HD!r-qkt1U^(0pPY z=tvP#FxE_DwDVv44!SvO7g{ERUw1JS!ELyZXtcOj1n(Z8Mx8-Y9g1FK)xkzV%V<8_ zuQ@pLt22d5OIH`*E5Fa`bxKMJ8(^cP%S9EYl&gs!X&qhDcK3_dIe+qn;2}6dbxgJO zJ;6IM&=Kk~+Rh(F{8BG4tiXP=%P{8^#{-ruPu%qUrDaGl84}8Xg%SPBxw+x+mj~qd z`bR}zOTygt*Ej+U_w=Ra51um$;NiJGg3}*3bDW5`hU?LJBBJ2b*`TwbE~nm86cjF% zCY!_LAe{IaAkaX-@(ZP0IYYEvIF?`B!R}x5V;{D|uJ4xAAGeg}$D)z>W9v3$cJq?`@2( zS`E6asmB0*+Z-Qe98&KVc(Y-FaE81XwMJ|Tbrg!{tIr_*{Q+Z12Q}GKY(#KE+lnxC zk{r^xKs!Tgg=j)0(VtI2GOlJT=-;8Xr3}B2ek1fs5yM#g^Q-aD?75$5$SH%LMY%%2A^0JOuHHsw`QPuR zl2nx$|NT$QdI)eG-v=;$=1-M#D3^%aGq{XWxknWLcH+m2d2|2E+<}>mr+5MQDv?C- z$L~A$hfD?x>d$cC|LNQCy?_2c{QKXZ_y6Myf4^-eoz4BcxfC}0`~AOt;b#tc3q5{L z#=N;tZ$l00UPMQC_0i{9KKnl-@|nj|d1Cg{%gx@QKlAhdZ(pn?G-eeQW1W+riGvL& zDkj#oUsq3Wd5Rq_Z|~ugU~v;ae}?Sr&4^BT?Ay1OKBEYx3acR6c<|yc^wIrw-krtJ zmJzSv>%@X%0BOE*Q|t#jOZl!XhU$!M<|{Lk`M|X7Y)PzUZ%EbN5cdo-aUy_Z0^h^w zZ-=J9(h^`Qi{E=~>I4+51Taw(LBX=_$F;RfJh}gi2gqDfjMC7bkrh=8SP}e7mI#x& z@VWX#o|;%&4}JQCWsNU*sCmZnZ1es-A6HMMrV53;06P;SEnq@*b%rsWb#;QVPodZZ z09%Zv20JiUY;sp8Q`_t@nx7doA8E=kl1MoIthH5MR1|1ReS7<5+7Qu+aJzzxf1ltI zEBCO|45w^YF^i;SIj3q;xFD*a{5@Ydhtg{A)b+;@C~oVD+)BQq^t)|Uo$IElPciU~ z+IHQv!9XWo?%;~2jZIC`*gFKDwcJOiLBs^^?$W$K;P}sa@l1#V@3}*OYiimH?J&>d3Zv$;0 z^?&>6MqoOD*au`y_msl^+T}6w&Mgu?K0aaJWh{4MEG~(olVTbMQ}rjKSWD0#KB7{n zjh;}GA|WbTCr*n;{GI>zg25_dGsunjOoP$)2+h;$UFffp$*5Q#KrjeGHvcHhGcJ$R z)xI(q)7}eBy=QbD=Xsv@JfHXTow__v$Kej_KRE;xCx5>ZjIy~jBR zVH0_&`g0G8RvN7et>s!0VK36|6_;7Qyftv4MTa;YJ|&pcdkXtsY-sT@-np~v-#?26 zRrAwU$^kpO&n)?~MEXY8Pct0_yzTYZT$;P!S;V1P;HC&T#QbVxq{dUQUdXtx=^31# zyLua(?g4>0IkGD+OgxW~IVHHpqEMTOQW_H8fx5SE$8LtJ@f}Ojv$08l5H#ZolII!x zdK$kEd)7YWkbLV_aN##7eS~4teGUE3#pe=|fuQxEseO~$n!5%?3NAxr1M1n_i+b*_ zO}UEyx@L(p`v0}%TDJ!hn+cgsu6VHa1O4B$^^{F~>L&T9PMR13M>9Ep<%$=k^+tOD zprT2vUAr(F*lVm2ksVrDHOhi?)o0yIVI)%?p8WDM&j0b}T9H;QSDu3BfH~-#HH8c5^EJ?469LAPZr5|+akyZ8i?A+W$?f64?OaQcM zFuT_i;nuZ*X9bbD6>|1$&E9cMqUb#_aqb_1hsTEcx{I6Puiw7tz2B&_JLTE)=XbNR zV(@SwV{L4}eLrkUcLz}XpGk{_+rGZ0#uafyl3$eqjS^XAMsjZqV%<*S8<%+1W;DXos!Mti65r>?0^ zO=mgmZDQ%)f6KA2cf%K>WHHOzApRoUf_U0=BE>50)giZrOmxWA|P|;gS|Fkr% zw&5mV{l8e5I{#HCRTl(Z3(58*;bcOj68zCCms)Nfn(DNYV0DLR)tdUOYKUeKNOs4W zI#+dsCTB?~i+D@e*0x05>Yd+%tD_)0cHG-BE9rgqs z<`}QwJ95-|2TnjYw`?@@z;ib?&G_ur-EY?EqU46D2vb;QxexnWy;J!Xr{A_LR@*zv zdqZ%G;GLid+v-e*S7`>~d-m?tS#LIyx+4uspiEQ}yxekU1X}x~^9qQ*M`I9ZjLX(d0 za24;)<+8@6J-%XYsnRS~sUu1+;QWYwuo!vENZuzTMeCs-%swyp1XE!emwCj6xU#!)`6icCr@e?f_r1CH4n40kk>z zy!?(>(M_cK8?g)?CEt$1XN`ggwyz7qGp-q6SW^4+ebJ|SPHM=ZGi^6zW4V#$`SK& z097-Rk_>08JRDfoD8o`>o~i%sG_oZ zIkm!^Ur?}Y=}BU;LITKX=%0l@ov9XzkBpiUbxB>j*Hl~bzLno%lHf#S7i7m>WmmoDC zb(mB9Rrsaool^;SZ9x2f&r$vGjDRPIV@iYTBR~nb^*TR&0;?&#k7FYaicG=B=u+t`QGCPI`#GSuZ+IS?QLEL=-3llG}NNDOf2)0WUdGoMI|NovFKpag3R(u zgFj#q!#84lose}udl`G7=6|h7mN{N5FhB@HvCwO#Pn1Q00hb7A(f~F9mi2%hQ0Uir zPV8k90{-321bD1O4td(|d%c+yH)fy8DO|~1O_UcGp(B=_#A9haORL?DE~E86K%EL9 z2kIC@eVsy!^7zqhW}Y^Ymspl}Sa_M(Urk_+E9eo1=H|2~$3KRzyPMqQ>La02VTU=0 zG11YEq_NC0F@mwv!hB-9$3I*a`g(l9n@H{psKQ^kewD9%G!bFst92Uc zidu(msHIjFA3|xInVBh4_b0{f_4CBKcJ6v(nxC*)tx(kWNVt|fu(zb%R`uwOzjW*e0y8n@Cqw#3NT&R)Hz(4#N7bFHMW zuP>8v|NbySo>p|z2w*y7rMWzCX}`(6g*aItVFXiZ=7%c7+QPuExioXO%^b~ioAi72 z)^+lCp7P5$RLA4cBq9w*RCffSDkF}5zt!|{I;2CwKU)gi(-@MEVpY+^T;%x&sQJd1 zwnI?f+Esv$x)k%ivK3z~5uwk#(8S{Wb$7d8dyoWF87i(;u|#pBiDYvdiFf2DHc3Zl zH((sghLf(N=GcqPHFtNj8`=|iTW@(_B~U!Uw{I-Yj~oD)jyfXQIB;0YJ^pQT@Kq7J zAlmpyVE&5A%1`aq-{14Cr{|?b5gt|cVxR2liQk)q7l^0$gM=U~sd4x-#s_f~d2S{* zTUnQ_zg}BsPJHta;tUt~R|0Ft_J{sF}*5y63>(*p3qV9V;eN>FT#jWLrY z7Wyip#fG-5+$wIpGT6w11crmNv-{Db*B}BYIl5(jho9O=$8r!wgZyH2U|ujH{j+jv z4{P~M+%wi!wSs;YQJ{Bh0^{PK#%+Uw6x2yZ(d*y5wtKcHt4JEX?UezYF2v&7ugK|$ zMK(1z6KZchq%OXF`?ir$wl!3^Eja-ZkB!9qF+5qSK11sS>|l~}ek13n5slv2t&KKv zoZUaqk6rlgUtDjw*1#Men~JVn8OQ@g)X9>g%#)X{TIQ(~6lOo&cPOGD5y zvI~drJpX)*P0+u(ocjb;V!zUnJ&WZP44f7#Q7imv>reQ>{JNRl5p4P;Kozd6nXvS1 zyO^J!k1De|zY{(Z^6O*3DpwSV8fQuMb$LF@Hz zN`8I+@aj$^NE5}8vNjoRwb>CB4`Do)>%OYyyRn!;u={&!@{L*K{)MGk1Bhlq1sb`{ zKte7#k#LT05}I56;p&pzUIhb>=JN6~UJ!z)N$KF}3p^Wt4PA&3n&Kv}%Y;1miBb_} z70lbatwHF?OeWcxN?WpEfvvs$1_0_GrNT_CvEcG_-)LN&dttd?^VBNrrr+Fo^HkhE zlZtJ8(?V_F7u3q2n(o@QRlSlrxd`Je=iY!FXsDO68ty|wGeFBa4^-r~bQO{%FVc2_ z23@HhD(&dOaE*B}Y^yG8P%9}cf8ecsc~Es@6d%rr*p%4qHF@b?@c3i!i?t^JX;FHo z!As-Qx!y6)bQ+Xu5*uP(yHvtU6aICg1^LPZwIzCD3_VgFlb;0uUhVyw)Xb#a6oK9CN@UhK7N z^C$-!9I4N=bs8jkp)f;kgrk$w)8uhcz`JEiMbE^6ZX8@XAM(K$eP7R!J5378gz)t# zv|-GjmFMsLn#76=WsN+nD(b5Ry>(Z*%Uwg;8A-W@X#^5+!p}%yQtsQ)K zIT+wS7cy1B3#8IwB*qLrf|+Fn*GE)E;&~dO)JE5X<~w#IO>8=)Y8F6V50krG(Xo}$ znZ_T^>qV?Yu(sE)iE%vKxf|ZtdwFeaAA)w!C>GAYs8mWJqsol=u8xHMR6&Mj+sRO>JtQK7N3<`hz{9=L( z=gkjJ>f-k}SeS{Znpg-oA*TN}w7#hk!`YA)L#+s27ADOkZf&yIQ@d=aL(e9)fB9l0 zk9N1|B_f8QH4a{l(Fg5c?R0Dp<_s0W`sydpHSYYXag&5Voh1Ep3IuybUp7rzTu(5} zNRGd-EFh1(D(@u79Zu&x0>i@2)JeBlAUuB$=hm_cp?nZLzztKw*<5fp?M5o%>6(X3#h#sLQo_%|&xBpJ1iIPCqZfAZcQHIU0p>lQcegJW|;^hS3jtL+a zAvg;&UE7p<3N@Y*jN|C1%WO7Dlpe%Sg-i?$Mb0i)webOC>m9nf&t!{BpepYPXf!w% zf8%P@m|WgXL(#TXJ9>M2E1o?wu|)O(VNx^KTm=49ED@IpHF#dL0D~e&N9#aN584S9 zR>kn);1+WmKw0+oTivvo5QLCgF*CT6tg!WL)KS&x;oSk(BKUMD;31n2h0G5v{|UPs zv{qb;HtRj+S=K)EowesJJo}MN!bi72j}RlF%GNfR0nLAs-@P`@QGS>QF8 z8g=>SgGi#}DfwOU2s{7hk|*={2UuPKap`#4G4%c<)Rib~HeAISx3+5q zTdXLTyi1%vxBW#ottW)}YAo}FPi09-qHTX^?PztMd^hFWnURM1gQyhrt4_<@{gGuo zqgbl+1*(q5c6+&yZG8Ax*48R!HQp@%!wU6m!!r7+dR;38uAWa+0^ zzQ-2tpNxN$BNRW_$aqQCC|ONRUSXz+-TKQA$773n6Z&Lo0zl;iEmR>mG}IlMnyf4# zb-ytL110h&nn;$XrQEv3OeCZG(=&1HsHS8%Ptg@(Gz%;EZ*}oob{SKrN0=|T;YhM! zqV`F8E>Tle74ntVCAuU1^sqqlpHV%!s#@bJM4{Dx%Ley2(H|Dm_4}&$KblqHdNFs> z3fU5$Kl6)-Y~Ky_%`vru5dEaQVS!VlReklXWJEBXC!Q}86b&lmEl5(kw)FMjSpAzPBH;_~R@B!Mic=94k9C^gr&u}0xH?>Y*wV@l z?SJMRns2krb*Kpukb-%(@4_gI6?BE$t8$kb14-j(__=e>aHZklyxiOZJLUgl zZorMB!U|KfbLvvQf&4I^n{e`-tR$ELWeGZji3pp;F4COmT=zy$SjX5eFAgId7-fN zt|mA$6*J9pf_~^PdKG?iq+g3q&phdyhlsanL;o%(-s373M2xBU5vZ*&`c!u=q2*4I zMV|POKy~X7ayQH)AIHAlByI9o5uTfhRZDJFJ>_Ew5u_ zmAzaxe~B2v0`pW4e>jnK4Ev!=VV_pJcZ{(NAC<;9pV@e*Sw1fPgi%L_ilM5)4QDg; z2l@HZX4&*M7zAa`kd2%j)gRv!$YA<@8|plI#$EN4;+$>WpO+D%Avk$>_A?Qje)2sv z<>zqP-P85q3yrg(_yQ08uD14qQAHb|fQs9jR)xDje%V&}{COK33kG75=qw_lq2Cp6 zhZ`heP$h7(nL(ZpihIX&r9W{i?6jO|P;z~C^K^1b^YT;pv_f}Bbsmw&+n`xrrz523 zT%#idt<3Y-by;z!eFu)fi7~=SVeIswBwBYNzddR04Xnc5MQ&|e3v$3DnQLonTM?^j z_I#=&C`%15uQ=tRjUqOC2?VHA={_2rEpx#G?z>7PU%60TjgHmtAW3*RPBEJ}C^Shh zrh6@cCV4WoD4UU70tbp>k}QQkA&Q54Q7fL2+*k%Kje_T*_Z1h#(82<3jMfY>Ne3u4 zsUz%A!RCEyq@@!NLYgDKLxa&U{ed3)qA++zJSVFkV*l#H#^cJdJEO-Q0!!{RG%@k@v>AYDp+cB{e@`QnE%&z`e#C&mOSc{ zT2x8je=;#yJUd>z>HwdauDIDdodbd^bZCMK;x#LVjgLwn6p!i-+ArItFL*3O%q~bw z_R5y+3JJ;H{+@>z_sa`=Fvi4tv8-BL{pFw6`X{_!?0(sx*z@_I>MUy%Q|>Ea#lqas z+uql?sGCU~bxNc(9^{yzBI;Zi61GH%@2Veo$c~1@rhdp7%O8A)H%i_A%$(8|cOVCi zr(^!Ha9&Wlt>@2gd(&5iq*Q33i9=bAtf9+(J}6uYH;7$II-#{tR)c0M;-Y^;-vbks4TvA~K95qL810ZU8Ek@r^b zx=hGiRF0Uu%^o0-Ag1A&H*a8I;2`K=%<5n^%R3ecAboMM0`TH{@F@UpWga_IBZ+QY z86$`B2eIn@ejppMHtXLR+`n2ikCzwW?f10^3}63V`fu)o5hA zmv-5H#J_xM!gt#4avW?Rdi#fmKR$*b3l*kN&p524_suJs(yoa-<$9d{y9e=cfaYrelHxIZ=+XD7 zrqu&CRy*DFeN)qYpW*LUM8}5bU3%b`^P)GV^)p}&HRQm5#pg^)!5Ipj_af|Lb2=-d)>*Yt9@T=t|;(>c`LKUc% zU0Q4Trn$N9l0UUD{=tI>--2q@+fT>L3UlHGn*k#IibpG2xKHJ{^zl9yfS{UTZ2$}b z(4J*{nuU6Dc(I7E@Bv3hoi6OMeg#T)^&m^MT7Dc^X0PZ}{^Q%b#aqYw-(>TsCuh}< zikFQkMl&`en#K58%yLe+y7^p%p2lG?-AEl@>(VZr;~Em#J|ST}`9b+50#Wj&N(*=G zk!R|!-7=~Pf}pd-P-w}Lw*~G_(wD))T2bwhOK3FacY z|9F0lh0wbqn;zjgDKUcuTkPb$Mb=^veyiF28*!Ng3B_ zgAcjSY-DWs$o-3j{=d{7e@DwR>(b(uF)8t_uS@Hjo0Y*4e%U=TI@-Fo^rvU-?G~Bi z@g+lL&9>yO3B7#DfUJxRXFy|GiCx4*d!%}Nrz*145Jnzo5dr2ytZ6@=6`hXV(ouHE%~L)(YrNJQ|0 zWo@%K@jKH+NwX*%Br3;DTr_Aw$}#jXpqg{$hd9XvbPiF{G%`Ja z%rTR~5nw=`enNESCgM3H)A85XWAKx|dBwl`V-D7*n8teZnXH#5vtM=c2duZjyAp4M zcV)c|-j(&iiO+)%j#ps){^Gx(~$d!5#g}RZsna6>T(<5%&=~E&}Mp9|GegkchwiAzqAw_(uQB duTA*-&*(1aEeLbTR>ALb=xFL~$)s8Z{10p7t=<3t literal 108273 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 Cbu^p+ diff --git a/docs/images/db_schemas.uxf b/docs/images/db_schemas.uxf index dfe43bb6..03db9da1 100644 --- a/docs/images/db_schemas.uxf +++ b/docs/images/db_schemas.uxf @@ -28,9 +28,9 @@ fontfamily=Monospaced UMLClass - 208 - 120 - 408 + 336 + 248 + 416 80 resources @@ -47,9 +47,9 @@ layer=1 UMLClass - 208 - 272 - 408 + 336 + 400 + 416 104 identifiers @@ -68,9 +68,9 @@ layer=1 UMLClass - 208 - 392 - 408 + 336 + 520 + 416 80 srn @@ -87,9 +87,9 @@ layer=1 UMLClass - 208 - 488 - 408 + 336 + 616 + 416 88 children @@ -107,9 +107,9 @@ layer=1 UMLPackage - 192 - 240 - 440 + 320 + 368 + 448 352 identifiers-<csi>.json @@ -120,10 +120,10 @@ bg=#dddddd UMLClass - 208 - 640 - 408 - 232 + 336 + 768 + 416 + 248 subscriptions -- @@ -138,6 +138,7 @@ PK ri : string // subscription resourceID nus : list of string // notification URLs bn : batchNotify struct // batch notification cr : string // creator + nec : integer // notification event category org : string // originator ma : timestamp // maxAge nse : boolean // notificationStats enabled @@ -151,10 +152,10 @@ layer=1 UMLPackage - 192 - 608 - 440 - 280 + 320 + 736 + 448 + 296 subscriptions-<csi>.json fg=gray @@ -164,9 +165,9 @@ bg=#dddddd UMLClass - 672 - 464 - 408 + 816 + 592 + 416 104 batchNotifications @@ -185,9 +186,9 @@ layer=1 UMLClass - 712 - 960 - 408 + 1368 + 792 + 448 64 halign=left @@ -198,9 +199,9 @@ Why extra structs? Cheaper than full resources UMLPackage - 656 - 432 - 440 + 800 + 560 + 448 152 batchNotifications-<csi>.json @@ -211,9 +212,9 @@ bg=#dddddd UMLPackage - 656 - 88 - 440 + 800 + 216 + 448 112 statistics-<csi>.json @@ -224,9 +225,9 @@ bg=#dddddd UMLClass - 672 - 120 - 408 + 816 + 248 + 416 64 statistics @@ -242,9 +243,9 @@ layer=1 UMLClass - 672 - 248 - 408 + 816 + 376 + 416 152 actions @@ -267,9 +268,9 @@ layer=1 UMLPackage - 656 - 216 - 440 + 800 + 344 + 448 200 actions-<csi>.json @@ -280,10 +281,10 @@ bg=#dddddd UMLClass - 672 - 632 - 408 - 168 + 816 + 760 + 416 + 184 requests -- @@ -294,6 +295,7 @@ PK ts : float // UTC timestamp op : Operation // request operation rsc : status code // Response Status Code out : boolean // CSE initiated request + ot : string // operation timestamp req : JSON // request JSON rsp : JSON // response JSON -- @@ -306,10 +308,10 @@ layer=1 UMLPackage - 656 - 600 - 440 - 216 + 800 + 728 + 448 + 232 requests -<csi>.json fg=gray @@ -319,13 +321,46 @@ bg=#dddddd UMLPackage - 192 - 88 - 440 + 320 + 216 + 448 136 resources-<csi>.json fg=gray +bg=#dddddd + + + + UMLClass + + 816 + 1008 + 416 + 96 + + schedules +-- +PK ri : string // schedule's resourceID + pi : string // parent resource resourceID + sce : list of string // list of schedule timestamps +-- + +bg=#ffffff +transparency=0 +layer=1 + + + + UMLPackage + + 800 + 976 + 448 + 144 + + schedules-<csi>.json +fg=gray bg=#dddddd From 45fb88e199b0640b53034c9aae63f7c3c467cbbc Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 5 Oct 2023 10:51:15 +0200 Subject: [PATCH 133/165] Added documentation, renamed insertIdentifier to upsertIdentifier --- acme/services/Storage.py | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/acme/services/Storage.py b/acme/services/Storage.py index 6d3555e6..21f3c516 100644 --- a/acme/services/Storage.py +++ b/acme/services/Storage.py @@ -78,6 +78,9 @@ class Storage(object): def __init__(self) -> None: """ Initialization of the storage manager. + + Raises: + RuntimeError: In case of an error during initialization. """ # create data directory @@ -200,10 +203,12 @@ def createResource(self, resource:Resource, overwrite:Optional[bool] = True) -> Args: resource: The resource to store in the database. overwrite: Indicator whether an existing resource shall be overwritten. + + Raises: + CONFLICT: In case the resource already exists and *overwrite* is "False". """ ri = resource.ri srn = resource.getSrn() - # L.logDebug(f'Adding resource (ty: {resource.ty}, ri: {resource.ri}, rn: {resource.rn}, srn: {srn}') if overwrite: L.isDebug and L.logDebug('Resource enforced overwrite') self.db.upsertResource(resource, ri) @@ -214,10 +219,10 @@ def createResource(self, resource:Resource, overwrite:Optional[bool] = True) -> raise CONFLICT(L.logWarn(f'Resource already exists (Skipping): {resource} ri: {ri} srn:{srn}')) # Add path to identifiers db - self.db.insertIdentifier(resource, ri, srn) + self.db.upsertIdentifier(resource, ri, srn) # Add record to childResources db - self.db.addChildResource(resource, ri) + self.db.upsertChildResource(resource, ri) def hasResource(self, ri:Optional[str] = None, srn:Optional[str] = None) -> bool: @@ -251,6 +256,10 @@ def retrieveResource(self, ri:Optional[str] = None, Returns: The resource. + + Raises: + NOT_FOUND: In case the resource does not exist. + INTENRAL_SERVER_ERROR: In case of a database inconsistency. """ resources = [] @@ -287,6 +296,10 @@ def retrieveResourceRaw(self, ri:str) -> JSON: Returns: The resource dictionary. + + Raises: + NOT_FOUND: In case the resource does not exist. + INTENRAL_SERVER_ERROR: In case of a database inconsistency. """ resources = self.db.searchResources(ri = ri) match len(resources): @@ -330,6 +343,9 @@ def deleteResource(self, resource:Resource) -> None: Args: resource: Resource to delete. + + Raises: + NOT_FOUND: In case the resource does not exist. """ # L.logDebug(f'Removing resource (ty: {resource.ty}, ri: {resource.ri}, rn: {resource.rn})') try: @@ -337,7 +353,7 @@ def deleteResource(self, resource:Resource) -> None: self.db.deleteIdentifier(resource) self.db.removeChildResource(resource) except KeyError: - raise NOT_FOUND(dbg = L.logDebug(f'Cannot remove: {resource.ri} (NOT_FOUND). Could be an expected error.')) + raise NOT_FOUND(L.logDebug(f'Cannot remove: {resource.ri} (NOT_FOUND). Could be an expected error.')) def directChildResources(self, pi:str, @@ -503,12 +519,15 @@ def removeSubscription(self, subscription:Resource) -> bool: Return: Boolean value to indicate success or failure. + + Raises: + NOT_FOUND: In case the subscription does not exist. """ # L.logDebug(f'Removing subscription: {subscription.ri}') try: return self.db.removeSubscription(subscription) except KeyError as e: - raise NOT_FOUND(dbg = L.logDebug(f'Cannot subscription data for: {subscription.ri} (NOT_FOUND). Could be an expected error.')) + raise NOT_FOUND(L.logDebug(f'Cannot subscription data for: {subscription.ri} (NOT_FOUND). Could be an expected error.')) def updateSubscription(self, subscription:Resource) -> bool: @@ -1274,8 +1293,8 @@ def searchByFragment(self, dct:dict) -> list[Document]: # Identifiers, Structured RI, Child Resources # - def insertIdentifier(self, resource:Resource, ri:str, srn:str) -> None: - """ Insert an identifier into the identifiers DB. + def upsertIdentifier(self, resource:Resource, ri:str, srn:str) -> None: + """ Insert or update an identifier into the identifiers DB. Args: resource: The resource to insert. @@ -1322,7 +1341,7 @@ def searchIdentifiers(self, ri:Optional[str] = None, ri: Resource ID to search for. srn: Structured path to search for. Return: - A list of found identifier documents (see `insertIdentifier`), or an empty list if not found. + A list of found identifier documents (see `upsertIdentifier`), or an empty list if not found. """ _r:Document if srn: @@ -1338,7 +1357,7 @@ def searchIdentifiers(self, ri:Optional[str] = None, return [] - def addChildResource(self, resource:Resource, ri:str) -> None: + def upsertChildResource(self, resource:Resource, ri:str) -> None: """ Add a child resource to the childResources DB. Args: From 1f242c74c19a63747e90f6e4c8c581318b35393b Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 6 Oct 2023 14:49:55 +0200 Subject: [PATCH 134/165] Support for the Python *Web Server Gateway Interface* to improve integration with a reverse proxy or API gateway, ie. Nginx. --- CHANGELOG.md | 1 + acme.ini.default | 14 +++ acme/__main__.py | 1 + acme/services/Configuration.py | 214 +++++++++++++++++++-------------- acme/services/HttpServer.py | 33 +++-- acme/services/Onboarding.py | 10 ++ docs/Configuration.md | 15 +++ docs/Installation.md | 1 + docs/Running.md | 35 +++--- docs/Supported.md | 21 ++-- init/configurations.docmd | 40 ++++++ requirements.txt | 28 +++-- setup.py | 1 + 13 files changed, 278 insertions(+), 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0059eb22..b334e9e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Calendar Versioning](https://calver.org). - [TUI] Improved resource view in the text UI. Enumeration interpretations are now shown. - [TUI] Added utility "Attribute Info Search". - [HTTP] Added support for http authorization for *basic* and *bearer* (token) methods. +- [HTTP] Support for the Python *Web Server Gateway Interface* to improve integration with a reverse proxy or API gateway, ie. Nginx. Thanks to @samuelbles07 for the idea. - [MISC] Added tool to generate documentation from the source code. See [tools/apidocs/README.md](tools/apidocs/README.md). ### Experimental diff --git a/acme.ini.default b/acme.ini.default index d420f3bb..786279ac 100644 --- a/acme.ini.default +++ b/acme.ini.default @@ -210,6 +210,20 @@ enable=false resources=/* +[http.wsgi] +; Enable WSGI support for the HTTP binding. +; Default: false +enable=false +; The number of threads used to process requests. +; This number should be of similar size as the "connectionLimit" setting. +; Default: 100 +threadPoolSize=100 +; The number of possible parallel connections that can be accepted by the WSGI server. +; One connection uses one system file descriptor. +; Default: 100 +connectionLimit=100 + + ; ; MQTT client settings ; diff --git a/acme/__main__.py b/acme/__main__.py index ccb60fb7..3bd012df 100644 --- a/acme/__main__.py +++ b/acme/__main__.py @@ -63,6 +63,7 @@ def parseArgs() -> argparse.Namespace: groupEnableHttp = parser.add_mutually_exclusive_group() groupEnableHttp.add_argument('--http', action='store_false', dest='http', default=None, help='run CSE with http server') groupEnableHttp.add_argument('--https', action='store_true', dest='https', default=None, help='run CSE with https server') + groupEnableHttp.add_argument('--http-wsgi', action='store_true', dest='httpWsgi', default=None, help='run CSE with http WSGI support') groupEnableMqtt = parser.add_mutually_exclusive_group() groupEnableMqtt.add_argument('--mqtt', action='store_true', dest='mqttenabled', default=None, help='enable mqtt binding') diff --git a/acme/services/Configuration.py b/acme/services/Configuration.py index 24ef7192..e8f134dc 100644 --- a/acme/services/Configuration.py +++ b/acme/services/Configuration.py @@ -98,6 +98,7 @@ class Configuration(object): _argsMqttEnabled:bool = None _argsRemoteCSEEnabled:bool = None _argsRunAsHttps:bool = None + _argsRunAsHttpWsgi:bool = None _argsStatisticsEnabled:bool = None _argsTextUI:bool = None @@ -128,6 +129,7 @@ def init(args:argparse.Namespace = None) -> bool: Configuration._argsMqttEnabled = args.mqttenabled if args and 'mqttenabled' in args else None Configuration._argsRemoteCSEEnabled = args.remotecseenabled if args and 'remotecseenabled' in args else None Configuration._argsRunAsHttps = args.https if args and 'https' in args else None + Configuration._argsRunAsHttpWsgi = args.httpWsgi if args and 'httpWsgi' in args else None Configuration._argsStatisticsEnabled = args.statisticsenabled if args and 'statisticsenabled' in args else None Configuration._argsTextUI = args.textui if args and 'textui' in args else None @@ -146,10 +148,10 @@ def init(args:argparse.Namespace = None) -> bool: # Read and parse the configuration file - config = configparser.ConfigParser( interpolation=configparser.ExtendedInterpolation(), + config = configparser.ConfigParser( interpolation = configparser.ExtendedInterpolation(), # Convert csv to list, ignore empty elements - converters={'list': lambda x: [i.strip() for i in x.split(',') if i]} + converters = {'list': lambda x: [i.strip() for i in x.split(',') if i]} ) config.read_dict({ 'basic.config': { 'baseDirectory' : pathlib.Path(os.path.abspath(os.path.dirname(__file__))).parent.parent, # points to the acme module's parent directory @@ -314,6 +316,15 @@ def init(args:argparse.Namespace = None) -> bool: 'http.cors.enable' : config.getboolean('http.cors', 'enable', fallback = False), 'http.cors.resources' : config.getlist('http.cors', 'resources', fallback = [ r'/*' ]), # type: ignore [attr-defined] + # + # HTTP Server WSGI + # + + 'http.wsgi.enable' : config.getboolean('http.wsgi', 'enable', fallback = False), + 'http.wsgi.threadPoolSize' : config.getint('http.wsgi', 'threadPoolSize', fallback = 100), + 'http.wsgi.connectionLimit' : config.getint('http.wsgi', 'connectionLimit', fallback = 100), + + # # HTTP Server Security # @@ -498,50 +509,58 @@ def init(args:argparse.Namespace = None) -> bool: def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: # Some clean-ups and overrides + def _get(key:str) -> Any: + return Configuration.get(key) + + + def _put(key:str, value:Any) -> None: + Configuration._configuration[key] = value + + from ..etc.Utils import normalizeURL, isValidCSI # cannot import at the top because of circel import # CSE type - if isinstance(cseType := Configuration._configuration['cse.type'], str): + if isinstance(cseType := _get('cse.type'), str): cseType = cseType.lower() match cseType: case 'asn': - Configuration._configuration['cse.type'] = CSEType.ASN + _put('cse.type', CSEType.ASN) case 'mn': - Configuration._configuration['cse.type'] = CSEType.MN + _put('cse.type', CSEType.MN) case 'in': - Configuration._configuration['cse.type'] = CSEType.IN + _put('cse.type', CSEType.IN) case _: return False, f'Configuration Error: Unsupported \[cse]:type: {cseType}' # CSE Serialization - if isinstance(ct := Configuration._configuration['cse.defaultSerialization'], str): - Configuration._configuration['cse.defaultSerialization'] = ContentSerializationType.toContentSerialization(ct) - if Configuration._configuration['cse.defaultSerialization'] == ContentSerializationType.UNKNOWN: + if isinstance(ct := _get('cse.defaultSerialization'), str): + _put('cse.defaultSerialization', ContentSerializationType.toContentSerialization(ct)) + if _get('cse.defaultSerialization') == ContentSerializationType.UNKNOWN: return False, f'Configuration Error: Unsupported \[cse]:defaultSerialization: {ct}' # Registrar Serialization - if isinstance(ct := Configuration._configuration['cse.registrar.serialization'], str): - Configuration._configuration['cse.registrar.serialization'] = ContentSerializationType.toContentSerialization(ct) - if Configuration._configuration['cse.registrar.serialization'] == ContentSerializationType.UNKNOWN: + if isinstance(ct := _get('cse.registrar.serialization'), str): + _put('cse.registrar.serialization', ContentSerializationType.toContentSerialization(ct)) + if _get('cse.registrar.serialization') == ContentSerializationType.UNKNOWN: return False, f'Configuration Error: Unsupported \[cse.registrar]:serialization: {ct}' # Loglevel and various overrides from command line from ..services.Logging import LogLevel - if isinstance(logLevel := Configuration._configuration['logging.level'], str): + if isinstance(logLevel := _get('logging.level'), str): logLevel = logLevel.lower() logLevel = (Configuration._argsLoglevel or logLevel) # command line args override config match logLevel: case 'off': - Configuration._configuration['logging.level'] = LogLevel.OFF + _put('logging.level', LogLevel.OFF) case 'info': - Configuration._configuration['logging.level'] = LogLevel.INFO + _put('logging.level', LogLevel.INFO) case 'warn' | 'warning': - Configuration._configuration['logging.level'] = LogLevel.WARNING + _put('logging.level', LogLevel.WARNING) case 'error': - Configuration._configuration['logging.level'] = LogLevel.ERROR + _put('logging.level', LogLevel.ERROR) case 'debug': - Configuration._configuration['logging.level'] = LogLevel.DEBUG + _put('logging.level', LogLevel.DEBUG) case _: return False, f'Configuration Error: Unsupported \[logging]:level: {logLevel}' @@ -551,39 +570,39 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: return False, f'Configuration Error: \[logging]:queueSize must be 0 or greater' # Overwriting some configurations from command line - if Configuration._argsDBReset is True: Configuration._configuration['database.resetOnStartup'] = True # Override DB reset from command line - if Configuration._argsDBStorageMode is not None: Configuration._configuration['database.inMemory'] = Configuration._argsDBStorageMode == 'memory' # Override DB storage mode from command line - if Configuration._argsHttpAddress is not None: Configuration._configuration['http.address'] = Configuration._argsHttpAddress # Override server http address - if Configuration._argsHttpPort is not None: Configuration._configuration['http.port'] = Configuration._argsHttpPort # Override server http port - if Configuration._argsImportDirectory is not None: Configuration._configuration['cse.resourcesPath'] = Configuration._argsImportDirectory # Override import directory from command line - if Configuration._argsListenIF is not None: Configuration._configuration['http.listenIF'] = Configuration._argsListenIF # Override binding network interface - if Configuration._argsMqttEnabled is not None: Configuration._configuration['mqtt.enable'] = Configuration._argsMqttEnabled # Override mqtt enable - if Configuration._argsRemoteCSEEnabled is not None: Configuration._configuration['cse.enableRemoteCSE'] = Configuration._argsRemoteCSEEnabled # Override remote CSE enablement - if Configuration._argsRunAsHttps is not None: Configuration._configuration['http.security.useTLS'] = Configuration._argsRunAsHttps # Override useTLS - if Configuration._argsStatisticsEnabled is not None: Configuration._configuration['cse.statistics.enable'] = Configuration._argsStatisticsEnabled # Override statistics enablement - if Configuration._argsTextUI is not None: Configuration._configuration['textui.startWithTUI'] = Configuration._argsTextUI - if Configuration._argsHeadless is True: - Configuration._configuration['console.headless'] = True + if Configuration._argsDBReset is True: _put('database.resetOnStartup', True) # Override DB reset from command line + if Configuration._argsDBStorageMode is not None: _put('database.inMemory', Configuration._argsDBStorageMode == 'memory') # Override DB storage mode from command line + if Configuration._argsHttpAddress is not None: _put('http.address', Configuration._argsHttpAddress) # Override server http address + if Configuration._argsHttpPort is not None: _put('http.port', Configuration._argsHttpPort) # Override server http port + if Configuration._argsImportDirectory is not None: _put('cse.resourcesPath', Configuration._argsImportDirectory) # Override import directory from command line + if Configuration._argsListenIF is not None: _put('http.listenIF', Configuration._argsListenIF) # Override binding network interface + if Configuration._argsMqttEnabled is not None: _put('mqtt.enable', Configuration._argsMqttEnabled) # Override mqtt enable + if Configuration._argsRemoteCSEEnabled is not None: _put('cse.enableRemoteCSE', Configuration._argsRemoteCSEEnabled) # Override remote CSE enablement + if Configuration._argsRunAsHttps is not None: _put('http.security.useTLS', Configuration._argsRunAsHttps) # Override useTLS + if Configuration._argsRunAsHttpWsgi is not None: _put('http.wsgi.enable', Configuration._argsRunAsHttpWsgi) # Override use WSGI + if Configuration._argsStatisticsEnabled is not None: _put('cse.statistics.enable', Configuration._argsStatisticsEnabled) # Override statistics enablement + if Configuration._argsTextUI is not None: _put('textui.startWithTUI', Configuration._argsTextUI) + if Configuration._argsHeadless is True: _put('console.headless', True) # Correct urls - Configuration._configuration['cse.registrar.address'] = normalizeURL(Configuration._configuration['cse.registrar.address']) - Configuration._configuration['http.address'] = normalizeURL(Configuration._configuration['http.address']) - Configuration._configuration['http.root'] = normalizeURL(Configuration._configuration['http.root']) - Configuration._configuration['cse.registrar.root'] = normalizeURL(Configuration._configuration['cse.registrar.root']) + _put('cse.registrar.address', normalizeURL(Configuration._configuration['cse.registrar.address'])) + _put('http.address', normalizeURL(Configuration._configuration['http.address'])) + _put('http.root', normalizeURL(Configuration._configuration['http.root'])) + _put('cse.registrar.root', normalizeURL(Configuration._configuration['cse.registrar.root'])) # Just in case: check the URL's - if Configuration._configuration['http.security.useTLS']: - if Configuration._configuration['http.address'].startswith('http:'): + if _get('http.security.useTLS'): + if _get('http.address').startswith('http:'): Configuration._print('[orange3]Configuration Warning: Changing "http" to "https" in [i]\[http]:address[/i]') - Configuration._configuration['http.address'] = Configuration._configuration['http.address'].replace('http:', 'https:') + _put('http.address', _get('http.address').replace('http:', 'https:')) # registrar might still be accessible vi another protocol # if Configuration._configuration['cse.registrar.address'].startswith('http:'): # _print('[orange3]Configuration Warning: Changing "http" to "https" in \[cse.registrar]:address') # Configuration._configuration['cse.registrar.address'] = Configuration._configuration['cse.registrar.address'].replace('http:', 'https:') else: - if Configuration._configuration['http.address'].startswith('https:'): + if _get('http.address').startswith('https:'): Configuration._print('[orange3]Configuration Warning: Changing "https" to "http" in [i]\[http]:address[/i]') - Configuration._configuration['http.address'] = Configuration._configuration['http.address'].replace('https:', 'http:') + _put('http.address', _get('http.address').replace('https:', 'http:')) # registrar might still be accessible vi another protocol # if Configuration._configuration['cse.registrar.address'].startswith('https:'): # _print('[orange3]Configuration Warning: Changing "https" to "http" in \[cse.registrar]:address') @@ -591,11 +610,11 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: # Operation - if Configuration._configuration['cse.operation.jobs.balanceTarget'] <= 0.0: + if _get('cse.operation.jobs.balanceTarget') <= 0.0: return False, f'Configuration Error: [i]\[cse.operation.jobs]:balanceTarget[/i] must be > 0.0' - if Configuration._configuration['cse.operation.jobs.balanceLatency'] < 0: + if _get('cse.operation.jobs.balanceLatency') < 0: return False, f'Configuration Error: [i]\[cse.operation.jobs]:balanceLatency[/i] must be >= 0' - if Configuration._configuration['cse.operation.jobs.balanceReduceFactor'] < 1.0: + if _get('cse.operation.jobs.balanceReduceFactor') < 1.0: return False, f'Configuration Error: [i]\[cse.operation.jobs]:balanceReduceFactor[/i] must be >= 1.0' @@ -604,88 +623,101 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: # # HTTP TLS & certificates - if not Configuration._configuration['http.security.useTLS']: # clear certificates configuration if not in use - Configuration._configuration['http.security.verifyCertificate'] = False - Configuration._configuration['http.security.tlsVersion'] = 'auto' - Configuration._configuration['http.security.caCertificateFile'] = '' - Configuration._configuration['http.security.caPrivateKeyFile'] = '' + if not _get('http.security.useTLS'): # clear certificates configuration if not in use + _put('http.security.verifyCertificate', False) + _put('http.security.tlsVersion', 'auto') + _put('http.security.caCertificateFile', '') + _put('http.security.caPrivateKeyFile', '') else: - if not (val := Configuration._configuration['http.security.tlsVersion']).lower() in [ 'tls1.1', 'tls1.2', 'auto' ]: + if not (val := _get('http.security.tlsVersion')).lower() in [ 'tls1.1', 'tls1.2', 'auto' ]: return False, f'Configuration Error: Unknown value for [i]\[http.security]:tlsVersion[/i]: {val}' - if not (val := Configuration._configuration['http.security.caCertificateFile']): + if not (val := _get('http.security.caCertificateFile')): return False, 'Configuration Error: [i]\[http.security]:caCertificateFile[/i] must be set when TLS is enabled' if not os.path.exists(val): return False, f'Configuration Error: [i]\[http.security]:caCertificateFile[/i] does not exists or is not accessible: {val}' - if not (val := Configuration._configuration['http.security.caPrivateKeyFile']): + if not (val := _get('http.security.caPrivateKeyFile')): return False, 'Configuration Error: [i]\[http.security]:caPrivateKeyFile[/i] must be set when TLS is enabled' if not os.path.exists(val): return False, f'Configuration Error: [i]\[http.security]:caPrivateKeyFile[/i] does not exists or is not accessible: {val}' + # HTTP CORS - if initial and Configuration._configuration['http.cors.enable'] and not Configuration._configuration['http.security.useTLS']: + if initial and _get('http.cors.enable') and not _get('http.security.useTLS'): Configuration._print('[orange3]Configuration Warning: [i]\[http.security].useTLS[/i] (https) should be enabled when [i]\[http.cors].enable[/i] is enabled.') + # HTTP authentication - if Configuration._configuration['http.security.enableBasicAuth'] and not Configuration._configuration['http.security.basicAuthFile']: + if _get('http.security.enableBasicAuth') and not _get('http.security.basicAuthFile'): return False, 'Configuration Error: [i]\[http.security]:httpBasicAuthFile[/i] must be set when HTTP Basic Auth is enabled' - if Configuration._configuration['http.security.enableTokenAuth'] and not Configuration._configuration['http.security.tokenAuthFile']: + if _get('http.security.enableTokenAuth') and not _get('http.security.tokenAuthFile'): return False, 'Configuration Error: [i]\[http.security]:httpTokenAuthFile[/i] must be set when HTTP Token Auth is enabled' + + + # HTTP WSGI + if _get('http.wsgi.enable') and _get('http.security.useTLS'): + # WSGI and TLS cannot both be enabled + return False, 'Configuration Error: [i]\[http.security].useTLS[/i] (https) cannot be enabled when [i]\[http.wsgi].enable[/i] is enabled (WSGI and TLS cannot both be enabled).' + if _get('http.wsgi.threadPoolSize') < 1: + return False, 'Configuration Error: [i]\[http.wsgi]:threadPoolSize[/i] must be > 0' + if _get('http.wsgi.connectionLimit') < 1: + return False, 'Configuration Error: [i]\[http.wsgi]:connectionLimit[/i] must be > 0' + # # MQTT client # - if not Configuration._configuration['mqtt.port']: # set the default port depending on whether to use TLS - Configuration._configuration['mqtt.port'] = 8883 if Configuration._configuration['mqtt.security.useTLS'] else 1883 - if not (Configuration._configuration['mqtt.security.username']) != (not Configuration._configuration['mqtt.security.password']): + if not _get('mqtt.port'): # set the default port depending on whether to use TLS + _put('mqtt.port', 8883) if _get('mqtt.security.useTLS') else 1883 + if not _get('mqtt.security.username') != (not _get('mqtt.security.password')): # Hack: != -> either both are empty, or both are set return False, f'Configuration Error: Username or password missing for [i]\[mqtt.security][/i]' # remove empty cid from the list - Configuration._configuration['mqtt.security.allowedCredentialIDs'] = [ cid for cid in Configuration._configuration['mqtt.security.allowedCredentialIDs'] if len(cid) ] + _put('mqtt.security.allowedCredentialIDs', [ cid for cid in _get('mqtt.security.allowedCredentialIDs') if len(cid) ]) # COAP TLS & certificates - if not Configuration._configuration['coap.security.useDTLS']: # clear certificates configuration if not in use - Configuration._configuration['coap.security.verifyCertificate'] = False - Configuration._configuration['coap.security.tlsVersion'] = 'auto' - Configuration._configuration['coap.security.caCertificateFile'] = '' - Configuration._configuration['coap.security.caPrivateKeyFile'] = '' + if not _get('coap.security.useDTLS'): # clear certificates configuration if not in use + _put('coap.security.verifyCertificate', False) + _put('coap.security.tlsVersion', 'auto') + _put('coap.security.caCertificateFile', '') + _put('coap.security.caPrivateKeyFile', '') else: - if not (val := Configuration._configuration['coap.security.dtlsVersion']).lower() in [ 'tls1.1', 'tls1.2', 'auto' ]: + if not (val := _get('coap.security.dtlsVersion')).lower() in [ 'tls1.1', 'tls1.2', 'auto' ]: return False, f'Configuration Error: Unknown value for [i]\[coap.security]:dtlsVersion[/i]: {val}' - if not (val := Configuration._configuration['coap.security.certificateFile']): + if not (val := _get('coap.security.certificateFile')): return False, 'Configuration Error: [i]\[coap.security]:certificateFile[/i] must be set when DTLS is enabled' if not os.path.exists(val): return False, f'Configuration Error: [i]\[coap.security]:certificateFile[/i] does not exists or is not accessible: {val}' - if not (val := Configuration._configuration['coap.security.privateKeyFile']): + if not (val := _get('coap.security.privateKeyFile')): return False, 'Configuration Error: [i]\[coap.security]:privateKeyFile[/i] must be set when TLS is enabled' if not os.path.exists(val): return False, f'Configuration Error: [i]\[coap.security]:privateKeyFile[/i] does not exists or is not accessible: {val}' # check the csi format and value - if not isValidCSI(val:=Configuration._configuration['cse.cseID']): + if not isValidCSI(val := _get('cse.cseID')): return False, f'Configuration Error: Wrong format for [i]\[cse]:cseID[/i]: {val}' - if Configuration._configuration['cse.cseID'][1:] == Configuration._configuration['cse.resourceName']: + if _get('cse.cseID')[1:] == _get('cse.resourceName'): return False, f'Configuration Error: [i]\[cse]:cseID[/i] must be different from [i]\[cse]:resourceName[/i]' - if Configuration._configuration['cse.registrar.address'] and Configuration._configuration['cse.registrar.cseID']: - if not isValidCSI(val:=Configuration._configuration['cse.registrar.cseID']): + if _get('cse.registrar.address') and _get('cse.registrar.cseID'): + if not isValidCSI(val := _get('cse.registrar.cseID')): return False, f'Configuration Error: Wrong format for [i]\[cse.registrar]:cseID[/i]: {val}' - if len(Configuration._configuration['cse.registrar.cseID']) > 0 and len(Configuration._configuration['cse.registrar.resourceName']) == 0: + if len(_get('cse.registrar.cseID')) > 0 and len(_get('cse.registrar.resourceName')) == 0: return False, 'Configuration Error: Missing configuration [i]\[cse.registrar]:resourceName[/i]' # Check default subscription duration - if Configuration._configuration['resource.sub.batchNotifyDuration'] < 1: + if _get('resource.sub.batchNotifyDuration') < 1: return False, 'Configuration Error: [i]\[resource.sub]:batchNotifyDuration[/i] must be > 0' # Check flexBlocking value - Configuration._configuration['cse.flexBlockingPreference'] = Configuration._configuration['cse.flexBlockingPreference'].lower() - if Configuration._configuration['cse.flexBlockingPreference'] not in ['blocking', 'nonblocking']: + _put('cse.flexBlockingPreference', _get('cse.flexBlockingPreference').lower()) + if _get('cse.flexBlockingPreference') not in ['blocking', 'nonblocking']: return False, 'Configuration Error: [i]\[cse]:flexBlockingPreference[/i] must be "blocking" or "nonblocking"' # Check release versions - if len(srv := Configuration._configuration['cse.supportedReleaseVersions']) == 0: + if len(srv := _get('cse.supportedReleaseVersions')) == 0: return False, 'Configuration Error: [i]\[cse]:supportedReleaseVersions[/i] must not be empty' - if len(rvi := Configuration._configuration['cse.releaseVersion']) == 0: + if len(rvi := _get('cse.releaseVersion')) == 0: return False, 'Configuration Error: [i]\[cse]:releaseVersion[/i] must not be empty' if rvi not in srv: return False, f'Configuration Error: [i]\[cse]:releaseVersion[/i]: {rvi} not in [i]\[cse].supportedReleaseVersions[/i]: {srv}' @@ -693,35 +725,35 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: # return False, f'Configuration Error: \[cse]:releaseVersion: {rvi} less than highest value in \[cse].supportedReleaseVersions: {srv}. Either increase the [i]releaseVersion[/i] or reduce the set of [i]supportedReleaseVersions[/i].' # Check various intervals - if Configuration._configuration['cse.checkExpirationsInterval'] <= 0: + if _get('cse.checkExpirationsInterval') <= 0: return False, 'Configuration Error: [i]\[cse]:checkExpirationsInterval[/i] must be > 0' - if Configuration._configuration['console.refreshInterval'] <= 0.0: + if _get('console.refreshInterval') <= 0.0: return False, 'Configuration Error: [i]\[console]:refreshInterval[/i] must be > 0.0' - if Configuration._configuration['cse.maxExpirationDelta'] <= 0: + if _get('cse.maxExpirationDelta') <= 0: return False, 'Configuration Error: [i]\[cse]:maxExpirationDelta[/i] must be > 0' # Console settings from ..services.Console import TreeMode - if isinstance(tm := Configuration._configuration['console.treeMode'], str): + if isinstance(tm := _get('console.treeMode'), str): if not (treeMode := TreeMode.to(tm)): return False, f'Configuration Error: [i]\[console]:treeMode[/i] must be one of {TreeMode.names()}' - Configuration._configuration['console.treeMode'] = treeMode + _put('console.treeMode', treeMode) - Configuration._configuration['console.theme'] = (theme := Configuration._configuration['console.theme'].lower()) + _put('console.theme', (theme := _get('console.theme').lower())) if theme not in [ 'dark', 'light' ]: return False, f'Configuration Error: [i]\[console]:theme[/i] must be "light" or "dark"' - if Configuration._configuration['console.headless']: - Configuration._configuration['logging.enableScreenLogging'] = False - Configuration._configuration['textui.startWithTUI'] = False + if _get('console.headless'): + _put('logging.enableScreenLogging', False) + _put('textui.startWithTUI', False) # Script settings - if Configuration._configuration['scripting.fileMonitoringInterval'] < 0.0: + if _get('scripting.fileMonitoringInterval') < 0.0: return False, f'Configuration Error: [i]\[scripting]:fileMonitoringInterval[/i] must be >= 0.0' - if Configuration._configuration['scripting.maxRuntime'] < 0.0: + if _get('scripting.maxRuntime') < 0.0: return False, f'Configuration Error: [i]\[scripting]:maxRuntime[/i] must be >= 0.0' - if (scriptDirs := Configuration._configuration['scripting.scriptDirectories']): + if (scriptDirs := _get('scripting.scriptDirectories')): lst = [] for each in scriptDirs: if not each: @@ -729,18 +761,18 @@ def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: if not os.path.isdir(each): return False, f'Configuration Error: [i]\[scripting]:scriptDirectory[/i]: directory "{each}" does not exist, is not a directory or is not accessible' lst.append(each) - Configuration._configuration['scripting.scriptDirectories'] = lst + _put('scripting.scriptDirectories', lst) # TimeSyncBeacon defaults - bcni = Configuration._configuration['resource.tsb.bcni'] + bcni = _get('resource.tsb.bcni') try: isodate.parse_duration(bcni) except Exception as e: return False, f'Configuration Error: [i]\[resource.tsb]:bcni[/i]: configuration value must be an ISO8601 duration' # Check group resource defaults - if Configuration._configuration['resource.grp.resultExpirationTime'] < 0: + if _get('resource.grp.resultExpirationTime') < 0: return False, f'Configuration Error: [i]\[resource.grp]:resultExpirationTime[/i] must be >= 0' # Everything is fine diff --git a/acme/services/HttpServer.py b/acme/services/HttpServer.py index 9c4e4f5b..77329a5d 100644 --- a/acme/services/HttpServer.py +++ b/acme/services/HttpServer.py @@ -16,10 +16,10 @@ import flask from flask import Flask, Request, request - from werkzeug.wrappers import Response from werkzeug.serving import WSGIRequestHandler from werkzeug.datastructures import MultiDict +from waitress import serve from flask_cors import CORS import requests import isodate @@ -73,11 +73,13 @@ class HttpServer(object): 'corsResources', 'enableBasicAuth', 'enableTokenAuth', + 'wsgiEnable', + 'wsgiThreadPoolSize', + 'wsgiConnectionLimit', 'backgroundActor', 'serverID', '_responseHeaders', 'webui', - 'mappeings', 'httpActor', '_eventHttpRetrieve', @@ -181,6 +183,9 @@ def _assignConfig(self) -> None: self.corsResources = Configuration.get('http.cors.resources') self.enableBasicAuth = Configuration.get('http.security.enableBasicAuth') self.enableTokenAuth = Configuration.get('http.security.enableTokenAuth') + self.wsgiEnable = Configuration.get('http.wsgi.enable') + self.wsgiThreadPoolSize = Configuration.get('http.wsgi.threadPoolSize') + self.wsgiConnectionLimit= Configuration.get('http.wsgi.connectionLimit') def configUpdate(self, name:str, @@ -202,6 +207,9 @@ def configUpdate(self, name:str, 'webui.root', 'http.cors.enable', 'http.cors.resources', + 'http.wsgi.enable', + 'http.wsgi.threadPoolSize', + 'http.wsgi.connectionLimit', 'http.security.enableBasicAuth', 'http.security.enableTokenAuth', 'mqtt.security.password' @@ -255,12 +263,21 @@ def _run(self) -> None: cli.show_server_banner = lambda *x: None # type: ignore # Start the server try: - self.flaskApp.run(host = self.listenIF, - port = self.port, - threaded = True, - request_handler = ACMERequestHandler, - ssl_context = CSE.security.getSSLContext(), - debug = False) + if self.wsgiEnable: + L.isInfo and L.log(f'HTTP server listening on {self.listenIF}:{self.port} (wsgi)') + serve(self.flaskApp, + host = self.listenIF, + port = self.port, + threads = self.wsgiThreadPoolSize, + connection_limit = self.wsgiConnectionLimit) + else: + L.isInfo and L.log(f'HTTP server listening on {self.listenIF}:{self.port} (flask http)') + self.flaskApp.run(host = self.listenIF, + port = self.port, + threaded = True, + request_handler = ACMERequestHandler, + ssl_context = CSE.security.getSSLContext(), + debug = False) except Exception as e: # No logging for headless, nevertheless print the reason what happened if CSE.isHeadless: diff --git a/acme/services/Onboarding.py b/acme/services/Onboarding.py index 5f33d515..67baaadf 100644 --- a/acme/services/Onboarding.py +++ b/acme/services/Onboarding.py @@ -106,6 +106,8 @@ def basicConfig() -> None: value = 'Regular'), Choice(name = 'Headless - Like "regular", plus disable most screen output, and the console and text UIs', value = 'Headless'), + Choice(name = 'WSGI - Like "regular", but enable a WSGI server instead of the built-in HTTP server', + value = 'WSGI'), ], default = 'Development', transformer = lambda result: result.split()[0], @@ -371,6 +373,12 @@ def csePolicies() -> InquirerPySessionResult: """ [console] headless=True +""" + + cnfWSGI = \ +""" +[http.wsgi] +enable=True """ # Construct the configuration @@ -386,6 +394,8 @@ def csePolicies() -> InquirerPySessionResult: jcnf += cnfIntroduction case 'Headless': jcnf += cnfHeadless + case 'WSGI': + jcnf += cnfWSGI # Show configuration and confirm write _print('\n[b]Save configuration\n') diff --git a/docs/Configuration.md b/docs/Configuration.md index 97164ef9..70670a98 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -70,6 +70,7 @@ The following tables provide detailed descriptions of all the possible CSE confi [[http] - HTTP Server Settings](#http) [[http.security] - HTTP Security Settings](#security_http) [[http.cors] - HTTP CORS (Cross-Origin Resource Sharing) Settings](#http_cors) +[[http.wsgi] - HTTP WSGI (Web Server Gateway Interface) Settings](#http_wsgi) [[logging] - Logging Settings](#logging) [[mqtt] - MQTT Client Settings](#client_mqtt) [[mqtt.security] - MQTT Security Settings](#security_mqtt) @@ -211,7 +212,21 @@ The following tables provide detailed descriptions of all the possible CSE confi [top](#sections) --- + +### [http.wsgi] - HTTP WSGI (Web Server Gateway Interface) Settings + +| Setting | Description | Configuration Name | +|:----------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------| +| enable | Enable WSGI support for the HTTP binding.
Default: false | http.wsgi.enable | +| threadPoolSize | The number of threads used to process requests. This number should be of similar size as the *connectionLimit* setting.
Default: 100 | http.wsgi.threadPoolSize | +| connectionLimit | The number of possible parallel connections that can be accepted by the WSGI server. Note: One connection uses one system file descriptor.
Default: 100 | http.wsgi.connectionLimit | + + + +[top](#sections) + +--- ### [mqtt] - MQTT Client Settings diff --git a/docs/Installation.md b/docs/Installation.md index 6f5e22f1..a87c8279 100644 --- a/docs/Installation.md +++ b/docs/Installation.md @@ -76,6 +76,7 @@ The following third-party components are used by the ACME CSE. - The CSE uses the [Rich](https://github.com/willmcgugan/rich) text formatter library to format various terminal output. MIT License - [shapely](https://github.com/shapely/shapely) is a library for manipulation and analysis of geometric objects. BSD 3-Clause License - [Textual](https://github.com/textualize/textual) is a Rapid Application Development framework for to build textual user interfaces in Python. MIT License +- [waitress](https://github.com/Pylons/waitress) is a production-quality pure-Python WSGI server with very acceptable performance. ZPL 2.1 License - To store resources the CSE uses the lightweight [TinyDB](https://github.com/msiemens/tinydb) document database. MIT License diff --git a/docs/Running.md b/docs/Running.md index a2ba7b21..e7518fb1 100644 --- a/docs/Running.md +++ b/docs/Running.md @@ -18,23 +18,24 @@ configuration process](Installation.md#first_setup) is started if the configurat In additions, you can provide additional command line arguments that will override the respective settings from the configuration file: -| Command Line Argument | Description | -|:--------------------------------------------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------| -| -h, --help | Show a help message and exit. | -| --http, --https | Run the CSE with http or https server.
This overrides the [useTLS](Configuration.md#security) configuration setting. | -| --config <filename> | Specify a configuration file that is used instead of the default (*acme.ini*) one. | -| --db-reset | Reset and clear the database when starting the CSE. | -| --db-storage {memory,disk} | Specify the DB\'s storage mode.
This overrides the [inMemory](Configuration.md#database) configuration setting. | -| --headless | Operate the CSE in headless mode. This disables almost all screen output and also the build-in console interface. | -| --http-address <server URL> | Specify the CSE\'s http server URL.
This overrides the [address](Configuration.md#http_server) configuration setting. | -| --http-port <http port> | Specify the CSE\'s http server port.
This overrides the [address](Configuration.md#http_port) configuration setting. | -| --import-directory <directory> | Specify the import directory.
This overrides the [resourcesPath](Configuration.md#general) configuration setting. | -| --network-interface <ip address | Specify the network interface/IP address to bind to.
This overrides the [listenIF](Configuration.md#server_http) configuration setting. | -| --log-level {info, error, warn, debug, off} | Set the log level, or turn logging off.
This overrides the [level](Configuration.md#logging) configuration setting. | -| --mqtt, --no-mqtt | Enable or disable the MQTT binding.
This overrides MQTT's [enable](Configuration.md#client_mqtt) configuration setting. | -| --remote-cse, --no-remote-cse | Enable or disable remote CSE connections and checking.
This overrides the [enableRemoteCSE](Configuration.md#general) configuration setting. | -| --statistics, --no-statistics | Enable or disable collecting CSE statistics.
This overrides the [enable](Configuration.md#statistics) configuration setting. | -| --textui | Run the CSE's text UI after startup. | +| Command Line Argument | Description | +|:--------------------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------| +| -h, --help | Show a help message and exit. | +| --config <filename> | Specify a configuration file that is used instead of the default (*acme.ini*) one. | +| --db-reset | Reset and clear the database when starting the CSE. | +| --db-storage {memory,disk} | Specify the DB\'s storage mode.
This overrides the [inMemory](Configuration.md#database) configuration setting. | +| --headless | Operate the CSE in headless mode. This disables almost all screen output and also the build-in console interface. | +| --http, --https | Run the CSE with http or https server.
This overrides the [useTLS](Configuration.md#security) configuration setting. | +| --http-wsgi | Run CSE with http WSGI support.
This overrides the [http.wsgi.enable]() configuration setting. | +| --http-address <server URL> | Specify the CSE\'s http server URL.
This overrides the [address](Configuration.md#http_server) configuration setting. | +| --http-port <http port> | Specify the CSE\'s http server port.
This overrides the [address](Configuration.md#http_port) configuration setting. | +| --import-directory <directory> | Specify the import directory.
This overrides the [resourcesPath](Configuration.md#general) configuration setting. | +| --network-interface <ip address | Specify the network interface/IP address to bind to.
This overrides the [listenIF](Configuration.md#server_http) configuration setting. | +| --log-level {info, error, warn, debug, off} | Set the log level, or turn logging off.
This overrides the [level](Configuration.md#logging) configuration setting. | +| --mqtt, --no-mqtt | Enable or disable the MQTT binding.
This overrides MQTT's [enable](Configuration.md#client_mqtt) configuration setting. | +| --remote-cse, --no-remote-cse | Enable or disable remote CSE connections and checking.
This overrides the [enableRemoteCSE](Configuration.md#general) configuration setting. | +| --statistics, --no-statistics | Enable or disable collecting CSE statistics.
This overrides the [enable](Configuration.md#statistics) configuration setting. | +| --textui | Run the CSE's text UI after startup. | diff --git a/docs/Supported.md b/docs/Supported.md index 2f909b3d..4cf08571 100644 --- a/docs/Supported.md +++ b/docs/Supported.md @@ -107,16 +107,17 @@ The following table presents the supported management object specifications. ### Additional CSE Features -| Functionality | Remark | -|:----------------------|:----------------------------------------------------------------------------------------------------------| -| HTTP CORS | Support for *Cross-Origin Resource Sharing* to support http(s) redirects. | -| HTTP Authorization | Basic support for *basic* and *bearer* (token) authorization. | -| Text Console | Control and manage the CSE, inspect resources, run scripts in a text console. | -| Test UI | Text-based UI to inspect resources and requests, configurations, stats, and more | -| Testing: Upper Tester | Basic support for the Upper Tester protocol defined in TS-0019, and additional command execution support. | -| Request Recording | Record requests to and from the CSE to learn and debug requests over Mca and Mcc. | -| Script Interpreter | Lisp-based scripting support to extent functionalities, implement simple AEs, prototypes, test, ... | -| Web UI | | +| Functionality | Remark | +|:----------------------|:-----------------------------------------------------------------------------------------------------------------------------| +| HTTP CORS | Support for *Cross-Origin Resource Sharing* to support http(s) redirects. | +| HTTP Authorization | Basic support for *basic* and *bearer* (token) authorization. | +| HTTP WSGI | Support for the Python *Web Server Gateway Interface* to improve integration with a reverse proxy or API gateway, ie. Nginx. | +| Text Console | Control and manage the CSE, inspect resources, run scripts in a text console. | +| Test UI | Text-based UI to inspect resources and requests, configurations, stats, and more | +| Testing: Upper Tester | Basic support for the Upper Tester protocol defined in TS-0019, and additional command execution support. | +| Request Recording | Record requests to and from the CSE to learn and debug requests over Mca and Mcc. | +| Script Interpreter | Lisp-based scripting support to extent functionalities, implement simple AEs, prototypes, test, ... | +| Web UI | | ### Experimental CSE Features diff --git a/init/configurations.docmd b/init/configurations.docmd index 8edefd29..922687fc 100644 --- a/init/configurations.docmd +++ b/init/configurations.docmd @@ -881,6 +881,7 @@ Comments are lines starting with a #. The default value is `${basic.config:dataDirectory}/certs/http_basic_auth.txt`. + # http.security.tokenAuthFile This setting specifies the path to the CSE's HTTP server's token authentication file. @@ -891,6 +892,45 @@ The default value is `${basic.config:dataDirectory}/certs/http_token_auth.txt`. +# http.wsgi + +This section contains settings that control the CSE's HTTP server's WSGI support. + +The *Web Server Gateway Interface* is a simple calling convention for web servers to forward requests to +web applications. It is intended to be used together with a reverse proxy server or API gateway, for example *nginx*. + +Note, that the CSE's HTTP server's WSGI implementation does not support TLS. It is intended for use in +a local network and behind a secure gateway only. + + + +# http.wsgi.enable + +This setting enables or disables the CSE's HTTP server's WSGI support. + +The default value is `False`. + + + +# http.wsgi.connectionLimit + +This setting specifies the number of possible parallel connections that can be accepted by the WSGI server. +One connection uses one system file descriptor. + +The default value is `100`. + + + +# http.wsgi.threadPoolSize + +This setting specifies the number of threads used to process requests. + +This number should be of similar size as the *connectionLimit* setting. + +The default value is `100`. + + + # logging This section contains settings that control the CSE's logging behavior. diff --git a/requirements.txt b/requirements.txt index 8068a71f..e7d81850 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,11 +10,11 @@ cbor2==5.4.6 # via ACME-oneM2M-CSE (setup.py) certifi==2023.7.22 # via requests -charset-normalizer==3.2.0 +charset-normalizer==3.3.0 # via requests click==8.1.7 # via flask -flask==2.3.3 +flask==3.0.0 # via # ACME-oneM2M-CSE (setup.py) # flask-cors @@ -49,7 +49,7 @@ mdit-py-plugins==0.4.0 # via markdown-it-py mdurl==0.1.2 # via markdown-it-py -numpy==1.25.2 +numpy==1.26.0 # via shapely paho-mqtt==1.6.1 # via ACME-oneM2M-CSE (setup.py) @@ -69,7 +69,7 @@ rdflib==7.0.0 # via ACME-oneM2M-CSE (setup.py) requests==2.31.0 # via ACME-oneM2M-CSE (setup.py) -rich==13.5.2 +rich==13.6.0 # via # ACME-oneM2M-CSE (setup.py) # textual @@ -77,19 +77,27 @@ shapely==2.0.1 # via ACME-oneM2M-CSE (setup.py) six==1.16.0 # via isodate -textual==0.36.0 +textual==0.38.1 # via ACME-oneM2M-CSE (setup.py) tinydb==4.8.0 # via ACME-oneM2M-CSE (setup.py) -typing-extensions==4.7.1 +tree-sitter==0.20.2 + # via + # textual + # tree-sitter-languages +tree-sitter-languages==1.7.0 + # via textual +typing-extensions==4.8.0 # via textual uc-micro-py==1.0.2 # via linkify-it-py -urllib3==2.0.4 +urllib3==2.0.6 # via requests -wcwidth==0.2.6 +waitress==2.1.2 + # via ACME-oneM2M-CSE (setup.py) +wcwidth==0.2.8 # via prompt-toolkit -werkzeug==2.3.7 +werkzeug==3.0.0 # via flask -zipp==3.16.2 +zipp==3.17.0 # via importlib-metadata diff --git a/setup.py b/setup.py index 05015bbd..69b33a8e 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,7 @@ 'shapely', 'textual', 'tinydb', + 'waitress', ], entry_points={ 'console_scripts': [ From 435dea7d026ddc0edae3e3b9f653a66a82fc6ad7 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sat, 7 Oct 2023 14:04:15 +0200 Subject: [PATCH 135/165] Prevented error on startup when http basic or token auth is not enabled --- acme/services/HttpServer.py | 6 +++--- acme/services/SecurityManager.py | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/acme/services/HttpServer.py b/acme/services/HttpServer.py index 77329a5d..a6c9440e 100644 --- a/acme/services/HttpServer.py +++ b/acme/services/HttpServer.py @@ -8,9 +8,9 @@ # from __future__ import annotations -from typing import Any, Callable, cast, Tuple, Optional +from typing import Any, Callable, cast, Optional -import logging, sys, urllib3, re, base64 +import logging, sys, urllib3, re from copy import deepcopy import flask @@ -25,7 +25,7 @@ import isodate from ..etc.Constants import Constants -from ..etc.Types import ReqResp, RequestType, ResourceTypes, Result, ResponseStatusCode, JSON +from ..etc.Types import ReqResp, RequestType, Result, ResponseStatusCode, JSON from ..etc.Types import Operation, CSERequest, ContentSerializationType, DesiredIdentifierResultType, ResponseType, ResultContentType from ..etc.ResponseStatusCodes import INTERNAL_SERVER_ERROR, BAD_REQUEST, REQUEST_TIMEOUT, TARGET_NOT_REACHABLE, ResponseException from ..etc.Utils import exceptionToResult, renameThread, uniqueRI, toSPRelative, removeNoneValuesFromDict,isURL diff --git a/acme/services/SecurityManager.py b/acme/services/SecurityManager.py index 39e8e729..6ee438a7 100644 --- a/acme/services/SecurityManager.py +++ b/acme/services/SecurityManager.py @@ -539,7 +539,8 @@ def _readHttpBasicAuthFile(self) -> None: The data is stored in the `httpBasicAuthData` dictionary. """ self.httpBasicAuthData = {} - if self.httpBasicAuthFile: + # We need to access the configuration directly, since the http server is not yet initialized + if Configuration.get('http.security.enableBasicAuth') and self.httpBasicAuthFile: try: with open(self.httpBasicAuthFile, 'r') as f: for line in f: @@ -560,7 +561,8 @@ def _readHttpTokenAuthFile(self) -> None: The data is stored in the `httpTokenAuthData` list. """ self.httpTokenAuthData = [] - if self.httpTokenAuthFile: + # We need to access the configuration directly, since the http server is not yet initialized + if Configuration.get('http.security.enableTokenAuth') and self.httpTokenAuthFile: try: with open(self.httpTokenAuthFile, 'r') as f: for line in f: From 9faff7e14516224f70f8a04b1ef9d777dfa62f35 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sat, 7 Oct 2023 14:06:39 +0200 Subject: [PATCH 136/165] Possibility to limit the size of a single log message --- CHANGELOG.md | 1 + acme.ini.default | 26 +++++++++++++++++++------- acme/services/CSE.py | 5 +++-- acme/services/Configuration.py | 20 ++++++++++++-------- acme/services/Logging.py | 3 +++ docs/Configuration.md | 3 ++- init/configurations.docmd | 10 ++++++++++ 7 files changed, 50 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b334e9e8..fff697e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Calendar Versioning](https://calver.org). - [TUI] Added utility "Attribute Info Search". - [HTTP] Added support for http authorization for *basic* and *bearer* (token) methods. - [HTTP] Support for the Python *Web Server Gateway Interface* to improve integration with a reverse proxy or API gateway, ie. Nginx. Thanks to @samuelbles07 for the idea. +- [LOGGING] Added limiting the size of a single log message. Messages that are too large are truncated. This feature is configurable (ie. length and whether to truncate or not). - [MISC] Added tool to generate documentation from the source code. See [tools/apidocs/README.md](tools/apidocs/README.md). ### Experimental diff --git a/acme.ini.default b/acme.ini.default index 786279ac..151d4734 100644 --- a/acme.ini.default +++ b/acme.ini.default @@ -380,19 +380,30 @@ excludeCSRAttributes= ; [logging] -; Enable logging to file. Default: False +; Enable logging to file. +; Default: False enableFileLogging=False -; Enable logging to the screen. Default: True +; Enable logging to the screen. +; Default: True enableScreenLogging=true -; Path to the log files. Default: ./logs +; Path to the log files. +; Default: ./logs path=${basic.config:dataDirectory}/logs -; Loglevel. Allowed values: debug, info, warning, error, off. Default: debug +; Loglevel. Allowed values: debug, info, warning, error, off. +; Default: debug level=${basic.config:logLevel} -; Number of files for log rotation. Default: 10 +; Number of files for log rotation. +; Default: 10 count=10 -; Size per log file. Default: 100.000 bytes +; Size per log file. +; Default: 100.000 bytes size=100000 -; Print a stack trace when logging an 'error' level message. Default: True +; Maximum length of a log message. Longer messages will be truncated. +; A value of 0 means no truncation. +; Default: 1000 characters +maxLogMessageLength=1000 +; Print a stack trace when logging an 'error' level message. +; Default: True stackTraceOnError=False ; Enable logging of low-level HTTP & MQTT client events. ; Default: False @@ -405,6 +416,7 @@ queueSize=5000 filter=werkzeug,markdown_it,asyncio + ; ; Settings for resource announcements ; diff --git a/acme/services/CSE.py b/acme/services/CSE.py index a23a8aad..3da4fea3 100644 --- a/acme/services/CSE.py +++ b/acme/services/CSE.py @@ -247,10 +247,11 @@ def startup(args:argparse.Namespace, **kwargs:Dict[str, Any]) -> bool: # init Logging # L.init() + L.queueOff() # No queuing of log messages during startup L.log('Starting CSE') L.log(f'CSE-Type: {cseType.name}') - L.log(Configuration.print()) - L.queueOff() # No queuing of log messages during startup + for l in Configuration.print().split('\n'): + L.log(l) # set the logger for the backgroundWorkers. Add an offset to compensate for # this and other redirect functions to determine the correct file / linenumber diff --git a/acme/services/Configuration.py b/acme/services/Configuration.py index e8f134dc..2fdff1ad 100644 --- a/acme/services/Configuration.py +++ b/acme/services/Configuration.py @@ -316,14 +316,6 @@ def init(args:argparse.Namespace = None) -> bool: 'http.cors.enable' : config.getboolean('http.cors', 'enable', fallback = False), 'http.cors.resources' : config.getlist('http.cors', 'resources', fallback = [ r'/*' ]), # type: ignore [attr-defined] - # - # HTTP Server WSGI - # - - 'http.wsgi.enable' : config.getboolean('http.wsgi', 'enable', fallback = False), - 'http.wsgi.threadPoolSize' : config.getint('http.wsgi', 'threadPoolSize', fallback = 100), - 'http.wsgi.connectionLimit' : config.getint('http.wsgi', 'connectionLimit', fallback = 100), - # # HTTP Server Security @@ -339,6 +331,16 @@ def init(args:argparse.Namespace = None) -> bool: 'http.security.basicAuthFile' : config.get('http.security', 'basicAuthFile', fallback = './certs/http_basic_auth.txt'), 'http.security.tokenAuthFile' : config.get('http.security', 'tokenAuthFile', fallback = './certs/http_token_auth.txt'), + + # + # HTTP Server WSGI + # + + 'http.wsgi.enable' : config.getboolean('http.wsgi', 'enable', fallback = False), + 'http.wsgi.connectionLimit' : config.getint('http.wsgi', 'connectionLimit', fallback = 100), + 'http.wsgi.threadPoolSize' : config.getint('http.wsgi', 'threadPoolSize', fallback = 100), + + # # Logging # @@ -349,11 +351,13 @@ def init(args:argparse.Namespace = None) -> bool: 'logging.enableScreenLogging' : config.getboolean('logging', 'enableScreenLogging', fallback = True), 'logging.filter' : config.getlist('logging', 'filter', fallback = []), # type: ignore [attr-defined] 'logging.level' : config.get('logging', 'level', fallback = 'debug'), + 'logging.maxLogMessageLength' : config.getint('logging', 'maxLogMessageLength', fallback = 1000), # Max length of a log message 'logging.path' : config.get('logging', 'path', fallback = './logs'), 'logging.queueSize' : config.getint('logging', 'queueSize', fallback = 5000), # Size of the log queue 'logging.size' : config.getint('logging', 'size', fallback = 100000), 'logging.stackTraceOnError' : config.getboolean('logging', 'stackTraceOnError', fallback = True), + # # MQTT Client # diff --git a/acme/services/Logging.py b/acme/services/Logging.py index 59f7f300..79391634 100644 --- a/acme/services/Logging.py +++ b/acme/services/Logging.py @@ -126,6 +126,7 @@ class Logging: enableQueue = False # Can be used to enable/disable the logging queue queueSize:int = 0 # max number of items in the logging queue. Might otherwise grow forever on large load filterSources:tuple[str, ...] = () # List of log sources that will be removed while processing the log messages + maxLogMessageLength:int = 0 # Max length of a log message. Longer messages will be truncated _console:Console = None _richHandler:ACMERichLogHandler = None @@ -156,6 +157,7 @@ def init() -> None: Logging.enableBindingsLogging = Configuration.get('logging.enableBindingsLogging') Logging.queueSize = Configuration.get('logging.queueSize') Logging.filterSources = tuple(Configuration.get('logging.filter')) + Logging.maxLogMessageLength = Configuration.get('logging.maxLogMessageLength') Logging._configureColors(Configuration.get('console.theme')) @@ -427,6 +429,7 @@ def _log(level:int, msg:Any, stackOffset:Optional[int] = 0, immediate:Optional[b # Queue a log message : (level, message, caller from stackframe, current thread) caller = inspect.getframeinfo(inspect.stack()[stackOffset + 2][0]) thread = threading.current_thread() + msg = msg[:Logging.maxLogMessageLength] if Logging.maxLogMessageLength else msg # truncate message if necessary if Logging.enableQueue and not immediate: Logging.queue.put((level, msg, caller, thread)) else: diff --git a/docs/Configuration.md b/docs/Configuration.md index 70670a98..a9a4974b 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -290,10 +290,11 @@ The following tables provide detailed descriptions of all the possible CSE confi | level | Loglevel. Allowed values: debug, info, warning, error, off.
See also command line argument [–log-level](Running.md).
Default: debug | logging.level | | count | Number of files for log rotation.
Default: 10 | logging.count | | size | Size per log file.
Default: 100.000 bytes | logging.size | +| maxLogMessageLength | Maximum length of a log message. Longer messages will be truncated. A value of 0 means no truncation.
Default: 1000 characters | logging.maxLogMessageLength | | stackTraceOnError | Print a stack trace when logging an 'error' level message.
Default: True | logging.stackTraceOnError | | enableBindingsLogging | Enable logging of low-level HTTP & MQTT client events.
Default: False | logging.enableBindingsLogging | | queueSize | Number of log entries that can be added to the asynchronous queue before blocking. A queue size of 0 means disabling the queue.
Default: F5000 entries | logging.queueSize | -| filter | List of component names to exclude from logging.
Default: werkzeug,markdown_it | logging.filter | +| filter | List of component names to exclude from logging.
Default: werkzeug,markdown_it | logging.filter | [top](#sections) diff --git a/init/configurations.docmd b/init/configurations.docmd index 922687fc..a3c42cf8 100644 --- a/init/configurations.docmd +++ b/init/configurations.docmd @@ -989,6 +989,16 @@ The default value is `debug`. +# logging.maxLogMessageLength + +This setting specifies the maximum length of a log message. Longer messages will be truncated. + +A value of 0 means no truncation. + +The default value is `1000` characters. + + + # logging.path This setting specifies the path to the CSE's log files. From 2d86c3b6a1f863fcc0b4a2a4af13a72a6b6d4bc7 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sat, 7 Oct 2023 14:06:49 +0200 Subject: [PATCH 137/165] Updated UML --- docs/CSE.uxf | 1264 ++++++++++++++++++++------------------- docs/images/cse_uml.png | Bin 107640 -> 106118 bytes 2 files changed, 636 insertions(+), 628 deletions(-) diff --git a/docs/CSE.uxf b/docs/CSE.uxf index 2a568376..547d14c9 100644 --- a/docs/CSE.uxf +++ b/docs/CSE.uxf @@ -1,14 +1,14 @@ - 6 + 8 UMLGeneric - 480 - 660 - 96 - 48 + 528 + 320 + 128 + 64 HTTP Server REST @@ -24,10 +24,10 @@ drawLine(21.8,11.4,20,15) Relation - 516 - 594 - 18 - 78 + 576 + 232 + 24 + 104 lt=()- @@ -36,10 +36,10 @@ drawLine(21.8,11.4,20,15) Relation - 486 - 702 - 42 - 42 + 536 + 376 + 56 + 56 lt=)- fontsize=8 @@ -50,10 +50,10 @@ requests Relation - 540 - 756 - 54 - 42 + 608 + 448 + 72 + 56 lt=()- handle resource @@ -65,10 +65,10 @@ fontsize=8 UMLGeneric - 480 - 786 - 96 - 48 + 528 + 488 + 128 + 64 Dispatcher symbol=component @@ -79,10 +79,10 @@ transparency=0 UMLGeneric - 480 - 876 - 96 - 48 + 528 + 608 + 128 + 64 Resource Storage symbol=component @@ -97,10 +97,10 @@ drawLine(21.8,11.4,20,15) Relation - 522 - 852 - 30 - 36 + 584 + 576 + 40 + 48 lt=()- BREAD @@ -111,10 +111,10 @@ fontsize=8 Relation - 516 - 828 - 30 - 36 + 576 + 544 + 40 + 48 lt=)- 20.0;30.0;20.0;10.0 @@ -122,10 +122,10 @@ fontsize=8 UMLGeneric - 600 - 876 - 96 - 48 + 688 + 608 + 128 + 64 oneM2M Resource Classes @@ -137,10 +137,10 @@ transparency=0 UMLGeneric - 360 - 876 - 96 - 48 + 368 + 608 + 128 + 64 Importer symbol=component @@ -151,10 +151,10 @@ transparency=0 Relation - 396 - 846 - 48 - 42 + 416 + 568 + 64 + 56 lt=)- add @@ -166,10 +166,10 @@ fontsize=8 UMLGeneric - 240 - 876 - 96 - 48 + 208 + 608 + 128 + 64 Configuration symbol=component @@ -180,10 +180,10 @@ transparency=0 Relation - 282 - 852 - 36 - 36 + 264 + 576 + 48 + 48 lt=()- set/get @@ -193,10 +193,10 @@ fontsize=8 UMLNote - 462 - 684 - 36 - 18 + 504 + 352 + 48 + 24 Flask Requests @@ -211,10 +211,10 @@ fontsize=10 UMLNote - 552 - 912 - 36 - 18 + 624 + 656 + 48 + 24 TinyDB transparency=0 @@ -228,10 +228,10 @@ fontsize=10 UMLGeneric - 120 - 876 - 96 - 48 + 48 + 608 + 128 + 64 Logging symbol=component @@ -242,10 +242,10 @@ transparency=0 Relation - 162 - 846 - 48 - 42 + 104 + 568 + 64 + 56 lt=()- info/debug @@ -256,10 +256,10 @@ fontsize=8 UMLGeneric - 486 - 966 - 84 - 18 + 536 + 728 + 112 + 24 Resources symbol=artifact @@ -272,10 +272,10 @@ fontsize=10 UMLGeneric - 486 - 990 - 84 - 18 + 536 + 760 + 112 + 24 Identifiers symbol=artifact @@ -288,10 +288,10 @@ fontsize=10 UMLGeneric - 480 - 948 - 186 - 138 + 528 + 704 + 248 + 184 DB Tables transparency=0 @@ -300,10 +300,10 @@ transparency=0 Relation - 522 - 918 - 18 - 42 + 584 + 664 + 24 + 56 lt=- fontsize=8 @@ -312,10 +312,10 @@ fontsize=8 UMLGeneric - 360 - 990 - 96 - 18 + 368 + 760 + 128 + 24 Scripts symbol=artifact @@ -328,10 +328,10 @@ fontsize=10 Relation - 402 - 918 - 18 - 42 + 424 + 664 + 24 + 56 lt=- fontsize=8 @@ -340,10 +340,10 @@ fontsize=8 UMLGeneric - 126 - 954 - 84 - 18 + 56 + 712 + 112 + 24 Rotating Logs symbol=artifact @@ -356,10 +356,10 @@ fontsize=10 UMLGeneric - 168 - 936 - 12 - 12 + 112 + 688 + 16 + 16 lw=0 fontsize=12 @@ -369,10 +369,10 @@ fontsize=12 Relation - 162 - 918 - 18 - 42 + 104 + 664 + 24 + 56 lt=- fontsize=8 @@ -381,10 +381,10 @@ fontsize=8 UMLGeneric - 522 - 612 - 30 - 18 + 584 + 256 + 40 + 24 lw=0 fontsize=8 @@ -396,10 +396,10 @@ via http UMLGeneric - 240 - 954 - 96 - 18 + 208 + 712 + 128 + 24 Configuration File symbol=artifact @@ -412,10 +412,10 @@ fontsize=10 Relation - 282 - 918 - 18 - 48 + 264 + 664 + 24 + 64 lt=- fontsize=8 @@ -424,10 +424,10 @@ fontsize=8 Relation - 648 - 846 - 60 - 42 + 752 + 568 + 80 + 56 lt=)- access resources @@ -438,10 +438,10 @@ fontsize=8 UMLPackage - 102 - 630 - 1488 - 474 + 24 + 280 + 1984 + 632 ACME CSE -- @@ -453,10 +453,10 @@ transparency=0 UMLGeneric - 126 - 978 - 84 - 18 + 56 + 744 + 112 + 24 Console symbol=artifact @@ -469,10 +469,10 @@ fontsize=10 UMLGeneric - 120 - 948 - 96 - 54 + 48 + 704 + 128 + 72 transparency=0 @@ -480,10 +480,10 @@ fontsize=10 UMLGeneric - 360 - 786 - 96 - 48 + 368 + 488 + 128 + 64 Security Manager symbol=component @@ -494,10 +494,10 @@ transparency=0 Relation - 384 - 750 - 48 - 48 + 400 + 440 + 64 + 64 lt=()- check @@ -508,10 +508,10 @@ fontsize=8 Relation - 642 - 756 - 42 - 42 + 744 + 448 + 56 + 56 lt=()- handle @@ -525,10 +525,10 @@ fontsize=8 UMLGeneric - 288 - 942 - 12 - 12 + 272 + 696 + 16 + 16 lw=0 fontsize=12 @@ -538,10 +538,10 @@ fontsize=12 UMLGeneric - 408 - 936 - 12 - 12 + 432 + 688 + 16 + 16 lw=0 fontsize=12 @@ -551,10 +551,10 @@ fontsize=12 UMLGeneric - 528 - 936 - 12 - 12 + 592 + 688 + 16 + 16 lw=0 fontsize=12 @@ -564,10 +564,10 @@ fontsize=12 Relation - 570 - 882 - 42 - 24 + 648 + 616 + 56 + 32 lt=- stores @@ -578,10 +578,10 @@ fontsize=8 UMLGeneric - 588 - 894 - 12 - 12 + 672 + 632 + 16 + 16 lw=0 fontsize=12 @@ -591,10 +591,10 @@ fontsize=12 UMLNote - 552 - 942 - 48 - 18 + 624 + 696 + 64 + 24 In memory or file system @@ -609,10 +609,10 @@ layer=1 Relation - 426 - 756 - 48 - 42 + 456 + 448 + 64 + 56 lt=)- fontsize=8 @@ -623,10 +623,10 @@ resources UMLGeneric - 240 - 786 - 96 - 48 + 208 + 488 + 128 + 64 Notification Manager symbol=component @@ -641,10 +641,10 @@ drawLine(21.8,11.4,20,15) Relation - 540 - 594 - 30 - 78 + 608 + 232 + 40 + 104 lt=)- 20.0;20.0;20.0;110.0 @@ -652,10 +652,10 @@ drawLine(21.8,11.4,20,15) UMLGeneric - 552 - 612 - 36 - 18 + 624 + 256 + 48 + 24 lw=0 fontsize=8 @@ -667,10 +667,10 @@ via http Relation - 288 - 750 - 36 - 48 + 272 + 440 + 48 + 64 lt=()- add/del @@ -681,10 +681,10 @@ fontsize=8 Relation - 318 - 756 - 48 - 42 + 312 + 448 + 64 + 56 lt=)- fontsize=8 @@ -695,10 +695,10 @@ resources UMLGeneric - 720 - 786 - 96 - 48 + 848 + 488 + 128 + 64 remoteCSE Manager symbol=component @@ -713,10 +713,10 @@ drawLine(21.8,11.4,20,15) UMLGeneric - 486 - 1014 - 84 - 18 + 536 + 792 + 112 + 24 Subscriptions symbol=artifact @@ -729,10 +729,10 @@ fontsize=10 Relation - 624 - 846 - 36 - 42 + 720 + 568 + 48 + 56 lt=()- fontsize=8 @@ -744,10 +744,10 @@ set/get Relation - 606 - 846 - 30 - 18 + 696 + 568 + 40 + 24 lt=..> fontsize=8 @@ -756,10 +756,10 @@ fontsize=8 Relation - 474 - 594 - 30 - 78 + 520 + 232 + 40 + 104 lt=)- 20.0;20.0;20.0;110.0 @@ -767,10 +767,10 @@ fontsize=8 UMLGeneric - 486 - 612 - 24 - 18 + 536 + 256 + 32 + 24 lw=0 fontsize=8 @@ -782,10 +782,10 @@ via http Relation - 552 - 702 - 36 - 42 + 624 + 376 + 48 + 56 lt=()- send @@ -796,10 +796,10 @@ fontsize=8 Relation - 246 - 756 - 54 - 42 + 216 + 448 + 72 + 56 lt=)- fontsize=8 @@ -809,10 +809,10 @@ send NOTIFY UMLGeneric - 618 - 528 - 96 - 48 + 712 + 144 + 128 + 64 WebUI symbol=component @@ -823,10 +823,10 @@ transparency=0 Relation - 564 - 540 - 66 - 30 + 640 + 160 + 88 + 40 lt=)- 20.0;20.0;90.0;20.0 @@ -834,10 +834,10 @@ transparency=0 Relation - 570 - 570 - 120 - 126 + 648 + 200 + 160 + 168 lt=- serves @@ -848,10 +848,10 @@ fontsize=8 UMLGeneric - 570 - 528 - 30 - 18 + 648 + 144 + 40 + 24 lw=0 fontsize=8 @@ -863,10 +863,10 @@ via http UMLGeneric - 960 - 786 - 96 - 48 + 1168 + 488 + 128 + 64 Announcement Manager @@ -882,10 +882,10 @@ drawLine(21.8,11.4,20,15) UMLGeneric - 840 - 786 - 96 - 48 + 1008 + 488 + 128 + 64 Group Manager @@ -897,10 +897,10 @@ transparency=0 Relation - 900 - 750 - 42 - 48 + 1088 + 440 + 56 + 64 handle group @@ -912,10 +912,10 @@ fontsize=8 Relation - 852 - 756 - 48 - 42 + 1024 + 448 + 64 + 56 lt=)- access @@ -926,10 +926,10 @@ fontsize=8 UMLGeneric - 840 - 876 - 96 - 48 + 1008 + 608 + 128 + 64 Statistics symbol=component @@ -940,10 +940,10 @@ transparency=0 UMLGeneric - 486 - 1038 - 84 - 18 + 536 + 824 + 112 + 24 CSE Statistics symbol=artifact @@ -956,10 +956,10 @@ fontsize=10 Relation - 876 - 840 - 36 - 48 + 1056 + 560 + 48 + 64 lt=)- store @@ -970,10 +970,10 @@ fontsize=8 Relation - 846 - 846 - 30 - 42 + 1016 + 568 + 40 + 56 lt=()- recv @@ -984,10 +984,10 @@ fontsize=8 UMLGeneric - 720 - 876 - 96 - 48 + 848 + 608 + 128 + 64 Event Manager symbol=component @@ -1002,10 +1002,10 @@ drawLine(21.8,11.4,20,15) Relation - 768 - 846 - 30 - 42 + 912 + 568 + 40 + 56 lt=()- recv @@ -1016,10 +1016,10 @@ fontsize=8 Relation - 726 - 846 - 48 - 42 + 856 + 568 + 64 + 56 lt=()- manage @@ -1031,10 +1031,10 @@ fontsize=8 Relation - 786 - 846 - 36 - 42 + 936 + 568 + 48 + 56 lt=)- fwd @@ -1045,47 +1045,23 @@ fontsize=8 Relation - 906 - 846 - 36 - 42 + 1096 + 568 + 48 + 56 lt=()- statistics fontsize=8 10.0;10.0;10.0;50.0 - - UMLNote - - 348 - 912 - 48 - 30 - - *TODO* - -Keep watching -the directory -transparency=0 -bg=yellow -valign=top -halign=center -layer=1 -fontsize=8 -customelement= -drawArc(10,10,10,10,0,270,true) transparency=100 -drawLine(16.5,14,20,15) -drawLine(21.8,11.4,20,15) - - UMLGeneric - 120 - 786 - 96 - 48 + 48 + 488 + 128 + 64 Registration Manager symbol=component @@ -1100,10 +1076,10 @@ drawLine(21.8,11.4,20,15) Relation - 138 - 756 - 36 - 42 + 72 + 448 + 48 + 56 lt=()- register @@ -1113,10 +1089,10 @@ fontsize=8 Relation - 174 - 756 - 48 - 42 + 120 + 448 + 64 + 56 lt=)- fontsize=8 @@ -1124,32 +1100,13 @@ access resources 20.0;20.0;20.0;50.0 - - UMLNote - - 348 - 822 - 54 - 24 - - *TODO* - -R4 Attribute ACP -transparency=0 -bg=yellow -valign=top -halign=center -layer=1 -fontsize=8 - - UMLGeneric - 960 - 876 - 96 - 48 + 1168 + 608 + 128 + 64 Validation Manager @@ -1161,10 +1118,10 @@ transparency=0 Relation - 1002 - 846 - 42 - 42 + 1224 + 568 + 56 + 56 lt=()- handle @@ -1175,10 +1132,10 @@ fontsize=8 UMLGeneric - 600 - 786 - 96 - 48 + 688 + 488 + 128 + 64 Request Manager @@ -1190,10 +1147,10 @@ transparency=0 Relation - 600 - 750 - 54 - 48 + 688 + 440 + 72 + 64 lt=()- handle @@ -1206,10 +1163,10 @@ fontsize=8 Relation - 966 - 750 - 54 - 48 + 1176 + 440 + 72 + 64 handle resource @@ -1221,10 +1178,10 @@ fontsize=8 Relation - 738 - 750 - 54 - 48 + 872 + 440 + 72 + 64 lt=()- handle @@ -1236,10 +1193,10 @@ fontsize=8 Relation - 1008 - 744 - 60 - 54 + 1232 + 432 + 80 + 72 remote resource @@ -1251,10 +1208,10 @@ fontsize=8 Relation - 480 - 756 - 48 - 42 + 528 + 448 + 64 + 56 lt=)- security & @@ -1266,10 +1223,10 @@ fontsize=8 UMLNote - 126 - 822 - 84 - 12 + 56 + 536 + 112 + 16 incl. resource expirations transparency=0 @@ -1283,10 +1240,10 @@ fontsize=10 UMLPackage - 600 - 432 - 132 - 162 + 688 + 16 + 176 + 216 Web UI transparency=0 @@ -1296,10 +1253,10 @@ layer=-2 UMLGeneric - 618 - 462 - 96 - 48 + 712 + 56 + 128 + 64 HTTP Server REST @@ -1315,10 +1272,10 @@ drawLine(21.8,11.4,20,15) Relation - 636 - 504 - 30 - 36 + 736 + 112 + 40 + 48 lt=- serves @@ -1328,10 +1285,10 @@ fontsize=8 Relation - 564 - 474 - 66 - 30 + 640 + 72 + 88 + 40 lt=)- 20.0;20.0;90.0;20.0 @@ -1339,10 +1296,10 @@ fontsize=8 UMLGeneric - 570 - 462 - 30 - 18 + 648 + 56 + 40 + 24 lw=0 fontsize=8 @@ -1354,10 +1311,10 @@ Mca Relation - 678 - 504 - 48 - 36 + 792 + 112 + 64 + 48 lt=- Mca via proxy @@ -1367,10 +1324,10 @@ fontsize=8 UMLGeneric - 486 - 1062 - 84 - 18 + 536 + 856 + 112 + 24 Batch Notifications symbol=artifact @@ -1383,10 +1340,10 @@ fontsize=10 Relation - 672 - 750 - 48 - 48 + 784 + 440 + 64 + 64 lt=)- send @@ -1398,10 +1355,10 @@ fontsize=8 Relation - 780 - 756 - 48 - 42 + 928 + 448 + 64 + 56 lt=)- fontsize=8 @@ -1412,10 +1369,10 @@ resources UMLClass - 90 - 420 - 1512 - 696 + 8 + 0 + 2016 + 928 lw=0 bg=white @@ -1426,10 +1383,10 @@ layer=-2 UMLNote - 192 - 912 - 36 - 18 + 144 + 656 + 48 + 24 rich transparency=0 @@ -1443,10 +1400,10 @@ fontsize=10 UMLGeneric - 1080 - 786 - 96 - 48 + 1328 + 488 + 128 + 64 TimeSeries Manager @@ -1458,10 +1415,10 @@ transparency=0 Relation - 1086 - 750 - 42 - 48 + 1336 + 440 + 56 + 64 lt=()- handle new @@ -1473,10 +1430,10 @@ fontsize=8 UMLGeneric - 720 - 660 - 96 - 48 + 848 + 320 + 128 + 64 Console symbol=component @@ -1487,10 +1444,10 @@ transparency=0 Relation - 762 - 600 - 42 - 72 + 904 + 240 + 56 + 96 lt=()- m1=handle\nuser input @@ -1500,10 +1457,10 @@ fontsize=8 Relation - 774 - 702 - 42 - 42 + 920 + 376 + 56 + 56 lt=)- various @@ -1513,10 +1470,10 @@ fontsize=8 Relation - 1128 - 750 - 54 - 48 + 1392 + 440 + 72 + 64 lt=)- send @@ -1528,10 +1485,10 @@ fontsize=8 UMLGeneric - 360 - 660 - 96 - 48 + 368 + 320 + 128 + 64 MQTT Client REST @@ -1547,10 +1504,10 @@ drawLine(21.8,11.4,20,15) Relation - 396 - 594 - 18 - 78 + 416 + 232 + 24 + 104 lt=()- @@ -1559,10 +1516,10 @@ drawLine(21.8,11.4,20,15) Relation - 366 - 702 - 42 - 42 + 376 + 376 + 56 + 56 lt=)- fontsize=8 @@ -1573,10 +1530,10 @@ requests UMLNote - 342 - 684 - 36 - 18 + 344 + 352 + 48 + 24 Paho transparency=0 @@ -1590,10 +1547,10 @@ fontsize=10 UMLGeneric - 402 - 612 - 30 - 18 + 424 + 256 + 40 + 24 lw=0 fontsize=8 @@ -1605,10 +1562,10 @@ via MQTT Relation - 432 - 594 - 30 - 78 + 464 + 232 + 40 + 104 lt=)- 20.0;20.0;20.0;110.0 @@ -1616,10 +1573,10 @@ via MQTT UMLGeneric - 444 - 612 - 36 - 18 + 480 + 256 + 48 + 24 lw=0 fontsize=8 @@ -1631,10 +1588,10 @@ via MQTT Relation - 354 - 594 - 30 - 78 + 360 + 232 + 40 + 104 lt=)- 20.0;20.0;20.0;110.0 @@ -1642,10 +1599,10 @@ via MQTT UMLGeneric - 366 - 612 - 30 - 18 + 376 + 256 + 40 + 24 lw=0 fontsize=8 @@ -1657,10 +1614,10 @@ via MQTT Relation - 432 - 702 - 36 - 42 + 464 + 376 + 48 + 56 lt=()- send @@ -1671,10 +1628,10 @@ fontsize=8 UMLGeneric - 1080 - 876 - 96 - 48 + 1328 + 608 + 128 + 64 Script Manager symbol=component @@ -1686,10 +1643,10 @@ customelement= Relation - 1116 - 840 - 30 - 48 + 1376 + 560 + 40 + 64 lt=()- recv @@ -1700,10 +1657,10 @@ fontsize=8 Relation - 1074 - 840 - 48 - 48 + 1320 + 560 + 64 + 64 lt=)- call service @@ -1715,10 +1672,10 @@ fontsize=8 Relation - 1140 - 840 - 30 - 48 + 1408 + 560 + 40 + 64 lt=()- run @@ -1729,10 +1686,10 @@ fontsize=8 Relation - 564 - 594 - 36 - 78 + 640 + 232 + 48 + 104 lt=()- @@ -1741,10 +1698,10 @@ fontsize=8 UMLGeneric - 588 - 612 - 36 - 18 + 672 + 256 + 48 + 24 lw=0 fontsize=8 @@ -1756,10 +1713,10 @@ Tester UMLGeneric - 1200 - 786 - 96 - 48 + 1488 + 488 + 128 + 64 Time Manager @@ -1771,10 +1728,10 @@ transparency=0 Relation - 1242 - 756 - 36 - 42 + 1544 + 448 + 48 + 56 lt=()- time @@ -1786,10 +1743,10 @@ fontsize=8 UMLGeneric - 1080 - 954 - 96 - 18 + 1328 + 712 + 128 + 24 Scripts symbol=artifact @@ -1806,10 +1763,10 @@ drawLine(21.8,11.4,20,15) lw=1 Relation - 1122 - 918 - 18 - 48 + 1384 + 664 + 24 + 64 lt=- fontsize=8 @@ -1819,10 +1776,10 @@ fontsize=8 UMLGeneric - 1128 - 942 - 12 - 12 + 1392 + 696 + 16 + 16 lw=0 fontsize=12 @@ -1832,10 +1789,10 @@ fontsize=12 UMLGeneric - 960 - 660 - 96 - 48 + 1168 + 320 + 128 + 64 Upper Tester symbol=component @@ -1846,10 +1803,10 @@ transparency=0 Relation - 1002 - 600 - 48 - 72 + 1224 + 240 + 64 + 96 lt=()- m1=Upper Tester\nrequests @@ -1859,10 +1816,10 @@ fontsize=8 Relation - 972 - 702 - 42 - 42 + 1184 + 376 + 56 + 56 lt=)- requests @@ -1872,10 +1829,10 @@ fontsize=8 Relation - 1020 - 702 - 36 - 42 + 1248 + 376 + 48 + 56 lt=)- scripts @@ -1885,10 +1842,10 @@ fontsize=8 Relation - 1362 - 756 - 36 - 42 + 1704 + 448 + 48 + 56 lt=()- handle @@ -1900,10 +1857,10 @@ fontsize=8 UMLGeneric - 1320 - 786 - 96 - 48 + 1648 + 488 + 128 + 64 Semantic Manager @@ -1915,10 +1872,10 @@ transparency=0 UMLGeneric - 840 - 660 - 96 - 48 + 1008 + 320 + 128 + 64 Text UI symbol=component @@ -1929,10 +1886,10 @@ transparency=0 Relation - 906 - 702 - 36 - 42 + 1096 + 376 + 48 + 56 lt=()- logging @@ -1943,10 +1900,10 @@ fontsize=8 Relation - 852 - 702 - 48 - 42 + 1024 + 376 + 64 + 56 lt=)- requests @@ -1958,10 +1915,10 @@ fontsize=8 Relation - 882 - 600 - 42 - 72 + 1064 + 240 + 56 + 96 lt=()- m1=handle\nuser input @@ -1971,10 +1928,10 @@ fontsize=8 UMLGeneric - 576 - 966 - 84 - 18 + 656 + 728 + 112 + 24 Actions symbol=artifact @@ -1987,10 +1944,10 @@ fontsize=10 UMLGeneric - 576 - 1014 - 84 - 18 + 656 + 792 + 112 + 24 Schedules symbol=artifact @@ -2003,10 +1960,10 @@ fontsize=10 UMLGeneric - 576 - 990 - 84 - 18 + 656 + 760 + 112 + 24 Requests symbol=artifact @@ -2019,10 +1976,10 @@ fontsize=10 UMLGeneric - 354 - 948 - 108 - 138 + 360 + 704 + 144 + 184 Imports transparency=0 @@ -2031,10 +1988,10 @@ transparency=0 UMLGeneric - 360 - 966 - 96 - 18 + 368 + 728 + 128 + 24 Attribute Policies symbol=artifact @@ -2047,10 +2004,10 @@ fontsize=10 UMLGeneric - 360 - 1014 - 96 - 18 + 368 + 792 + 128 + 24 Attribute Policies symbol=artifact @@ -2063,10 +2020,10 @@ fontsize=10 UMLGeneric - 1200 - 876 - 96 - 48 + 1488 + 608 + 128 + 64 Onboarding symbol=component @@ -2077,10 +2034,10 @@ transparency=0 Relation - 1242 - 846 - 42 - 42 + 1544 + 568 + 56 + 56 lt=()- handle @@ -2091,10 +2048,10 @@ fontsize=8 UMLGeneric - 1320 - 876 - 96 - 48 + 1648 + 608 + 128 + 64 Location Manager @@ -2106,10 +2063,10 @@ transparency=0 Relation - 1362 - 846 - 36 - 42 + 1704 + 568 + 48 + 56 lt=()- handle @@ -2121,10 +2078,10 @@ fontsize=8 UMLGeneric - 1434 - 786 - 96 - 48 + 1800 + 488 + 128 + 64 Action Manager @@ -2136,10 +2093,10 @@ transparency=0 Relation - 1494 - 756 - 36 - 42 + 1880 + 448 + 48 + 56 lt=()- handle @@ -2151,10 +2108,10 @@ fontsize=8 Relation - 1440 - 750 - 48 - 48 + 1808 + 440 + 64 + 64 lt=)- call service @@ -2163,4 +2120,55 @@ fontsize=8 20.0;20.0;20.0;60.0 + + UMLNote + + 984 + 352 + 64 + 24 + + Textualize +transparency=0 +bg=light_gray +valign=center +halign=center +layer=1 +fontsize=10 + + + + UMLNote + + 1624 + 520 + 48 + 24 + + rdflib +transparency=0 +bg=light_gray +valign=center +halign=center +layer=1 +fontsize=10 + + + + UMLNote + + 824 + 352 + 48 + 24 + + rich +transparency=0 +bg=light_gray +valign=center +halign=center +layer=1 +fontsize=10 + + diff --git a/docs/images/cse_uml.png b/docs/images/cse_uml.png index 648f4878a0a6be811c0fc16cd1f6d7bf828e1c7a..c9a0f047440c6c38e97ce1f0d5674598517f4f5a 100644 GIT binary patch literal 106118 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 literal 107640 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 qDZpSMk{O#dSzl?$O%T3^6pU7k|B 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 From 6807c0ae44ee71eec24848dfd58061678fecd5e9 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sat, 7 Oct 2023 14:55:37 +0200 Subject: [PATCH 138/165] Removed originalHttpArgs handling --- acme/etc/Types.py | 3 --- acme/services/HttpServer.py | 1 - acme/services/RequestManager.py | 22 ---------------------- 3 files changed, 26 deletions(-) diff --git a/acme/etc/Types.py b/acme/etc/Types.py index 8fd946ec..39f96dec 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -2051,9 +2051,6 @@ def __post_init__(self) -> None: httpAccept:list[str] = None """ http Accept header media type. """ - originalHttpArgs:Any = None - """ Original http request arguments. A MultiDict. """ - # # Helpers # diff --git a/acme/services/HttpServer.py b/acme/services/HttpServer.py index a6c9440e..e400edf3 100644 --- a/acme/services/HttpServer.py +++ b/acme/services/HttpServer.py @@ -836,7 +836,6 @@ def extractMultipleArgs(args:MultiDict, argName:str) -> None: # parse accept header cseRequest.httpAccept = [ a for a in _headers.getlist('accept') if a != '*/*' ] - cseRequest.originalHttpArgs = deepcopy(request.args) # Keep the original args # copy request arguments for greedy attributes checking _args = request.args.copy() # type: ignore [no-untyped-call] diff --git a/acme/services/RequestManager.py b/acme/services/RequestManager.py index 2ed918ab..5232b85e 100644 --- a/acme/services/RequestManager.py +++ b/acme/services/RequestManager.py @@ -658,28 +658,6 @@ def handleTransitNotifyRequest(self, request:CSERequest) -> Result: return self.handleSendRequest(request)[0].result # there should be at least one result - # def _getForwardURL(self, path:str) -> Optional[str]: # FIXME DELETE ME This may be removed due to the new request handling - # """ Get the new target URL when forwarding. - # """ - # # L.isDebug and L.logDebug(path) - # csr, pe = CSE.remote.getCSRFromPath(path) - # # L.isDebug and L.logDebug(csr) - # if csr and (poas := csr.poa) and len(poas) > 0: - # return f'{poas[0]}//{"/".join(pe[1:])}' # TODO check all available poas. - # return None - - - # def _constructForwardURL(self, request:CSERequest) -> str: - # """ Construct the target URL for the forward request. Add the original - # arguments. The URL is returned in Result.data . - # """ - # if not (url := self._getForwardURL(request.id)): - # raise NOT_FOUND(f'forward URL not found for id: {request.id}') - # if request.originalHttpArgs is not None and len(request.originalHttpArgs) > 0: # pass on other arguments, for discovery. Only http - # url += '?' + urllib.parse.urlencode(request.originalHttpArgs) - # return url - - def _originatorToSPRelative(self, request:CSERequest) -> None: """ Convert *from* to SP-relative format in the request. The *from* is converted in *request.originator* and *request.originalRequest*, but NOT in From fc3eb475b481e1a9502e40a82d9447e7bacbf0ad Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 9 Oct 2023 23:01:16 +0200 Subject: [PATCH 139/165] Added support and validation for 'xs:NCName' type in attribute policies --- CHANGELOG.md | 2 ++ acme/etc/Types.py | 1 + acme/services/Validator.py | 16 +++++++++- init/attributePolicies.ap | 4 +-- init/complexTypePolicies.ap | 22 +++++++------- tests/testRemote_Annc.py | 60 +++++++++++++++++++++++++++++++++++++ 6 files changed, 91 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fff697e0..067e1fba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ and this project adheres to [Calendar Versioning](https://calver.org). ### Fixed - [CSE] Removed superfluous code when announcing resources (see issue #122). +- [CSE] Added support and validation for 'xs:NCName' type in attribute policies. + ### Removed diff --git a/acme/etc/Types.py b/acme/etc/Types.py index 39f96dec..d87017c0 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -619,6 +619,7 @@ class BasicType(ACMEIntEnum): base64 = auto() schedule = auto() # scheduleEntry ID = auto() # m2m:ID + ncname = auto() # xs:NCName # aliases. Always put at the end! Seems cause confusion with python < 3.11 time = timestamp # alias type for time diff --git a/acme/services/Validator.py b/acme/services/Validator.py index eb84c71a..aa971148 100644 --- a/acme/services/Validator.py +++ b/acme/services/Validator.py @@ -733,6 +733,10 @@ def getEnumInterpretation(self, rtype: ResourceTypes, attr:str, value:int) -> st # Internals. # + _ncNameDisallowedChars = ( '!', '"', '#', '$', '%', '&', '\'', '(', ')', + '*', '+', ',', '/', ':', ';', '<', '=', '>', + '?', '@', '[', ']', '^', '´' , '`', '{', '|', '}', '~' ) + def _validateType(self, dataType:BasicType, value:Any, convert:Optional[bool] = False, @@ -810,7 +814,7 @@ def _validateType(self, dataType:BasicType, match value: case str(): try: - rel = int(value) + int(value) # fallthrough except Exception as e: # could happen if this is a string with an iso timestamp. Then try next test if fromAbsRelTimestamp(value) == 0.0: @@ -828,6 +832,16 @@ def _validateType(self, dataType:BasicType, case BasicType.ID if isinstance(value, str): # TODO check for valid resourceID return (dataType, value) + + case BasicType.ncname if isinstance(value, str): + if len(value) == 0 or value[0].isdigit() or value[0] in ('-', '.'): + raise BAD_REQUEST(f'invalid NCName: {value} (must not start with a digit, "-", or ".")') + for v in value: + if v.isspace(): + raise BAD_REQUEST(f'invalid NCName: {value} (must not contain whitespace)') + if v in self._ncNameDisallowedChars: + raise BAD_REQUEST(f'invalid NCName: {value} (must not contain any of {",".join(self._ncNameDisallowedChars)})') + return (dataType, value) case BasicType.list | BasicType.listNE if isinstance(value, list): if dataType == BasicType.listNE and len(value) == 0: diff --git a/init/attributePolicies.ap b/init/attributePolicies.ap index c68d9972..5bdcc28c 100644 --- a/init/attributePolicies.ap +++ b/init/attributePolicies.ap @@ -182,7 +182,7 @@ "lname": "announcedAttribute", "ns": "m2m", "type": "list", - "ltype": "string", + "ltype": "ncname", "car": "01L", "oc": "NP", "ou": "NP", @@ -194,7 +194,7 @@ "lname": "announcedAttribute", "ns": "m2m", "type": "list", - "ltype": "string", + "ltype": "ncname", "car": "01L", "oc": "O", "ou": "O", diff --git a/init/complexTypePolicies.ap b/init/complexTypePolicies.ap index dafc1f63..78e8ae3d 100644 --- a/init/complexTypePolicies.ap +++ b/init/complexTypePolicies.ap @@ -18,7 +18,7 @@ "lname": "attribute", "ns": "m2m", "type": "list", - "ltype": "string", //m2m:attributeList + "ltype": "m2m:attribute", "car": "01L" }, { @@ -26,8 +26,8 @@ "ctype": "m2m:eventNotificationCriteria", "lname": "attribute", "ns": "m2m", - "type": "list", - "ltype": "string", //m2m:attributeList + "type": "list", //m2m:attributeList + "ltype": "ncname", "car": "01L" } ], @@ -274,7 +274,7 @@ "ctype": "m2m:contentRef", "lname": "name", "ns": "m2m", - "type": "string", // xs:NCName + "type": "ncname", "car": "1" }, { @@ -282,7 +282,7 @@ "ctype": "m2m:attribute", "lname": "name", "ns": "m2m", - "type": "string", + "type": "ncname", "car": "1" } ], @@ -949,7 +949,7 @@ "lname": "accessControlAttributes", "ns": "m2m", "type": "list", // m2m:attributeList - "ltype": "string", + "ltype": "ncname", "car": "01" } ], @@ -964,7 +964,7 @@ "ctype": "m2m:actionInput", "lname": "contentString", "ns": "m2m", - "type": "string", // xs:NCName + "type": "ncname", "car": "01" } ], @@ -1199,7 +1199,7 @@ "ctype": "m2m:evalCriteria", "lname": "subject", "ns": "m2m", - "type": "string", // TODO "type": "xs:NCName", + "type": "ncname", "car": "1" } ], @@ -1565,7 +1565,7 @@ "lname": "tokenIDs", "ns": "m2m", "type": "list", - "ltype": "string", // m2m:tokenID TODO validate + "ltype": "ncname", "car": "01L" } ], @@ -1573,10 +1573,10 @@ { "rtypes": [ "COMPLEX" ], "ctype": "m2m:metaInformation", - "lname": "tokenIDs", + "lname": "localTokenIDs", "ns": "m2m", "type": "list", - "ltype": "string", // xs:NCName TODO validate + "ltype": "ncname", "car": "01L" } ], diff --git a/tests/testRemote_Annc.py b/tests/testRemote_Annc.py index 317d2119..ee5415b5 100644 --- a/tests/testRemote_Annc.py +++ b/tests/testRemote_Annc.py @@ -149,6 +149,63 @@ def test_deleteAnnounceAE(self) -> None: # create an announced AE, including announced attribute # + # + # Perhaps the following three (fail) tests should be moved to somewhere else + # But using the "aa" attribute seems to be the easiest way to test the + # "ncname" validation. + # + + # Create an AE with AT and AA, but wrong char in attribute + @unittest.skipIf(noRemote or noCSE, 'No CSEBase or remote CSEBase') + def test_createAnnounceAEwithATwithWrongAA1Fail(self) -> None: + """ Create and announce (AT, AA) with wrong char in attribute -> Fail """ + dct = { 'm2m:ae' : { + 'rn': aeRN, + 'api': APPID, + 'rr': False, + 'srv': [ RELEASEVERSION ], + 'lbl': [ 'aLabel'], + 'at': [ REMOTECSEID ], + 'aa': [ 'lbl', 'lb+l'] # wrong attribute + }} + r, rsc = CREATE(cseURL, 'C', T.AE, dct) + self.assertEqual(rsc, RC.BAD_REQUEST) + + + # Create an AE with AT and AA, but space in attribute + @unittest.skipIf(noRemote or noCSE, 'No CSEBase or remote CSEBase') + def test_createAnnounceAEwithATwithWrongAA2Fail(self) -> None: + """ Create and announce (AT, AA) with space in attribute -> Fail """ + dct = { 'm2m:ae' : { + 'rn': aeRN, + 'api': APPID, + 'rr': False, + 'srv': [ RELEASEVERSION ], + 'lbl': [ 'aLabel'], + 'at': [ REMOTECSEID ], + 'aa': [ 'lbl', 'lb l'] # wrong attribute + }} + r, rsc = CREATE(cseURL, 'C', T.AE, dct) + self.assertEqual(rsc, RC.BAD_REQUEST) + + + # Create an AE with AT and AA, but leading digit in attribute + @unittest.skipIf(noRemote or noCSE, 'No CSEBase or remote CSEBase') + def test_createAnnounceAEwithATwithWrongAA3Fail(self) -> None: + """ Create and announce (AT, AA) with space in attribute -> Fail """ + dct = { 'm2m:ae' : { + 'rn': aeRN, + 'api': APPID, + 'rr': False, + 'srv': [ RELEASEVERSION ], + 'lbl': [ 'aLabel'], + 'at': [ REMOTECSEID ], + 'aa': [ 'lbl', '1lbl'] # wrong attribute + }} + r, rsc = CREATE(cseURL, 'C', T.AE, dct) + self.assertEqual(rsc, RC.BAD_REQUEST) + + # Create an AE with AT and AA @unittest.skipIf(noRemote or noCSE, 'No CSEBase or remote CSEBase') def test_createAnnounceAEwithATwithAA(self) -> None: @@ -789,6 +846,9 @@ def run(testFailFast:bool) -> Tuple[int, int, int, float]: addTest(suite, TestRemote_Annc('test_deleteAnnounceAE')) # create an announced AE, including announced attribute + addTest(suite, TestRemote_Annc('test_createAnnounceAEwithATwithWrongAA1Fail')) + addTest(suite, TestRemote_Annc('test_createAnnounceAEwithATwithWrongAA2Fail')) + addTest(suite, TestRemote_Annc('test_createAnnounceAEwithATwithWrongAA3Fail')) addTest(suite, TestRemote_Annc('test_createAnnounceAEwithATwithAA')) addTest(suite, TestRemote_Annc('test_retrieveAnnouncedAEwithATwithAA')) addTest(suite, TestRemote_Annc('test_deleteAnnounceAE')) From 82f4dd5bcaef9af62b0330bd08e0695facc7edf9 Mon Sep 17 00:00:00 2001 From: ankraft Date: Mon, 9 Oct 2023 23:15:14 +0200 Subject: [PATCH 140/165] Small optimization for sub handling --- acme/services/NotificationManager.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/acme/services/NotificationManager.py b/acme/services/NotificationManager.py index 3e8eb479..fa1326d5 100644 --- a/acme/services/NotificationManager.py +++ b/acme/services/NotificationManager.py @@ -235,7 +235,7 @@ def getSubscriptionsByNetChty(self, ri:str, def checkSubscriptions( self, - resource:Resource, + resource:Optional[Resource], reason:NotificationEventType, childResource:Optional[Resource] = None, modifiedAttributes:Optional[JSON] = None, @@ -280,9 +280,19 @@ def checkSubscriptions( self, subs.append(sub) for sub in subs: + + if reason not in sub['net']: # check whether reason is actually included in the subscription + continue + # Prevent own notifications for subscriptions ri = sub['ri'] + # Check whether reason is included in the subscription + if childResource and \ + ri == childResource.ri and \ + reason in [ NotificationEventType.createDirectChild, NotificationEventType.deleteDirectChild ]: + continue + # Check the subscription's schedule, but only if it is not an immediate notification if not ((nec := sub['nec']) and nec == EventCategory.Immediate): if (_sc := CSE.storage.searchScheduleForTarget(ri)): @@ -296,15 +306,6 @@ def checkSubscriptions( self, # No schedule matches the current time, so continue with the next subscription continue - # Check whether reason is included in the subscription - if childResource and \ - ri == childResource.ri and \ - reason in [ NotificationEventType.createDirectChild, NotificationEventType.deleteDirectChild ]: - continue - - if reason not in sub['net']: # check whether reason is actually included in the subscription - continue - match reason: case NotificationEventType.createDirectChild | NotificationEventType.deleteDirectChild: # reasons for child resources chty = sub['chty'] From a0ed9e2b08862ff3e712f68cc25e78174b8a454e Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 10 Oct 2023 10:58:15 +0200 Subject: [PATCH 141/165] Added samuelbles07 as contributor --- docs/Contributing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Contributing.md b/docs/Contributing.md index 8bddb0f4..a2ef16ab 100644 --- a/docs/Contributing.md +++ b/docs/Contributing.md @@ -27,6 +27,6 @@ Thank you for contributed ideas, code, patches, testing, bug fixes, time, and mo [Massimo Vanetti](https://github.com/massimov) [Tyler Sengia](https://www.linkedin.com/in/tyler-sengia/) ![JiriD85](https://github.com/JiriD85.png?size=24) [JiriD85](https://github.com/JiriD85) - +![samuelbles07](https://github.com/samuelbles07.png?size=24) [samuelbles07](https://github.com/samuelbles07) [← README](../README.md) From d88b7c38ddc6e93af82f1969f33a015b67811927 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 15 Oct 2023 13:41:53 +0200 Subject: [PATCH 142/165] Renamed directChildResources() method --- acme/resources/CSEBase.py | 2 +- acme/resources/FCNT.py | 8 +++---- acme/resources/TS.py | 2 +- acme/services/Console.py | 4 ++-- acme/services/Dispatcher.py | 38 +++++++++++++++++++++++++++---- acme/services/RemoteCSEManager.py | 2 +- acme/services/RequestManager.py | 2 +- acme/services/Statistics.py | 2 +- acme/services/Storage.py | 3 +-- 9 files changed, 45 insertions(+), 18 deletions(-) diff --git a/acme/resources/CSEBase.py b/acme/resources/CSEBase.py index 24655f64..59526c13 100644 --- a/acme/resources/CSEBase.py +++ b/acme/resources/CSEBase.py @@ -134,7 +134,7 @@ def willBeRetrieved(self, originator:str, def childWillBeAdded(self, childResource: Resource, originator: str) -> None: super().childWillBeAdded(childResource, originator) if childResource.ty == ResourceTypes.SCH: - if CSE.dispatcher.directChildResources(self.ri, ResourceTypes.SCH): + if CSE.dispatcher.retrieveDirectChildResources(self.ri, ResourceTypes.SCH): raise BAD_REQUEST('Only one resource is allowed for the CSEBase') diff --git a/acme/resources/FCNT.py b/acme/resources/FCNT.py index 2300b9f5..4522dba9 100644 --- a/acme/resources/FCNT.py +++ b/acme/resources/FCNT.py @@ -236,7 +236,7 @@ def _validateChildren(self, originator:str, def flexContainerInstances(self) -> list[Resource]: """ Get all flexContainerInstances of a resource and return a sorted (by ct) list """ - return sorted(CSE.dispatcher.directChildResources(self.ri, ResourceTypes.FCI), key = lambda x: x.ct) # type:ignore[no-any-return] + return sorted(CSE.dispatcher.retrieveDirectChildResources(self.ri, ResourceTypes.FCI), key = lambda x: x.ct) # type:ignore[no-any-return] # Add a new FlexContainerInstance for this flexContainer @@ -306,10 +306,10 @@ def _removeLaOl(self) -> None: L.isDebug and L.logDebug(f'De-registering latest and oldest virtual resources for: {self.ri}') # remove latest - if len(chs := CSE.dispatcher.directChildResources(self.ri, ResourceTypes.FCNT_LA)) == 1: # type:ignore[no-any-return] + if len(chs := CSE.dispatcher.retrieveDirectChildResources(self.ri, ResourceTypes.FCNT_LA)) == 1: # type:ignore[no-any-return] CSE.dispatcher.deleteLocalResource(chs[0]) # ignore errors # remove oldest - if len(chs := CSE.dispatcher.directChildResources(self.ri, ResourceTypes.FCNT_OL)) == 1: # type:ignore[no-any-return] + if len(chs := CSE.dispatcher.retrieveDirectChildResources(self.ri, ResourceTypes.FCNT_OL)) == 1: # type:ignore[no-any-return] CSE.dispatcher.deleteLocalResource(chs[0]) # ignore errors self.setAttribute(self._hasFCI, False) @@ -319,7 +319,7 @@ def _removeFCIs(self) -> None: """ Remove the FCI childResources. """ L.isDebug and L.logDebug(f'Removing FCI child resources for: {self.ri}') - chs = CSE.dispatcher.directChildResources(self.ri, ty = ResourceTypes.FCI) + chs = CSE.dispatcher.retrieveDirectChildResources(self.ri, ty = ResourceTypes.FCI) for ch in chs: # self.childRemoved(r, originator) # It should not be necessary to notify self at this point. CSE.dispatcher.deleteLocalResource(ch, parentResource = self) diff --git a/acme/resources/TS.py b/acme/resources/TS.py index 824119b5..aba57599 100644 --- a/acme/resources/TS.py +++ b/acme/resources/TS.py @@ -394,7 +394,7 @@ def _clearMdlt(self, overwrite:Optional[bool] = True) -> None: def timeSeriesInstances(self) -> list[Resource]: """ Get all timeSeriesInstances of a timeSeries and return a sorted (by ct) list """ - return sorted(CSE.dispatcher.directChildResources(self.ri, ResourceTypes.TSI), key = lambda x: x.ct) # type:ignore[no-any-return] + return sorted(CSE.dispatcher.retrieveDirectChildResources(self.ri, ResourceTypes.TSI), key = lambda x: x.ct) # type:ignore[no-any-return] def addDgtToMdlt(self, dgtToAdd:float) -> None: diff --git a/acme/services/Console.py b/acme/services/Console.py index c170683c..b9526bbc 100644 --- a/acme/services/Console.py +++ b/acme/services/Console.py @@ -825,7 +825,7 @@ def _plotGraph(self, resource:Resource) -> None: # plot try: - cins = CSE.dispatcher.directChildResources(resource.ri, ResourceTypes.CIN) + cins = CSE.dispatcher.retrieveDirectChildResources(resource.ri, ResourceTypes.CIN) x = range(1, (lcins := len(cins)) + 1) y = [ float(each.con) for each in cins ] cols, rows = plotext.terminal_size() @@ -1422,7 +1422,7 @@ def getChildren(res:Resource, tree:Tree, level:int) -> None: """ if maxLevel > 0 and level == maxLevel: return - chs = CSE.dispatcher.directChildResources(res.ri) + chs = CSE.dispatcher.retrieveDirectChildResources(res.ri) for ch in chs: if ch.isVirtual() and not self.treeIncludeVirtualResources: # Ignore virual resources continue diff --git a/acme/services/Dispatcher.py b/acme/services/Dispatcher.py index 77634ca3..1493ad5a 100644 --- a/acme/services/Dispatcher.py +++ b/acme/services/Dispatcher.py @@ -365,7 +365,7 @@ def discoverResources(self, lim:int = filterCriteria.lim if filterCriteria.lim is not None else sys.maxsize # get all direct children and slice the page (offset and limit) - dcrs = self.directChildResources(id)[ofst-1:ofst-1 + lim] # now dcrs only contains the desired child resources for ofst and lim + dcrs = self.retrieveDirectChildResources(id)[ofst-1:ofst-1 + lim] # now dcrs only contains the desired child resources for ofst and lim # a bit of optimization. This length stays the same. allLen = len(filterCriteria.attributes) if filterCriteria.attributes else 0 @@ -418,7 +418,7 @@ def _discoverResources(self, rootResource:Resource, # get all direct children, if not provided if not dcrs: - if len(dcrs := self.directChildResources(rootResource.ri)) == 0: + if len(dcrs := self.retrieveDirectChildResources(rootResource.ri)) == 0: return [] @@ -1232,10 +1232,17 @@ def notifyLocalResource(self, ri:str, # Public Utility methods # - def directChildResources(self, pi:str, - ty:Optional[ResourceTypes] = None) -> list[Resource]: + def retrieveDirectChildResources(self, pi:str, + ty:Optional[ResourceTypes] = None) -> list[Resource]: """ Return all child resources of a resource, optionally filtered by type. An empty list is returned if no child resource could be found. + + Args: + pi: The parent's resourceIdentifier. + ty: The resource type to filter for. + + Return: + A list of retrieved `Resource` objects. This list might be empty. """ return cast(List[Resource], CSE.storage.directChildResources(pi, ty)) @@ -1244,12 +1251,26 @@ def directChildResourcesRI(self, pi:str, ty:Optional[ResourceTypes] = None) -> list[str]: """ Return the resourceIdentifiers of all child resources of a resource, optionally filtered by type. An empty list is returned if no child resource could be found. + + Args: + pi: The parent's resourceIdentifier. + ty: The resource type to filter for. + + Return: + A list of retrieved resourceIdentifiers. This list might be empty. """ return CSE.storage.directChildResourcesRI(pi, ty) def countDirectChildResources(self, pi:str, ty:Optional[ResourceTypes] = None) -> int: """ Return the number of all child resources of resource, optionally filtered by type. + + Args: + pi: The parent's resourceIdentifier. + ty: The resource type to filter for. + + Return: + Number of child resources. """ return CSE.storage.countDirectChildResources(pi, ty) @@ -1257,6 +1278,13 @@ def countDirectChildResources(self, pi:str, ty:Optional[ResourceTypes] = None) - def hasDirectChildResource(self, pi:str, ri:str) -> bool: """ Check if a resource has a direct child resource with a given resourceID + + Args: + pi: The parent's resourceIdentifier. + ri: The resourceIdentifier to check for. + + Return: + True if a direct child resource with the given resourceIdentifier exists, False otherwise. """ return riFromID(ri) in self.directChildResourcesRI(pi) @@ -1382,7 +1410,7 @@ def deleteChildResources(self, parentResource:Resource, If *ty* is set only the resources of this type are removed. """ # Remove directChildResources - rs = self.directChildResources(parentResource.ri) + rs = self.retrieveDirectChildResources(parentResource.ri) for r in rs: if ty is None or r.ty == ty: # ty is an int #parentResource.childRemoved(r, originator) # recursion here diff --git a/acme/services/RemoteCSEManager.py b/acme/services/RemoteCSEManager.py index 35c74cd7..d235c89b 100644 --- a/acme/services/RemoteCSEManager.py +++ b/acme/services/RemoteCSEManager.py @@ -527,7 +527,7 @@ def _retrieveLocalCSRResources(self, includeRegistrarCSR:Optional[bool] = False, A list of found CSR resources. """ registreeCsrList = [] - for eachCSR in CSE.dispatcher.directChildResources(pi = CSE.cseRi, ty = ResourceTypes.CSR): + for eachCSR in CSE.dispatcher.retrieveDirectChildResources(pi = CSE.cseRi, ty = ResourceTypes.CSR): if eachCSR.csi == self.registrarCSI: # type: ignore[name-defined] if includeRegistrarCSR: registreeCsrList.append(eachCSR) diff --git a/acme/services/RequestManager.py b/acme/services/RequestManager.py index 5232b85e..57217d28 100644 --- a/acme/services/RequestManager.py +++ b/acme/services/RequestManager.py @@ -1527,7 +1527,7 @@ def getTargetReleaseVersion(srv:list) -> str: pollingChannelResources = [] if targetResource.rr == False: L.isDebug and L.logDebug(f'Target: {uri} is not requestReachable. Trying .') - if not len(pollingChannelResources := CSE.dispatcher.directChildResources(targetResource.ri, ResourceTypes.PCH)): + if not len(pollingChannelResources := CSE.dispatcher.retrieveDirectChildResources(targetResource.ri, ResourceTypes.PCH)): L.isWarn and L.logWarn(f'Target: {uri} is not requestReachable and does not have a .') return [] # Take the first resource and return it. There should hopefully only be one, but we don't check this here diff --git a/acme/services/Statistics.py b/acme/services/Statistics.py index c4dfb6a7..2e825a81 100644 --- a/acme/services/Statistics.py +++ b/acme/services/Statistics.py @@ -355,7 +355,7 @@ def getChildren(res:Resource, level:int) -> str: result = '' if maxLevel > 0 and level == maxLevel: return result - chs = CSE.dispatcher.directChildResources(res.ri) + chs = CSE.dispatcher.retrieveDirectChildResources(res.ri) for ch in chs: result += ' ' * 2 * level + f'|_ {ch.rn} < {ResourceTypes(ch.ty).tpe()} >\n' result += getChildren(ch, level+1) diff --git a/acme/services/Storage.py b/acme/services/Storage.py index 21f3c516..1712c1fd 100644 --- a/acme/services/Storage.py +++ b/acme/services/Storage.py @@ -1170,8 +1170,7 @@ def deleteResource(self, resource:Resource) -> None: resource: The resource to delete. """ with self.lockResources: - _ri = resource.ri - self.tabResources.remove(doc_ids = [_ri]) + self.tabResources.remove(doc_ids = [resource.ri]) def searchResources(self, ri:Optional[str] = None, From 539ba0a4eec0008f9018f2f22de281f3285d8b3c Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 15 Oct 2023 15:16:59 +0200 Subject: [PATCH 143/165] Corrected state when terminating from a sub-call --- acme/helpers/Interpreter.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index 6c2c7243..7c1890cd 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -1311,6 +1311,8 @@ def executeSubexpression(self, expression:str) -> PContext: raise PInvalidArgumentError(self) self.result = None self.run(arguments = self.argv, isSubCall = True) # might throw exception + if self.state in(PState.terminated, PState.terminatedWithResult): # Correct state for subcall + self.state = PState.running self.ast = _ast self.script = _script return self From fbe9ebbc1cacd2a0f6f0a92638ac17e694c6cd71 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 17 Oct 2023 11:49:57 +0200 Subject: [PATCH 144/165] Added fromISO8601Date() function --- acme/etc/DateUtils.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/acme/etc/DateUtils.py b/acme/etc/DateUtils.py index 9e6b195a..717313a3 100644 --- a/acme/etc/DateUtils.py +++ b/acme/etc/DateUtils.py @@ -46,6 +46,17 @@ def toISO8601Date(ts:Union[float, datetime], readable:Optional[bool] = False) -> return ts.strftime('%Y-%m-%dT%H:%M:%S,%f' if readable else '%Y%m%dT%H%M%S,%f') +def fromISO8601Date(date:str) -> datetime: + """ Convert an ISO 8601 date time string to a *datetime* object. + + Args: + date: ISO 8601 datetime string. + Return: + Datetime object. + """ + return isodate.parse_datetime(date) + + def fromAbsRelTimestamp(absRelTimestamp:str, default:Optional[float] = 0.0, withMicroseconds:Optional[bool] = True) -> float: From e76be26aa380ffd46b39948916ba6a9aaeb4bac9 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 17 Oct 2023 11:56:13 +0200 Subject: [PATCH 145/165] Added optional passing of the worker object itself to the callback, if the argument _worker exists --- acme/helpers/BackgroundWorker.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/acme/helpers/BackgroundWorker.py b/acme/helpers/BackgroundWorker.py index 1d2ca5d7..5766cf45 100644 --- a/acme/helpers/BackgroundWorker.py +++ b/acme/helpers/BackgroundWorker.py @@ -13,7 +13,7 @@ from typing import Callable, List, Dict, Any, Tuple, Optional from .TextTools import simpleMatch -import random, sys, heapq, traceback, time +import random, sys, heapq, traceback, time, inspect from datetime import datetime, timezone from threading import Thread, Timer, Event, RLock, Lock, enumerate as threadsEnumerate import logging @@ -213,10 +213,15 @@ def _work(self) -> None: # - ignoreException is False then the exception is raised again while True: try: - if self.data is not None: - result = self.callback(_data = self.data, **self.args) - else: - result = self.callback(**self.args) + # check whether the callback has a _data and _worker argument + # and add them if they are + argSpec = inspect.getfullargspec(self.callback) + if '_data' in argSpec.args: + self.args['_data'] = self.data + if '_worker' in argSpec.args: + self.args['_worker'] = self + # call the callback + result = self.callback(**self.args) break except Exception as e: if BackgroundWorker._logger: @@ -269,7 +274,7 @@ def _postCall(self) -> None: def __repr__(self) -> str: - return f'BackgroundWorker(name={self.name}, callback = {str(self.callback)}, running = {self.running}, interval = {self.interval:f}, startWithDelay = {self.startWithDelay}, numberOfRuns = {self.numberOfRuns:d}, dispose = {self.dispose}, id = {self.id}, runOnTime = {self.runOnTime})' + return f'BackgroundWorker(name={self.name}, callback = {str(self.callback)}, running = {self.running}, interval = {self.interval:f}, startWithDelay = {self.startWithDelay}, numberOfRuns = {self.numberOfRuns:d}, dispose = {self.dispose}, id = {self.id}, runOnTime = {self.runOnTime}, data = {self.data})' @@ -598,7 +603,7 @@ def newActor(cls, workerCallback:Callable, (it may be 0.0s, though), or *at* a specific time (UTC timestamp). Args: - workerCallback: Callback that is executed to perform the action for the actor. + workerCallback: Callback that is executed to perform the action for the actor. It will receive the *data* in its *_data*, and the worker itself in the *_worker* arguments (if available as arguments). delay: Delay in seconds after which the actor callback is executed. This is an alternative to *at*. Only one of *at* or *delay* must be specified. @@ -610,7 +615,7 @@ def newActor(cls, workerCallback:Callable, finished: Callable that is executed after the worker finished. It will receive the same arguments as the *workerCallback* callback. ignoreException: Restart the actor in case an exception is encountered. - data: Any data structure that is stored in the worker and accessible by the *data* attribute, and which is passed as the first argument in the *_data* argument of the *workerCallback* if not *None*. + data: Any data structure that is stored in the worker and accessible by the *data* attribute, and which is passed in the *_data* argument of the *workerCallback* if not *None*. Return: `BackgroundWorker` object. It is only an initialized object and needs to be started manually with its `start()` method. """ From 77a07da5755141b889100f7ef0025e2ef45e945b Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 17 Oct 2023 11:57:08 +0200 Subject: [PATCH 146/165] Fixed clearing the worker's data list for sliding and periodic window --- acme/services/NotificationManager.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/acme/services/NotificationManager.py b/acme/services/NotificationManager.py index fa1326d5..84013958 100644 --- a/acme/services/NotificationManager.py +++ b/acme/services/NotificationManager.py @@ -632,14 +632,12 @@ def _crsCheckForNotification(self, data:list[str], else: # No schedule matches the current time, so clear the data and just return L.isDebug and L.logDebug(f'No matching schedule found for : {crsRi}') - data.clear() return try: resource = CSE.dispatcher.retrieveResource(crsRi) except ResponseException as e: L.logWarn(f'Cannot retrieve resource: {crsRi}: {e.dbg}') # Not much we can do here - data.clear() return crs = cast(CRS, resource) @@ -671,7 +669,6 @@ def _crsCheckForNotification(self, data:list[str], else: L.isDebug and L.logDebug(f'No notification sent') - data.clear() # Time Window Monitor : Periodic @@ -710,11 +707,13 @@ def stopCRSPeriodicWindow(self, crsRi:str) -> None: def _crsPeriodicWindowMonitor(self, _data:list[str], + _worker:BackgroundWorker, crsRi:str, expectedCount:int, eem:EventEvaluationMode = EventEvaluationMode.ALL_EVENTS_PRESENT) -> bool: L.isDebug and L.logDebug(f'Checking periodic window for : {crsRi}') self._crsCheckForNotification(_data, crsRi, expectedCount, eem) + _worker.data = [] return True @@ -752,12 +751,15 @@ def stopCRSSlidingWindow(self, crsRi:str) -> None: BackgroundWorkerPool.stopWorkers(self._getSlidingWorkerName(crsRi)) - def _crsSlidingWindowMonitor(self, _data:Any, + def _crsSlidingWindowMonitor(self, _data:Any, + _worker:BackgroundWorker, crsRi:str, subCount:int, eem:EventEvaluationMode = EventEvaluationMode.ALL_EVENTS_PRESENT) -> bool: L.isDebug and L.logDebug(f'Checking sliding window for : {crsRi}') self._crsCheckForNotification(_data, crsRi, subCount, eem) + _worker.data = [] + # _data.clear() return True From fbabca4107b9a1a722375920c5cd718d33a03bda Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 17 Oct 2023 16:23:12 +0200 Subject: [PATCH 147/165] Enabled filtering for multiple types --- acme/services/Dispatcher.py | 10 +++++----- acme/services/Storage.py | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/acme/services/Dispatcher.py b/acme/services/Dispatcher.py index 1493ad5a..76d7341b 100644 --- a/acme/services/Dispatcher.py +++ b/acme/services/Dispatcher.py @@ -1222,7 +1222,7 @@ def notifyLocalResource(self, ri:str, originator) return Result(rsc = ResponseStatusCode.OK) except ResponseException as e: - L.isWarn and L.logWarn(f'error handling notificatuin: {e.dbg}') + L.isWarn and L.logWarn(f'error handling notification: {e.dbg}') raise @@ -1233,13 +1233,13 @@ def notifyLocalResource(self, ri:str, # def retrieveDirectChildResources(self, pi:str, - ty:Optional[ResourceTypes] = None) -> list[Resource]: + ty:Optional[ResourceTypes|list[ResourceTypes]] = None) -> list[Resource]: """ Return all child resources of a resource, optionally filtered by type. An empty list is returned if no child resource could be found. Args: pi: The parent's resourceIdentifier. - ty: The resource type to filter for. + ty: The resource type or list of resource types to filter for. Return: A list of retrieved `Resource` objects. This list might be empty. @@ -1248,13 +1248,13 @@ def retrieveDirectChildResources(self, pi:str, def directChildResourcesRI(self, pi:str, - ty:Optional[ResourceTypes] = None) -> list[str]: + ty:Optional[ResourceTypes|list[ResourceTypes]] = None) -> list[str]: """ Return the resourceIdentifiers of all child resources of a resource, optionally filtered by type. An empty list is returned if no child resource could be found. Args: pi: The parent's resourceIdentifier. - ty: The resource type to filter for. + ty: The resource type or list of resource types to filter for. Return: A list of retrieved resourceIdentifiers. This list might be empty. diff --git a/acme/services/Storage.py b/acme/services/Storage.py index 1712c1fd..78917cb0 100644 --- a/acme/services/Storage.py +++ b/acme/services/Storage.py @@ -357,13 +357,13 @@ def deleteResource(self, resource:Resource) -> None: def directChildResources(self, pi:str, - ty:Optional[ResourceTypes] = None, + ty:Optional[ResourceTypes|list[ResourceTypes]] = None, raw:Optional[bool] = False) -> list[Document]|list[Resource]: """ Return a list of direct child resources, or an empty list Args: pi: The parent resource's Resource ID. - ty: Optional resource type to filter the result. + ty: Optional resource type or list of resource types to filter the result. raw: When "True" then return the child resources as resource dictionary instead of resources. Returns: @@ -376,12 +376,12 @@ def directChildResources(self, pi:str, def directChildResourcesRI(self, pi:str, - ty:Optional[ResourceTypes] = None) -> list[str]: + ty:Optional[ResourceTypes|list[ResourceTypes]] = None) -> list[str]: """ Return a list of direct child resource IDs, or an empty list Args: pi: The parent resource's Resource ID. - ty: Optional resource type to filter the result. + ty: Optional resource type or list of resource types to filter the result. Returns: Return a list of resource IDs. @@ -1412,22 +1412,24 @@ def removeChildResource(self, resource:Resource) -> None: self.tabChildResources.update(_r, doc_ids = [pi]) # type:ignore[arg-type, list-item] - def searchChildResourcesByParentRI(self, pi:str, ty:Optional[int] = None) -> list[str]: + def searchChildResourcesByParentRI(self, pi:str, ty:Optional[ResourceTypes|list[ResourceTypes]] = None) -> list[str]: """ Search for child resources by parent resource ID. Args: pi: The parent resource ID. - ty: The resource type of the child resources to search for. + ty: The resource type of the child resources to search for, or a list of resource types. Return: A list of child resource IDs, or an empty list if not found. """ - + # First convert ty to a list if it is just an int + if isinstance(ty, int): + ty = [ty] _r:Document = self.tabChildResources.get(doc_id = pi) #type:ignore[arg-type, assignment] if _r: if ty is None: # optimization: only check ty once for None return [ c[0] for c in _r['ch'] ] - return [ c[0] for c in _r['ch'] if ty == c[1] ] # c is a tuple (ri, ty) + return [ c[0] for c in _r['ch'] if c[1] in ty] # c is a tuple (ri, ty) return [] # From 80afc3115890ae27033195c2e70834aba47eee22 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 17 Oct 2023 16:23:37 +0200 Subject: [PATCH 148/165] Corrected CSS for TUI clock --- acme/textui/ACMEHeader.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/acme/textui/ACMEHeader.py b/acme/textui/ACMEHeader.py index 3b554a4e..53cef62f 100644 --- a/acme/textui/ACMEHeader.py +++ b/acme/textui/ACMEHeader.py @@ -25,10 +25,8 @@ class ACMEHeaderClock(HeaderClock): DEFAULT_CSS = """ ACMEHeaderClock { background: transparent; -} - -HeaderClockSpace { width: 26; + } """ From e556aa62f1646af634d5eaa2a46651200f5ab095 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 17 Oct 2023 16:32:35 +0200 Subject: [PATCH 149/165] TUI: Added diagram view for and that have instance children with numerical values --- CHANGELOG.md | 1 + acme/textui/ACMEContainerDiagram.py | 226 ++++++++++++++++++++++++++++ acme/textui/ACMEContainerTree.py | 49 +++++- requirements.txt | 22 +-- setup.py | 1 + 5 files changed, 284 insertions(+), 15 deletions(-) create mode 100644 acme/textui/ACMEContainerDiagram.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 067e1fba..1953d551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Calendar Versioning](https://calver.org). - [SCRIPTS] Functions now have their own variable scope. - [TUI] Improved resource view in the text UI. Enumeration interpretations are now shown. - [TUI] Added utility "Attribute Info Search". +- [TUI] Added diagram viee for <containers> that <contentInstance> child resources with numerical values. - [HTTP] Added support for http authorization for *basic* and *bearer* (token) methods. - [HTTP] Support for the Python *Web Server Gateway Interface* to improve integration with a reverse proxy or API gateway, ie. Nginx. Thanks to @samuelbles07 for the idea. - [LOGGING] Added limiting the size of a single log message. Messages that are too large are truncated. This feature is configurable (ie. length and whether to truncate or not). diff --git a/acme/textui/ACMEContainerDiagram.py b/acme/textui/ACMEContainerDiagram.py new file mode 100644 index 00000000..4a0ca8c3 --- /dev/null +++ b/acme/textui/ACMEContainerDiagram.py @@ -0,0 +1,226 @@ +# +# ACMEContainerDiagram.py +# +# (c) 2023 by Andreas Kraft +# License: BSD 3-Clause License. See the LICENSE file for further details. +# +""" This module defines the Diagram view for for *Container* resources for the ACME text UI. +""" +from __future__ import annotations +from typing import Optional, Callable +from enum import IntEnum + +from textual.app import ComposeResult +from textual import on +from textual.containers import Container, Vertical, Center, Horizontal, Middle +from textual.binding import Binding +from textual.widgets import Button, RadioSet, RadioButton +from textual_plotext import PlotextPlot + +from ..etc.DateUtils import fromISO8601Date +from ..services import CSE + + +class DiagramTypes(IntEnum): + """ Enumeration of the different diagram types. + """ + Line = 0 + Graph = 1 + Scatter = 2 + Bar = 3 + Timeline = 4 + + +class ACMEContainerDiagram(Container): + + DEFAULT_CSS = ''' +#diagram-view { + height: 100%; + padding: 0 1 1 1; +} + +#diagram-plot { + /*height: 100%;*/ +} + +#diagram-footer { + width: 100%; + margin-top: 1; + height: 1; +} + +#diagram-button-set { + width: auto; + margin-bottom: 0; +} + +#diagram-line-button { + height: 1; + border: none; + margin-right: 1; + min-width: 10; +} + +#diagram-graph-button { + height: 1; + border: none; + margin-right: 1; + min-width: 11; +} + +#diagram-scatter-button { + height: 1; + border: none; + margin-right: 1; + min-width: 13; +} + +#diagram-bar-button { + height: 1; + border: none; + margin-right: 1; + min-width: 9; +} + +#diagram-timeline-button { + height: 1; + border: none; + margin-right: 0; + min-width: 14; +} + +#diagram-refresh-button { + height: 1; + border: none; + margin-left: 4; + margin-right: 0; + min-width: 13; +} +''' + + def __init__(self, refreshCallback:Callable) -> None: + super().__init__() + self.plot:PlotextPlot = None + self.type = DiagramTypes.Line + self.color = (0, 120, 212) + self.values:list[float] = [] + self.dates:Optional[list[str]] = [] + self.refreshCallback = refreshCallback + + + def compose(self) -> ComposeResult: + if not self.plot: + self.plot = PlotextPlot(id = 'diagram-plot') + self.plot.plt.date_form('d/m/Y H:M:S', 'Y-m-d H:M:S') + self.buttons = { + # TODO Bar and the others diagram types are not working together, + # after Bar was seleced the dates are not shown anymore. + # Perhaps the current types are enough for now + + DiagramTypes.Line: Button('Line', variant = 'primary', id = 'diagram-line-button'), + # DiagramTypes.Graph: Button('Graph', variant = 'primary', id = 'diagram-graph-button'), + DiagramTypes.Scatter: Button('Scatter', variant = 'primary', id = 'diagram-scatter-button'), + # DiagramTypes.Bar: Button('Bar', variant = 'primary', id = 'diagram-bar-button'), + DiagramTypes.Timeline: Button('Timeline', variant = 'primary', id = 'diagram-timeline-button'), + } + with Vertical(id = 'diagram-view'): + yield self.plot + with Center(id = 'diagram-footer'): + with Horizontal(id = 'diagram-button-set'): + for button in self.buttons.values(): + yield button + yield Button('Refresh', variant = 'primary', id = 'diagram-refresh-button') + + + def on_show(self) -> None: + self.activateButton(self.type) + self.plotGraph() + + + def activateButton(self, type:DiagramTypes) -> None: + self.type = type + for b in self.buttons.values(): + b.variant = 'primary' + self.buttons[type].variant = 'success' + self.plotGraph() + + + @on(Button.Pressed, '#diagram-line-button') + def lineButtonExecute(self) -> None: + self.activateButton(DiagramTypes.Line) + + + @on(Button.Pressed, '#diagram-graph-button') + def graphButtonExecute(self) -> None: + self.activateButton(DiagramTypes.Graph) + + + @on(Button.Pressed, '#diagram-scatter-button') + def scatterButtonExecute(self) -> None: + self.activateButton(DiagramTypes.Scatter) + + + @on(Button.Pressed, '#diagram-bar-button') + def barButtonExecute(self) -> None: + self.activateButton(DiagramTypes.Bar) + + + @on(Button.Pressed, '#diagram-timeline-button') + def timeLineButtonExecute(self) -> None: + self.activateButton(DiagramTypes.Timeline) + + + @on(Button.Pressed, '#diagram-refresh-button') + def refreshButtonExecute(self) -> None: + if self.refreshCallback: + self.refreshCallback() + self.plotGraph() + + + def plotGraph(self) -> None: + dates = [ fromISO8601Date(d).strftime('%d/%m/%Y %H:%M:%S') for d in self.dates ] if self.dates else None + values = self.values + plt = self.plot.plt + + plt.clear_data() + + match self.type: + case DiagramTypes.Line: + if dates is None: + plt.plot(values, color = self.color) + else: + plt.plot(dates, values, color = self.color) + case DiagramTypes.Graph: + if dates is None: + plt.plot(values, color = self.color, fillx=True) + else: + plt.plot(dates, values, color = self.color, fillx=True) + case DiagramTypes.Scatter: + if dates is None: + plt.scatter(values, color = self.color) + else: + plt.scatter(dates, values, color = self.color) + case DiagramTypes.Bar: + if dates is None: + plt.bar(values, color = self.color) + else: + plt.bar(dates, values, color = self.color) + case DiagramTypes.Timeline: + _d = [ fromISO8601Date(d).strftime('%d/%m/%Y %H:%M:%S') for d in self.dates ] + if dates is None: + plt.event_plot(_d, color = self.color) + else: + plt.event_plot(dates, _d, color = self.color) # type: ignore[arg-type] + self.plot.refresh(layout = True) + + + def setData(self, values:list[float], dates:Optional[list[str]] = None) -> None: + """ Set the data to be displayed in the diagram. + + Args: + values: The data to be displayed. + dates: The dates for the data. If not given, the current time is used. + """ + self.values = values + self.dates = dates + return diff --git a/acme/textui/ACMEContainerTree.py b/acme/textui/ACMEContainerTree.py index 8cb8b175..73929921 100644 --- a/acme/textui/ACMEContainerTree.py +++ b/acme/textui/ACMEContainerTree.py @@ -8,6 +8,7 @@ """ from __future__ import annotations from typing import List, Tuple, Optional +from datetime import datetime from textual import events from textual.app import ComposeResult from textual.widgets import Tree as TextualTree, Static, TabbedContent, TabPane, Markdown, Label, Button @@ -20,8 +21,10 @@ from ..textui.ACMEContainerRequests import ACMEViewRequests from ..etc.ResponseStatusCodes import ResponseException from ..etc.Types import ResourceTypes +from ..etc.DateUtils import fromAbsRelTimestamp from ..helpers.TextTools import commentJson from .ACMEContainerDelete import ACMEContainerDelete +from .ACMEContainerDiagram import ACMEContainerDiagram idTree = 'tree' @@ -87,13 +90,14 @@ def _update_content(self, ri:str) -> None: self.parentContainer.header.update(f'## {ResourceTypes.fullname(resource.ty)}' if resource else '##  ') + def _retrieve_resource_children(self, ri:str) -> List[Tuple[Resource, bool]]: result:List[Tuple[Resource, bool]] = [] - chs = [ x for x in CSE.dispatcher.directChildResources(ri) if not x.ty in [ ResourceTypes.GRP_FOPT, ResourceTypes.PCH_PCU ]] + chs = [ x for x in CSE.dispatcher.retrieveDirectChildResources(ri) if not x.ty in [ ResourceTypes.GRP_FOPT, ResourceTypes.PCH_PCU ]] # chs = [ x for x in CSE.dispatcher.directChildResources(ri) if not x.isVirtual() ] # chs = [ x for x in CSE.dispatcher.directChildResources(ri) if not x.isVirtual() ] for r in chs: - result.append((r, len([ x for x in CSE.dispatcher.directChildResources(r.ri) ]) > 0)) + result.append((r, len([ x for x in CSE.dispatcher.retrieveDirectChildResources(r.ri) ]) > 0)) # result.append((r, len([ x for x in CSE.dispatcher.directChildResources(r.ri) if not x.isVirtual() ]) > 0)) return result @@ -133,6 +137,8 @@ class ACMEContainerTree(Container): /* TODO try to get padding working with later released of textualize */ } + + ''' def __init__(self) -> None: @@ -145,8 +151,9 @@ def __init__(self) -> None: # Tabs self.tabs = TabbedContent() - # Resource and Request views + # Various Resource and Request views self.deleteView = ACMEContainerDelete() + self.diagram = ACMEContainerDiagram(refreshCallback = lambda: self.updateResource()) # For some reason, the markdown header is not refreshed the very first time self.header = Markdown('') @@ -168,6 +175,9 @@ def compose(self) -> ComposeResult: with TabPane('Requests', id = 'tree-tab-requests'): yield self.requestView + with TabPane('Diagram', id = 'tree-tab-diagram'): + yield self.diagram + # with TabPane('CREATE', id = 'tree-tab-create', disabled = True): # yield Markdown('## Send CREATE Request') # yield Label('TODO') @@ -177,6 +187,7 @@ def compose(self) -> ComposeResult: # with TabPane('UPDATE', id = 'tree-tab-update', disabled = True): # yield Markdown('## Send UPDATE Request') # yield Label('TODO') + with TabPane('DELETE', id = 'tree-tab-delete'): yield Markdown('## Send DELETE Request') yield self.deleteView @@ -208,7 +219,12 @@ def update(self) -> None: def updateResource(self, resource:Optional[Resource] = None) -> None: - self.resource = resource + if resource: + # Store the resource for later + self.resource = resource + else: + # Otherwise use the old / current resource + resource = self.resource # Add attribute explanations if resource: @@ -224,6 +240,31 @@ def updateResource(self, resource:Optional[Resource] = None) -> None: self.deleteView.updateResource(resource) self.deleteView.disabled = False + # Update Diagram view + try: + if resource.ty in (ResourceTypes.CNT, ResourceTypes.TS): + instances = CSE.dispatcher.retrieveDirectChildResources(resource.ri, [ResourceTypes.CIN, ResourceTypes.TSI]) + + # The following line may fail if the content cannot be converted to a float. + # This is expected! This just means that any content is not a number and we cannot raw a diagram. + # The exception is caught below and the diagram view is hidden. + values = [float(r.con) for r in instances] + + dates = [r.ct for r in instances] + # values = [float(r.con) + # for r in instances + # if r.ty in (ResourceTypes.CIN, ResourceTypes.TSI)] + # dates = [r.ct + # for r in instances + # if r.ty in (ResourceTypes.CIN, ResourceTypes.TSI)] + + self.diagram.setData(values, dates) + self.tabs.show_tab('tree-tab-diagram') + else: + self.tabs.hide_tab('tree-tab-diagram') + except: + self.tabs.hide_tab('tree-tab-diagram') + else: jsns = '' diff --git a/requirements.txt b/requirements.txt index e7d81850..6111394c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ # # pip-compile # -blinker==1.6.2 +blinker==1.6.3 # via flask cbor2==5.4.6 # via ACME-oneM2M-CSE (setup.py) @@ -49,14 +49,16 @@ mdit-py-plugins==0.4.0 # via markdown-it-py mdurl==0.1.2 # via markdown-it-py -numpy==1.26.0 +numpy==1.26.1 # via shapely paho-mqtt==1.6.1 # via ACME-oneM2M-CSE (setup.py) pfzy==0.3.4 # via inquirerpy plotext==5.2.8 - # via ACME-oneM2M-CSE (setup.py) + # via + # ACME-oneM2M-CSE (setup.py) + # textual-plotext prompt-toolkit==3.0.39 # via inquirerpy pygments==2.16.1 @@ -73,20 +75,18 @@ rich==13.6.0 # via # ACME-oneM2M-CSE (setup.py) # textual -shapely==2.0.1 +shapely==2.0.2 # via ACME-oneM2M-CSE (setup.py) six==1.16.0 # via isodate -textual==0.38.1 +textual==0.40.0 + # via + # ACME-oneM2M-CSE (setup.py) + # textual-plotext +textual-plotext==0.1.0 # via ACME-oneM2M-CSE (setup.py) tinydb==4.8.0 # via ACME-oneM2M-CSE (setup.py) -tree-sitter==0.20.2 - # via - # textual - # tree-sitter-languages -tree-sitter-languages==1.7.0 - # via textual typing-extensions==4.8.0 # via textual uc-micro-py==1.0.2 diff --git a/setup.py b/setup.py index 69b33a8e..db2f5b58 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,7 @@ 'rich', 'shapely', 'textual', + 'textual-plotext', 'tinydb', 'waitress', ], From 13dfbac28c9138fe6c39c8aa84197787cb877692 Mon Sep 17 00:00:00 2001 From: ankraft Date: Tue, 17 Oct 2023 16:50:01 +0200 Subject: [PATCH 150/165] Debug for CRS --- CHANGELOG.md | 2 -- acme/resources/CRS.py | 11 ++++++++--- acme/textui/ACMEContainerDiagram.py | 1 - 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1953d551..ec11831b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,12 +39,10 @@ and this project adheres to [Calendar Versioning](https://calver.org). - [SCRIPTS] Moved utilities and system scripts to sub-directories. Now all scripts from directories "*.scripts" in the "init" directory are automatically imported. - [TUI] Simplified the request list view in the text UI. - ### Fixed - [CSE] Removed superfluous code when announcing resources (see issue #122). - [CSE] Added support and validation for 'xs:NCName' type in attribute policies. - ### Removed diff --git a/acme/resources/CRS.py b/acme/resources/CRS.py index 651a0f8e..b2df8608 100644 --- a/acme/resources/CRS.py +++ b/acme/resources/CRS.py @@ -419,6 +419,8 @@ def _deleteSubscriptionForRrat(self, subRI:str, originator:str) -> None: CSE.dispatcher.deleteResource(subRI, originator = originator) except NOT_FOUND as e: pass # ignore not found resources here + except Exception as e: + L.logErr(f'Cannot delete subscription for {subRI}: {e}') # To be sure: Set the RI in the rrats list to None _rrats = self.rrats @@ -432,8 +434,11 @@ def _deleteFromSubscriptionsForSrat(self, srat:str, originator:str) -> None: if (subRI := _subRIs.get(srat)) is not None: try: resource = CSE.dispatcher.retrieveResource(subRI, originator = originator) - except: - raise BAD_REQUEST(L.logWarn(f'Cannot retrieve subscription for {srat} uri: {subRI}')) + except Exception as e: + L.logErr(f'Cannot retrieve subscription for {subRI}: {e}') + + # except: + # raise BAD_REQUEST(L.logWarn(f'Cannot retrieve subscription for {srat} uri: {subRI}')) newDct:JSON = { 'm2m:sub': {} } # new request dct @@ -455,7 +460,7 @@ def _deleteFromSubscriptionsForSrat(self, srat:str, originator:str) -> None: try: resource = CSE.dispatcher.updateResourceFromDict(newDct, subRI, originator = originator, resource = resource) except ResponseException as e: - raise BAD_REQUEST(L.logWarn(f'Cannot update subscription for {srat} uri: {subRI}: {e.dbg}')) + raise BAD_REQUEST(L.logWarn(f'Cannot update subscription for {srat} uri: {subRI}: {e} {e.dbg}')) del _subRIs[srat] self.setAttribute(self._subSratRIs, _subRIs) diff --git a/acme/textui/ACMEContainerDiagram.py b/acme/textui/ACMEContainerDiagram.py index 4a0ca8c3..60b5d540 100644 --- a/acme/textui/ACMEContainerDiagram.py +++ b/acme/textui/ACMEContainerDiagram.py @@ -223,4 +223,3 @@ def setData(self, values:list[float], dates:Optional[list[str]] = None) -> None: """ self.values = values self.dates = dates - return From 219271871752ffc57917c024e6b830b3a9226e7f Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 18 Oct 2023 13:24:02 +0200 Subject: [PATCH 151/165] Warnings instead of Error log messages. --- acme/etc/DateUtils.py | 2 +- acme/resources/CRS.py | 12 ++++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/acme/etc/DateUtils.py b/acme/etc/DateUtils.py index 717313a3..38dde4a2 100644 --- a/acme/etc/DateUtils.py +++ b/acme/etc/DateUtils.py @@ -249,7 +249,7 @@ def cronMatchesTimestamp(cronPattern:Union[str, list[str]], ts:Optional[datetime] = None) -> bool: ''' A cron parser to determine if the *cronPattern* matches for a given timestamp *ts*. - The cronPattern must follow the usual crontab pattern of 5 fields: + The cronPattern must follow the usual crontab pattern of 7 fields: second minute hour dayOfMonth month dayOfWeek year diff --git a/acme/resources/CRS.py b/acme/resources/CRS.py index b2df8608..9e7559ff 100644 --- a/acme/resources/CRS.py +++ b/acme/resources/CRS.py @@ -417,10 +417,9 @@ def _deleteSubscriptionForRrat(self, subRI:str, originator:str) -> None: L.isDebug and L.logDebug(f'Deleting : {subRI}') try: CSE.dispatcher.deleteResource(subRI, originator = originator) - except NOT_FOUND as e: - pass # ignore not found resources here except Exception as e: - L.logErr(f'Cannot delete subscription for {subRI}: {e}') + # ignore not found resources here + L.logWarn(f'Cannot delete subscription for {subRI}: {e}') # To be sure: Set the RI in the rrats list to None _rrats = self.rrats @@ -435,10 +434,7 @@ def _deleteFromSubscriptionsForSrat(self, srat:str, originator:str) -> None: try: resource = CSE.dispatcher.retrieveResource(subRI, originator = originator) except Exception as e: - L.logErr(f'Cannot retrieve subscription for {subRI}: {e}') - - # except: - # raise BAD_REQUEST(L.logWarn(f'Cannot retrieve subscription for {srat} uri: {subRI}')) + L.logWarn(f'Cannot retrieve subscription for {subRI}: {e}') newDct:JSON = { 'm2m:sub': {} } # new request dct @@ -460,7 +456,7 @@ def _deleteFromSubscriptionsForSrat(self, srat:str, originator:str) -> None: try: resource = CSE.dispatcher.updateResourceFromDict(newDct, subRI, originator = originator, resource = resource) except ResponseException as e: - raise BAD_REQUEST(L.logWarn(f'Cannot update subscription for {srat} uri: {subRI}: {e} {e.dbg}')) + L.logWarn(f'Cannot update subscription for {srat} uri: {subRI}: {e} {e.dbg}') del _subRIs[srat] self.setAttribute(self._subSratRIs, _subRIs) From 81ac25943a7f6ff0a0a3a75866033dbb62f201c3 Mon Sep 17 00:00:00 2001 From: ankraft Date: Wed, 18 Oct 2023 13:25:09 +0200 Subject: [PATCH 152/165] Corrected plots. Its easier to create a new PloText object instead of modifying and existing one. That should get rid of some display problems --- acme/textui/ACMEContainerDiagram.py | 109 +++++++++++++++++++--------- 1 file changed, 76 insertions(+), 33 deletions(-) diff --git a/acme/textui/ACMEContainerDiagram.py b/acme/textui/ACMEContainerDiagram.py index 60b5d540..08e4caab 100644 --- a/acme/textui/ACMEContainerDiagram.py +++ b/acme/textui/ACMEContainerDiagram.py @@ -98,33 +98,31 @@ class ACMEContainerDiagram(Container): } ''' +# TODO perhaps replace the PlotextPlot instance every time one chooses another diagram type + def __init__(self, refreshCallback:Callable) -> None: super().__init__() - self.plot:PlotextPlot = None - self.type = DiagramTypes.Line self.color = (0, 120, 212) + self.type = DiagramTypes.Line + self.plotContainer:Container = None + self.plot:PlotextPlot = None self.values:list[float] = [] self.dates:Optional[list[str]] = [] self.refreshCallback = refreshCallback + self.buttons = { + DiagramTypes.Line: Button('Line', variant = 'primary', id = 'diagram-line-button'), + DiagramTypes.Graph: Button('Graph', variant = 'primary', id = 'diagram-graph-button'), + DiagramTypes.Scatter: Button('Scatter', variant = 'primary', id = 'diagram-scatter-button'), + DiagramTypes.Bar: Button('Bar', variant = 'primary', id = 'diagram-bar-button'), + DiagramTypes.Timeline: Button('Timeline', variant = 'primary', id = 'diagram-timeline-button'), + } + def compose(self) -> ComposeResult: - if not self.plot: - self.plot = PlotextPlot(id = 'diagram-plot') - self.plot.plt.date_form('d/m/Y H:M:S', 'Y-m-d H:M:S') - self.buttons = { - # TODO Bar and the others diagram types are not working together, - # after Bar was seleced the dates are not shown anymore. - # Perhaps the current types are enough for now - - DiagramTypes.Line: Button('Line', variant = 'primary', id = 'diagram-line-button'), - # DiagramTypes.Graph: Button('Graph', variant = 'primary', id = 'diagram-graph-button'), - DiagramTypes.Scatter: Button('Scatter', variant = 'primary', id = 'diagram-scatter-button'), - # DiagramTypes.Bar: Button('Bar', variant = 'primary', id = 'diagram-bar-button'), - DiagramTypes.Timeline: Button('Timeline', variant = 'primary', id = 'diagram-timeline-button'), - } + self._newPlot() with Vertical(id = 'diagram-view'): - yield self.plot + yield self.plotContainer with Center(id = 'diagram-footer'): with Horizontal(id = 'diagram-button-set'): for button in self.buttons.values(): @@ -133,57 +131,64 @@ def compose(self) -> ComposeResult: def on_show(self) -> None: - self.activateButton(self.type) - self.plotGraph() - - - def activateButton(self, type:DiagramTypes) -> None: - self.type = type - for b in self.buttons.values(): - b.variant = 'primary' - self.buttons[type].variant = 'success' + self._activateButton(self.type) self.plotGraph() @on(Button.Pressed, '#diagram-line-button') def lineButtonExecute(self) -> None: - self.activateButton(DiagramTypes.Line) + """ Callback to switch to the line diagram. + """ + self._activateButton(DiagramTypes.Line) @on(Button.Pressed, '#diagram-graph-button') def graphButtonExecute(self) -> None: - self.activateButton(DiagramTypes.Graph) + """ Callback to switch to the graph diagram. + """ + self._activateButton(DiagramTypes.Graph) @on(Button.Pressed, '#diagram-scatter-button') def scatterButtonExecute(self) -> None: - self.activateButton(DiagramTypes.Scatter) + """ Callback to switch to the scatter diagram. + """ + self._activateButton(DiagramTypes.Scatter) @on(Button.Pressed, '#diagram-bar-button') def barButtonExecute(self) -> None: - self.activateButton(DiagramTypes.Bar) + """ Callback to switch to the bar diagram. + """ + self._activateButton(DiagramTypes.Bar) @on(Button.Pressed, '#diagram-timeline-button') def timeLineButtonExecute(self) -> None: - self.activateButton(DiagramTypes.Timeline) + """ Callback to switch to the timeline diagram. + """ + self._activateButton(DiagramTypes.Timeline) @on(Button.Pressed, '#diagram-refresh-button') def refreshButtonExecute(self) -> None: + """ Callback to refresh the diagram. + """ if self.refreshCallback: self.refreshCallback() self.plotGraph() def plotGraph(self) -> None: + """ Plot the graph. + """ dates = [ fromISO8601Date(d).strftime('%d/%m/%Y %H:%M:%S') for d in self.dates ] if self.dates else None values = self.values - plt = self.plot.plt - plt.clear_data() + # plt.clear_data() + self._newPlot() + plt = self.plot.plt match self.type: case DiagramTypes.Line: if dates is None: @@ -223,3 +228,41 @@ def setData(self, values:list[float], dates:Optional[list[str]] = None) -> None: """ self.values = values self.dates = dates + + + ################################################################# + # + # Private + # + + def _newPlot(self) -> None: + """ Create a new plot instance and update the container. + """ + + # Remove a previous plot if there is one + if not self.plotContainer: + self.plotContainer = Container() + else: + self.plot.remove() + + # Create a new plot and configure its timestamp format + self.plot = PlotextPlot() + self.plot.plt.date_form('d/m/Y H:M:S', 'Y-m-d H:M:S') + + # Add the plot to the container and refresh the container + self.plotContainer._add_child(self.plot) + self.plotContainer.refresh(layout=True) + + + def _activateButton(self, type:DiagramTypes) -> None: + """ Activate a button. + + Args: + type: The button to activate. + """ + if self.type != type: + self.type = type + for b in self.buttons.values(): + b.variant = 'primary' + self.buttons[type].variant = 'success' + self.plotGraph() From f464c8773c21ff8541b4b09ce1268b69581add61 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 19 Oct 2023 17:35:19 +0200 Subject: [PATCH 153/165] Added more documentation --- acme/__init__.py | 4 + acme/__main__.py | 10 ++ acme/etc/ResponseStatusCodes.py | 206 ++++++++++++++++++++++++++++++- acme/etc/Types.py | 174 ++++++++++++++++++++++---- acme/helpers/BackgroundWorker.py | 89 ++++++++++++- acme/helpers/KeyHandler.py | 141 +++++++++++++++++++-- acme/helpers/OAuth.py | 17 ++- acme/helpers/OrderedSet.py | 3 +- acme/services/Configuration.py | 97 ++++++++++++++- 9 files changed, 693 insertions(+), 48 deletions(-) diff --git a/acme/__init__.py b/acme/__init__.py index e69de29b..408c1594 100644 --- a/acme/__init__.py +++ b/acme/__init__.py @@ -0,0 +1,4 @@ +""" This module contains the ACME CSE implementation. It is the main module of the ACME CSE. + It contains the main() function that is called when the CSE is started. + It also contains the CSE class that implements the CSE. +""" \ No newline at end of file diff --git a/acme/__main__.py b/acme/__main__.py index 3bd012df..b7fcceb7 100644 --- a/acme/__main__.py +++ b/acme/__main__.py @@ -7,6 +7,9 @@ # Starter for the ACME CSE # +""" This module contains the ACME CSE implementation. It is the main module of the ACME CSE. +""" + import os, re, sys if sys.version_info < (3, 8): print('Python version >= 3.8 is required') @@ -56,6 +59,11 @@ # Handle command line arguments def parseArgs() -> argparse.Namespace: + """ Parse the command line arguments. + + Returns: + The parsed arguments. + """ parser = argparse.ArgumentParser(prog='acme') parser.add_argument('--config', action='store', dest='configfile', default=C.defaultUserConfigFile, metavar='', help='specify the configuration file') @@ -92,6 +100,8 @@ def parseArgs() -> argparse.Namespace: def main() -> None: + """ Main function of the ACME CSE. + """ # Start the CSE with command line arguments. # In case the CSE should be started without command line parsing, the values # can be passed instead. Unknown arguments are ignored. diff --git a/acme/etc/ResponseStatusCodes.py b/acme/etc/ResponseStatusCodes.py index efa6ac25..109019b2 100644 --- a/acme/etc/ResponseStatusCodes.py +++ b/acme/etc/ResponseStatusCodes.py @@ -181,11 +181,19 @@ class ResponseException(Exception): rsc: The response status code. dbg: An optional debug message. error: This is an error-related exception. + data: Optional data. """ def __init__(self, rsc:ResponseStatusCode, dbg:Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + rsc: The response status code. + dbg: An optional debug message. + data: Optional data. + """ super().__init__() self.rsc = rsc self.dbg = dbg @@ -196,6 +204,12 @@ class ALREADY_EXISTS(ResponseException): """ ALREADY EXISTS Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.ALREADY_EXISTS, dbg, data) @@ -203,6 +217,12 @@ class APP_RULE_VALIDATION_FAILED(ResponseException): """ APP RULE VALIDATION FAILED Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.APP_RULE_VALIDATION_FAILED, dbg, data) @@ -210,6 +230,12 @@ class BAD_REQUEST(ResponseException): """ BAD REQUEST Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.BAD_REQUEST, dbg, data) @@ -217,6 +243,12 @@ class CONFLICT(ResponseException): """ CONFLICT Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.CONFLICT, dbg, data) @@ -224,6 +256,12 @@ class CONTENTS_UNACCEPTABLE(ResponseException): """ CONTENTS UNACCEPTABLE Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.CONTENTS_UNACCEPTABLE, dbg, data) @@ -231,6 +269,12 @@ class CROSS_RESOURCE_OPERATION_FAILURE(ResponseException): """ CROSS RESOURCE OPERATION FAILURE Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.CROSS_RESOURCE_OPERATION_FAILURE, dbg, data) @@ -238,6 +282,12 @@ class GROUP_MEMBER_TYPE_INCONSISTENT(ResponseException): """ GROUP MEMBER TYPE INCONSISTENT Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.GROUP_MEMBER_TYPE_INCONSISTENT, dbg, data) @@ -245,6 +295,12 @@ class INSUFFICIENT_ARGUMENTS(ResponseException): """ INSUFFICIENT ARGUMENTS Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.INSUFFICIENT_ARGUMENTS, dbg, data) @@ -252,6 +308,12 @@ class INTERNAL_SERVER_ERROR(ResponseException): """ INTERNAL SERVER ERRROR Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.INTERNAL_SERVER_ERROR, dbg, data) @@ -259,6 +321,12 @@ class INVALID_CHILD_RESOURCE_TYPE(ResponseException): """ INVALID CHILD RESOURCE TYPE Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.INVALID_CHILD_RESOURCE_TYPE, dbg, data) @@ -266,6 +334,12 @@ class INVALID_ARGUMENTS(ResponseException): """ INVALID ARGUMENTS Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.INVALID_ARGUMENTS, dbg, data) @@ -273,6 +347,12 @@ class INVALID_SPARQL_QUERY(ResponseException): """ INVALID SPARQL QUERY Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.INVALID_SPARQL_QUERY, dbg, data) @@ -287,6 +367,12 @@ class NOT_ACCEPTABLE(ResponseException): """ NOT ACCEPTABLE Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.NOT_ACCEPTABLE, dbg, data) @@ -294,6 +380,12 @@ class NOT_FOUND(ResponseException): """ NOT FOUND Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.NOT_FOUND, dbg, data) @@ -301,6 +393,12 @@ class NOT_IMPLEMENTED(ResponseException): """ NOT IMPLEMENTED Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.NOT_IMPLEMENTED, dbg, data) @@ -308,6 +406,12 @@ class OPERATION_DENIED_BY_REMOTE_ENTITY(ResponseException): """ OPERATION DENIED BY REMOTE ENTITY Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.OPERATION_DENIED_BY_REMOTE_ENTITY, dbg, data) @@ -315,6 +419,12 @@ class OPERATION_NOT_ALLOWED(ResponseException): """ OPERATION NOT ALLOWED Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.OPERATION_NOT_ALLOWED, dbg, data) @@ -322,6 +432,12 @@ class ORIGINATOR_HAS_ALREADY_REGISTERED(ResponseException): """ ORIGINATOR HAS ALREADY REGISTERED Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.ORIGINATOR_HAS_ALREADY_REGISTERED, dbg, data) @@ -329,6 +445,12 @@ class ORIGINATOR_HAS_NO_PRIVILEGE(ResponseException): """ ORIGINATOR HAS NO PRIVILEGE Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.ORIGINATOR_HAS_NO_PRIVILEGE, dbg, data) @@ -336,6 +458,12 @@ class RECEIVER_HAS_NO_PRIVILEGES(ResponseException): """ RECEIVER HAS NO PRIVILEGES Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.RECEIVER_HAS_NO_PRIVILEGES, dbg, data) @@ -343,6 +471,12 @@ class RELEASE_VERSION_NOT_SUPPORTED(ResponseException): """ RELEASE VERSION NOT SUPPORTED Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.RELEASE_VERSION_NOT_SUPPORTED, dbg, data) @@ -350,6 +484,12 @@ class REMOTE_ENTITY_NOT_REACHABLE(ResponseException): """ REMOTE ENTITY NOT REACHABLE Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.REMOTE_ENTITY_NOT_REACHABLE, dbg, data) @@ -357,6 +497,12 @@ class REQUEST_TIMEOUT(ResponseException): """ REQUEST TIMEOUT Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.REQUEST_TIMEOUT, dbg, data) @@ -364,6 +510,12 @@ class SECURITY_ASSOCIATION_REQUIRED(ResponseException): """ SECURITY ASSOCIATION REQUIRED Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.SECURITY_ASSOCIATION_REQUIRED, dbg, data) @@ -371,6 +523,12 @@ class SERVICE_SUBSCRIPTION_NOT_ESTABLISHED(ResponseException): """ SERVICE SUBSCRIPTION NOT ESTABLISHED Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.SERVICE_SUBSCRIPTION_NOT_ESTABLISHED, dbg, data) @@ -378,6 +536,12 @@ class SUBSCRIPTION_CREATER_HAS_NO_PRIVILEGE(ResponseException): """ SUBSCRIPTION CREATER HAS NO PRIVILEGE Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.SUBSCRIPTION_CREATER_HAS_NO_PRIVILEGE, dbg, data) @@ -385,6 +549,12 @@ class SUBSCRIPTION_HOST_HAS_NO_PRIVILEGE(ResponseException): """ SUBSCRIPTION HOST HAS NO PRIVILEGE Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.SUBSCRIPTION_HOST_HAS_NO_PRIVILEGE, dbg, data) @@ -392,6 +562,12 @@ class SUBSCRIPTION_VERIFICATION_INITIATION_FAILED(ResponseException): """ SUBSCRIPTION VERIFICATION INITIATION FAILED Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.SUBSCRIPTION_VERIFICATION_INITIATION_FAILED, dbg, data) @@ -399,6 +575,12 @@ class TARGET_NOT_REACHABLE(ResponseException): """ TARGET NOT REACHABLE Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.TARGET_NOT_REACHABLE, dbg, data) @@ -406,6 +588,12 @@ class TARGET_NOT_SUBSCRIBABLE(ResponseException): """ TARGET NOT SUBSCRIBABLE Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.TARGET_NOT_SUBSCRIBABLE, dbg, data) @@ -413,6 +601,12 @@ class UNSUPPORTED_MEDIA_TYPE(ResponseException): """ UNSUPPORTED MEDIA TYPE Response Status Code. """ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None: + """ Constructor. + + Args: + dbg: An optional debug message. + data: Optional data. + """ super().__init__(ResponseStatusCode.UNSUPPORTED_MEDIA_TYPE, dbg, data) @@ -454,9 +648,13 @@ def __init__(self, dbg: Optional[str] = None, data:Optional[Any] = None) -> None def exceptionFromRSC(rsc:ResponseStatusCode) -> Optional[Type[ResponseException]]: - return _mapping.get(rsc) - - - + """ Get the exception class for a Response Status Code. + + Args: + rsc: The Response Status Code. + Returns: + The exception class or None if not found. + """ + return _mapping.get(rsc) diff --git a/acme/etc/Types.py b/acme/etc/Types.py index d87017c0..8681eceb 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -387,18 +387,32 @@ def fullname(cls, ty:int) -> str: @dataclass() class ResourceDescription(): + """ Describes a resource type. + """ typeName:str = None + """ The resource type name. """ announcedType:ResourceTypes = None + """ The announced resource type. """ isAnnouncedResource:bool= False + """ Whether the resource type is an announced resource type. """ isMgmtSpecialization:bool = False + """ Whether the resource type is a mgmtObj specialization. """ isInstanceResource:bool = False + """ Whether the resource type is an instance resource. """ isInternalType:bool = False + """ Whether the resource type is an internal type. """ virtualResourceName:str = None # If this is set then the resource is a virtual resouce + """ The name of a virtual resource. """ clazz:Resource = None # type:ignore [name-defined] + """ The resource class. """ factory:FactoryCallableT = None + """ The resource factory callable to create this resource. """ isRequestCreatable:bool = True # Can be created by a request + """ Whether the resource type can be created by a request. """ isNotificationEntity:bool = False # Is a direct notification target + """ Whether the resource type is a direct notification target. """ fullName:str = '' # Full name of the resource type + """ The full name of the resource type. """ _ResourceTypeDetails = { @@ -596,34 +610,61 @@ class BasicType(ACMEIntEnum): """ positiveInteger = auto() + """ Positive integer. """ nonNegInteger = auto() + """ Non-negative integer. """ unsignedInt = auto() + """ Unsigned integer. """ unsignedLong = auto() + """ Unsigned long. """ string = auto() + """ String. """ timestamp = auto() + """ Timestamp. """ absRelTimestamp = auto() + """ Absolute or relative timestamp. """ list = auto() + """ List. """ listNE = auto() # Not empty list + """ Not empty list. """ dict = auto() + """ Dictionary or sub-structure. """ anyURI = auto() + """ Any URI. """ boolean = auto() + """ Boolean. """ float = auto() + """ Float. """ geoJsonCoordinate = auto() + """ GeoJSON coordinate. """ integer = auto() + """ Integer. """ void = auto() + """ Void. """ duration = auto() + """ Duration. """ any = auto() + """ Any type. """ complex = auto() + """ Complex type. """ enum = auto() + """ Enumeration. """ adict = auto() # anoymous dict structure + """ Anonymous dictionary. """ base64 = auto() + """ Base64 encoded data. """ schedule = auto() # scheduleEntry + """ Schedule entry. """ ID = auto() # m2m:ID + """ oneM2M ID. """ ncname = auto() # xs:NCName + """ XML NCName. """ # aliases. Always put at the end! Seems cause confusion with python < 3.11 time = timestamp # alias type for time + """ Alias for timestamp. """ date = timestamp # alias type for date + """ Alias for timestamp. """ @classmethod def to(cls, name:str|Tuple[str], insensitive:Optional[bool] = True) -> BasicType: @@ -640,21 +681,19 @@ def to(cls, name:str|Tuple[str], insensitive:Optional[bool] = True) -> BasicType class Cardinality(ACMEIntEnum): """ Resource attribute cardinalities. - - Attributes: - CAR1: Mandatory. - CAR1L: Mandatory list. - CAR1LN: Mandatory list that shall not be empty. - CAR01: Optional. - CAR01L: Optional list. - CAR1N: Mandatory but may be Null/None. """ CAR1 = auto() + """ Mandatory. """ CAR1L = auto() + """ Mandatory list. """ CAR1LN = auto() + """ Mandatory list that shall not be empty. """ CAR01 = auto() + """ Optional. """ CAR01L = auto() + """ Optional list. """ CAR1N = auto() + """ Mandatory but may be Null/None. """ @classmethod def hasCar(cls, name:str) -> bool: @@ -707,24 +746,17 @@ def _prepare(name:str) -> str: class RequestOptionality(ACMEIntEnum): """ Request optionality enum values. - - Attributes: - NP: Not provided. - O: Optional. - M: Mandatory. """ NP = auto() + """ Not provided. """ O = auto() + """ Optional. """ M = auto() + """ Mandatory. """ class Announced(ACMEIntEnum): """ Anouncement attribute enum values. - - Attributes: - NA: Not announced. - OA: Optionally announced. - MA: Mandatory announced. """ NA = auto() @@ -785,17 +817,15 @@ def isAllowedType(self, typ:BasicType) -> bool: class EvalMode(ACMEIntEnum): - """ Eval Mode enum values. """ + """ Eval Mode enum values. + """ off = 0 """ Evaluation off. """ - once = 1 """ Evaluation once. """ - periodic = 2 """ Evaluation periodic. """ - continous = 3 """ Evaluation continous. """ @@ -807,7 +837,8 @@ class EvalMode(ACMEIntEnum): # class Permission(ACMEIntEnum): - """ Permissions """ + """ Permissions. + """ NONE = 0 """ No permission """ CREATE = 1 @@ -916,17 +947,29 @@ def toOperation(cls, v:Optional[int]) -> Optional[Operation]: class ResultContentType(ACMEIntEnum): """ Result Content Types """ nothing = 0 + """ Nothing. """ attributes = 1 + """ Resource Attributes. """ hierarchicalAddress = 2 + """ Hierarchical Address. """ hierarchicalAddressAttributes = 3 + """ Hierarchical Address and Attributes. """ attributesAndChildResources = 4 + """ Attributes and Child Resources. """ attributesAndChildResourceReferences = 5 + """ Attributes and Child Resource References. """ childResourceReferences = 6 + """ Child Resource References. """ originalResource = 7 + """ Original Resource. """ childResources = 8 + """ Child Resources. """ modifiedAttributes = 9 + """ Modified Attributes. """ semanticContent = 10 + """ Semantic Content. """ discoveryResultReferences = 11 + """ Discovery Result References. """ def validForOperation(self, op:Operation) -> bool: @@ -941,6 +984,14 @@ def validForOperation(self, op:Operation) -> bool: @classmethod def default(cls, op:Operation) -> ResultContentType: + """ Get the default Result Content for an operation. + + Args: + op: The operation to get the default Result Content for. + + Return: + The default Result Content for the operation. + """ return _ResultContentTypeDefaults[op] @@ -988,22 +1039,31 @@ def default(cls, op:Operation) -> ResultContentType: class FilterOperation(ACMEIntEnum): """ Filter Operation """ AND = 1 # default + """ AND. The default. """ OR = 2 + """ OR. """ XOR = 3 + """ XOR. """ class FilterUsage(ACMEIntEnum): """ Filter Usage """ discoveryCriteria = 1 + """ Discovery Criteria. """ conditionalRetrieval = 2 # default + """ Conditional Retrieval. The default. """ ipeOnDemandDiscovery = 3 + """ IPE On-Demand Discovery. """ discoveryBasedOperation = 4 + """ Discovery Based Operation. """ class DesiredIdentifierResultType(ACMEIntEnum): """ Desired Identifier Result Type """ structured = 1 # default + """ Structured. """ unstructured = 2 + """ Unstructured. """ ############################################################################## @@ -1014,17 +1074,25 @@ class DesiredIdentifierResultType(ACMEIntEnum): class CSEType(ACMEIntEnum): """ CSE Types """ IN = 1 + """ Infrastructure Node. """ MN = 2 + """ Middle Node. """ ASN = 3 + """ Access Node. """ class CSEStatus(ACMEIntEnum): """ CSE Status """ STOPPED = auto() + """ CSE is stopped. """ STARTING = auto() + """ CSE is starting. """ RUNNING = auto() + """ CSE is running. """ STOPPING = auto() + """ CSE is stopping. """ RESETTING = auto() + """ CSE is resetting. """ ############################################################################## # @@ -1057,10 +1125,15 @@ class ResponseType(ACMEIntEnum): class RequestStatus(ACMEIntEnum): """ Reponse Types """ COMPLETED = 1 + """ Completed. """ FAILED = 2 + """ Failed. """ PENDING = 3 + """ Pending. """ FORWARDED = 4 + """ Forwarded. """ PARTIALLY_COMPLETED = 5 + """ Partially completed. """ ############################################################################## @@ -1088,11 +1161,15 @@ class ContentSerializationType(ACMEIntEnum): """ XML = auto() + """ XML. """ JSON = auto() + """ JSON. """ CBOR = auto() + """ CBOR. """ PLAIN = auto() - NA = auto() + """ Plain text. """ UNKNOWN = auto() + """ Unknown. """ def toHeader(self) -> str: """ Return the mime header for an enum value. @@ -1176,6 +1253,9 @@ def getType(cls, t:str, default:Optional[ContentSerializationType] = None) -> Co @classmethod def supportedContentSerializations(cls) -> list[str]: """ Return a list of supported media types for content serialization. + + Return: + A list of supported media types for content serialization. """ return [ 'application/json', 'application/vnd.onem2m-res+json', @@ -1187,11 +1267,22 @@ def supportedContentSerializations(cls) -> list[str]: def supportedContentSerializationsSimple(cls) -> list[str]: """ Return a simplified (only the names of the serializations) list of supported media types for content serialization. + + Return: + A list of supported media types for content serialization. """ return [ cls.JSON.toSimple(), cls.CBOR.toSimple() ] def __eq__(self, other:object) -> bool: + """ Compare two ContentSerializationType enums for equality. + + Args: + other: The other enum to compare with. + + Return: + True if the enums are equal. + """ if not isinstance(other, str): return NotImplemented return self.value == self.getType(str(other)) @@ -1205,8 +1296,11 @@ def __eq__(self, other:object) -> bool: class ConsistencyStrategy(ACMEIntEnum): """ Consistency Strategy """ abandonMember = 1 # default + """ Abandon member. The default. """ abandonGroup = 2 + """ Abandon group. """ setMixed = 3 + """ Set mixed. """ ############################################################################## @@ -1217,10 +1311,15 @@ class ConsistencyStrategy(ACMEIntEnum): class NotificationContentType(ACMEIntEnum): """ Notification Content Types """ allAttributes = 1 + """ All Attributes. """ modifiedAttributes = 2 + """ Modified Attributes. """ ri = 3 + """ Resource Identifier. """ triggerPayload = 4 + """ Trigger Payload. """ timeSeriesNotification = 5 + """ Time Series Notification. """ class NotificationEventType(ACMEIntEnum): @@ -1608,11 +1707,17 @@ class Result: the general result, a status code, values, resources etc. """ resource:Resource = None # type: ignore # Actually this is a Resource type, but have a circular import problem. + """ Resource instance. """ data:Any|Sequence[Any]|Tuple|JSON|str = None # Anything, or list of anything, or a JSON dictionary + """ Data. """ rsc:ResponseStatusCode = ResponseStatusCode.UNKNOWN # The responseStatusCode of a Result + """ ResponseStatusCode. """ dbg:Optional[str] = None + """ Optional debug message. """ request:Optional[CSERequest] = None # may contain the processed incoming request object + """ Optional `CSERequest`. """ embeddedRequest:Optional[CSERequest] = None # May contain a request as a response, e.g. when polling + """ Optional embedded `CSERequest`. """ # def errorResultCopy(self) -> Result: @@ -2150,28 +2255,49 @@ def convertToR1Target(self, targetRvi:str) -> CSERequest: @dataclass class AttributePolicy: + """ Attribute policy for a single resource attribute. + """ # !!! DON'T CHANGE the order of the attributes! type:BasicType + """ Type of the attribute. """ cardinality:Cardinality + """ Cardinality of the attribute. """ optionalCreate:RequestOptionality + """ Optionality of the attribute for create requests. """ optionalUpdate:RequestOptionality + """ Optionality of the attribute for update requests. """ optionalDiscovery:RequestOptionality + """ Optionality of the attribute for discovery requests. """ announcement:Announced + """ Whether the attribute is announced. """ sname:str = None # short name + """ Short name of the attribute. """ lname:str = None # longname + """ Long name of the attribute. """ namespace:str = None # namespace + """ Namespace of the attribute. """ tpe:str = None # namespace:type name + """ Type name of the attribute. """ rtypes:List[ResourceTypes] = None # Optional list of multiple resourceTypes + """ List of resource types that this attribute is valid for. """ ctype:str = None # Definition for a complex type attribute + """ Definition name for a complex type attribute. """ typeName:str = None # The type as written in the definition + """ The type as written in the definition. """ fname:str = None # Name of the definition file + """ Name of the definition file. """ ltype:BasicType = None # sub-type of a list + """ Sub-type of a list as writen in the definition. """ etype:str = None # name of the enum type + """ Name of the enum type (if the attribute is of type *enum*). """ lTypeName:str = None # sub-type of a list as writen in the definition + """ Sub-type of a list as writen in the definition. """ evalues:dict[int, str] = None # Dict of enum values and interpretations + """ Dict of enum values and interpretations. """ ptype:Type = None # Implementation type of the enum values + """ Implementation type of the enum values. """ # TODO support annnouncedSyncType diff --git a/acme/helpers/BackgroundWorker.py b/acme/helpers/BackgroundWorker.py index 5766cf45..7ba52315 100644 --- a/acme/helpers/BackgroundWorker.py +++ b/acme/helpers/BackgroundWorker.py @@ -30,6 +30,35 @@ def _utcTime() -> float: class BackgroundWorker(object): """ This class provides the functionality for background worker or a single actor instance. + + Background workers are executed in a separate thread. + + They are executed periodically according to the interval. The interval is the time between + the end of the previous execution and the start of the next execution. The interval is usually + not the time betweenthe start of two consecutive executions, but this could be achieved by setting the + *runOnTime* parameter to *True*. This will compensate for the processing time of the + worker callback. + + Background workers can be stopped and started again. They can also be paused and resumed. + + Attributes: + interval: Interval in seconds to run the worker callback. + runOnTime: If True then the worker is always run *at* the interval, otherwise the interval starts *after* the worker execution. + runPastEvents: If True then runs in the past are executed, otherwise they are dismissed. + nextRunTime: Timestamp of the next execution. + callback: Callback to run as a worker. + running: True if the worker is running. + executing: True if the worker is currently executing. + name: Name of the worker. + startWithDelay: If True then start the worker after a `interval` delay. + maxCount: Maximum number runs. + numberOfRuns: Number of runs. + dispose: If True then dispose the worker after finish. + finished: Callable that is executed after the worker finished. + ignoreException: Restart the actor in case an exception is encountered. + id: Unique ID of the worker. + data: Any data structure that is stored in the worker and accessible by the *data* attribute, and which is passed as the first argument in the *_data* argument of the *workerCallback* if not *None*. + args: Additional arguments passed to the worker callback. """ __slots__ = ( @@ -72,6 +101,22 @@ def __init__(self, finished:Optional[Callable] = None, ignoreException:Optional[bool] = False, data:Optional[Any] = None) -> None: + """ Initialize a background worker. + + Args: + interval: Interval in seconds to run the worker callback. + callback: Callback to run as a worker. + name: Name of the worker. + startWithDelay: If True then start the worker after a `interval` delay. + maxCount: Maximum number runs. + dispose: If True then dispose the worker after finish. + id: Unique ID of the worker. + runOnTime: If True then the worker is always run *at* the interval, otherwise the interval starts *after* the worker execution. + runPastEvents: If True then runs in the past are executed, otherwise they are dismissed. + finished: Callable that is executed after the worker finished. + ignoreException: Restart the actor in case an exception is encountered. + data: Any data structure that is stored in the worker and accessible by the *data* attribute, and which is passed as the first argument in the *_data* argument of the *workerCallback* if not *None*. + """ self.interval = interval self.runOnTime = runOnTime # Compensate for processing time self.runPastEvents = runPastEvents # Run events that are in the past @@ -274,6 +319,11 @@ def _postCall(self) -> None: def __repr__(self) -> str: + """ Return a string representation of the worker. + + Return: + A string representation of the worker. + """ return f'BackgroundWorker(name={self.name}, callback = {str(self.callback)}, running = {self.running}, interval = {self.interval:f}, startWithDelay = {self.startWithDelay}, numberOfRuns = {self.numberOfRuns:d}, dispose = {self.dispose}, id = {self.id}, runOnTime = {self.runOnTime}, data = {self.data})' @@ -439,6 +489,8 @@ def getJob(cls, task:Callable, finished:Optional[Callable] = None, name:Optional @classmethod def _balanceJobs(cls) -> None: + """ Internal function to balance the number of paused and running jobs. + """ if not Job._balanceLatency: return Job._balanceCount += 1 @@ -466,6 +518,13 @@ def setJobBalance(cls, balanceTarget:Optional[float] = 3.0, class WorkerEntry(object): + """ Internal class for a worker entry in the priority queue. + + Attributes: + timestamp: Timestamp of the next execution. + workerID: ID of the worker. + workerName: Name of the worker. + """ __slots__ = ( 'timestamp', @@ -473,31 +532,53 @@ class WorkerEntry(object): 'workerName', ) - # timestamp:float = 0.0 - # workerID:int = None - # workerName:str = None - def __init__(self, timestamp:float, workerID:int, workerName:str) -> None: + """ Initialize a WorkerEntry. + + Args: + timestamp: Timestamp of the next execution. + workerID: ID of the worker. + workerName: Name of the worker. + """ self.timestamp = timestamp self.workerID = workerID self.workerName = workerName def __lt__(self, other:WorkerEntry) -> bool: + """ Compare two WorkerEntry objects for less-than. + + Args: + other: The other WorkerEntry object to compare with. + + Return: + True if this WorkerEntry is less than the other. + """ return self.timestamp < other.timestamp def __str__(self) -> str: + """ Return a string representation of the WorkerEntry. + + Return: + A string representation of the WorkerEntry. + """ return f'(ts: {self.timestamp} id: {self.workerID} name: {self.workerName})' def __repr__(self) -> str: + """ Return a string representation of the WorkerEntry. + + Return: + A string representation of the WorkerEntry. + """ return self.__str__() class BackgroundWorkerPool(object): """ Pool and factory for background workers and actors. """ + backgroundWorkers:Dict[int, BackgroundWorker] = {} workerQueue:list[WorkerEntry] = [] """ Priority queue. Contains tuples (next execution timestamp, worker ID, worker name). """ diff --git a/acme/helpers/KeyHandler.py b/acme/helpers/KeyHandler.py index b51fd54b..ae5bdaf8 100644 --- a/acme/helpers/KeyHandler.py +++ b/acme/helpers/KeyHandler.py @@ -16,7 +16,7 @@ from enum import Enum _timeout = 0.5 - +""" Timeout for getch() in seconds. """ try: # Posix, Linux, Mac OS @@ -27,123 +27,226 @@ class FunctionKey(str, Enum): # Common LF = '\x0a' + """ Line feed. """ CR = '\x0d' + """ Carriage return. """ SPACE = '\x20' + """ Space. """ # ESC = '\x1b' BACKSPACE = '\x7f' + """ Backspace. """ TAB = '\x09' + """ Tab. """ SHIFT_TAB = '\x1b\x5b\x5a' + """ Shift tab. """ # CTRL-Keys CTRL_A = '\x01' + """ Ctrl-A. """ CTRL_B = '\x02' + """ Ctrl-B. """ CTRL_C = '\x03' + """ Ctrl-C. """ CTRL_D = '\x04' + """ Ctrl-D. """ CTRL_E = '\x05' + """ Ctrl-E. """ CTRL_F = '\x06' + """ Ctrl-F. """ CTRL_G = '\x07' + """ Ctrl-G. """ CTRL_H = '\x08' + """ Ctrl-H. """ CTRL_I = TAB + """ Ctrl-I. Mappped to TAB. """ CTRL_J = LF + """ Ctrl-J. Mapped to Line Feed. """ CTRL_K = '\x0b' + """ Ctrl-K. """ CTRL_L = '\x0c' + """ Ctrl-L. """ CTRL_M = CR + """ Ctrl-M. Mapped to Carriage Return. """ CTRL_N = '\x0e' + """ Ctrl-N. """ CTRL_O = '\x0f' + """ Ctrl-O. """ CTRL_P = '\x10' + """ Ctrl-P. """ CTRL_Q = '\x11' + """ Ctrl-Q. """ CTRL_R = '\x12' + """ Ctrl-R. """ CTRL_S = '\x13' + """ Ctrl-S. """ CTRL_T = '\x14' + """ Ctrl-T. """ CTRL_U = '\x15' + """ Ctrl-U. """ CTRL_V = '\x16' + """ Ctrl-V. """ CTRL_W = '\x17' + """ Ctrl-W. """ CTRL_X = '\x18' + """ Ctrl-X. """ CTRL_Y = '\x19' + """ Ctrl-Y. """ CTRL_Z = '\x1a' + """ Ctrl-Z. """ # Cursor keys UP = '\x1b\x5b\x41' + """ Cursor up. """ DOWN = '\x1b\x5b\x42' + """ Cursor down. """ LEFT = '\x1b\x5b\x44' + """ Cursor left. """ RIGHT = '\x1b\x5b\x43' + """ Cursor right. """ SHIFT_UP = '\x1b\x5b\x31\x3b\x32\x41' + """ Shift cursor up. """ SHIFT_DOWN = '\x1b\x5b\x31\x3b\x32\x42' + """ Shift cursor down. """ SHIFT_RIGHT = '\x1b\x5b\x31\x3b\x32\x43' + """ Shift cursor right. """ SHIFT_LEFT = '\x1b\x5b\x31\x3b\x32\x44' + """ Shift cursor left. """ CTRL_UP = '\x1b\x5b\x31\x3b\x35\x41' + """ Ctrl cursor up. """ CTRL_DOWN = '\x1b\x5b\x31\x3b\x35\x42' + """ Ctrl cursor down. """ CTRL_RIGHT = '\x1b\x5b\x31\x3b\x35\x43' + """ Ctrl cursor right. """ CTRL_LEFT = '\x1b\x5b\x31\x3b\x35\x44' + """ Ctrl cursor left. """ ALT_UP = '\x1b\x1b\x5b\x41' + """ Alt cursor up. """ ALT_DOWN = '\x1b\x1b\x5b\x42' + """ Alt cursor down. """ ALT_RIGHT = '\x1b\x1b\x5b\x43' + """ Alt cursor right. """ ALT_LEFT = '\x1b\x1b\x5b\x44' + """ Alt cursor left. """ SHIFT_ALT_UP = '\x1b\x5b\x31\x3b\x31\x30\x41' + """ Shift Alt cursor up. """ SHIFT_ALT_DOWN = '\x1b\x5b\x31\x3b\x31\x30\x42' + """ Shift Alt cursor down. """ SHIFT_ALT_RIGHT = '\x1b\x5b\x31\x3b\x31\x30\x43' + """ Shift Alt cursor right. """ SHIFT_ALT_LEFT = '\x1b\x5b\x31\x3b\x31\x30\x44' + """ Shift Alt cursor left. """ SHIFT_CTRL_UP = '\x1b\x5b\x31\x3b\x36\x41' + """ Shift Ctrl cursor up. """ SHIFT_CTRL_DOWN = '\x1b\x5b\x31\x3b\x36\x42' + """ Shift Ctrl cursor down. """ SHIFT_CTRL_RIGHT = '\x1b\x5b\x31\x3b\x36\x43' + """ Shift Ctrl cursor right. """ SHIFT_CTRL_LEFT = '\x1b\x5b\x31\x3b\x36\x44' + """ Shift Ctrl cursor left. """ SHIFT_CTRL_ALT_UP = '\x1b\x5b\x31\x3b\x31\x34\x41' + """ Shift Ctrl Alt cursor up. """ SHIFT_CTRL_ALT_DOWN = '\x1b\x5b\x31\x3b\x31\x34\x42' + """ Shift Ctrl Alt cursor down. """ SHIFT_CTRL_ALT_RIGHT= '\x1b\x5b\x31\x3b\x31\x34\x43' + """ Shift Ctrl Alt cursor right. """ SHIFT_CTRL_ALT_LEFT = '\x1b\x5b\x31\x3b\x31\x34\x44' + """ Shift Ctrl Alt cursor left. """ # Navigation keys INSERT = '\x1b\x5b\x32\x7e' + """ Insert. """ SUPR = '\x1b\x5b\x33\x7e' + """ Supr. """ HOME = '\x1b\x5b\x48' + """ Home. """ SHIFT_HOME = '\x1b\x5b\x31\x3b\x32\x48' + """ Shift Home. """ CTRL_HOME = '\x1b\x5b\x31\x3b\x35\x48' + """ Ctrl Home. """ ALT_HOME = '\x1b\x5b\x31\x3b\x39\x48' + """ Alt Home. """ SHIFT_CTRL_HOME = '\x1b\x5b\x31\x3b\x36\x48' + """ Shift Ctrl Home. """ SHIFT_ALT_HOME = '\x1b\x5b\x31\x3b\x31\x30\x48' + """ Shift Alt Home. """ SHIFT_CTRL_ALT_HOME = '\x1b\x5b\x31\x3b\x31\x34\x48' + """ Shift Ctrl Alt Home. """ END = '\x1b\x5b\x46' + """ End. """ SHIFT_END = '\x1b\x5b\x31\x3b\x32\x46' + """ Shift End. """ CTRL_END = '\x1b\x5b\x31\x3b\x35\x46' + """ Ctrl End. """ ALT_END = '\x1b\x5b\x31\x3b\x39\x46' + """ Alt End. """ SHIFT_CTRL_END = '\x1b\x5b\x31\x3b\x36\x46' + """ Shift Ctrl End. """ SHIFT_ALT_END = '\x1b\x5b\x31\x3b\x31\x30\x46' + """ Shift Alt End. """ SHIFT_CTRL_ALT_END = '\x1b\x5b\x31\x3b\x31\x34\x46' + """ Shift Ctrl Alt End. """ PAGE_UP = '\x1b\x5b\x35\x7e' + """ Page up. """ ALT_PAGE_UP = '\x1b\x1b\x5b\x35\x7e' - + """ Alt Page up. """ PAGE_DOWN = '\x1b\x5b\x36\x7e' + """ Page down. """ ALT_PAGE_DOWN = '\x1b\x1b\x5b\x36\x7e' + """ Alt Page down. """ # Funcion keys F1 = '\x1b\x4f\x50' + """ F1. """ F2 = '\x1b\x4f\x51' + """ F2. """ F3 = '\x1b\x4f\x52' + """ F3. """ F4 = '\x1b\x4f\x53' + """ F4. """ F5 = '\x1b\x5b\x31\x35\x7e' + """ F5. """ F6 = '\x1b\x5b\x31\x37\x7e' + """ F6. """ F7 = '\x1b\x5b\x31\x38\x7e' + """ F7. """ F8 = '\x1b\x5b\x31\x39\x7e' + """ F8. """ F9 = '\x1b\x5b\x32\x30\x7e' + """ F9. """ F10 = '\x1b\x5b\x32\x31\x7e' + """ F10. """ F11 = '\x1b\x5b\x32\x33\x7e' + """ F11. """ F12 = '\x1b\x5b\x32\x34\x7e' + """ F12. """ SHIFT_F1 = '\x1b\x5b\x31\x3b\x32\x50' + """ Shift F1. """ SHIFT_F2 = '\x1b\x5b\x31\x3b\x32\x51' + """ Shift F2. """ SHIFT_F3 = '\x1b\x5b\x31\x3b\x32\x52' + """ Shift F3. """ SHIFT_F4 = '\x1b\x5b\x31\x3b\x32\x53' + """ Shift F4. """ SHIFT_F5 = '\x1b\x5b\x31\x35\x3b\x32\x7e' + """ Shift F5. """ SHIFT_F6 = '\x1b\x5b\x31\x37\x3b\x32\x7e' + """ Shift F6. """ SHIFT_F7 = '\x1b\x5b\x31\x38\x3b\x32\x7e' + """ Shift F7. """ SHIFT_F8 = '\x1b\x5b\x31\x39\x3b\x32\x7e' + """ Shift F8. """ SHIFT_F9 = '\x1b\x5b\x32\x30\x3b\x32\x7e' + """ Shift F9. """ SHIFT_F10 = '\x1b\x5b\x32\x31\x3b\x32\x7e' + """ Shift F10. """ SHIFT_F11 = '\x1b\x5b\x32\x33\x3b\x32\x7e' + """ Shift F11. """ SHIFT_F12 = '\x1b\x5b\x32\x34\x3b\x32\x7e' + """ Shift F12. """ except ImportError: @@ -302,15 +405,18 @@ class FunctionKey(str, Enum): # type: ignore[no-redef] _errorInGetch:bool = False def getch() -> Optional[str|FunctionKey]: - """getch() -> key character + """ getch() -> key character + + Read a single keypress from stdin and return the resulting character. + Nothing is echoed to the console. This call will block if a keypress + is not already available, but will not wait for Enter to be pressed. - Read a single keypress from stdin and return the resulting character. - Nothing is echoed to the console. This call will block if a keypress - is not already available, but will not wait for Enter to be pressed. + If the pressed key was a modifier key, nothing will be detected; if + it were a special function key, it may return the first character of + of an escape sequence, leaving additional characters in the buffer. - If the pressed key was a modifier key, nothing will be detected; if - it were a special function key, it may return the first character of - of an escape sequence, leaving additional characters in the buffer. + Returns: + A single character str or a FunctionKey enum value. """ global _errorInGetch if _errorInGetch: # getch() doesnt't fully work previously, so just return @@ -339,7 +445,7 @@ def flushInput() -> None: sys.stdin.flush() _functionKeys:Tuple[FunctionKey, str] = [(e, e.value) for e in FunctionKey] # type:ignore -# TODO +""" List of all function keys. """ Commands = Dict[str, Callable[[str], None]] """ Mapping between characters and callback functions. """ @@ -493,6 +599,12 @@ def stopLoop() -> None: def readline(prompt:str='>') -> str: """ Read a line from the console. Catch EOF (^D) and Keyboard Interrup (^C). I that case None is returned. + + Args: + prompt: The prompt to display before the input. + + Returns: + The input line or None. """ answer = None try: @@ -504,6 +616,15 @@ def readline(prompt:str='>') -> str: return answer def waitForKeypress(s:float) -> Optional[str]: + """ Wait for a keypress for a maximum of *s* seconds. + If no key was pressed then return None. + + Args: + s: Maximum time to wait in seconds. + + Returns: + The key that was pressed or None. + """ for i in range(0, int(s * 1.0 / _timeout)): ch = None try: diff --git a/acme/helpers/OAuth.py b/acme/helpers/OAuth.py index a3c80728..c576031d 100644 --- a/acme/helpers/OAuth.py +++ b/acme/helpers/OAuth.py @@ -4,8 +4,8 @@ # (c) 2021 by Andreas Kraft # License: BSD 3-Clause License. See the LICENSE file for further details. # -# This module implements OAuth token retrieval. -# +""" This module implements OAuth token retrieval. +""" from __future__ import annotations from typing import Optional @@ -13,7 +13,10 @@ import requests Token = collections.namedtuple('Token', 'token expiration') +""" A named tuple for a token. """ + _expirationLeeway:float = 5.0 # 5 seconds leeway for token expiration +""" Leeway for token expiration. """ def getOAuthToken(serverURL:str, @@ -26,6 +29,16 @@ def getOAuthToken(serverURL:str, This function returns a new named tuple Token(token, expiration), or None in case of an error. The expiration is in epoch seconds. + + Args: + serverURL: The URL of the OAuth server. + clientID: The client ID. + clientSecret: The client secret. + token: Optional token to check if it is still valid. + kind: The kind of OAuth server. Currently only 'keycloak' is supported. + + Returns: + A Token tuple or None in case of an error. """ if not token: token = Token(token = None, expiration=0.0) diff --git a/acme/helpers/OrderedSet.py b/acme/helpers/OrderedSet.py index 543b7a09..d5229f0d 100644 --- a/acme/helpers/OrderedSet.py +++ b/acme/helpers/OrderedSet.py @@ -4,11 +4,10 @@ # (c) 2023 by Andreas Kraft # License: BSD 3-Clause License. See the LICENSE file for further details. # +""" Simple implementation of an ordered set.""" from typing import Any -""" Simple implementation of an ordered set.""" - class OrderedSet(list): """ Simple implementation of an ordered set. diff --git a/acme/services/Configuration.py b/acme/services/Configuration.py index 2fdff1ad..667cd604 100644 --- a/acme/services/Configuration.py +++ b/acme/services/Configuration.py @@ -6,6 +6,8 @@ # # Managing CSE configurations # +""" This module implements the configuration of the CSE. It reads the configuration file, performs checks, + and provides access to the configuration values. """ from __future__ import annotations @@ -51,6 +53,7 @@ 'textui': 'https://github.com/ankraft/ACME-oneM2M-CSE/blob/master/docs/Configuration.md#textui', 'webui': 'https://github.com/ankraft/ACME-oneM2M-CSE/blob/master/docs/Configuration.md#webui', } +""" Documentation links for configuration settings. These are used in the console and text UIto show the documentation for a configuration setting. """ # # Deprecated secttions @@ -74,6 +77,7 @@ ('cse.textui', 'textui'), ('cse.scripting', 'scripting') ) +""" Deprecated sections. Mapping from old section name to new section name.""" @@ -82,36 +86,67 @@ class Configuration(object): method init(). Access to configuration valus is done by calling Configuration.get(). """ _configuration: Dict[str, Any] = {} + """ The configuration values as a dictionary. """ _configurationDocs: Dict[str, str] = {} + """ The configuration values documentation as a dictionary. """ _defaultConfigFile:str = None + """ The default configuration file. """ _argsConfigfile:str = None + """ The configuration file passed as argument. This overrides the respective value in the configuration file. """ _argsLoglevel:str = None + """ The log level passed as argument. This overrides the respective value in the configuration file. """ _argsDBReset:bool = None + """ The reset DB flag passed as argument. This overrides the respective value in the configuration file. """ _argsDBStorageMode:str = None + """ The DB storage mode passed as argument. This overrides the respective value in the configuration file. """ _argsHeadless:bool = None + """ The headless flag passed as argument. This overrides the respective value in the configuration file. """ _argsHttpAddress:str = None + """ The http address passed as argument. This overrides the respective value in the configuration file. """ _argsHttpPort:int = None + """ The http port passed as argument. This overrides the respective value in the configuration file. """ _argsImportDirectory:str = None + """ The import directory passed as argument. This overrides the respective value in the configuration file. """ _argsListenIF:str = None + """ The network interface passed as argument. This overrides the respective value in the configuration file. """ _argsMqttEnabled:bool = None + """ The mqtt enabled flag passed as argument. This overrides the respective value in the configuration file. """ _argsRemoteCSEEnabled:bool = None + """ The remote CSE enabled flag passed as argument. This overrides the respective value in the configuration file. """ _argsRunAsHttps:bool = None + """ The https flag passed as argument. This overrides the respective value in the configuration file. """ _argsRunAsHttpWsgi:bool = None + """ The http WSGI flag passed as argument. This overrides the respective value in the configuration file. """ _argsStatisticsEnabled:bool = None + """ The statistics enabled flag passed as argument. This overrides the respective value in the configuration file. """ _argsTextUI:bool = None + """ The text UI flag passed as argument. This overrides the respective value in the configuration file. """ # Internal print function that takes the headless setting into account @staticmethod def _print(msg:str) -> None: + """ Print a message to the console. If the CSE is running in headless mode, then the message is not printed. + + Args: + msg: The message to print. + """ if not Configuration._argsHeadless: Console().print(msg) # Print error message to console @staticmethod - def init(args:argparse.Namespace = None) -> bool: + def init(args:Optional[argparse.Namespace] = None) -> bool: + """ Initialize and read the configuration. This method must be called before accessing any configuration value. + + Args: + args: Optional arguments. If not given, then the command line arguments are used. + + Returns: + True on success, False otherwise. + """ # The default ini file Configuration._defaultConfigFile = f'{pathlib.Path.cwd()}{os.sep}{C.defaultConfigFile}' @@ -511,13 +546,31 @@ def init(args:argparse.Namespace = None) -> bool: @staticmethod def validate(initial:Optional[bool] = False) -> Tuple[bool, str]: + """ Validates the configuration and returns a tuple (bool, str) with the result and an error message if applicable. + + Args: + initial: True if this is the initial validation during startup, False otherwise. Default: False + + Returns: + A tuple (bool, str) with the result and an error message if applicable. + """ # Some clean-ups and overrides def _get(key:str) -> Any: + """ Helper function to retrieve a configuration value. If the value is not found, None is returned. + + Args: + key: The configuration key to retrieve. + """ return Configuration.get(key) def _put(key:str, value:Any) -> None: + """ Helper function to set a configuration value. + + Args: + key: The configuration key to set. + """ Configuration._configuration[key] = value @@ -785,6 +838,11 @@ def _put(key:str, value:Any) -> None: @staticmethod def print() -> str: + """ Prints the current configuration to the console. + + Returns: + A string with the current configuration. + """ result = 'Configuration:\n' # Magic string used e.g. in tests, don't remove for (k,v) in Configuration._configuration.items(): result += f' {k} = {v}\n' @@ -793,24 +851,49 @@ def print() -> str: @staticmethod def all() -> Dict[str, Any]: + """ Returns the complete configuration as a dictionary. + + Returns: + A dictionary with the complete configuration. + """ return Configuration._configuration @staticmethod def get(key: str) -> Any: """ Retrieve a configuration value or None if no configuration could be found for a key. + + Args: + key: The configuration key to retrieve. + + Returns: + The configuration value or None if no configuration could be found for a key. """ return Configuration._configuration.get(key) @staticmethod def addDoc(key: str, markdown:str) -> None: + """ Adds a documentation for a configuration key. + + Args: + key: The configuration key to add the documentation for. + markdown: The documentation in markdown format. + """ if key: Configuration._configurationDocs[key] = markdown @staticmethod - def getDoc(key:str) -> str|None: + def getDoc(key:str) -> Optional[str]: + """ Retrieves the documentation for a configuration key. + + Args: + key: The configuration key to retrieve the documentation for. + + Returns: + The documentation in markdown format or None if no documentation could be found for the key. + """ return Configuration._configurationDocs.get(key) @@ -818,6 +901,10 @@ def getDoc(key:str) -> str|None: def update(key:str, value:Any) -> Optional[str]: """ Update a configuration value and inform other components via an event. + Args: + key: The configuration key to update. + value: The new value for the configuration key. + Returns: None if no error occurs, or a string with an error message, what has gone wrong while validating """ @@ -840,6 +927,12 @@ def update(key:str, value:Any) -> Optional[str]: @staticmethod def has(key:str) -> bool: """ Check whether a configuration setting exsists. + + Args: + key: The configuration key to check. + + Returns: + True if the configuration key exists, False otherwise. """ return key in Configuration._configuration From b8e110d65c2f073d42eecd14d6b693aa9614d4eb Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 19 Oct 2023 17:36:13 +0200 Subject: [PATCH 154/165] Added FAQ for 64 bit OS and terminals --- docs/FAQ.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/FAQ.md b/docs/FAQ.md index 8e5691db..ec2ae93f 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -98,24 +98,37 @@ writeDelay=10 ``` + ## Web UI 1. **Can I use the web UI also with other CSE implementations?** The web UI can also be run as an independent application. Since it communicates with the CSE via the Mca interface it should be possible to use it with other CSE implementations as well as long as those third party CSEs follow the oneM2M http binding specification. It only supports the resource types that the ACME CSE supports, but at least it will present all other resource types as *unknown*. -## Console +## Console and Text UI 1. **Some of the tables, text graphics etc are not aligned or correctly displayed in the console** Some mono-spaced fonts don't work well with UTF-8 character sets and graphic elements. Especially the MS Windows *cmd.exe* console seems to have problems. Try one of the more extended fonts like *JuliaMono* or *DejaVu Sans Mono*. +1. **There is an error message "UnicodeEncodeError: 'latin-1' codec can't encode character"** + This error message is shown when the console tries to display a character that is not supported by the current console encoding. Try to set the console encoding to UTF-8 by setting the environment variable *PYTHONIOENCODING* to *utf-8*, for example: + ```bash + export PYTHONIOENCODING=utf-8 + ``` + ## Operating Systems ### RaspberryPi -1. **Restrictions** +1. **Restrictions on 32 bit Systems** Currently, the normally installed Raspbian OS is a 32 bit system. This means that several restrictions apply here, such as the maximum date supported (~2038). It needs to be determined whether these restrictions still apply when the 64 bit version of Raspbian is available. +1. **The console or the text UI is not displayed correctly** + It could be that the OS's terminal applications doesn't support rendering of extra characters, like line graphics. One recommendation on Linux systems is to install the [Mate Terminal](https://wiki.mate-desktop.org/mate-desktop/applications/mate-terminal/), which supports UTF-8 and line graphics. It also renders the output much faster. + + ```bash + sudo apt-get install mate-terminal + ``` 1. **Timing Issues** Also, the resolution of the available Python timers is rather low on Raspbian, and background tasks might not run exactly on the desired time. Unfortunately, this is also why sometimes a couple of the CSE's tests cases may fail randomly. From 771ffca13e1e7164d26eb51676597facf2b193fb Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 19 Oct 2023 21:18:07 +0200 Subject: [PATCH 155/165] More documentation --- acme/etc/ResponseStatusCodes.py | 24 ++++++++------ acme/etc/Types.py | 13 ++++++-- acme/helpers/Interpreter.py | 56 ++++++++++++++++----------------- acme/resources/ACP.py | 26 +++++++-------- acme/resources/CIN.py | 6 ++++ acme/resources/CINAnnc.py | 4 +++ acme/resources/CNT.py | 5 +++ acme/resources/CNTAnnc.py | 4 +++ acme/resources/CSEBase.py | 6 +++- acme/resources/CSEBaseAnnc.py | 1 + acme/resources/RBO.py | 3 ++ acme/resources/RBOAnnc.py | 3 ++ 12 files changed, 98 insertions(+), 53 deletions(-) diff --git a/acme/etc/ResponseStatusCodes.py b/acme/etc/ResponseStatusCodes.py index 109019b2..04df5108 100644 --- a/acme/etc/ResponseStatusCodes.py +++ b/acme/etc/ResponseStatusCodes.py @@ -100,8 +100,8 @@ class ResponseStatusCode(ACMEIntEnum): INSUFFICIENT_ARGUMENTS = 6024 """ INSUFFICIENT_ARGUMENTS """ - UNKNOWN = -1 + """ UNKNOWN """ def httpStatusCode(self) -> int: @@ -158,6 +158,7 @@ def httpStatusCode(self) -> int: ResponseStatusCode.UNKNOWN : HTTPStatus.NOT_IMPLEMENTED, # NOT IMPLEMENTED } +""" Mapping of oneM2M return codes to http status codes. """ _successRSC = ( ResponseStatusCode.ACCEPTED, @@ -168,21 +169,23 @@ def httpStatusCode(self) -> int: ResponseStatusCode.DELETED, ResponseStatusCode.UPDATED, ) +""" The list of success response status codes. """ def isSuccessRSC(rsc:ResponseStatusCode) -> bool: + """ Check whether a response status code is a success code. + + Args: + rsc: The response status code to check. + + Returns: + True if the response status code is a success code, False otherwise. +""" return rsc in _successRSC class ResponseException(Exception): - """ Base class for CSE Exceptions. - - Attributes: - rsc: The response status code. - dbg: An optional debug message. - error: This is an error-related exception. - data: Optional data. - """ + """ Base class for CSE Exceptions.""" def __init__(self, rsc:ResponseStatusCode, dbg:Optional[str] = None, @@ -196,8 +199,11 @@ def __init__(self, rsc:ResponseStatusCode, """ super().__init__() self.rsc = rsc + """ The response status code. """ self.dbg = dbg + """ An optional debug message. """ self.data = data + """ Optional data. """ class ALREADY_EXISTS(ResponseException): diff --git a/acme/etc/Types.py b/acme/etc/Types.py index 8681eceb..ed6f23e9 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -2315,12 +2315,15 @@ def select(self, index:int) -> Optional[Any]: return None -""" Represent a dictionary of attribute policies used in validation. """ AttributePolicyDict = Dict[str, AttributePolicy] +""" Represent a dictionary of attribute policies used in validation. """ ResourceAttributePolicyDict = Dict[Tuple[Union[ResourceTypes, str], str], AttributePolicy] +""" Represent a dictionary of attribute policies used in validation. """ FlexContainerAttributes = Dict[str, Dict[str, AttributePolicy]] +""" Type definition for a dictionary of attribute policies for a flexContainer. """ FlexContainerSpecializations = Dict[str, str] +""" Type definition for a dictionary of specializations for a flexContainer. """ ############################################################################## @@ -2330,17 +2333,23 @@ def select(self, index:int) -> Optional[Any]: Parameters = Dict[str, str] -Attributes = Dict[str, Any] +""" Type definition for a dictionary of parameters. """ JSON = Dict[str, Any] +""" Type definition for a JSON type, which is just a dictionary. """ JSONLIST = List[JSON] +""" Type definition for a list of JSON types. """ ReqResp = Dict[str, Union[int, str, List[str], JSON]] +""" Type definition for a dictionary of request/response parameters. """ RequestCallback = namedtuple('RequestCallback', 'ownRequest dispatcherRequest sendRequest httpEvent mqttEvent') +""" Type definition for a callback function to handle outgoing requests. """ RequestHandler = Dict[Operation, RequestCallback] """ Type definition for a map between operations and handler for outgoing request operations. """ RequestResponse = namedtuple('RequestResponse', 'request result') +""" Type definition for a request/response pair. """ RequestResponseList = List[RequestResponse] +""" Type definition for a list of request/response pairs. """ FactoryCallableT = Callable[ [ Dict[str, object], str, str, bool], object ] """ Type definition for a factory callback to create and initializy a Resource instance. """ \ No newline at end of file diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index 7c1890cd..e48a5ff9 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -704,33 +704,7 @@ class PCall(): class PContext(): - """ Process context for a single script. Can be re-used. - - Attributes: - argv: List of string that are arguments to the script. - ast: The script' abstract syntax tree. - environment: Dictionary of variables that are passed by the application to the script. Similar to `variables`, but the environment is not cleared. - error: Error state. - errorFunc: An optional function that is called when an error occured. - evaluateInline: Check and execute inline expressions in strings. - functions: Dictoonary of defined script functions. - logErrorFunc: An optional function that receives error log messages. - logFunc: An optional function that receives non-error log messages. - matchFunc: An optional function that is used to run regex comparisons. - maxRuntime: Number of seconds that is a script allowed to run. - meta: Dictionary of the script's meta tags and their arguments. - postFunc: An optional function that is called after running a script. - preFunc: An optional function that is called before running a script. - printFunc: An optional function for printing messages to the screen, console, etc. - result: Intermediate and final results during the execution. - script: The script to run. - state: The internal state of a script. - symbols: A dictionary of new symbols / functions to add to the interpreter. - _variables: Dictionary of variables. - _maxRTimestamp: The max timestamp until the script may run (internal). - _callStack: The internal call stack (internal). - _symbols: Dictionary with all build-in and provided functions (internal). - """ + """ Process context for a single script. Can be re-used. """ __slots__ = ( 'script', @@ -803,35 +777,61 @@ def __init__(self, # Extra parameters that can be provided self.script = script + """ The script to run. """ self.symbols = _builtinCommands + """ A dictionary of new symbols / functions to add to the interpreter. """ self.logFunc = logFunc + """ An optional function that receives non-error log messages. """ self.logErrorFunc = logErrorFunc + """ An optional function that receives error log messages. """ self.printFunc = printFunc + """ An optional function for printing messages to the screen, console, etc. """ self.preFunc = preFunc + """ An optional function that is called before running a script. """ self.postFunc = postFunc + """ An optional function that is called after running a script. """ self.errorFunc = errorFunc + """ An optional function that is called when an error occured. """ self.matchFunc = matchFunc + """ An optional function that is used to run regex comparisons. """ self.maxRuntime = maxRuntime + """ Number of seconds that is a script allowed to run. """ self.fallbackFunc = fallbackFunc + """ An optional function to retrieve unknown symbols from the caller. """ self.monitorFunc = monitorFunc + """ An optional function to monitor function calls, e.g. to forbid them during particular executions. """ self.allowBrackets = allowBrackets + """ Allow "[" and "]" for opening and closing lists as well. """ # State, result and error attributes self.ast:list[SSymbol] = None + """ The script's abstract syntax tree.""" self.result:SSymbol = None + """ Intermediate and final results during the execution. """ self.verbose:bool = verbose + """ Print more debug messages. """ self.state:PState = PState.created + """ The internal state of a script.""" self.error:PErrorState = PErrorState(PError.noError, 0, '', None ) + """ Error state. """ self.meta:Dict[str, str] = {} + """ Dictionary of the script's meta tags and their arguments. """ self.functions:dict[str, FunctionDefinition] = {} - self.environment:Dict[str,SSymbol] = {} # Similar to variables, but not cleared + """ Dictoonary of defined script functions. """ + self.environment:Dict[str, SSymbol] = {} # Similar to variables, but not cleared + """ Dictionary of variables that are passed by the application to the script. Similar to `variables`, but the environment is not cleared. """ self.argv:list[str] = [] + """ List of string that are arguments to the script. """ self.evaluateInline = True # check and execute inline expressions + """ Check and execute inline expressions in strings. """ # Internal attributes that should not be accessed from extern self._maxRTimestamp:float = None + """ The max timestamp until the script may run (internal). """ self._callStack:list[PCall] = [] + """ The internal call stack (internal). """ self._symbols:PSymbolDict = None # builtins + provided commands + """ Dictionary with all build-in and provided functions (internal). """ # self._variables:Dict[str, SSymbol] = {} diff --git a/acme/resources/ACP.py b/acme/resources/ACP.py index 322edd73..91a62f3e 100644 --- a/acme/resources/ACP.py +++ b/acme/resources/ACP.py @@ -77,24 +77,15 @@ def validate(self, originator:Optional[str] = None, if not self.pvs: raise BAD_REQUEST('pvs must not be empty') - # Check acod - # TODO Is this still necessary? Check in resource validation? - def _checkAcod(acrs:list) -> None: - if acrs: - for acr in acrs: - if (acod := acr.get('acod')): - for each in acod: - if not (chty := each.get('chty')) or not isinstance(chty, list): - raise BAD_REQUEST('chty is mandatory in acod') - - _checkAcod(findXPath(dct, f'{ResourceTypes.ACPAnnc.tpe()}/pv/acr')) - _checkAcod(findXPath(dct, f'{ResourceTypes.ACPAnnc.tpe()}/pvs/acr')) - # Get types for the acor members. Ignore if not found # This is an optimization used later in case there is a group in acor riTyDict = {} def _getAcorTypes(pv:JSON) -> None: + """ Get the types of the acor members. + Args: + pv: The pv attribute to get the types for. + """ if pv: for acr in pv.get('acr', []): if (acor := acr.get('acor')): @@ -238,6 +229,15 @@ def checkSelfPermission(self, originator:str, requestedPermission:Permission) -> def _checkAcor(self, acor:list[str], originator:str) -> bool: + """ Check whether an originator is in the list of acor entries. + + Args: + acor: The list of acor entries. + originator: The originator to check. + + Return: + True if the originator is in the list of acor entries, False otherwise. + """ # Check originator if 'all' in acor or \ diff --git a/acme/resources/CIN.py b/acme/resources/CIN.py index ff483b92..37e63441 100644 --- a/acme/resources/CIN.py +++ b/acme/resources/CIN.py @@ -6,6 +6,8 @@ # # ResourceType: ContentInstance # +""" ContentInstance (CIN) resource type. +""" from __future__ import annotations from typing import Optional @@ -20,9 +22,12 @@ class CIN(AnnounceableResource): + """ ContentInstance resource type. + """ # Specify the allowed child-resource types _allowedChildResourceTypes:list[ResourceTypes] = [ ResourceTypes.SMD ] + """ The allowed child-resource types. """ # Attributes and Attribute policies for this Resource Class # Assigned during startup in the Importer @@ -57,6 +62,7 @@ class CIN(AnnounceableResource): 'dcnt': None, 'dgt': None } + """ Attributes and `AttributePolicy` for this resource type. """ def __init__(self, dct:Optional[JSON] = None, diff --git a/acme/resources/CINAnnc.py b/acme/resources/CINAnnc.py index fb993961..6e61d2c7 100644 --- a/acme/resources/CINAnnc.py +++ b/acme/resources/CINAnnc.py @@ -6,6 +6,7 @@ # # CIN : Announceable variant # +""" ContentInstance announced (CINA) resource type.""" from __future__ import annotations from typing import Optional @@ -14,9 +15,11 @@ class CINAnnc(AnnouncedResource): + """ ContentInstance announced (CINA) resource type. """ # Specify the allowed child-resource types _allowedChildResourceTypes:list[ResourceTypes] = [ ] + """ The allowed child-resource types. """ # Attributes and Attribute policies for this Resource Class # Assigned during startup in the Importer @@ -41,6 +44,7 @@ class CINAnnc(AnnouncedResource): 'or': None, 'conr': None } + """ Attributes and `AttributePolicy` for this resource type. """ def __init__(self, dct:Optional[JSON] = None, diff --git a/acme/resources/CNT.py b/acme/resources/CNT.py index 65cad4da..85ea71bd 100644 --- a/acme/resources/CNT.py +++ b/acme/resources/CNT.py @@ -6,6 +6,8 @@ # # ResourceType: Container # +""" Container (CNT) resource type. +""" from __future__ import annotations from typing import Optional, cast @@ -23,6 +25,7 @@ class CNT(ContainerResource): + """ Container resource type. """ _allowedChildResourceTypes = [ ResourceTypes.ACTR, ResourceTypes.CNT, @@ -33,6 +36,7 @@ class CNT(ContainerResource): ResourceTypes.TS, ResourceTypes.CNT_LA, ResourceTypes.CNT_OL ] + """ The allowed child-resource types. """ # Attributes and Attribute policies for this Resource Class # Assigned during startup in the Importer @@ -69,6 +73,7 @@ class CNT(ContainerResource): # EXPERIMENTAL 'subi': None, } + """ Attributes and `AttributePolicy` for this resource type. """ def __init__(self, dct:Optional[JSON] = None, diff --git a/acme/resources/CNTAnnc.py b/acme/resources/CNTAnnc.py index bbd99988..760953da 100644 --- a/acme/resources/CNTAnnc.py +++ b/acme/resources/CNTAnnc.py @@ -6,6 +6,7 @@ # # CNT : Announceable variant # +""" Container announced (CNTA) resource type.""" from __future__ import annotations from typing import Optional @@ -15,6 +16,7 @@ class CNTAnnc(AnnouncedResource): + """ Container announced (CNTA) resource type. """ # Specify the allowed child-resource types _allowedChildResourceTypes = [ ResourceTypes.ACTR, @@ -28,6 +30,7 @@ class CNTAnnc(AnnouncedResource): ResourceTypes.SUB, ResourceTypes.TS, ResourceTypes.TSAnnc ] + """ The allowed child-resource types. """ # Attributes and Attribute policies for this Resource Class # Assigned during startup in the Importer @@ -55,6 +58,7 @@ class CNTAnnc(AnnouncedResource): 'or': None, 'disr': None } + """ Attributes and `AttributePolicy` for this resource type. """ def __init__(self, dct:Optional[JSON] = None, diff --git a/acme/resources/CSEBase.py b/acme/resources/CSEBase.py index 59526c13..eda5fc08 100644 --- a/acme/resources/CSEBase.py +++ b/acme/resources/CSEBase.py @@ -6,6 +6,7 @@ # # ResourceType: CSEBase # +""" CSEBase (CSEBase) resource type. """ from __future__ import annotations from typing import Optional @@ -22,6 +23,7 @@ # TODO notificationCongestionPolicy class CSEBase(AnnounceableResource): + """ CSEBase (CSEBase) resource type. """ # Specify the allowed child-resource types _allowedChildResourceTypes = [ ResourceTypes.ACP, @@ -40,6 +42,7 @@ class CSEBase(AnnounceableResource): ResourceTypes.TS, ResourceTypes.TSB, ResourceTypes.CSEBaseAnnc ] + """ The allowed child-resource types. """ # Attributes and Attribute policies for this Resource Class # Assigned during startup in the Importer @@ -67,6 +70,8 @@ class CSEBase(AnnounceableResource): 'csz': None, 'ctm': None, } + """ Represent a dictionary of attribute policies used in validation. """ + def __init__(self, dct:JSON, create:Optional[bool] = False) -> None: @@ -144,5 +149,4 @@ def getCSE() -> CSEBase: # Actual: CSEBase Resource Return: resource. """ - #return CSE.dispatcher.retrieveResource(CSE.cseRi) return resourceFromCSI(CSE.cseCsi) diff --git a/acme/resources/CSEBaseAnnc.py b/acme/resources/CSEBaseAnnc.py index 0502279a..aab3e984 100644 --- a/acme/resources/CSEBaseAnnc.py +++ b/acme/resources/CSEBaseAnnc.py @@ -6,6 +6,7 @@ # # CNT : Announceable variant # +""" CSEBase announced (CSEBaseA) resource type. """ from __future__ import annotations from typing import Optional diff --git a/acme/resources/RBO.py b/acme/resources/RBO.py index e0248666..1ad3a479 100644 --- a/acme/resources/RBO.py +++ b/acme/resources/RBO.py @@ -6,6 +6,7 @@ # # ResourceType: mgmtObj:Reboot # +""" MgmtObj:Reboot (RBO) resource type.""" from __future__ import annotations from typing import Optional @@ -17,6 +18,7 @@ from ..helpers.TextTools import findXPath class RBO(MgmtObj): + """ MgmtObj:Reboot (RBO) resource type. """ # Attributes and Attribute policies for this Resource Class # Assigned during startup in the Importer @@ -49,6 +51,7 @@ class RBO(MgmtObj): 'rbo': None, 'far': None } + """ The allowed attributes and their policy for this resource type.""" def __init__(self, dct:Optional[JSON] = None, diff --git a/acme/resources/RBOAnnc.py b/acme/resources/RBOAnnc.py index 379d76db..20a6a659 100644 --- a/acme/resources/RBOAnnc.py +++ b/acme/resources/RBOAnnc.py @@ -6,6 +6,7 @@ # # RBO : Announceable variant # +""" MgmtObj:Reboot announced (RBOA) resource type. """ from __future__ import annotations from typing import Optional @@ -15,6 +16,7 @@ class RBOAnnc(MgmtObjAnnc): + """ MgmtObj:Reboot announced (RBOA) resource type. """ # Attributes and Attribute policies for this Resource Class # Assigned during startup in the Importer @@ -45,6 +47,7 @@ class RBOAnnc(MgmtObjAnnc): 'rbo': None, 'far': None } + """ The allowed attributes and their policy for this resource type.""" def __init__(self, dct:Optional[JSON] = None, From ea35c689a3363a2c37d45b9c45de43bda47366a3 Mon Sep 17 00:00:00 2001 From: ankraft Date: Thu, 19 Oct 2023 21:18:19 +0200 Subject: [PATCH 156/165] Bumped urllib3 version --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 6111394c..f58a50c3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -91,7 +91,7 @@ typing-extensions==4.8.0 # via textual uc-micro-py==1.0.2 # via linkify-it-py -urllib3==2.0.6 +urllib3==2.0.7 # via requests waitress==2.1.2 # via ACME-oneM2M-CSE (setup.py) From d9664ff8565c262b15bff5c4f3e6a3d64fd52b57 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 20 Oct 2023 00:21:37 +0200 Subject: [PATCH 157/165] Documentation --- acme/helpers/MQTTConnection.py | 192 +++++++++++++++++++++++++++++++-- acme/helpers/NetworkTools.py | 25 +++++ 2 files changed, 211 insertions(+), 6 deletions(-) diff --git a/acme/helpers/MQTTConnection.py b/acme/helpers/MQTTConnection.py index 9cd870da..c63e9acd 100644 --- a/acme/helpers/MQTTConnection.py +++ b/acme/helpers/MQTTConnection.py @@ -6,6 +6,7 @@ # # Implementation of an MQTT Client helper class. # +""" Implementation of an MQTT Client helper class. """ from __future__ import annotations from typing import Callable, Any, Tuple, Optional @@ -24,10 +25,15 @@ class MQTTTopic: """ Structure that represents a subscribed-to topic. """ topic:str = None + """ The MQTT topic. """ mid:int = None + """ The message ID of the MQTT subscription. """ isSubscribed:bool = False + """ Whether the topic is subscribed to. """ callback:MQTTCallback = None + """ The callback function for the topic. """ callbackArgs:dict = None + """ The callback arguments for the topic. """ class MQTTHandler(object): @@ -43,38 +49,88 @@ def onConnect(self, connection:MQTTConnection) -> bool: """ This method is called after the MQTT client connected to the MQTT broker. Usually, an MQTT client should subscribe to topics and register the callback methods here. + + Args: + connection: The MQTT connection. + + Returns: + True if successful, False otherwise. """ return True + def onDisconnect(self, connection:MQTTConnection) -> bool: """ This method is called after the MQTT client disconnected from the MQTT broker. + + Args: + connection: The MQTT connection. + + Returns: + True if successful, False otherwise. """ return True + def onSubscribed(self, connection:MQTTConnection, topic:str) -> bool: """ This method is called after the MQTT client successfully subsribed to a topic. + + Args: + connection: The MQTT connection. + topic: The topic that was subscribed to. + + Returns: + True if successful, False otherwise. """ connection.subscribedCount += 1 return True + def onUnsubscribed(self, connection:MQTTConnection, topic:str) -> bool: """ This method is called after the MQTT client successfully unsubsribed from a topic. + + Args: + connection: The MQTT connection. + topic: The topic that was unsubscribed from. + + Returns: + True if successful, False otherwise. """ connection.subscribedCount -= 1 return True + def onError(self, connection:MQTTConnection, rc:int) -> bool: """ This method is called when receiving an error when communicating with the MQTT broker. + + Args: + connection: The MQTT connection. + rc: The error code. + + Returns: + True if successful, False otherwise. """ return True + def logging(self, connection:MQTTConnection, level:int, message:str) -> bool: """ This method is called when a log message should be handled. + + Args: + connection: The MQTT connection. + level: The log level. + message: The log message. + + Returns: + True if successful, False otherwise. """ return True + def onShutdown(self, connection:MQTTConnection) -> None: """ This method is called after the ```connection``` was shut down. + + Args: + connection: The MQTT connection. """ @@ -82,7 +138,9 @@ def onShutdown(self, connection:MQTTConnection) -> None: class MQTTConnection(object): - + """ This class implements an MQTT client. It is a wrapper around the paho MQTT client. + It is implemented as a BackgroundWorker/Actor, so it runs in its own thread. + """ __slots__ = ( 'address', @@ -106,6 +164,7 @@ class MQTTConnection(object): 'actor', 'subscribedTopics', ) + """ Slots of the class. """ # # Runtime methods @@ -126,33 +185,75 @@ def __init__(self, address:str, lowLevelLogging:bool = True, messageHandler:MQTTHandler = None ) -> None: + """ Constructor. Initialize the MQTT client. + + Args: + address: The address of the MQTT broker. + port: The port of the MQTT broker. + keepalive: The keepalive time for the MQTT connection. + interface: The interface to bind to. + clientID: The client ID for the MQTT client. + username: The username for the MQTT broker. + password: The password for the MQTT broker. + useTLS: Whether to use TLS for the MQTT connection. + caFile: The CA file for the MQTT broker's certificate. + verifyCertificate: Indicator whether to verify the MQTT broker's certificate. + certfile: The certificate file for the MQTT client. + keyfile: The key file for the MQTT client. + lowLevelLogging: Indicator whether to log MQTT messages. + messageHandler: The message handler. + """ + self.address = address + """ The address of the MQTT broker. """ self.port = port if port else 8883 if useTLS else 1883 + """ The port of the MQTT broker. """ self.keepalive = keepalive + """ The keepalive time for the MQTT connection. """ self.bindIF = interface + """ The interface to bind to. """ self.username:str = username + """ The username for the MQTT broker. """ self.password:str = password + """ The password for the MQTT broker. """ self.useTLS:bool = useTLS + """ Whether to use TLS for the MQTT connection. """ self.verifyCertificate = verifyCertificate + """ Indicator whether to verify the MQTT broker's certificate. """ self.caFile = caFile + """ The CA file for the MQTT broker's certificate. """ self.mqttsCertfile = certfile + """ The certificate file for the MQTT client. """ self.mqttsKeyfile = keyfile + """ The key file for the MQTT client. """ self.clientID = clientID + """ The client ID for the MQTT client. """ self.lowLevelLogging = lowLevelLogging + """ Indicator whether to log MQTT messages. """ self.isStopped = True + """ Indicator whether the MQTT client is stopped.""" self.isConnected = False + """ Indicator whether the MQTT client is connected.""" self.subscribedCount = 0 + """ The number of subscribed-to topics. """ self.mqttClient:mqtt.Client = None + """ The MQTT client. """ self.messageHandler:MQTTHandler = messageHandler + """ The message handler. """ self.actor:BackgroundWorker = None + """ The actor for the MQTT client. """ self.subscribedTopics:dict[str, MQTTTopic] = {} + """ The list of subscribed-to topics. """ def shutdown(self) -> bool: """ Shutting down the MQTT client. + + Returns: + True if successful, False otherwise. """ self.isStopped = True @@ -215,7 +316,10 @@ def run(self) -> None: def _mqttActor(self) -> bool: - """ Backgroundworker callback to run the actuall MQTT loop. + """ BackgroundWorker callback to run the actuall MQTT loop. + + Returns: + Always True. """ self.isStopped = False self.messageHandler and self.messageHandler.logging(self.mqttClient, logging.INFO, 'MQTT: client started') @@ -232,6 +336,12 @@ def _mqttActor(self) -> bool: def _onConnect(self, client:mqtt.Client, userdata:Any, flags:dict, rc:int) -> None: """ Callback when the MQTT client connected to the broker. + + Args: + client: The MQTT client. + userdata: User data. + flags: Flags. + rc: Result code. """ self.messageHandler and self.messageHandler.logging(self, logging.DEBUG, f'MQTT: Connected with result code: {rc} ({mqtt.error_string(rc)})') if rc == 0: @@ -246,6 +356,11 @@ def _onConnect(self, client:mqtt.Client, userdata:Any, flags:dict, rc:int) -> No def _onDisconnect(self, client:mqtt.Client, userdata:Any, rc:int) -> None: """ Callback when the MQTT client disconnected from the broker. + + Args: + client: The MQTT client. + userdata: User data. + rc: Result code. """ self.messageHandler and self.messageHandler.logging(self, logging.DEBUG, f'MQTT: Disconnected with result code: {rc} ({mqtt.error_string(rc)})') self.subscribedTopics.clear() @@ -268,12 +383,28 @@ def _onDisconnect(self, client:mqtt.Client, userdata:Any, rc:int) -> None: def _onLog(self, client:mqtt.Client, userdata:Any, level:int, buf:str) -> None: - """ Mapping of the paho MQTT client's log to the logging system. Also handles different log-level scheme. + """ Mapping of the paho MQTT client's log to the logging system. + Also handles different log-level scheme. + + Args: + client: The MQTT client. + userdata: User data. + level: Log level. + buf: Log message. """ self.lowLevelLogging and self.messageHandler and self.messageHandler.logging(self, mqtt.LOGGING_LEVEL[level], f'MQTT: {buf}') def _onSubscribe(self, client:mqtt.Client, userdata:Any, mid:int, granted_qos:int) -> None: + """ Callback when the client successfulle subscribed to a topic. The topic + is also added to the internal topic list. + + Args: + client: The MQTT client. + userdata: User data. + mid: The message ID. + granted_qos: The QoS level. + """ # TODO doc, error check when not connected, not subscribed for t in self.subscribedTopics.values(): if t.mid == mid: @@ -283,9 +414,17 @@ def _onSubscribe(self, client:mqtt.Client, userdata:Any, mid:int, granted_qos:in def _onUnsubscribe(self, client:mqtt.Client, userdata:Any, mid:int) -> None: + """ Callback when the client successfulle unsubscribed from a topic. The topic + is also removed from the internal topic list. + """ # TODO doc, error check when not connected, not subscribed """ Callback when the client successfulle unsubscribed from a topic. The topic is also removed from the internal list. + + Args: + client: The MQTT client. + userdata: User data. + mid: The message ID. """ for t in self.subscribedTopics.values(): if t.mid == mid: @@ -295,7 +434,13 @@ def _onUnsubscribe(self, client:mqtt.Client, userdata:Any, mid:int) -> None: def _onMessage(self, client:mqtt.Client, userdata:Any, message:mqtt.MQTTMessage) -> None: - """ Handle a received message. Forward it to the apropriate handler callback (in a Thread) + """ Handle a received message. Forward it to the apropriate handler callback + (in another Thread). + + Args: + client: The MQTT client. + userdata: User data. + message: The received message. """ self.lowLevelLogging and self.messageHandler and self.messageHandler.logging(self, logging.DEBUG, f'MQTT: received topic:{message.topic}, payload:{message.payload}') for t in self.subscribedTopics.keys(): @@ -317,6 +462,11 @@ def _onMessage(self, client:mqtt.Client, userdata:Any, message:mqtt.MQTTMessage) def subscribeTopic(self, topic:str|list[str], callback:Optional[MQTTCallback] = None, **kwargs:Any) -> None: """ Add one or more MQTT topics to subscribe to. Add the topic(s) afterwards to the list of subscribed-to topics. + + Args: + topic: The topic(s) to subscribe to. Either a single topic or a list of topics. + callback: The callback function to call when a message is received for the topic. + kwargs: Additional arguments for the callback function. """ def _subscribe(topic:str) -> None: """ Handle subscription of a single topic. @@ -342,6 +492,9 @@ def unsubscribeTopic(self, topic:str|MQTTTopic) -> None: """ Unsubscribe from a topic. `topic` is either an MQTTTopic structure with a previously subscribed to topic, or a topic name, in which case it is searched for in the list of MQTTTopics. + + Args: + topic: The topic to unsubscribe from. """ if isinstance(topic, MQTTTopic): if topic.topic not in self.subscribedTopics: @@ -373,13 +526,19 @@ def unsubscribeTopic(self, topic:str|MQTTTopic) -> None: def isFullySubscribed(self) -> bool: """ Check whether the number managed subscriptions matches the number of currently subscribed-to topics. + + Return: + True if fully subscribed, False otherwise. """ return self.subscribedCount == len(self.subscribedTopics) - def publish(self, topic:str, data:bytes) -> None: """ Publish the message *data* with the topic *topic* with the MQTT broker. + + Args: + topic: The topic to publish to. + data: The data to publish. """ self.mqttClient.publish(topic, data) @@ -392,17 +551,38 @@ def publish(self, topic:str, data:bytes) -> None: def idToMQTT(id:str) -> str: """ Convert a oneM2M ID to an MQTT compatible path element. + + Args: + id: The oneM2M ID to convert. + + Returns: + The MQTT compatible path element. """ return f'{id.lstrip("/").replace("/", ":")}' def idToMQTTClientID(id:str, isCSE:Optional[bool] = True) -> str: """ Convert a oneM2M ID to an MQTT client ID. + + Args: + id: The oneM2M ID to convert. + isCSE: Whether the ID is a CSE-ID or an AE-ID. + + Returns: + The MQTT client ID. """ return f'{"C::" if isCSE else "A::"}{id.lstrip("/")}' + def mqttToId(mqttId:str, isCSE:Optional[bool] = True) -> Tuple[str, bool]: """ Convert an MQTT compatible path element to an ID. + + Args: + mqttId: The MQTT compatible path element to convert. + isCSE: Whether the ID is a CSE-ID or an AE-ID. + + Returns: + The ID and whether it is a CSE-ID or an AE-ID. """ match mqttId: case x if x.startswith('A:'): @@ -414,5 +594,5 @@ def mqttToId(mqttId:str, isCSE:Optional[bool] = True) -> Tuple[str, bool]: return mqttId[2:].replace(':', '/'), isCSE -# Type for an MQTT Callback MQTTCallback = Callable[[MQTTConnection, str, bytes], None] +""" Type for an MQTT Callback. """ diff --git a/acme/helpers/NetworkTools.py b/acme/helpers/NetworkTools.py index a529da17..fcba8bf0 100644 --- a/acme/helpers/NetworkTools.py +++ b/acme/helpers/NetworkTools.py @@ -29,8 +29,17 @@ def isValidateIpAddress(ip:str) -> bool: return True _allowedPart = re.compile("(?!-)[A-Z\d-]{1,63}(? bool: + """ Validate a host name. + + Args: + hostname: The host name to validate. + + Return: + True if the *hostname* is valid, or False otherwise. + """ if len(hostname) > 255: return False if hostname[-1] == '.': @@ -39,6 +48,14 @@ def isValidateHostname(hostname:str) -> bool: def isValidPort(port:str) -> bool: + """ Validate a port number. + + Args: + port: The port number to validate. + + Return: + True if *port* is valid, or False otherwise. + """ try: _port = int(port) except ValueError: @@ -47,6 +64,13 @@ def isValidPort(port:str) -> bool: def isTCPPortAvailable(port:int) -> bool: + """ Check whether a TCP port is available. + + Args: + port: The port to check. + + Return: + True if *port* is available, or False otherwise.""" try: with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -55,6 +79,7 @@ def isTCPPortAvailable(port:int) -> bool: return False return True + def getIPAddress(hostname:Optional[str] = None) -> str: """ Lookup and return the IP address for a host name. From c506ed02289f57fde50cff53d26ed85055707ed2 Mon Sep 17 00:00:00 2001 From: ankraft Date: Fri, 20 Oct 2023 14:53:01 +0200 Subject: [PATCH 158/165] More documentation --- acme/etc/Types.py | 116 ++++++++++++++++++++------ acme/etc/Utils.py | 29 +++++++ acme/helpers/EventManager.py | 15 ++-- acme/helpers/TextTools.py | 3 + acme/helpers/TinyDBBufferedStorage.py | 28 ++----- acme/textui/ACMEContainerDiagram.py | 2 +- 6 files changed, 139 insertions(+), 54 deletions(-) diff --git a/acme/etc/Types.py b/acme/etc/Types.py index ed6f23e9..e776cd3c 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -89,7 +89,7 @@ class ResourceTypes(ACMEIntEnum): ACTR = 65 """ Action resource type. """ DEPR = 66 - + """ Dependency resource type. """ # Virtual resources (some are proprietary resource types) @@ -179,7 +179,6 @@ class ResourceTypes(ACMEIntEnum): ACTRAnnc = 10065 """ Announced Action resource type. """ DEPRAnnc = 10066 - """ Announced Dependency resource type. """ FWRAnnc = -30001 """ Announced Firmware ManagementObject specialization. """ @@ -793,26 +792,34 @@ class EvalCriteriaOperator(ACMEIntEnum): """ Less than or equal. """ def isAllowedType(self, typ:BasicType) -> bool: - # Ordered types are allowed for all operators - if typ in [ BasicType.positiveInteger, - BasicType.nonNegInteger, - BasicType.unsignedInt, - BasicType.unsignedLong, - BasicType.timestamp, - BasicType.absRelTimestamp, - BasicType.float, - BasicType.integer, - BasicType.duration, - BasicType.enum, - BasicType.time, - BasicType.date ]: - return True - # Equal and unequal are the only operators allowed for all other types - if self.value in [ EvalCriteriaOperator.equal, - EvalCriteriaOperator.notEqual ]: - return True - # Not allowed - return False + """ Check if the given BasicType is allowed for the current EvalCriteriaOperator. + + Args: + typ: The BasicType to check. + + Returns: + True if the BasicType is allowed for the current EvalCriteriaOperator, False otherwise. + """ + # Ordered types are allowed for all operators + if typ in [ BasicType.positiveInteger, + BasicType.nonNegInteger, + BasicType.unsignedInt, + BasicType.unsignedLong, + BasicType.timestamp, + BasicType.absRelTimestamp, + BasicType.float, + BasicType.integer, + BasicType.duration, + BasicType.enum, + BasicType.time, + BasicType.date ]: + return True + # Equal and unequal are the only operators allowed for all other types + if self.value in [ EvalCriteriaOperator.equal, + EvalCriteriaOperator.notEqual ]: + return True + # Not allowed + return False @@ -1324,17 +1331,28 @@ class NotificationContentType(ACMEIntEnum): class NotificationEventType(ACMEIntEnum): """ eventNotificationCriteria/NotificationEventTypes """ - resourceUpdate = 1 # A, default - resourceDelete = 2 # B + + resourceUpdate = 1 # A, default + """ Resource Update (the default).""" + resourceDelete = 2 # B + """ Resource Delete. """ createDirectChild = 3 # C + """ Create Direct Child. """ deleteDirectChild = 4 # D + """ Delete Direct Child. """ retrieveCNTNoChild = 5 # E # TODO not supported yet + """ Retrieve CNT No Child. """ triggerReceivedForAE = 6 # F # TODO not supported yet + """ Trigger Received For AE. """ blockingUpdate = 7 # G + """ Blocking Update. """ # TODO spec and implementation for blockingUpdateDirectChild = ??? reportOnGeneratedMissingDataPoints = 8 # H + """ Report On Generated Missing Data Points. """ blockingRetrieve = 9 # I # EXPERIMENTAL + """ Blocking Retrieve. """ blockingRetrieveDirectChild = 10 # J # EXPERIMENTAL + """ Blocking Retrieve Direct Child. """ def isAllowedNCT(self, nct:NotificationContentType) -> bool: @@ -1396,24 +1414,42 @@ def defaultNCT(self) -> NotificationContentType: @dataclass class MissingData: """ Data class for collecting the missing data states. """ + subscriptionRi:str + """ Subscription resource identifier. """ missingDataDuration:float + """ Missing data duration. """ missingDataNumber:int + """ Missing data number. """ timeWindowEndTimestamp:float = None + """ Time window end timestamp. """ missingDataList:list[str] = field(default_factory=list) + """ Missing data list. """ missingDataCurrentNr:int = 0 + """ Missing data current number. """ def clear(self) -> None: + """ Clear the missing data states. + """ + self.timeWindowEndTimestamp = None self.clearMissingDataList() def clearMissingDataList(self) -> None: + """ Clear the missing data list. + """ + self.missingDataList = [] self.missingDataCurrentNr = 0 def asDict(self) -> JSON: + """ Return the missing data as a dictionary. + + Return: + The missing data as a dictionary. + """ return { 'mdlt': self.missingDataList, 'mdc' : self.missingDataCurrentNr @@ -1427,20 +1463,29 @@ class LastTSInstance: # runtime attributes dgt:list[float] = field(default_factory = lambda: [0]) + """ List of data generation times. """ expectedDgt:float = 0.0 + """ Expected data generation time. """ missingDataDetectionTime:float = 0.0 + """ Missing data detection time. """ # attributes pei:float = 0.0 + """ Periodic interval. """ mdt:float = 0.0 + """ Missing data detection time. """ peid:float = 0.0 + """ Periodic interval duration. """ # Subscriptions missingData:dict[str, MissingData] = field(default_factory = dict) + """ Missing data. """ # Internal actor:BackgroundWorker = None #type:ignore[name-defined] # actor for this TS + """ Actor for this TS.""" running:bool = False # for late activation of this + """ Running. """ def prepareNextDgt(self) -> None: @@ -1456,6 +1501,11 @@ def prepareNextRun(self) -> None: def addDgt(self, dgt:float) -> None: + """ Add a data generation time to the list of data generation times. + + Args: + dgt: The data generation time to add. + """ # TODO really support list. currently only one dgt is put, but # always overrides the old one. # Also change declaration of dgt above @@ -1466,16 +1516,31 @@ def addDgt(self, dgt:float) -> None: def nextDgt(self) -> float: + """ Get the next expected data generation time. + + Return: + The next expected data generation time. + """ if len(self.dgt) == 0: return None return self.dgt.pop(0) def hasDgt(self) -> bool: + """ Check if there is a data generation time. + + Return: + True if there is a data generation time. + """ return len(self.dgt) > 0 def clearDgt(self) -> None: + """ Clear the data generation time. + + Return: + True if there is a data generation time. + """ self.dgt.clear() @@ -1487,6 +1552,7 @@ def clearDgt(self) -> None: class AnnounceSyncType(ACMEIntEnum): """ Announce Sync Types """ + UNI_DIRECTIONAL = 1 """ Announcement shall be done uni-directional, ie. changes in the announced resource are not synced back.""" BI_DIRECTIONAL = 2 @@ -2317,11 +2383,13 @@ def select(self, index:int) -> Optional[Any]: AttributePolicyDict = Dict[str, AttributePolicy] """ Represent a dictionary of attribute policies used in validation. """ + ResourceAttributePolicyDict = Dict[Tuple[Union[ResourceTypes, str], str], AttributePolicy] """ Represent a dictionary of attribute policies used in validation. """ FlexContainerAttributes = Dict[str, Dict[str, AttributePolicy]] """ Type definition for a dictionary of attribute policies for a flexContainer. """ + FlexContainerSpecializations = Dict[str, str] """ Type definition for a dictionary of specializations for a flexContainer. """ diff --git a/acme/etc/Utils.py b/acme/etc/Utils.py index 16c18e9e..05f1538a 100644 --- a/acme/etc/Utils.py +++ b/acme/etc/Utils.py @@ -107,6 +107,8 @@ def noNamespace(id:str) -> str: _randomIDCharSet = string.ascii_uppercase + string.digits + string.ascii_lowercase +""" Character set for random IDs. """ + def _randomID() -> str: """ Generate an ID. Prevent certain patterns in the ID. @@ -228,6 +230,8 @@ def isValidID(id:str, allowEmpty:Optional[bool] = False) -> bool: _unreserved = re.compile(r'^[\w\-.~]*$') +""" Regular expression to test for unreserved characters. """ + def hasOnlyUnreserved(id:str) -> bool: """ Test that an ID only contains characters from the unreserved character set of RFC 3986. @@ -241,6 +245,8 @@ def hasOnlyUnreserved(id:str) -> bool: _csiRx = re.compile('^/[^/\s]+') # Must start with a / and must not contain a further / or white space +""" Regular expression to test for valid CSE-ID format. """ + def isValidCSI(csi:str) -> bool: """ Test for valid CSE-ID format. @@ -253,6 +259,8 @@ def isValidCSI(csi:str) -> bool: _aeRx = re.compile('^[^/\s]+') # Must not start with a / and must not contain a further / or white space +""" Regular expression to test for valid AE-ID format. """ + def isValidAEI(aei:str) -> bool: """ Test for valid AE-ID format. @@ -579,6 +587,8 @@ def riFromID(id:str) -> str: # r'\S+' # re.IGNORECASE # optional path ) +""" Regular expression to test for a valid URL. """ + def isURL(url:str) -> bool: """ Check whether a given string is a URL. @@ -643,7 +653,10 @@ def normalizeURL(url:str) -> str: # _excludeFromRoot = [ 'pi' ] +""" Attributes that are excluded from the root of a resource tree. """ + _pureResourceRegex = re.compile('[\w]+:[\w]') +""" Regular expression to test for a pure resource name. """ def pureResource(dct:JSON) -> Tuple[JSON, str, str]: """ Return the "pure" structure without the ":xxx" resource type name, and the oneM2M type identifier. @@ -740,6 +753,15 @@ def resourceModifiedAttributes(old:JSON, new:JSON, requestPC:JSON, modifiers:Opt def filterAttributes(dct:JSON, attributesToInclude:JSON) -> JSON: + """ Filter a dictionary by a list of attributes to include. + + Args: + dct: Dictionary to filter. + attributesToInclude: List of attributes to include. + + Return: + Filtered dictionary. + """ return { k: v for k, v in dct.items() if k in attributesToInclude } @@ -878,5 +900,12 @@ def runsInIPython() -> bool: def reverseEnumerate(data:list) -> Generator[Tuple[int, Any], None, None]: + """ Reverse enumerate a list. + + Args: + data: List to enumerate. + Return: + Generator that yields a tuple with the index and the value of the list. + """ for i in range(len(data)-1, -1, -1): yield (i, data[i]) \ No newline at end of file diff --git a/acme/helpers/EventManager.py b/acme/helpers/EventManager.py index ffce3cb9..af297361 100644 --- a/acme/helpers/EventManager.py +++ b/acme/helpers/EventManager.py @@ -44,11 +44,6 @@ class Event(list): # type:ignore[type-arg] Attention: Since the parent class is a *list* calling *isInstance(obj, list)* will return True. - - Attributes: - runInBackground: Indicator whether an event should be handled in a separate thread. - manager: The responsible `EventManager` to handle an event. - name: The event name. """ __slots__ = ( @@ -56,6 +51,7 @@ class Event(list): # type:ignore[type-arg] 'manager', 'name', ) + """ Slots of the Event class. """ def __init__(self, runInBackground:Optional[bool] = True, manager:Optional[EventManager] = None, @@ -68,8 +64,11 @@ def __init__(self, runInBackground:Optional[bool] = True, name: The event name. """ self.runInBackground = runInBackground + """ Indicator whether an event should be handled in a separate thread. """ self.manager = manager + """ The responsible `EventManager` to handle an event. """ self.name = name + """ The event name. """ def __call__(self, *args:Any, **kwargs:Any) -> None: @@ -131,20 +130,20 @@ class EventManager(object): manager.anEvent(anArg) Raise the *anEvent* `Event` with an *anArg* argument. - - Attributes: - _running: Internal Running indicator for the manager instance. """ __slots__ = ( '_running', ) + """ Slots of the EventManager class. """ def __init__(self) -> None: """ EventManager initialization. """ self._running = True + """ Internal Running indicator for the manager instance. """ + def shutdown(self) -> bool: """ Shutdown the Event Manager. diff --git a/acme/helpers/TextTools.py b/acme/helpers/TextTools.py index 80b423af..4875eb48 100644 --- a/acme/helpers/TextTools.py +++ b/acme/helpers/TextTools.py @@ -130,6 +130,8 @@ def commentJson(data:Union[str, dict], _decimalMatch = re.compile(r'{(\d+)}') +""" Compiled regex expression of recognize decimal numbers in a string. """ + def findXPath(dct:Dict[str, Any], key:str, default:Optional[Any] = None) -> Optional[Any]: """ Find a structured *key* in the dictionary *dct*. If *key* does not exists then *default* is returned. @@ -297,6 +299,7 @@ def isNumber(string:Any) -> bool: ('MN', '5'), ('R', '6'), ) +""" Replacement characters for the soundex algorithm. """ def soundex(s:str, maxCount:Optional[int] = 4) -> str: """ Convert a string to a Soundex value. diff --git a/acme/helpers/TinyDBBufferedStorage.py b/acme/helpers/TinyDBBufferedStorage.py index 90fde468..f9e1eb0b 100644 --- a/acme/helpers/TinyDBBufferedStorage.py +++ b/acme/helpers/TinyDBBufferedStorage.py @@ -16,16 +16,6 @@ class TinyDBBufferedStorage(JSONStorage): """ Storage driver class for TinyDB that implements a buffered disk write. - - Attributes: - __slots__: Define slots for instance variables. - _writeEvent: Event instance to notify when a write happened. - _writeDelay: Delay before writing the data to disk. - _shutdownLock: Internal lock when shutting down the database. - _running: Indicating that the database is open and in use. - _shutting_down: Indicator that the database is closing. This is different from `_running`. - _changed: Indicator that the write buffer is *dirty* and needs to be written. - _data: The actual database data, which is also strored in memory as a buffer. """ __slots__ = ( @@ -52,27 +42,23 @@ def __init__(self, path:str, create_dirs:bool = False, encoding:str = None, acce create_dirs: Whether the directory structure to the database file should be created or not. write_delay: Time to wait before writing a changed database buffer, in seconds. kwargs: Any other argument. - - Attributes: - __slots__: Define slots for instance variables. - _writeEvent: Event instance to notify when a write happened. - _writeDelay: Delay before writing the data to disk. - _shutdownLock: Internal lock when shutting down the database. - _running: Indicating that the database is open and in use. - _shutting_down: Indicator that the database is closing. This is different from `_running`. - _changed: Indicator that the write buffer is *dirty* and needs to be written. - _data: The actual database data, which is also strored in memory as a buffer. - """ super().__init__(path, create_dirs, encoding, access_mode, **kwargs) self._shutdownLock = Thread.allocate_lock() + """ Internal lock when shutting down the database. """ self._writeEvent = Event() + """ Event instance to notify when a write happened. """ self._running = True + """ Indicating that the database is open and in use. """ self._shutting_down = False + """ Indicator that the database is closing. This is different from `_running`. """ self._changed = False + """ Indicator that the write buffer is *dirty* and needs to be written. """ self._writeDelay:int = write_delay + """ Time to wait before writing a changed database buffer, in seconds. """ self._data:Dict[str, Dict[str, Any]] = {} + """ The actual database data, which is also strored in memory as a buffer. """ # finishing init. Read the data for the first time self._data = super().read() diff --git a/acme/textui/ACMEContainerDiagram.py b/acme/textui/ACMEContainerDiagram.py index 08e4caab..bcbbb6ff 100644 --- a/acme/textui/ACMEContainerDiagram.py +++ b/acme/textui/ACMEContainerDiagram.py @@ -110,7 +110,7 @@ def __init__(self, refreshCallback:Callable) -> None: self.dates:Optional[list[str]] = [] self.refreshCallback = refreshCallback self.buttons = { - DiagramTypes.Line: Button('Line', variant = 'primary', id = 'diagram-line-button'), + DiagramTypes.Line: Button('Line', variant = 'success', id = 'diagram-line-button'), DiagramTypes.Graph: Button('Graph', variant = 'primary', id = 'diagram-graph-button'), DiagramTypes.Scatter: Button('Scatter', variant = 'primary', id = 'diagram-scatter-button'), DiagramTypes.Bar: Button('Bar', variant = 'primary', id = 'diagram-bar-button'), From 40b2471cb02a9924956b950bc8730dff573a7f4f Mon Sep 17 00:00:00 2001 From: Miguel Angel Reina Ortega Date: Fri, 20 Oct 2023 17:41:25 +0200 Subject: [PATCH 159/165] Fix for instances announcement when parent has not been announced --- acme/services/AnnouncementManager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/acme/services/AnnouncementManager.py b/acme/services/AnnouncementManager.py index 225caf6d..b82691bc 100644 --- a/acme/services/AnnouncementManager.py +++ b/acme/services/AnnouncementManager.py @@ -293,8 +293,8 @@ def checkCSEBaseAnnouncement(cseBase:AnnounceableResource) -> None: # Don't allow instances to be announced without their parents if resource.ty in [ResourceTypes.CIN, ResourceTypes.FCI, ResourceTypes.TSI]: - raise OPERATION_NOT_ALLOWED(L.logDebug('announcing instances without their parents is not allowed')) - + L.logWarn('Announcing instances without their parents is not allowed. Unsuccessful announcement') + return # Whatever the parent resource is, check whether the CSEBase has been announced. Announce it if necessay # and set the announced CSEBase as new parent checkCSEBaseAnnouncement(parentResource := getCSE()) From 51427e78432a959754246da181f3516202b14b46 Mon Sep 17 00:00:00 2001 From: Miguel Angel Reina Ortega Date: Fri, 20 Oct 2023 20:42:26 +0200 Subject: [PATCH 160/165] Fix for CREATE request sending, missing operation parameter --- acme/services/Dispatcher.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/acme/services/Dispatcher.py b/acme/services/Dispatcher.py index 76d7341b..457de468 100644 --- a/acme/services/Dispatcher.py +++ b/acme/services/Dispatcher.py @@ -705,7 +705,8 @@ def createResourceFromDict(self, dct:JSON, res = CSE.request.handleSendRequest(CSERequest(to = (pri := toSPRelative(parentID)), originator = originator, ty = ty, - pc = dct) + pc = dct, + op = Operation.CREATE) )[0].result # there should be at least one result # The request might have gone through normally and returned, but might still have failed on the remote CSE. From 4456eba4806d1e892ef2d5e3fa5dc308481df18e Mon Sep 17 00:00:00 2001 From: Miguel Angel Reina Ortega Date: Fri, 20 Oct 2023 21:16:34 +0200 Subject: [PATCH 161/165] Fix for sending notifications when event evaluation mode is not configured Signed-off-by: Miguel Angel Reina Ortega --- acme/services/NotificationManager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/acme/services/NotificationManager.py b/acme/services/NotificationManager.py index 84013958..f219510b 100644 --- a/acme/services/NotificationManager.py +++ b/acme/services/NotificationManager.py @@ -613,7 +613,8 @@ def _crsCheckForNotification(self, data:list[str], L.isDebug and L.logDebug(f'Checking : {crsRi} window properties: unique notification count: {len(data)}, max expected count: {subCount}, eem: {eem}') # Test for conditions - if (eem == EventEvaluationMode.ALL_EVENTS_PRESENT and len(data) == subCount) or \ + if ((eem is None or eem == EventEvaluationMode.ALL_EVENTS_PRESENT) and len(data) == subCount) or \ + (eem == EventEvaluationMode.ALL_EVENTS_PRESENT and len(data) == subCount) or \ (eem == EventEvaluationMode.ALL_OR_SOME_EVENTS_PRESENT and 1 <= len(data) <= subCount) or \ (eem == EventEvaluationMode.SOME_EVENTS_MISSING and 1 <= len(data) < subCount) or \ (eem == EventEvaluationMode.ALL_OR_SOME_EVENTS_MISSING and 0 <= len(data) < subCount) or \ From ebde5be7794ff3bcbdd1979483e5bdd363255fdd Mon Sep 17 00:00:00 2001 From: ankraft Date: Sat, 21 Oct 2023 01:01:41 +0200 Subject: [PATCH 162/165] More documentation --- acme/helpers/Interpreter.py | 44 +++++--- acme/services/Dispatcher.py | 210 +++++++++++++++++++++++++++++++++--- acme/services/Storage.py | 133 ++++++++++++----------- 3 files changed, 295 insertions(+), 92 deletions(-) diff --git a/acme/helpers/Interpreter.py b/acme/helpers/Interpreter.py index e48a5ff9..c47eeda1 100644 --- a/acme/helpers/Interpreter.py +++ b/acme/helpers/Interpreter.py @@ -7,6 +7,9 @@ # Implementation of a simple s-expression-based command processor. # """ The interpreter module implements an extensible lisp-based scripting runtime. + + See: + `PContext` for the main class to run a script. """ from __future__ import annotations @@ -704,7 +707,16 @@ class PCall(): class PContext(): - """ Process context for a single script. Can be re-used. """ + """ Process context for a single script. + + This is the main runtime object for the interpreter. + To run a script, create a `PContext` object, and call its `run()` method. + + To add new symbols to the interpreter, inherit from `PContext` + and add them to the `symbols` dictionary during initialization. + + A `PContext` object can be re-used. + """ __slots__ = ( 'script', @@ -743,24 +755,24 @@ class PContext(): def __init__(self, script:str, - symbols:PSymbolDict = None, - logFunc:PLogCallable = lambda pcontext, msg: print(f'** {msg}'), - logErrorFunc:PErrorLogCallable = lambda pcontext, msg, exception: print(f'!! {msg}'), - printFunc:PLogCallable = lambda pcontext, msg: print(msg), - preFunc:PFuncCallable = None, - postFunc:PFuncCallable = None, - errorFunc:PFuncCallable = None, - matchFunc:PMatchCallable = lambda pcontext, l, r: l == r, - maxRuntime:float = None, - fallbackFunc:PSymbolCallable = None, - monitorFunc:PSymbolCallable = None, - allowBrackets:bool = False, - verbose:bool = False) -> None: + symbols:Optional[PSymbolDict] = None, + logFunc:Optional[PLogCallable] = lambda pcontext, msg: print(f'** {msg}'), + logErrorFunc:Optional[PErrorLogCallable] = lambda pcontext, msg, exception: print(f'!! {msg}'), + printFunc:Optional[PLogCallable] = lambda pcontext, msg: print(msg), + preFunc:Optional[PFuncCallable] = None, + postFunc:Optional[PFuncCallable] = None, + errorFunc:Optional[PFuncCallable] = None, + matchFunc:Optional[PMatchCallable] = lambda pcontext, l, r: l == r, + maxRuntime:Optional[float] = None, + fallbackFunc:Optional[PSymbolCallable] = None, + monitorFunc:Optional[PSymbolCallable] = None, + allowBrackets:Optional[bool] = False, + verbose:Optional[bool] = False) -> None: """ Initialization of a `PContext` object. Args: script: The script to run. - symbols: A dictionary of new symbols / functions to add to the interpreter. + symbols: An optional dictionary of new symbols / functions to add to the interpreter. logFunc: An optional function that receives non-error log messages. logErrorFunc: An optional function that receives error log messages. printFunc: An optional function for printing messages to the screen, console, etc. @@ -1653,7 +1665,7 @@ def _joinExpression(self, symbols:list[SSymbol], sep:str = ' ') -> PContext: PSymbolCallable = Callable[[PContext, SSymbol], PContext] """ Signature of a symbol callable. The callbacks are called with a `PContext` object and is supposed to return - it again, or None in case of an error. + it again, updated with a return value, or *None* in case of an error. """ PLogCallable = Callable[[PContext, str], None] diff --git a/acme/services/Dispatcher.py b/acme/services/Dispatcher.py index 457de468..477b6f34 100644 --- a/acme/services/Dispatcher.py +++ b/acme/services/Dispatcher.py @@ -6,6 +6,11 @@ # # Most internal requests are routed through here. # +""" Dispatcher module. Handles all requests and dispatches them to the + appropriate handlers. This includes requests for resources, requests + for resource creation, and requests for resource deletion. + Also handles the discovery of resources. +""" from __future__ import annotations from typing import List, Tuple, cast, Sequence, Optional @@ -41,6 +46,10 @@ # TODO NOTIFY optimize local resource notifications # TODO handle config update class Dispatcher(object): + """ Dispatcher class. Handles all requests and dispatches them to the + appropriate handlers. This includes requests for resources, requests + for resource creation, and requests for resource deletion. + """ __slots__ = ( 'csiSlashLen', @@ -51,15 +60,24 @@ class Dispatcher(object): '_eventUpdateResource', '_eventDeleteResource', ) + """ Slots of class attributes. """ def __init__(self) -> None: + """ Initialize the Dispatcher. """ + self.csiSlashLen = len(CSE.cseCsiSlash) + """ Length of the CSI with a slash. """ self.sortDiscoveryResources = Configuration.get('cse.sortDiscoveredResources') + """ Sort the discovered resources. """ self._eventCreateResource = CSE.event.createResource # type: ignore [attr-defined] + """ Event handler for resource creation events. """ self._eventCreateChildResource = CSE.event.createChildResource # type: ignore [attr-defined] + """ Event handler for child resource creation events. """ self._eventUpdateResource = CSE.event.updateResource # type: ignore [attr-defined] + """ Event handler for resource update events. """ self._eventDeleteResource = CSE.event.deleteResource # type: ignore [attr-defined] + """ Event handler for resource deletion events. """ L.isInfo and L.log('Dispatcher initialized') @@ -95,8 +113,14 @@ def processRetrieveRequest(self, request:CSERequest, request: The incoming request. originator: The requests originator. id: Optional ID of the request. + Return: Result object. + + Raises: + BAD_REQUEST: If the request is invalid. + ORIGINATOR_HAS_NO_PRIVILEGE: If the originator has no privilege. + INTERNAL_SERVER_ERROR: If an internal error occurred. """ L.isDebug and L.logDebug(f'Process RETRIEVE request for id: {request.id}|{request.srn}') @@ -290,9 +314,9 @@ def retrieveResource(self, id:str, If no, then try to retrieve the resource from a connected (!) remote CSE. originator: The originator of the request. postRetrieveHook: Only when retrieving localls, invoke the Resource's *willBeRetrieved()* callback. + Return: Result instance. - """ if id: if id.startswith(CSE.cseCsiSlash) and len(id) > self.csiSlashLen: # TODO for all operations? @@ -319,6 +343,20 @@ def retrieveLocalResource(self, ri:Optional[str] = None, srn:Optional[str] = None, originator:Optional[str] = None, request:Optional[CSERequest] = None) -> Resource: + """ Retrieve a resource locally. + + Args: + ri: The resource ID. + srn: The structured resource name. + originator: The originator of the request. + request: The request. + + Return: + The retrieved resource. + + Raises: + NOT_FOUND: If the resource cannot be found. + """ L.isDebug and L.logDebug(f'Retrieve local resource: {ri}|{srn} for originator: {originator}') if ri: @@ -350,6 +388,18 @@ def discoverResources(self, filterCriteria:Optional[FilterCriteria] = None, rootResource:Optional[Resource] = None, permission:Optional[Permission] = Permission.DISCOVERY) -> List[Resource]: + """ Discover resources. This is the main function for resource discovery. + + Args: + id: The ID of the resource to start discovery from. + originator: The originator of the request. + filterCriteria: The filter criteria. + rootResource: The root resource for discovery. + permission: The permission to use. + + Return: + A list of discovered resources. + """ L.isDebug and L.logDebug('Discovering resources') if not rootResource: @@ -413,6 +463,21 @@ def _discoverResources(self, rootResource:Resource, dcrs:Optional[list[Resource]] = None, filterCriteria:Optional[FilterCriteria] = None, permission:Optional[Permission] = Permission.DISCOVERY) -> list[Resource]: + """ Discover resources recursively. This is a helper function for discoverResources(). + + Args: + rootResource: The root resource for discovery. + originator: The originator of the request. + level: The level of discovery. + fo: The filter operation. + allLen: The length of all filter criteria. + dcrs: The direct child resources of the root resource. + filterCriteria: The filter criteria. + permission: The permission to use. + + Return: + A list of discovered resources. + """ if not rootResource or level == 0: # no resource or level == 0 return [] @@ -561,8 +626,17 @@ def processCreateRequest(self, request:CSERequest, request: The incoming request. originator: The requests originator. id: Optional ID of the request. + Return: Result object. + + Raises: + BAD_REQUEST: If the request is invalid. + NOT_FOUND: If the resource cannot be found. + OPERATION_NOT_ALLOWED: If the operation is not allowed. + SECURITY_ASSOCIATION_REQUIRED: If a security association is required. + ORIGINATOR_HAS_NO_PRIVILEGE: If the originator has no privilege. + CONFLICT: If the resource already exists. """ L.isDebug and L.logDebug(f'Process CREATE request for id: {request.id}|{request.srn}') @@ -596,7 +670,7 @@ def processCreateRequest(self, request:CSERequest, # Get parent resource and check permissions L.isDebug and L.logDebug(f'Get parent resource and check permissions: {id}') - parentResource = CSE.dispatcher.retrieveResource(id) + parentResource = self.retrieveResource(id) if not CSE.security.hasAccess(originator, parentResource, Permission.CREATE, ty = ty, parentResource = parentResource): if ty == ResourceTypes.AE: @@ -630,7 +704,7 @@ def processCreateRequest(self, request:CSERequest, # Create the resource. If this fails we de-register everything try: - _resource = CSE.dispatcher.createLocalResource(newResource, parentResource, originator, request = request) + _resource = self.createLocalResource(newResource, parentResource, originator, request = request) except ResponseException as e: CSE.registration.checkResourceDeletion(newResource) # deregister resource. Ignore result, we take this from the creation raise e @@ -675,7 +749,22 @@ def createResourceFromDict(self, dct:JSON, parentID:str, ty:ResourceTypes, originator:str) -> Tuple[str, str, str]: - # TODO doc + """ Create a resource from a JSON dictionary. + + Args: + dct: The dictionary. + parentID: The parent ID. + ty: The resource type. + originator: The originator. + + Return: + A tuple of (resource ID, CSE-ID, parent ID). + + Raises: + INTERNAL_SERVER_ERROR: If an unknown/unsupported RSC is returned. + ORIGINATOR_HAS_NO_PRIVILEGE: If the originator has no privilege. + + """ # Create locally if (pID := localResourceID(parentID)) is not None: L.isDebug and L.logDebug(f'Creating local resource with ID: {pID} originator: {originator}') @@ -729,6 +818,21 @@ def createLocalResource(self, parentResource:Resource, originator:Optional[str] = None, request:Optional[CSERequest] = None) -> Resource: + """ Create a resource locally. + + Args: + resource: The resource to create. + parentResource: The parent resource. + originator: The originator of the request. + request: The request. + + Return: + The created resource. + + Raises: + TARGET_NOT_SUBSCRIBABLE: If the parent resource is not subscribable. + INVALID_CHILD_RESOURCE_TYPE: If the child resource type is invalid. + """ L.isDebug and L.logDebug(f'CREATING resource ri: {resource.ri}, type: {resource.ty}') if parentResource: # parentResource might be None if this is the root resource @@ -797,8 +901,15 @@ def processUpdateRequest(self, request:CSERequest, request: The incoming request. originator: The requests originator. id: Optional ID of the request. + Return: Result object. + + Raises: + BAD_REQUEST: If the request is invalid. + NOT_FOUND: If the resource cannot be found. + OPERATION_NOT_ALLOWED: If the operation is not allowed. + ORIGINATOR_HAS_NO_PRIVILEGE: If the originator has no privilege. """ L.isDebug and L.logDebug(f'Process UPDATE request for id: {request.id}|{request.srn}') @@ -890,6 +1001,7 @@ def updateLocalResource(self, resource:Resource, dct: JSON dictionary with the updated attributes. doUpdateCheck: Enable/disable a call to update(). originator: The request's originator. + Return: Updated resource. """ @@ -913,7 +1025,21 @@ def updateResourceFromDict(self, dct:JSON, id:str, originator:Optional[str] = None, resource:Optional[Resource] = None) -> Resource: - # TODO doc + """ Update a resource from a JSON dictionary. + + Args: + dct: The dictionary. + id: The resource ID. + originator: The originator. + resource: The resource to update. + + Return: + The updated resource. + + Raises: + INTERNAL_SERVER_ERROR: If the resource cannot be updated. + ORIGINATOR_HAS_NO_PRIVILEGE: If the originator has no UPDATE privileges. + """ # Update locally if (rID := localResourceID(id)) is not None: @@ -967,8 +1093,13 @@ def processDeleteRequest(self, request:CSERequest, request: The incoming request. originator: The requests originator. id: Optional ID of the request. + Return: Result object. + + Raises: + NOT_FOUND: If the resource cannot be found. + ORIGINATOR_HAS_NO_PRIVILEGE: If the originator has no privilege. """ L.isDebug and L.logDebug(f'Process DELETE request for id: {request.id}|{request.srn}') @@ -1059,6 +1190,15 @@ def deleteLocalResource(self, resource:Resource, withDeregistration:Optional[bool] = False, parentResource:Optional[Resource] = None, doDeleteCheck:Optional[bool] = True) -> None: + """ Delete a resource from the CSE. Call deactivate() and deleted() callbacks on the resource. + + Args: + resource: The resource to delete. + originator: The originator of the request. + withDeregistration: If True, deregister the resource. + parentResource: The parent resource. + doDeleteCheck: If True, call childRemoved() on the parent resource. + """ L.isDebug and L.logDebug(f'Removing resource ri: {resource.ri}, type: {resource.ty}') resource.deactivate(originator) # deactivate it first @@ -1147,8 +1287,13 @@ def processNotifyRequest(self, request:CSERequest, request: The incoming request. originator: The requests originator. id: Optional ID of the request. + Return: Result object. + + Raises: + BAD_REQUEST: If the request is invalid. + ORIGINATOR_HAS_NO_PRIVILEGE: If the originator has no privilege. """ L.isDebug and L.logDebug(f'Process NOTIFY request for id: {request.id}|{request.srn}') @@ -1201,7 +1346,19 @@ def processNotifyRequest(self, request:CSERequest, def notifyLocalResource(self, ri:str, originator:str, content:JSON) -> Result: - # TODO doc + """ Notify a local resource. + + Args: + ri: The resourceIdentifier of the resource to notify. + originator: The originator of the request. + content: The notification content. + + Return: + Result object. + + Raises: + ORIGINATOR_HAS_NO_PRIVILEGE: If the originator has no NOTIFY access to the resource. + """ L.isDebug and L.logDebug(f'Sending NOTIFY to local resource: {ri}') resource = self.retrieveLocalResource(ri, originator = originator) @@ -1334,7 +1491,18 @@ def discoverChildren(self, id:str, originator:str, filterCriteria:FilterCriteria, permission:Permission) -> Optional[list[Resource]]: - # TODO documentation + """ Discover child resources of a resource. + + Args: + id: The resourceIdentifier of the resource to discover the children for. + resource: The resource to discover the children for. + originator: The originator of the request. + filterCriteria: The filter criteria to use. + permission: The permission to check. + + Return: + A list of child resources. This list might be empty. + """ resources = self.discoverResources(id, originator, filterCriteria = filterCriteria, rootResource = resource, permission = permission) # check and filter by ACP @@ -1396,7 +1564,7 @@ def retrieveResourceWithPermission(self, ri:str, originator:str, permission:Perm `ORIGINATOR_HAS_NO_PRIVILEGE`: In case the originator has not the required permission to the resoruce. """ - resource = CSE.dispatcher.retrieveResource(riFromID(ri), originator) + resource = self.retrieveResource(riFromID(ri), originator) if not CSE.security.hasAccess(originator, resource, permission): raise ORIGINATOR_HAS_NO_PRIVILEGE(L.logDebug(f'originator has no access to the resource: {ri}')) return resource @@ -1584,8 +1752,14 @@ def _resourceTreeReferences(self, resources:list[Resource], return targetResource - # Retrieve full child resources of a resource and add them to a new target resource def _childResourceTree(self, resources:list[Resource], targetResource:Resource|JSON) -> None: + """ Retrieve child resources of a resource and add them to + a **new** target resource instance as "children" + + Args: + resources: A list of resources to retrieve the child resources from. + targetResource: The target resource to add the child resources to. + """ if len(resources) == 0: return result:JSON = {} @@ -1632,7 +1806,7 @@ def _getPollingChannelURIResource(self, id:str) -> Optional[PCH_PCU]: if not (id := structuredPathFromRI(id)): return None - resource = CSE.dispatcher.retrieveResource(id) + resource = self.retrieveResource(id) if resource.ty == ResourceTypes.PCH_PCU: return cast(PCH_PCU, resource) @@ -1664,7 +1838,7 @@ def _getFanoutPointResource(self, id:str) -> Optional[Resource]: if nid: try: - return CSE.dispatcher.retrieveResource(nid) + return self.retrieveResource(nid) except: pass return None @@ -1685,13 +1859,25 @@ def _latestOldestResource(self, id:str) -> Optional[Resource]: if not isStructured(id): if not (id := structuredPathFromRI(id)): return None - if (resource := CSE.dispatcher.retrieveResource(id)) and ResourceTypes.isLatestOldestResource(resource.ty): + if (resource := self.retrieveResource(id)) and ResourceTypes.isLatestOldestResource(resource.ty): return resource # Fallthrough return None def _partialFromResource(self, resource:Resource, attributeList:JSON) -> Result: + """ Filter attributes from a resource. + + Args: + resource: The resource to filter the attributes from. + attributeList: The list of attributes to filter. + + Return: + A Result object with the filtered resource. + + Raises: + BAD_REQUEST: In case an attribute is not defined for the resource. + """ if attributeList: # Validate that the attribute(s) are actual resouce attributes for a in attributeList: diff --git a/acme/services/Storage.py b/acme/services/Storage.py index 78917cb0..49a9c190 100644 --- a/acme/services/Storage.py +++ b/acme/services/Storage.py @@ -47,25 +47,27 @@ # Constants for database and table names _resources = 'resources' +""" Name of the resources table. """ _identifiers = 'identifiers' +""" Name of the identifiers table. """ _children = 'children' -_srn = 'srn' +""" Name of the children table. """ _subscriptions = 'subscriptions' +""" Name of the subscriptions table. """ _batchNotifications = 'batchNotifications' +""" Name of the batchNotifications table. """ _statistics = 'statistics' +""" Name of the statistics table. """ _actions = 'actions' +""" Name of the actions table. """ _requests = 'requests' +""" Name of the requests table. """ _schedules = 'schedules' +""" Name of the schedules table. """ class Storage(object): """ This class implements the entry points to the CSE's underlying database functions. - - Attributes: - inMemory: Indicator whether the database is located in memory (volatile) or on disk. - dbPath: In case *inMemory* is "False" this attribute contains the path to a directory where the database is stored in disk. - dbReset: Indicator that the database should be reset or cleared during start-up. - db: The database object. """ __slots__ = ( @@ -95,6 +97,7 @@ def __init__(self) -> None: # create DB object and open DB self.db = TinyDBBinding(self.dbPath, CSE.cseCsi[1:]) # add CSE CSI as postfix + """ The database object. """ # Reset dbs? if self.dbReset: @@ -128,8 +131,11 @@ def _assignConfig(self) -> None: """ Assign default configurations. """ self.inMemory = Configuration.get('database.inMemory') + """ Indicator whether the database is located in memory (volatile) or on disk. """ self.dbPath = Configuration.get('database.path') + """ In case *inMemory* is "False" this attribute contains the path to a directory where the database is stored in disk. """ self.dbReset = Configuration.get('database.resetOnStartup') + """ Indicator that the database should be reset or cleared during start-up. """ def purge(self) -> None: @@ -829,55 +835,6 @@ def removeSchedule(self, schedule:SCH) -> bool: class TinyDBBinding(object): """ This class implements the TinyDB binding to the database. It is used by the Storage class. - - Attributes: - path: Path to the database directory. - cacheSize: Size of the cache for the TinyDB tables. - writeDelay: Delay for writing to the database. - maxRequests: Maximum number of oneM2M recorded requests to keep in the database. - lockResources: Lock for the resources table. - lockIdentifiers: Lock for the identifiers table. - lockChildResources: Lock for the childResources table. - lockStructuredIDs: Lock for the structuredIDs table. - lockSubscriptions: Lock for the subscriptions table. - lockBatchNotifications: Lock for the batchNotifications table. - lockStatistics: Lock for the statistics table. - lockActions: Lock for the actions table. - lockRequests: Lock for the requests table. - lockSchedules: Lock for the schedules table. - fileResources: Filename for the resources table. - fileIdentifiers: Filename for the identifiers table. - fileSubscriptions: Filename for the subscriptions table. - fileBatchNotifications: Filename for the batchNotifications table. - fileStatistics: Filename for the statistics table. - fileActions: Filename for the actions table. - fileRequests: Filename for the requests table. - fileSchedules: Filename for the schedules table. - dbResources: The TinyDB database for the resources table. - dbIdentifiers: The TinyDB database for the identifiers table. - dbSubscriptions: The TinyDB database for the subscriptions table. - dbBatchNotifications: The TinyDB database for the batchNotifications table. - dbStatistics: The TinyDB database for the statistics table. - dbActions: The TinyDB database for the actions table. - dbRequests: The TinyDB database for the requests table. - dbSchedules: The TinyDB database for the schedules table. - tabResources: The TinyDB table for the resources table. - tabIdentifiers: The TinyDB table for the identifiers table. - tabChildResources: The TinyDB table for the childResources table. - tabStructuredIDs: The TinyDB table for the structuredIDs table. - tabSubscriptions: The TinyDB table for the subscriptions table. - tabBatchNotifications: The TinyDB table for the batchNotifications table. - tabStatistics: The TinyDB table for the statistics table. - tabActions: The TinyDB table for the actions table. - tabRequests: The TinyDB table for the requests table. - tabSchedules: The TinyDB table for the schedules table. - resourceQuery: The TinyDB query object for the resources table. - identifierQuery: The TinyDB query object for the identifiers table. - subscriptionQuery: The TinyDB query object for the subscriptions table. - batchNotificationQuery: The TinyDB query object for the batchNotifications table. - actionsQuery: The TinyDB query object for the actions table. - requestsQuery: The TinyDB query object for the requests table. - schedulesQuery: The TinyDB query object for the schedules table. """ __slots__ = ( @@ -945,111 +902,159 @@ def __init__(self, path:str, postfix:str) -> None: """ self.path = path + """ Path to the database directory. """ self._assignConfig() + """ Assign configuration values. """ L.isInfo and L.log(f'Cache Size: {self.cacheSize:d}') # create transaction locks self.lockResources = Lock() + """ Lock for the resources table.""" self.lockIdentifiers = Lock() + """ Lock for the identifiers table.""" self.lockChildResources = Lock() + """ Lock for the childResources table.""" self.lockStructuredIDs = Lock() + """ Lock for the structuredIDs table.""" self.lockSubscriptions = Lock() + """ Lock for the subscriptions table.""" self.lockBatchNotifications = Lock() + """ Lock for the batchNotifications table.""" self.lockStatistics = Lock() + """ Lock for the statistics table.""" self.lockActions = Lock() + """ Lock for the actions table.""" self.lockRequests = Lock() + """ Lock for the requests table.""" self.lockSchedules = Lock() + """ Lock for the schedules table.""" # file names self.fileResources = f'{self.path}/{_resources}-{postfix}.json' + """ Filename for the resources table.""" self.fileIdentifiers = f'{self.path}/{_identifiers}-{postfix}.json' + """ Filename for the identifiers table.""" self.fileSubscriptions = f'{self.path}/{_subscriptions}-{postfix}.json' + """ Filename for the subscriptions table.""" self.fileBatchNotifications = f'{self.path}/{_batchNotifications}-{postfix}.json' + """ Filename for the batchNotifications table.""" self.fileStatistics = f'{self.path}/{_statistics}-{postfix}.json' + """ Filename for the statistics table.""" self.fileActions = f'{self.path}/{_actions}-{postfix}.json' + """ Filename for the actions table.""" self.fileRequests = f'{self.path}/{_requests}-{postfix}.json' + """ Filename for the requests table.""" self.fileSchedules = f'{self.path}/{_schedules}-{postfix}.json' + """ Filename for the schedules table.""" # All databases/tables will use the smart query cache if Configuration.get('database.inMemory'): L.isInfo and L.log('DB in memory') self.dbResources = TinyDB(storage = MemoryStorage) + """ The TinyDB database for the resources table.""" self.dbIdentifiers = TinyDB(storage = MemoryStorage) + """ The TinyDB database for the identifiers table.""" self.dbSubscriptions = TinyDB(storage = MemoryStorage) + """ The TinyDB database for the subscriptions table.""" self.dbBatchNotifications = TinyDB(storage = MemoryStorage) + """ The TinyDB database for the batchNotifications table.""" self.dbStatistics = TinyDB(storage = MemoryStorage) + """ The TinyDB database for the statistics table.""" self.dbActions = TinyDB(storage = MemoryStorage) + """ The TinyDB database for the actions table.""" self.dbRequests = TinyDB(storage = MemoryStorage) + """ The TinyDB database for the requests table.""" self.dbSchedules = TinyDB(storage = MemoryStorage) + """ The TinyDB database for the schedules table.""" else: L.isInfo and L.log('DB in file system') - # self.dbResources = TinyDB(self.fileResources) - # self.dbIdentifiers = TinyDB(self.fileIdentifiers) - # self.dbSubscriptions = TinyDB(self.fileSubscriptions) - # self.dbBatchNotifications = TinyDB(self.fileBatchNotifications) - # self.dbStatistics = TinyDB(self.fileStatistics) - # self.dbActions = TinyDB(self.fileActions) - - # EXPERIMENTAL Using TinyDBBufferedStorage - Buffers read and writes to disk self.dbResources = TinyDB(self.fileResources, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) + """ The TinyDB database for the resources table.""" self.dbIdentifiers = TinyDB(self.fileIdentifiers, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) + """ The TinyDB database for the identifiers table.""" self.dbSubscriptions = TinyDB(self.fileSubscriptions, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) + """ The TinyDB database for the subscriptions table.""" self.dbBatchNotifications = TinyDB(self.fileBatchNotifications, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) + """ The TinyDB database for the batchNotifications table.""" self.dbStatistics = TinyDB(self.fileStatistics, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) + """ The TinyDB database for the statistics table.""" self.dbActions = TinyDB(self.fileActions, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) + """ The TinyDB database for the actions table.""" self.dbRequests = TinyDB(self.fileRequests, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) + """ The TinyDB database for the requests table.""" self.dbSchedules = TinyDB(self.fileSchedules, storage = TinyDBBufferedStorage, write_delay = self.writeDelay) + """ The TinyDB database for the schedules table.""" # Open/Create tables self.tabResources = self.dbResources.table(_resources, cache_size = self.cacheSize) + """ The TinyDB table for the resources table.""" TinyDBBetterTable.assign(self.tabResources) self.tabIdentifiers = self.dbIdentifiers.table(_identifiers, cache_size = self.cacheSize) + """ The TinyDB table for the identifiers table.""" TinyDBBetterTable.assign(self.tabIdentifiers) self.tabChildResources = self.dbIdentifiers.table(_children, cache_size = self.cacheSize) + """ The TinyDB table for the childResources table.""" TinyDBBetterTable.assign(self.tabChildResources) self.tabStructuredIDs = self.dbIdentifiers.table('srn', cache_size = self.cacheSize) + """ The TinyDB table for the structuredIDs table.""" TinyDBBetterTable.assign(self.tabStructuredIDs) self.tabSubscriptions = self.dbSubscriptions.table(_subscriptions, cache_size = self.cacheSize) + """ The TinyDB table for the subscriptions table.""" TinyDBBetterTable.assign(self.tabSubscriptions) self.tabBatchNotifications = self.dbBatchNotifications.table(_batchNotifications, cache_size = self.cacheSize) + """ The TinyDB table for the batchNotifications table.""" TinyDBBetterTable.assign(self.tabBatchNotifications) self.tabStatistics = self.dbStatistics.table(_statistics, cache_size = self.cacheSize) + """ The TinyDB table for the statistics table.""" TinyDBBetterTable.assign(self.tabStatistics) self.tabActions = self.dbActions.table(_actions, cache_size = self.cacheSize) + """ The TinyDB table for the actions table.""" TinyDBBetterTable.assign(self.tabActions) self.tabRequests = self.dbRequests.table(_requests, cache_size = self.cacheSize) + """ The TinyDB table for the requests table.""" TinyDBBetterTable.assign(self.tabRequests) self.tabSchedules = self.dbSchedules.table(_schedules, cache_size = self.cacheSize) + """ The TinyDB table for the schedules table.""" TinyDBBetterTable.assign(self.tabSchedules) # Create the Queries self.resourceQuery = Query() + """ The TinyDB query object for the resources table.""" self.identifierQuery = Query() + """ The TinyDB query object for the identifiers table.""" self.subscriptionQuery = Query() + """ The TinyDB query object for the subscriptions table.""" self.batchNotificationQuery = Query() + """ The TinyDB query object for the batchNotifications table.""" self.actionsQuery = Query() + """ The TinyDB query object for the actions table.""" self.requestsQuery = Query() + """ The TinyDB query object for the requests table.""" self.schedulesQuery = Query() + """ The TinyDB query object for the schedules table.""" def _assignConfig(self) -> None: """ Assign default configurations. """ self.cacheSize = Configuration.get('database.cacheSize') + """ Size of the cache for the TinyDB tables. """ self.writeDelay = Configuration.get('database.writeDelay') + """ Delay for writing to the database. """ self.maxRequests = Configuration.get('cse.operation.requests.size') + """ Maximum number of oneM2M recorded requests to keep in the database. """ def closeDB(self) -> None: From c13643d152bb3582497035e91c5b6f53276dd87e Mon Sep 17 00:00:00 2001 From: ankraft Date: Sat, 21 Oct 2023 01:02:26 +0200 Subject: [PATCH 163/165] Small correction --- acme/services/NotificationManager.py | 1 - tests/testCRS.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/acme/services/NotificationManager.py b/acme/services/NotificationManager.py index f219510b..b7b0079d 100644 --- a/acme/services/NotificationManager.py +++ b/acme/services/NotificationManager.py @@ -614,7 +614,6 @@ def _crsCheckForNotification(self, data:list[str], # Test for conditions if ((eem is None or eem == EventEvaluationMode.ALL_EVENTS_PRESENT) and len(data) == subCount) or \ - (eem == EventEvaluationMode.ALL_EVENTS_PRESENT and len(data) == subCount) or \ (eem == EventEvaluationMode.ALL_OR_SOME_EVENTS_PRESENT and 1 <= len(data) <= subCount) or \ (eem == EventEvaluationMode.SOME_EVENTS_MISSING and 1 <= len(data) < subCount) or \ (eem == EventEvaluationMode.ALL_OR_SOME_EVENTS_MISSING and 0 <= len(data) < subCount) or \ diff --git a/tests/testCRS.py b/tests/testCRS.py index 4f623b1e..340327c5 100644 --- a/tests/testCRS.py +++ b/tests/testCRS.py @@ -1419,7 +1419,6 @@ def test_createCRSPeriodicAllSomeEventsMissingSome(self) -> None: @unittest.skipIf(noCSE, 'No CSEBase') def test_createCRSPeriodicAllSomeEventsMissingNone(self) -> None: """ CREATE with rrat, one encs, periodic window, all or some events missing, no event""" - clearLastNotification() dct = { 'm2m:crs' : { 'rn' : crsRN, 'nu' : [ '/id-in/'+TestCRS.originator ], @@ -1444,6 +1443,7 @@ def test_createCRSPeriodicAllSomeEventsMissingNone(self) -> None: # Create NO CIN # wait and check notification + clearLastNotification() testSleep(crsTimeWindowSize + 1.0) self.assertIsNotNone(notification := getLastNotification()) self.assertIsNotNone(findXPath(notification, 'm2m:sgn')) From 48574355c0578e3dcc4e9b3340ac8c0472645bc3 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 22 Oct 2023 12:08:08 +0200 Subject: [PATCH 164/165] More documentation --- acme/etc/Types.py | 25 ++++++++++++ acme/helpers/BackgroundWorker.py | 68 ++++++++++++++++++-------------- 2 files changed, 64 insertions(+), 29 deletions(-) diff --git a/acme/etc/Types.py b/acme/etc/Types.py index e776cd3c..f4598e3a 100644 --- a/acme/etc/Types.py +++ b/acme/etc/Types.py @@ -209,10 +209,22 @@ class ResourceTypes(ACMEIntEnum): def tpe(self) -> str: + """ Get the resource type name. + + Return: + The resource type name. + """ return _ResourceTypesNames.get(self) def announced(self, mgd:Optional[ResourceTypes] = None) -> ResourceTypes: + """ Get the announced resource type for a resource type. + + Args: + mgd: The mgmtObj specialization type. Only used for mgmtObjs. + Return: + The announced resource type, or UNKNOWN. + """ if self != ResourceTypes.MGMTOBJ: # Handling for non-mgmtObjs @@ -502,6 +514,7 @@ class ResourceDescription(): ResourceTypes.COMPLEX : ResourceDescription(typeName = 'complex', isInternalType = True), } +""" Mapping between resource types and their description. """ def addResourceFactoryCallback(ty:ResourceTypes, clazz:Resource, factory:FactoryCallableT) -> None: # type:ignore [name-defined] @@ -1029,6 +1042,7 @@ def default(cls, op:Operation) -> ResultContentType: ResultContentType.childResourceReferences ], Operation.NOTIFY: [ ResultContentType.nothing ], } +""" Mappings between request operations and allowed Result Content """ _ResultContentTypeDefaults = { Operation.RETRIEVE: ResultContentType.attributes, @@ -1038,6 +1052,7 @@ def default(cls, op:Operation) -> ResultContentType: Operation.DELETE: ResultContentType.nothing, Operation.NOTIFY: None, } +""" Mappings between request operations and default Result Content """ # ResultContentType.discoveryRCN = [ ResultContentType.discoveryResultReferences, # type: ignore @@ -1405,6 +1420,7 @@ def defaultNCT(self) -> NotificationContentType: NotificationEventType.blockingUpdate: NotificationContentType.modifiedAttributes, NotificationEventType.reportOnGeneratedMissingDataPoints: NotificationContentType.timeSeriesNotification } +""" Mappings between NotificationEventType and default NotificationContentType """ ############################################################################## # @@ -1676,6 +1692,7 @@ class SemanticFormat(ACMEIntEnum): SemanticFormat.FF_Manchester: 'manchester', SemanticFormat.FF_JsonLD: 'json-ld', } +""" Mappings between semantic formats and strings representations. """ ############################################################################## @@ -1814,6 +1831,14 @@ class Result: def toData(self, ct:Optional[ContentSerializationType] = None) -> str|bytes|JSON: + """ Return the result data as a string or bytes or JSON. + + Args: + ct: The content serialization type to use. If not given, the default serialization type is used. + + Return: + The result data as a string or bytes or JSON. + """ from ..resources.Resource import Resource from ..etc.RequestUtils import serializeData from ..services.CSE import defaultSerialization diff --git a/acme/helpers/BackgroundWorker.py b/acme/helpers/BackgroundWorker.py index 7ba52315..35a3f79c 100644 --- a/acme/helpers/BackgroundWorker.py +++ b/acme/helpers/BackgroundWorker.py @@ -40,25 +40,6 @@ class BackgroundWorker(object): worker callback. Background workers can be stopped and started again. They can also be paused and resumed. - - Attributes: - interval: Interval in seconds to run the worker callback. - runOnTime: If True then the worker is always run *at* the interval, otherwise the interval starts *after* the worker execution. - runPastEvents: If True then runs in the past are executed, otherwise they are dismissed. - nextRunTime: Timestamp of the next execution. - callback: Callback to run as a worker. - running: True if the worker is running. - executing: True if the worker is currently executing. - name: Name of the worker. - startWithDelay: If True then start the worker after a `interval` delay. - maxCount: Maximum number runs. - numberOfRuns: Number of runs. - dispose: If True then dispose the worker after finish. - finished: Callable that is executed after the worker finished. - ignoreException: Restart the actor in case an exception is encountered. - id: Unique ID of the worker. - data: Any data structure that is stored in the worker and accessible by the *data* attribute, and which is passed as the first argument in the *_data* argument of the *workerCallback* if not *None*. - args: Additional arguments passed to the worker callback. """ __slots__ = ( @@ -80,6 +61,7 @@ class BackgroundWorker(object): 'data', 'args', ) + """ Slots for the class. """ # Holds a reference to an specific logging function. # This must have the same signature as the `logging.log` method. @@ -118,21 +100,39 @@ def __init__(self, data: Any data structure that is stored in the worker and accessible by the *data* attribute, and which is passed as the first argument in the *_data* argument of the *workerCallback* if not *None*. """ self.interval = interval + """ Interval in seconds to run the worker callback. """ self.runOnTime = runOnTime # Compensate for processing time + """ If True then the worker is always run *at* the interval, otherwise the interval starts *after* the worker execution. """ self.runPastEvents = runPastEvents # Run events that are in the past + """ If True then missed worker runs in the past are executed, otherwise they are dismissed. """ self.nextRunTime:float = None # Timestamp + """ Timestamp of the next execution. """ self.callback = callback # Actual callback to process + """ Callback function to run as a worker. """ self.running = False # Indicator that a worker is running or will be stopped + """ True if the worker is running. """ self.executing = False # Indicator that the worker callback is currently executed + """ True if the worker is currently executing. """ self.name = name + """ Name of the worker. """ self.startWithDelay = startWithDelay + """ If True then start the worker after a `interval` delay. """ self.maxCount = maxCount # max runs + """ Maximum number runs. """ self.numberOfRuns = 0 # Actual runs + """ Number of runs. """ self.dispose = dispose # Only run once, then remove itself from the pool + """ If True then dispose the worker after finish. """ self.finished = finished # Callback after worker finished + """ Callback that is executed after the worker finished. """ self.ignoreException = ignoreException # Ignore exception when running workers + """ Restart the actor in case an exception is encountered. """ self.id = id + """ Unique ID of the worker. """ self.data = data # Any extra data + """ Any data structure that is stored in the worker and accessible by the *data* attribute, and which is passed as the first argument in the *_data* argument of the *workerCallback* if not *None*. """ + self.args:Dict[str, Any] = {} # Arguments for the callback + """ Arguments for the callback. """ @@ -342,18 +342,26 @@ class Job(Thread): 'Callable', 'finished', ) + """ Slots for the class.""" - jobListLock = RLock() # Re-entrent lock (for the same thread) + jobListLock = RLock() + """ Lock for the job lists. """ # Paused and running job lists pausedJobs:list[Job] = [] + """ List of paused jobs. """ runningJobs:list[Job] = [] + """ List of running jobs. """ # Defaults for reducing overhead jobs - _balanceTarget:float = 3.0 # Target balance between paused and running jobs (n paused for 1 running) - _balanceLatency:int = 1000 # Number of requests for getting a new Job before a check - _balanceReduceFactor:float = 2.0 # Factor to reduce the paused jobs (number of paused / balanceReduceFactor) - _balanceCount:int = 0 # Counter for current runs. Compares against balance + _balanceTarget:float = 3.0 + """ Target balance between paused and running jobs (n paused for 1 running). """ + _balanceLatency:int = 1000 + """ Number of requests for getting a new Job before a balance check. """ + _balanceReduceFactor:float = 2.0 + """ Factor to reduce the paused jobs (number of paused / balanceReduceFactor). """ + _balanceCount:int = 0 + """ Counter for current runs. Compares against balance. """ def __init__(self, *args:Any, **kwargs:Any) -> None: @@ -519,11 +527,6 @@ def setJobBalance(cls, balanceTarget:Optional[float] = 3.0, class WorkerEntry(object): """ Internal class for a worker entry in the priority queue. - - Attributes: - timestamp: Timestamp of the next execution. - workerID: ID of the worker. - workerName: Name of the worker. """ __slots__ = ( @@ -531,6 +534,7 @@ class WorkerEntry(object): 'workerID', 'workerName', ) + """ Slots for the class. """ def __init__(self, timestamp:float, workerID:int, workerName:str) -> None: """ Initialize a WorkerEntry. @@ -541,8 +545,11 @@ def __init__(self, timestamp:float, workerID:int, workerName:str) -> None: workerName: Name of the worker. """ self.timestamp = timestamp + """ Timestamp of the next execution. """ self.workerID = workerID + """ ID of the worker. """ self.workerName = workerName + """ Name of the worker. """ def __lt__(self, other:WorkerEntry) -> bool: @@ -580,13 +587,16 @@ class BackgroundWorkerPool(object): """ backgroundWorkers:Dict[int, BackgroundWorker] = {} + """ All background workers. """ workerQueue:list[WorkerEntry] = [] """ Priority queue. Contains tuples (next execution timestamp, worker ID, worker name). """ workerTimer:Timer = None """ A single timer to run the next task in the *workerQueue*. """ queueLock:Lock = Lock() + """ Lock for the *workerQueue*. """ timerLock:Lock = Lock() + """ Lock for the *workerTimer*. """ def __new__(cls, *args:str, **kwargs:str) -> BackgroundWorkerPool: From aec94ac0d13b6f82a3a20588b64ae6319d4b0039 Mon Sep 17 00:00:00 2001 From: ankraft Date: Sun, 22 Oct 2023 12:32:18 +0200 Subject: [PATCH 165/165] New requirements.txt file --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f58a50c3..45c27719 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ # blinker==1.6.3 # via flask -cbor2==5.4.6 +cbor2==5.5.0 # via ACME-oneM2M-CSE (setup.py) certifi==2023.7.22 # via requests