From 9020e6cc4bfcc85e0b3a92c18046bc3626573556 Mon Sep 17 00:00:00 2001 From: Brian Greunke Date: Fri, 14 Nov 2025 11:02:18 -0600 Subject: [PATCH 1/4] refactor(workspace): rename slug to identifier --- dreadnode/api/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dreadnode/api/models.py b/dreadnode/api/models.py index 391a90fb..1ef8a13a 100644 --- a/dreadnode/api/models.py +++ b/dreadnode/api/models.py @@ -448,8 +448,8 @@ class Workspace(BaseModel): """Unique identifier for the workspace.""" name: str """Name of the workspace.""" - slug: str - """URL-friendly slug for the workspace.""" + identifier: str + """URL-friendly identifier for the workspace.""" description: str | None """Description of the workspace.""" created_by: UUID | None = None From dff6478cbb87a41d33f8045601f6dc9e0ca73efc Mon Sep 17 00:00:00 2001 From: Brian Greunke Date: Fri, 14 Nov 2025 15:42:00 -0600 Subject: [PATCH 2/4] feat(workspace): Add unique identifiers and improve CLI lists --- dreadnode/api/client.py | 2 + dreadnode/api/models.py | 6 +++ dreadnode/cli/rbac/organizations.py | 18 ++++++++- dreadnode/cli/rbac/workspaces.py | 61 +++++++++++++++++++++++++---- 4 files changed, 77 insertions(+), 10 deletions(-) diff --git a/dreadnode/api/client.py b/dreadnode/api/client.py index 6febeaa2..4ff4bc21 100644 --- a/dreadnode/api/client.py +++ b/dreadnode/api/client.py @@ -867,6 +867,7 @@ def get_workspace(self, workspace_id: str | UUID, org_id: UUID | None = None) -> def create_workspace( self, name: str, + identifier: str, organization_id: UUID, description: str | None = None, ) -> Workspace: @@ -883,6 +884,7 @@ def create_workspace( payload = { "name": name, + "identifier": identifier, "description": description, "org_id": str(organization_id), } diff --git a/dreadnode/api/models.py b/dreadnode/api/models.py index 1ef8a13a..29fb118f 100644 --- a/dreadnode/api/models.py +++ b/dreadnode/api/models.py @@ -469,6 +469,9 @@ class Workspace(BaseModel): updated_at: datetime """Last update timestamp.""" + def __str__(self) -> str: + return f"{self.name} (Identifier: {self.identifier}), ID: {self.id}" + class WorkspaceFilter(BaseModel): """Filter parameters for workspace listing""" @@ -513,6 +516,9 @@ class Organization(BaseModel): updated_at: datetime """Last update timestamp.""" + def __str__(self) -> str: + return f"{self.name} (Identifier: {self.identifier}), ID: {self.id}" + # Derived types diff --git a/dreadnode/cli/rbac/organizations.py b/dreadnode/cli/rbac/organizations.py index 3139c071..313ed7f7 100644 --- a/dreadnode/cli/rbac/organizations.py +++ b/dreadnode/cli/rbac/organizations.py @@ -1,7 +1,9 @@ import cyclopts +from rich import box +from rich.table import Table from dreadnode.cli.api import create_api_client -from dreadnode.logging_ import print_info +from dreadnode.logging_ import console cli = cyclopts.App("organizations", help="View and manage organizations.", help_flags=[]) @@ -11,5 +13,17 @@ def show() -> None: # get the client and call the list organizations endpoint client = create_api_client() organizations = client.list_organizations() + + table = Table(box=box.ROUNDED) + table.add_column("Name", style="orange_red1") + table.add_column("Identifier", style="green") + table.add_column("ID") + for org in organizations: - print_info(f"- {org.name} (ID: {org.id})") + table.add_row( + org.name, + org.identifier, + str(org.id), + ) + + console.print(table) diff --git a/dreadnode/cli/rbac/workspaces.py b/dreadnode/cli/rbac/workspaces.py index 7920e942..7c96f2ec 100644 --- a/dreadnode/cli/rbac/workspaces.py +++ b/dreadnode/cli/rbac/workspaces.py @@ -1,13 +1,45 @@ +import re + import cyclopts from click import confirm +from rich import box +from rich.table import Table from dreadnode.api.models import Organization, Workspace, WorkspaceFilter from dreadnode.cli.api import create_api_client -from dreadnode.logging_ import print_error, print_info +from dreadnode.logging_ import console, print_error, print_info cli = cyclopts.App("workspaces", help="View and manage workspaces.", help_flags=[]) +def _create_identifier_from_name(name: str) -> str: + identifier = name.strip().lower() + + # 2. Replace one or more spaces or underscores with a single hyphen + identifier = re.sub(r"[\s_]+", "-", identifier) + + # 3. Remove any character that is not a letter, number, or hyphen + return re.sub(r"[^a-z0-9-]", "", identifier) + + +def _print_workspace_table(workspaces: list[Workspace], organization: Organization) -> None: + table = Table(box=box.ROUNDED) + table.add_column("Name", style="orange_red1") + table.add_column("Identifier", style="green") + table.add_column("ID") + table.add_column("dn.configure() Command", style="cyan") + + for ws in workspaces: + table.add_row( + ws.name, + ws.identifier, + str(ws.id), + f'dn.configure(organization="{organization.identifier}", workspace="{ws.identifier}")', + ) + + console.print(table) + + @cli.command(name=["list", "ls", "show"]) def show( # optional parameter of organization name or id @@ -35,20 +67,28 @@ def show( workspace_filter = WorkspaceFilter(org_id=matched_organization.id) workspaces = client.list_workspaces(filters=workspace_filter) - print_info(f"Workspaces in Organization '{matched_organization.name}':") - for workspace in workspaces: - print_info(f"- {workspace.name} (ID: {workspace.id})") - print_info("") + + table = Table(box=box.ROUNDED) + table.add_column("Name", style="orange_red1") + table.add_column("Identifier", style="green") + table.add_column("ID") + table.add_column("dn.configure() Command", style="cyan") + + _print_workspace_table(workspaces, matched_organization) @cli.command(name=["create", "new"]) def create( name: str, + identifier: str | None = None, description: str | None = None, organization: str | None = None, ) -> None: # get the client and call the create workspace endpoint client = create_api_client() + if not identifier: + identifier = _create_identifier_from_name(name) + if organization: matched_organization = client.get_organization(organization) if not matched_organization: @@ -65,16 +105,21 @@ def create( ) return matched_organization = user_organizations[0] - print_info(f"The workspace will be created in organization '{matched_organization.name}'") + print_info( + f"Workspace '{name}' ([cyan]{identifier}[/cyan]) will be created in organization '{matched_organization.name}'" + ) # verify with the user if not confirm("Do you want to continue?"): print_info("Workspace creation cancelled.") return workspace: Workspace = client.create_workspace( - name=name, organization_id=matched_organization.id, description=description + name=name, + identifier=identifier, + organization_id=matched_organization.id, + description=description, ) - print_info(f"Workspace '{workspace.name}' created inwith ID: {workspace.id}") + _print_workspace_table([workspace], matched_organization) @cli.command(name=["delete", "rm"]) From 41953eb809822278964dafcee1d109d9991fbd7d Mon Sep 17 00:00:00 2001 From: Brian Greunke Date: Sat, 15 Nov 2025 14:23:53 -0600 Subject: [PATCH 3/4] refactor(core): standardize entity key naming and add validation --- dreadnode/api/client.py | 20 +++++---- dreadnode/api/models.py | 10 ++--- dreadnode/cli/rbac/organizations.py | 4 +- dreadnode/cli/rbac/workspaces.py | 31 +++++--------- dreadnode/main.py | 66 +++++++++++++++++++++-------- dreadnode/util.py | 17 ++++++++ 6 files changed, 94 insertions(+), 54 deletions(-) diff --git a/dreadnode/api/client.py b/dreadnode/api/client.py index 4ff4bc21..bc69457f 100644 --- a/dreadnode/api/client.py +++ b/dreadnode/api/client.py @@ -805,17 +805,17 @@ def list_organizations(self) -> list[Organization]: response = self.request("GET", "/organizations") return [Organization(**org) for org in response.json()] - def get_organization(self, organization_id: str | UUID) -> Organization: + def get_organization(self, org_id_or_key: UUID | str) -> Organization: """ Retrieves details of a specific organization. Args: - organization_id (str): The organization identifier. + org_id_or_key (str | UUID): The organization identifier. Returns: Organization: The Organization object. """ - response = self.request("GET", f"/organizations/{organization_id!s}") + response = self.request("GET", f"/organizations/{org_id_or_key!s}") return Organization(**response.json()) def list_workspaces(self, filters: WorkspaceFilter | None = None) -> list[Workspace]: @@ -848,12 +848,14 @@ def list_workspaces(self, filters: WorkspaceFilter | None = None) -> list[Worksp return all_workspaces - def get_workspace(self, workspace_id: str | UUID, org_id: UUID | None = None) -> Workspace: + def get_workspace( + self, workspace_id_or_key: UUID | str, org_id: UUID | None = None + ) -> Workspace: """ Retrieves details of a specific workspace. Args: - workspace_id (str): The workspace identifier. + workspace_id_or_key (str | UUID): The workspace identifier. Returns: Workspace: The Workspace object. @@ -861,13 +863,13 @@ def get_workspace(self, workspace_id: str | UUID, org_id: UUID | None = None) -> params: dict[str, str] = {} if org_id: params = {"org_id": str(org_id)} - response = self.request("GET", f"/workspaces/{workspace_id!s}", params=params) + response = self.request("GET", f"/workspaces/{workspace_id_or_key!s}", params=params) return Workspace(**response.json()) def create_workspace( self, name: str, - identifier: str, + key: str, organization_id: UUID, description: str | None = None, ) -> Workspace: @@ -884,7 +886,7 @@ def create_workspace( payload = { "name": name, - "identifier": identifier, + "key": key, "description": description, "org_id": str(organization_id), } @@ -897,7 +899,7 @@ def delete_workspace(self, workspace_id: str | UUID) -> None: Deletes a specific workspace. Args: - workspace_id (str | UUID): The workspace identifier. + workspace_id (str | UUID): The workspace key. """ self.request("DELETE", f"/workspaces/{workspace_id!s}") diff --git a/dreadnode/api/models.py b/dreadnode/api/models.py index 29fb118f..194cb415 100644 --- a/dreadnode/api/models.py +++ b/dreadnode/api/models.py @@ -448,8 +448,8 @@ class Workspace(BaseModel): """Unique identifier for the workspace.""" name: str """Name of the workspace.""" - identifier: str - """URL-friendly identifier for the workspace.""" + key: str + """Unique key for the workspace.""" description: str | None """Description of the workspace.""" created_by: UUID | None = None @@ -470,7 +470,7 @@ class Workspace(BaseModel): """Last update timestamp.""" def __str__(self) -> str: - return f"{self.name} (Identifier: {self.identifier}), ID: {self.id}" + return f"{self.name} (Key: {self.key}), ID: {self.id}" class WorkspaceFilter(BaseModel): @@ -501,7 +501,7 @@ class Organization(BaseModel): """Unique identifier for the organization.""" name: str """Name of the organization.""" - identifier: str + key: str """URL-friendly identifer for the organization.""" description: str | None """Description of the organization.""" @@ -517,7 +517,7 @@ class Organization(BaseModel): """Last update timestamp.""" def __str__(self) -> str: - return f"{self.name} (Identifier: {self.identifier}), ID: {self.id}" + return f"{self.name} (Identifier: {self.key}), ID: {self.id}" # Derived types diff --git a/dreadnode/cli/rbac/organizations.py b/dreadnode/cli/rbac/organizations.py index 313ed7f7..c110c879 100644 --- a/dreadnode/cli/rbac/organizations.py +++ b/dreadnode/cli/rbac/organizations.py @@ -16,13 +16,13 @@ def show() -> None: table = Table(box=box.ROUNDED) table.add_column("Name", style="orange_red1") - table.add_column("Identifier", style="green") + table.add_column("Key", style="green") table.add_column("ID") for org in organizations: table.add_row( org.name, - org.identifier, + org.key, str(org.id), ) diff --git a/dreadnode/cli/rbac/workspaces.py b/dreadnode/cli/rbac/workspaces.py index 7c96f2ec..f574ee52 100644 --- a/dreadnode/cli/rbac/workspaces.py +++ b/dreadnode/cli/rbac/workspaces.py @@ -1,5 +1,3 @@ -import re - import cyclopts from click import confirm from rich import box @@ -8,33 +6,24 @@ from dreadnode.api.models import Organization, Workspace, WorkspaceFilter from dreadnode.cli.api import create_api_client from dreadnode.logging_ import console, print_error, print_info +from dreadnode.util import create_key_from_name cli = cyclopts.App("workspaces", help="View and manage workspaces.", help_flags=[]) -def _create_identifier_from_name(name: str) -> str: - identifier = name.strip().lower() - - # 2. Replace one or more spaces or underscores with a single hyphen - identifier = re.sub(r"[\s_]+", "-", identifier) - - # 3. Remove any character that is not a letter, number, or hyphen - return re.sub(r"[^a-z0-9-]", "", identifier) - - def _print_workspace_table(workspaces: list[Workspace], organization: Organization) -> None: table = Table(box=box.ROUNDED) table.add_column("Name", style="orange_red1") - table.add_column("Identifier", style="green") + table.add_column("Key", style="green") table.add_column("ID") table.add_column("dn.configure() Command", style="cyan") for ws in workspaces: table.add_row( ws.name, - ws.identifier, + ws.key, str(ws.id), - f'dn.configure(organization="{organization.identifier}", workspace="{ws.identifier}")', + f'dn.configure(organization="{organization.key}", workspace="{ws.key}")', ) console.print(table) @@ -70,7 +59,7 @@ def show( table = Table(box=box.ROUNDED) table.add_column("Name", style="orange_red1") - table.add_column("Identifier", style="green") + table.add_column("Key", style="green") table.add_column("ID") table.add_column("dn.configure() Command", style="cyan") @@ -80,14 +69,14 @@ def show( @cli.command(name=["create", "new"]) def create( name: str, - identifier: str | None = None, + key: str | None = None, description: str | None = None, organization: str | None = None, ) -> None: # get the client and call the create workspace endpoint client = create_api_client() - if not identifier: - identifier = _create_identifier_from_name(name) + if not key: + key = create_key_from_name(name) if organization: matched_organization = client.get_organization(organization) @@ -106,7 +95,7 @@ def create( return matched_organization = user_organizations[0] print_info( - f"Workspace '{name}' ([cyan]{identifier}[/cyan]) will be created in organization '{matched_organization.name}'" + f"Workspace '{name}' ([cyan]{key}[/cyan]) will be created in organization '{matched_organization.name}'" ) # verify with the user if not confirm("Do you want to continue?"): @@ -115,7 +104,7 @@ def create( workspace: Workspace = client.create_workspace( name=name, - identifier=identifier, + key=key, organization_id=matched_organization.id, description=description, ) diff --git a/dreadnode/main.py b/dreadnode/main.py index fd74d89c..eb65f752 100644 --- a/dreadnode/main.py +++ b/dreadnode/main.py @@ -74,7 +74,9 @@ from dreadnode.user_config import UserConfig from dreadnode.util import ( clean_str, + create_key_from_name, handle_internal_errors, + valid_key, warn_at_user_stacklevel, ) from dreadnode.version import VERSION @@ -215,6 +217,16 @@ def _resolve_organization(self) -> None: if self._api is None: raise RuntimeError("API client is not initialized.") + with contextlib.suppress(ValueError): + self.organization = UUID( + str(self.organization) + ) # Now, it's a UUID if possible, else str (name/slug) + + if isinstance(self.organization, str) and not valid_key(self.organization): + raise RuntimeError( + f'Invalid Organization Key: "{self.organization}". The expected characters are lowercase letters, numbers, and hyphens (-).\n\nYou can get the keys for your organization using the CLI or the web interface.', + ) + if self.organization: self._organization = self._api.get_organization(self.organization) if not self._organization: @@ -236,7 +248,7 @@ def _resolve_organization(self) -> None: ) self._organization = organizations[0] - def _create_workspace(self, name: str) -> Workspace: + def _create_workspace(self, key: str) -> Workspace: """ Create a new workspace. @@ -255,9 +267,12 @@ def _create_workspace(self, name: str) -> Workspace: try: logging_console.print( - f"[yellow]WARNING: This workspace was not found. Creating a new workspace '{name}'...[/]" + f"[yellow]WARNING: This workspace was not found. Creating a new workspace '{key}'...[/]" + ) + key = create_key_from_name(key) + return self._api.create_workspace( + name=key, key=key, organization_id=self._organization.id ) - return self._api.create_workspace(name=name, organization_id=self._organization.id) except RuntimeError as e: if "403: Forbidden" in str(e): raise RuntimeError( @@ -281,6 +296,16 @@ def _resolve_workspace(self) -> None: if self._api is None: raise RuntimeError("API client is not initialized.") + with contextlib.suppress(ValueError): + self.workspace = UUID( + str(self.workspace) + ) # Now, it's a UUID if possible, else str (name/slug) + + if isinstance(self.workspace, str) and not valid_key(self.workspace): + raise RuntimeError( + f'Invalid Workspace Key: "{self.workspace}". The expected characters are lowercase letters, numbers, and hyphens (-).\n\nYou can get the keys for your workspace using the CLI or the web interface.', + ) + found_workspace: Workspace | None = None if self.workspace: try: @@ -298,7 +323,7 @@ def _resolve_workspace(self) -> None: if not found_workspace and isinstance(self.workspace, str): # specified by name/slug # create the workspace (must be an org contributor) - found_workspace = self._create_workspace(name=self.workspace) + found_workspace = self._create_workspace(key=self.workspace) else: # the user provided no workspace, attempt to find a default one workspaces = self._api.list_workspaces( @@ -332,6 +357,11 @@ def _resolve_project(self) -> None: if self._api is None: raise RuntimeError("API client is not initialized.") + if self.project and not valid_key(self.project): + raise RuntimeError( + f'Invalid Project Key: "{self.project}". The expected characters are lowercase letters, numbers, and hyphens (-).\n\nYou can get the keys for your project using the CLI or the web interface.', + ) + # fetch the project found_project: Project | None = None try: @@ -418,13 +448,22 @@ def _extract_project_components(path: str | None) -> tuple[str | None, str | Non match = re.match(pattern, path) if not match: - raise RuntimeError(f"Invalid project path format: '{path}'") + raise RuntimeError( + f"Invalid project path format: '{path}'.\n\nExpected formats are 'org/workspace/project', 'workspace/project', or 'project'. Where each component is the key for that entity.'" + ) # The groups are: (Org, Workspace, Project) groups = match.groups() present_components = [c for c in groups if c is not None] + # validate each component + for component in present_components: + if not valid_key(component): + raise RuntimeError( + f'Invalid Key: "{component}". The expected characters are lowercase letters, numbers, and hyphens (-).\n\nYou can get the keys for your organization, workspace, and project using the CLI or the web interface.', + ) + if len(present_components) == 3: org, workspace, project = groups elif len(present_components) == 2: @@ -472,6 +511,10 @@ def configure( 1. Environment variables: - `DREADNODE_SERVER_URL` or `DREADNODE_SERVER` - `DREADNODE_API_TOKEN` or `DREADNODE_API_KEY` + - `DREADNODE_ORGANIZATION` + - `DREADNODE_WORKSPACE` + - `DREADNODE_PROJECT` + 2. Dreadnode profile (from `dreadnode login`) - Uses `profile` parameter if provided - Falls back to `DREADNODE_PROFILE` environment variable @@ -484,7 +527,7 @@ def configure( local_dir: The local directory to store data in. organization: The default organization name or ID to use. workspace: The default workspace name or ID to use. - project: The default project name to associate all runs with. This can also be in the format `org/workspace/project`. + project: The default project name to associate all runs with. This can also be in the format `org/workspace/project` using the keys. service_name: The service name to use for OpenTelemetry. service_version: The service version to use for OpenTelemetry. console: Log span information to the console (`DREADNODE_CONSOLE` or the default is True). @@ -544,19 +587,8 @@ def configure( self.local_dir = local_dir _org, _workspace, _project = self._extract_project_components(project) - self.organization = _org or organization or os.environ.get(ENV_ORGANIZATION) - with contextlib.suppress(ValueError): - self.organization = UUID( - str(self.organization) - ) # Now, it's a UUID if possible, else str (name/slug) - self.workspace = _workspace or workspace or os.environ.get(ENV_WORKSPACE) - with contextlib.suppress(ValueError): - self.workspace = UUID( - str(self.workspace) - ) # Now, it's a UUID if possible, else str (name/slug) - self.project = _project or project or os.environ.get(ENV_PROJECT) self.service_name = service_name diff --git a/dreadnode/util.py b/dreadnode/util.py index 69342172..37f13cc6 100644 --- a/dreadnode/util.py +++ b/dreadnode/util.py @@ -152,6 +152,23 @@ def format_dict(data: dict[str, t.Any], max_length: int = 80) -> str: return f"{{{formatted}}}" +def create_key_from_name(name: str) -> str: + key = name.strip().lower() + + # 2. Replace one or more spaces or underscores with a single hyphen + key = re.sub(r"[\s_]+", "-", key) + + # 3. Remove any character that is not a letter, number, or hyphen + return re.sub(r"[^a-z0-9-]", "", key) + + +def valid_key(key: str) -> bool: + """ + Check if the key is valid (only contains lowercase letters, numbers, and hyphens). + """ + return bool(re.fullmatch(r"[a-z0-9-]+", key)) + + # Imports From eacd490c9cef0a71ca466c351b8daf5f9f12c00b Mon Sep 17 00:00:00 2001 From: Brian Greunke Date: Sat, 15 Nov 2025 21:06:49 -0600 Subject: [PATCH 4/4] chore: merged main --- .hooks/generate_docs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.hooks/generate_docs.py b/.hooks/generate_docs.py index c6c77b35..67cc2c37 100644 --- a/.hooks/generate_docs.py +++ b/.hooks/generate_docs.py @@ -129,7 +129,8 @@ def generate_docs_for_module( html = self.handler.render(module_data, options) if "Source code in " in html: - with open("debug.html", "w", encoding="utf-8") as f: + debug_path = Path("debug.html") + with debug_path.open("w", encoding="utf-8") as f: f.write(html) return str(