-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #92 from neptune-ai/kg/create-project-api
Add `neptune_scale.projects.create_project()`
- Loading branch information
Showing
6 changed files
with
267 additions
and
34 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
import os | ||
import re | ||
from enum import Enum | ||
from json import JSONDecodeError | ||
from typing import ( | ||
Any, | ||
Optional, | ||
) | ||
|
||
import httpx | ||
|
||
from neptune_scale.exceptions import ( | ||
NeptuneApiTokenNotProvided, | ||
NeptuneBadRequestError, | ||
NeptuneProjectAlreadyExists, | ||
) | ||
from neptune_scale.net.api_client import ( | ||
HostedApiClient, | ||
with_api_errors_handling, | ||
) | ||
from neptune_scale.util.envs import API_TOKEN_ENV_NAME | ||
|
||
PROJECTS_PATH_BASE = "/api/backend/v1/projects" | ||
|
||
|
||
class ProjectVisibility(Enum): | ||
PRIVATE = "priv" | ||
PUBLIC = "pub" | ||
WORKSPACE = "workspace" | ||
|
||
|
||
ORGANIZATION_NOT_FOUND_RE = re.compile(r"Organization .* not found") | ||
|
||
|
||
@with_api_errors_handling | ||
def create_project( | ||
workspace: str, | ||
name: str, | ||
*, | ||
visibility: ProjectVisibility = ProjectVisibility.PRIVATE, | ||
description: Optional[str] = None, | ||
key: Optional[str] = None, | ||
fail_if_exists: bool = False, | ||
api_token: Optional[str] = None, | ||
) -> None: | ||
api_token = api_token or os.environ.get(API_TOKEN_ENV_NAME) | ||
if api_token is None: | ||
raise NeptuneApiTokenNotProvided() | ||
|
||
client = HostedApiClient(api_token=api_token) | ||
visibility = ProjectVisibility(visibility) | ||
|
||
body = { | ||
"name": name, | ||
"description": description, | ||
"projectKey": key, | ||
"organizationIdentifier": workspace, | ||
"visibility": visibility.value, | ||
} | ||
|
||
response = client.backend.get_httpx_client().request("post", PROJECTS_PATH_BASE, json=body) | ||
json = _safe_json(response) | ||
|
||
try: | ||
response.raise_for_status() | ||
except httpx.HTTPStatusError as e: | ||
code = e.response.status_code | ||
if code == 409: | ||
if fail_if_exists: | ||
raise NeptuneProjectAlreadyExists() | ||
# We need to match plain text, as this is what the backend returns | ||
elif code == 404 and ORGANIZATION_NOT_FOUND_RE.match(response.text): | ||
raise NeptuneBadRequestError(status_code=code, reason=f"Workspace '{workspace}' not found") | ||
elif code // 100 == 4: | ||
raise NeptuneBadRequestError(status_code=code, reason=json.get("message")) | ||
else: | ||
raise e | ||
|
||
|
||
def _safe_json(response: httpx.Response) -> Any: | ||
try: | ||
return response.json() | ||
except JSONDecodeError: | ||
return {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
import re | ||
from typing import ( | ||
Optional, | ||
Tuple, | ||
cast, | ||
) | ||
|
||
from neptune_scale.api.validation import verify_type | ||
from neptune_scale.net import projects | ||
from neptune_scale.net.projects import ProjectVisibility | ||
|
||
PROJECT_QUALIFIED_NAME_RE = re.compile(r"^((?P<workspace>[\w\-.]+)/)?(?P<project>[\w\-.]+)$") | ||
|
||
|
||
def create_project( | ||
name: str, | ||
*, | ||
workspace: Optional[str] = None, | ||
visibility: str = ProjectVisibility.PRIVATE.value, | ||
description: Optional[str] = None, | ||
key: Optional[str] = None, | ||
fail_if_exists: bool = False, | ||
api_token: Optional[str] = None, | ||
) -> str: | ||
"""Creates a new project in a Neptune workspace. | ||
Args: | ||
name (str): Name of the project. Can contain letters and hyphens (-). For example, "project-x". | ||
workspace (str, optional): Name of your Neptune workspace. | ||
You can omit this argument if you include the workspace name in the `name` argument. | ||
visibility: Level of privacy for the project. Options: | ||
- "pub": Public. Anyone on the internet can see it. | ||
- "priv" (default): Private. Only users specifically assigned to the project can access it. Requires a plan with | ||
project-level access control. | ||
- "workspace" (team workspaces only): Accessible to all workspace members. | ||
description: Project description. If None, it's left empty. | ||
key: Project identifier. Must contain 1-10 upper case letters or numbers (at least one letter). | ||
For example, "PX2". If you leave it out, Neptune generates a project key for you. | ||
fail_if_exists: If the project already exists and this flag is set to `True`, an error is raised. | ||
api_token: Account's API token. | ||
If not provided, the value of the NEPTUNE_API_TOKEN environment variable is used (recommended). | ||
Returns: | ||
The name of the new project created. | ||
""" | ||
|
||
verify_type("name", name, str) | ||
verify_type("workspace", workspace, (str, type(None))) | ||
verify_type("visibility", visibility, str) | ||
verify_type("description", description, (str, type(None))) | ||
verify_type("key", key, (str, type(None))) | ||
verify_type("api_token", api_token, (str, type(None))) | ||
|
||
workspace, name = extract_workspace_and_project(name=name, workspace=workspace) | ||
projects.create_project( | ||
workspace=workspace, | ||
name=name, | ||
visibility=visibility, | ||
description=description, | ||
key=key, | ||
fail_if_exists=fail_if_exists, | ||
api_token=api_token, | ||
) | ||
|
||
return normalize_project_name(name, workspace) | ||
|
||
|
||
def extract_workspace_and_project(name: str, workspace: Optional[str] = None) -> Tuple[str, str]: | ||
"""Return a tuple of (workspace name, project name) from the provided | ||
fully qualified project name, or a name + workspace | ||
>>> extract_workspace_and_project("my-own.workspace_/pr_oj-ect") | ||
('my-own.workspace_', 'pr_oj-ect') | ||
>>> extract_workspace_and_project("project", "workspace") | ||
('workspace', 'project') | ||
>>> extract_workspace_and_project("workspace/project", "workspace") | ||
('workspace', 'project') | ||
>>> extract_workspace_and_project("workspace/project", "another_workspace") | ||
Traceback (most recent call last): | ||
... | ||
ValueError: The provided `workspace` argument `another_workspace` is different ... | ||
>>> extract_workspace_and_project("project") | ||
Traceback (most recent call last): | ||
... | ||
ValueError: Workspace not provided ... | ||
>>> extract_workspace_and_project("workspace/project!@#") | ||
Traceback (most recent call last): | ||
... | ||
ValueError: Invalid project name ... | ||
""" | ||
project_spec = PROJECT_QUALIFIED_NAME_RE.search(name) | ||
|
||
if not project_spec: | ||
raise ValueError(f"Invalid project name `{name}`") | ||
|
||
extracted_workspace, extracted_project_name = ( | ||
project_spec["workspace"], | ||
project_spec["project"], | ||
) | ||
|
||
if not workspace and not extracted_workspace: | ||
raise ValueError("Workspace not provided in neither project name or the `workspace` parameter.") | ||
|
||
if workspace and extracted_workspace and workspace != extracted_workspace: | ||
raise ValueError( | ||
f"The provided `workspace` argument `{workspace}` is different from the one in project name `{name}`" | ||
) | ||
|
||
final_workspace_name = cast(str, extracted_workspace or workspace) | ||
|
||
return final_workspace_name, extracted_project_name | ||
|
||
|
||
def normalize_project_name(name: str, workspace: Optional[str] = None) -> str: | ||
extracted_workspace_name, extracted_project_name = extract_workspace_and_project(name=name, workspace=workspace) | ||
|
||
return f"{extracted_workspace_name}/{extracted_project_name}" |
Oops, something went wrong.