From 9a6d96cad1c0be77f72ee7a1bd95d26876571c43 Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Wed, 11 Nov 2026 17:51:40 +0000 Subject: [PATCH 1/2] group: rename the group profile to config A group's name, description and options were set through `profile`, as an attribute and as a constructor keyword. `profile` is also the keyword that selects which account to connect as, on every resource, so a group created with `profile={...}` connected with no credentials. Group settings are now `config`, as on jobs and repos: org.Group("reports", config={"options": {"allow_inbound_mail": True}}) group.config.options.allow_inbound_mail = True `profile` on a group now only selects the connection. The API paths under `/profile` are unchanged. --- novem/group/__init__.py | 16 ++-- novem/group/{profile.py => config.py} | 23 ++--- tests/test_org.py | 70 +++++++------- tests/test_org_group.py | 127 +++++++++++++++++--------- 4 files changed, 137 insertions(+), 99 deletions(-) rename novem/group/{profile.py => config.py} (91%) diff --git a/novem/group/__init__.py b/novem/group/__init__.py index 302fb86..288f875 100644 --- a/novem/group/__init__.py +++ b/novem/group/__init__.py @@ -3,13 +3,13 @@ from novem.exceptions import Novem403, Novem404, raise_on_response from ..api_ref import NovemAPI -from .profile import NovemGroupProfile +from .config import NovemGroupConfig from .roles import NovemRoles class NovemGroupAPI(NovemAPI): roles: Optional[NovemRoles] = None - profile: Optional[NovemGroupProfile] = None + config: Optional[NovemGroupConfig] = None id: str _type: str = "NA" @@ -34,10 +34,10 @@ def __init__(self, **kwargs: Any) -> None: self.api_create("") self.roles = NovemRoles(self) - self.profile = NovemGroupProfile(self, type=self._type) + self.config = NovemGroupConfig(self, type=self._type) - if "profile" in kwargs: - self.profile.set(kwargs["profile"]) + if "config" in kwargs: + self.config.set(kwargs["config"]) if "roles" in kwargs: self.roles.set(kwargs["roles"]) @@ -51,7 +51,7 @@ def _parse_kwargs(self, **kwargs: Any) -> None: # get a list of valid properties props = [ - x for x in dir(self) if x[0] != "_" and x not in ["data", "read", "delete", "write", "roles", "profile"] + x for x in dir(self) if x[0] != "_" and x not in ["data", "read", "delete", "write", "roles", "config"] ] for k, v in kwargs.items(): @@ -63,8 +63,8 @@ def _parse_kwargs(self, **kwargs: Any) -> None: def __setattr__(self, name: str, value: Any) -> None: if name == "roles" and self.roles: self.roles.set(value) - if name == "profile" and self.profile: - self.profile.set(value) + if name == "config" and self.config: + self.config.set(value) else: super().__setattr__(name, value) diff --git a/novem/group/profile.py b/novem/group/config.py similarity index 91% rename from novem/group/profile.py rename to novem/group/config.py index e6b0b11..d49ccd9 100644 --- a/novem/group/profile.py +++ b/novem/group/config.py @@ -9,17 +9,17 @@ def __init__(self, api: "NovemGroupAPI") -> None: """Initialize organization options with API reference""" self.api: "NovemGroupAPI" = api - def set(self, profile: Dict[str, Any]) -> None: + def set(self, options: Dict[str, Any]) -> None: """ - Set profile options + Set config options """ props = [x for x in dir(self) if x not in ["api", "set"] and x[0] != "_"] - for k in profile.keys(): + for k in options.keys(): if k not in props: continue - v = profile[k] + v = options[k] setattr(self, k, v) @property @@ -127,9 +127,10 @@ def mail_verify_spf(self, value: bool) -> None: return self.api.api_write("/profile/options/mail_verify_spf", val) -class NovemGroupProfile(object): +class NovemGroupConfig(object): """ - Novem group profile + Novem group config: the group's name, description and options, stored + under ``/profile`` in the API """ options: GroupOptions @@ -143,20 +144,20 @@ def __init__(self, api: "NovemGroupAPI", type: str = "group") -> None: elif type == "org_group": self.options = OrgGroupOptions(api) - def set(self, profile: Dict[str, Any]) -> None: + def set(self, config: Dict[str, Any]) -> None: """ - Set profile options + Set config options """ props = [x for x in dir(self) if x in ["name", "description", "options"]] - for k in profile.keys(): + for k in config.keys(): if k not in props: continue - v = profile[k] + v = config[k] if k == "options": - self.options.set(profile[k]) + self.options.set(config[k]) else: setattr(self, k, v) diff --git a/tests/test_org.py b/tests/test_org.py index 01c78ee..10fc172 100644 --- a/tests/test_org.py +++ b/tests/test_org.py @@ -7,7 +7,7 @@ from novem import Org -def test_org_profile_prop(requests_mock): +def test_org_config_prop(requests_mock): org_id = "test_org" org_name = "long test name" org_description = "long test description" @@ -61,7 +61,7 @@ def verify_create(request, context): text=verify_create, ) - # basic profile settings + # basic config settings requests_mock.register_uri( "post", f"{api_root}admin/orgs/{org_id}/profile/name", @@ -85,7 +85,7 @@ def verify_create(request, context): text=partial(verify_read, "desc", org_description), ) - # basic profile options + # basic config options for opt in ["is_open", "enable_subdomain", "show_description", "show_members", "show_profile"]: requests_mock.register_uri( "post", f"{api_root}admin/orgs/{org_id}/profile/options/{opt}", text=partial(verify_write, opt, "yes") @@ -100,35 +100,35 @@ def verify_create(request, context): config_path=config_file, ) - assert n.profile.name == "NA" - assert n.profile.description == "NA" + assert n.config.name == "NA" + assert n.config.description == "NA" # Test basic properties - n.profile.name = org_name - n.profile.description = org_description + n.config.name = org_name + n.config.description = org_description - assert n.profile.name == org_name - assert n.profile.description == org_description + assert n.config.name == org_name + assert n.config.description == org_description - assert n.profile.options.is_open is False - n.profile.options.is_open = True - assert n.profile.options.is_open is True + assert n.config.options.is_open is False + n.config.options.is_open = True + assert n.config.options.is_open is True - assert n.profile.options.enable_subdomain is False - n.profile.options.enable_subdomain = True - assert n.profile.options.enable_subdomain is True + assert n.config.options.enable_subdomain is False + n.config.options.enable_subdomain = True + assert n.config.options.enable_subdomain is True - assert n.profile.options.show_members is False - n.profile.options.show_members = True - assert n.profile.options.show_members is True + assert n.config.options.show_members is False + n.config.options.show_members = True + assert n.config.options.show_members is True - assert n.profile.options.show_description is False - n.profile.options.show_description = True - assert n.profile.options.show_description is True + assert n.config.options.show_description is False + n.config.options.show_description = True + assert n.config.options.show_description is True - assert n.profile.options.show_profile is False - n.profile.options.show_profile = True - assert n.profile.options.show_profile is True + assert n.config.options.show_profile is False + n.config.options.show_profile = True + assert n.config.options.show_profile is True # Verify all operations were performed for k, v in gcheck.items(): @@ -137,7 +137,7 @@ def verify_create(request, context): assert n._api_root == "https://api.novem.io/v1/" -def test_org_profile_dict(requests_mock): +def test_org_config_dict(requests_mock): org_id = "test_org" org_name = "long test name" org_description = "long test description" @@ -191,7 +191,7 @@ def verify_create(request, context): text=verify_create, ) - # basic profile settings + # basic config settings requests_mock.register_uri( "post", f"{api_root}admin/orgs/{org_id}/profile/name", @@ -215,7 +215,7 @@ def verify_create(request, context): text=partial(verify_read, "desc", org_description), ) - # basic profile options + # basic config options for opt in ["is_open", "enable_subdomain", "show_description", "show_members", "show_profile"]: requests_mock.register_uri( "post", f"{api_root}admin/orgs/{org_id}/profile/options/{opt}", text=partial(verify_write, opt, "yes") @@ -228,7 +228,7 @@ def verify_create(request, context): n = Org( org_id, config_path=config_file, - profile={ + config={ "name": org_name, "description": org_description, "options": { @@ -241,13 +241,13 @@ def verify_create(request, context): }, ) - assert n.profile.name == org_name - assert n.profile.description == org_description - assert n.profile.options.is_open is True - assert n.profile.options.enable_subdomain is True - assert n.profile.options.show_members is True - assert n.profile.options.show_description is True - assert n.profile.options.show_profile is True + assert n.config.name == org_name + assert n.config.description == org_description + assert n.config.options.is_open is True + assert n.config.options.enable_subdomain is True + assert n.config.options.show_members is True + assert n.config.options.show_description is True + assert n.config.options.show_profile is True # Verify all operations were performed for k, v in gcheck.items(): diff --git a/tests/test_org_group.py b/tests/test_org_group.py index 8ef81fd..b19124c 100644 --- a/tests/test_org_group.py +++ b/tests/test_org_group.py @@ -7,7 +7,7 @@ from novem import Org -def test_org_group_profile_prop(requests_mock): +def test_org_group_config_prop(requests_mock): org_id = "test_org" org_group_id = "test_org_group" @@ -73,7 +73,7 @@ def verify_create(request, context): text=verify_create, ) - # basic profile settings + # basic config settings requests_mock.register_uri( "post", f"{api_root}admin/orgs/{org_id}/groups/{org_group_id}/profile/name", @@ -97,7 +97,7 @@ def verify_create(request, context): text=partial(verify_read, "desc", org_group_description), ) - # basic profile options + # basic config options for opt in [ "allow_inbound_mail", "mail_verify_dkim", @@ -129,43 +129,43 @@ def verify_create(request, context): config_path=config_file, ) - assert g.profile.name == "NA" - assert g.profile.description == "NA" + assert g.config.name == "NA" + assert g.config.description == "NA" # Test basic properties - g.profile.name = org_group_name - g.profile.description = org_group_description + g.config.name = org_group_name + g.config.description = org_group_description - assert g.profile.name == org_group_name - assert g.profile.description == org_group_description + assert g.config.name == org_group_name + assert g.config.description == org_group_description - assert g.profile.options.is_open is False - g.profile.options.is_open = True - assert g.profile.options.is_open is True + assert g.config.options.is_open is False + g.config.options.is_open = True + assert g.config.options.is_open is True - assert g.profile.options.allow_inbound_mail is False - g.profile.options.allow_inbound_mail = True - assert g.profile.options.allow_inbound_mail is True + assert g.config.options.allow_inbound_mail is False + g.config.options.allow_inbound_mail = True + assert g.config.options.allow_inbound_mail is True - assert g.profile.options.mail_verify_dkim is False - g.profile.options.mail_verify_dkim = True - assert g.profile.options.mail_verify_dkim is True + assert g.config.options.mail_verify_dkim is False + g.config.options.mail_verify_dkim = True + assert g.config.options.mail_verify_dkim is True - assert g.profile.options.mail_verify_spf is False - g.profile.options.mail_verify_spf = True - assert g.profile.options.mail_verify_spf is True + assert g.config.options.mail_verify_spf is False + g.config.options.mail_verify_spf = True + assert g.config.options.mail_verify_spf is True - assert g.profile.options.show_members is False - g.profile.options.show_members = True - assert g.profile.options.show_members is True + assert g.config.options.show_members is False + g.config.options.show_members = True + assert g.config.options.show_members is True - assert g.profile.options.show_description is False - g.profile.options.show_description = True - assert g.profile.options.show_description is True + assert g.config.options.show_description is False + g.config.options.show_description = True + assert g.config.options.show_description is True - assert g.profile.options.show_profile is False - g.profile.options.show_profile = True - assert g.profile.options.show_profile is True + assert g.config.options.show_profile is False + g.config.options.show_profile = True + assert g.config.options.show_profile is True # Verify all operations were performed for k, v in gcheck.items(): @@ -174,7 +174,7 @@ def verify_create(request, context): assert g._api_root == "https://api.novem.io/v1/" -def test_org_group_profile_dict(requests_mock): +def test_org_group_config_dict(requests_mock): org_id = "test_org" org_group_id = "test_org_group" @@ -240,7 +240,7 @@ def verify_create(request, context): text=verify_create, ) - # basic profile settings + # basic config settings requests_mock.register_uri( "post", f"{api_root}admin/orgs/{org_id}/groups/{org_group_id}/profile/name", @@ -264,7 +264,7 @@ def verify_create(request, context): text=partial(verify_read, "desc", org_group_description), ) - # basic profile options + # basic config options for opt in [ "allow_inbound_mail", "mail_verify_dkim", @@ -294,7 +294,7 @@ def verify_create(request, context): g = o.Group( org_group_id, config_path=config_file, - profile={ + config={ "name": org_group_name, "description": org_group_description, "options": { @@ -308,19 +308,19 @@ def verify_create(request, context): }, ) - assert g.profile.name == org_group_name - assert g.profile.description == org_group_description - assert g.profile.options.is_open is True - assert g.profile.options.allow_inbound_mail is True - assert g.profile.options.mail_verify_dkim is True - assert g.profile.options.mail_verify_spf is False - assert g.profile.options.show_members is True - assert g.profile.options.show_description is True - assert g.profile.options.show_profile is True + assert g.config.name == org_group_name + assert g.config.description == org_group_description + assert g.config.options.is_open is True + assert g.config.options.allow_inbound_mail is True + assert g.config.options.mail_verify_dkim is True + assert g.config.options.mail_verify_spf is False + assert g.config.options.show_members is True + assert g.config.options.show_description is True + assert g.config.options.show_profile is True # fianl toggle test - g.profile.options.mail_verify_spf = True - assert g.profile.options.mail_verify_spf is True + g.config.options.mail_verify_spf = True + assert g.config.options.mail_verify_spf is True # Verify all operations were performed for k, v in gcheck.items(): @@ -627,3 +627,40 @@ def verify_create(request, context): assert g.w("permissions", "rax").permissions == "rax" assert g.get_share_string() == f"+{org_id}~{org_group_id}~rax" + + +def test_group_profile_selects_the_connection(requests_mock): + """`profile` picks the account to connect as; group settings are `config`.""" + import configparser + import os + + from novem import Org + + base = os.path.dirname(os.path.abspath(__file__)) + config_file = f"{base}/test.conf" + conf = configparser.ConfigParser() + conf.read(config_file) + api_root = conf["general"]["api_root"] + token = conf["profile:demo"]["token"] + + written = {} + requests_mock.register_uri("put", f"{api_root}admin/orgs/test_org", text="") + requests_mock.register_uri("put", f"{api_root}admin/orgs/test_org/groups/test_group", text="") + requests_mock.register_uri( + "post", + f"{api_root}admin/orgs/test_org/groups/test_group/profile/options/allow_inbound_mail", + text=lambda request, context: written.setdefault("allow_inbound_mail", request.text) and "", + ) + + org = Org("test_org", profile="demo", config_path=config_file) + group = org.Group( + "test_group", + profile="demo", + config={"options": {"allow_inbound_mail": True}}, + config_path=config_file, + ) + + assert written == {"allow_inbound_mail": "yes"} + assert group.token == token + for request in requests_mock.request_history: + assert request.headers["Authorization"] == f"Bearer {token}" From 96ae2ae946eb12426ae1a43cf404b3c614b2a681 Mon Sep 17 00:00:00 2001 From: Sondov Engen Date: Wed, 11 Nov 2026 17:53:08 +0000 Subject: [PATCH 2/2] group: read options that end in a newline The API returns option values with a trailing newline, so every option read back as False. Strip the value before comparing. --- novem/group/config.py | 16 ++++++++-------- tests/test_org_group.py | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/novem/group/config.py b/novem/group/config.py index d49ccd9..e86d0bf 100644 --- a/novem/group/config.py +++ b/novem/group/config.py @@ -25,7 +25,7 @@ def set(self, options: Dict[str, Any]) -> None: @property def is_open(self) -> bool: res = self.api.api_read("/profile/options/is_open") - if res.lower() == "yes": + if res.strip().lower() == "yes": return True return False @@ -37,7 +37,7 @@ def is_open(self, value: bool) -> None: @property def show_description(self) -> bool: res = self.api.api_read("/profile/options/show_description") - if res.lower() == "yes": + if res.strip().lower() == "yes": return True return False @@ -49,7 +49,7 @@ def show_description(self, value: bool) -> None: @property def show_members(self) -> bool: res = self.api.api_read("/profile/options/show_members") - if res.lower() == "yes": + if res.strip().lower() == "yes": return True return False @@ -61,7 +61,7 @@ def show_members(self, value: bool) -> None: @property def show_profile(self) -> bool: res = self.api.api_read("/profile/options/show_profile") - if res.lower() == "yes": + if res.strip().lower() == "yes": return True return False @@ -76,7 +76,7 @@ class OrgOptions(GroupOptions): def enable_subdomain(self) -> bool: res = self.api.api_read("/profile/options/enable_subdomain") - if res.lower() == "yes": + if res.strip().lower() == "yes": return True return False @@ -91,7 +91,7 @@ class OrgGroupOptions(GroupOptions): def allow_inbound_mail(self) -> bool: res = self.api.api_read("/profile/options/allow_inbound_mail") - if res.lower() == "yes": + if res.strip().lower() == "yes": return True return False @@ -104,7 +104,7 @@ def allow_inbound_mail(self, value: bool) -> None: def mail_verify_dkim(self) -> bool: res = self.api.api_read("/profile/options/mail_verify_dkim") - if res.lower() == "yes": + if res.strip().lower() == "yes": return True return False @@ -117,7 +117,7 @@ def mail_verify_dkim(self, value: bool) -> None: def mail_verify_spf(self) -> bool: res = self.api.api_read("/profile/options/mail_verify_spf") - if res.lower() == "yes": + if res.strip().lower() == "yes": return True return False diff --git a/tests/test_org_group.py b/tests/test_org_group.py index b19124c..8a5c997 100644 --- a/tests/test_org_group.py +++ b/tests/test_org_group.py @@ -664,3 +664,26 @@ def test_group_profile_selects_the_connection(requests_mock): assert group.token == token for request in requests_mock.request_history: assert request.headers["Authorization"] == f"Bearer {token}" + + +def test_group_option_reads_ignore_the_trailing_newline(requests_mock): + import configparser + import os + + from novem import Org + + base = os.path.dirname(os.path.abspath(__file__)) + config_file = f"{base}/test.conf" + conf = configparser.ConfigParser() + conf.read(config_file) + api_root = conf["general"]["api_root"] + + root = f"{api_root}admin/orgs/test_org/groups/test_group" + requests_mock.register_uri("get", f"{root}/profile/options/allow_inbound_mail", text="yes\n") + requests_mock.register_uri("get", f"{root}/profile/options/mail_verify_spf", text="no\n") + + group = Org("test_org", create=False, config_path=config_file).Group( + "test_group", create=False, config_path=config_file + ) + assert group.config.options.allow_inbound_mail is True + assert group.config.options.mail_verify_spf is False