Skip to content

Commit

Permalink
Merge pull request #76 from Anaconda-Platform/progress-indication
Browse files Browse the repository at this point in the history
Stream logs to an abstract Frontend interface, and pass through rather than swallowing conda output
  • Loading branch information
havocp committed May 25, 2017
2 parents 9171f46 + 7a2af00 commit e04f216
Show file tree
Hide file tree
Showing 54 changed files with 1,200 additions and 492 deletions.
13 changes: 9 additions & 4 deletions anaconda_project/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def __init__(self):
"""Construct an API instance."""
pass

def load_project(self, directory_path):
def load_project(self, directory_path, frontend):
"""Load a project from the given directory.
If there's a problem, the returned Project instance will
Expand All @@ -42,12 +42,13 @@ def load_project(self, directory_path):
Args:
directory_path (str): path to the project directory
frontend (Frontend): UX abstraction
Returns:
a Project instance
"""
return project.Project(directory_path=directory_path)
return project.Project(directory_path=directory_path, frontend=frontend)

def create_project(self, directory_path, make_directory=False, name=None, icon=None, description=None):
"""Create a project skeleton in the given directory.
Expand Down Expand Up @@ -735,7 +736,7 @@ def archive(self, project, filename):
"""
return project_ops.archive(project=project, filename=filename)

def unarchive(self, filename, project_dir, parent_dir=None):
def unarchive(self, filename, project_dir, parent_dir=None, frontend=None):
"""Unpack an archive of the project.
The archive can be untrusted (we will safely defeat attempts
Expand All @@ -753,12 +754,16 @@ def unarchive(self, filename, project_dir, parent_dir=None):
filename (str): name of a zip, tar.gz, or tar.bz2 archive file
project_dir (str): the directory to place the project inside
parent_dir (str): directory to place project_dir within
frontend (Frontend): frontend instance representing current UX
Returns:
a ``Status``, if failed has ``errors``, on success has ``project_dir`` property.
"""
return project_ops.unarchive(filename=filename, project_dir=project_dir, parent_dir=parent_dir)
return project_ops.unarchive(filename=filename,
project_dir=project_dir,
parent_dir=parent_dir,
frontend=frontend)

def upload(self, project, site=None, username=None, token=None, log_level=None):
"""Upload the project to the Anaconda server.
Expand Down
153 changes: 78 additions & 75 deletions anaconda_project/archiver.py

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions anaconda_project/conda_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ def pop_conda_manager_class():
_conda_manager_classes.pop()


def new_conda_manager():
def new_conda_manager(frontend=None):
"""Create a new concrete ``CondaManager``."""
global _conda_manager_classes
if len(_conda_manager_classes) == 0:
from anaconda_project.internal.default_conda_manager import DefaultCondaManager
klass = DefaultCondaManager
else:
klass = _conda_manager_classes[-1]
return klass()
return klass(frontend=frontend)


class CondaManagerError(Exception):
Expand Down
137 changes: 137 additions & 0 deletions anaconda_project/frontend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
# ----------------------------------------------------------------------------
# Copyright © 2016, Continuum Analytics, Inc. All rights reserved.
#
# The full license is in the file LICENSE.txt, distributed with this software.
# ----------------------------------------------------------------------------
"""Frontend class representing a UX."""
from __future__ import absolute_import

from abc import ABCMeta, abstractmethod

from anaconda_project.internal.metaclass import with_metaclass


class Frontend(with_metaclass(ABCMeta)):
"""A UX (CLI, GUI, etc.) for project operations."""

def __init__(self):
"""Construct a Frontend."""
self._info_buf = ''
self._error_buf = ''

def _partial(self, data, buf, line_handler):
buf = buf + data
(start, sep, end) = buf.partition('\n')
while sep != '':
# we do this instead of using os.linesep in case
# something on windows outputs unix-style line
# endings, we don't want to go haywire. On unix when
# we actually want \r to carriage return, we'll be
# overriding this "partial" handler and not using this
# buffering implementation.
if start.endswith('\r'):
start = start[:-1]
line_handler(start)
buf = end
(start, sep, end) = buf.partition('\n')
return buf

def partial_info(self, data):
"""Log only part of an info-level line.
The default implementation buffers this until a line separator
and then passes the entire line to info().
Subtypes can override this if they want to print output
immediately as it arrives.
"""
self._info_buf = self._partial(data, self._info_buf, self.info)

def partial_error(self, data):
"""Log only part of an error-level line.
The default implementation buffers this until a line separator
and then passes the entire line to error().
Subtypes can override this if they want to print output
immediately as it arrives.
"""
self._error_buf = self._partial(data, self._error_buf, self.error)

@abstractmethod
def info(self, message):
"""Log an info-level message."""
pass # pragma: no cover

@abstractmethod
def error(self, message):
"""Log an error-level message.
A rule of thumb is that if a function also returns a
``Status``, this message should also be appended to the
``errors`` field on that status.
"""
pass # pragma: no cover

# @abstractmethod
# def new_progress(self):
# """Create an appropriate subtype of Progress."""
# pass # pragma: no cover


class NullFrontend(Frontend):
"""A frontend that doesn't do anything."""

def __init__(self):
"""Construct a null frontend."""
super(NullFrontend, self).__init__()

def partial_info(self, data):
"""Part of a log message."""
pass

def partial_error(self, data):
"""Part of an error message."""
pass

def info(self, message):
"""Log an info-level message."""
pass

def error(self, message):
"""Log an error-level message."""
pass


_singleton_null_frontend = None


def _null_frontend():
global _singleton_null_frontend
if _singleton_null_frontend is None:
_singleton_null_frontend = NullFrontend()
return _singleton_null_frontend


class _ErrorRecordingFrontendProxy(Frontend):
def __init__(self, underlying):
super(_ErrorRecordingFrontendProxy, self).__init__()
self._errors = []
self.underlying = underlying

def info(self, message):
"""Log an info-level message."""
self.underlying.info(message)

def error(self, message):
"""Log an error-level message."""
self._errors.append(message)
self.underlying.error(message)

def pop_errors(self):
result = self._errors
self._errors = []
return result


def _new_error_recorder(frontend):
return _ErrorRecordingFrontendProxy(frontend)
2 changes: 0 additions & 2 deletions anaconda_project/internal/cli/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ def archive_command(project_dir, archive_filename):
project = load_project(project_dir)
status = project_ops.archive(project, archive_filename)
if status:
for line in status.logs:
print(line)
print(status.status_description)
return 0
else:
Expand Down
5 changes: 4 additions & 1 deletion anaconda_project/internal/cli/clean.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ def clean_command(project_dir):
exit code
"""
project = load_project(project_dir)
result = prepare_without_interaction(project, mode=PROVIDE_MODE_CHECK)
# we don't want to print errors during this prepare, clean
# can proceed even though the prepare fails.
with project.null_frontend():
result = prepare_without_interaction(project, mode=PROVIDE_MODE_CHECK)
status = project_ops.clean(project, result)
if status:
print(status.status_description)
Expand Down
8 changes: 3 additions & 5 deletions anaconda_project/internal/cli/console_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,10 @@ def print_project_problems(project):


def print_status_errors(status):
"""Print errors from the status."""
"""Print out status description to stderr."""
assert status is not None
for log in status.logs:
print(log, file=sys.stderr)
for error in status.errors:
print(error, file=sys.stderr)
# don't print status.errors because we will have done that
# already in streaming fashion from our Frontend.
print(status.status_description, file=sys.stderr)


Expand Down
5 changes: 4 additions & 1 deletion anaconda_project/internal/cli/download_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ def add_download(project_dir, filename_variable, download_url, filename, hash_al
def remove_download(project_dir, filename_variable):
"""Remove a download requirement from project and from file system."""
project = load_project(project_dir)
result = prepare_without_interaction(project, mode=PROVIDE_MODE_CHECK)
# we can remove a download even if prepare fails, so disable
# printing errors in the frontend.
with project.null_frontend():
result = prepare_without_interaction(project, mode=PROVIDE_MODE_CHECK)
status = project_ops.remove_download(project, result, env_var=filename_variable)
if status:
print(status.status_description)
Expand Down
2 changes: 0 additions & 2 deletions anaconda_project/internal/cli/environment_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@

def _handle_status(status, success_message=None):
if status:
for line in status.logs:
print(line)
print(status.status_description)
if success_message is not None:
print(success_message)
Expand Down
3 changes: 3 additions & 0 deletions anaconda_project/internal/cli/prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""The ``prepare`` command configures a project to run, asking the user questions if necessary."""
from __future__ import absolute_import, print_function

import anaconda_project.internal.cli.console_utils as console_utils
from anaconda_project.internal.cli.prepare_with_mode import prepare_with_ui_mode_printing_errors
from anaconda_project.internal.cli.project_load import load_project

Expand All @@ -18,6 +19,8 @@ def prepare_command(project_dir, ui_mode, conda_environment, command_name):
Prepare result (can be treated as True on success).
"""
project = load_project(project_dir)
if console_utils.print_project_problems(project):
return False
result = prepare_with_ui_mode_printing_errors(project,
env_spec_name=conda_environment,
ui_mode=ui_mode,
Expand Down
2 changes: 0 additions & 2 deletions anaconda_project/internal/cli/prepare_with_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,6 @@ def prepare_with_ui_mode_printing_errors(project,
extra_command_args=extra_command_args)

if result.failed:
result.print_output()

if ask and _interactively_fix_missing_variables(project, result):
environ = result.environ
continue # re-prepare, building on our previous environ
Expand Down
24 changes: 23 additions & 1 deletion anaconda_project/internal/cli/project_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,36 @@
"""Command-line-specific project load utilities."""
from __future__ import absolute_import, print_function

import sys

from anaconda_project.project import Project
from anaconda_project.frontend import Frontend

import anaconda_project.internal.cli.console_utils as console_utils


class CliFrontend(Frontend):
def __init__(self):
super(CliFrontend, self).__init__()

def info(self, message):
print(message)

def error(self, message):
print(message, file=sys.stderr)

def partial_info(self, data):
sys.stdout.write(data)
sys.stdout.flush()

def partial_error(self, data):
sys.stderr.write(data)
sys.stderr.flush()


def load_project(dirname):
"""Load a Project, fixing it if needed and possible."""
project = Project(dirname)
project = Project(dirname, frontend=CliFrontend())

if console_utils.stdin_is_interactive():
had_fixable = len(project.fixable_problems) > 0
Expand Down
5 changes: 4 additions & 1 deletion anaconda_project/internal/cli/service_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ def add_service(project_dir, service_type, variable_name):
def remove_service(project_dir, variable_name):
"""Remove an item from the services section."""
project = load_project(project_dir)
result = prepare_without_interaction(project, mode=PROVIDE_MODE_CHECK)
# we don't want to print errors during this prepare, remove
# service can proceed even though the prepare fails.
with project.null_frontend():
result = prepare_without_interaction(project, mode=PROVIDE_MODE_CHECK)
status = project_ops.remove_service(project, result, variable_name=variable_name)
if status:
print(status.status_description)
Expand Down
2 changes: 1 addition & 1 deletion anaconda_project/internal/cli/test/test_clean.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def check(dirname):
assert code == 0

out, err = capsys.readouterr()
assert 'Cleaned.\n' == out
assert "Nothing to clean up for environment 'default'.\nCleaned.\n" == out
assert '' == err

with_directory_contents_completing_project_file(dict(), check)
Expand Down

0 comments on commit e04f216

Please sign in to comment.