Skip to content

Commit

Permalink
Merge 3685e38 into 5c214bb
Browse files Browse the repository at this point in the history
  • Loading branch information
FlorianWilhelm committed Oct 18, 2018
2 parents 5c214bb + 3685e38 commit ea27679
Show file tree
Hide file tree
Showing 17 changed files with 190 additions and 79 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Expand Up @@ -12,6 +12,9 @@ Version 3.2
- Add support for ``os.PathLike`` objects in ``helpers.{modify,ensure,reject}``, issue #211
- Remove ``release`` alias in ``setup.cfg``, use ``twine`` instead
- Set ``project-urls`` and ``long-description-content-type`` in ``setup.cfg``, issue #216
- Added additional command line argument ``very-verbose``
- Assure clean workspace when updating existing project, issue #190
- Show stacktrace on errors if ``--very-verbose`` is used

Current versions
================
Expand Down
4 changes: 2 additions & 2 deletions docs/dependencies.rst
Expand Up @@ -144,7 +144,7 @@ while the dev set should contain things like ``tox``, ``pytest-runner``,
while developing.

.. note:: Test dependencies are internally managed by the test runner,
so we don't have to tell Pipenv about them
so we don't have to tell Pipenv about them.

The easiest way of doing so is to add a ``-e .`` dependency (in resemblance
with the non-automated workflow) in the default set, and all the other ones in
Expand Down Expand Up @@ -174,7 +174,7 @@ add them to your virtual environment.
.. warning::

*Experimental Feature* - `Pipenv`_ is still a young project that is moving
very fast. Changes in the way developpers can use it are expected in the
very fast. Changes in the way developers can use it are expected in the
near future, and therefore PyScaffold support might change as well.

.. _Pipenv: https://docs.pipenv.org/
Expand Down
2 changes: 1 addition & 1 deletion docs/features.rst
Expand Up @@ -32,7 +32,7 @@ mentioned above and use twine_ to upload it to PyPI_, e.g.::
twine upload dist/*

For this to work, you have to first register a PyPI_ account. If you just
wanna test, please be kind and `use TestPyPI`_ before uploading to PyPI_.
want to test, please be kind and `use TestPyPI`_ before uploading to PyPI_.

.. warning::
Be aware that the usage of ``python setup.py upload`` for PyPI_ uploads
Expand Down
2 changes: 1 addition & 1 deletion setup.cfg
Expand Up @@ -4,7 +4,7 @@ description = Template tool for putting up the scaffold of a Python project
author = Florian Wilhelm
author-email = Florian.Wilhelm@gmail.com
license = MIT
url = https://github.com/blue-yonder/pyscaffold/
url = https://github.com/pyscaffold/pyscaffold/
project-urls =
Documentation = https://pyscaffold.org/
Twitter = https://twitter.com/PyScaffold
Expand Down
8 changes: 6 additions & 2 deletions src/pyscaffold/api/__init__.py
Expand Up @@ -13,9 +13,10 @@
from ..exceptions import (
DirectoryAlreadyExists,
DirectoryDoesNotExist,
GitDirtyWorkspace,
InvalidIdentifier
)
from ..log import logger, configure_logger
from ..log import logger
from ..structure import (
create_structure,
define_structure
Expand Down Expand Up @@ -200,6 +201,10 @@ def verify_options_consistency(struct, opts):
"Package name {} is not a valid "
"identifier.".format(opts['package']))

if opts['update'] and not opts['force']:
if not info.is_git_workspace_clean(opts['project']):
raise GitDirtyWorkspace

return struct, opts


Expand Down Expand Up @@ -317,7 +322,6 @@ def create_project(opts=None, **kwargs):
opts = opts if opts else {}
opts.update(kwargs)

configure_logger(opts)
actions = discover_actions(opts.get('extensions', []))

# call the actions to generate final struct and opts
Expand Down
45 changes: 35 additions & 10 deletions src/pyscaffold/cli.py
Expand Up @@ -12,7 +12,8 @@

from . import __version__ as pyscaffold_version
from . import api, info, shell, templates, utils
from .log import ReportFormatter
from .exceptions import NoPyScaffoldProject
from .log import ReportFormatter, configure_logger
from .utils import get_id


Expand Down Expand Up @@ -87,6 +88,13 @@ def add_default_args(parser):
const=logging.INFO,
dest="log_level",
help="show additional information about current actions")
parser.add_argument(
"-vv",
"--very-verbose",
action="store_const",
const=logging.DEBUG,
dest="log_level",
help="show all available information about current actions")

group = parser.add_mutually_exclusive_group()
group.add_argument(
Expand All @@ -106,16 +114,14 @@ def add_default_args(parser):


def parse_args(args):
"""Parse command line parameters
"""Parse command line parameters respecting extensions
Args:
args ([str]): command line parameters as list of strings
Returns:
dict: command line parameters
"""
# check for required setuptools before importing
utils.check_setuptools_version()
from pkg_resources import iter_entry_points

# create the argument parser
Expand All @@ -139,10 +145,30 @@ def parse_args(args):

# Parse options and transform argparse Namespace object into common dict
opts = vars(parser.parse_args(args))
return opts


def process_opts(opts):
"""Process and enrich command line arguments
Args:
opts (dict): dictionary of parameters
Returns:
dict: dictionary of parameters from command line arguments
"""
# When pretending the user surely wants to see the output
if opts['pretend']:
opts['log_level'] = logging.INFO

configure_logger(opts)

# In case of an update read and parse setup.cfg
if opts['update']:
opts = info.project(opts)
try:
opts = info.project(opts)
except Exception as e:
raise NoPyScaffoldProject from e

# Save cli params for later updating
opts['cli_params'] = {'extensions': list(), 'args': dict()}
Expand All @@ -151,15 +177,12 @@ def parse_args(args):
if extension.args is not None:
opts['cli_params']['args'][extension.name] = extension.args

# When pretending the user surely wants to see the output
if opts['pretend']:
opts['log_level'] = logging.INFO

# Strip (back)slash when added accidentally during update
opts['project'] = opts['project'].rstrip(os.sep)

# Remove options with None values
return {k: v for k, v in opts.items() if v is not None}
opts = {k: v for k, v in opts.items() if v is not None}
return opts


def run_scaffold(opts):
Expand Down Expand Up @@ -196,7 +219,9 @@ def main(args):
Args:
args ([str]): command line arguments
"""
utils.check_setuptools_version()
opts = parse_args(args)
opts = process_opts(opts)
opts['command'](opts)


Expand Down
11 changes: 11 additions & 0 deletions src/pyscaffold/exceptions.py
Expand Up @@ -45,6 +45,17 @@ def __init__(self, message=DEFAULT_MESSAGE, *args, **kwargs):
super().__init__(message, *args, **kwargs)


class GitDirtyWorkspace(RuntimeError):
"""Workspace of git is empty."""

DEFAULT_MESSAGE = (
"Your working tree is dirty. Commit your changes first"
" or use '--force'.")

def __init__(self, message=DEFAULT_MESSAGE, *args, **kwargs):
super().__init__(message, *args, **kwargs)


class InvalidIdentifier(RuntimeError):
"""Python requires a specific format for its identifiers.
Expand Down
93 changes: 54 additions & 39 deletions src/pyscaffold/info.py
Expand Up @@ -5,22 +5,19 @@

import copy
import getpass
import logging
import socket
import traceback
from operator import itemgetter

from . import shell
from .exceptions import (
GitNotConfigured,
GitNotInstalled,
NoPyScaffoldProject,
PyScaffoldTooOld,
ShellCommandException
)
from .templates import licenses
from .update import read_setupcfg
from .utils import levenshtein
from .utils import chdir, levenshtein


def username():
Expand Down Expand Up @@ -97,6 +94,29 @@ def check_git():
raise GitNotConfigured


def is_git_workspace_clean(path):
"""Checks if git workspace is clean
Args:
path (str): path to git repository
Returns:
bool: condition if workspace is clean or not
Raises:
:class:`~.GitNotInstalled`: when git command is not available
:class:`~.GitNotConfigured`: when git does not know user information
"""
# ToDo: Change to pathlib for v4
check_git()
try:
with chdir(path):
shell.git('diff-index', '--quiet', 'HEAD', '--')
except ShellCommandException:
return False
return True


def project(opts):
"""Update user options with the options of an existing PyScaffold project
Expand All @@ -114,41 +134,36 @@ def project(opts):
from pkg_resources import iter_entry_points

opts = copy.deepcopy(opts)
try:
cfg = read_setupcfg(opts['project']).to_dict()
if 'pyscaffold' not in cfg:
raise PyScaffoldTooOld
pyscaffold = cfg['pyscaffold']
metadata = cfg['metadata']
# This would be needed in case of inplace updates, see issue #138
# if opts['project'] == '.':
# opts['project'] = metadata['name']
# Overwrite only if user has not provided corresponding cli argument
opts.setdefault('package', pyscaffold['package'])
opts.setdefault('author', metadata['author'])
opts.setdefault('email', metadata['author-email'])
opts.setdefault('url', metadata['url'])
opts.setdefault('description', metadata['description'])
opts.setdefault('license', best_fit_license(metadata['license']))
# Additional parameters compare with `get_default_options`
opts['classifiers'] = metadata['classifiers'].strip().split('\n')
# complement the cli extensions with the ones from configuration
if 'extensions' in pyscaffold:
cfg_extensions = pyscaffold['extensions'].strip().split('\n')
opt_extensions = [ext.name for ext in opts['extensions']]
add_extensions = set(cfg_extensions) - set(opt_extensions)
for extension in iter_entry_points('pyscaffold.cli'):
if extension.name in add_extensions:
extension_obj = extension.load()(extension.name)
if extension.name in pyscaffold:
ext_value = pyscaffold[extension.name]
extension_obj.args = ext_value
opts[extension.name] = ext_value
opts['extensions'].append(extension_obj)
except Exception as e:
if opts.get('log_level', logging.ERROR) <= logging.INFO:
traceback.print_stack()
raise NoPyScaffoldProject from e
cfg = read_setupcfg(opts['project']).to_dict()
if 'pyscaffold' not in cfg:
raise PyScaffoldTooOld
pyscaffold = cfg['pyscaffold']
metadata = cfg['metadata']
# This would be needed in case of inplace updates, see issue #138, v4
# if opts['project'] == '.':
# opts['project'] = metadata['name']
# Overwrite only if user has not provided corresponding cli argument
opts.setdefault('package', pyscaffold['package'])
opts.setdefault('author', metadata['author'])
opts.setdefault('email', metadata['author-email'])
opts.setdefault('url', metadata['url'])
opts.setdefault('description', metadata['description'])
opts.setdefault('license', best_fit_license(metadata['license']))
# Additional parameters compare with `get_default_options`
opts['classifiers'] = metadata['classifiers'].strip().split('\n')
# complement the cli extensions with the ones from configuration
if 'extensions' in pyscaffold:
cfg_extensions = pyscaffold['extensions'].strip().split('\n')
opt_extensions = [ext.name for ext in opts['extensions']]
add_extensions = set(cfg_extensions) - set(opt_extensions)
for extension in iter_entry_points('pyscaffold.cli'):
if extension.name in add_extensions:
extension_obj = extension.load()(extension.name)
if extension.name in pyscaffold:
ext_value = pyscaffold[extension.name]
extension_obj.args = ext_value
opts[extension.name] = ext_value
opts['extensions'].append(extension_obj)
return opts


Expand Down
8 changes: 7 additions & 1 deletion src/pyscaffold/utils.py
Expand Up @@ -5,10 +5,12 @@

import functools
import keyword
import logging
import os
import re
import shutil
import sys
import traceback
from contextlib import contextmanager

from pkg_resources import parse_version
Expand Down Expand Up @@ -52,7 +54,8 @@ def chdir(path, **kwargs):
try:
with _chdir_logging_context(path, should_log):
if not should_pretend:
os.chdir(path)
# ToDo: Remove str when we require PY 3.6
os.chdir(str(path)) # str to handle pathlib args
yield
finally:
os.chdir(curr_dir)
Expand Down Expand Up @@ -139,6 +142,9 @@ def func_wrapper(*args, **kwargs):
try:
func(*args, **kwargs)
except tuple(exception_list) as e:
if logger.level <= logging.DEBUG:
# user surely wants to see the stacktrace
traceback.print_exc()
print("ERROR: {}".format(e))
sys.exit(1)
return func_wrapper
Expand Down
3 changes: 2 additions & 1 deletion tests/extensions/test_cookiecutter.py
Expand Up @@ -8,7 +8,7 @@
import pytest

from pyscaffold.api import create_project
from pyscaffold.cli import parse_args, run
from pyscaffold.cli import parse_args, process_opts, run
from pyscaffold.extensions import cookiecutter
from pyscaffold.templates import setup_py

Expand Down Expand Up @@ -42,6 +42,7 @@ def test_pretend_create_project_with_cookiecutter(tmpfolder, caplog):
caplog.set_level(logging.INFO)
opts = parse_args(
[PROJ_NAME, '--pretend', '--cookiecutter', COOKIECUTTER_URL])
opts = process_opts(opts)

# when the project is created,
create_project(opts)
Expand Down
3 changes: 2 additions & 1 deletion tests/extensions/test_django.py
Expand Up @@ -8,7 +8,7 @@
import pytest

from pyscaffold.api import create_project
from pyscaffold.cli import parse_args, run
from pyscaffold.cli import parse_args, process_opts, run
from pyscaffold.extensions import django
from pyscaffold.templates import setup_py

Expand Down Expand Up @@ -40,6 +40,7 @@ def test_pretend_create_project_with_django(tmpfolder, caplog):
# Given options with the django extension,
caplog.set_level(logging.INFO)
opts = parse_args([PROJ_NAME, '--pretend', '--django'])
opts = process_opts(opts)

# when the project is created,
create_project(opts)
Expand Down

0 comments on commit ea27679

Please sign in to comment.