diff --git a/bin/yunohost b/bin/yunohost index d4483df5..fb03b34d 100755 --- a/bin/yunohost +++ b/bin/yunohost @@ -5,25 +5,29 @@ import sys import os.path import gettext -# Debug option -if '--debug' in sys.argv: - sys.path.append(os.path.abspath(os.path.dirname(__file__) +'/../src')) -from moulinette import cli -from moulinette.core.helpers import YunoHostError, colorize +# Run from source +basedir = os.path.abspath(os.path.dirname(__file__) +'/../') +if os.path.isdir(basedir +'/src'): + sys.path.append(basedir +'/src') -gettext.install('YunoHost') +from moulinette import init, cli, MoulinetteError +from moulinette.helpers import YunoHostError, colorize + +gettext.install('yunohost') ## Main action if __name__ == '__main__': + # Run from source (prefix and libdir set to None) + init('yunohost', prefix=None, libdir=None, + cachedir=os.path.join(basedir, 'cache')) + # Additional arguments use_cache = True if '--no-cache' in sys.argv: use_cache = False sys.argv.remove('--no-cache') - if '--debug' in sys.argv: - sys.argv.remove('--debug') try: args = list(sys.argv) @@ -36,6 +40,9 @@ if __name__ == '__main__': # Execute the action cli(args, use_cache) + except MoulinetteError as e: + print(e.colorize()) + sys.exit(e.code) except YunoHostError as e: print(colorize(_("Error: "), 'red') + e.message) sys.exit(e.code) diff --git a/bin/yunohost-api b/bin/yunohost-api index 625d2b7e..18d10d09 100755 --- a/bin/yunohost-api +++ b/bin/yunohost-api @@ -5,12 +5,14 @@ import sys import os.path import gettext -# Debug option -if '--debug' in sys.argv: - sys.path.append(os.path.abspath(os.path.dirname(__file__) +'/../src')) -from moulinette import api +# Run from source +basedir = os.path.abspath(os.path.dirname(__file__) +'/../') +if os.path.isdir(basedir +'/src'): + sys.path.append(basedir +'/src') -gettext.install('YunoHost') +from moulinette import init, api + +gettext.install('yunohost') ## Callbacks for additional routes @@ -29,6 +31,10 @@ def is_installed(): ## Main action if __name__ == '__main__': + # Run from source (prefix and libdir set to None) + init('yunohost', prefix=None, libdir=None, + cachedir=os.path.join(basedir, 'cache')) + # Additional arguments use_cache = True if '--no-cache' in sys.argv: diff --git a/data/actionsmap/yunohost.yml b/data/actionsmap/yunohost.yml index fe5ab237..52a62654 100644 --- a/data/actionsmap/yunohost.yml +++ b/data/actionsmap/yunohost.yml @@ -76,7 +76,9 @@ user: help: Must be unique extra: ask: "Username" - pattern: '^[a-z0-9_]+$' + pattern: + - '^[a-z0-9_]+$' + - "Must be alphanumeric and underscore characters only" -f: full: --firstname extra: @@ -109,7 +111,9 @@ user: nargs: "*" extra: ask: "Users to delete" - pattern: '^[a-z0-9_]+$' + pattern: + - '^[a-z0-9_]+$' + - "Must be alphanumeric and underscore characters only" --purge: action: store_true @@ -187,7 +191,9 @@ domain: help: Domain name to add nargs: '+' extra: - pattern: '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' + pattern: + - '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' + - "Must be a valid domain name (e.g. my-domain.org)" -m: full: --main help: Is the main domain @@ -206,7 +212,9 @@ domain: help: Domain(s) to delete nargs: "+" extra: - pattern: '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' + pattern: + - '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' + - "Must be a valid domain name (e.g. my-domain.org)" ### domain_info() info: @@ -216,7 +224,9 @@ domain: domain: help: "" extra: - pattern: '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' + pattern: + - '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' + - "Must be a valid domain name (e.g. my-domain.org)" ############################# @@ -253,7 +263,9 @@ app: help: Name of the list to remove extra: ask: "List to remove" - pattern: '^[a-z0-9_]+$' + pattern: + - '^[a-z0-9_]+$' + - "Must be alphanumeric and underscore characters only" ### app_list() list: @@ -302,7 +314,9 @@ app: full: --user help: Allowed app map for a user extra: - pattern: '^[a-z0-9_]+$' + pattern: + - '^[a-z0-9_]+$' + - "Must be alphanumeric and underscore characters only" ### app_install() TODO: Write help @@ -388,7 +402,9 @@ app: port: help: Port to check extra: - pattern: '^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$' + pattern: + - '^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$' + - "Must be a valid port number (i.e. 0-65535)" ### app_checkurl() checkurl: @@ -659,7 +675,9 @@ service: help: Number of lines to display default: "50" extra: - pattern: '^[0-9]+$' + pattern: + - '^[0-9]+$' + - "Must be a valid number" ############################# @@ -691,7 +709,9 @@ firewall: port: help: Port to open extra: - pattern: '^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$' + pattern: + - '^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$' + - "Must be a valid port number (i.e. 0-65535)" protocol: help: Protocol associated with port choices: @@ -840,12 +860,16 @@ tools: -o: full: --old-domain extra: - pattern: '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' + pattern: + - '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' + - "Must be a valid domain name (e.g. my-domain.org)" -n: full: --new-domain extra: ask: "New main domain" - pattern: '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' + pattern: + - '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' + - "Must be a valid domain name (e.g. my-domain.org)" ### tools_postinstall() postinstall: @@ -857,7 +881,9 @@ tools: help: YunoHost main domain extra: ask: "Main domain" - pattern: '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' + pattern: + - '^([a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)(\.[a-zA-Z0-9]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)*(\.[a-zA-Z]{1}([a-zA-Z0-9\-]*[a-zA-Z0-9])*)$' + - "Must be a valid domain name (e.g. my-domain.org)" -p: full: --password help: YunoHost admin password diff --git a/src/moulinette/__init__.py b/src/moulinette/__init__.py index f96eae48..aa8c6753 100755 --- a/src/moulinette/__init__.py +++ b/src/moulinette/__init__.py @@ -24,68 +24,98 @@ __credits__ = """ You should have received a copy of the GNU Affero General Public License along with this program; if not, see http://www.gnu.org/licenses """ +__all__ = [ + 'init', 'api', 'cli', + 'MoulinetteError', +] + +from .core import MoulinetteError + +curr_namespace = None -## Fast access functions +## Package functions + +def init(namespace=None, **kwargs): + """Package initialization + + Initialize directories and global variables. It must be called + before any of package method is used - even the easy access + functions. + + Keyword arguments: + - namespace -- The namespace to initialize and use + - **kwargs -- See helpers.Package + + At the end, the global variable 'pkg' will contain a Package + instance. See helpers.Package for available methods and variables. + + """ + import __builtin__ + from .core import Package + + global curr_namespace + curr_namespace = namespace + + __builtin__.__dict__['pkg'] = Package(**kwargs) + + +## Easy access to interfaces def api(port, routes={}, use_cache=True): - """ + """Web server (API) interface + Run a HTTP server with the moulinette for an API usage. Keyword arguments: - - port -- Port to run on - - routes -- A dict of additional routes to add in the form of {(method, uri): callback} - - use_cache -- False if it should parse the actions map file instead of using the cached one """ from bottle import run - from core.actionsmap import ActionsMap - from core.api import MoulinetteAPI - from core.helpers import Interface + from .actionsmap import ActionsMap + from .interface.api import MoulinetteAPI - amap = ActionsMap(Interface.api, use_cache=use_cache) + amap = ActionsMap('api', use_cache=use_cache) moulinette = MoulinetteAPI(amap, routes) run(moulinette.app, port=port) def cli(args, use_cache=True): - """ + """Command line interface + Execute an action with the moulinette from the CLI and print its result in a readable format. Keyword arguments: - - args -- A list of argument strings - - use_cache -- False if it should parse the actions map file instead of using the cached one """ import os - from core.actionsmap import ActionsMap - from core.helpers import Interface, YunoHostError, pretty_print_dict + from .actionsmap import ActionsMap + from .helpers import YunoHostError, pretty_print_dict lock_file = '/var/run/moulinette.lock' # TODO: Move the lock checking into the ActionsMap class # Check the lock if os.path.isfile(lock_file): - raise YunoHostError(1, _("The moulinette is already running")) + raise MoulinetteError(1, _("The moulinette is already running")) # Create a lock with open(lock_file, 'w') as f: pass os.system('chmod 400 '+ lock_file) try: - amap = ActionsMap(Interface.cli, use_cache=use_cache) + amap = ActionsMap('cli', use_cache=use_cache) pretty_print_dict(amap.process(args)) except KeyboardInterrupt, EOFError: - raise YunoHostError(125, _("Interrupted")) + raise MoulinetteError(125, _("Interrupted")) finally: # Remove the lock os.remove(lock_file) diff --git a/src/moulinette/actionsmap.py b/src/moulinette/actionsmap.py new file mode 100644 index 00000000..f2eb5464 --- /dev/null +++ b/src/moulinette/actionsmap.py @@ -0,0 +1,338 @@ +# -*- coding: utf-8 -*- + +import pickle +import yaml +import re +import os +from collections import OrderedDict + +import logging + +from . import __version__, curr_namespace, MoulinetteError +from .extra.parameters import extraparameters_list + +## Extra parameters Parser + +class ExtraParser(object): + """ + Global parser for the extra parameters. + + """ + def __init__(self, iface): + self.iface = iface + self.extra = OrderedDict() + + # Append available extra parameters for the current interface + for klass in extraparameters_list: + if iface in klass.skipped_iface: + continue + self.extra[klass.name] = klass + + def validate(self, arg_name, parameters): + """ + Validate values of extra parameters for an argument + + Keyword arguments: + - arg_name -- The argument name + - parameters -- A dict of extra parameters with their values + + """ + # Iterate over parameters to validate + for p, v in parameters.items(): + # Remove unknow parameters + if p not in self.extra.keys(): + del parameters[p] + + # Validate parameter value + parameters[p] = self.extra[p].validate(v, arg_name) + + return parameters + + def parse(self, arg_name, arg_value, parameters): + """ + Parse argument with extra parameters + + Keyword arguments: + - arg_name -- The argument name + - arg_value -- The argument value + - parameters -- A dict of extra parameters with their values + + """ + # Iterate over available parameters + for p, klass in self.extra.items(): + if p not in parameters.keys(): + continue + + # Initialize the extra parser + parser = klass(self.iface) + + # Parse the argument + if isinstance(arg_value, list): + for v in arg_value: + r = parser(parameters[p], arg_name, v) + if r not in arg_value: + arg_value.append(r) + else: + arg_value = parser(parameters[p], arg_name, arg_value) + + return arg_value + + +## Main class + +class ActionsMap(object): + """ + Validate and process action defined into the actions map. + + The actions map defines features and their usage of the main + application. It is composed by categories which contain one or more + action(s). Moreover, the action can have specific argument(s). + + Keyword arguments: + + - interface -- Interface type that requires the actions map. + Possible value is one of: + - 'cli' for the command line interface + - 'api' for an API usage (HTTP requests) + + - use_cache -- False if it should parse the actions map file + instead of using the cached one. + + """ + def __init__(self, interface, use_cache=True): + self.use_cache = use_cache + + try: + # Retrieve the interface parser + mod = __import__('interface.%s' % interface, + globals=globals(), level=1, + fromlist=['actionsmap_parser']) + parser = getattr(mod, 'actionsmap_parser') + except (AttributeError, ImportError): + raise MoulinetteError(22, _("Invalid interface '%s'" % interface)) + else: + self._parser_class = parser + + logging.debug("initializing ActionsMap for the '%s' interface" % interface) + + actionsmaps = {} + namespaces = self.get_actionsmap_namespaces() + if curr_namespace and curr_namespace in namespaces: + namespaces = [curr_namespace] + + # Iterate over actions map namespaces + for n in namespaces: + logging.debug("loading '%s' actions map namespace" % n) + + if use_cache: + # Attempt to load cache if it exists + cache_file = '%s/%s.pkl' % (pkg.cachedir('actionsmap'), n) + if os.path.isfile(cache_file): + with open(cache_file, 'r') as f: + actionsmaps[n] = pickle.load(f) + else: + self.use_cache = False + actionsmaps = self.generate_cache(namespaces) + break + else: + am_file = '%s/%s.yml' % (pkg.datadir('actionsmap'), n) + with open(am_file, 'r') as f: + actionsmaps[n] = yaml.load(f) + + # Generate parsers + self.extraparser = ExtraParser(interface) + self.parser = self._construct_parser(actionsmaps) + + def process(self, args, **kwargs): + """ + Parse arguments and process the proper action + + Keyword arguments: + - args -- The arguments to parse + - **kwargs -- Additional interface arguments + + """ + # Parse arguments + arguments = vars(self.parser.parse_args(args, **kwargs)) + arguments = self._parse_extra_parameters(arguments) + + # Retrieve action information + namespace, category, action = arguments.pop('_info') + func_name = '%s_%s' % (category, action) + + try: + mod = __import__('%s.%s' % (namespace, category), + globals=globals(), level=0, + fromlist=[func_name]) + func = getattr(mod, func_name) + except (AttributeError, ImportError): + raise MoulinetteError(168, _('Function is not defined')) + else: + # Process the action + return func(**arguments) + + @staticmethod + def get_actionsmap_namespaces(): + """ + Retrieve actions map namespaces from a given path + + Returns: + A list of available namespaces + + """ + namespaces = [] + + for f in os.listdir(pkg.datadir('actionsmap')): + if f.endswith('.yml'): + namespaces.append(f[:-4]) + return namespaces + + @classmethod + def generate_cache(klass, namespaces=None): + """ + Generate cache for the actions map's file(s) + + Keyword arguments: + - namespaces -- A list of namespaces to generate cache for + + Returns: + A dict of actions map for each namespaces + + """ + actionsmaps = {} + if not namespaces: + namespaces = klass.get_actionsmap_namespaces() + + # Iterate over actions map namespaces + for n in namespaces: + logging.debug("generating cache for '%s' actions map namespace" % n) + + # Read actions map from yaml file + am_file = pkg.datafile('actionsmap/%s.yml' % n) + with open(am_file, 'r') as f: + actionsmaps[n] = yaml.load(f) + + # Cache actions map into pickle file + cache_file = pkg.cachefile('actionsmap/%s.pkl' % n, make_dir=True) + with open(cache_file, 'w') as f: + pickle.dump(actionsmaps[n], f) + + return actionsmaps + + + ## Private class and methods + + def _store_extra_parameters(self, parser, arg_name, arg_extra): + """ + Store extra parameters for a given argument + + Keyword arguments: + - parser -- Parser object for the arguments + - arg_name -- Argument name + - arg_extra -- Argument extra parameters + + Returns: + The parser object + + """ + if arg_extra: + # Retrieve current extra parameters dict + extra = parser.get_default('_extra') + if not extra or not isinstance(extra, dict): + extra = {} + + if not self.use_cache: + # Validate extra parameters for the argument + extra[arg_name] = self.extraparser.validate(arg_name, arg_extra) + else: + extra[arg_name] = arg_extra + parser.set_defaults(_extra=extra) + + return parser + + def _parse_extra_parameters(self, args): + """ + Parse arguments with their extra parameters + + Keyword arguments: + - args -- A dict of all arguments + + Return: + The parsed arguments dict + + """ + # Retrieve extra parameters for the arguments + extra = args.pop('_extra', None) + if not extra: + return args + + # Validate extra parameters for each arguments + for an, parameters in extra.items(): + args[an] = self.extraparser.parse(an, args[an], parameters) + + return args + + def _construct_parser(self, actionsmaps): + """ + Construct the parser with the actions map + + Keyword arguments: + - actionsmaps -- A dict of multi-level dictionnary of + categories/actions/arguments list for each namespaces + + Returns: + An interface relevant's parser object + + """ + # Instantiate parser + top_parser = self._parser_class() + + # Iterate over actions map namespaces + for n, actionsmap in actionsmaps.items(): + if 'general_arguments' in actionsmap: + # Parse general arguments + if top_parser.parse_general: + parser = top_parser.add_general_parser() + for an, ap in actionsmap['general_arguments'].items(): + if 'version' in ap: + ap['version'] = ap['version'].replace('%version%', + __version__) + argname = top_parser.format_arg_name(an, ap.pop('full', None)) + parser.add_argument(*argname, **ap) + del actionsmap['general_arguments'] + + # Parse categories + for cn, cp in actionsmap.items(): + if 'actions' not in cp: + continue + actions = cp.pop('actions') + + # Add category parser + if top_parser.parse_category: + cat_parser = top_parser.add_category_parser(cn, **cp) + else: + cat_parser = top_parser + + # Parse actions + if not top_parser.parse_action: + continue + for an, ap in actions.items(): + arguments = ap.pop('arguments', {}) + + # Add action parser + parser = cat_parser.add_action_parser(an, **ap) + if not parser: + continue + + # Store action information + parser.set_defaults(_info=(n, cn, an)) + + # Add action arguments + for argn, argp in arguments.items(): + name = top_parser.format_arg_name(argn, argp.pop('full', None)) + extra = argp.pop('extra', None) + + arg = parser.add_argument(*name, **argp) + parser = self._store_extra_parameters(parser, arg.dest, extra) + + return top_parser diff --git a/src/moulinette/config.py b/src/moulinette/config.py deleted file mode 100644 index 578c8ef1..00000000 --- a/src/moulinette/config.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- - -# TODO: Remove permanent debug values -import os - -# Path for the the web sessions -session_path = '/var/cache/yunohost/session' - -# Path of the actions map definition(s) -actionsmap_path = os.path.dirname(__file__) +'/../../data/actionsmap' - -# Path for the actions map cache -actionsmap_cache_path = '/var/cache/yunohost/actionsmap' - -# Path of the doc in json format -doc_json_path = os.path.dirname(__file__) +'/../../doc' diff --git a/src/moulinette/core.py b/src/moulinette/core.py new file mode 100644 index 00000000..62fe34fe --- /dev/null +++ b/src/moulinette/core.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- + +import os +import sys +import gettext +from .helpers import colorize + +class Package(object): + """Package representation and easy access + + Initialize directories and variables for the package and give them + easy access. + + Keyword arguments: + - prefix -- The installation prefix + - libdir -- The library directory; usually, this would be + prefix + '/lib' (or '/lib64') when installed + - cachedir -- The cache directory; usually, this would be + '/var/cache' when installed + - destdir -- The destination prefix only if it's an installation + + 'prefix' and 'libdir' arguments should be empty in order to run + package from source. + + """ + def __init__(self, prefix, libdir, cachedir, destdir=None): + if not prefix and not libdir: + # Running from source directory + basedir = os.path.abspath(os.path.dirname(sys.argv[0]) +'/../') + self._datadir = os.path.join(basedir, 'data') + self._libdir = os.path.join(basedir, 'src') + self._cachedir = cachedir + else: + self._datadir = os.path.join(prefix, 'share/moulinette') + self._libdir = os.path.join(libdir, 'moulinette') + self._cachedir = os.path.join(cachedir, 'moulinette') + + # Append library path to python's path + sys.path.append(self._libdir) + self._destdir = destdir or None + + + ## Easy access to directories and files + + def datadir(self, subdir=None, **kwargs): + """Return the path to a data directory""" + return self.get_dir(self._datadir, subdir, **kwargs) + + def datafile(self, filename, **kwargs): + """Return the path to a data file""" + return self.get_file(self._datadir, filename, **kwargs) + + def libdir(self, subdir=None, **kwargs): + """Return the path to a lib directory""" + return self.get_dir(self._libdir, subdir, **kwargs) + + def libfile(self, filename, **kwargs): + """Return the path to a lib file""" + return self.get_file(self._libdir, filename, **kwargs) + + def cachedir(self, subdir=None, **kwargs): + """Return the path to a cache directory""" + return self.get_dir(self._cachedir, subdir, **kwargs) + + def cachefile(self, filename, **kwargs): + """Return the path to a cache file""" + return self.get_file(self._cachedir, filename, **kwargs) + + + ## Standard methods + + def get_dir(self, basedir, subdir=None, make_dir=False): + """Get a directory path + + Return a path composed by a base directory and an optional + subdirectory. The path will be created if needed. + + Keyword arguments: + - basedir -- The base directory + - subdir -- An optional subdirectory + - make_dir -- True if it should create needed directory + + """ + # Retrieve path + path = basedir + if self._destdir: + path = os.path.join(self._destdir, path) + if subdir: + path = os.path.join(path, subdir) + + # Create directory + if make_dir and not os.path.isdir(path): + os.makedirs(path) + return path + + def get_file(self, basedir, filename, **kwargs): + """Get a file path + + Return the path of the filename in the specified directory. This + directory will be created if needed. + + Keyword arguments: + - basedir -- The base directory of the file + - filename -- The filename or a path relative to basedir + - **kwargs -- Additional arguments for Package.get_dir + + """ + # Check for a directory in filename + subdir = os.path.dirname(filename) or None + if subdir: + filename = os.path.basename(filename) + + # Get directory path + dirpath = self.get_dir(basedir, subdir, **kwargs) + return os.path.join(dirpath, filename) + + +class MoulinetteError(Exception): + """Moulinette base exception + + Keyword arguments: + - code -- Integer error code + - message -- Error message to display + + """ + def __init__(self, code, message): + self.code = code + self.message = message + + errorcode_desc = { + 1 : _('Fail'), + 13 : _('Permission denied'), + 17 : _('Already exists'), + 22 : _('Invalid arguments'), + 87 : _('Too many users'), + 111 : _('Connection refused'), + 122 : _('Quota exceeded'), + 125 : _('Operation canceled'), + 167 : _('Not found'), + 168 : _('Undefined'), + 169 : _('LDAP operation error') + } + if code in errorcode_desc: + self.desc = errorcode_desc[code] + else: + self.desc = _('Error %s' % code) + + def __str__(self, colorized=False): + desc = self.desc + if colorized: + desc = colorize(self.desc, 'red') + return _('%s: %s' % (desc, self.message)) + + def colorize(self): + return self.__str__(colorized=True) diff --git a/src/moulinette/core/actionsmap.py b/src/moulinette/core/actionsmap.py deleted file mode 100644 index 8c90d973..00000000 --- a/src/moulinette/core/actionsmap.py +++ /dev/null @@ -1,503 +0,0 @@ -# -*- coding: utf-8 -*- - -import argparse -import pickle -import yaml -import re -import os -from collections import OrderedDict - -import logging - -from .. import __version__ -from ..config import actionsmap_path, actionsmap_cache_path - -from extraparameters import extraparameters_list -from helpers import Interface, YunoHostError - -## Additional parsers - -class _HTTPArgumentParser(object): - - def __init__(self, method, uri): - # Initialize the ArgumentParser object - self._parser = argparse.ArgumentParser(usage='', - prefix_chars='@', - add_help=False) - self._parser.error = self._error - - self.method = method - self.uri = uri - - self._positional = [] # list(arg_name) - self._optional = {} # dict({arg_name: option_strings}) - - def set_defaults(self, **kwargs): - return self._parser.set_defaults(**kwargs) - - def get_default(self, dest): - return self._parser.get_default(dest) - - def add_argument(self, *args, **kwargs): - action = self._parser.add_argument(*args, **kwargs) - - # Append newly created action - if len(action.option_strings) == 0: - self._positional.append(action.dest) - else: - self._optional[action.dest] = action.option_strings - - return action - - def parse_args(self, args): - arg_strings = [] - - ## Append an argument to the current one - def append(arg_strings, value, option_string=None): - # TODO: Process list arguments - if isinstance(value, bool): - # Append the option string only - if option_string is not None: - arg_strings.append(option_string) - elif isinstance(value, str): - if option_string is not None: - arg_strings.append(option_string) - arg_strings.append(value) - else: - arg_strings.append(value) - - return arg_strings - - # Iterate over positional arguments - for dest in self._positional: - if dest in args: - arg_strings = append(arg_strings, args[dest]) - - # Iterate over optional arguments - for dest, opt in self._optional.items(): - if dest in args: - arg_strings = append(arg_strings, args[dest], opt[0]) - return self._parser.parse_args(arg_strings) - - def _error(self, message): - # TODO: Raise a proper exception - raise Exception(message) - -class HTTPParser(object): - """ - Object for parsing HTTP requests into Python objects. - - """ - - def __init__(self): - self._parsers = {} # dict({(method, uri): _HTTPArgumentParser}) - - @property - def routes(self): - """Get current routes""" - return self._parsers.keys() - - def add_parser(self, method, uri): - """ - Add a parser for a given route - - Keyword arguments: - - method -- The route's HTTP method (GET, POST, PUT, DELETE) - - uri -- The route's URI - - Returns: - A new _HTTPArgumentParser object for the route - - """ - # Check if a parser already exists for the route - key = (method, uri) - if key in self.routes: - raise ValueError("A parser for '%s' already exists" % key) - - # Create and append parser - parser = _HTTPArgumentParser(method, uri) - self._parsers[key] = parser - - # Return the created parser - return parser - - def parse_args(self, method, uri, args={}): - """ - Convert argument variables to objects and assign them as - attributes of the namespace for a given route - - Keyword arguments: - - method -- The route's HTTP method (GET, POST, PUT, DELETE) - - uri -- The route's URI - - args -- Argument variables for the route - - Returns: - The populated namespace - - """ - # Retrieve the parser for the route - key = (method, uri) - if key not in self.routes: - raise ValueError("No parser for '%s %s' found" % key) - - return self._parsers[key].parse_args(args) - -class ExtraParser(object): - """ - Global parser for the extra parameters. - - """ - def __init__(self, iface): - self.iface = iface - self.extra = OrderedDict() - - # Append available extra parameters for the current interface - for klass in extraparameters_list: - if iface in klass.skipped_iface: - continue - if klass.name in self.extra: - logging.warning("extra parameter named '%s' was already added" % klass.name) - continue - self.extra[klass.name] = klass - - def validate(self, arg_name, parameters): - """ - Validate values of extra parameters for an argument - - Keyword arguments: - - arg_name -- The argument name - - parameters -- A dict of extra parameters with their values - - """ - # Iterate over parameters to validate - for p, v in parameters.items(): - # Remove unknow parameters - if p not in self.extra.keys(): - del parameters[p] - - # Validate parameter value - parameters[p] = self.extra[p].validate(v, arg_name) - - return parameters - - def parse(self, arg_name, arg_value, parameters): - """ - Parse argument with extra parameters - - Keyword arguments: - - arg_name -- The argument name - - arg_value -- The argument value - - parameters -- A dict of extra parameters with their values - - """ - # Iterate over available parameters - for p, klass in self.extra.items(): - if p not in parameters.keys(): - continue - - # Initialize the extra parser - parser = klass(self.iface) - - # Parse the argument - if isinstance(arg_value, list): - for v in arg_value: - r = parser(parameters[p], arg_name, v) - if r not in arg_value: - arg_value.append(r) - else: - arg_value = parser(parameters[p], arg_name, arg_value) - - return arg_value - - -## Main class - -class ActionsMap(object): - """ - Validate and process action defined into the actions map. - - The actions map defines features and their usage of the main - application. It is composed by categories which contain one or more - action(s). Moreover, the action can have specific argument(s). - - Keyword arguments: - - - interface -- Interface type that requires the actions map. - Possible value is one of: - - 'cli' for the command line interface - - 'api' for an API usage (HTTP requests) - - - use_cache -- False if it should parse the actions map file - instead of using the cached one. - - """ - def __init__(self, interface, use_cache=True): - if interface not in Interface.all(): - raise ValueError(_("Invalid interface '%s'" % interface)) - self.interface = interface - self.use_cache = use_cache - - logging.debug("initializing ActionsMap for the '%s' interface" % interface) - - # Iterate over actions map namespaces - actionsmaps = {} - for n in self.get_actionsmap_namespaces(): - logging.debug("loading '%s' actions map namespace" % n) - - if use_cache: - # Attempt to load cache if it exists - cache_file = '%s/%s.pkl' % (actionsmap_cache_path, n) - if os.path.isfile(cache_file): - with open(cache_file, 'r') as f: - actionsmaps[n] = pickle.load(f) - else: - self.use_cache = False - actionsmaps = self.generate_cache() - break - else: - am_file = '%s/%s.yml' % (actionsmap_path, n) - with open(am_file, 'r') as f: - actionsmaps[n] = yaml.load(f) - - # Generate parsers - self.extraparser = ExtraParser(interface) - self.parser = self._construct_parser(actionsmaps) - - def process(self, args, route=None): - """ - Parse arguments and process the proper action - - Keyword arguments: - - args -- The arguments to parse - - route -- A tupple (method, uri) of the requested route (api only) - - """ - arguments = None - - # Parse arguments - if self.interface == Interface.cli: - arguments = self.parser.parse_args(args) - elif self.interface == Interface.api: - if route is None: - # TODO: Raise a proper exception - raise Exception(_("Missing route argument")) - method, uri = route - arguments = self.parser.parse_args(method, uri, args) - arguments = vars(arguments) - - # Parse extra parameters - arguments = self._parse_extra_parameters(arguments) - - # Retrieve action information - namespace = arguments['_info']['namespace'] - category = arguments['_info']['category'] - action = arguments['_info']['action'] - del arguments['_info'] - - module = '%s.%s' % (namespace, category) - function = '%s_%s' % (category, action) - - try: - mod = __import__(module, globals=globals(), fromlist=[function], level=0) - func = getattr(mod, function) - except (AttributeError, ImportError): - raise YunoHostError(168, _('Function is not defined')) - else: - # Process the action - return func(**arguments) - - @staticmethod - def get_actionsmap_namespaces(path=actionsmap_path): - """ - Retrieve actions map namespaces from a given path - - Returns: - A list of available namespaces - - """ - namespaces = [] - - for f in os.listdir(path): - if f.endswith('.yml'): - namespaces.append(f[:-4]) - return namespaces - - @classmethod - def generate_cache(klass): - """ - Generate cache for the actions map's file(s) - - Returns: - A dict of actions map for each namespaces - - """ - actionsmaps = {} - - if not os.path.isdir(actionsmap_cache_path): - os.makedirs(actionsmap_cache_path) - - # Iterate over actions map namespaces - for n in klass.get_actionsmap_namespaces(): - logging.debug("generating cache for '%s' actions map namespace" % n) - - # Read actions map from yaml file - am_file = '%s/%s.yml' % (actionsmap_path, n) - with open(am_file, 'r') as f: - actionsmaps[n] = yaml.load(f) - - # Cache actions map into pickle file - cache_file = '%s/%s.pkl' % (actionsmap_cache_path, n) - with open(cache_file, 'w') as f: - pickle.dump(actionsmaps[n], f) - - return actionsmaps - - - ## Private class and methods - - def _store_extra_parameters(self, parser, arg_name, arg_params): - """ - Store extra parameters for a given argument - - Keyword arguments: - - parser -- Parser object for the arguments - - arg_name -- Argument name - - arg_params -- Argument parameters - - Returns: - The parser object - - """ - if 'extra' in arg_params: - # Retrieve current extra parameters dict - extra = parser.get_default('_extra') - if not extra or not isinstance(extra, dict): - extra = {} - - if not self.use_cache: - # Validate extra parameters for the argument - extra[arg_name] = self.extraparser.validate(arg_name, arg_params['extra']) - else: - extra[arg_name] = arg_params['extra'] - parser.set_defaults(_extra=extra) - - return parser - - def _parse_extra_parameters(self, args): - """ - Parse arguments with their extra parameters - - Keyword arguments: - - args -- A dict of all arguments - - Return: - The parsed arguments dict - - """ - # Retrieve extra parameters from the arguments - if '_extra' not in args: - return args - extra = args['_extra'] - del args['_extra'] - - # Validate extra parameters for each arguments - for an, parameters in extra.items(): - args[an] = self.extraparser.parse(an, args[an], parameters) - - return args - - def _construct_parser(self, actionsmaps): - """ - Construct the parser with the actions map - - Keyword arguments: - - actionsmaps -- A dict of multi-level dictionnary of - categories/actions/arguments list for each namespaces - - Returns: - An interface relevant's parser object - - """ - top_parser = None - iface = self.interface - - # Create parser object - if iface == Interface.cli: - # TODO: Add descritpion (from __description__?) - top_parser = argparse.ArgumentParser() - top_subparsers = top_parser.add_subparsers() - elif iface == Interface.api: - top_parser = HTTPParser() - - ## Format option strings from argument parameters - def _option_strings(arg_name, arg_params): - if iface == Interface.cli: - if arg_name[0] == '-' and 'full' in arg_params: - return [arg_name, arg_params['full']] - return [arg_name] - elif iface == Interface.api: - if arg_name[0] != '-': - return [arg_name] - if 'full' in arg_params: - return [arg_params['full'].replace('--', '@', 1)] - if arg_name.startswith('--'): - return [arg_name.replace('--', '@', 1)] - return [arg_name.replace('-', '@', 1)] - - ## Remove extra parameters - def _clean_params(arg_params): - for k in {'full', 'extra'}: - if k in arg_params: - del arg_params[k] - return arg_params - - # Iterate over actions map namespaces - for n, actionsmap in actionsmaps.items(): - # Parse general arguments for the cli only - if iface == Interface.cli: - for an, ap in actionsmap['general_arguments'].items(): - if 'version' in ap: - ap['version'] = ap['version'].replace('%version%', __version__) - top_parser.add_argument(*_option_strings(an, ap), **_clean_params(ap)) - del actionsmap['general_arguments'] - - # Parse categories - for cn, cp in actionsmap.items(): - if 'actions' not in cp: - continue - - # Add category subparsers for the cli only - if iface == Interface.cli: - c_help = cp.get('category_help') - subparsers = top_subparsers.add_parser(cn, help=c_help).add_subparsers() - - # Parse actions - for an, ap in cp['actions'].items(): - parser = None - - # Add parser for the current action - if iface == Interface.cli: - a_help = ap.get('action_help') - parser = subparsers.add_parser(an, help=a_help) - elif iface == Interface.api and 'api' in ap: - # Extract method and uri - m = re.match('(GET|POST|PUT|DELETE) (/\S+)', ap['api']) - if m: - parser = top_parser.add_parser(m.group(1), m.group(2)) - if not parser: - continue - - # Store action information - parser.set_defaults(_info={'namespace': n, - 'category': cn, - 'action': an}) - - # Add arguments - if not 'arguments' in ap: - continue - for argn, argp in ap['arguments'].items(): - arg = parser.add_argument(*_option_strings(argn, argp), - **_clean_params(argp.copy())) - parser = self._store_extra_parameters(parser, arg.dest, argp) - - return top_parser diff --git a/src/moulinette/core/__init__.py b/src/moulinette/extra/__init__.py old mode 100755 new mode 100644 similarity index 100% rename from src/moulinette/core/__init__.py rename to src/moulinette/extra/__init__.py diff --git a/src/moulinette/core/extraparameters.py b/src/moulinette/extra/parameters.py similarity index 95% rename from src/moulinette/core/extraparameters.py rename to src/moulinette/extra/parameters.py index 3f4248c0..f1bf0ba9 100644 --- a/src/moulinette/core/extraparameters.py +++ b/src/moulinette/extra/parameters.py @@ -4,7 +4,8 @@ import getpass import re import logging -from helpers import Interface, colorize, YunoHostError +from .. import MoulinetteError +from ..helpers import colorize class _ExtraParameter(object): """ @@ -78,7 +79,7 @@ class AskParameter(_ExtraParameter): """ name = 'ask' - skipped_iface = {Interface.api} + skipped_iface = { 'api' } def __call__(self, message, arg_name, arg_value): # TODO: Fix asked arguments ordering @@ -119,7 +120,7 @@ class PasswordParameter(AskParameter): pwd1 = getpass.getpass(colorize(message + ': ', 'cyan')) pwd2 = getpass.getpass(colorize('Retype ' + message + ': ', 'cyan')) if pwd1 != pwd2: - raise YunoHostError(22, _("Passwords don't match")) + raise MoulinetteError(22, _("Passwords don't match")) return pwd1 class PatternParameter(_ExtraParameter): @@ -137,7 +138,7 @@ class PatternParameter(_ExtraParameter): message = arguments[1] if arg_value is not None and not re.match(pattern, arg_value): - raise YunoHostError(22, message) + raise MoulinetteError(22, message) return arg_value @staticmethod diff --git a/src/moulinette/core/helpers.py b/src/moulinette/helpers.py similarity index 63% rename from src/moulinette/core/helpers.py rename to src/moulinette/helpers.py index 3d0517aa..69efaf1f 100644 --- a/src/moulinette/core/helpers.py +++ b/src/moulinette/helpers.py @@ -21,21 +21,6 @@ import getpass if not __debug__: import traceback - -class Interface(): - """ - Contain available interfaces to use with the moulinette. - - """ - api = 'api' - cli = 'cli' - - @classmethod - def all(klass): - """Get a list of all interfaces""" - ifaces = set(i for i in dir(klass) if not i.startswith('_')) - return ifaces - win = [] def random_password(length=8): @@ -105,33 +90,6 @@ def win_msg(astr): win.append(astr) -def str_to_func(astr): - """ - Call a function from a string name - - Keyword arguments: - astr -- Name of function to call - - Returns: - Function - - """ - try: - module, _, function = astr.rpartition('.') - if module: - __import__(module) - mod = sys.modules[module] - else: - mod = sys.modules['__main__'] # default module - - func = getattr(mod, function) - except (AttributeError, ImportError): - #raise YunoHostError(168, _('Function is not defined')) - return None - else: - return func - - def validate(pattern, array): """ Validate attributes with a pattern @@ -441,115 +399,3 @@ class YunoHostLDAP(Singleton): else: raise YunoHostError(17, _('Attribute already exists') + ' "' + attr + '=' + value + '"') return True - - -def parse_dict(action_map): - """ - Turn action dictionnary to parser, subparsers and arguments - - Keyword arguments: - action_map -- Multi-level dictionnary of categories/actions/arguments list - - Returns: - Namespace of args - - """ - # Intialize parsers - parsers = subparsers_category = subparsers_action = {} - parsers['general'] = argparse.ArgumentParser() - subparsers = parsers['general'].add_subparsers() - new_args = [] - patterns = {} - - # Add general arguments - for arg_name, arg_params in action_map['general_arguments'].items(): - if 'version' in arg_params: - v = arg_params['version'] - arg_params['version'] = v.replace('%version%', __version__) - if 'full' in arg_params: - arg_names = [arg_name, arg_params['full']] - arg_fullname = arg_params['full'] - del arg_params['full'] - else: arg_names = [arg_name] - parsers['general'].add_argument(*arg_names, **arg_params) - - del action_map['general_arguments'] - - # Split categories into subparsers - for category, category_params in action_map.items(): - if 'category_help' not in category_params: category_params['category_help'] = '' - subparsers_category[category] = subparsers.add_parser(category, help=category_params['category_help']) - subparsers_action[category] = subparsers_category[category].add_subparsers() - # Split actions - if 'actions' in category_params: - for action, action_params in category_params['actions'].items(): - if 'action_help' not in action_params: action_params['action_help'] = '' - parsers[category + '_' + action] = subparsers_action[category].add_parser(action, help=action_params['action_help']) - # Set the action s related function - parsers[category + '_' + action].set_defaults( - func=str_to_func('yunohost_' + category + '.' - + category + '_' + action.replace('-', '_'))) - # Add arguments - if 'arguments' in action_params: - for arg_name, arg_params in action_params['arguments'].items(): - arg_fullname = False - - if 'password' in arg_params: - if arg_params['password']: is_password = True - del arg_params['password'] - else: is_password = False - - if 'full' in arg_params: - arg_names = [arg_name, arg_params['full']] - arg_fullname = arg_params['full'] - del arg_params['full'] - else: arg_names = [arg_name] - - if 'ask' in arg_params: - require_input = True - if '-h' in sys.argv or '--help' in sys.argv: - require_input = False - if (category != sys.argv[1]) or (action != sys.argv[2]): - require_input = False - for name in arg_names: - if name in sys.argv[2:]: require_input = False - - if require_input: - if is_password: - if os.isatty(1): - pwd1 = getpass.getpass(colorize(arg_params['ask'] + ': ', 'cyan')) - pwd2 = getpass.getpass(colorize('Retype ' + arg_params['ask'][0].lower() + arg_params['ask'][1:] + ': ', 'cyan')) - if pwd1 != pwd2: - raise YunoHostError(22, _("Passwords don't match")) - sys.exit(1) - else: - raise YunoHostError(22, _("Missing arguments") + ': ' + arg_name) - if arg_name[0] == '-': arg_extend = [arg_name, pwd1] - else: arg_extend = [pwd1] - else: - if os.isatty(1): - arg_value = raw_input(colorize(arg_params['ask'] + ': ', 'cyan')) - else: - raise YunoHostError(22, _("Missing arguments") + ': ' + arg_name) - if arg_name[0] == '-': arg_extend = [arg_name, arg_value] - else: arg_extend = [arg_value] - new_args.extend(arg_extend) - del arg_params['ask'] - - if 'pattern' in arg_params: - if (category == sys.argv[1]) and (action == sys.argv[2]): - if 'dest' in arg_params: name = arg_params['dest'] - elif arg_fullname: name = arg_fullname[2:] - else: name = arg_name - name = name.replace('-', '_') - patterns[name] = arg_params['pattern'] - del arg_params['pattern'] - - parsers[category + '_' + action].add_argument(*arg_names, **arg_params) - - args = parsers['general'].parse_args(sys.argv.extend(new_args)) - args_dict = vars(args) - for key, value in patterns.items(): - validate(value, args_dict[key]) - - return args diff --git a/src/moulinette/interface/__init__.py b/src/moulinette/interface/__init__.py new file mode 100755 index 00000000..3a99535f --- /dev/null +++ b/src/moulinette/interface/__init__.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- + +class BaseParser(object): + """Actions map's base Parser + + Each interfaces must implement a parser class derived from this + class. It is used to parse the main parts of the actions map (i.e. + general arguments, categories and actions). + + """ + + ## Optional variables + # Each parser classes can overwrite these variables. + + """Either it will parse general arguments, or not""" + parse_general = True + + """Either it will parse categories, or not""" + parse_category = True + + """Either it will parse actions, or not""" + parse_action = True + + + ## Virtual methods + # Each parser classes can implement these methods. + + @staticmethod + def format_arg_name(name, full): + """Format argument name + + Format agument name depending on its 'full' parameters and return + a list to use it as option string for the argument parser. + + Keyword arguments: + - name -- The argument name + - full -- The argument's 'full' parameter + + Returns: + A list of option strings + + """ + raise NotImplementedError("derived class '%s' must override this method" % \ + self.__class__.__name__) + + def add_general_parser(self, **kwargs): + """Add a parser for general arguments + + Create and return an argument parser for general arguments. + + Returns: + An ArgumentParser based object + + """ + if not self.parse_general: + msg = "doesn't parse general arguments" + else: + msg = "must override this method" + raise NotImplementedError("derived class '%s' %s" % \ + (self.__class__.__name__, msg)) + + def add_category_parser(self, name, **kwargs): + """Add a parser for a category + + Create a new category and return a parser for it. + + Keyword arguments: + - name -- The category name + + Returns: + A BaseParser based object + + """ + if not self.parse_categories: + msg = "doesn't parse categories" + else: + msg = "must override this method" + raise NotImplementedError("derived class '%s' %s" % \ + (self.__class__.__name__, msg)) + + def add_action_parser(self, name, **kwargs): + """Add a parser for an action + + Create a new action and return an argument parser for it. + + Keyword arguments: + - name -- The action name + + Returns: + An ArgumentParser based object + + """ + if not self.parse_general: + msg = "doesn't parse actions" + else: + msg = "must override this method" + raise NotImplementedError("derived class '%s' %s" % \ + (self.__class__.__name__, msg)) + + def parse_args(self, args, **kwargs): + """Parse arguments + + Convert argument variables to objects and assign them as + attributes of the namespace. + + Keyword arguments: + - args -- Arguments string or dict (TODO) + + Returns: + The populated namespace + + """ + raise NotImplementedError("derived class '%s' must override this method" % \ + self.__class__.__name__) diff --git a/src/moulinette/core/api.py b/src/moulinette/interface/api.py similarity index 53% rename from src/moulinette/core/api.py rename to src/moulinette/interface/api.py index 19e0af18..6c959c9f 100644 --- a/src/moulinette/core/api.py +++ b/src/moulinette/interface/api.py @@ -1,16 +1,162 @@ # -*- coding: utf-8 -*- +import re +import argparse import os.path from bottle import Bottle, request, response, HTTPResponse from beaker.middleware import SessionMiddleware -from ..config import session_path, doc_json_path -from helpers import YunoHostError, YunoHostLDAP +from . import BaseParser +from .. import MoulinetteError +from ..helpers import YunoHostError, YunoHostLDAP + +## API arguments Parser + +class _HTTPArgumentParser(object): + """Argument parser for HTTP requests + + Object for parsing HTTP requests into Python objects. It is based + on argparse.ArgumentParser class and implements some of its methods. + + """ + def __init__(self): + # Initialize the ArgumentParser object + self._parser = argparse.ArgumentParser(usage='', + prefix_chars='@', + add_help=False) + self._parser.error = self._error + + self._positional = [] # list(arg_name) + self._optional = {} # dict({arg_name: option_strings}) + + def set_defaults(self, **kwargs): + return self._parser.set_defaults(**kwargs) + + def get_default(self, dest): + return self._parser.get_default(dest) + + def add_argument(self, *args, **kwargs): + action = self._parser.add_argument(*args, **kwargs) + + # Append newly created action + if len(action.option_strings) == 0: + self._positional.append(action.dest) + else: + self._optional[action.dest] = action.option_strings + + return action + + def parse_args(self, args): + arg_strings = [] + + ## Append an argument to the current one + def append(arg_strings, value, option_string=None): + # TODO: Process list arguments + if isinstance(value, bool): + # Append the option string only + if option_string is not None: + arg_strings.append(option_string) + elif isinstance(value, str): + if option_string is not None: + arg_strings.append(option_string) + arg_strings.append(value) + else: + arg_strings.append(value) + + return arg_strings + + # Iterate over positional arguments + for dest in self._positional: + if dest in args: + arg_strings = append(arg_strings, args[dest]) + + # Iterate over optional arguments + for dest, opt in self._optional.items(): + if dest in args: + arg_strings = append(arg_strings, args[dest], opt[0]) + return self._parser.parse_args(arg_strings) + + def _error(self, message): + # TODO: Raise a proper exception + raise MoulinetteError(1, message) + +class APIParser(BaseParser): + """Actions map's API Parser + + """ + parse_category = False + parse_general = False + + def __init__(self): + self._parsers = {} # dict({(method, path): _HTTPArgumentParser}) + + @property + def routes(self): + """Get current routes""" + return self._parsers.keys() + + + ## Implement virtual methods + + @staticmethod + def format_arg_name(name, full): + if name[0] != '-': + return [name] + if full: + return [full.replace('--', '@', 1)] + if name.startswith('--'): + return [name.replace('--', '@', 1)] + return [name.replace('-', '@', 1)] + + def add_action_parser(self, name, api=None, **kwargs): + """Add a parser for an action + + Keyword arguments: + - api -- The action route (e.g. 'GET /' ) + + Returns: + A new _HTTPArgumentParser object for the route + + """ + if not api: + return None + + # Validate action route + m = re.match('(GET|POST|PUT|DELETE) (/\S+)', api) + if not m: + return None + + # Check if a parser already exists for the route + key = (m.group(1), m.group(2)) + if key in self.routes: + raise ValueError("A parser for '%s' already exists" % key) + + # Create and append parser + parser = _HTTPArgumentParser() + self._parsers[key] = parser + + # Return the created parser + return parser + + def parse_args(self, args, route, **kwargs): + """Parse arguments + + Keyword arguments: + - route -- The action route (e.g. 'GET /' ) + + """ + # Retrieve the parser for the route + if route not in self.routes: + raise MoulinetteError(22, "No parser for '%s %s' found" % key) + + return self._parsers[route].parse_args(args) + +actionsmap_parser = APIParser -## Bottle Plugins +## API moulinette interface -class APIAuthPlugin(object): +class _APIAuthPlugin(object): """ Manage the authentication for the API access. @@ -20,8 +166,7 @@ class APIAuthPlugin(object): def __init__(self): # TODO: Add options (e.g. session type, content type, ...) - if not os.path.isdir(session_path): - os.makedirs(session_path) + pass @property def app(self): @@ -44,7 +189,7 @@ class APIAuthPlugin(object): session_opts = { 'session.type': 'file', 'session.cookie_expires': True, - 'session.data_dir': session_path, + 'session.data_dir': pkg.cachedir('session', make_dir=True), 'session.secure': True } self._app = SessionMiddleware(app, session_opts) @@ -119,7 +264,7 @@ class APIAuthPlugin(object): return True return False -class ActionsMapPlugin(object): +class _ActionsMapPlugin(object): """ Process action for the request using the actions map. @@ -158,19 +303,15 @@ class ActionsMapPlugin(object): return wrapper -## Main class - class MoulinetteAPI(object): """ Initialize a HTTP server which serves the API to access to the moulinette actions. Keyword arguments: - - actionsmap -- The relevant ActionsMap instance - - routes -- A dict of additional routes to add in the form of - {(method, uri): callback} + {(method, path): callback} """ @@ -182,14 +323,14 @@ class MoulinetteAPI(object): callback=self.doc, skip=['apiauth']) # Append routes from the actions map - amap = ActionsMapPlugin(actionsmap) - for (m, u) in actionsmap.parser.routes: - app.route(u, method=m, callback=self._error, apply=amap) + amap = _ActionsMapPlugin(actionsmap) + for (m, p) in actionsmap.parser.routes: + app.route(p, method=m, callback=self._error, apply=amap) # Append additional routes # TODO: Add an option to skip auth for the route - for (m, u), c in routes.items(): - app.route(u, method=m, callback=c) + for (m, p), c in routes.items(): + app.route(p, method=m, callback=c) # Define and install a plugin which sets proper header def apiheader(callback): @@ -201,7 +342,7 @@ class MoulinetteAPI(object): app.install(apiheader) # Install authentication plugin - apiauth = APIAuthPlugin() + apiauth = _APIAuthPlugin() app.install(apiauth) self._app = apiauth.app @@ -220,11 +361,11 @@ class MoulinetteAPI(object): """ if category is None: - with open(doc_json_path +'/resources.json') as f: + with open(pkg.datafile('doc/resources.json')) as f: return f.read() try: - with open(doc_json_path +'/'+ category +'.json') as f: + with open(pkg.datafile('doc/%s.json' % category)) as f: return f.read() except IOError: return 'unknown' diff --git a/src/moulinette/interface/cli.py b/src/moulinette/interface/cli.py new file mode 100644 index 00000000..c4be4c57 --- /dev/null +++ b/src/moulinette/interface/cli.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- + +import argparse +from . import BaseParser + +## CLI arguments Parser + +class CLIParser(BaseParser): + """Actions map's CLI Parser + + """ + def __init__(self, parser=None): + self._parser = parser or argparse.ArgumentParser() + self._subparsers = self._parser.add_subparsers() + + @staticmethod + def format_arg_name(name, full): + if name[0] == '-' and full: + return [name, full] + return [name] + + def add_general_parser(self, **kwargs): + return self._parser + + def add_category_parser(self, name, category_help=None, **kwargs): + parser = self._subparsers.add_parser(name, help=category_help) + return CLIParser(parser) + + def add_action_parser(self, name, action_help, **kwargs): + return self._subparsers.add_parser(name, help=action_help) + + def parse_args(self, args, **kwargs): + return self._parser.parse_args(args) + +actionsmap_parser = CLIParser diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 03a6cd77..eeb9808b 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -37,7 +37,7 @@ from domain import domain_list, domain_add from user import user_info, user_list from hook import hook_exec, hook_add, hook_remove -from .moulinette.core.helpers import YunoHostError, YunoHostLDAP, win_msg, random_password, is_true, validate +from moulinette.helpers import YunoHostError, YunoHostLDAP, win_msg, random_password, is_true, validate repo_path = '/var/cache/yunohost/repo' apps_path = '/usr/share/yunohost/apps' diff --git a/src/yunohost/backup.py b/src/yunohost/backup.py index 0e245a62..d4723d9d 100644 --- a/src/yunohost/backup.py +++ b/src/yunohost/backup.py @@ -29,7 +29,7 @@ import json import yaml import glob -from moulinette.core.helpers import YunoHostError, YunoHostLDAP, validate, colorize, win_msg +from moulinette.helpers import YunoHostError, YunoHostLDAP, validate, colorize, win_msg def backup_init(helper=False): """ diff --git a/src/yunohost/domain.py b/src/yunohost/domain.py index 8545dacb..b10a92a7 100644 --- a/src/yunohost/domain.py +++ b/src/yunohost/domain.py @@ -34,7 +34,7 @@ import requests from urllib import urlopen from dyndns import dyndns_subscribe -from moulinette.core.helpers import YunoHostError, YunoHostLDAP, win_msg, colorize, validate, get_required_args +from moulinette.helpers import YunoHostError, YunoHostLDAP, win_msg, colorize, validate, get_required_args def domain_list(filter=None, limit=None, offset=None): diff --git a/src/yunohost/dyndns.py b/src/yunohost/dyndns.py index 200af870..1bd6b6ba 100644 --- a/src/yunohost/dyndns.py +++ b/src/yunohost/dyndns.py @@ -30,7 +30,7 @@ import json import glob import base64 -from moulinette.core.helpers import YunoHostError, YunoHostLDAP, validate, colorize, win_msg +from moulinette.helpers import YunoHostError, YunoHostLDAP, validate, colorize, win_msg def dyndns_subscribe(subscribe_host="dyndns.yunohost.org", domain=None, key=None): """ diff --git a/src/yunohost/firewall.py b/src/yunohost/firewall.py index f55d4f59..3fb68dea 100644 --- a/src/yunohost/firewall.py +++ b/src/yunohost/firewall.py @@ -37,7 +37,7 @@ except ImportError: sys.stderr.write('apt-get install python-yaml\n') sys.exit(1) -from moulinette.core.helpers import YunoHostError, win_msg +from moulinette.helpers import YunoHostError, win_msg def firewall_allow(protocol=None, port=None, ipv6=None, upnp=False): diff --git a/src/yunohost/hook.py b/src/yunohost/hook.py index 7acdc2a8..9136acfb 100644 --- a/src/yunohost/hook.py +++ b/src/yunohost/hook.py @@ -28,7 +28,7 @@ import sys import re import json -from moulinette.core.helpers import YunoHostError, YunoHostLDAP, win_msg, colorize +from moulinette.helpers import YunoHostError, YunoHostLDAP, win_msg, colorize hook_folder = '/usr/share/yunohost/hooks/' diff --git a/src/yunohost/monitor.py b/src/yunohost/monitor.py index 36b81684..cd0a22ae 100644 --- a/src/yunohost/monitor.py +++ b/src/yunohost/monitor.py @@ -37,7 +37,7 @@ from datetime import datetime, timedelta from service import (service_enable, service_disable, service_start, service_stop, service_status) -from moulinette.core.helpers import YunoHostError, win_msg +from moulinette.helpers import YunoHostError, win_msg glances_uri = 'http://127.0.0.1:61209' stats_path = '/var/lib/yunohost/stats' diff --git a/src/yunohost/service.py b/src/yunohost/service.py index 3186608d..139e1ca2 100644 --- a/src/yunohost/service.py +++ b/src/yunohost/service.py @@ -28,7 +28,7 @@ import glob import subprocess import os.path -from moulinette.core.helpers import YunoHostError, win_msg +from moulinette.helpers import YunoHostError, win_msg def service_start(names): diff --git a/src/yunohost/tools.py b/src/yunohost/tools.py index cf0408bd..dd57d47f 100644 --- a/src/yunohost/tools.py +++ b/src/yunohost/tools.py @@ -36,7 +36,7 @@ from dyndns import dyndns_subscribe from backup import backup_init from app import app_ssowatconf -from moulinette.core.helpers import YunoHostError, YunoHostLDAP, validate, colorize, get_required_args, win_msg +from moulinette.helpers import YunoHostError, YunoHostLDAP, validate, colorize, get_required_args, win_msg def tools_ldapinit(password=None): diff --git a/src/yunohost/user.py b/src/yunohost/user.py index 86c79577..b619a227 100644 --- a/src/yunohost/user.py +++ b/src/yunohost/user.py @@ -33,7 +33,7 @@ import getpass from domain import domain_list from hook import hook_callback -from moulinette.core.helpers import YunoHostError, YunoHostLDAP, win_msg, colorize, validate, get_required_args +from moulinette.helpers import YunoHostError, YunoHostLDAP, win_msg, colorize, validate, get_required_args def user_list(fields=None, filter=None, limit=None, offset=None): """