diff --git a/async_tests/test_async_ldap_model.py b/async_tests/test_async_ldap_model.py index 363c2ff..4b83d92 100644 --- a/async_tests/test_async_ldap_model.py +++ b/async_tests/test_async_ldap_model.py @@ -30,7 +30,6 @@ from phi.async_ldap.model import ( ) from phi.security import hash_pass - BASE_DN = "dc=test,dc=abbiamoundominio,dc=org" USER_LIST = [{"uid": ["conte_mascetti"]}, {"uid": ["perozzi"]}, {"uid": ["necchi"]}] SERVICE_LIST = [{"uid": ["phi"]}, {"uid": ["irc"]}] diff --git a/async_tests/test_async_ldap_new_model.py b/async_tests/test_async_ldap_new_model.py new file mode 100644 index 0000000..c8d4132 --- /dev/null +++ b/async_tests/test_async_ldap_new_model.py @@ -0,0 +1,96 @@ +# -*- encoding: utf-8 -*- +import asyncio + +from async_generator import asynccontextmanager +from bonsai import LDAPEntry +import mock +import pytest + +from phi.async_ldap.new_model import get_dn, User + +BASE_DN = "dc=test,dc=domain,dc=tld" + + +class MockClient(object): + def __init__(self, *args, **kwargs): + self.return_value = kwargs.get("return_value") + self.connect_called = False + self.conn = mock.MagicMock() + + def connect_called_with_search(self): + self.conn.search.assert_called() + + def connect_called_with_add(self): + self.conn.add.assert_called() + + def connect_called_with_modify(self): + self.conn.modify.assert_called() + + def connect_called_with_delete(self): + self.conn.delete.assert_called() + + @property + def base_dn(self): + return BASE_DN + + @asynccontextmanager + async def connect(self, *args, **kwargs): + self.connect_called = True + + async def _search(*a, **kw): + return self.return_value + + async def _add(*a, **kw): + return self.return_value + + async def _modify(*a, **kw): + return self.return_value + + async def _delete(*a, **kw): + return self.return_value + + self.conn.search = mock.MagicMock(side_effect=_search) + self.conn.add = mock.MagicMock(side_effect=_add) + self.conn.modify = mock.MagicMock(side_effect=_modify) + self.conn.delete = mock.MagicMock(side_effect=_delete) + + yield self.conn + + +cl = mock.MagicMock() +cl.base_dn = BASE_DN + + +@pytest.mark.parametrize( + "input_obj, expected_result", + [ + (User(cl, "test_user"), f"uid=test_user,ou=Hackers,{BASE_DN}"), + ( + LDAPEntry(f"uid=test_user,ou=Hackers,{BASE_DN}"), + f"uid=test_user,ou=Hackers,{BASE_DN}", + ), + (f"uid=test_user,ou=Hackers,{BASE_DN}", f"uid=test_user,ou=Hackers,{BASE_DN}"), + ], +) +def test_get_dn(input_obj, expected_result): + assert get_dn(input_obj) == expected_result + + +def test_get_dn_raises(): + with pytest.raises(ValueError) as e: + _ = get_dn(object) + + assert "Unacceptable input:" in str(e.value) + + +@pytest.mark.asyncio +async def test_User_add(): + _cl = MockClient(return_value=None) + u = User(_cl, "test_user") + + assert u.dn == f"uid=test_user,ou=Hackers,{BASE_DN}" + + _ = await u.save() + + assert _cl.connect_called + _cl.connect_called_with_add() diff --git a/openldap/init.ldif b/openldap/init.ldif index 6579bb4..f7bfe91 100644 --- a/openldap/init.ldif +++ b/openldap/init.ldif @@ -11,31 +11,31 @@ objectClass: organizationalUnit objectClass: top ou: Hackers -dn: ou=Services,dc=unit,dc=macaomilano,dc=org +dn: ou=Robots,dc=unit,dc=macaomilano,dc=org objectClass: top objectClass: organizationalUnit -ou: Services +ou: Robots -dn: ou=Groups,dc=unit,dc=macaomilano,dc=org +dn: ou=Congregations,dc=unit,dc=macaomilano,dc=org objectClass: top objectClass: organizationalUnit -ou: Groups +ou: Congregations -dn: uid=phi,ou=Services,dc=unit,dc=macaomilano,dc=org +dn: uid=phi,ou=Robots,dc=unit,dc=macaomilano,dc=org objectClass: account objectClass: simpleSecurityObject objectClass: top uid: phi userPassword: {SHA}REu9CtcqSaA1c5J+sEYlTgg0H+M= -dn: uid=irc,ou=Services,dc=unit,dc=macaomilano,dc=org +dn: uid=irc,ou=Robots,dc=unit,dc=macaomilano,dc=org objectClass: account objectClass: simpleSecurityObject objectClass: top uid: irc userPassword: {SHA}0WvxFW9MSsesf55SOh4vnuwdkgY= -dn: uid=git,ou=Services,dc=unit,dc=macaomilano,dc=org +dn: uid=git,ou=Robots,dc=unit,dc=macaomilano,dc=org objectClass: account objectClass: simpleSecurityObject objectClass: top @@ -47,9 +47,9 @@ objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person objectClass: top -memberOf: cn=Admins,ou=Groups,dc=unit,dc=macaomilano,dc=org -memberOf: cn=GitUsers,ou=Groups,dc=unit,dc=macaomilano,dc=org -memberOf: cn=IRCUsers,ou=Groups,dc=unit,dc=macaomilano,dc=org +memberOf: cn=Admins,ou=Congregations,dc=unit,dc=macaomilano,dc=org +memberOf: cn=GitUsers,ou=Congregations,dc=unit,dc=macaomilano,dc=org +memberOf: cn=IRCUsers,ou=Congregations,dc=unit,dc=macaomilano,dc=org cn: Raffaello sn: Mascetti mail: rmascetti@autistici.org @@ -61,8 +61,8 @@ objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person objectClass: top -memberOf: cn=GitUsers,ou=Groups,dc=unit,dc=macaomilano,dc=org -memberOf: cn=IRCUsers,ou=Groups,dc=unit,dc=macaomilano,dc=org +memberOf: cn=GitUsers,ou=Congregations,dc=unit,dc=macaomilano,dc=org +memberOf: cn=IRCUsers,ou=Congregations,dc=unit,dc=macaomilano,dc=org cn: Guido sn: Necchi mail: gnecchi@autistici.org @@ -74,20 +74,20 @@ objectClass: inetOrgPerson objectClass: organizationalPerson objectClass: person objectClass: top -memberOf: cn=GitUsers,ou=Groups,dc=unit,dc=macaomilano,dc=org +memberOf: cn=GitUsers,ou=Congregations,dc=unit,dc=macaomilano,dc=org cn: Giorgio sn: Perozzi mail: gperozzi@autistici.org uid: perozzi userPassword: {SHA}0+CRQKqsTj1I82PHxvZ4ebbddXQ= -dn: cn=Admins,ou=Groups,dc=unit,dc=macaomilano,dc=org +dn: cn=Admins,ou=Congregations,dc=unit,dc=macaomilano,dc=org member: uid=conte_mascetti,ou=Hackers,dc=unit,dc=macaomilano,dc=org cn: Admins objectClass: groupOfNames objectClass: top -dn: cn=GitUsers,ou=Groups,dc=unit,dc=macaomilano,dc=org +dn: cn=GitUsers,ou=Congregations,dc=unit,dc=macaomilano,dc=org member: uid=conte_mascetti,ou=Hackers,dc=unit,dc=macaomilano,dc=org member: uid=necchi,ou=Hackers,dc=unit,dc=macaomilano,dc=org member: uid=perozzi,ou=Hackers,dc=unit,dc=macaomilano,dc=org @@ -95,7 +95,7 @@ cn: GitUsers objectClass: groupOfNames objectClass: top -dn: cn=IRCUsers,ou=Groups,dc=unit,dc=macaomilano,dc=org +dn: cn=IRCUsers,ou=Congregations,dc=unit,dc=macaomilano,dc=org member: uid=conte_mascetti,ou=Hackers,dc=unit,dc=macaomilano,dc=org member: uid=necchi,ou=Hackers,dc=unit,dc=macaomilano,dc=org cn: IRCUsers diff --git a/src/phi/async_ldap/mixins.py b/src/phi/async_ldap/mixins.py index 89b6b9b..ee4ab2d 100644 --- a/src/phi/async_ldap/mixins.py +++ b/src/phi/async_ldap/mixins.py @@ -1,5 +1,7 @@ # -*- encoding: utf-8 -*- from bonsai import LDAPEntry, LDAPModOp, NoSuchObjectError # type: ignore +from bonsai.ldapvaluelist import LDAPValueList +import bonsai.errors from phi.exceptions import ( PhiAttributeMissing, @@ -7,10 +9,10 @@ from phi.exceptions import ( PhiEntryExists, PhiUnauthorized, PhiUnexpectedRuntimeValue, + PhiCannotExecute, ) -ATTR_TAG = "_ldap_" -ATTR_TAG_LEN = len(ATTR_TAG) +from phi.security import hash_pass, handle_password async def build_heritage(obj, child_class, attribute_id="uid"): @@ -21,18 +23,20 @@ async def build_heritage(obj, child_class, attribute_id="uid"): async for child in obj.get_children(): if attribute_id in child: _name = child[attribute_id][0] - yield child_class(_name, obj.client) + yield child_class(obj.client, _name) class Singleton(object): """ - to singletonize a class. The class is crafted to be used with the mixins + Mixin to singletonize a class. The class is crafted to be used with the mixins that implement the compatible __init__. """ - def __new__(cls, client, *args): - if "name" in args: + def __new__(cls, client, *args, **kwargs): + if "name" in kwargs: name = f"{cls.__name__}-{args['name']}-{id(client)}" + elif args: + name = f"{cls.__name__}-{args[0]}-{id(client)}" else: name = f"{cls.__name__}-{id(client)}" if name not in cls._instances: @@ -40,13 +44,26 @@ class Singleton(object): return cls._instances[name] +def get_value(obj, attr): + """ + Return the tuple (attribute_name, attribute_value) from obj. Extract the value, + either it being a constant or the result of a function call. + """ + if not isinstance(getattr(type(obj), attr), property) and callable( + getattr(type(obj), attr) + ): + return attr, getattr(obj, attr)() + else: + return attr, getattr(obj, attr) + + class Entry(object): """ - to interact with LDAP. + Mixin to interact with LDAP. """ def get_all_ldap_attributes(self): - return [attr for attr in dir(self) if attr.startswith(ATTR_TAG)] + return [get_value(self, attr) for attr in self.ldap_attributes] def __repr__(self): return f"<{self.__class__.__name__} {self.dn}>" @@ -55,24 +72,17 @@ class Entry(object): return f"<{self.__class__.__name__} {self.dn}>" def __iter__(self): - for attr in self.get_all_ldap_attributes(): - if not isinstance(getattr(type(self), attr), property) and callable( - getattr(type(self), attr) - ): - yield attr[ATTR_TAG_LEN:], getattr(self, attr)() - else: - yield attr[ATTR_TAG_LEN:], getattr(self, attr) + for k, v in self.get_all_ldap_attributes(): + yield k, v def __dict__(self): return dict(self) async def _create_new(self): - entry = LDAPEntry(self.dn) - entry["objectClass"] = self.object_class - entry.update(self.get_all_ldap_attributes()) + self._entry["objectClass"] = self.object_class async with self.client.connect(is_async=True) as conn: - await conn.add(entry) - return entry + await conn.add(self._entry) + return self._entry async def _get(self): async with self.client.connect(is_async=True) as conn: @@ -80,27 +90,50 @@ class Entry(object): # the one we are interested. _res = await conn.search(self.dn, 0) if len(_res) == 0: - return + raise PhiEntryDoesNotExist() elif len(_res) > 1: raise PhiUnexpectedRuntimeValue( "return value should be no more than one", res ) return _res[0] + async def _modify(self): + _ = await self._get() + async with self.client.connect(is_async=True) as conn: + self._entry.connection = conn + await self._entry.modify() + + async def _delete(self): + async with self.client.connect(is_async=True) as conn: + await conn.delete(self.dn) + async def describe(self): - res = dict(await self._get()) - res["dn"] = self.dn - res["name"] = self.name - return res + return dict(await self._get()) + + @property + def delete_cascade(self): + if hasattr(self, "_delete_cascade"): + return self._delete_cascade + return False + + @delete_cascade.setter + def delete_cascade(self, value): + if not isinstance(value, bool): + raise ValueError("delete_cascade must be a boolean") + self._delete_cascade = value class OrganizationalUnit(object): - def __init__(self, client): + object_class = ["organizationalUnit", "top"] + + def __init__(self, client, **kwargs): self.client = client self.base_dn = client.base_dn self.name = self.__class__.__name__ - self.children = build_heritage(self, getattr(self, "child_class")) + self.children = build_heritage(self, self.child_class, self.child_class.id_tag) self._entry = LDAPEntry(self.dn) + for k, v in kwargs.items(): + self._entry[k] = v def __aiter__(self): return self @@ -109,28 +142,117 @@ class OrganizationalUnit(object): try: return await self.children.__anext__() except StopAsyncIteration: - self.children = build_heritage(self, getattr(self, "child_class")) + self.children = build_heritage( + self, self.child_class, self.child_class.id_tag + ) raise async def get_children(self): async with self.client.connect(is_async=True) as conn: - for el in await conn.search(self.dn, 2): + for el in await conn.search(self.dn, 1): yield el @property def dn(self): - return f"ou={self.ou_name},{self.base_dn}" + return f"ou={self.ou},{self.base_dn}" + + async def save(self): + try: + await self._create_new() + except bonsai.errors.AlreadyExists: + raise PhiEntryExists(self.dn) + + async def search(self, member_name): + result = None + async with self.client.connect(is_async=True) as conn: + result = await conn.search( + f"{self.child_class.id_tag}={member_name},{self.dn}", 0 + ) + if not result: + raise PhiEntryDoesNotExist( + f"{self.child_class.id_tag}={member_name},{self.dn}" + ) + if isinstance(result, list) and len(result) > 1: + raise PhiUnexpectedRuntimeValue( + "return value should be no more than one", result + ) + return self.child_class(self.client, member_name, **result[0]) + + async def delete(self): + """ + Delete all the members of this OU only if `delete_cascade` is set to `True`, + raises otherwise. + """ + if self.delete_cascade: + async for member in self: + await member.delete() + else: + raise PhiCannotExecute("Cannot delete an OU and delete_cascade is not set") -class Child(object): - object_class = ["inetOrgPerson", "organizationalPerson", "person", "top"] +def hydrate(obj, data): + for k, v in data.items(): + if k in obj.ldap_attributes: + if isinstance(v, list) and not isinstance(v, LDAPValueList): + obj._entry[k] = [] + for _v in v: + obj._entry[k].append(_v.dn) + elif k == "userPassword": + obj._entry[k] = handle_password(v) + else: + obj._entry[k] = v - def __init__(self, client, name): + +class Member(object): + def __init__(self, client, name, **kwargs): self.client = client self.base_dn = client.base_dn self.name = name self._entry = LDAPEntry(self.dn) + if kwargs: + hydrate(self, kwargs) + + def __eq__(self, other): + if isinstance(other, type(self)): + return other.dn == self.dn + elif isinstance(other, str): + return other == self.dn + elif isinstance(other, LDAPEntry): + return str(other) == self.dn + else: + return False @property def dn(self): - return f"{self.id_tag}={self.name},ou={self.ou_name},{self.base_dn}" + return f"{self.id_tag}={self.name},ou={self.ou},{self.base_dn}" + + def __setitem__(self, attr, val): + if attr not in self.ldap_attributes: + raise PhiCannotExecute( + f"{attr} is not an allowed ldap_attribute: {self.ldap_attributes}" + ) + self._entry[attr] = val + + def __getitem__(self, attr): + if attr not in self.ldap_attributes: + raise PhiCannotExecute( + f"{attr} is not an allowed ldap_attribute: {self.ldap_attributes}" + ) + return self._entry[attr][0] + + async def save(self): + try: + await self._create_new() + except bonsai.errors.AlreadyExists: + raise PhiEntryExists(self.dn) + + async def modify(self): + await self._modify() + + async def delete(self): + await self._delete() + + async def sync(self): + res = await self._get() + hydrate(self, res) + return self diff --git a/src/phi/async_ldap/new_model.py b/src/phi/async_ldap/new_model.py index 098e2c7..645265e 100644 --- a/src/phi/async_ldap/new_model.py +++ b/src/phi/async_ldap/new_model.py @@ -1,15 +1,79 @@ # -*- encoding: utf-8 -*- +from bonsai import LDAPEntry +from multidict import MultiDict + from phi.async_ldap import mixins -class User(mixins.Singleton, mixins.Entry, mixins.Child): - _instances = dict() +class User(mixins.Singleton, mixins.Entry, mixins.Member): + object_class = [ + "inetOrgPerson", + "simpleSecurityObject", + "organizationalPerson", + "person", + "top", + ] + _instances = dict() # type: ignore id_tag = "uid" - ou_name = "Hackers" + ou = "Hackers" + ldap_attributes = ["uid", "ou", "cn", "sn", "mail", "userPassword"] class Hackers(mixins.Singleton, mixins.Entry, mixins.OrganizationalUnit): - _instances = dict() - ou_name = "Hackers" + _instances = dict() # type: ignore + ou = "Hackers" child_class = User + + +class Service(mixins.Singleton, mixins.Entry, mixins.Member): + object_class = ["simpleSecurityObject", "account", "top"] + _instances = dict() # type: ignore + id_tag = "uid" + ou = "Robots" + ldap_attributes = ["uid", "ou", "userPassword"] + + +class Robots(mixins.Singleton, mixins.Entry, mixins.OrganizationalUnit): + _instances = dict() # type: ignore + ou = "Robots" + child_class = Service + + +class Group(mixins.Singleton, mixins.Entry, mixins.Member): + object_class = ["groupOfNames", "top"] + _instances = dict() # type: ignore + id_tag = "cn" + ou = "Congregations" + ldap_attributes = ["uid", "ou", "member"] + memeber_classes = {"Hackers": User, "Robots": Service} + + async def add_member(self, member): + self._entry["member"].append(get_dn(member)) + await self.modify() + + async def remove_member(self, member): + self._entry["member"] = [get_dn(m) for m in self.get_members() if member != m] + await self.modify() + + def get_members(self): + for member in self._entry.get("member", []): + dn = MultiDict(e.split("=") for e in member.split(",")) + yield self.memeber_classes.get(dn["ou"])(self.client, dn["uid"]) + + +class Congregations(mixins.Singleton, mixins.Entry, mixins.OrganizationalUnit): + _instances = dict() # type: ignore + ou = "Congregations" + child_class = Group + + +def get_dn(obj): + if isinstance(obj, mixins.Entry): + return obj.dn + elif isinstance(obj, LDAPEntry): + return obj["dn"] + elif isinstance(obj, str): + return obj + else: + raise ValueError(f"Unacceptable input: {obj}") diff --git a/src/phi/exceptions.py b/src/phi/exceptions.py index e0f8727..e899d10 100644 --- a/src/phi/exceptions.py +++ b/src/phi/exceptions.py @@ -5,11 +5,23 @@ class PhiEntryExists(Exception): def __init__(self, dn): self.dn = dn + def __str__(self): + return f"Entry exists yet ({self.dn})" + + def __repr__(self): + return f"PhiEntryExists({self.dn})" + class PhiEntryDoesNotExist(Exception): def __init__(self, dn): self.dn = dn + def __str__(self): + return f"Entry does not exist ({self.dn})" + + def __repr__(self): + return f"PhiEntryDoesNotExist({self.dn})" + class PhiAttributeMissing(Exception): def __init__(self, dn, attr): @@ -28,3 +40,15 @@ class PhiUnexpectedRuntimeValue(RuntimeWarning): super().__init__(self) self.msg = msg self.result = result + + +class PhiCannotExecute(RuntimeWarning): + def __init__(self, msg): + super().__init__(self) + self.msg = msg + + def __str__(self): + return f"Cannot execute: {self.msg}" + + def __repr__(self): + return f"PhiCannotExecute({self.msg})" diff --git a/src/phi/security.py b/src/phi/security.py index 8bcfc92..699d286 100644 --- a/src/phi/security.py +++ b/src/phi/security.py @@ -1,4 +1,5 @@ # -*- encoding: utf-8 -*- +from bonsai.ldapvaluelist import LDAPValueList from passlib.hash import ( ldap_sha1, ldap_bcrypt, @@ -8,7 +9,6 @@ from passlib.hash import ( ldap_pbkdf2_sha512, ) - HASH_CALLABLE = { "sha1": ldap_sha1.hash, "bcrypt": ldap_bcrypt.hash, @@ -21,3 +21,9 @@ HASH_CALLABLE = { def hash_pass(password, method="sha1"): return HASH_CALLABLE[method](password) + + +def handle_password(password, method="sha1"): + if isinstance(password, LDAPValueList): + return password + return hash_pass(password, method) diff --git a/test/aux_async_model.py b/test/aux_async_model.py index c7d9554..4d3ba32 100644 --- a/test/aux_async_model.py +++ b/test/aux_async_model.py @@ -3,19 +3,17 @@ import asyncio from pprint import pprint as pp -from phi.async_ldap.model import ( - # Hackers, - # User, +from phi.async_ldap.new_model import ( + Hackers, + User, Robots, Service, - Congregations, Group, - build_heritage, - iter_children, + Congregations, ) - -from phi.async_ldap.new_model import Hackers, User +from phi.async_ldap.mixins import build_heritage from phi.async_ldap.client import AsyncClient +import phi.exceptions as e async def dlv(h, cls): @@ -47,15 +45,115 @@ async def get_all_children(): return (hackers, robots, groups) -async def print_async(awaitable): +async def get_members(group): + return [el async for el in group] + + +async def print_async(label, awaitable): + print(label) result = await awaitable pp(result) async def describe(obj): - results = await obj.describe() - return results + return await obj.describe() -asyncio.run(print_async(describe(Hackers(cl)))) -asyncio.run(print_async(describe(User(cl, "conte_mascetti")))) +async def _await(awaitable): + return await awaitable + + +def sync_await(awaitable): + return asyncio.run(_await(awaitable)) + + +h = Hackers(cl) +r = Robots(cl) +c = Congregations(cl) + + +# asyncio.run(print_async("hackers:", describe(h))) +# asyncio.run(print_async("conte_mascetti:", describe(User(cl, "conte_mascetti")))) +# asyncio.run(print_async("robots:", describe(r))) +# asyncio.run(print_async("phi:", describe(Service(cl, "phi")))) +# asyncio.run(print_async("congregations:", describe(c))) +# asyncio.run(print_async("GitUsers:", describe(Group(cl, "GitUsers")))) + +# asyncio.run(print_async("Hackers members:", get_members(h))) +# asyncio.run(print_async("Robots members:", get_members(r))) +# asyncio.run(print_async("Congregations members:", get_members(c))) + +# +async def add_new(obj, name, **kw): + try: + _new = obj(cl, name, **kw) + await _new.save() + except e.PhiEntryExists as err: + print(f"Failed add: {repr(err)}") + + +async def safe_search(group, name): + try: + res = await group.search(name) + print("Search result:", res) + return res + except e.PhiEntryDoesNotExist as err: + print(f"Failed search: {repr(err)}") + + +async def safe_delete(obj, cascade=None): + try: + if cascade: + obj.delete_cascade = cascade + await obj.delete() + except Exception as err: + print(f"Failed delete: {repr(err)}") + + +async def add_member(group, member): + await group.add_member(member) + + +async def remove_member(group, member): + await group.remove_member(member) + + +# asyncio.run(safe_search(h, "pippo")) +# asyncio.run( +# add_new(User, "pippo", cn="Pippo (Goofy)", sn="Pippo", mail="pippo@unit.info") +# ) +# asyncio.run(safe_search(h, "pippo")) +# asyncio.run( +# add_new(User, "pippo", cn="Pippo (Goofy)", sn="Pippo", mail="pippo@unit.net") +# ) +# asyncio.run(safe_delete(asyncio.run(safe_search(h, "pippo")))) +# asyncio.run(print_async("Hackers members:", get_members(h))) +# asyncio.run(safe_delete(h)) +# asyncio.run(print_async("Hackers members:", get_members(h))) +# asyncio.run(safe_delete(h, True)) +# asyncio.run(print_async("Hackers members:", get_members(h))) + +# asyncio.run(safe_search(r, "phi")) +# asyncio.run(print_async("Robots members:", get_members(r))) +# asyncio.run(add_new(Service, "db", userPassword="lolpassword")) +# asyncio.run(print_async("Robots members:", get_members(r))) + +asyncio.run(safe_search(c, "GitUsers")) +asyncio.run(print_async("Congregations members:", get_members(c))) +asyncio.run( + add_new(Group, "naughty", member=[User(cl, "conte_mascetti"), User(cl, "necchi")]) +) +asyncio.run(print_async("Congregations members:", get_members(c))) +asyncio.run(safe_delete(Group(cl, "naughty"))) +asyncio.run(print_async("Congregations members:", get_members(c))) +asyncio.run( + add_new(Group, "naughty", member=[User(cl, "conte_mascetti"), User(cl, "necchi")]) +) +asyncio.run(print_async("Congregations members:", get_members(c))) +print("==> HERE <==") +naughty = sync_await(Group(cl, "naughty").sync()) +print("NAUGHTY =>>", [m for m in naughty.get_members()]) +asyncio.run(add_member(naughty, User(cl, "perozzi"))) +print("NAUGHTY =>>", [m for m in naughty.get_members()]) +asyncio.run(remove_member(naughty, User(cl, "conte_mascetti"))) +print("NAUGHTY =>>", [m for m in naughty.get_members()])