diff --git a/renku/cli/dataset.py b/renku/cli/dataset.py index 224bc76e09..06a92826a6 100644 --- a/renku/cli/dataset.py +++ b/renku/cli/dataset.py @@ -474,7 +474,7 @@ def list_dataset(format, columns): type=click.Path(exists=True, dir_okay=False), help="Custom metadata to be associated with the dataset.", ) -@click.option("-k", "--keyword", default=None, multiple=True, type=click.STRING, help="List of keywords or tags.") +@click.option("-k", "--keyword", default=None, multiple=True, type=click.STRING, help="List of keywords.") def create(name, title, description, creators, metadata, keyword): """Create an empty dataset in the current repo.""" from renku.core.commands.dataset import create_dataset diff --git a/renku/cli/init.py b/renku/cli/init.py index 9e3e8aafe5..dd5bdc1ae3 100644 --- a/renku/cli/init.py +++ b/renku/cli/init.py @@ -248,6 +248,7 @@ def resolve_data_directory(data_dir, path): @click.argument("path", default=".", type=click.Path(writable=True, file_okay=False, resolve_path=True)) @click.option("-n", "--name", callback=validate_name, help="Provide a custom project name.") @click.option("--description", help="Provide a description for the project.") +@click.option("-k", "--keyword", default=None, multiple=True, type=click.STRING, help="List of keywords.") @click.option( "--data-dir", default=None, @@ -292,6 +293,7 @@ def init( path, name, description, + keyword, template_id, template_index, template_source, @@ -327,6 +329,7 @@ def init( path=path, name=name, description=description, + keywords=keyword, template_id=template_id, template_index=template_index, template_source=template_source, diff --git a/renku/cli/project.py b/renku/cli/project.py index e2c273804c..eb24181675 100644 --- a/renku/cli/project.py +++ b/renku/cli/project.py @@ -17,6 +17,23 @@ # limitations under the License. r"""Renku CLI commands for handling of projects. +Showing project metadata +~~~~~~~~~~~~~~~~~~~~~~~~ + +You can see the metadata of the current project by using ``renku project show``: + .. code-block:: console + + $ renku project show + Id: /projects/john.doe/flights-tutorial + Name: flights-tutorial + Description: Flight tutorial project + Creator: John Doe + Created: 2021-11-05T10:32:57+01:00 + Keywords: keyword1, keyword2 + Renku Version: 1.0.0 + Project Template: python-minimal (1.0.0) + + Editing projects ~~~~~~~~~~~~~~~~ @@ -55,6 +72,7 @@ def project(): @project.command() @click.option("-d", "--description", default=None, type=click.STRING, help="Project's description.") +@click.option("-k", "--keyword", default=None, multiple=True, type=click.STRING, help="List of keywords.") @click.option( "-c", "--creator", @@ -69,7 +87,7 @@ def project(): type=click.Path(exists=True, dir_okay=False), help="Custom metadata to be associated with the project.", ) -def edit(description, creator, metadata): +def edit(description, keyword, creator, metadata): """Edit project metadata.""" from renku.core.commands.project import edit_project_command @@ -81,7 +99,7 @@ def edit(description, creator, metadata): result = ( edit_project_command() .build() - .execute(description=description, creator=creator, custom_metadata=custom_metadata) + .execute(description=description, creator=creator, keywords=keyword, custom_metadata=custom_metadata) ) updated, no_email_warning = result.output @@ -92,3 +110,30 @@ def edit(description, creator, metadata): click.echo("Successfully updated: {}.".format(", ".join(updated.keys()))) if no_email_warning: click.echo(ClickCallback.WARNING + f"No email or wrong format for: {no_email_warning}") + + +def _print_project(project): + """Print project metadata.""" + click.echo(click.style("Id: ", bold=True, fg="magenta") + click.style(project.id, bold=True)) + click.echo(click.style("Name: ", bold=True, fg="magenta") + click.style(project.name, bold=True)) + click.echo(click.style("Description: ", bold=True, fg="magenta") + click.style(project.description, bold=True)) + click.echo(click.style("Creator: ", bold=True, fg="magenta") + click.style(project.creator_str, bold=True)) + click.echo(click.style("Created: ", bold=True, fg="magenta") + click.style(project.created_str, bold=True)) + click.echo(click.style("Keywords: ", bold=True, fg="magenta") + click.style(project.keywords_str, bold=True)) + click.echo(click.style("Renku Version: ", bold=True, fg="magenta") + click.style(project.agent, bold=True)) + click.echo( + click.style("Project Template: ", bold=True, fg="magenta") + click.style(project.template_info, bold=True) + ) + + if project.annotations: + click.echo(click.style("Annotations: ", bold=True, fg="magenta") + click.style(project.annotations, bold=True)) + + +@project.command() +def show(): + """Show details for the project.""" + from renku.core.commands.project import show_project_command + + project = show_project_command().build().execute().output + + _print_project(project) diff --git a/renku/core/commands/init.py b/renku/core/commands/init.py index 60201f55b9..235d1f7ed1 100644 --- a/renku/core/commands/init.py +++ b/renku/core/commands/init.py @@ -237,6 +237,7 @@ def _init( path, name, description, + keywords, template_id, template_index, template_source, @@ -351,6 +352,7 @@ def _init( force=force, data_dir=data_dir, description=description, + keywords=keywords, ) except FileExistsError as e: raise errors.InvalidFileOperation(e) @@ -503,6 +505,7 @@ def create_from_template( user=None, commit_message=None, description=None, + keywords=None, ): """Initialize a new project from a template.""" @@ -521,7 +524,9 @@ def create_from_template( metadata["name"] = name with client.commit(commit_message=commit_message, commit_only=commit_only, skip_dirty_checks=True): - with client.with_metadata(name=name, description=description, custom_metadata=custom_metadata) as project: + with client.with_metadata( + name=name, description=description, custom_metadata=custom_metadata, keywords=keywords + ) as project: project.template_source = metadata["__template_source__"] project.template_ref = metadata["__template_ref__"] project.template_id = metadata["__template_id__"] @@ -554,6 +559,7 @@ def _create_from_template_local( initial_branch=None, commit_message=None, description=None, + keywords=None, ): """Initialize a new project from a template.""" @@ -580,6 +586,7 @@ def _create_from_template_local( user=user, commit_message=commit_message, description=description, + keywords=keywords, ) diff --git a/renku/core/commands/project.py b/renku/core/commands/project.py index 8726ab03fe..f3a1b5f6a4 100644 --- a/renku/core/commands/project.py +++ b/renku/core/commands/project.py @@ -17,17 +17,24 @@ # limitations under the License. """Project management.""" +from renku.core.commands.view_model.project import ProjectViewModel from renku.core.management.command_builder import inject from renku.core.management.command_builder.command import Command +from renku.core.management.interface.client_dispatcher import IClientDispatcher from renku.core.management.interface.project_gateway import IProjectGateway from renku.core.management.repository import DATABASE_METADATA_PATH from renku.core.utils.metadata import construct_creator @inject.autoparams() -def _edit_project(description, creator, custom_metadata, project_gateway: IProjectGateway): +def _edit_project(description, creator, keywords, custom_metadata, project_gateway: IProjectGateway): """Edit dataset metadata.""" - possible_updates = {"creator": creator, "description": description, "custom_metadata": custom_metadata} + possible_updates = { + "creator": creator, + "description": description, + "keywords": keywords, + "custom_metadata": custom_metadata, + } creator, no_email_warnings = construct_creator(creator, ignore_email=True) @@ -35,7 +42,9 @@ def _edit_project(description, creator, custom_metadata, project_gateway: IProje if updated: project = project_gateway.get_project() - project.update_metadata(creator=creator, description=description, custom_metadata=custom_metadata) + project.update_metadata( + creator=creator, description=description, keywords=keywords, custom_metadata=custom_metadata + ) project_gateway.update_project(project) return updated, no_email_warnings @@ -45,3 +54,14 @@ def edit_project_command(): """Command for editing project metadata.""" command = Command().command(_edit_project).lock_project().with_database(write=True) return command.require_migration().with_commit(commit_only=DATABASE_METADATA_PATH) + + +@inject.autoparams() +def _show_project(client_dispatcher: IClientDispatcher): + """Show project metadata.""" + return ProjectViewModel.from_project(client_dispatcher.current_client.project) + + +def show_project_command(): + """Command for showing project metadata.""" + return Command().command(_show_project).lock_project().with_database().require_migration() diff --git a/renku/core/commands/view_model/project.py b/renku/core/commands/view_model/project.py new file mode 100644 index 0000000000..49945bc62d --- /dev/null +++ b/renku/core/commands/view_model/project.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2017-2021 - Swiss Data Science Center (SDSC) +# A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and +# Eidgenössische Technische Hochschule Zürich (ETHZ). +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Project view model.""" + +import json +from datetime import datetime +from typing import List, Optional + +from renku.core.models.project import Project +from renku.core.models.provenance.agent import Person + + +class ProjectViewModel: + """A view model for a ``Project``.""" + + def __init__( + self, + id: str, + name: str, + creator: Person, + created: datetime, + description: str, + agent: str, + annotations: Optional[str], + template_info: str, + keywords: Optional[List[str]], + ): + self.id = id + self.name = name + self.creator = creator + self.creator_str = creator.full_identity + self.created = created + self.created_str = created.isoformat() + self.description = description + self.agent = agent + self.annotations = annotations + self.template_info = template_info + self.keywords = keywords + self.keywords_str = ", ".join(keywords) + + @classmethod + def from_project(cls, project: Project): + """Create view model from ``Project``.""" + template_info = "" + + if project.template_source: + if project.template_source == "renku": + template_info = f"{project.template_id} ({project.template_version})" + else: + template_info = f"{project.template_source}@{project.template_ref}: {project.template_id}" + + return cls( + id=project.id, + name=project.name, + creator=project.creator, + created=project.date_created, + description=project.description, + agent=project.agent_version, + annotations=json.dumps([{"id": a.id, "body": a.body, "source": a.source} for a in project.annotations]) + if project.annotations + else None, + template_info=template_info, + keywords=project.keywords, + ) diff --git a/renku/core/management/repository.py b/renku/core/management/repository.py index 20285911b6..c76c3f7ba6 100644 --- a/renku/core/management/repository.py +++ b/renku/core/management/repository.py @@ -249,6 +249,7 @@ def with_metadata( read_only=False, name=None, description=None, + keywords=None, custom_metadata=None, ): """Yield an editable metadata object.""" @@ -257,7 +258,7 @@ def with_metadata( project = project_gateway.get_project() except ValueError: project = Project.from_client( - name=name, description=description, custom_metadata=custom_metadata, client=self + name=name, description=description, keywords=keywords, custom_metadata=custom_metadata, client=self ) yield project diff --git a/renku/core/models/project.py b/renku/core/models/project.py index 996741d13b..d88ef9c144 100644 --- a/renku/core/models/project.py +++ b/renku/core/models/project.py @@ -36,6 +36,8 @@ class Project(persistent.Persistent): """Represent a project.""" + keywords = None + def __init__( self, *, @@ -54,6 +56,7 @@ def __init__( template_source: str = None, template_version: str = None, version: str = None, + keywords: List[str] = None, ): from renku.core.management.migrate import SUPPORTED_PROJECT_VERSION @@ -79,6 +82,7 @@ def __init__( self.template_source: str = template_source self.template_version: str = template_version self.version: str = version + self.keywords: List[str] = keywords or [] @classmethod def from_client( @@ -86,6 +90,7 @@ def from_client( client, name: str = None, description: str = None, + keywords: List[str] = None, custom_metadata: Dict = None, creator: Person = None, ) -> "Project": @@ -101,7 +106,9 @@ def from_client( raise ValueError("Project Creator not set") id = cls.generate_id(namespace=namespace, name=name) - return cls(creator=creator, id=id, name=name, description=description, annotations=annotations) + return cls( + creator=creator, id=id, name=name, description=description, keywords=keywords, annotations=annotations + ) @staticmethod def get_namespace_and_name(*, client=None, name: str = None, creator: Person = None): @@ -134,7 +141,7 @@ def generate_id(namespace: str, name: str): def update_metadata(self, custom_metadata=None, **kwargs): """Updates metadata.""" - editable_attributes = ["creator", "description"] + editable_attributes = ["creator", "description", "keywords"] for name, value in kwargs.items(): if name not in editable_attributes: raise errors.ParameterError(f"Cannot edit field: '{name}'") @@ -174,3 +181,4 @@ class Meta: template_source = fields.String(renku.templateSource, missing=None) template_version = fields.String(renku.templateVersion, missing=None) version = StringList(schema.schemaVersion, missing="1") + keywords = fields.List(schema.keywords, fields.String(), missing=None) diff --git a/renku/data/shacl_shape.json b/renku/data/shacl_shape.json index 676cdede2d..4e969e0f92 100644 --- a/renku/data/shacl_shape.json +++ b/renku/data/shacl_shape.json @@ -214,6 +214,13 @@ "sh:class": { "@id": "prov:Activity" } + }, + { + "nodeKind": "sh:Literal", + "path": "schema:keywords", + "datatype": { + "@id": "xsd:string" + } } ] }, diff --git a/renku/service/controllers/project_edit.py b/renku/service/controllers/project_edit.py index 7dd65d0e9a..b96f968302 100644 --- a/renku/service/controllers/project_edit.py +++ b/renku/service/controllers/project_edit.py @@ -54,6 +54,7 @@ def renku_op(self): description=self.ctx.get("description"), creator=self.ctx.get("creator"), custom_metadata=self.ctx.get("custom_metadata"), + keywords=self.ctx.get("keywords"), ) ) diff --git a/renku/service/controllers/project_show.py b/renku/service/controllers/project_show.py new file mode 100644 index 0000000000..5ba935053c --- /dev/null +++ b/renku/service/controllers/project_show.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2020 - Swiss Data Science Center (SDSC) +# A partnership between École Polytechnique Fédérale de Lausanne (EPFL) and +# Eidgenössische Technische Hochschule Zürich (ETHZ). +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Renku service project show controller.""" +from renku.core.commands.project import show_project_command +from renku.service.controllers.api.abstract import ServiceCtrl +from renku.service.controllers.api.mixins import RenkuOperationMixin +from renku.service.serializers.project import ProjectShowRequest, ProjectShowResponseRPC +from renku.service.views import result_response + + +class ProjectShowCtrl(ServiceCtrl, RenkuOperationMixin): + """Controller for project show endpoint.""" + + REQUEST_SERIALIZER = ProjectShowRequest() + RESPONSE_SERIALIZER = ProjectShowResponseRPC() + + def __init__(self, cache, user_data, request_data, migrate_project=False): + """Construct a project edit controller.""" + self.ctx = ProjectShowCtrl.REQUEST_SERIALIZER.load(request_data) + + super().__init__(cache, user_data, request_data, migrate_project=migrate_project) + + @property + def context(self): + """Controller operation context.""" + return self.ctx + + def renku_op(self): + """Renku operation for the controller.""" + result = show_project_command().build().execute() + return result.output + + def to_response(self): + """Execute controller flow and serialize to service response.""" + result = self.execute_op() + return result_response(ProjectShowCtrl.RESPONSE_SERIALIZER, result) diff --git a/renku/service/serializers/project.py b/renku/service/serializers/project.py index 9b40d0f99e..4a33fb666d 100644 --- a/renku/service/serializers/project.py +++ b/renku/service/serializers/project.py @@ -17,6 +17,7 @@ # limitations under the License. """Renku service project serializers.""" from marshmallow import fields +from marshmallow.schema import Schema from renku.core.models.dataset import DatasetCreatorsJson as DatasetCreators from renku.service.serializers.common import ( @@ -29,19 +30,48 @@ from renku.service.serializers.rpc import JsonRPCResponse +class ProjectShowRequest(AsyncSchema, LocalRepositorySchema, RemoteRepositorySchema, MigrateSchema): + """Project show metadata request.""" + + +class ProjectShowResponse(Schema): + """Response schema for project show.""" + + id = fields.String(description="The ID of this project") + name = fields.String(description="The name of the project") + description = fields.String(default=None, description="The optional description of the project") + created = fields.DateTime(description="The date this project was created at.") + creator = fields.Nested(DatasetCreators, description="The creator of this project") + agent = fields.String(description="The renku version last used on this project") + custom_metadata = fields.Dict( + default=None, attribute="annotations", description="Custom JSON-LD metadata of the project" + ) + template_info = fields.String(description="The template that was used in the creation of this project") + keywords = fields.List( + fields.String(), default=None, Missing=None, description="They keywords associated with this project" + ) + + +class ProjectShowResponseRPC(RenkuSyncSchema): + """RPC schema for project show.""" + + result = fields.Nested(ProjectShowResponse) + + class ProjectEditRequest(AsyncSchema, LocalRepositorySchema, RemoteRepositorySchema, MigrateSchema): """Project edit metadata request.""" - description = fields.String(default=None) - creator = fields.Nested(DatasetCreators) - custom_metadata = fields.Dict(default=None) + description = fields.String(default=None, description="New description for the project") + creator = fields.Nested(DatasetCreators, description="New creator for the project") + custom_metadata = fields.Dict(default=None, description="Custom JSON-LD metadata") + keywords = fields.List(fields.String(), default=None, Missing=None, description="Keyword(s) for the project") class ProjectEditResponse(RenkuSyncSchema): """Project edit metadata response.""" - edited = fields.Dict(required=True) - warning = fields.String() + edited = fields.Dict(required=True, description="Key:value paris of edited metadata") + warning = fields.String(description="Warnings raised when editing metadata") class ProjectEditResponseRPC(JsonRPCResponse): diff --git a/renku/service/serializers/templates.py b/renku/service/serializers/templates.py index 80f4248504..ad7a700f26 100644 --- a/renku/service/serializers/templates.py +++ b/renku/service/serializers/templates.py @@ -61,6 +61,7 @@ class ProjectTemplateRequest(ProjectCloneContext, ManifestTemplatesRequest): project_repository = fields.String(required=True) project_slug = fields.String(required=True) project_description = fields.String(missing=None) + project_keywords = fields.List(fields.String(), missing=None) project_custom_metadata = fields.Dict(missing=None) new_project_url = fields.String(required=True) diff --git a/renku/service/views/project.py b/renku/service/views/project.py index f12360984a..dc97d1e280 100644 --- a/renku/service/views/project.py +++ b/renku/service/views/project.py @@ -20,6 +20,7 @@ from renku.service.config import SERVICE_PREFIX from renku.service.controllers.project_edit import ProjectEditCtrl +from renku.service.controllers.project_show import ProjectShowCtrl from renku.service.views.api_versions import V1_0, VersionedBlueprint from renku.service.views.decorators import accepts_json, handle_common_except, requires_cache, requires_identity @@ -27,6 +28,34 @@ project_blueprint = VersionedBlueprint(PROJECT_BLUEPRINT_TAG, __name__, url_prefix=SERVICE_PREFIX) +@project_blueprint.route("/project.show", methods=["POST"], provide_automatic_options=False, versions=[V1_0]) +@handle_common_except +@accepts_json +@requires_cache +@requires_identity +def show_project_view(user_data, cache): + """ + Show project metadata view. + + --- + post: + description: Show project metadata. + requestBody: + content: + application/json: + schema: ProjectShowRequest + responses: + 200: + description: Metadata of the project. + content: + application/json: + schema: ProjectShowResponseRPC + tags: + - project + """ + return ProjectShowCtrl(cache, user_data, dict(request.json)).to_response() + + @project_blueprint.route("/project.edit", methods=["POST"], provide_automatic_options=False, versions=[V1_0]) @handle_common_except @accepts_json diff --git a/tests/cli/test_project.py b/tests/cli/test_project.py index 8c7724fc72..fcf15d8f7a 100644 --- a/tests/cli/test_project.py +++ b/tests/cli/test_project.py @@ -25,6 +25,17 @@ from tests.utils import format_result_exception +def test_project_show(runner, client, subdirectory, client_database_injection_manager): + """Check showing project metadata.""" + result = runner.invoke(cli, ["project", "show"]) + + assert 0 == result.exit_code, format_result_exception(result) + assert "Id:" in result.output + assert "Name:" in result.output + assert "Creator:" in result.output + assert "Renku Version:" in result.output + + def test_project_edit(runner, client, subdirectory, client_database_injection_manager): """Check project metadata editing.""" (client.path / "README.md").write_text("Make repo dirty.") @@ -42,11 +53,25 @@ def test_project_edit(runner, client, subdirectory, client_database_injection_ma commit_sha_before = client.repository.head.commit.hexsha result = runner.invoke( - cli, ["project", "edit", "-d", " new description ", "-c", creator, "--metadata", str(metadata_path)] + cli, + [ + "project", + "edit", + "-d", + " new description ", + "-c", + creator, + "--metadata", + str(metadata_path), + "-k", + "keyword1", + "-k", + "keyword2", + ], ) assert 0 == result.exit_code, format_result_exception(result) - assert "Successfully updated: creator, description, custom_metadata." in result.output + assert "Successfully updated: creator, description, keywords, custom_metadata." in result.output assert "Warning: No email or wrong format for: Forename Surname" in result.output with client_database_injection_manager(client): @@ -58,11 +83,21 @@ def test_project_edit(runner, client, subdirectory, client_database_injection_ma assert "Forename Surname" == project.creator.name assert "Affiliation" == project.creator.affiliation assert metadata == project.annotations[0].body + assert {"keyword1", "keyword2"} == set(project.keywords) assert client.repository.is_dirty(untracked_files=True) commit_sha_after = client.repository.head.commit.hexsha assert commit_sha_before != commit_sha_after + result = runner.invoke(cli, ["project", "show"]) + + assert 0 == result.exit_code, format_result_exception(result) + assert "Id:" in result.output + assert "Name:" in result.output + assert "Creator:" in result.output + assert "Renku Version:" in result.output + assert "Keywords:" in result.output + def test_project_edit_no_change(runner, client): """Check project metadata editing does not commit when there is no change.""" diff --git a/tests/service/views/test_project_views.py b/tests/service/views/test_project_views.py index dabeef730a..49a9c4cc07 100644 --- a/tests/service/views/test_project_views.py +++ b/tests/service/views/test_project_views.py @@ -32,6 +32,34 @@ def assert_rpc_response(response, with_key="result"): assert with_key in response_text +@pytest.mark.service +@pytest.mark.integration +@retry_failed +def test_show_project_view(svc_client_with_repo): + """Test show project metadata.""" + svc_client, headers, project_id, _ = svc_client_with_repo + + show_payload = { + "project_id": project_id, + } + response = svc_client.post("/1.0/project.show", data=json.dumps(show_payload), headers=headers) + + assert response + assert_rpc_response(response) + + assert { + "id", + "name", + "description", + "created", + "creator", + "agent", + "custom_metadata", + "template_info", + "keywords", + } == set(response.json["result"]) + + @pytest.mark.service @pytest.mark.integration @retry_failed