diff --git a/openhexa/cli/api.py b/openhexa/cli/api.py index b00f506e..8ae70b20 100644 --- a/openhexa/cli/api.py +++ b/openhexa/cli/api.py @@ -295,8 +295,47 @@ def get_pipeline_from_code(pipeline_code: str) -> dict[str, typing.Any]: return data["pipelineByCode"] -def create_pipeline(pipeline_name: str, functional_type: str = None, tags: list[str] = None): - """Create a pipeline using the API.""" +def _build_pipeline_version_input( + pipeline, + pipeline_directory_path: str | Path, + name: str = None, + description: str = None, + external_link: str = None, +) -> dict: + """Build the GraphQL input to create a new pipeline version.""" + zip_file = generate_zip_file(pipeline_directory_path.absolute()) + + if settings.debug: + # Write zip_file to disk for debugging + with open("pipeline.zip", "wb") as debug_file: + debug_file.write(zip_file.read()) + zip_file.seek(0) + + return { + "name": name, + "description": description, + "externalLink": external_link, + "zipfile": base64.b64encode(zip_file.read()).decode("ascii"), + "parameters": [p.to_dict() for p in pipeline.parameters], + "timeout": pipeline.timeout, + } + + +def create_pipeline( + pipeline_name: str, + pipeline_directory_path: str | Path = None, + version_name: str = None, + version_description: str = None, + version_external_link: str = None, + functional_type: str = None, + tags: list[str] = None, +): + """Create a pipeline, optionally with its first version in a single atomic call. + + When ``pipeline_directory_path`` is provided, the directory is zipped and sent as + the first version (atomic on the backend). The returned dict then carries both the + ``pipeline`` and the ``pipelineVersion`` keys; otherwise only ``pipeline`` is set. + """ if settings.current_workspace is None: raise NoActiveWorkspaceError @@ -311,27 +350,64 @@ def create_pipeline(pipeline_name: str, functional_type: str = None, tags: list[ if tags: input_data["tags"] = tags + if pipeline_directory_path is not None: + pipeline = get_pipeline(pipeline_directory_path.absolute()) + input_data["version"] = _build_pipeline_version_input( + pipeline, + pipeline_directory_path, + name=version_name, + description=version_description, + external_link=version_external_link, + ) + data = graphql( """ - mutation createPipeline($input: CreatePipelineInput!) { - createPipeline(input: $input) { - success - errors - pipeline { - id - code - name + mutation createPipeline($input: CreatePipelineInput!) { + createPipeline(input: $input) { + success + errors + pipeline { + id + code + permissions { + createTemplateVersion { + isAllowed + } + } + template { + id + code + name + } + } + pipelineVersion { + id + versionName + pipeline { + id + code + permissions { + createTemplateVersion { + isAllowed + } + } + template { + id + code + name + } + } + } + } } - } - } - """, + """, {"input": input_data}, ) if not data["createPipeline"]["success"]: raise Exception(data["createPipeline"]["errors"]) - return data["createPipeline"]["pipeline"] + return data["createPipeline"] def download_pipeline_sourcecode(pipeline_code, output_path: Path = None, force_overwrite=False): @@ -628,27 +704,18 @@ def upload_pipeline( if settings.current_workspace is None: raise NoActiveWorkspaceError - directory = pipeline_directory_path.absolute() - pipeline = get_pipeline(directory) - zip_file = generate_zip_file(directory) - - if settings.debug: - # Write zip_file to disk for debugging - with open("pipeline.zip", "wb") as debug_file: - debug_file.write(zip_file.read()) - zip_file.seek(0) - - base64_content = base64.b64encode(zip_file.read()).decode("ascii") + pipeline = get_pipeline(pipeline_directory_path.absolute()) input_data = { "workspaceSlug": settings.current_workspace, "code": target_pipeline_code, - "name": name, - "description": description, - "externalLink": link, - "zipfile": base64_content, - "parameters": [p.to_dict() for p in pipeline.parameters], - "timeout": pipeline.timeout, + **_build_pipeline_version_input( + pipeline, + pipeline_directory_path, + name=name, + description=description, + external_link=link, + ), } if functional_type or pipeline.functional_type: diff --git a/openhexa/cli/cli.py b/openhexa/cli/cli.py index 6d8dba42..2648e21b 100644 --- a/openhexa/cli/cli.py +++ b/openhexa/cli/cli.py @@ -461,20 +461,30 @@ def pipelines_push( click.confirm(confirmation_message, default=True, abort=True) normalized_tags = [normalize_tag(t) for t in tag] if tag else [] - selected_pipeline = selected_pipeline or create_pipeline( - pipeline.name, functional_type=functional_type, tags=normalized_tags - ) uploaded_pipeline_version = None try: - uploaded_pipeline_version = upload_pipeline( - selected_pipeline["code"], - path, - name, - description=description, - link=link, - functional_type=functional_type, - tags=normalized_tags, - ) + if selected_pipeline: + uploaded_pipeline_version = upload_pipeline( + selected_pipeline["code"], + path, + name, + description=description, + link=link, + functional_type=functional_type, + tags=normalized_tags, + ) + else: + create_result = create_pipeline( + pipeline.name, + path, + version_name=name, + version_description=description, + version_external_link=link, + functional_type=functional_type, + tags=normalized_tags, + ) + uploaded_pipeline_version = create_result["pipelineVersion"] + selected_pipeline = create_result["pipeline"] version_url = click.style( f"{settings.public_api_url}/workspaces/{workspace}/pipelines/{selected_pipeline['code']}", fg="bright_blue", diff --git a/openhexa/graphql/graphql_client/__init__.py b/openhexa/graphql/graphql_client/__init__.py index 64426806..b31850a4 100644 --- a/openhexa/graphql/graphql_client/__init__.py +++ b/openhexa/graphql/graphql_client/__init__.py @@ -109,6 +109,7 @@ CreateAccessmodProjectError, CreateAccessmodProjectMemberError, CreateAccessmodZonalStatisticsError, + CreateAssistantConversationError, CreateBucketFolderError, CreateConnectionError, CreateDatasetError, @@ -156,6 +157,7 @@ DHIS2MetadataType, DisableTwoFactorError, EnableTwoFactorError, + FileEncoding, FileSampleStatus, FileType, GenerateChallengeError, @@ -171,6 +173,7 @@ LaunchAccessmodAnalysisError, LaunchNotebookServerError, LinkDatasetError, + LinkedObjectType, LoginError, MembershipRole, MessagePriority, @@ -233,6 +236,7 @@ UpdateWorkspaceMemberError, UpgradePipelineVersionFromTemplateError, VerifyDeviceError, + WebappOperationScope, WebappType, WorkspaceInvitationStatus, WorkspaceMembershipRole, @@ -275,6 +279,7 @@ CreateAccessmodProjectInput, CreateAccessmodProjectMemberInput, CreateAccessmodZonalStatisticsInput, + CreateAssistantConversationInput, CreateBucketFolderInput, CreateConnectionInput, CreateDatasetInput, @@ -286,9 +291,11 @@ CreatePipelineInput, CreatePipelineRecipientInput, CreatePipelineTemplateVersionInput, + CreatePipelineVersionInput, CreateTeamInput, CreateWebappInput, CreateWorkspaceInput, + DatasetVersionFileContentInput, DeclineWorkspaceInvitationInput, DeleteAccessmodAnalysisInput, DeleteAccessmodFilesetInput, @@ -392,6 +399,7 @@ UpgradePipelineVersionFromTemplateInput, UploadPipelineInput, VerifyDeviceInput, + WebappFileInput, WebappSourceInput, WorkspaceInvitationInput, WorkspacePermissionInput, @@ -541,6 +549,8 @@ "CreateAccessmodProjectMemberInput", "CreateAccessmodZonalStatisticsError", "CreateAccessmodZonalStatisticsInput", + "CreateAssistantConversationError", + "CreateAssistantConversationInput", "CreateBucketFolderError", "CreateBucketFolderInput", "CreateConnection", @@ -580,6 +590,7 @@ "CreatePipelineTemplateVersionCreatePipelineTemplateVersionPipelineTemplate", "CreatePipelineTemplateVersionError", "CreatePipelineTemplateVersionInput", + "CreatePipelineVersionInput", "CreateTeamError", "CreateTeamInput", "CreateTemplateVersionPermissionReason", @@ -609,6 +620,7 @@ "DatasetDatasetVersionsItems", "DatasetDatasetVersionsItemsCreatedBy", "DatasetDatasetWorkspace", + "DatasetVersionFileContentInput", "Datasets", "DatasetsDatasets", "DatasetsDatasetsItems", @@ -683,6 +695,7 @@ "DisableTwoFactorInput", "EnableTwoFactorError", "EnableTwoFactorInput", + "FileEncoding", "FileSampleStatus", "FileType", "GenerateChallengeError", @@ -732,6 +745,7 @@ "LaunchNotebookServerInput", "LinkDatasetError", "LinkDatasetInput", + "LinkedObjectType", "LogPipelineMessageInput", "LoginError", "LoginInput", @@ -912,6 +926,8 @@ "UploadPipelineUploadPipeline", "VerifyDeviceError", "VerifyDeviceInput", + "WebappFileInput", + "WebappOperationScope", "WebappSourceInput", "WebappType", "Workspace", diff --git a/openhexa/graphql/graphql_client/enums.py b/openhexa/graphql/graphql_client/enums.py index e1fd56a0..52243a7a 100644 --- a/openhexa/graphql/graphql_client/enums.py +++ b/openhexa/graphql/graphql_client/enums.py @@ -157,6 +157,14 @@ class CreateAccessmodZonalStatisticsError(str, Enum): NAME_DUPLICATE = "NAME_DUPLICATE" +class CreateAssistantConversationError(str, Enum): + INVALID_INSTRUCTION_SET = "INVALID_INSTRUCTION_SET" + INVALID_LINKED_OBJECT_TYPE = "INVALID_LINKED_OBJECT_TYPE" + LINKED_OBJECT_NOT_FOUND = "LINKED_OBJECT_NOT_FOUND" + PERMISSION_DENIED = "PERMISSION_DENIED" + WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND" + + class CreateBucketFolderError(str, Enum): ALREADY_EXISTS = "ALREADY_EXISTS" INVALID_PATH = "INVALID_PATH" @@ -171,6 +179,7 @@ class CreateConnectionError(str, Enum): class CreateDatasetError(str, Enum): + FILE_UPLOAD_FAILED = "FILE_UPLOAD_FAILED" PERMISSION_DENIED = "PERMISSION_DENIED" WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND" @@ -178,6 +187,7 @@ class CreateDatasetError(str, Enum): class CreateDatasetVersionError(str, Enum): DATASET_NOT_FOUND = "DATASET_NOT_FOUND" DUPLICATE_NAME = "DUPLICATE_NAME" + FILE_UPLOAD_FAILED = "FILE_UPLOAD_FAILED" PERMISSION_DENIED = "PERMISSION_DENIED" @@ -235,6 +245,7 @@ class CreateWebappError(str, Enum): PERMISSION_DENIED = "PERMISSION_DENIED" SUPERSET_INSTANCE_NOT_FOUND = "SUPERSET_INSTANCE_NOT_FOUND" SUPERSET_NOT_CONFIGURED = "SUPERSET_NOT_CONFIGURED" + WEBAPPS_NOT_CONFIGURED = "WEBAPPS_NOT_CONFIGURED" WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND" @@ -394,6 +405,7 @@ class DeleteTemplateVersionError(str, Enum): class DeleteWebappError(str, Enum): PERMISSION_DENIED = "PERMISSION_DENIED" + WEBAPPS_NOT_CONFIGURED = "WEBAPPS_NOT_CONFIGURED" WEBAPP_NOT_FOUND = "WEBAPP_NOT_FOUND" @@ -432,6 +444,11 @@ class EnableTwoFactorError(str, Enum): EMAIL_MISMATCH = "EMAIL_MISMATCH" +class FileEncoding(str, Enum): + BASE64 = "BASE64" + TEXT = "TEXT" + + class FileSampleStatus(str, Enum): FAILED = "FAILED" FINISHED = "FINISHED" @@ -523,6 +540,10 @@ class LinkDatasetError(str, Enum): WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND" +class LinkedObjectType(str, Enum): + Pipeline = "Pipeline" + + class LoginError(str, Enum): INVALID_CREDENTIALS = "INVALID_CREDENTIALS" INVALID_OTP = "INVALID_OTP" @@ -856,6 +877,7 @@ class UpdatePipelineError(str, Enum): MISSING_VERSION_CONFIG = "MISSING_VERSION_CONFIG" NOT_FOUND = "NOT_FOUND" PERMISSION_DENIED = "PERMISSION_DENIED" + PIPELINE_VERSION_NOT_FOUND = "PIPELINE_VERSION_NOT_FOUND" class UpdatePipelineVersionError(str, Enum): @@ -893,9 +915,19 @@ class UpdateUserError(str, Enum): class UpdateWebappError(str, Enum): INVALID_URL = "INVALID_URL" PERMISSION_DENIED = "PERMISSION_DENIED" + SAVE_FAILED = "SAVE_FAILED" + SUBDOMAIN_ALREADY_TAKEN = "SUBDOMAIN_ALREADY_TAKEN" + SUBDOMAIN_HAS_DOTS = "SUBDOMAIN_HAS_DOTS" + SUBDOMAIN_INVALID_FORMAT = "SUBDOMAIN_INVALID_FORMAT" + SUBDOMAIN_NOT_LOWERCASE = "SUBDOMAIN_NOT_LOWERCASE" + SUBDOMAIN_REQUIRED = "SUBDOMAIN_REQUIRED" + SUBDOMAIN_RESERVED = "SUBDOMAIN_RESERVED" + SUBDOMAIN_TOO_SHORT = "SUBDOMAIN_TOO_SHORT" SUPERSET_INSTANCE_NOT_FOUND = "SUPERSET_INSTANCE_NOT_FOUND" SUPERSET_NOT_CONFIGURED = "SUPERSET_NOT_CONFIGURED" TYPE_MISMATCH = "TYPE_MISMATCH" + VERSION_NOT_FOUND = "VERSION_NOT_FOUND" + WEBAPPS_NOT_CONFIGURED = "WEBAPPS_NOT_CONFIGURED" WEBAPP_NOT_FOUND = "WEBAPP_NOT_FOUND" @@ -920,10 +952,19 @@ class VerifyDeviceError(str, Enum): NO_DEVICE = "NO_DEVICE" +class WebappOperationScope(str, Enum): + DATASETS_READ = "DATASETS_READ" + DATASETS_WRITE = "DATASETS_WRITE" + FILES_READ = "FILES_READ" + FILES_WRITE = "FILES_WRITE" + PIPELINES_READ = "PIPELINES_READ" + PIPELINES_RUN = "PIPELINES_RUN" + USER_READ = "USER_READ" + + class WebappType(str, Enum): - BUNDLE = "BUNDLE" - HTML = "HTML" IFRAME = "IFRAME" + STATIC = "STATIC" SUPERSET = "SUPERSET" diff --git a/openhexa/graphql/graphql_client/input_types.py b/openhexa/graphql/graphql_client/input_types.py index 90c8b83f..8b1b756e 100644 --- a/openhexa/graphql/graphql_client/input_types.py +++ b/openhexa/graphql/graphql_client/input_types.py @@ -9,6 +9,8 @@ from .enums import ( AccessmodAccessibilityAnalysisAlgorithm, ConnectionType, + FileEncoding, + LinkedObjectType, MembershipRole, MessagePriority, OrganizationMembershipRole, @@ -16,6 +18,7 @@ PermissionMode, PipelineFunctionalType, PipelineNotificationLevel, + WebappOperationScope, WorkspaceMembershipRole, ) @@ -107,6 +110,15 @@ class CreateAccessmodZonalStatisticsInput(BaseModel): project_id: str = Field(alias="projectId") +class CreateAssistantConversationInput(BaseModel): + instruction_set: Optional[str] = Field(alias="instructionSet", default=None) + linked_object_id: Optional[Any] = Field(alias="linkedObjectId", default=None) + linked_object_type: Optional[LinkedObjectType] = Field( + alias="linkedObjectType", default=None + ) + workspace_slug: str = Field(alias="workspaceSlug") + + class CreateBucketFolderInput(BaseModel): folder_key: str = Field(alias="folderKey") workspace_slug: str = Field(alias="workspaceSlug") @@ -123,6 +135,7 @@ class CreateConnectionInput(BaseModel): class CreateDatasetInput(BaseModel): description: Optional[str] = None + files: Optional[List["DatasetVersionFileContentInput"]] = None name: str workspace_slug: str = Field(alias="workspaceSlug") @@ -136,6 +149,7 @@ class CreateDatasetVersionFileInput(BaseModel): class CreateDatasetVersionInput(BaseModel): changelog: Optional[str] = None dataset_id: str = Field(alias="datasetId") + files: Optional[List["DatasetVersionFileContentInput"]] = None name: str @@ -163,12 +177,14 @@ class CreatePipelineFromTemplateVersionInput(BaseModel): class CreatePipelineInput(BaseModel): code: Optional[str] = None + description: Optional[str] = None functional_type: Optional[PipelineFunctionalType] = Field( alias="functionalType", default=None ) name: str notebook_path: Optional[str] = Field(alias="notebookPath", default=None) tags: Optional[List[str]] = None + version: Optional["CreatePipelineVersionInput"] = None workspace_slug: str = Field(alias="workspaceSlug") @@ -189,11 +205,24 @@ class CreatePipelineTemplateVersionInput(BaseModel): workspace_slug: str = Field(alias="workspaceSlug") +class CreatePipelineVersionInput(BaseModel): + config: Optional[Any] = None + description: Optional[str] = None + external_link: Optional[Any] = Field(alias="externalLink", default=None) + name: Optional[str] = None + parameters: Optional[List["ParameterInput"]] = None + timeout: Optional[int] = None + zipfile: str + + class CreateTeamInput(BaseModel): name: str class CreateWebappInput(BaseModel): + allowed_operations: Optional[List[WebappOperationScope]] = Field( + alias="allowedOperations", default=None + ) description: Optional[str] = None icon: Optional[str] = None is_public: Optional[bool] = Field(alias="isPublic", default=None) @@ -212,6 +241,12 @@ class CreateWorkspaceInput(BaseModel): slug: Optional[str] = None +class DatasetVersionFileContentInput(BaseModel): + content: str + content_type: str = Field(alias="contentType") + uri: str + + class DeclineWorkspaceInvitationInput(BaseModel): invitation_id: Any = Field(alias="invitationId") @@ -708,6 +743,9 @@ class UpdatePipelineInput(BaseModel): id: Any name: Optional[str] = None schedule: Optional[str] = None + scheduled_pipeline_version_id: Optional[Any] = Field( + alias="scheduledPipelineVersionId", default=None + ) tags: Optional[List[str]] = None webhook_enabled: Optional[bool] = Field(alias="webhookEnabled", default=None) @@ -764,12 +802,20 @@ class UpdateUserInput(BaseModel): class UpdateWebappInput(BaseModel): + allowed_operations: Optional[List[WebappOperationScope]] = Field( + alias="allowedOperations", default=None + ) description: Optional[str] = None + files: Optional[List["WebappFileInput"]] = None icon: Optional[str] = None id: Any is_public: Optional[bool] = Field(alias="isPublic", default=None) name: Optional[str] = None + published_version_id: Optional[str] = Field( + alias="publishedVersionId", default=None + ) source: Optional["UpdateWebappSourceInput"] = None + subdomain: Optional[str] = None class UpdateWebappSourceInput(BaseModel): @@ -816,8 +862,15 @@ class VerifyDeviceInput(BaseModel): token: Optional[str] = None +class WebappFileInput(BaseModel): + content: str + encoding: Optional[FileEncoding] = FileEncoding.TEXT + path: str + + class WebappSourceInput(BaseModel): iframe: Optional["IframeSourceInput"] = None + static: Optional[List["WebappFileInput"]] = None superset: Optional["SupersetSourceInput"] = None @@ -841,7 +894,11 @@ class WriteFileContentInput(BaseModel): CreateAccessmodProjectInput.model_rebuild() CreateConnectionInput.model_rebuild() +CreateDatasetInput.model_rebuild() +CreateDatasetVersionInput.model_rebuild() CreateOrganizationInput.model_rebuild() +CreatePipelineInput.model_rebuild() +CreatePipelineVersionInput.model_rebuild() CreateWebappInput.model_rebuild() CreateWorkspaceInput.model_rebuild() InviteOrganizationMemberInput.model_rebuild() diff --git a/openhexa/graphql/schema.generated.graphql b/openhexa/graphql/schema.generated.graphql index 37d4e78b..33b8722d 100644 --- a/openhexa/graphql/schema.generated.graphql +++ b/openhexa/graphql/schema.generated.graphql @@ -1042,8 +1042,8 @@ input CreatePipelineInput { name: String! notebookPath: String tags: [String!] + version: CreatePipelineVersionInput workspaceSlug: String! - zipfile: String } """Represents the input for adding a recipient to a pipeline.""" @@ -1092,6 +1092,20 @@ type CreatePipelineTemplateVersionResult { success: Boolean! } +""" +Configures the first pipeline version, created atomically alongside the pipeline. +Providing this sub-input signals that a first version should be created. +""" +input CreatePipelineVersionInput { + config: JSON + description: String + externalLink: URL + name: String + parameters: [ParameterInput!] + timeout: Int + zipfile: String! +} + """ The CreateTeamError enum represents the possible errors that can occur during the createTeam mutation. """ @@ -2240,10 +2254,19 @@ type File { updatedAt: DateTime } +""" +How the `content` field of a FileNode (or WebappFileInput) is encoded over the wire. +""" +enum FileEncoding { + BASE64 + TEXT +} + """Represents a file or directory node in a flattened structure.""" type FileNode { autoSelect: Boolean! content: String + encoding: FileEncoding id: String! language: String lineCount: Int @@ -3322,6 +3345,7 @@ type PinDatasetResult { """Represents a pipeline.""" type Pipeline { + assistantConversations: [AssistantConversation!]! autoUpdateFromTemplate: Boolean! code: String! config: JSON! @@ -5409,8 +5433,16 @@ type Webapp { workspace: Workspace! } +""" +A file to write to a webapp's git repository. + +`content` is interpreted according to `encoding`: + * TEXT (default) — content is a UTF-8 string; suitable for hand-written or AI-generated code. + * BASE64 — content is base64-encoded raw bytes; required for binary files (images, fonts, …). +""" input WebappFileInput { content: String! + encoding: FileEncoding = TEXT path: String! } @@ -5640,4 +5672,4 @@ type WriteFileContentResult { filePath: String size: Int success: Boolean! -} +} \ No newline at end of file diff --git a/tests/test_api.py b/tests/test_api.py index 205315d8..488012a2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -7,7 +7,7 @@ from unittest import mock from zipfile import ZipFile -from openhexa.cli.api import create_pipeline_structure, upload_pipeline +from openhexa.cli.api import create_pipeline, create_pipeline_structure, upload_pipeline def test_create_pipeline_structure(settings): @@ -75,3 +75,92 @@ def test_upload_pipeline_success(settings): assert "readme.md" in zip_file.namelist() assert "pipeline.py" in zip_file.namelist() assert len(zip_file.namelist()) == 2 + + +def test_create_pipeline_atomic_with_version(settings): + """When a path is provided, create_pipeline sends pipeline + first version atomically.""" + with tempfile.TemporaryDirectory() as temp_dir: + pipeline_dir = create_pipeline_structure( + "my_pipeline", + Path(temp_dir), + workspace="workspace-slug", + ) + + with mock.patch("openhexa.cli.api.graphql") as mocked_graphql_client: + mocked_graphql_client.return_value = { + "createPipeline": { + "success": True, + "errors": [], + "pipeline": { + "id": "p-1", + "code": "my-pipeline", + "permissions": {"createTemplateVersion": {"isAllowed": False}}, + "template": None, + }, + "pipelineVersion": { + "id": "v-1", + "versionName": "v1", + "pipeline": { + "id": "p-1", + "code": "my-pipeline", + "permissions": {"createTemplateVersion": {"isAllowed": False}}, + "template": None, + }, + }, + } + } + + create_pipeline( + "My Pipeline", + pipeline_dir, + version_name="v1", + version_description="first version", + version_external_link="https://github.com/", + functional_type="extraction", + tags=["tag-a"], + ) + + args_input = mocked_graphql_client.call_args[0][1]["input"] + + # pipeline-level fields + assert args_input["workspaceSlug"] == "workspace-slug" + assert args_input["name"] == "My Pipeline" + assert args_input["functionalType"] == "extraction" + assert args_input["tags"] == ["tag-a"] + + # version sub-input + version_input = args_input["version"] + assert version_input["name"] == "v1" + assert version_input["description"] == "first version" + assert version_input["externalLink"] == "https://github.com/" + assert version_input["parameters"] == [] + assert version_input["timeout"] is None + + with ZipFile(io.BytesIO(base64.b64decode(version_input["zipfile"]))) as zip_file: + assert "pipeline.py" in zip_file.namelist() + + +def test_create_pipeline_without_version(settings): + """When no path is given, create_pipeline omits the `version` sub-input.""" + with mock.patch("openhexa.cli.api.graphql") as mocked_graphql_client: + mocked_graphql_client.return_value = { + "createPipeline": { + "success": True, + "errors": [], + "pipeline": { + "id": "p-1", + "code": "my-pipeline", + "permissions": {"createTemplateVersion": {"isAllowed": False}}, + "template": None, + }, + "pipelineVersion": None, + } + } + + create_pipeline("My Pipeline", functional_type="extraction", tags=["tag-a"]) + + args_input = mocked_graphql_client.call_args[0][1]["input"] + assert "version" not in args_input + assert args_input["name"] == "My Pipeline" + assert args_input["functionalType"] == "extraction" + assert args_input["tags"] == ["tag-a"] diff --git a/tests/test_cli.py b/tests/test_cli.py index c0fbf3c8..6628c053 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -243,6 +243,79 @@ def test_push_pipeline_with_non_existing_code( self.assertEqual(result.exit_code, 1) self.assertIn("❌ Pipeline with code 'non_existing_code' not found.", result.output) + @patch("openhexa.cli.api.graphql") + @patch("openhexa.cli.cli.get_pipeline") + @patch("openhexa.cli.cli.get_pipelines_pages") + @patch("openhexa.cli.cli.upload_pipeline") + @patch("openhexa.cli.cli.create_pipeline") + @patch.dict(os.environ, {"HEXA_API_URL": "https://www.bluesquarehub.com/", "HEXA_WORKSPACE": "workspace"}) + def test_push_pipeline_creates_new_atomically( + self, + mock_create_pipeline, + mock_upload_pipeline, + mock_get_pipelines_pages, + mock_get_pipeline, + mock_graphql, + ): + """When the user picks 'Create a new pipeline', a single atomic create_pipeline call happens.""" + with self.runner.isolated_filesystem() as tmp: + with open(Path(tmp) / python_file_name, "w") as f: + f.write(python_code) + mock_graphql.return_value = setup_graphql_response() + mock_pipeline = MagicMock(spec=Pipeline) + mock_pipeline.name = pipeline_name + mock_get_pipeline.return_value = mock_pipeline + mock_get_pipelines_pages.return_value = { + "items": [ + {"name": "Pipeline1", "code": "code1"}, + {"name": "Pipeline2", "code": "code2"}, + ], + "totalPages": 1, # single page so no "enter code" option appears + } + mock_create_pipeline.return_value = { + "pipeline": { + "id": pipeline_id, + "code": "pipeline-1234", + "permissions": {"createTemplateVersion": {"isAllowed": False}}, + "template": None, + }, + "pipelineVersion": { + "id": pipeline_version_id, + "versionName": version, + "pipeline": { + "id": pipeline_id, + "code": "pipeline-1234", + "permissions": {"createTemplateVersion": {"isAllowed": False}}, + "template": None, + }, + }, + } + + # choices: [P1, P2, "Create a new ...", "Cancel"] → "3" = create new + # then "Y" to confirm push + result = self.runner.invoke( + pipelines_push, + [tmp, "--name", version, "--description", "first", "--link", "https://github.com/"], + input="\n".join(["3", "Y"]) + "\n", + ) + + self.assertEqual(result.exit_code, 0) + self.assertTrue(mock_create_pipeline.called) + self.assertFalse(mock_upload_pipeline.called, "upload_pipeline must not be called on first push") + + call_kwargs = mock_create_pipeline.call_args.kwargs + call_args = mock_create_pipeline.call_args.args + assert call_args[0] == pipeline_name # pipeline name (positional) + assert call_kwargs["version_name"] == version + assert call_kwargs["version_description"] == "first" + assert call_kwargs["version_external_link"] == "https://github.com/" + + self.assertIn( + f"✅ New version '{version}' created! " + "You can view the pipeline in OpenHEXA on https://www.bluesquarehub.com/workspaces/workspace/pipelines/pipeline-1234", + result.output, + ) + @patch("openhexa.cli.api.graphql") @patch("openhexa.cli.cli.get_pipeline") @patch("openhexa.cli.cli.get_pipelines_pages")