Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): add renku log command #2358

Merged
merged 7 commits into from
Oct 5, 2021
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions renku/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
from renku.cli.githooks import githooks as githooks_command
from renku.cli.graph import graph
from renku.cli.init import init as init_command
from renku.cli.log import log
from renku.cli.login import login, logout, token
from renku.cli.migrate import check_immutable_template_files, migrate, migrationscheck
from renku.cli.move import move
Expand Down Expand Up @@ -197,6 +198,7 @@ def help(ctx):
cli.add_command(githooks_command)
cli.add_command(graph)
cli.add_command(init_command)
cli.add_command(log)
cli.add_command(login)
cli.add_command(logout)
cli.add_command(migrate)
Expand Down
53 changes: 53 additions & 0 deletions renku/cli/log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
#
# Copyright 2018-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.
"""Renku cli for history of renku commands.

You can use ``renku log`` to get a history of renku commands.
At the moment, it only shows workflow executions

.. code-block:: console

$ renku log
DATE TYPE DESCRIPTION
------------------- ------------------ -------------
2021-09-21 15:46:02 Workflow Execution cp A C
2021-09-21 10:52:51 Workflow Execution cp A B
"""

import click

from renku.core.commands.log import log_command
from renku.core.commands.view_model.log import LOG_COLUMNS, LOG_FORMATS


@click.command()
@click.option(
"-c",
"--columns",
type=click.STRING,
default="date,type,description",
metavar="<columns>",
help="Comma-separated list of column to display: {}.".format(", ".join(LOG_COLUMNS.keys())),
show_default=True,
)
@click.option("--format", type=click.Choice(LOG_FORMATS), default="tabular", help="Choose an output format.")
@click.option("-w", "--workflows", is_flag=True, default=False, help="Show only workflow executions.")
def log(columns, format, workflows):
"""Log in to the platform."""
result = log_command().with_database().build().execute(workflows_only=workflows).output
click.echo(LOG_FORMATS[format](result, columns))
4 changes: 2 additions & 2 deletions renku/core/commands/format/tabulate.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from renku.core.models.tabulate import tabulate as tabulate_


def tabulate(collection, columns, columns_mapping, columns_alignments=None, sort=True):
def tabulate(collection, columns, columns_mapping, columns_alignments=None, sort=True, reverse=False):
"""Format collection with a tabular output."""
if not columns:
raise errors.ParameterError("Columns cannot be empty.")
Expand All @@ -38,7 +38,7 @@ def tabulate(collection, columns, columns_mapping, columns_alignments=None, sort
try:
attr = list(headers.keys())[0]
getter = attrgetter(attr)
collection = sorted(collection, key=lambda d: getter(d))
collection = sorted(collection, key=getter, reverse=reverse)
except TypeError:
pass

Expand Down
40 changes: 40 additions & 0 deletions renku/core/commands/log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
#
# Copyright 2018-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.
"""Log of renku commands."""

from renku.core.commands.view_model.log import LogViewModel
from renku.core.management.command_builder import Command, inject
from renku.core.management.interface.activity_gateway import IActivityGateway

CONFIG_SECTION = "http"
RENKU_BACKUP_PREFIX = "renku-backup"


def log_command():
"""Return a command for getting a log of renku commands."""
return Command().command(_log)


@inject.autoparams("activity_gateway")
def _log(activity_gateway: IActivityGateway, workflows_only: bool = False):
"""Get a log of renku commands."""

activities = activity_gateway.get_all_activities()
log_entries = [LogViewModel.from_activity(a) for a in activities]

return log_entries
87 changes: 87 additions & 0 deletions renku/core/commands/view_model/log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# -*- 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.
"""Log view model."""

from datetime import datetime
from enum import Enum
from typing import List

from renku.core.commands.format.tabulate import tabulate
from renku.core.models.provenance.activity import Activity


def tabular(data, columns):
"""Tabular output."""
return tabulate(data, columns, columns_mapping=LOG_COLUMNS, reverse=True)


def json(data, columns):
"""JSON output."""
import json

data = sorted(data, key=lambda x: x.date)
return json.dumps([d.to_dict() for d in data], indent=2)


class LogType(str, Enum):
"""Enum of different types of Log entries."""

ACTIVITY = "Workflow Execution"
DATASET = "Dataset"


class LogViewModel:
"""ViewModel for renku log entries."""

def __init__(self, date: datetime, type: LogType, description: str, agents: List[str]):
self.date = date
self.type = type.value
self.description = description
self.agents = agents

def to_dict(self):
"""Return a dict representation of this view model."""
return {
"date": self.date.isoformat(),
"type": self.type,
"description": self.description,
"agents": self.agents,
}

@classmethod
def from_activity(cls, activity: Activity):
"""Create a log entry from an activity."""
return cls(
date=activity.ended_at_time,
type=LogType.ACTIVITY,
description=" ".join(activity.plan_with_values.to_argv()),
agents=[a.full_identity for a in activity.agents],
)


LOG_COLUMNS = {
"date": ("date", None),
"type": ("type", None),
"description": ("description", None),
"actors": ("agents", "actors"),
}

LOG_FORMATS = {
"tabular": tabular,
"json": json,
}
5 changes: 5 additions & 0 deletions renku/core/models/provenance/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ def from_commit(cls, commit) -> Union["Person", "SoftwareAgent"]:
"""Create an instance from a Git commit."""
return SoftwareAgent.from_commit(commit) if commit.author != commit.committer else Person.from_commit(commit)

@property
def full_identity(self):
"""Return the identity of this Agent."""
return f"{self.name} <{self.id}>"


class SoftwareAgent(Agent):
"""Represent executed software."""
Expand Down
39 changes: 39 additions & 0 deletions tests/cli/test_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
#
# Copyright 2018-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.
"""Test ``log`` command."""

from renku.cli import cli
from tests.utils import format_result_exception


def test_activity_log(runner, project):
"""Test renku log for activities."""
result = runner.invoke(cli, ["run", "--name", "run1", "touch", "foo"])
assert 0 == result.exit_code, format_result_exception(result)

result = runner.invoke(cli, ["log"])
assert 0 == result.exit_code, format_result_exception(result)
assert "Workflow Execution touch foo" in result.output

result = runner.invoke(cli, ["run", "--name", "run2", "cp", "foo", "bar"])
assert 0 == result.exit_code, format_result_exception(result)

result = runner.invoke(cli, ["log"])
assert 0 == result.exit_code, format_result_exception(result)
assert "Workflow Execution touch foo" in result.output
assert "Workflow Execution cp foo bar" in result.output
8 changes: 4 additions & 4 deletions tests/cli/test_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,19 +276,19 @@ def test_workflow_remove_command(runner, project):
assert 2 == result.exit_code

result = runner.invoke(cli, ["run", "--success-code", "0", "--no-output", "--name", workflow_name, "echo", "foo"])
assert 0 == result.exit_code
assert 0 == result.exit_code, format_result_exception(result)

result = runner.invoke(cli, ["workflow", "remove", "--force", workflow_name])
assert 0 == result.exit_code
assert 0 == result.exit_code, format_result_exception(result)


def test_workflow_export_command(runner, project):
"""test workflow export with builder."""
result = runner.invoke(cli, ["run", "--success-code", "0", "--no-output", "--name", "run1", "touch", "data.csv"])
assert 0 == result.exit_code
assert 0 == result.exit_code, format_result_exception(result)

result = runner.invoke(cli, ["workflow", "export", "run1", "-o", "run1.cwl"])
assert 0 == result.exit_code
assert 0 == result.exit_code, format_result_exception(result)
assert Path("run1.cwl").exists()

workflow = cwlgen.load_document("run1.cwl")
Expand Down
89 changes: 89 additions & 0 deletions tests/core/commands/test_log.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# -*- 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.
"""Renku log command tests."""


from datetime import datetime, timedelta

from renku.core.commands.log import _log
from renku.core.commands.view_model.log import LogType
from renku.core.models.provenance.activity import Activity, Association
from renku.core.models.provenance.agent import Person, SoftwareAgent


def test_log_activities(mocker):
"""Test getting activity viewmodels on log."""
agents = [
Person(id="mailto:john.doe@example.com", email="john.doe@example.com", name="John Doe"),
SoftwareAgent(name="renku 99.1.1", id="<https://github.com/swissdatasciencecenter/renku-python/tree/v0.16.1>"),
]

plan1 = mocker.MagicMock()
plan1.to_argv.return_value = ["touch", "A"]
plan1.copy.return_value = plan1

plan2 = mocker.MagicMock()
plan2.to_argv.return_value = ["cp", "A", "B"]
plan2.copy.return_value = plan2

plan3 = mocker.MagicMock()
plan3.to_argv.return_value = ["cp", "B", "C"]
plan3.copy.return_value = plan3

previous_id = Activity.generate_id()
previous = Activity(
id=previous_id,
started_at_time=datetime.utcnow() - timedelta(hours=1, seconds=15),
ended_at_time=datetime.utcnow() - timedelta(hours=1, seconds=10),
association=Association(id=Association.generate_id(previous_id), plan=plan1),
agents=agents,
)

intermediate_id = Activity.generate_id()
intermediate = Activity(
id=intermediate_id,
started_at_time=datetime.utcnow() - timedelta(hours=1, seconds=10),
ended_at_time=datetime.utcnow() - timedelta(hours=1, seconds=5),
association=Association(id=Association.generate_id(intermediate_id), plan=plan2),
agents=agents,
)

following_id = Activity.generate_id()
following = Activity(
id=following_id,
started_at_time=datetime.utcnow() - timedelta(hours=1, seconds=5),
ended_at_time=datetime.utcnow() - timedelta(hours=1),
association=Association(id=Association.generate_id(following_id), plan=plan3),
agents=agents,
)

activity_gateway = mocker.MagicMock()
activity_gateway.get_all_activities.return_value = [previous, intermediate, following]
full_agents = [a.full_identity for a in agents]
result = _log(activity_gateway, workflows_only=True)
assert 3 == len(result)
assert all(log.type == LogType.ACTIVITY for log in result)
assert result[0].date == previous.ended_at_time
assert result[1].date == intermediate.ended_at_time
assert result[2].date == following.ended_at_time
assert result[0].agents == full_agents
assert result[1].agents == full_agents
assert result[2].agents == full_agents
assert result[0].description == "touch A"
assert result[1].description == "cp A B"
assert result[2].description == "cp B C"