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,
+)