diff --git a/.gitattributes b/.gitattributes index fb551edd2fa..78317f82f0a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,13 +1,13 @@ -# Set the default behavior (used when a rule below doesn't match) -* text=auto - -*.sln -text -*.ico -text -*.bmp -text -*.png -text -*.snk -text -*.mht -text -*.pickle -text - -# Some Windows-specific files should always be CRLF -*.bat eol=crlf +# Set the default behavior (used when a rule below doesn't match) +* text=auto + +*.sln -text +*.ico -text +*.bmp -text +*.png -text +*.snk -text +*.mht -text +*.pickle -text + +# Some Windows-specific files should always be CRLF +*.bat eol=crlf diff --git a/.gitignore b/.gitignore index 48f54c75747..864be54ace5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,61 +1,60 @@ -# Python cache -__pycache__/ -*.pyc - -# Virtual environment -env/ - -# PTVS analysis -.ptvs/ - -# Build results -bin/ -obj/ -dist/ -MANIFEST - -# Result of running python setup.py install/pip install -e -RECORD.txt -build/ -*.egg-info/ - -# Test results -TestResults/ - -# Credentials -credentials_real.json -testsettings_local.json -servicebus_settings_real.py -storage_settings_real.py -legacy_mgmt_settings_real.py -mgmt_settings_real.py -app_creds_real.py - -# User-specific files -*.suo -*.user -*.sln.docstates -.vs/ - -# Windows image file caches -Thumbs.db -ehthumbs.db - -# Folder config file -Desktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Mac desktop service store files -.DS_Store - -.idea -src/build -*.iml -/doc/_build -/.vs/config/applicationhost.config - -# Azure deployment credentials -*.pubxml - +# Python cache +__pycache__/ +*.pyc + +# Virtual environment +env/ + +# PTVS analysis +.ptvs/ + +# Build results +obj/ +dist/ +MANIFEST + +# Result of running python setup.py install/pip install -e +RECORD.txt +build/ +*.egg-info/ + +# Test results +TestResults/ + +# Credentials +credentials_real.json +testsettings_local.json +servicebus_settings_real.py +storage_settings_real.py +legacy_mgmt_settings_real.py +mgmt_settings_real.py +app_creds_real.py + +# User-specific files +*.suo +*.user +*.sln.docstates +.vs/ + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Mac desktop service store files +.DS_Store + +.idea +src/build +*.iml +/doc/_build +/.vs/config/applicationhost.config + +# Azure deployment credentials +*.pubxml + diff --git a/.hgignore b/.hgignore deleted file mode 100644 index f50f597da93..00000000000 --- a/.hgignore +++ /dev/null @@ -1,62 +0,0 @@ -syntax: glob -# Python cache -__pycache__/ -*.pyc - -# Virtual environment -env/ - -# PTVS analysis -.ptvs/ - -# Build results -bin/ -obj/ -dist/ -MANIFEST - -# Result of running python setup.py install/pip install -e -RECORD.txt -build/ -*.egg-info/ - -# Test results -TestResults/ - -# Credentials -credentials_real.json -testsettings_local.json -servicebus_settings_real.py -storage_settings_real.py -legacy_mgmt_settings_real.py -mgmt_settings_real.py -app_creds_real.py - -# User-specific files -*.suo -*.user -*.sln.docstates -.vs/ - -# Windows image file caches -Thumbs.db -ehthumbs.db - -# Folder config file -Desktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Mac desktop service store files -.DS_Store - -.idea -src/build -*.iml -/doc/_build -/.vs/config/applicationhost.config - -# Azure deployment credentials -*.pubxml - diff --git a/azure-cli.pyproj b/azure-cli.pyproj new file mode 100644 index 00000000000..de447fd25f9 --- /dev/null +++ b/azure-cli.pyproj @@ -0,0 +1,62 @@ + + + + Debug + 2.0 + {938454f7-93bd-41a7-84b2-3c89d64b969d} + src\ + + + . + . + {888888a0-9f3d-457c-b088-3a5042f75d52} + Standard Python launcher + {1dd9c42b-5980-42ce-a2c3-46d3bf0eede4} + 3.5 + + + + + 10.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\Python Tools\Microsoft.PythonTools.targets + + + + + + + + Code + + + + + Code + + + + + + + + + + + + + + + {1dd9c42b-5980-42ce-a2c3-46d3bf0eede4} + {2af0f10d-7135-4994-9156-5d01c9c11b7e} + 3.5 + env (Python 3.5) + Scripts\python.exe + Scripts\pythonw.exe + Lib\ + PYTHONPATH + X86 + + + + + \ No newline at end of file diff --git a/azure-cli.sln b/azure-cli.sln new file mode 100644 index 00000000000..36e28160043 --- /dev/null +++ b/azure-cli.sln @@ -0,0 +1,20 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.24720.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{888888A0-9F3D-457C-B088-3A5042F75D52}") = "azure-cli", "azure-cli.pyproj", "{938454F7-93BD-41A7-84B2-3C89D64B969D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {938454F7-93BD-41A7-84B2-3C89D64B969D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {938454F7-93BD-41A7-84B2-3C89D64B969D}.Release|Any CPU.ActiveCfg = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/bin/extract-loc.py b/bin/extract-loc.py new file mode 100644 index 00000000000..16df9469fae --- /dev/null +++ b/bin/extract-loc.py @@ -0,0 +1,30 @@ +#! /usr/bin/env python3 + +import os +import re +import subprocess +import sys + +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent / "src" / "azure" / "cli" +OUTPUT = ROOT / "locale" / "en-US" / "messages.txt" + +print('Extracting from:', ROOT) + +if not ROOT.is_dir(): + print("Failed to locate 'azure/cli'") + sys.exit(1) + +if not OUTPUT.parent.is_dir(): + os.makedirs(str(OUTPUT.parent)) + +with open(str(OUTPUT), 'w', encoding='utf-8-sig') as f_out: + for path in ROOT.rglob('*.py'): + with open(str(path), 'r', encoding='utf-8') as f: + content = f.read() + for m in re.finditer('[^\w_]_\(("(.+)"|\'(.+)\')\)', content): + print('# From', path, ':', m.span()[0], file=f_out) + print('KEY:', m.group(2) or m.group(3), file=f_out) + print(m.group(2) or m.group(3), file=f_out) + print(file=f_out) diff --git a/src/MANIFEST.in b/src/MANIFEST.in new file mode 100644 index 00000000000..c9f6b6302e1 --- /dev/null +++ b/src/MANIFEST.in @@ -0,0 +1,2 @@ +include *.rst +exclude azure/__init__.py diff --git a/src/README.rst b/src/README.rst new file mode 100644 index 00000000000..f72fa40dbdd --- /dev/null +++ b/src/README.rst @@ -0,0 +1,62 @@ +Microsoft Azure Command-Line Tools +================================== + +This is the Microsoft Azure Service Bus Runtime Client Library. + +This package has [not] been tested [much] with Python 2.7, 3.4 and 3.5. + + +Installation +============ + +Download Package +---------------- + +To install via the Python Package Index (PyPI), type: + +.. code:: shell + + pip install azure-cli + + +Download Source Code +-------------------- + +To get the source code of the SDK via **git** type: + +.. code:: shell + + git clone https://github.com/Azure/azure-cli.git + + +Usage +===== + + + +Need Help? +========== + +Be sure to check out the Microsoft Azure `Developer Forums on Stack +Overflow `__ if you have +trouble with the provided code. + + +Contribute Code or Provide Feedback +=================================== + +If you would like to become an active contributor to this project please +follow the instructions provided in `Microsoft Azure Projects +Contribution +Guidelines `__. + +If you encounter any bugs with the tool please file an issue in the +`Issues `__ +section of the project. + + +Learn More +========== + +`Microsoft Azure Python Developer +Center `__ diff --git a/src/azure/__init__.py b/src/azure/__init__.py new file mode 100644 index 00000000000..de40ea7ca05 --- /dev/null +++ b/src/azure/__init__.py @@ -0,0 +1 @@ +__import__('pkg_resources').declare_namespace(__name__) diff --git a/src/azure/cli/__init__.py b/src/azure/cli/__init__.py new file mode 100644 index 00000000000..8c1c1d79bc8 --- /dev/null +++ b/src/azure/cli/__init__.py @@ -0,0 +1,8 @@ +'''The Azure Command-line tool. + +This tools provides a command-line interface to Azure's management and storage +APIs. +''' + +__author__ = "Microsoft Corporation " +__version__ = "0.0.1" diff --git a/src/azure/cli/__main__.py b/src/azure/cli/__main__.py new file mode 100644 index 00000000000..8c109acaaa8 --- /dev/null +++ b/src/azure/cli/__main__.py @@ -0,0 +1,13 @@ +import time +_import_time = time.perf_counter() + +import sys + +import azure.cli.main +from azure.cli._logging import logging + +try: + sys.exit(azure.cli.main.main(sys.argv[1:])) +finally: + # Note: script time includes idle and network time + logging.info('Execution time: %8.3fms', 1000 * (time.perf_counter() - _import_time)) diff --git a/src/azure/cli/_argparse.py b/src/azure/cli/_argparse.py new file mode 100644 index 00000000000..46f041776b6 --- /dev/null +++ b/src/azure/cli/_argparse.py @@ -0,0 +1,251 @@ +from __future__ import print_function +import json +import os +import sys + +from ._locale import get_file as locale_get_file +from ._logging import logging + +# Named arguments are prefixed with one of these strings +ARG_PREFIXES = sorted(('-', '--', '/'), key=len, reverse=True) + +# Values are separated from argument name with one or more of these characters +# or a space +ARG_SEPARATORS = ':=' + +class IncorrectUsageError(Exception): + '''Raised when a command is incorrectly used and the usage should be + displayed to the user. + ''' + pass + +class Arguments(dict): + def __init__(self, source=None): + self.positional = [] + if source: + self.update(source) + + def add_from_dotted(self, key, value): + d = self + bits = key.split('.') + for p in bits[:-1]: + d = d.setdefault(p, Arguments()) + if not isinstance(d, Arguments): + raise RuntimeError('incompatible arguments for "{}"'.format(p)) + d[bits[-1]] = value + + def __getattr__(self, key): + try: + return self[key] + except LookupError: + pass + logging.debug('Argument %s is required', key) + raise IncorrectUsageError(_("Argument {0} is required").format(key)) + +def _read_arg(string): + for prefix in ARG_PREFIXES: + if string.startswith(prefix): + a1, a2 = string, None + indices = sorted((a1.find(sep), sep) for sep in ARG_SEPARATORS) + sep = next((i[1] for i in indices if i[0] > len(prefix)), None) + if sep: + a1, _, a2 = a1.partition(sep) + return a1[len(prefix):].lower(), a2 + return None, None + +def _index(string, char, default=sys.maxsize): + try: + return string.index(char) + except ValueError: + return default + + +class ArgumentParser(object): + def __init__(self, prog): + self.prog = prog + self.noun_map = { + '$doc': 'azure-cli.txt', + } + self.help_args = { '--help', '-h' } + self.complete_args = { '--complete' } + self.global_args = { '--verbose', '--debug' } + + def add_command(self, handler, name=None, description=None, args=None): + '''Registers a command that may be parsed by this parser. + + `handler` is the function to call with two `Arguments` objects. + All recognized arguments will appear on the first; all others on the + second. Accessing a missing argument as an attribute will raise + `IncorrectUsageError` that typically displays the command help. + Accessing a missing argument as an index or using `get` behaves like a + dictionary. + + `name` is a space-separated list of names identifying the command. + + `description` is a short piece of help text to display in usage info. + + `args` is a list of (spec, description) tuples. Each spec is either the + name of a positional argument, or an ``'--argument -a '`` + string listing one or more argument names and an optional variable name. + When multiple names are specified, the first is always used as the + name on `Arguments`. + ''' + nouns = (name or handler.__name__).split() + full_name = '' + m = self.noun_map + for n in nouns: + full_name += n + m = m.setdefault(n.lower(), { + '$doc': full_name + ".txt" + }) + full_name += '.' + m['$description'] = description or handler.__doc__ + m['$handler'] = handler + + m['$args'] = [] + m['$kwargs'] = kw = {} + m['$argdoc'] = ad = [] + for spec, desc in (args or []): + if not any(spec.startswith(p) for p in ARG_PREFIXES): + m['$args'].append(spec.strip('<> ')) + ad.append((spec, desc)) + continue + + aliases = spec.split() + if any(aliases[-1].startswith(p) for p in ARG_PREFIXES): + v = True + else: + v = aliases.pop().strip('<> ') + target, _ = _read_arg(aliases[0]) + kw.update({_read_arg(a)[0]: (target, v) for a in aliases}) + ad.append(('/'.join(aliases), desc)) + + + def execute(self, args, show_usage=False, show_completions=False, out=sys.stdout): + '''Parses `args` and invokes the associated handler. + + The handler is passed two `Arguments` objects with all arguments other + than those in `self.help_args`, `self.complete_args` and + `self.global_args`. The first contains arguments that were defined by + the handler spec, while the second contains all other arguments. + + If `show_usage` is ``True`` or any of `self.help_args` has been provided + then usage information will be displayed instead of executing the + command. + + If `show_completions` is ``True`` or any of `self.complete_args` has + been provided then raw information about the likely arguments will be + provided. + ''' + if not show_usage: + show_usage = any(a in self.help_args for a in args) + if not show_completions: + show_completions = any(a in self.complete_args for a in args) + + all_global_args = set(a.lstrip('-/') for a in self.help_args | self.complete_args | self.global_args) + def not_global(a): + return a.lstrip('-/') not in all_global_args + it = filter(not_global, args) + + m = self.noun_map + nouns = [] + n = next(it, '') + while n: + try: + m = m[n.lower()] + nouns.append(n.lower()) + except LookupError: + if '$args' not in m: + show_usage = True + break + n = next(it, '') + + try: + expected_args = m['$args'] + expected_kwargs = m['$kwargs'] + handler = m['$handler'] + except LookupError: + logging.debug('Missing data for noun %s', n) + show_usage = True + + if show_completions: + return self._display_completions(nouns, m, args, out) + if show_usage: + return self._display_usage(nouns, m, args, out) + + parsed = Arguments() + others = Arguments() + while n: + next_n = next(it, '') + + key_n, value = _read_arg(n) + if key_n: + target_value = expected_kwargs.get(key_n) + if target_value is None: + # Unknown arg always takes an argument. + if value is None: + value, next_n = next_n, next(it, '') + others.add_from_dotted(key_n, value) + elif target_value[1] is True: + # Arg with no value + if value is not None: + print(_("argument '{0}' does not take a value").format(key_n), file=out) + return self._display_usage(nouns, m, args, out) + parsed.add_from_dotted(target_value[0], True) + else: + # Arg with a value + if value is None: + value, next_n = next_n, next(it, '') + parsed.add_from_dotted(target_value[0], value) + else: + # Positional arg + parsed.positional.append(n) + n = next_n + + old_stdout = sys.stdout + try: + sys.stdout = out + return handler(parsed, others) + except IncorrectUsageError as ex: + print(str(ex), file=out) + return self.display_usage(nouns, m, args, out) + finally: + sys.stdout = old_stdout + + def _display_usage(self, nouns, noun_map, arguments, out=sys.stdout): + spec = ' '.join(noun_map.get('$spec') or nouns) + print(' {} {}'.format(self.prog, spec), file=out) + print(file=out, flush=True) + + subnouns = sorted(k for k in noun_map if not k.startswith('$')) + if subnouns: + print('Subcommands', file=out) + for n in subnouns: + print(' {}'.format(n), file=out) + print(file=out, flush=True) + + argdoc = noun_map.get('$argdoc') + if argdoc: + print('Arguments', file=out) + maxlen = max(len(a) for a, d in argdoc) + for a, d in argdoc: + print(' {0:<{1}} - {2}'.format(a, maxlen, d), file=out) + print(file=out, flush=True) + + doc_file = locale_get_file(noun_map['$doc']) + try: + with open(doc_file, 'r') as f: + print(f.read(), file=out, flush=True) + except OSError: + # TODO: Behave better when no docs available + print('No documentation available', file=out, flush=True) + logging.debug('Expected documentation at %s', doc_file) + + def _display_completions(self, nouns, noun_map, arguments, out=sys.stdout): + completions = [k for k in noun_map if not k.startswith('$')] + + kwargs = noun_map.get('$kwargs') + if kwargs: + completions.extend('--' + a for a in kwargs if a) + + print('\n'.join(sorted(completions)), file=out, flush=True) diff --git a/src/azure/cli/_locale.py b/src/azure/cli/_locale.py new file mode 100644 index 00000000000..97faae764d4 --- /dev/null +++ b/src/azure/cli/_locale.py @@ -0,0 +1,30 @@ +import os.path + +from codecs import open + +def install(locale_dir): + mapping = [] + + with open(os.path.join(locale_dir, "messages.txt"), 'r', encoding='utf-8-sig') as f: + for i in f: + if not i or i.startswith('#') or not i.strip(): + continue + if i.startswith('KEY: '): + mapping.append((i[5:].strip(), None)) + else: + mapping[-1] = (mapping[-1][0], i.strip()) + + translations = dict(mapping) + def _(key): + return translations.get(key) or ''.format(key) + _.locale_dir = locale_dir + + __builtins__['_'] = _ + +def get_file(name): + try: + src = _.locale_dir + except (NameError, AttributeError): + raise RuntimeError("localizations not installed") + + return os.path.join(src, name) diff --git a/src/azure/cli/_logging.py b/src/azure/cli/_logging.py new file mode 100644 index 00000000000..a3612cd7889 --- /dev/null +++ b/src/azure/cli/_logging.py @@ -0,0 +1,82 @@ +import logging as _logging +import sys + +__all__ = ['logging', 'configure_logging'] + +_CODE_LEVEL = _logging.INFO + 1 + +class Logger(_logging.Logger): + def __init__(self, name, level = _logging.NOTSET): + super(Logger, self).__init__(name, level) + + def code(self, msg, *args): + self._log(_CODE_LEVEL, msg, args) + +logging = Logger('az', _logging.WARNING) + +class PyFileFormatter(_logging.Formatter): + def __init__(self): + super(PyFileFormatter, self).__init__('# %(levelname)s: %(message)s') + self.info_style = _logging.PercentStyle('%(message)s') + + def format(self, record): + assert isinstance(record, _logging.LogRecord) + if record.levelno == _CODE_LEVEL: + return record.getMessage() + return super(PyFileFormatter, self).format(record) + +def _arg_name(arg): + a = arg.lstrip('-/') + if a == arg: + return None + return a.lower() + +def configure_logging(argv, config): + level = _logging.WARNING + + # Load logging info from config + if config.get('verbose'): + level = _logging.INFO + if config.get('debug'): + level = _logging.DEBUG + logfile = config.get('log') + + # Load logging info from arguments + # Also remove any arguments consumed so that the parser does not + # have to explicitly ignore them. + i = 0 + while i < len(argv): + arg = _arg_name(argv[i]) + if arg in ('v', 'verbose'): + level = min(_logging.INFO, level) + argv.pop(i) + elif arg in ('debug',): + level = min(_logging.DEBUG, level) + argv.pop(i) + elif arg in ('log',): + argv.pop(i) + try: + logfile = argv.pop(i) + except IndexError: + pass + else: + i += 1 + + # Configure the console output handler + stderr_handler = _logging.StreamHandler(sys.stderr) + stderr_handler.formatter = _logging.Formatter('%(levelname)s: %(message)s') + logging.level = stderr_handler.level = level + logging.handlers.append(stderr_handler) + + if logfile and logfile.lower().endswith('.py'): + # Configure a handler that logs code to a Python script + py_handler = _logging.StreamHandler(open(logfile, 'w', encoding='utf-8')) + py_handler.formatter = PyFileFormatter() + py_handler.level = level if level == _logging.DEBUG else _logging.INFO + logging.handlers.append(py_handler) + elif logfile: + # Configure the handler that logs code to a text file + log_handler = _logging.StreamHandler(open(logfile, 'w', encoding='utf-8')) + log_handler.formatter = _logging.Formatter('[%(levelname)s:%(asctime)s] %(message)s') + log_handler.level = level if level == _logging.DEBUG else _logging.INFO + logging.handlers.append(log_handler) diff --git a/src/azure/cli/_session.py b/src/azure/cli/_session.py new file mode 100644 index 00000000000..46c7daf15e8 --- /dev/null +++ b/src/azure/cli/_session.py @@ -0,0 +1,65 @@ +import collections.abc +import json +import os +import time + +from codecs import open + +class Session(collections.abc.MutableMapping): + '''A simple dict-like class that is backed by a JSON file. + + All direct modifications will save the file. Indirect modifications should + be followed by a call to `save_with_retry` or `save`. + ''' + + def __init__(self): + self.filename = None + self.data = {} + + def load(self, filename, max_age=0): + self.filename = filename + self.data = {} + try: + if max_age > 0: + st = os.stat(self.filename) + if st.st_mtime + max_age < time.clock(): + self.save() + with open(self.filename, 'r', encoding='utf-8-sig') as f: + self.data = json.load(f) + except OSError: + self.save() + + def save(self): + if self.filename: + with open(self.filename, 'w', encoding='utf-8-sig') as f: + json.dump(self.data, f) + + def save_with_retry(self, retries=5): + for _ in range(retries - 1): + try: + self.save() + break + except OSError: + time.sleep(0.1) + else: + self.save() + + def get(self, key, default=None): + return self.data.get(key, default) + + def __getitem__(self, key): + return self.data.setdefault(key, {}) + + def __setitem__(self, key, value): + self.data[key] = value + self.save_with_retry() + + def __delitem__(self, key): + del self.data[key] + self.save_with_retry() + + def __iter__(self): + return iter(self.data) + + def __len__(self): + return len(self.data) diff --git a/src/azure/cli/_util.py b/src/azure/cli/_util.py new file mode 100644 index 00000000000..83f30161e5d --- /dev/null +++ b/src/azure/cli/_util.py @@ -0,0 +1,38 @@ +import types + +class TableOutput(object): + def __enter__(self): + self._rows = [{}] + self._columns = {} + self._column_order = [] + return self + + def __exit__(self, ex_type, ex_value, ex_tb): + if ex_type: + return + if len(self._rows) == 1: + return + + cols = [(c, self._columns[c]) for c in self._column_order] + print(' | '.join(c.center(w) for c, w in cols)) + print('-|-'.join('-' * w for c, w in cols)) + for r in self._rows[:-1]: + print(' | '.join(r[c].ljust(w) for c, w in cols)) + print() + + @property + def any_rows(self): + return len(self._rows) > 1 + + def cell(self, name, value): + n = str(name) + v = str(value) + max_width = self._columns.get(n) + if max_width is None: + self._column_order.append(n) + max_width = len(n) + self._rows[-1][n] = v + self._columns[n] = max(max_width, len(v)) + + def end_row(self): + self._rows.append({}) diff --git a/src/azure/cli/commands/__init__.py b/src/azure/cli/commands/__init__.py new file mode 100644 index 00000000000..0f92fab95c3 --- /dev/null +++ b/src/azure/cli/commands/__init__.py @@ -0,0 +1,59 @@ +from .._argparse import IncorrectUsageError +from .._logging import logging + +# TODO: Alternatively, simply scan the directory for all modules +COMMAND_MODULES = [ + 'login', + 'storage', +] + +_COMMANDS = {} + +def command(name): + def add_command(handler): + _COMMANDS.setdefault(handler, {})['name'] = name + logging.debug('Added %s as command "%s"', handler, name) + return handler + return add_command + +def description(description): + def add_description(handler): + _COMMANDS.setdefault(handler, {})['description'] = description + logging.debug('Added description "%s" to %s', description, handler) + return handler + return add_description + +def option(spec, description=None): + def add_option(handler): + _COMMANDS.setdefault(handler, {}).setdefault('args', []).append((spec, description)) + logging.debug('Added option "%s" to %s', spec, handler) + return handler + return add_option + +def add_to_parser(parser, command=None): + '''Loads commands into the parser + + When `command` is specified, only commands from that module will be loaded. + If the module is not found, all commands are loaded. + ''' + + # Importing the modules is sufficient to invoke the decorators. Then we can + # get all of the commands from the _COMMANDS variable. + loaded = False + if command: + try: + __import__('azure.cli.commands.' + command) + loaded = True + except ImportError: + # Unknown command - we'll load all below + pass + + if not loaded: + for mod in COMMAND_MODULES: + __import__('azure.cli.commands.' + mod) + loaded = True + + for handler, info in _COMMANDS.items(): + # args have probably been added in reverse order + info.setdefault('args', []).reverse() + parser.add_command(handler, **info) diff --git a/src/azure/cli/commands/login.py b/src/azure/cli/commands/login.py new file mode 100644 index 00000000000..c3b62a228fb --- /dev/null +++ b/src/azure/cli/commands/login.py @@ -0,0 +1,71 @@ +from .._logging import logging +from ..main import CONFIG, SESSION +from ..commands import command, description, option + +PII_WARNING_TEXT = _( + 'If you choose to continue, Azure command-line interface will cache your ' + 'authentication information. Note that this sensitive information will be stored in ' + 'plain text on the file system of your computer at {}. Ensure that you take suitable ' + 'precautions to protect your computer from unauthorized access in order to minimize the ' + 'risk of that information being disclosed.' + '\nDo you wish to continue: (y/n) ' +) + +@command('login') +@description('logs you in') +@option('--username -u ', _('user name or service principal ID. If multifactor authentication is required, ' + 'you will be prompted to use the login command without parameters for ' + 'interactive support.')) +@option('--environment -e ', _('Environment to authenticate against, such as AzureChinaCloud; ' + 'must support active directory.')) +@option('--password -p ', _('user password or service principal secret, will prompt if not given.')) +@option('--service-principal', _('If given, log in as a service principal rather than a user.')) +@option('--certificate-file ', _('A PEM encoded certificate private key file.')) +@option('--thumbprint ', _('A hex encoded thumbprint of the certificate.')) +@option('--tenant ', _('Tenant domain or ID to log into.')) +@option('--quiet -q', _('do not prompt for confirmation of PII storage.')) +def login(args, unexpected): + username = args.get('username') + interactive = bool(username) + + environment_name = args.get('environment') or 'AzureCloud' + environment = CONFIG['environments'].get(environment_name) + if not environment: + raise RuntimeError(_('Unknown environment {0}').format(environment_name)) + + tenant = args.get('tenant') + if args.get('service-principal') and not tenant: + tenant = input(_('Tenant: ')) + + # TODO: PII warning + + password = args.get('password') + require_password = not args.get('service-principal') or not args.get('certificate-file') + if not interactive and require_password and not password: + import getpass + password = getpass.getpass(_('Password: ')) + + if not require_password: + password = { + 'certificateFile': args['certificate-file'], + 'thumbprint': args.thumbprint, + } + + if not interactive: + # TODO: Remove cached token + SESSION.pop(username + '_token', None) + + # TODO: Perform login + token = '' + + SESSION[username + '_token'] = token + + # TODO: Get subscriptions + subscriptions = ['not-a-real-subscription'] + if not subscriptions: + raise RuntimeError(_("No subscriptions found for this account")) + + active_subscription = subscriptions[0] + + logging.info(_('Setting subscription %s as default'), active_subscription) + SESSION['active_subscription'] = active_subscription diff --git a/src/azure/cli/commands/storage.py b/src/azure/cli/commands/storage.py new file mode 100644 index 00000000000..cea5b6b8e8c --- /dev/null +++ b/src/azure/cli/commands/storage.py @@ -0,0 +1,57 @@ +from ..main import CONFIG, SESSION +from .._logging import logging +from .._util import TableOutput +from ..commands import command, description, option + +@command('storage account list') +@description('List storage accounts') +@option('--resource-group -g ', _("the resource group name")) +@option('--subscription -s ', _("the subscription id")) +def list_accounts(args, unexpected): + from azure.mgmt.storage import StorageManagementClient, StorageManagementClientConfiguration + from azure.mgmt.storage.models import StorageAccount + from msrestazure.azure_active_directory import UserPassCredentials + + username = '' # TODO: get username somehow + password = '' # TODO: get password somehow + + logging.code('''smc = StorageManagementClient(StorageManagementClientConfiguration( + credentials=UserPassCredentials(%r, %r), + subscription_id=%r +)''', username, password, args.subscription) + smc = StorageManagementClient(StorageManagementClientConfiguration( + credentials=UserPassCredentials(username, password), + subscription_id=args.subscription, + )) + + group = args.get('resource-group') + if group: + logging.code('accounts = smc.storage_accounts.list_by_resource_group(%r)', group) + accounts = smc.storage_accounts.list_by_resource_group(group) + else: + logging.code('accounts = smc.storage_accounts.list()') + accounts = smc.storage_accounts.list() + + with TableOutput() as to: + for acc in accounts: + assert isinstance(acc, StorageAccount) + to.cell('Name', acc.name) + to.cell('Type', acc.account_type) + to.cell('Location', acc.location) + to.end_row() + if not to.any_rows: + print('No storage accounts defined') + +@command('storage account check') +@option('--account-name ') +def checkname(args, unexpected): + from azure.mgmt.storage import StorageManagementClient, StorageManagementClientConfiguration + + logging.code('''smc = StorageManagementClient(StorageManagementClientConfiguration()) +smc.storage_accounts.check_name_availability({0.account_name!r}) +'''.format(args)) + + smc = StorageManagementClient(StorageManagementClientConfiguration()) + logging.warn(smc.storage_accounts.check_name_availability(args.account_name)) + + diff --git a/src/azure/cli/locale/en-US/messages.txt b/src/azure/cli/locale/en-US/messages.txt new file mode 100644 index 00000000000..7c745152204 --- /dev/null +++ b/src/azure/cli/locale/en-US/messages.txt @@ -0,0 +1,60 @@ +# From D:\Repos\azure-cli\src\azure\cli\_argparse.py : 1208 +KEY: Argument {0} is required +Argument {0} is required + +# From D:\Repos\azure-cli\src\azure\cli\_argparse.py : 6848 +KEY: argument '{0}' does not take a value +argument '{0}' does not take a value + +# From D:\Repos\azure-cli\src\azure\cli\commands\login.py : 1147 +KEY: user password or service principal secret, will prompt if not given. +user password or service principal secret, will prompt if not given. + +# From D:\Repos\azure-cli\src\azure\cli\commands\login.py : 1253 +KEY: If given, log in as a service principal rather than a user. +If given, log in as a service principal rather than a user. + +# From D:\Repos\azure-cli\src\azure\cli\commands\login.py : 1367 +KEY: A PEM encoded certificate private key file. +A PEM encoded certificate private key file. + +# From D:\Repos\azure-cli\src\azure\cli\commands\login.py : 1454 +KEY: A hex encoded thumbprint of the certificate. +A hex encoded thumbprint of the certificate. + +# From D:\Repos\azure-cli\src\azure\cli\commands\login.py : 1535 +KEY: Tenant domain or ID to log into. +Tenant domain or ID to log into. + +# From D:\Repos\azure-cli\src\azure\cli\commands\login.py : 1597 +KEY: do not prompt for confirmation of PII storage. +do not prompt for confirmation of PII storage. + +# From D:\Repos\azure-cli\src\azure\cli\commands\login.py : 1927 +KEY: Unknown environment {0} +Unknown environment {0} + +# From D:\Repos\azure-cli\src\azure\cli\commands\login.py : 2091 +KEY: Tenant: +Tenant: + +# From D:\Repos\azure-cli\src\azure\cli\commands\login.py : 2381 +KEY: Password: +Password: + +# From D:\Repos\azure-cli\src\azure\cli\commands\login.py : 2883 +KEY: No subscriptions found for this account +No subscriptions found for this account + +# From D:\Repos\azure-cli\src\azure\cli\commands\login.py : 2991 +KEY: Setting subscription %s as default +Setting subscription %s as default + +# From D:\Repos\azure-cli\src\azure\cli\commands\storage.py : 268 +KEY: the resource group name +the resource group name + +# From D:\Repos\azure-cli\src\azure\cli\commands\storage.py : 332 +KEY: the subscription id +the subscription id + diff --git a/src/azure/cli/main.py b/src/azure/cli/main.py new file mode 100644 index 00000000000..a090c8af4ae --- /dev/null +++ b/src/azure/cli/main.py @@ -0,0 +1,45 @@ +import os + +from ._argparse import ArgumentParser +from ._locale import install as locale_install +from ._logging import configure_logging, logging +from ._session import Session + +# CONFIG provides external configuration options +CONFIG = Session() + +# SESSION provides read-write session variables +SESSION = Session() + +# Load the user's preferred locale from their configuration +LOCALE = CONFIG.get('locale', 'en-US') +locale_install(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'locale', LOCALE)) + + +def main(args): + CONFIG.load(os.path.expanduser('~/az.json')) + SESSION.load(os.path.expanduser('~/az.sess'), max_age=3600) + + configure_logging(args, CONFIG) + + parser = ArgumentParser("az") + + import azure.cli.commands as commands + + # Find the first noun on the command line and only load commands from that + # module to improve startup time. + for a in args: + if not a.startswith('-'): + commands.add_to_parser(parser, a) + break + else: + # No noun found, so load all commands. + commands.add_to_parser(parser) + + try: + parser.execute(args) + except RuntimeError as ex: + logging.error(ex.args[0]) + return ex.args[1] if len(ex.args) >= 2 else -1 + except KeyboardInterrupt: + return -1 diff --git a/src/azure/cli/tests/test_argparse.py b/src/azure/cli/tests/test_argparse.py new file mode 100644 index 00000000000..119158e3ccd --- /dev/null +++ b/src/azure/cli/tests/test_argparse.py @@ -0,0 +1,86 @@ +import unittest + +from azure.cli._argparse import ArgumentParser, IncorrectUsageError +from azure.cli._logging import logging + +class Test_argparse(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Ensure initialization has occurred correctly + import azure.cli.main + logging.basicConfig(level=logging.DEBUG) + + @classmethod + def tearDownClass(cls): + logging.shutdown() + + def test_nouns(self): + p = ArgumentParser('test') + res = [False, False, False] + def set_n1(a, b): res[0] = True + def set_n2(a, b): res[1] = True + def set_n3(a, b): res[2] = True + p.add_command(set_n1, 'n1') + p.add_command(set_n2, 'n1 n2') + p.add_command(set_n3, 'n1 n2 n3') + + p.execute('n1 n2 n3'.split()) + self.assertSequenceEqual(res, (False, False, True)) + p.execute('n1'.split()) + self.assertSequenceEqual(res, (True, False, True)) + res[0] = False + p.execute('n1 n2'.split()) + self.assertSequenceEqual(res, (False, True, True)) + + def test_args(self): + p = ArgumentParser('test') + p.add_command(lambda a, b: (a, b), 'n1', args=[('--arg -a', ''), ('-b ', '')]) + + res, other = p.execute('n1 -a x'.split()) + self.assertTrue(res.arg) + self.assertSequenceEqual(res.positional, ['x']) + + # Should recognize args with alternate prefix + res, other = p.execute('n1 /a'.split()) + self.assertTrue(res.arg) + res, other = p.execute('n1 /arg'.split()) + self.assertTrue(res.arg) + + # Should not recognize "------a" + res, other = p.execute('n1 ------a'.split()) + self.assertNotIn('arg', res) + # First two '--' match, so '----a' is added to dict + self.assertIn('----a', other) + + res = p.execute('n1 -a:x'.split()) + self.assertIsNone(res) + + res, other = p.execute('n1 -b -a x'.split()) + self.assertEquals(res.b, '-a') + self.assertSequenceEqual(res.positional, ['x']) + self.assertRaises(IncorrectUsageError, lambda: res.arg) + + res, other = p.execute('n1 -b:-a x'.split()) + self.assertEquals(res.b, '-a') + self.assertSequenceEqual(res.positional, ['x']) + self.assertRaises(IncorrectUsageError, lambda: res.arg) + + def test_unexpected_args(self): + p = ArgumentParser('test') + p.add_command(lambda a, b: (a, b), 'n1', args=[('-a', '')]) + + res, other = p.execute('n1 -b=2'.split()) + self.assertFalse(res) + self.assertEquals('2', other.b) + + res, other = p.execute('n1 -b.c.d=2'.split()) + self.assertFalse(res) + self.assertEquals('2', other.b.c.d) + + res, other = p.execute('n1 -b.c.d 2 -b.c.e:3'.split()) + self.assertFalse(res) + self.assertEquals('2', other.b.c.d) + self.assertEquals('3', other.b.c.e) + +if __name__ == '__main__': + unittest.main() diff --git a/src/setup.cfg b/src/setup.cfg new file mode 100644 index 00000000000..7c1852ce7f4 --- /dev/null +++ b/src/setup.cfg @@ -0,0 +1,6 @@ +[bdist_wheel] +universal=1 + +[install] +single-version-externally-managed=1 +record=RECORD.txt diff --git a/src/setup.py b/src/setup.py new file mode 100644 index 00000000000..11a4db4a6c6 --- /dev/null +++ b/src/setup.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python + +#------------------------------------------------------------------------- +# Copyright (c) Microsoft. All rights reserved. +# +# 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. +#-------------------------------------------------------------------------- + +from __future__ import print_function +from codecs import open +from setuptools import setup + +VERSION = '0.0.1' + +# If we have source, validate that our version numbers match +# This should prevent uploading releases with mismatched versions. +try: + with open('azure/cli/__init__.py', 'r', encoding='utf-8') as f: + content = f.read() +except OSError: + pass +else: + import re, sys + m = re.search(r'__version__\s*=\s*[\'"](.+?)[\'"]', content) + if not m: + print('Could not find __version__ in azure/cli/__init__.py') + sys.exit(1) + if m.group(1) != VERSION: + print('Expected __version__ = "{}"; found "{}"'.format(VERSION, m.group(1))) + sys.exit(1) + +# The full list of classifiers is available at +# https://pypi.python.org/pypi?%3Aaction=list_classifiers +CLASSIFIERS = [ + 'Development Status :: 3 - Alpha', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + #'License :: OSI Approved :: Apache Software License', + #'License :: OSI Approved :: MIT License', +] + +# The azure-mgmt requirement should always be pinned to ensure +# that installing a specific azure-cli version will target the +# expected Azure API versions +# +# Alternatively, the more specific requirements such as +# azure-mgmt-resource may be specified in place of the roll-up +# packages. +# +# Common azure package dependencies will be pulled in by these +# references, so do not specify azure-common or -nspkg here. +DEPENDENCIES = [ + 'azure-mgmt==0.20.2', + 'azure-storage==0.20.3', +] + +with open('README.rst', 'r', encoding='utf-8') as f: + README = f.read() + +setup( + name='azure-cli', + version=VERSION, + description='Microsoft Azure Command-Line Tools', + long_description=README, + license='TBD', + author='Microsoft Corporation', + author_email='SOMEBODY@microsoft.com', + url='https://github.com/Azure/azure-cli', + classifiers=CLASSIFIERS, + zip_safe=False, + packages=[ + 'azure.cli', + 'azure.cli.commands', + ], + install_requires=DEPENDENCIES, +)