Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .hooks/generate_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 11 additions & 7 deletions dreadnode/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -848,25 +848,28 @@ 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.
"""
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:
Expand All @@ -883,6 +886,7 @@ def create_workspace(

payload = {
"name": name,
"key": key,
"description": description,
"org_id": str(organization_id),
}
Expand All @@ -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}")
12 changes: 9 additions & 3 deletions dreadnode/api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"""
Expand Down Expand Up @@ -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."""
Expand All @@ -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

Expand Down
18 changes: 16 additions & 2 deletions dreadnode/cli/rbac/organizations.py
Original file line number Diff line number Diff line change
@@ -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=[])

Expand All @@ -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)
50 changes: 42 additions & 8 deletions dreadnode/cli/rbac/workspaces.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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"])
Expand Down
66 changes: 49 additions & 17 deletions dreadnode/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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.

Expand All @@ -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(
Expand All @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand Down
Loading