Merge pull request #4516 from ryanpetrello/py2

support the new CLI in py2 *and* py3

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2019-08-20 21:33:39 +00:00
committed by GitHub
21 changed files with 154 additions and 52 deletions

View File

@@ -376,7 +376,7 @@ test:
. $(VENV_BASE)/awx/bin/activate; \ . $(VENV_BASE)/awx/bin/activate; \
fi; \ fi; \
PYTHONDONTWRITEBYTECODE=1 py.test -p no:cacheprovider -n auto $(TEST_DIRS) PYTHONDONTWRITEBYTECODE=1 py.test -p no:cacheprovider -n auto $(TEST_DIRS)
cd awxkit && $(VENV_BASE)/awx/bin/tox -re py3 cd awxkit && $(VENV_BASE)/awx/bin/tox -re py2,py3
awx-manage check_migrations --dry-run --check -n 'vNNN_missing_migration_file' awx-manage check_migrations --dry-run --check -n 'vNNN_missing_migration_file'
test_unit: test_unit:

View File

@@ -1,4 +1,4 @@
from .api import pages, client, resources # NOQA from awxkit.api import pages, client, resources # NOQA
from .config import config # NOQA from awxkit.config import config # NOQA
from . import awx # NOQA from awxkit import awx # NOQA
from .ws import WSClient # NOQA from awxkit.ws import WSClient # NOQA

View File

@@ -1,6 +1,8 @@
from datetime import datetime from datetime import datetime
import json import json
import six
from awxkit.utils import poll_until from awxkit.utils import poll_until
@@ -43,7 +45,7 @@ class HasStatus(object):
return self.wait_until_status(self.started_statuses, interval=interval, timeout=timeout) return self.wait_until_status(self.started_statuses, interval=interval, timeout=timeout)
def assert_status(self, status_list, msg=None): def assert_status(self, status_list, msg=None):
if isinstance(status_list, str): if isinstance(status_list, six.text_type):
status_list = [status_list] status_list = [status_list]
if self.status in status_list: if self.status in status_list:
# include corner cases in is_successful logic # include corner cases in is_successful logic

View File

@@ -1,10 +1,11 @@
import http.client
import inspect import inspect
import logging import logging
import json import json
import re import re
from requests import Response from requests import Response
import six
from six.moves import http_client as http
from awxkit.utils import ( from awxkit.utils import (
PseudoNamespace, PseudoNamespace,
@@ -12,7 +13,8 @@ from awxkit.utils import (
are_same_endpoint, are_same_endpoint,
super_dir_set, super_dir_set,
suppress, suppress,
is_list_or_tuple is_list_or_tuple,
to_str
) )
from awxkit.api.client import Connection from awxkit.api.client import Connection
from awxkit.api.registry import URLRegistry from awxkit.api.registry import URLRegistry
@@ -167,7 +169,11 @@ class Page(object):
@classmethod @classmethod
def from_json(cls, raw): def from_json(cls, raw):
resp = Response() resp = Response()
resp._content = bytes(json.dumps(raw), 'utf-8') data = json.dumps(raw)
if six.PY3:
resp._content = bytes(data, 'utf-8')
else:
resp._content = data
resp.encoding = 'utf-8' resp.encoding = 'utf-8'
resp.status_code = 200 resp.status_code = 200
return cls(r=resp) return cls(r=resp)
@@ -199,16 +205,16 @@ class Page(object):
"Unable to parse JSON response ({0.status_code}): {1} - '{2}'".format(response, e, text)) "Unable to parse JSON response ({0.status_code}): {1} - '{2}'".format(response, e, text))
exc_str = "%s (%s) received" % ( exc_str = "%s (%s) received" % (
http.client.responses[response.status_code], response.status_code) http.responses[response.status_code], response.status_code)
exception = exception_from_status_code(response.status_code) exception = exception_from_status_code(response.status_code)
if exception: if exception:
raise exception(exc_str, data) raise exception(exc_str, data)
if response.status_code in ( if response.status_code in (
http.client.OK, http.OK,
http.client.CREATED, http.CREATED,
http.client.ACCEPTED): http.ACCEPTED):
# Not all JSON responses include a URL. Grab it from the request # Not all JSON responses include a URL. Grab it from the request
# object, if needed. # object, if needed.
@@ -235,7 +241,7 @@ class Page(object):
r=response, r=response,
ds=ds) ds=ds)
elif response.status_code == http.client.FORBIDDEN: elif response.status_code == http.FORBIDDEN:
if is_license_invalid(response): if is_license_invalid(response):
raise exc.LicenseInvalid(exc_str, data) raise exc.LicenseInvalid(exc_str, data)
elif is_license_exceeded(response): elif is_license_exceeded(response):
@@ -243,7 +249,7 @@ class Page(object):
else: else:
raise exc.Forbidden(exc_str, data) raise exc.Forbidden(exc_str, data)
elif response.status_code == http.client.BAD_REQUEST: elif response.status_code == http.BAD_REQUEST:
if is_license_invalid(response): if is_license_invalid(response):
raise exc.LicenseInvalid(exc_str, data) raise exc.LicenseInvalid(exc_str, data)
if is_duplicate_error(response): if is_duplicate_error(response):
@@ -314,14 +320,14 @@ class Page(object):
return page_cls(self.connection, endpoint=endpoint).get(**kw) return page_cls(self.connection, endpoint=endpoint).get(**kw)
_exception_map = {http.client.NO_CONTENT: exc.NoContent, _exception_map = {http.NO_CONTENT: exc.NoContent,
http.client.NOT_FOUND: exc.NotFound, http.NOT_FOUND: exc.NotFound,
http.client.INTERNAL_SERVER_ERROR: exc.InternalServerError, http.INTERNAL_SERVER_ERROR: exc.InternalServerError,
http.client.BAD_GATEWAY: exc.BadGateway, http.BAD_GATEWAY: exc.BadGateway,
http.client.METHOD_NOT_ALLOWED: exc.MethodNotAllowed, http.METHOD_NOT_ALLOWED: exc.MethodNotAllowed,
http.client.UNAUTHORIZED: exc.Unauthorized, http.UNAUTHORIZED: exc.Unauthorized,
http.client.PAYMENT_REQUIRED: exc.PaymentRequired, http.PAYMENT_REQUIRED: exc.PaymentRequired,
http.client.CONFLICT: exc.Conflict} http.CONFLICT: exc.Conflict}
def exception_from_status_code(status_code): def exception_from_status_code(status_code):
@@ -376,10 +382,10 @@ class PageList(object):
class TentativePage(str): class TentativePage(str):
def __new__(cls, endpoint, connection): def __new__(cls, endpoint, connection):
return super(TentativePage, cls).__new__(cls, endpoint) return super(TentativePage, cls).__new__(cls, to_str(endpoint))
def __init__(self, endpoint, connection): def __init__(self, endpoint, connection):
self.endpoint = endpoint self.endpoint = to_str(endpoint)
self.connection = connection self.connection = connection
def _create(self): def _create(self):

View File

@@ -6,6 +6,7 @@ import yaml
from requests.exceptions import ConnectionError, SSLError from requests.exceptions import ConnectionError, SSLError
from .client import CLI from .client import CLI
from awxkit.utils import to_str
from awxkit.exceptions import Unauthorized, Common from awxkit.exceptions import Unauthorized, Common
from awxkit.cli.utils import cprint from awxkit.cli.utils import cprint
@@ -46,7 +47,14 @@ def run(stdout=sys.stdout, stderr=sys.stderr, argv=[]):
if cli.get_config('format') == 'json': if cli.get_config('format') == 'json':
json.dump(e.msg, sys.stdout) json.dump(e.msg, sys.stdout)
elif cli.get_config('format') == 'yaml': elif cli.get_config('format') == 'yaml':
sys.stdout.write(yaml.dump(e.msg)) sys.stdout.write(to_str(
yaml.safe_dump(
e.msg,
default_flow_style=False,
encoding='utf-8',
allow_unicode=True
)
))
sys.exit(1) sys.exit(1)
except Exception as e: except Exception as e:
if cli.verbose: if cli.verbose:

View File

@@ -1,9 +1,12 @@
from __future__ import print_function
import logging import logging
import os import os
import pkg_resources import pkg_resources
import sys import sys
from requests.exceptions import RequestException from requests.exceptions import RequestException
import six
from .custom import handle_custom_actions from .custom import handle_custom_actions
from .format import (add_authentication_arguments, from .format import (add_authentication_arguments,
@@ -160,10 +163,18 @@ class CLI(object):
changed=self.original_action in ('modify', 'create') changed=self.original_action in ('modify', 'create')
) )
if formatted: if formatted:
print(formatted, file=self.stdout) print(utils.to_str(formatted), file=self.stdout)
else: else:
self.parser.print_help() self.parser.print_help()
if six.PY2 and not self.help:
# Unfortunately, argparse behavior between py2 and py3
# changed in a notable way when required subparsers
# have invalid (or missing) arguments specified
# see: https://github.com/python/cpython/commit/f97c59aaba2d93e48cbc6d25f7ff9f9c87f8d0b2
print('\nargument resource: invalid choice')
raise SystemExit(2)
def parse_action(self, page, from_sphinx=False): def parse_action(self, page, from_sphinx=False):
"""Perform an HTTP OPTIONS request """Perform an HTTP OPTIONS request

View File

@@ -1,3 +1,5 @@
from six import with_metaclass
from .stdout import monitor, monitor_workflow from .stdout import monitor, monitor_workflow
from .utils import CustomRegistryMeta, color_enabled from .utils import CustomRegistryMeta, color_enabled
@@ -17,7 +19,7 @@ class CustomActionRegistryMeta(CustomRegistryMeta):
return ' '.join([self.resource, self.action]) return ' '.join([self.resource, self.action])
class CustomAction(object, metaclass=CustomActionRegistryMeta): class CustomAction(with_metaclass(CustomActionRegistryMeta)):
"""Base class for defining a custom action for a resource.""" """Base class for defining a custom action for a resource."""
def __init__(self, page): def __init__(self, page):

View File

@@ -1,6 +1,7 @@
import json import json
from distutils.util import strtobool from distutils.util import strtobool
import six
import yaml import yaml
from awxkit.cli.utils import colored from awxkit.cli.utils import colored
@@ -79,7 +80,7 @@ def add_output_formatting_arguments(parser, env):
def format_response(response, fmt='json', filter='.', changed=False): def format_response(response, fmt='json', filter='.', changed=False):
if response is None: if response is None:
return # HTTP 204 return # HTTP 204
if isinstance(response, str): if isinstance(response, six.text_type):
return response return response
if 'results' in response.__dict__: if 'results' in response.__dict__:
@@ -113,7 +114,7 @@ def format_jq(output, fmt):
results = [] results = []
for x in jq.jq(fmt).transform(output, multiple_output=True): for x in jq.jq(fmt).transform(output, multiple_output=True):
if x not in (None, ''): if x not in (None, ''):
if isinstance(x, str): if isinstance(x, six.text_type):
results.append(x) results.append(x)
else: else:
results.append(json.dumps(x)) results.append(json.dumps(x))
@@ -126,9 +127,11 @@ def format_json(output, fmt):
def format_yaml(output, fmt): def format_yaml(output, fmt):
output = json.loads(json.dumps(output)) output = json.loads(json.dumps(output))
return yaml.dump( return yaml.safe_dump(
output, output,
default_flow_style=False default_flow_style=False,
encoding='utf-8',
allow_unicode=True
) )

View File

@@ -1,5 +1,7 @@
import os import os
from six import PY3, with_metaclass
from awxkit import api, config from awxkit import api, config
from awxkit.api.pages import Page from awxkit.api.pages import Page
from awxkit.cli.format import format_response, add_authentication_arguments from awxkit.cli.format import format_response, add_authentication_arguments
@@ -40,7 +42,7 @@ DEPRECATED_RESOURCES_REVERSE = dict(
) )
class CustomCommand(object, metaclass=CustomRegistryMeta): class CustomCommand(with_metaclass(CustomRegistryMeta)):
"""Base class for implementing custom commands. """Base class for implementing custom commands.
Custom commands represent static code which should run - they are Custom commands represent static code which should run - they are
@@ -121,15 +123,28 @@ def parse_resource(client, skip_deprecated=False):
if k in ('dashboard',): if k in ('dashboard',):
# the Dashboard API is deprecated and not supported # the Dashboard API is deprecated and not supported
continue continue
aliases = []
if not skip_deprecated: # argparse aliases are *only* supported in Python3 (not 2.7)
kwargs = {}
if not skip_deprecated and PY3:
if k in DEPRECATED_RESOURCES: if k in DEPRECATED_RESOURCES:
aliases = [DEPRECATED_RESOURCES[k]] kwargs['aliases'] = [DEPRECATED_RESOURCES[k]]
client.subparsers[k] = subparsers.add_parser( client.subparsers[k] = subparsers.add_parser(
k, help='', aliases=aliases k, help='', **kwargs
) )
resource = client.parser.parse_known_args()[0].resource try:
resource = client.parser.parse_known_args()[0].resource
except SystemExit:
if PY3:
raise
else:
# Unfortunately, argparse behavior between py2 and py3
# changed in a notable way when required subparsers
# have invalid (or missing) arguments specified
# see: https://github.com/python/cpython/commit/f97c59aaba2d93e48cbc6d25f7ff9f9c87f8d0b2
# In py2, this raises a SystemExit; which we want to _ignore_
resource = None
if resource in DEPRECATED_RESOURCES.values(): if resource in DEPRECATED_RESOURCES.values():
client.argv[ client.argv[
client.argv.index(resource) client.argv.index(resource)

View File

@@ -1,8 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import print_function
import sys import sys
import time import time
from .utils import cprint, color_enabled, STATUS_COLORS from .utils import cprint, color_enabled, STATUS_COLORS
from awxkit.utils import to_str
def monitor_workflow(response, session, print_stdout=True, timeout=None, def monitor_workflow(response, session, print_stdout=True, timeout=None,
@@ -25,6 +29,7 @@ def monitor_workflow(response, session, print_stdout=True, timeout=None,
sys.stdout.write('\x1b[2K') sys.stdout.write('\x1b[2K')
for result in results: for result in results:
result['name'] = to_str(result['name'])
if print_stdout: if print_stdout:
print('{id} - {name} '.format(**result), end='') print('{id} - {name} '.format(**result), end='')
status = result['status'] status = result['status']
@@ -39,7 +44,7 @@ def monitor_workflow(response, session, print_stdout=True, timeout=None,
cprint('------Starting Standard Out Stream------', 'red') cprint('------Starting Standard Out Stream------', 'red')
if print_stdout: if print_stdout:
print('Launching {}...'.format(get().json.name)) print('Launching {}...'.format(to_str(get().json.name)))
started = time.time() started = time.time()
seen = set() seen = set()
@@ -84,7 +89,7 @@ def monitor(response, session, print_stdout=True, timeout=None, interval=.25):
# skip it for now and wait until the prior lines arrive and are # skip it for now and wait until the prior lines arrive and are
# printed # printed
continue continue
stdout = result.get('stdout') stdout = to_str(result.get('stdout'))
if stdout and print_stdout: if stdout and print_stdout:
print(stdout) print(stdout)
next_line = result['end_line'] next_line = result['end_line']

View File

@@ -1,3 +1,5 @@
from __future__ import print_function
from argparse import ArgumentParser from argparse import ArgumentParser
import os import os
import sys import sys

View File

@@ -10,6 +10,7 @@ import sys
import re import re
import os import os
import six
import yaml import yaml
from awxkit.words import words from awxkit.words import words
@@ -132,7 +133,7 @@ class PseudoNamespace(dict):
def is_relative_endpoint(candidate): def is_relative_endpoint(candidate):
return isinstance(candidate, (str,)) and candidate.startswith('/api/') return isinstance(candidate, (six.text_type,)) and candidate.startswith('/api/')
def is_class_or_instance(obj, cls): def is_class_or_instance(obj, cls):
@@ -320,6 +321,22 @@ def update_payload(payload, fields, kwargs):
return payload return payload
def to_str(obj):
if six.PY3:
if isinstance(obj, bytes):
return obj.decode('utf-8')
return obj
if not isinstance(obj, six.text_type):
try:
return str(obj)
except UnicodeDecodeError:
try:
obj = six.text_type(obj, 'utf8')
except UnicodeDecodeError:
obj = obj.decode('latin1')
return obj.encode('utf8')
def to_bool(obj): def to_bool(obj):
if isinstance(obj, (str,)): if isinstance(obj, (str,)):
return obj.lower() not in ('false', 'off', 'no', 'n', '0', '') return obj.lower() not in ('false', 'off', 'no', 'n', '0', '')

View File

@@ -1,11 +1,12 @@
from queue import Queue, Empty
import time import time
import threading import threading
import logging import logging
import atexit import atexit
import json import json
import ssl import ssl
import urllib.parse
from six.moves.queue import Queue, Empty
from six.moves.urllib.parse import urlparse
from awxkit.config import config from awxkit.config import config
@@ -55,7 +56,7 @@ class WSClient(object):
import websocket import websocket
if not hostname: if not hostname:
result = urllib.parse.urlparse(config.base_url) result = urlparse(config.base_url)
secure = result.scheme == 'https' secure = result.scheme == 'https'
port = result.port port = result.port
if port is None: if port is None:

View File

@@ -1,2 +1,3 @@
PyYAML PyYAML
requests requests
six

View File

@@ -67,7 +67,7 @@ setup(
}, },
include_package_data=True, include_package_data=True,
install_requires=requirements, install_requires=requirements,
python_requires=">= 3.5", python_requires=">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*",
extras_require={ extras_require={
'formatting': ['jq', 'tabulate'], 'formatting': ['jq', 'tabulate'],
'websockets': ['websocket-client>0.54.0'], 'websockets': ['websocket-client>0.54.0'],

View File

@@ -50,8 +50,14 @@ def test_list_resources(capfd, resource):
cli.parse_args(['awx {}'.format(resource)]) cli.parse_args(['awx {}'.format(resource)])
cli.connect() cli.connect()
cli.parse_resource() try:
out, err = capfd.readouterr() cli.parse_resource()
out, err = capfd.readouterr()
except SystemExit:
# python2 argparse raises SystemExit for invalid/missing required args,
# py3 doesn't
_, out = capfd.readouterr()
assert "usage:" in out assert "usage:" in out
for snippet in ( for snippet in (
'--conf.host https://example.awx.org]', '--conf.host https://example.awx.org]',

View File

@@ -1,7 +1,10 @@
import argparse import argparse
import json import json
import unittest import unittest
from io import StringIO try:
from StringIO import StringIO
except ImportError:
from io import StringIO
import pytest import pytest
from requests import Response from requests import Response

View File

@@ -1,4 +1,7 @@
from unittest.mock import patch try:
from unittest.mock import patch
except ImportError:
from mock import patch
import pytest import pytest

View File

@@ -1,8 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import datetime from datetime import datetime
import sys
from unittest import mock try:
from unittest import mock
except ImportError:
import mock
import pytest import pytest
import six
from awxkit import utils from awxkit import utils
from awxkit import exceptions as exc from awxkit import exceptions as exc
@@ -73,11 +78,19 @@ def test_load_invalid_json_or_yaml(inp):
@pytest.mark.parametrize('non_ascii', [True, False]) @pytest.mark.parametrize('non_ascii', [True, False])
@pytest.mark.skipif(
sys.version_info < (3, 6),
reason='this is only intended to be used in py3, not the CLI'
)
def test_random_titles_are_unicode(non_ascii): def test_random_titles_are_unicode(non_ascii):
assert isinstance(utils.random_title(non_ascii=non_ascii), str) assert isinstance(utils.random_title(non_ascii=non_ascii), six.text_type)
@pytest.mark.parametrize('non_ascii', [True, False]) @pytest.mark.parametrize('non_ascii', [True, False])
@pytest.mark.skipif(
sys.version_info < (3, 6),
reason='this is only intended to be used in py3, not the CLI'
)
def test_random_titles_generates_correct_characters(non_ascii): def test_random_titles_generates_correct_characters(non_ascii):
title = utils.random_title(non_ascii=non_ascii) title = utils.random_title(non_ascii=non_ascii)
if non_ascii: if non_ascii:

View File

@@ -1,7 +1,10 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from collections import namedtuple from collections import namedtuple
from unittest.mock import patch try:
from unittest.mock import patch
except ImportError:
from mock import patch
import pytest import pytest
from awxkit.ws import WSClient from awxkit.ws import WSClient

View File

@@ -14,6 +14,7 @@ setenv =
deps = deps =
websocket-client websocket-client
coverage coverage
mock
pytest pytest
pytest-mock pytest-mock