diff --git a/.hooks/generate_docs.py b/.hooks/generate_docs.py index c6c77b3..67cc2c3 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( diff --git a/dreadnode/api/client.py b/dreadnode/api/client.py index 6febeaa..bc69457 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,12 +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, + key: str, organization_id: UUID, description: str | None = None, ) -> Workspace: @@ -883,6 +886,7 @@ def create_workspace( payload = { "name": name, + "key": key, "description": description, "org_id": str(organization_id), } @@ -895,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 391a90f..194cb41 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.""" + key: str + """Unique key for the workspace.""" description: str | None """Description of the workspace.""" created_by: UUID | None = None @@ -469,6 +469,9 @@ class Workspace(BaseModel): updated_at: datetime """Last update timestamp.""" + def __str__(self) -> str: + return f"{self.name} (Key: {self.key}), ID: {self.id}" + class WorkspaceFilter(BaseModel): """Filter parameters for workspace listing""" @@ -498,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.""" @@ -513,6 +516,9 @@ class Organization(BaseModel): updated_at: datetime """Last update timestamp.""" + def __str__(self) -> str: + 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 3139c07..c110c87 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("Key", style="green") + table.add_column("ID") + for org in organizations: - print_info(f"- {org.name} (ID: {org.id})") + table.add_row( + org.name, + org.key, + str(org.id), + ) + + console.print(table) diff --git a/dreadnode/cli/rbac/workspaces.py b/dreadnode/cli/rbac/workspaces.py index 7920e94..f574ee5 100644 --- a/dreadnode/cli/rbac/workspaces.py +++ b/dreadnode/cli/rbac/workspaces.py @@ -1,13 +1,34 @@ 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 +from dreadnode.util import create_key_from_name cli = cyclopts.App("workspaces", help="View and manage workspaces.", help_flags=[]) +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("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.key, + str(ws.id), + f'dn.configure(organization="{organization.key}", workspace="{ws.key}")', + ) + + console.print(table) + + @cli.command(name=["list", "ls", "show"]) def show( # optional parameter of organization name or id @@ -35,20 +56,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("Key", 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, + 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 key: + key = create_key_from_name(name) + if organization: matched_organization = client.get_organization(organization) if not matched_organization: @@ -65,16 +94,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]{key}[/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, + key=key, + 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"]) diff --git a/dreadnode/main.py b/dreadnode/main.py index fd74d89..eb65f75 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 6934217..37f13cc 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