Skip to content

Commit

Permalink
#190 - Add package change confirmation (#293)
Browse files Browse the repository at this point in the history
  • Loading branch information
lights7412 committed Apr 20, 2023
1 parent fb5ae1d commit b9fb25b
Show file tree
Hide file tree
Showing 4 changed files with 315 additions and 26 deletions.
69 changes: 44 additions & 25 deletions cli/functionary/package.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import pathlib
import shutil
import sys
import tarfile

import click
Expand All @@ -11,7 +12,7 @@
from .client import get, post
from .config import get_config_value
from .parser import parse
from .utils import flatten, format_results
from .utils import check_changes, flatten, format_results, sort_functions_by_package


def create_languages() -> list[str]:
Expand Down Expand Up @@ -101,15 +102,31 @@ def create_cmd(ctx, language, name, output_directory):
is_flag=True,
help="Keep build artifacts after publishing, rather than cleaning them up",
)
@click.option(
"-y",
"skip_confirm",
is_flag=True,
help="Bypass confirmation of changes and immediately publish",
)
@click.pass_context
def publish(ctx, path, keep):
def publish(ctx, path, keep, skip_confirm):
"""
Publish a package to make it available in the currently active environment.
Use the -k option to keep the build artifacts
(found in $HOME/.functionary/builds) after publishing,
rather than cleaning it up.
Use the -y flag to bypass the confirmation of changes and publish.
"""
validate_package(path)
changes = check_changes(path + "/package.yaml")
if not changes:
print("There were no changes.")
if not skip_confirm:
confirm = input("Continue [y|N]? ").lower()
if confirm not in ("y", "yes"):
sys.exit(1)
host = get_config_value("host", raise_exception=True)
full_path = pathlib.Path(path).resolve()
tar_path = get_tar_path(full_path.name)
Expand All @@ -126,6 +143,11 @@ def publish(ctx, path, keep):
click.echo(f"Package upload complete\nBuild id: {id}")


def validate_package(path):
if not os.path.exists(path + "/package.yaml"):
raise click.ClickException("No package.yaml in " + path)


def get_tar_path(tar_name):
"""Construct the path to the package tarball"""
tar_name = tar_name + ".tar.gz"
Expand Down Expand Up @@ -169,39 +191,36 @@ def list(ctx):
"""
packages = get("packages")
functions = get("functions")
functions_lookup = {}

for function in functions:
package_id = function["package"]
function_dict = {}
function_dict["Function"] = function["name"]
function_dict["Display Name"] = function["display_name"]

# Use the summary if available to keep the table tidy, otherwise
# use the description
if not (description := function.get("summary", None)):
description = function["description"]
function_dict["Description"] = description if description else ""

if package_id in functions_lookup:
functions_lookup[package_id].append(function_dict)
else:
functions_lookup[package_id] = [function_dict]
function_lookup = sort_functions_by_package(functions)

for package in packages:
name = package["name"]
id = package["id"]
# Use the description since there's more room if it's available,
# otherwise use the summary
if not (description := package.get("description", None)):
description = package["summary"]
associated_functions = functions_lookup[id]
if not (package_description := package.get("description", None)):
package_description = package["summary"]

associated_functions = []
for function in function_lookup[id]:
function_dict = {}
function_dict["Function"] = function["name"]
function_dict["Display Name"] = function["display_name"]

# Use the summary if available to keep the table tidy, otherwise
# use the description
if not (function_description := function.get("summary", None)):
function_description = function["description"]
function_dict["Description"] = (
function_description if function_description else ""
)
associated_functions.append(function_dict)

title = Text(f"{name}", style="bold blue")

# Don't show if there's no package summary or description
if description:
title.append(f"\n{description}", style="blue dim")
if package_description:
title.append(f"\n{package_description}", style="blue dim")
format_results(associated_functions, title=title)
click.echo("\n")

Expand Down
150 changes: 150 additions & 0 deletions cli/functionary/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import datetime

import yaml
from rich.console import Console
from rich.table import Table
from rich.text import Text

from .client import get


def flatten(results, object_fields):
Expand Down Expand Up @@ -89,3 +93,149 @@ def format_results(results, title="", excluded_fields=[]):
table.add_row(*row_data)
first_row = False
console.print(table)


def _get_package_functions(path):
"""Returns list of dictionary of functions in the package.yaml"""
with open(path, "r") as package_yaml:
package_definition = yaml.safe_load(package_yaml)
return package_definition["package"]


def _get_functions_for_package(package_name):
"""
Takes in package_name, returns functions associated with package
"""
packages = get("packages")
functions = get("functions")
if not packages or not functions:
return {}
package_id = _get_package_id(packages, package_name)
if package_id is None:
return {}
package_functions = sort_functions_by_package(functions)
return package_functions[package_id]


def _get_package_id(packages, package_name):
"""
Takes in packages, desired package_name, returns associated package id
"""
for package in packages:
if package["name"] == package_name:
return package["id"]


def sort_functions_by_package(functions):
"""
Sorts functions by their package id
"""
functions_lookup = {}
for function in functions:
package_id = function["package"]
if package_id in functions_lookup:
functions_lookup[package_id].append(function)
else:
functions_lookup[package_id] = [function]
return functions_lookup


def check_changes(path):
"""
Checks for discrepancies between the package.yaml and the API functions, returns
whether or not changes occurred
"""
packagefunctions = _get_package_functions(path)["functions"]
apifunctions = _get_functions_for_package(_get_package_functions(path)["name"])

if not apifunctions:
newfunctions = packagefunctions
removedfunctions = {}
updatedfunctions = {}
else:
newfunctions = new_functions(packagefunctions, apifunctions)
removedfunctions = removed_functions(packagefunctions, apifunctions)
updatedfunctions = updated_functions(packagefunctions, apifunctions)

if newfunctions:
bullet_list(newfunctions, "New functions")
if removedfunctions:
bullet_list(removedfunctions, "Removed Functions")
if updatedfunctions:
_format_updated_functions(updatedfunctions, "Updated Functions")

return bool(newfunctions or removedfunctions or updatedfunctions)


def new_functions(packagefunctions, apifunctions):
"""
Compares package functions to api functions will return list of new functions
"""
newfunctions = packagefunctions.copy()
for packagefunction in packagefunctions:
for apifunction in apifunctions:
if packagefunction.get("name") == apifunction.get("name"):
newfunctions.remove(packagefunction)
break
return newfunctions


def removed_functions(packagefunctions, apifunctions):
"""
Compares package functions to api functions will return list of removed functions
"""
removedfunctions = apifunctions.copy()
for apifunction in apifunctions:
for packagefunction in packagefunctions:
if apifunction.get("name") == packagefunction.get("name"):
removedfunctions.remove(apifunction)
break
return removedfunctions


def updated_functions(packagefunctions, apifunctions):
"""
Compares package functions to api functions will return dictionary of updated
function names and the changed fields
"""
updatedfunctions = {}
for packagefunction in packagefunctions:
changedfield = []
for apifunction in apifunctions:
if packagefunction.get("name") == apifunction.get("name"):
for key in packagefunction.keys():
if key in {"parameters", "variables"}:
continue
if packagefunction[key] != apifunction[key]:
changedfield.append(key)
if changedfield:
updatedfunctions[packagefunction.get("name")] = changedfield

return updatedfunctions


def bullet_list(list, title):
"""
Prints to console: Title in blue and list in bullet form
"""
console = Console()
console.print(Text(f"{title}", style="bold blue"))
for entry in list:
print(f"\u2022 {entry.get('name')}")


def _format_updated_functions(results, title=""):
"""
Print The function name and changed fields in a table
"""
title = Text(f"{title}", style="bold blue")
table = Table(title=title, show_lines=True, title_justify="left")
console = Console()
table.add_column("Function Name")
table.add_column("Changed Fields")
for key in results.keys():
row_data = ""
for value in results[key]:
row_data = row_data + value + "\n"
table.add_row(key, row_data)
console.print(table)
15 changes: 14 additions & 1 deletion cli/tests/test_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import requests
from click.testing import CliRunner

from functionary import package
from functionary.config import save_config_value
from functionary.package import get_tar_path, publish

Expand All @@ -27,9 +28,19 @@ def response_200(*args, **kwargs):
return response


def validate_package_mock(path):
return None


def check_changes_mock(path):
return True


@pytest.mark.usefixtures("config")
def test_publish_with_keep(fakefs, monkeypatch):
"""Call publish with --keep : Keep build artifacts rather than cleaning them up"""
monkeypatch.setattr(package, "validate_package", validate_package_mock)
monkeypatch.setattr(package, "check_changes", check_changes_mock)
monkeypatch.setattr(requests, "post", response_200)
os.environ["HOME"] = "/tmp/test_home"
fakefs.create_file(pathlib.Path.home() / "tar_this.txt")
Expand All @@ -38,13 +49,15 @@ def test_publish_with_keep(fakefs, monkeypatch):
save_config_value("host", host)

runner = CliRunner()
runner.invoke(publish, [str(pathlib.Path.home()), "--keep"])
runner.invoke(publish, [str(pathlib.Path.home()), "--keep", "-y"])
assert os.path.isfile(get_tar_path(pathlib.Path.home().name))


@pytest.mark.usefixtures("config")
def test_publish_without_keep(fakefs, monkeypatch):
"""Call publish without --keep : Cleaning up build artifacts after publishing"""
monkeypatch.setattr(package, "validate_package", validate_package_mock)
monkeypatch.setattr(package, "check_changes", check_changes_mock)
monkeypatch.setattr(requests, "post", response_200)
os.environ["HOME"] = "/tmp/test_home"
fakefs.create_file(pathlib.Path.home() / "tar_this.txt")
Expand Down

0 comments on commit b9fb25b

Please sign in to comment.