Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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
8 changes: 8 additions & 0 deletions cli/kleinkram/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
from kleinkram.wrappers import create_project
from kleinkram.wrappers import create_template
from kleinkram.wrappers import create_template_version
from kleinkram.wrappers import create_trigger
from kleinkram.wrappers import delete_execution
from kleinkram.wrappers import delete_file
from kleinkram.wrappers import delete_files
from kleinkram.wrappers import delete_mission
from kleinkram.wrappers import delete_project
from kleinkram.wrappers import delete_template
from kleinkram.wrappers import delete_trigger
from kleinkram.wrappers import download
from kleinkram.wrappers import download_artifact
from kleinkram.wrappers import get_execution
Expand All @@ -25,9 +27,11 @@
from kleinkram.wrappers import list_missions
from kleinkram.wrappers import list_projects
from kleinkram.wrappers import list_templates
from kleinkram.wrappers import list_triggers
from kleinkram.wrappers import update_file
from kleinkram.wrappers import update_mission
from kleinkram.wrappers import update_project
from kleinkram.wrappers import update_trigger
from kleinkram.wrappers import upload
from kleinkram.wrappers import verify

Expand Down Expand Up @@ -62,4 +66,8 @@
"create_template",
"create_template_version",
"launch_execution",
"create_trigger",
"update_trigger",
"list_triggers",
"delete_trigger",
]
75 changes: 70 additions & 5 deletions cli/kleinkram/api/deser.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,20 @@

from kleinkram.errors import ParsingError
from kleinkram.models import ActionTemplate
from kleinkram.models import ActionTrigger
from kleinkram.models import Execution
from kleinkram.models import File
from kleinkram.models import FileConfig
from kleinkram.models import FileState
from kleinkram.models import FileTriggerEvent
from kleinkram.models import LogEntry
from kleinkram.models import MetadataValue
from kleinkram.models import Mission
from kleinkram.models import Project
from kleinkram.models import TimeConfig
from kleinkram.models import TriggerConfig
from kleinkram.models import TriggerType
from kleinkram.models import WebhookConfig

__all__ = [
"_parse_project",
Expand All @@ -33,6 +40,8 @@
MissionObject = NewType("MissionObject", Dict[str, Any])
FileObject = NewType("FileObject", Dict[str, Any])
ExecutionObject = NewType("ExecutionObject", Dict[str, Any])
TemplateObject = NewType("TemplateObject", Dict[str, Any])
TriggerObject = NewType("TriggerObject", Dict[str, Any])

MISSION = "mission"
PROJECT = "project"
Expand Down Expand Up @@ -99,6 +108,19 @@ class TemplateObjectKeys(str, Enum):
VERSION = "version"


class ActionTriggerObjectKeys(str, Enum):
UUID = "uuid"
NAME = "name"
DESCRIPTION = "description"
MISSION_UUID = "missionUuid"
TEMPLATE_NAME = "templateName"
TEMPLATE_UUID = "templateUuid"
TYPE = "type"
CONFIG = "config"
CREATOR_NAME = "creatorName"
CREATOR_UUID = "creatorUuid"


class LogEntryObjectKeys(str, Enum):
TIMESTAMP = "timestamp"
LEVEL = "type"
Expand Down Expand Up @@ -227,9 +249,9 @@ def _parse_file(file: FileObject) -> File:
return parsed


def _parse_action_template(template_object: Dict[str, Any]) -> ActionTemplate:
def _parse_action_template(template_object: TemplateObject) -> ActionTemplate:
try:
uuid_ = UUID(template_object[TemplateObjectKeys.UUID], version=4)
uuid = UUID(template_object[TemplateObjectKeys.UUID], version=4)
access_rights = template_object[TemplateObjectKeys.ACCESS_RIGHTS]
command = template_object[TemplateObjectKeys.COMMAND]
cpu_cores = template_object[TemplateObjectKeys.CPU_CORES]
Expand All @@ -247,7 +269,7 @@ def _parse_action_template(template_object: Dict[str, Any]) -> ActionTemplate:
raise ParsingError(f"error parsing action template: {template_object}") from e

return ActionTemplate(
uuid=uuid_,
uuid=uuid,
access_rights=access_rights,
command=command,
cpu_cores=cpu_cores,
Expand All @@ -265,7 +287,7 @@ def _parse_action_template(template_object: Dict[str, Any]) -> ActionTemplate:

def _parse_execution(execution_object: ExecutionObject) -> Execution:
try:
uuid_ = UUID(execution_object[ExecutionObjectKeys.UUID], version=4)
uuid = UUID(execution_object[ExecutionObjectKeys.UUID], version=4)
state = execution_object[ExecutionObjectKeys.STATE]
state_cause = execution_object[ExecutionObjectKeys.STATE_CAUSE]
artifact_url = execution_object.get(ExecutionObjectKeys.ARTIFACT_URL)
Expand Down Expand Up @@ -303,7 +325,7 @@ def _parse_execution(execution_object: ExecutionObject) -> Execution:
raise ParsingError(f"error parsing run: {execution_object}") from e

return Execution(
uuid=uuid_,
uuid=uuid,
state=state,
state_cause=state_cause,
artifact_url=artifact_url,
Expand All @@ -316,3 +338,46 @@ def _parse_execution(execution_object: ExecutionObject) -> Execution:
template_name=template_name,
logs=logs,
)


def _parse_action_trigger(trigger_object: TriggerObject) -> ActionTrigger:
try:
uuid = UUID(trigger_object[ActionTriggerObjectKeys.UUID], version=4)
name = trigger_object[ActionTriggerObjectKeys.NAME]
description = trigger_object[ActionTriggerObjectKeys.DESCRIPTION]
mission_uuid = UUID(trigger_object[ActionTriggerObjectKeys.MISSION_UUID], version=4)
template_uuid = UUID(trigger_object[ActionTriggerObjectKeys.TEMPLATE_UUID], version=4)
template_name = trigger_object[ActionTriggerObjectKeys.TEMPLATE_NAME]
type_ = TriggerType(trigger_object[ActionTriggerObjectKeys.TYPE])
creator_name = trigger_object[ActionTriggerObjectKeys.CREATOR_NAME]
creator_uuid = UUID(trigger_object[ActionTriggerObjectKeys.CREATOR_UUID], version=4)

config: TriggerConfig
if type_ is TriggerType.FILE:
raw_config = trigger_object[ActionTriggerObjectKeys.CONFIG]
config = FileConfig(
patterns=tuple(raw_config.get("patterns") or ()),
event=tuple(FileTriggerEvent(e) for e in raw_config.get("event")) if raw_config.get("event") else (),
)
Comment thread
LevinCeglie marked this conversation as resolved.
elif type_ is TriggerType.TIME:
config = TimeConfig(**trigger_object[ActionTriggerObjectKeys.CONFIG])
elif type_ is TriggerType.WEBHOOK:
config = WebhookConfig(**trigger_object[ActionTriggerObjectKeys.CONFIG])
else:
raise ParsingError(f"unknown trigger type: {type_}")

except Exception as e:
raise ParsingError(f"error parsing action trigger: {trigger_object}") from e

return ActionTrigger(
uuid=uuid,
name=name,
description=description,
mission_uuid=mission_uuid,
template_uuid=template_uuid,
template_name=template_name,
type=type_,
creator_name=creator_name,
creator_uuid=creator_uuid,
config=config,
)
1 change: 1 addition & 0 deletions cli/kleinkram/api/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def paginated_request(
resp.raise_for_status()

paged_data = resp.json()

data_page = cast(List[DataPage], paged_data["data"])

for entry in data_page:
Expand Down
11 changes: 11 additions & 0 deletions cli/kleinkram/api/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ class ExecutionQuery:
template_name: Optional[str] = None


@dataclass
class TriggerQuery:
"""
This matches the parameters supported by the backend. The backend
does not yet support filtering by list of ids as for
other resources (e.g. projects and missions).
"""

mission_uuid: Optional[UUID] = None


def check_mission_query_is_creatable(query: MissionQuery) -> str:
"""\
check if a query is unique and can be used to create a mission
Expand Down
103 changes: 101 additions & 2 deletions cli/kleinkram/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
from kleinkram.api.deser import FileObject
from kleinkram.api.deser import MissionObject
from kleinkram.api.deser import ProjectObject
from kleinkram.api.deser import TemplateObject
from kleinkram.api.deser import TriggerObject
from kleinkram.api.deser import _parse_action_template
from kleinkram.api.deser import _parse_action_trigger
from kleinkram.api.deser import _parse_execution
from kleinkram.api.deser import _parse_file
from kleinkram.api.deser import _parse_mission
Expand All @@ -33,6 +36,7 @@
from kleinkram.api.query import FileQuery
from kleinkram.api.query import MissionQuery
from kleinkram.api.query import ProjectQuery
from kleinkram.api.query import TriggerQuery
from kleinkram.api.query import file_query_is_unique
from kleinkram.api.query import mission_query_is_unique
from kleinkram.api.query import project_query_is_unique
Expand All @@ -52,10 +56,13 @@
from kleinkram.errors import TemplateNotFound
from kleinkram.errors import TemplateValidationError
from kleinkram.models import ActionTemplate
from kleinkram.models import ActionTrigger
from kleinkram.models import Execution
from kleinkram.models import File
from kleinkram.models import Mission
from kleinkram.models import Project
from kleinkram.models import TriggerConfig
from kleinkram.models import TriggerType
from kleinkram.utils import is_valid_uuid4
from kleinkram.utils import parse_uuid_like
from kleinkram.utils import split_args
Expand Down Expand Up @@ -236,7 +243,7 @@ def get_template_revisions(
) -> Generator[ActionTemplate, None, None]:
try:
response_stream = paginated_request(client, f"/templates/{template_id}/revisions")
yield from map(lambda p: _parse_action_template(p), response_stream)
yield from map(lambda p: _parse_action_template(TemplateObject(p)), response_stream)
except ValueError as e:
raise kleinkram.errors.TemplateNotFound(f"Template not found: {template_id}") from e
except httpx.HTTPStatusError:
Expand All @@ -260,7 +267,30 @@ def get_templates(
client: AuthenticatedClient,
) -> Generator[ActionTemplate, None, None]:
response_stream = paginated_request(client, "/templates")
yield from map(lambda p: _parse_action_template(p), response_stream)
yield from map(lambda p: _parse_action_template(TemplateObject(p)), response_stream)


LIST_ACTIONTRIGGERS_ENDPOINT = "/triggers"


def get_triggers(client: AuthenticatedClient, query: Optional[TriggerQuery] = None) -> List[ActionTrigger]:
params = {"missionUuid": str(query.mission_uuid)} if query and query.mission_uuid else None
# the backend does not support pagination for triggers currently, so we do a single request
resp = client.get(LIST_ACTIONTRIGGERS_ENDPOINT, params=params)
resp.raise_for_status()
payload = resp.json()
return list(map(lambda p: _parse_action_trigger(TriggerObject(p)), payload))


def get_trigger(
client: AuthenticatedClient,
trigger_uuid: UUID,
) -> ActionTrigger:
resp = client.patch(UPDATE_TRIGGER.format(trigger_uuid), json={})
Comment thread
LevinCeglie marked this conversation as resolved.
if resp.status_code == 404:
raise kleinkram.errors.TriggerNotFound(f"Trigger not found: {trigger_uuid}")
resp.raise_for_status()
return _parse_action_trigger(TriggerObject(resp.json()))


def get_project(client: AuthenticatedClient, query: ProjectQuery, exact_match: bool = False) -> Project:
Expand Down Expand Up @@ -299,6 +329,29 @@ def get_file(client: AuthenticatedClient, query: FileQuery) -> File:
raise kleinkram.errors.FileNotFound(f"File not found: {query}")


def _create_trigger(
client: AuthenticatedClient,
name: str,
description: str,
template_uuid: UUID,
mission_uuid: UUID,
type_: TriggerType,
config: TriggerConfig,
) -> UUID:
payload = {
"name": name,
"description": description,
"templateUuid": str(template_uuid),
"missionUuid": str(mission_uuid),
"type": type_.value,
"config": config.__dict__,
}
Comment thread
LevinCeglie marked this conversation as resolved.
resp = client.post("/triggers", json=payload)
resp.raise_for_status()

return UUID(resp.json()["uuid"], version=4)


def _launch_execution(client: AuthenticatedClient, mission_uuid: UUID, template_uuid: UUID) -> UUID:
"""
Submits a new action to the API and returns the action UUID.
Expand Down Expand Up @@ -460,6 +513,42 @@ def _update_project(
resp.raise_for_status()


UPDATE_TRIGGER = "/triggers/{}"


def _update_trigger(
client: AuthenticatedClient,
trigger_uuid: UUID,
*,
name: Optional[str] = None,
description: Optional[str] = None,
template_uuid: Optional[UUID] = None,
mission_uuid: Optional[UUID] = None,
type_: Optional[TriggerType] = None,
config: Optional[TriggerConfig] = None,
) -> None:

if all(v is None for v in [name, description, template_uuid, mission_uuid, type_, config]):
raise ValueError("at least one field must be updated")

body = {}
if name is not None:
body["name"] = name
if description is not None:
body["description"] = description
if template_uuid is not None:
body["templateUuid"] = str(template_uuid)
if mission_uuid is not None:
body["missionUuid"] = str(mission_uuid)
if type_ is not None:
body["type"] = type_.value
if config is not None:
body["config"] = config.__dict__

resp = client.patch(f"{UPDATE_TRIGGER.format(trigger_uuid)}", json=body)
resp.raise_for_status()


def _get_api_version() -> Tuple[int, int, int]:
config = get_config()
client = httpx.Client()
Expand Down Expand Up @@ -537,3 +626,13 @@ def _delete_execution(client: AuthenticatedClient, execution_id: UUID) -> None:
if resp.status_code == 404:
raise kleinkram.errors.ExecutionNotFound(f"Execution not found: {execution_id}")
resp.raise_for_status()


DELETE_TRIGGER_ONE = "/triggers/{}"


def _delete_trigger(client: AuthenticatedClient, trigger_uuid: UUID) -> None:
resp = client.delete(DELETE_TRIGGER_ONE.format(trigger_uuid))
if resp.status_code == 404:
raise kleinkram.errors.TriggerNotFound(f"Trigger not found: {trigger_uuid}")
resp.raise_for_status()
13 changes: 10 additions & 3 deletions cli/kleinkram/cli/_templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@

LIST_HELP = "Lists action templates (definitions). To list individual executions, use `klein executions list`."
CREATE_HELP = "Creates a new action template."
REVISIONS_HELP = "Lists revisions/history for a template."
DELETE_HELP = (
"Deletes an action template. Only the latest version of a"
" template can be deleted. If the template has existing executions, "
"it will be archived instead of being deleted."
)
CREATE_VERSION_HELP = "Creates a new version of an existing template."


@templates_typer.command(help=LIST_HELP, name="list")
Expand All @@ -45,7 +52,7 @@ def list_templates_cli(
print_templates_table(templates, pprint=get_shared_state().verbose)


@templates_typer.command(help="List revisions/history for a template.", name="revisions")
@templates_typer.command(help=REVISIONS_HELP, name="revisions")
def revisions(template: str = typer.Argument(..., metavar="TEMPLATE_ID", help="Template ID (UUID)")) -> None:
client = AuthenticatedClient()
if not is_valid_uuid4(template):
Expand All @@ -58,7 +65,7 @@ def revisions(template: str = typer.Argument(..., metavar="TEMPLATE_ID", help="T
print_templates_table(revisions, pprint=get_shared_state().verbose)


@templates_typer.command(help="Create a new version of an existing template.", name="create-version")
@templates_typer.command(help=CREATE_VERSION_HELP, name="create-version")
def create_version(
template: str = typer.Argument(..., metavar="TEMPLATE_ID", help="Template ID (UUID)"),
description: Optional[str] = typer.Option(None, "--description", "-d", help="Template description override"),
Expand Down Expand Up @@ -99,7 +106,7 @@ def create_version(
print_templates_table([template_parsed], pprint=get_shared_state().verbose)


@templates_typer.command(help="Deletes an action template.", name="delete")
@templates_typer.command(help=DELETE_HELP, name="delete")
def delete(template: str = typer.Argument(..., metavar="TEMPLATE_ID", help="Template ID (UUID)")) -> None:
if not is_valid_uuid4(template):
typer.secho(f"Error: '{template}' is not a valid UUID.", fg=typer.colors.RED)
Expand Down
Loading
Loading