diff options
author | Rob Crittenden <rcritten@redhat.com> | 2009-01-22 10:42:41 -0500 |
---|---|---|
committer | Rob Crittenden <rcritten@redhat.com> | 2009-01-22 10:42:41 -0500 |
commit | c2967a675a288e7d31374229fd974d0cb9966f2c (patch) | |
tree | 58be8ca6319f4660d9f18b97a37b9c0c56104d02 | |
parent | 2b8b87b4d6c3b4389a0a7bf48c225035c53e7ad1 (diff) | |
parent | 5d82e3b35a8fb2d4c25f282cddad557a7650197c (diff) | |
download | freeipa-c2967a675a288e7d31374229fd974d0cb9966f2c.tar.gz freeipa-c2967a675a288e7d31374229fd974d0cb9966f2c.tar.xz freeipa-c2967a675a288e7d31374229fd974d0cb9966f2c.zip |
Merge branch 'master' of git://fedorapeople.org/~jderose/freeipa2
95 files changed, 25624 insertions, 0 deletions
diff --git a/.bzrignore b/.bzrignore new file mode 100644 index 000000000..3d1eb8741 --- /dev/null +++ b/.bzrignore @@ -0,0 +1,2 @@ +.git +freeipa2-dev-doc diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..7ed6c41dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.pyc +.bzr +freeipa2-dev-doc +build diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 000000000..f7a626160 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include LICENSE TODO lite-webui.py lite-xmlrpc.py +graft tests/ +graft ipawebui/static/ +include ipawebui/templates/*.kid @@ -0,0 +1,97 @@ +API chages before January 2009 simi-freeze: + + * Merge Param and Type together so that rather than taking the type as a + kwarg, you simply use the Type. For example, instead of: + >>> Param('number', type=Int()) + You would do this: + >>> Int('number') + The types will correspond to Python 3.0 text/binary disambiguaiton, so we + will have Bytes, Str, Int, Float, and Bool. + + * Rename crud Method base classes to standard CRUDS name: Add=>Create, + Get=>Retrieve, Mod=>Update, Del=>Delete, Find=>Search. + + * Add a Command.backend convenience attribute that checks if the class + uses_backend attribute is sets the Command.backend attribute like this: + self.backend = self.Backend[self.uses_backend] + + * Finish methods on Plugin base class for calling external commands via + subprocess. + + * Probably renamed ipa_server package to ipaserver. + + * Add special logging methods to Plugin baseclass for authorization events + (escalation, de-escalation, and denial). + + * Implement gettext service. + + * Add ability to register pre-op, post-op plugins per command. + + * Add ability to have certain args/options only active on either client-side + or server-side, and also the same for things like default_from callbacks. + + * Add ability to have a post-processing step that only gets called + client-side. It should have a signature like output_for_cli() minus the + textui argument. Need to decide whether we allow this method to modify + the return value. + + * Make Plugin base class parse class docstring into overview and + full-description strings (similar to Bazaar). + + * Removed depreciated code in config.py. + + * Remove __getattr__() from Env (and probably elsewhere) as in Python 2.4 and + 2.5 hasattr() will catch KeyboardInterrupt and SystemExit exceptions (BTW, + this has been fixed in Python 2.6). + + * Remove support for dynamic environment values from Env... Jason feels this + the Env class should be simple and static. Other mechanisms should be used + for retrieving per-request dynamic environment variables. + + +CRUD base classes: + + * The Retrieve method should add in the common Flag('all') option for + retrieving all attributes. + + * We probably need some LDAP centric crud method base classes, like + LDAPCreate, etc. Or other options it to have an LDAPObject base class and + have the crud Method plugins rely more on their corresponding Object plugin. + + * Update the Retrieve, Update, Delete, and Search classes so that the utilize + the new Param.query kwarg (to turn off validation) when cloning params. + + +Existing plugins: + + * Many existing plugins that are doing crud-type operations aren't using the + Object + Method way of defining their parameters, and are therefore defining + the exact same parameter several times in a module. This should be fixed + one way or another... if there are deficiencies in the crud base classes, + they need to be improved. + + +Command Line interface: + + * Finish textui plugin + + * Make possible Enum values self-documenting + + * All "comma-separated list of..." parameters should really be changed to + multivalue and have a flag that tells the CLI whether a multivalue should + be parsed as comma-separated. + + +Improve ease of plugin writting + - make "from ipalib import *" import everything a plugin writter will need + - Finish ipa_types, add Str and Float Types + +Packaging + - Use setuptools instead of plain distutils + - Make setup.py generate dev-docs and run unit tests + - Package for rpm (.spec file) + - Package for apt (debian/ dir) + +Migration + - Add the IPAService objectclass to existing principals + - Move existng host/ principals from cn=services to cn=computers? @@ -0,0 +1,41 @@ +#!/usr/bin/env python + +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Command Line Interface for IPA administration. + +The CLI functionality is implemented in ipalib/cli.py +""" + +import sys +from ipalib import api +from ipalib.cli import CLI + +if __name__ == '__main__': + # If we can't explicitly determin the encoding, we assume UTF-8: + if sys.stdin.encoding is None: + encoding = 'UTF-8' + else: + encoding = sys.stdin.encoding + cli = CLI(api, + (s.decode(encoding) for s in sys.argv[1:]) + ) + sys.exit(cli.run()) diff --git a/ipalib/__init__.py b/ipalib/__init__.py new file mode 100644 index 000000000..29344e182 --- /dev/null +++ b/ipalib/__init__.py @@ -0,0 +1,915 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + + +''' +Package containing the core library. + +============================= + Tutorial for Plugin Authors +============================= + +This tutorial will introduce you to writing plugins for freeIPA v2. It does +not cover every detail, but it provides enough to get you started and is +heavily cross-referenced with further documentation that (hopefully) fills +in the missing details. + +In addition to this tutorial, the many built-in plugins in `ipalib.plugins` +and `ipaserver.plugins` provide real-life examples of how to write good +plugins. + + +---------------------------- +How this tutorial is written +---------------------------- + +The code examples in this tutorial are presented as if entered into a Python +interactive interpreter session. As such, when you create a real plugin in +a source file, a few details will be different (in addition to the fact that +you will never include the ``>>>`` nor ``...`` that the interpreter places at +the beginning of each line of code). + +The tutorial examples all have this pattern: + + :: + + >>> from ipalib import Command, create_api + >>> api = create_api() + >>> class my_command(Command): + ... pass + ... + >>> api.register(my_command) + >>> api.finalize() + +In the tutorial we call `create_api()` to create an *example* instance +of `plugable.API` to work with. But a real plugin will simply use +``ipalib.api``, the standard run-time instance of `plugable.API`. + +A real plugin will have this pattern: + + :: + + from ipalib import Command, api + + class my_command(Command): + pass + api.register(my_command) + +As seen above, also note that in a real plugin you will *not* call +`plugable.API.finalize()`. When in doubt, look at some of the built-in +plugins for guidance, like those in `ipalib.plugins`. + +If you don't know what the Python *interactive interpreter* is, or are +confused about what this *Python* is in the first place, then you probably +should start with the Python tutorial: + + http://docs.python.org/tutorial/index.html + + +------------------------------------ +First steps: A simple command plugin +------------------------------------ + +Our first example will create the most basic command plugin possible. This +command will be seen in the list of command plugins, but it wont be capable +of actually doing anything yet. + +A command plugin simultaneously adds a new command that can be called through +the command-line ``ipa`` script *and* adds a new XML-RPC method... the two are +one in the same, simply invoked in different ways. + +A freeIPA plugin is a Python class, and when you create a plugin, you register +this class itself (instead of an instance of the class). To be a command +plugin, your plugin must subclass from `frontend.Command` (or from a subclass +thereof). Here is our first example: + +>>> from ipalib import Command, create_api +>>> api = create_api() +>>> class my_command(Command): # Step 1, define class +... """My example plugin.""" +... +>>> api.register(my_command) # Step 2, register class + +Notice that we are registering the ``my_command`` class itself, not an +instance of ``my_command``. + +Until `plugable.API.finalize()` is called, your plugin class has not been +instantiated nor does the ``Command`` namespace yet exist. For example: + +>>> hasattr(api, 'Command') +False +>>> api.finalize() # plugable.API.finalize() +>>> hasattr(api.Command, 'my_command') +True +>>> api.Command.my_command.doc +'My example plugin.' + +Notice that your plugin instance is accessed through an attribute named +``my_command``, the same name as your plugin class name. + + +------------------------------ +Make your command do something +------------------------------ + +This simplest way to make your example command plugin do something is to +implement a ``run()`` method, like this: + +>>> class my_command(Command): +... """My example plugin with run().""" +... +... def run(self): +... return 'My run() method was called!' +... +>>> api = create_api() +>>> api.register(my_command) +>>> api.finalize() +>>> api.Command.my_command() # Call your command +'My run() method was called!' + +When `frontend.Command.__call__()` is called, it first validates any arguments +and options your command plugin takes (if any) and then calls its ``run()`` +method. + + +------------------------ +Forwarding vs. execution +------------------------ + +However, unlike the example above, a typical command plugin will implement an +``execute()`` method instead of a ``run()`` method. Your command plugin can +be loaded in two distinct contexts: + + 1. In a *client* context - Your command plugin is only used to validate + any arguments and options it takes, and then ``self.forward()`` is + called, which forwards the call over XML-RPC to an IPA server where + the actual work is done. + + 2. In a *server* context - Your same command plugin validates any + arguments and options it takes, and then ``self.execute()`` is called, + which you should implement to perform whatever work your plugin does. + +The base `frontend.Command.run()` method simply dispatches the call to +``self.execute()`` if ``self.env.in_server`` is True, or otherwise +dispatches the call to ``self.forward()``. + +For example, say you have a command plugin like this: + +>>> class my_command(Command): +... """Forwarding vs. execution.""" +... +... def forward(self): +... return 'in_server=%r; forward() was called.' % self.env.in_server +... +... def execute(self): +... return 'in_server=%r; execute() was called.' % self.env.in_server +... + +If ``my_command`` is loaded in a *client* context, ``forward()`` will be +called: + +>>> api = create_api() +>>> api.env.in_server = False # run() will dispatch to forward() +>>> api.register(my_command) +>>> api.finalize() +>>> api.Command.my_command() # Call your command plugin +'in_server=False; forward() was called.' + +On the other hand, if ``my_command`` is loaded in a *server* context, +``execute()`` will be called: + +>>> api = create_api() +>>> api.env.in_server = True # run() will dispatch to execute() +>>> api.register(my_command) +>>> api.finalize() +>>> api.Command.my_command() # Call your command plugin +'in_server=True; execute() was called.' + +Normally there should be no reason to override `frontend.Command.forward()`, +but, as above, it can be done for demonstration purposes. In contrast, there +*is* a reason you might want to override `frontend.Command.run()`: if it only +makes sense to execute your command locally, if it should never be forwarded +to the server. In this case, you should implement your *do-stuff* in the +``run()`` method instead of in the ``execute()`` method. + +For example, the ``ipa`` command line script has a ``help`` command +(`ipalib.cli.help`) that is specific to the command-line-interface and should +never be forwarded to the server. + + +--------------- +Backend plugins +--------------- + +There are two types of plugins: + + 1. *Frontend plugins* - These are loaded in both the *client* and *server* + contexts. These need to be installed with any application built atop + the `ipalib` library. The built-in frontend plugins can be found in + `ipalib.plugins`. The ``my_command`` example above is a frontend + plugin. + + 2. *Backend plugins* - These are only loaded in a *server* context and + only need to be installed on the IPA server. The built-in backend + plugins can be found in `ipaserver.plugins`. + +Backend plugins should provide a set of methods that standardize how IPA +interacts with some external system or library. For example, all interaction +with LDAP is done through the ``ldap`` backend plugin defined in +`ipaserver.plugins.b_ldap`. As a good rule of thumb, anytime you need to +import some package that is not part of the Python standard library, you +should probably interact with that package via a corresponding backend +plugin you implement. + +Backend plugins are much more free-form than command plugins. Aside from a +few reserved attribute names, you can define arbitrary public methods on your +backend plugin (in contrast, frontend plugins get wrapped in a +`plugable.PluginProxy`, which allow access to only specific attributes on the +frontend plugin). + +Here is a simple example: + +>>> from ipalib import Backend +>>> class my_backend(Backend): +... """My example backend plugin.""" +... +... def do_stuff(self): +... """Part of your API.""" +... return 'Stuff got done.' +... +>>> api = create_api() +>>> api.register(my_backend) +>>> api.finalize() +>>> api.Backend.my_backend.do_stuff() +'Stuff got done.' + + +------------------------------- +How your command should do work +------------------------------- + +We now return to our ``my_command`` plugin example. + +Plugins are separated into frontend and backend plugins so that there are not +unnecessary dependencies required by an application that only uses `ipalib` and +its built-in frontend plugins (and then forwards over XML-RPC for execution). + +But how do we avoid introducing additional dependencies? For example, the +``user_add`` command needs to talk to LDAP to add the user, yet we want to +somehow load the ``user_add`` plugin on client machines without requiring the +``python-ldap`` package (Python bindings to openldap) to be installed. To +answer that, we consult our golden rule: + + **The golden rule:** A command plugin should implement its ``execute()`` + method strictly via calls to methods on one or more backend plugins. + +So the module containing the ``user_add`` command does not itself import the +Python LDAP bindings, only the module containing the ``ldap`` backend plugin +does that, and the backend plugins are only installed on the server. The +``user_add.execute()`` method, which is only called when in a server context, +is implemented as a series of calls to methods on the ``ldap`` backend plugin. + +When `plugable.Plugin.set_api()` is called, each plugin stores a reference to +the `plugable.API` instance it has been loaded into. So your plugin can +access the ``my_backend`` plugin as ``self.api.Backend.my_backend``. + +Additionally, convenience attributes are set for each namespace, so your +plugin can also access the ``my_backend`` plugin as simply +``self.Backend.my_backend``. + +This next example will tie everything together. First we create our backend +plugin: + +>>> api = create_api() +>>> api.env.in_server = True # We want to execute, not forward +>>> class my_backend(Backend): +... """My example backend plugin.""" +... +... def do_stuff(self): +... """my_command.execute() calls this.""" +... return 'my_backend.do_stuff() indeed did do stuff!' +... +>>> api.register(my_backend) + +Second, we have our frontend plugin, the command: + +>>> class my_command(Command): +... """My example command plugin.""" +... +... def execute(self): +... """Implemented against Backend.my_backend""" +... return self.Backend.my_backend.do_stuff() +... +>>> api.register(my_command) + +Lastly, we call ``api.finalize()`` and see what happens when we call +``my_command()``: + +>>> api.finalize() +>>> api.Command.my_command() +'my_backend.do_stuff() indeed did do stuff!' + +When not in a server context, ``my_command.execute()`` never gets called, so +it never tries to access the non-existent backend plugin at +``self.Backend.my_backend.`` To emphasize this point, here is one last +example: + +>>> api = create_api() +>>> api.env.in_server = False # We want to forward, not execute +>>> class my_command(Command): +... """My example command plugin.""" +... +... def execute(self): +... """Same as above.""" +... return self.Backend.my_backend.do_stuff() +... +... def forward(self): +... return 'Just my_command.forward() getting called here.' +... +>>> api.register(my_command) +>>> api.finalize() + +Notice that the ``my_backend`` plugin has certainly not be registered: + +>>> hasattr(api.Backend, 'my_backend') +False + +And yet we can call ``my_command()``: + +>>> api.Command.my_command() +'Just my_command.forward() getting called here.' + + +---------------------------------------- +Calling other commands from your command +---------------------------------------- + +It can be useful to have your ``execute()`` method call other command plugins. +Among other things, this allows for meta-commands that conveniently call +several other commands in a single operation. For example: + +>>> api = create_api() +>>> api.env.in_server = True # We want to execute, not forward +>>> class meta_command(Command): +... """My meta-command plugin.""" +... +... def execute(self): +... """Calls command_1(), command_2()""" +... return '%s; %s.' % ( +... self.Command.command_1(), +... self.Command.command_2() +... ) +>>> class command_1(Command): +... def execute(self): +... return 'command_1.execute() called' +... +>>> class command_2(Command): +... def execute(self): +... return 'command_2.execute() called' +... +>>> api.register(meta_command) +>>> api.register(command_1) +>>> api.register(command_2) +>>> api.finalize() +>>> api.Command.meta_command() +'command_1.execute() called; command_2.execute() called.' + +Because this is quite useful, we are going to revise our golden rule somewhat: + + **The revised golden rule:** A command plugin should implement its + ``execute()`` method strictly via what it can access through ``self.api``, + most likely via the backend plugins in ``self.api.Backend`` (which can also + be conveniently accessed as ``self.Backend``). + + +----------------------------------------------- +Defining arguments and options for your command +----------------------------------------------- + +You can define a command that will accept specific arguments and options. +For example: + +>>> from ipalib import Str +>>> class nudge(Command): +... """Takes one argument, one option""" +... +... takes_args = ['programmer'] +... +... takes_options = [Str('stuff', default=u'documentation')] +... +... def execute(self, programmer, **kw): +... return '%s, go write more %s!' % (programmer, kw['stuff']) +... +>>> api = create_api() +>>> api.env.in_server = True +>>> api.register(nudge) +>>> api.finalize() +>>> api.Command.nudge(u'Jason') +u'Jason, go write more documentation!' +>>> api.Command.nudge(u'Jason', stuff=u'unit tests') +u'Jason, go write more unit tests!' + +The ``args`` and ``options`` attributes are `plugable.NameSpace` instances +containing a command's arguments and options, respectively, as you can see: + +>>> list(api.Command.nudge.args) # Iterates through argument names +['programmer'] +>>> api.Command.nudge.args.programmer +Str('programmer') +>>> list(api.Command.nudge.options) # Iterates through option names +['stuff'] +>>> api.Command.nudge.options.stuff +Str('stuff', default=u'documentation') +>>> api.Command.nudge.options.stuff.default +u'documentation' + +The arguments and options must not contain colliding names. They are both +merged together into the ``params`` attribute, another `plugable.NameSpace` +instance, as you can see: + +>>> api.Command.nudge.params +NameSpace(<2 members>, sort=False) +>>> list(api.Command.nudge.params) # Iterates through the param names +['programmer', 'stuff'] + +When calling a command, its positional arguments can also be provided as +keyword arguments, and in any order. For example: + +>>> api.Command.nudge(stuff=u'lines of code', programmer=u'Jason') +u'Jason, go write more lines of code!' + +When a command plugin is called, the values supplied for its parameters are +put through a sophisticated processing pipeline that includes steps for +normalization, type conversion, validation, and dynamically constructing +the defaults for missing values. The details wont be covered here; however, +here is a quick teaser: + +>>> from ipalib import Int +>>> class create_player(Command): +... takes_options = [ +... 'first', +... 'last', +... Str('nick', +... normalizer=lambda value: value.lower(), +... default_from=lambda first, last: first[0] + last, +... ), +... Int('points', default=0), +... ] +... +>>> cp = create_player() +>>> cp.finalize() +>>> cp.convert(points=u' 1000 ') +{'points': 1000} +>>> cp.normalize(nick=u'NickName') +{'nick': u'nickname'} +>>> cp.get_default(first=u'Jason', last=u'DeRose') +{'nick': u'jderose', 'points': 0} + +For the full details on the parameter system, see the +`frontend.parse_param_spec()` function, and the `frontend.Param` and +`frontend.Command` classes. + + +--------------------------------------- +Allowed return values from your command +--------------------------------------- + +The return values from your command can be rendered by different user +interfaces (CLI, web-UI); furthermore, a call to your command can be +transparently forwarded over the network (XML-RPC, JSON). As such, the return +values from your command must be usable by the least common denominator. + +Your command should return only simple data types and simple data structures, +the kinds that can be represented in an XML-RPC request or in the JSON format. +The return values from your command's ``execute()`` method can include only +the following: + + Simple scalar values: + These can be ``str``, ``unicode``, ``int``, and ``float`` instances, + plus the ``True``, ``False``, and ``None`` constants. + + Simple compound values: + These can be ``dict``, ``list``, and ``tuple`` instances. These + compound values must contain only the simple scalar values above or + other simple compound values. These compound values can also be empty. + For our purposes here, the ``list`` and ``tuple`` types are equivalent + and can be used interchangeably. + +Also note that your ``execute()`` method should not contain any ``print`` +statements or otherwise cause any output on ``sys.stdout``. Your command can +(and should) produce log messages by using ``self.log`` (see below). + +To learn more about XML-RPC (XML Remote Procedure Call), see: + + http://docs.python.org/library/xmlrpclib.html + + http://en.wikipedia.org/wiki/XML-RPC + +To learn more about JSON (Java Script Object Notation), see: + + http://docs.python.org/library/json.html + + http://www.json.org/ + + +--------------------------------------- +How your command should print to stdout +--------------------------------------- + +As noted above, your command should not print anything while in its +``execute()`` method. So how does your command format its output when +called from the ``ipa`` script? + +After the `cli.CLI.run_cmd()` method calls your command, it will call your +command's ``output_for_cli()`` method (if you have implemented one). + +If you implement an ``output_for_cli()`` method, it must have the following +signature: + + :: + + output_for_cli(textui, result, *args, **options) + + textui + An object implementing methods for outputting to the console. + Currently the `ipalib.cli.textui` plugin is passed, which your method + can also access as ``self.Backend.textui``. However, in case this + changes in the future, your method should use the instance passed to + it in this first argument. + + result + This is the return value from calling your command plugin. Depending + upon how your command is implemented, this is probably the return + value from your ``execute()`` method. + + args + The arguments your command was called with. If your command takes no + arguments, you can omit this. You can also explicitly list your + arguments rather than using the generic ``*args`` form. + + options + The options your command was called with. If your command takes no + options, you can omit this. If your command takes any options, you + must use the ``**options`` form as they will be provided strictly as + keyword arguments. + +For example, say we setup a command like this: + +>>> class show_items(Command): +... +... takes_args = ['key?'] +... +... takes_options = [Flag('reverse')] +... +... def execute(self, key, **options): +... items = dict( +... fruit='apple', +... pet='dog', +... city='Berlin', +... ) +... if key in items: +... return items[key] +... return [ +... (k, items[k]) for k in sorted(items, reverse=options['reverse']) +... ] +... +... def output_for_cli(self, textui, result, key, **options): +... if key is not None: +... textui.print_plain('%s = %r' % (key, result)) +... else: +... textui.print_name(self.name) +... textui.print_keyval(result) +... format = '%d items' +... if options['reverse']: +... format += ' (in reverse order)' +... textui.print_count(result, format) +... +>>> api = create_api() +>>> api.env.in_server = True # We want to execute, not forward. +>>> api.register(show_items) +>>> api.finalize() + +Normally when you invoke the ``ipa`` script, `cli.CLI.load_plugins()` will +register the `cli.textui` backend plugin, but for the sake of our example, +we will just create an instance here: + +>>> from ipalib import cli +>>> textui = cli.textui() # We'll pass this to output_for_cli() + +Now for what we are concerned with in this example, calling your command +through the ``ipa`` script basically will do the following: + +>>> result = api.Command.show_items() +>>> api.Command.show_items.output_for_cli(textui, result, None, reverse=False) +----------- +show-items: +----------- + city = 'Berlin' + fruit = 'apple' + pet = 'dog' +------- +3 items +------- + +Similarly, calling it with ``reverse=True`` would result in the following: + +>>> result = api.Command.show_items(reverse=True) +>>> api.Command.show_items.output_for_cli(textui, result, None, reverse=True) +----------- +show-items: +----------- + pet = 'dog' + fruit = 'apple' + city = 'Berlin' +-------------------------- +3 items (in reverse order) +-------------------------- + +Lastly, providing a ``key`` would result in the following: + +>>> result = api.Command.show_items(u'city') +>>> api.Command.show_items.output_for_cli(textui, result, 'city', reverse=False) +city = 'Berlin' + +See the `ipalib.cli.textui` plugin for a description of its methods. + + +------------------------ +Logging from your plugin +------------------------ + +After `plugable.Plugin.set_api()` is called, your plugin will have a +``self.log`` attribute. Plugins should only log through this attribute. +For example: + +>>> class paint_house(Command): +... +... takes_args = ['color'] +... +... def execute(self, color): +... """Uses self.log.error()""" +... if color not in ('red', 'blue', 'green'): +... self.log.error("I don't have %s paint!", color) # Log error +... return +... return 'I painted the house %s.' % color +... + +Some basic knowledge of the Python ``logging`` module might be helpful. See: + + http://docs.python.org/library/logging.html + +The important thing to remember is that your plugin should not configure +logging itself, but should instead simply use the ``self.log`` logger. + +Also see the `plugable.API.bootstrap()` method for details on how the logging +is configured. + + +--------------------- +Environment variables +--------------------- + +Plugins access configuration variables and run-time information through +``self.api.env`` (or for convenience, ``self.env`` is equivalent). This +attribute is a refences to the `ipalib.config.Env` instance created in +`plugable.API.__init__()`. + +After `API.bootstrap()` has been called, the `Env` instance will be populated +with all the environment information used by the built-in plugins. +This will be called before any plugins are registered, so plugin authors can +assume these variables will all exist by the time the module containing their +plugin (or plugins) is imported. For example: + +>>> api = create_api() +>>> len(api.env) +1 +>>> api.bootstrap(in_server=True) # We want to execute, not forward +>>> len(api.env) +35 + +`Env._bootstrap()`, which is called by `API.bootstrap()`, will create several +run-time variables that connot be overriden in configuration files or through +command-line options. Here is an overview of this run-time information: + +============= ============================= ======================= +Key Example value Description +============= ============================= ======================= +bin '/usr/bin' Dir. containing script +dot_ipa '/home/jderose/.ipa' User config directory +home os.environ['HOME'] User home dir. +ipalib '.../site-packages/ipalib' Dir. of ipalib package +mode 'unit_test' The mode ipalib is in +script sys.argv[0] Path of script +site_packages '.../python2.5/site-packages' Dir. containing ipalib/ +============= ============================= ======================= + +If your plugin requires new environment variables *and* will be included in +the freeIPA built-in plugins, you should add the defaults for your variables +in `ipalib.constants.DEFAULT_CONFIG`. Also, you should consider whether your +new environment variables should have any auto-magic logic to determine their +values if they haven't already been set by the time `config.Env._bootstrap()`, +`config.Env._finalize_core()`, or `config.Env._finalize()` is called. + +On the other hand, if your plugin requires new environment variables and will +be installed in a 3rd-party package, your plugin should set these variables +in the module it is defined in. + +`config.Env` values work on a first-one-wins basis... after a value has been +set, it can not be overridden with a new value. As any variables can be set +using the command-line ``-e`` global option or set in a configuration file, +your module must check whether a variable has already been set before +setting its default value. For example: + +>>> if 'message_of_the_day' not in api.env: +... api.env.message_of_the_day = 'Hello, world!' +... + +Your plugin can access any environment variables via ``self.env``. +For example: + +>>> class motd(Command): +... """Print message of the day.""" +... +... def execute(self): +... return self.env.message_of_the_day +... +>>> api.register(motd) +>>> api.finalize() +>>> api.Command.motd() +'Hello, world!' + +Also see the `plugable.API.bootstrap_with_global_options()` method. + + +--------------------------------------------- +Indispensable ipa script commands and options +--------------------------------------------- + +The ``console`` command will launch a custom interactive Python interpreter +session. The global environment will have an ``api`` variable, which is the +standard `plugable.API` instance found at ``ipalib.api``. All plugins will +have been loaded (well, except the backend plugins if ``in_server`` is False) +and ``api`` will be fully initialized. To launch the console from within the +top-level directory in the the source tree, just run ``ipa console`` from a +terminal, like this: + + :: + + $ ./ipa console + +By default, ``in_server`` is False. If you want to start the console in a +server context (so that all the backend plugins are loaded), you can use the +``-e`` option to set the ``in_server`` environment variable, like this: + + :: + + $ ./ipa -e in_server=True console + +You can specify multiple environment variables by including the ``-e`` option +multiple times, like this: + + :: + + $ ./ipa -e in_server=True -e mode=dummy console + +The space after the ``-e`` is optional. This is equivalent to the above command: + + :: + + $ ./ipa -ein_server=True -emode=dummy console + +The ``env`` command will print out the full environment in key=value pairs, +like this: + + :: + + $ ./ipa env + +If you use the ``--server`` option, it will forward the call to the server +over XML-RPC and print out what the environment is on the server, like this: + + :: + + $ ./ipa env --server + +The ``plugins`` command will show details of all the plugin that are loaded, +like this: + + :: + + $ ./ipa plugins + + +----------------------------------- +Learning more about freeIPA plugins +----------------------------------- + +To learn more about writing freeIPA plugins, you should: + + 1. Look at some of the built-in plugins, like the frontend plugins in + `ipalib.plugins.f_user` and the backend plugins in + `ipaserver.plugins.b_ldap`. + + 2. Learn about the base classes for frontend plugins in `ipalib.frontend`. + + 3. Learn about the core plugin framework in `ipalib.plugable`. + +Furthermore, the freeIPA plugin architecture was inspired by the Bazaar plugin +architecture. Although the two are different enough that learning how to +write plugins for Bazaar will not particularly help you write plugins for +freeIPA, some might be interested in the documentation on writing plugins for +Bazaar, available here: + + http://bazaar-vcs.org/WritingPlugins + +If nothing else, we just want to give credit where credit is deserved! +However, freeIPA does not use any *code* from Bazaar... it merely borrows a +little inspiration. + + +-------------------------- +A note on docstring markup +-------------------------- + +Lastly, a quick note on markup: All the Python docstrings in freeIPA v2 +(including this tutorial) use the *reStructuredText* markup language. For +information on reStructuredText, see: + + http://docutils.sourceforge.net/rst.html + +For information on using reStructuredText markup with epydoc, see: + + http://epydoc.sourceforge.net/manual-othermarkup.html + + +-------------------------------------------------- +Next steps: get involved with freeIPA development! +-------------------------------------------------- + +The freeIPA team is always interested in feedback and contribution from the +community. To get involved with freeIPA, see the *Contribute* page on +freeIPA.org: + + http://freeipa.org/page/Contribute + +''' + +import plugable +from backend import Backend, Context +from frontend import Command, LocalOrRemote, Application +from frontend import Object, Method, Property +from parameters import DefaultFrom, Bool, Flag, Int, Float, Bytes, Str, Password +from parameters import BytesEnum, StrEnum + +try: + import uuid +except ImportError: + import ipauuid as uuid + +def create_api(mode='dummy'): + """ + Return standard `plugable.API` instance. + + This standard instance allows plugins that subclass from the following + base classes: + + - `frontend.Command` + + - `frontend.Object` + + - `frontend.Method` + + - `frontend.Property` + + - `frontend.Application` + + - `backend.Backend` + + - `backend.Context` + """ + api = plugable.API( + Command, Object, Method, Property, Application, + Backend, Context, + ) + if mode is not None: + api.env.mode = mode + return api + + +api = create_api(mode=None) diff --git a/ipalib/aci.py b/ipalib/aci.py new file mode 100755 index 000000000..9dde767c0 --- /dev/null +++ b/ipalib/aci.py @@ -0,0 +1,245 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +import shlex +import re +import ldap + +# The Python re module doesn't do nested parenthesis + +# Break the ACI into 3 pieces: target, name, permissions/bind_rules +ACIPat = re.compile(r'\s*(\(.*\)+)\s*\(version\s+3.0\s*;\s*acl\s+\"(.*)\"\s*;\s*(.*);\)') + +# Break the permissions/bind_rules out +PermPat = re.compile(r'(\w+)\s*\((.*)\)\s+(.*)') + + +class ACI: + """ + Holds the basic data for an ACI entry, as stored in the cn=accounts + entry in LDAP. Has methods to parse an ACI string and export to an + ACI String. + """ + + # Don't allow arbitrary attributes to be set in our __setattr__ implementation. + _objectattrs = ["name", "orig_acistr", "target", "action", "permissions", + "bindrule"] + + __actions = ["allow", "deny"] + + __permissions = ["read", "write", "add", "delete", "search", "compare", + "selfwrite", "proxy", "all"] + + def __init__(self,acistr=None): + self.name = None + self.orig_acistr = acistr + self.target = {} + self.action = "allow" + self.permissions = ["write"] + self.bindrule = None + if acistr is not None: + self._parse_acistr(acistr) + + def __getitem__(self,key): + """Fake getting attributes by key for sorting""" + if key == 0: + return self.name + if key == 1: + return self.source_group + if key == 2: + return self.dest_group + raise TypeError("Unknown key value %s" % key) + + def __repr__(self): + """An alias for export_to_string()""" + return self.export_to_string() + + def __getattr__(self, name): + """ + Backward compatibility for the old ACI class. + + The following extra attributes are available: + + - source_group + - dest_group + - attrs + """ + if name == 'source_group': + group = '' + dn = self.bindrule.split('=',1) + if dn[0] == "groupdn": + group = self._remove_quotes(dn[1]) + if group.startswith("ldap:///"): + group = group[8:] + return group + if name == 'dest_group': + group = self.target.get('targetfilter', '') + if group: + g = group.split('=',1)[1] + if g.endswith(')'): + g = g[:-1] + return g + return '' + if name == 'attrs': + return self.target.get('targetattr', None) + raise AttributeError, "object has no attribute '%s'" % name + + def __setattr__(self, name, value): + """ + Backward compatibility for the old ACI class. + + The following extra attributes are available: + - source_group + - dest_group + - attrs + """ + if name == 'source_group': + self.__dict__['bindrule'] = 'groupdn="ldap:///%s"' % value + elif name == 'dest_group': + if value.startswith('('): + self.__dict__['target']['targetfilter'] = 'memberOf=%s' % value + else: + self.__dict__['target']['targetfilter'] = '(memberOf=%s)' % value + elif name == 'attrs': + self.__dict__['target']['targetattr'] = value + elif name in self._objectattrs: + self.__dict__[name] = value + else: + raise AttributeError, "object has no attribute '%s'" % name + + def export_to_string(self): + """Output a Directory Server-compatible ACI string""" + self.validate() + aci = "" + for t in self.target: + if isinstance(self.target[t], list): + target = "" + for l in self.target[t]: + target = target + l + " || " + target = target[:-4] + aci = aci + "(%s=\"%s\")" % (t, target) + else: + aci = aci + "(%s=\"%s\")" % (t, self.target[t]) + aci = aci + "(version 3.0;acl \"%s\";%s (%s) %s" % (self.name, self.action, ",".join(self.permissions), self.bindrule) + ";)" + return aci + + def _remove_quotes(self, s): + # Remove leading and trailing quotes + if s.startswith('"'): + s = s[1:] + if s.endswith('"'): + s = s[:-1] + return s + + def _parse_target(self, aci): + lexer = shlex.shlex(aci) + lexer.wordchars = lexer.wordchars + "." + + l = [] + + var = False + for token in lexer: + # We should have the form (a = b)(a = b)... + if token == "(": + var = lexer.next().strip() + operator = lexer.next() + if operator != "=" and operator != "!=": + raise SyntaxError('No operator in target, got %s' % operator) + val = lexer.next().strip() + val = self._remove_quotes(val) + end = lexer.next() + if end != ")": + raise SyntaxError('No end parenthesis in target, got %s' % end) + + if var == 'targetattr': + # Make a string of the form attr || attr || ... into a list + t = re.split('[\W]+', val) + self.target[var] = t + else: + self.target[var] = val + + def _parse_acistr(self, acistr): + acimatch = ACIPat.match(acistr) + if not acimatch or len(acimatch.groups()) < 3: + raise SyntaxError, "malformed ACI" + self._parse_target(acimatch.group(1)) + self.name = acimatch.group(2) + bindperms = PermPat.match(acimatch.group(3)) + if not bindperms or len(bindperms.groups()) < 3: + raise SyntaxError, "malformed ACI" + self.action = bindperms.group(1) + self.permissions = bindperms.group(2).split(',') + self.bindrule = bindperms.group(3) + + def validate(self): + """Do some basic verification that this will produce a + valid LDAP ACI. + + returns True if valid + """ + if not isinstance(self.permissions, list): + raise SyntaxError, "permissions must be a list" + for p in self.permissions: + if not p.lower() in self.__permissions: + raise SyntaxError, "invalid permission: '%s'" % p + if not self.name: + raise SyntaxError, "name must be set" + if not isinstance(self.name, basestring): + raise SyntaxError, "name must be a string" + if not isinstance(self.target, dict) or len(self.target) == 0: + raise SyntaxError, "target must be a non-empty dictionary" + return True + +def extract_group_cns(aci_list, client): + """Extracts all the cn's from a list of aci's and returns them as a hash + from group_dn to group_cn. + + It first tries to cheat by looking at the first rdn for the + group dn. If that's not cn for some reason, it looks up the group.""" + group_dn_to_cn = {} + for aci in aci_list: + for dn in (aci.source_group, aci.dest_group): + if not group_dn_to_cn.has_key(dn): + rdn_list = ldap.explode_dn(dn, 0) + first_rdn = rdn_list[0] + (type,value) = first_rdn.split('=') + if type == "cn": + group_dn_to_cn[dn] = value + else: + try: + group = client.get_entry_by_dn(dn, ['cn']) + group_dn_to_cn[dn] = group.getValue('cn') + except ipaerror.IPAError, e: + group_dn_to_cn[dn] = 'unknown' + + return group_dn_to_cn + +if __name__ == '__main__': + # Pass in an ACI as a string + a = ACI('(targetattr="title")(targetfilter="(memberOf=cn=bar,cn=groups,cn=accounts ,dc=example,dc=com)")(version 3.0;acl "foobar";allow (write) groupdn="ldap:///cn=foo,cn=groups,cn=accounts,dc=example,dc=com";)') + print a + + # Create an ACI in pieces + a = ACI() + a.name ="foobar" + a.source_group="cn=foo,cn=groups,dc=example,dc=org" + a.dest_group="cn=bar,cn=groups,dc=example,dc=org" + a.attrs = ['title'] + a.permissions = ['read','write','add'] + print a diff --git a/ipalib/backend.py b/ipalib/backend.py new file mode 100644 index 000000000..b1e15f337 --- /dev/null +++ b/ipalib/backend.py @@ -0,0 +1,45 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Base classes for all backed-end plugins. +""" + +import plugable + + +class Backend(plugable.Plugin): + """ + Base class for all backend plugins. + """ + + __proxy__ = False # Backend plugins are not wrapped in a PluginProxy + + +class Context(plugable.Plugin): + """ + Base class for plugable context components. + """ + + __proxy__ = False # Backend plugins are not wrapped in a PluginProxy + + def get_value(self): + raise NotImplementedError( + '%s.get_value()' % self.__class__.__name__ + ) diff --git a/ipalib/base.py b/ipalib/base.py new file mode 100644 index 000000000..bff8f1951 --- /dev/null +++ b/ipalib/base.py @@ -0,0 +1,486 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Foundational classes and functions. +""" + +import re +from constants import NAME_REGEX, NAME_ERROR +from constants import TYPE_ERROR, SET_ERROR, DEL_ERROR, OVERRIDE_ERROR + + +class ReadOnly(object): + """ + Base class for classes that can be locked into a read-only state. + + Be forewarned that Python does not offer true read-only attributes for + user-defined classes. Do *not* rely upon the read-only-ness of this + class for security purposes! + + The point of this class is not to make it impossible to set or to delete + attributes after an instance is locked, but to make it impossible to do so + *accidentally*. Rather than constantly reminding our programmers of things + like, for example, "Don't set any attributes on this ``FooBar`` instance + because doing so wont be thread-safe", this class offers a real way to + enforce read-only attribute usage. + + For example, before a `ReadOnly` instance is locked, you can set and delete + its attributes as normal: + + >>> class Person(ReadOnly): + ... pass + ... + >>> p = Person() + >>> p.name = 'John Doe' + >>> p.phone = '123-456-7890' + >>> del p.phone + + But after an instance is locked, you cannot set its attributes: + + >>> p.__islocked__() # Is this instance locked? + False + >>> p.__lock__() # This will lock the instance + >>> p.__islocked__() + True + >>> p.department = 'Engineering' + Traceback (most recent call last): + ... + AttributeError: locked: cannot set Person.department to 'Engineering' + + Nor can you deleted its attributes: + + >>> del p.name + Traceback (most recent call last): + ... + AttributeError: locked: cannot delete Person.name + + However, as noted at the start, there are still obscure ways in which + attributes can be set or deleted on a locked `ReadOnly` instance. For + example: + + >>> object.__setattr__(p, 'department', 'Engineering') + >>> p.department + 'Engineering' + >>> object.__delattr__(p, 'name') + >>> hasattr(p, 'name') + False + + But again, the point is that a programmer would never employ the above + techniques *accidentally*. + + Lastly, this example aside, you should use the `lock()` function rather + than the `ReadOnly.__lock__()` method. And likewise, you should + use the `islocked()` function rather than the `ReadOnly.__islocked__()` + method. For example: + + >>> readonly = ReadOnly() + >>> islocked(readonly) + False + >>> lock(readonly) is readonly # lock() returns the instance + True + >>> islocked(readonly) + True + """ + + __locked = False + + def __lock__(self): + """ + Put this instance into a read-only state. + + After the instance has been locked, attempting to set or delete an + attribute will raise an AttributeError. + """ + assert self.__locked is False, '__lock__() can only be called once' + self.__locked = True + + def __islocked__(self): + """ + Return True if instance is locked, otherwise False. + """ + return self.__locked + + def __setattr__(self, name, value): + """ + If unlocked, set attribute named ``name`` to ``value``. + + If this instance is locked, an AttributeError will be raised. + + :param name: Name of attribute to set. + :param value: Value to assign to attribute. + """ + if self.__locked: + raise AttributeError( + SET_ERROR % (self.__class__.__name__, name, value) + ) + return object.__setattr__(self, name, value) + + def __delattr__(self, name): + """ + If unlocked, delete attribute named ``name``. + + If this instance is locked, an AttributeError will be raised. + + :param name: Name of attribute to delete. + """ + if self.__locked: + raise AttributeError( + DEL_ERROR % (self.__class__.__name__, name) + ) + return object.__delattr__(self, name) + + +def lock(instance): + """ + Lock an instance of the `ReadOnly` class or similar. + + This function can be used to lock instances of any class that implements + the same locking API as the `ReadOnly` class. For example, this function + can lock instances of the `config.Env` class. + + So that this function can be easily used within an assignment, ``instance`` + is returned after it is locked. For example: + + >>> readonly = ReadOnly() + >>> readonly is lock(readonly) + True + >>> readonly.attr = 'This wont work' + Traceback (most recent call last): + ... + AttributeError: locked: cannot set ReadOnly.attr to 'This wont work' + + Also see the `islocked()` function. + + :param instance: The instance of `ReadOnly` (or similar) to lock. + """ + assert instance.__islocked__() is False, 'already locked: %r' % instance + instance.__lock__() + assert instance.__islocked__() is True, 'failed to lock: %r' % instance + return instance + + +def islocked(instance): + """ + Return ``True`` if ``instance`` is locked. + + This function can be used on an instance of the `ReadOnly` class or an + instance of any other class implemented the same locking API. + + For example: + + >>> readonly = ReadOnly() + >>> islocked(readonly) + False + >>> readonly.__lock__() + >>> islocked(readonly) + True + + Also see the `lock()` function. + + :param instance: The instance of `ReadOnly` (or similar) to interrogate. + """ + assert ( + hasattr(instance, '__lock__') and callable(instance.__lock__) + ), 'no __lock__() method: %r' % instance + return instance.__islocked__() + + +def check_name(name): + """ + Verify that ``name`` is suitable for a `NameSpace` member name. + + In short, ``name`` must be a valid lower-case Python identifier that + neither starts nor ends with an underscore. Otherwise an exception is + raised. + + This function will raise a ``ValueError`` if ``name`` does not match the + `constants.NAME_REGEX` regular expression. For example: + + >>> check_name('MyName') + Traceback (most recent call last): + ... + ValueError: name must match '^[a-z][_a-z0-9]*[a-z0-9]$'; got 'MyName' + + Also, this function will raise a ``TypeError`` if ``name`` is not an + ``str`` instance. For example: + + >>> check_name(u'my_name') + Traceback (most recent call last): + ... + TypeError: name: need a <type 'str'>; got u'my_name' (a <type 'unicode'>) + + So that `check_name()` can be easily used within an assignment, ``name`` + is returned unchanged if it passes the check. For example: + + >>> n = check_name('my_name') + >>> n + 'my_name' + + :param name: Identifier to test. + """ + if type(name) is not str: + raise TypeError( + TYPE_ERROR % ('name', str, name, type(name)) + ) + if re.match(NAME_REGEX, name) is None: + raise ValueError( + NAME_ERROR % (NAME_REGEX, name) + ) + return name + + +class NameSpace(ReadOnly): + """ + A read-only name-space with handy container behaviours. + + A `NameSpace` instance is an ordered, immutable mapping object whose values + can also be accessed as attributes. A `NameSpace` instance is constructed + from an iterable providing its *members*, which are simply arbitrary objects + with a ``name`` attribute whose value: + + 1. Is unique among the members + + 2. Passes the `check_name()` function + + Beyond that, no restrictions are placed on the members: they can be + classes or instances, and of any type. + + The members can be accessed as attributes on the `NameSpace` instance or + through a dictionary interface. For example, say we create a `NameSpace` + instance from a list containing a single member, like this: + + >>> class my_member(object): + ... name = 'my_name' + ... + >>> namespace = NameSpace([my_member]) + >>> namespace + NameSpace(<1 member>, sort=True) + + We can then access ``my_member`` both as an attribute and as a dictionary + item: + + >>> my_member is namespace.my_name # As an attribute + True + >>> my_member is namespace['my_name'] # As dictionary item + True + + For a more detailed example, say we create a `NameSpace` instance from a + generator like this: + + >>> class Member(object): + ... def __init__(self, i): + ... self.i = i + ... self.name = 'member%d' % i + ... def __repr__(self): + ... return 'Member(%d)' % self.i + ... + >>> ns = NameSpace(Member(i) for i in xrange(3)) + >>> ns + NameSpace(<3 members>, sort=True) + + As above, the members can be accessed as attributes and as dictionary items: + + >>> ns.member0 is ns['member0'] + True + >>> ns.member1 is ns['member1'] + True + >>> ns.member2 is ns['member2'] + True + + Members can also be accessed by index and by slice. For example: + + >>> ns[0] + Member(0) + >>> ns[-1] + Member(2) + >>> ns[1:] + (Member(1), Member(2)) + + (Note that slicing a `NameSpace` returns a ``tuple``.) + + `NameSpace` instances provide standard container emulation for membership + testing, counting, and iteration. For example: + + >>> 'member3' in ns # Is there a member named 'member3'? + False + >>> 'member2' in ns # But there is a member named 'member2' + True + >>> len(ns) # The number of members + 3 + >>> list(ns) # Iterate through the member names + ['member0', 'member1', 'member2'] + + Although not a standard container feature, the `NameSpace.__call__()` method + provides a convenient (and efficient) way to iterate through the *members* + (as opposed to the member names). Think of it like an ordered version of + the ``dict.itervalues()`` method. For example: + + >>> list(ns[name] for name in ns) # One way to do it + [Member(0), Member(1), Member(2)] + >>> list(ns()) # A more efficient, simpler way to do it + [Member(0), Member(1), Member(2)] + + Another convenience method is `NameSpace.__todict__()`, which will return + a copy of the ``dict`` mapping the member names to the members. + For example: + + >>> ns.__todict__() + {'member1': Member(1), 'member0': Member(0), 'member2': Member(2)} + + As `NameSpace.__init__()` locks the instance, `NameSpace` instances are + read-only from the get-go. An ``AttributeError`` is raised if you try to + set *any* attribute on a `NameSpace` instance. For example: + + >>> ns.member3 = Member(3) # Lets add that missing 'member3' + Traceback (most recent call last): + ... + AttributeError: locked: cannot set NameSpace.member3 to Member(3) + + (For information on the locking protocol, see the `ReadOnly` class, of which + `NameSpace` is a subclass.) + + By default the members will be sorted alphabetically by the member name. + For example: + + >>> sorted_ns = NameSpace([Member(7), Member(3), Member(5)]) + >>> sorted_ns + NameSpace(<3 members>, sort=True) + >>> list(sorted_ns) + ['member3', 'member5', 'member7'] + >>> sorted_ns[0] + Member(3) + + But if the instance is created with the ``sort=False`` keyword argument, the + original order of the members is preserved. For example: + + >>> unsorted_ns = NameSpace([Member(7), Member(3), Member(5)], sort=False) + >>> unsorted_ns + NameSpace(<3 members>, sort=False) + >>> list(unsorted_ns) + ['member7', 'member3', 'member5'] + >>> unsorted_ns[0] + Member(7) + + The `NameSpace` class is used in many places throughout freeIPA. For a few + examples, see the `plugable.API` and the `frontend.Command` classes. + """ + + def __init__(self, members, sort=True): + """ + :param members: An iterable providing the members. + :param sort: Whether to sort the members by member name. + """ + if type(sort) is not bool: + raise TypeError( + TYPE_ERROR % ('sort', bool, sort, type(sort)) + ) + self.__sort = sort + if sort: + self.__members = tuple( + sorted(members, key=lambda m: m.name) + ) + else: + self.__members = tuple(members) + self.__names = tuple(m.name for m in self.__members) + self.__map = dict() + for member in self.__members: + name = check_name(member.name) + if name in self.__map: + raise AttributeError(OVERRIDE_ERROR % + (self.__class__.__name__, name, self.__map[name], member) + ) + assert not hasattr(self, name), 'Ouch! Has attribute %r' % name + self.__map[name] = member + setattr(self, name, member) + lock(self) + + def __len__(self): + """ + Return the number of members. + """ + return len(self.__members) + + def __iter__(self): + """ + Iterate through the member names. + + If this instance was created with ``sort=False``, the names will be in + the same order as the members were passed to the constructor; otherwise + the names will be in alphabetical order (which is the default). + + This method is like an ordered version of ``dict.iterkeys()``. + """ + for name in self.__names: + yield name + + def __call__(self): + """ + Iterate through the members. + + If this instance was created with ``sort=False``, the members will be + in the same order as they were passed to the constructor; otherwise the + members will be in alphabetical order by name (which is the default). + + This method is like an ordered version of ``dict.itervalues()``. + """ + for member in self.__members: + yield member + + def __contains__(self, name): + """ + Return ``True`` if namespace has a member named ``name``. + """ + return name in self.__map + + def __getitem__(self, key): + """ + Return a member by name or index, or return a slice of members. + + :param key: The name or index of a member, or a slice object. + """ + if type(key) is str: + return self.__map[key] + if type(key) in (int, slice): + return self.__members[key] + raise TypeError( + TYPE_ERROR % ('key', (str, int, slice), key, type(key)) + ) + + def __repr__(self): + """ + Return a pseudo-valid expression that could create this instance. + """ + cnt = len(self) + if cnt == 1: + m = 'member' + else: + m = 'members' + return '%s(<%d %s>, sort=%r)' % ( + self.__class__.__name__, + cnt, + m, + self.__sort, + ) + + def __todict__(self): + """ + Return a copy of the private dict mapping member name to member. + """ + return dict(self.__map) diff --git a/ipalib/cli.py b/ipalib/cli.py new file mode 100644 index 000000000..fb2fd95f4 --- /dev/null +++ b/ipalib/cli.py @@ -0,0 +1,854 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Functionality for Command Line Interface. +""" + +import re +import textwrap +import sys +import getpass +import code +import optparse +import socket +import fcntl +import termios +import struct + +import frontend +import backend +import errors +import plugable +import util +from constants import CLI_TAB +from parameters import Password + + +def to_cli(name): + """ + Takes a Python identifier and transforms it into form suitable for the + Command Line Interface. + """ + assert isinstance(name, str) + return name.replace('_', '-') + + +def from_cli(cli_name): + """ + Takes a string from the Command Line Interface and transforms it into a + Python identifier. + """ + return str(cli_name).replace('-', '_') + + +class textui(backend.Backend): + """ + Backend plugin to nicely format output to stdout. + """ + + def get_tty_width(self): + """ + Return the width (in characters) of output tty. + + If stdout is not a tty, this method will return ``None``. + """ + # /usr/include/asm/termios.h says that struct winsize has four + # unsigned shorts, hence the HHHH + if sys.stdout.isatty(): + try: + winsize = fcntl.ioctl(sys.stdout, termios.TIOCGWINSZ, + struct.pack('HHHH', 0, 0, 0, 0)) + return struct.unpack('HHHH', winsize)[1] + except IOError: + return None + + def max_col_width(self, rows, col=None): + """ + Return the max width (in characters) of a specified column. + + For example: + + >>> ui = textui() + >>> rows = [ + ... ('a', 'package'), + ... ('an', 'egg'), + ... ] + >>> ui.max_col_width(rows, col=0) # len('an') + 2 + >>> ui.max_col_width(rows, col=1) # len('package') + 7 + >>> ui.max_col_width(['a', 'cherry', 'py']) # len('cherry') + 6 + """ + if type(rows) not in (list, tuple): + raise TypeError( + 'rows: need %r or %r; got %r' % (list, tuple, rows) + ) + if len(rows) == 0: + return 0 + if col is None: + return max(len(row) for row in rows) + return max(len(row[col]) for row in rows) + + def __get_encoding(self, stream): + assert stream in (sys.stdin, sys.stdout) + if stream.encoding is None: + if stream.isatty(): + return sys.getdefaultencoding() + return 'UTF-8' + return stream.encoding + + def decode(self, str_buffer): + """ + Decode text from stdin. + """ + assert type(str_buffer) is str + encoding = self.__get_encoding(sys.stdin) + return str_buffer.decode(encoding) + + def encode(self, unicode_text): + """ + Encode text for output to stdout. + """ + assert type(unicode_text) is unicode + encoding = self.__get_encoding(sys.stdout) + return unicode_text.encode(encoding) + + def choose_number(self, n, singular, plural=None): + if n == 1 or plural is None: + return singular % n + return plural % n + + def print_plain(self, string): + """ + Print exactly like ``print`` statement would. + """ + print string + + def print_line(self, text, width=None): + """ + Force printing on a single line, using ellipsis if needed. + + For example: + + >>> ui = textui() + >>> ui.print_line('This line can fit!', width=18) + This line can fit! + >>> ui.print_line('This line wont quite fit!', width=18) + This line wont ... + + The above example aside, you normally should not specify the + ``width``. When you don't, it is automatically determined by calling + `textui.get_tty_width()`. + """ + if width is None: + width = self.get_tty_width() + if width is not None and width < len(text): + text = text[:width - 3] + '...' + print text + + def print_paragraph(self, text, width=None): + """ + Print a paragraph, automatically word-wrapping to tty width. + + For example: + + >>> text = ''' + ... Python is a dynamic object-oriented programming language that can + ... be used for many kinds of software development. + ... ''' + >>> ui = textui() + >>> ui.print_paragraph(text, width=45) + Python is a dynamic object-oriented + programming language that can be used for + many kinds of software development. + + The above example aside, you normally should not specify the + ``width``. When you don't, it is automatically determined by calling + `textui.get_tty_width()`. + + The word-wrapping is done using the Python ``textwrap`` module. See: + + http://docs.python.org/library/textwrap.html + """ + if width is None: + width = self.get_tty_width() + for line in textwrap.wrap(text.strip(), width): + print line + + def print_indented(self, text, indent=1): + """ + Print at specified indentation level. + + For example: + + >>> ui = textui() + >>> ui.print_indented('One indentation level.') + One indentation level. + >>> ui.print_indented('Two indentation levels.', indent=2) + Two indentation levels. + >>> ui.print_indented('No indentation.', indent=0) + No indentation. + """ + print (CLI_TAB * indent + text) + + def print_keyval(self, rows, indent=1): + """ + Print (key = value) pairs, one pair per line. + + For example: + + >>> items = [ + ... ('in_server', True), + ... ('mode', 'production'), + ... ] + >>> ui = textui() + >>> ui.print_keyval(items) + in_server = True + mode = 'production' + >>> ui.print_keyval(items, indent=0) + in_server = True + mode = 'production' + + Also see `textui.print_indented`. + """ + for (key, value) in rows: + self.print_indented('%s = %r' % (key, value), indent) + + def print_entry(self, entry, indent=1): + """ + Print an ldap entry dict. + + For example: + + >>> entry = dict(sn='Last', givenname='First', uid='flast') + >>> ui = textui() + >>> ui.print_entry(entry) + givenname: 'First' + sn: 'Last' + uid: 'flast' + """ + assert type(entry) is dict + for key in sorted(entry): + value = entry[key] + if type(value) in (list, tuple): + value = ', '.join(repr(v) for v in value) + else: + value = repr(value) + self.print_indented('%s: %s' % (key, value), indent) + + def print_dashed(self, string, above=True, below=True, indent=0, dash='-'): + """ + Print a string with a dashed line above and/or below. + + For example: + + >>> ui = textui() + >>> ui.print_dashed('Dashed above and below.') + ----------------------- + Dashed above and below. + ----------------------- + >>> ui.print_dashed('Only dashed below.', above=False) + Only dashed below. + ------------------ + >>> ui.print_dashed('Only dashed above.', below=False) + ------------------ + Only dashed above. + """ + assert isinstance(dash, basestring) + assert len(dash) == 1 + dashes = dash * len(string) + if above: + self.print_indented(dashes, indent) + self.print_indented(string, indent) + if below: + self.print_indented(dashes, indent) + + def print_h1(self, text): + """ + Print a primary header at indentation level 0. + + For example: + + >>> ui = textui() + >>> ui.print_h1('A primary header') + ================ + A primary header + ================ + """ + self.print_dashed(text, indent=0, dash='=') + + def print_h2(self, text): + """ + Print a secondary header at indentation level 1. + + For example: + + >>> ui = textui() + >>> ui.print_h2('A secondary header') + ------------------ + A secondary header + ------------------ + """ + self.print_dashed(text, indent=1, dash='-') + + def print_name(self, name): + """ + Print a command name. + + The typical use for this is to mark the start of output from a + command. For example, a hypothetical ``show_status`` command would + output something like this: + + >>> ui = textui() + >>> ui.print_name('show_status') + ------------ + show-status: + ------------ + """ + self.print_dashed('%s:' % to_cli(name)) + + def print_count(self, count, singular, plural=None): + """ + Print a summary count. + + The typical use for this is to print the number of items returned + by a command, especially when this return count can vary. This + preferably should be used as a summary and should be the final text + a command outputs. + + For example: + + >>> ui = textui() + >>> ui.print_count(1, '%d goose', '%d geese') + ------- + 1 goose + ------- + >>> ui.print_count(['Don', 'Sue'], 'Found %d user', 'Found %d users') + ------------- + Found 2 users + ------------- + + If ``count`` is not an integer, it must be a list or tuple, and then + ``len(count)`` is used as the count. + """ + if type(count) is not int: + assert type(count) in (list, tuple) + count = len(count) + self.print_dashed( + self.choose_number(count, singular, plural) + ) + + def prompt(self, label, default=None, get_values=None): + """ + Prompt user for input. + """ + # TODO: Add tab completion using readline + if default is None: + prompt = u'%s: ' % label + else: + prompt = u'%s [%s]: ' % (label, default) + return self.decode( + raw_input(self.encode(prompt)) + ) + + def prompt_password(self, label): + """ + Prompt user for a password. + """ + try: + while True: + pw1 = getpass.getpass('%s: ' % label) + pw2 = getpass.getpass('Enter again to verify: ') + if pw1 == pw2: + return self.decode(pw1) + print ' ** Passwords do not match. Please enter again. **' + except KeyboardInterrupt: + print '' + print ' ** Cancelled. **' + + +class help(frontend.Application): + '''Display help on a command.''' + + takes_args = ['command?'] + + def run(self, command): + textui = self.Backend.textui + if command is None: + self.print_commands() + return + key = str(command) + if key not in self.application: + raise errors.UnknownHelpError(key) + cmd = self.application[key] + print 'Purpose: %s' % cmd.doc + self.application.build_parser(cmd).print_help() + + def print_commands(self): + std = set(self.Command) - set(self.Application) + print '\nStandard IPA commands:' + for key in sorted(std): + cmd = self.api.Command[key] + self.print_cmd(cmd) + print '\nSpecial CLI commands:' + for cmd in self.api.Application(): + self.print_cmd(cmd) + print '\nUse the --help option to see all the global options' + print '' + + def print_cmd(self, cmd): + print ' %s %s' % ( + to_cli(cmd.name).ljust(self.application.mcl), + cmd.doc, + ) + + +class console(frontend.Application): + """Start the IPA interactive Python console.""" + + def run(self): + code.interact( + '(Custom IPA interactive Python console)', + local=dict(api=self.api) + ) + + +class show_api(frontend.Application): + 'Show attributes on dynamic API object' + + takes_args = ('namespaces*',) + + def run(self, namespaces): + if namespaces is None: + names = tuple(self.api) + else: + for name in namespaces: + if name not in self.api: + raise errors.NoSuchNamespaceError(name) + names = namespaces + lines = self.__traverse(names) + ml = max(len(l[1]) for l in lines) + self.Backend.textui.print_name('run') + first = True + for line in lines: + if line[0] == 0 and not first: + print '' + if first: + first = False + print '%s%s %r' % ( + ' ' * line[0], + line[1].ljust(ml), + line[2], + ) + if len(lines) == 1: + s = '1 attribute shown.' + else: + s = '%d attributes show.' % len(lines) + self.Backend.textui.print_dashed(s) + + + def __traverse(self, names): + lines = [] + for name in names: + namespace = self.api[name] + self.__traverse_namespace('%s' % name, namespace, lines) + return lines + + def __traverse_namespace(self, name, namespace, lines, tab=0): + lines.append((tab, name, namespace)) + for member_name in namespace: + member = namespace[member_name] + lines.append((tab + 1, member_name, member)) + if not hasattr(member, '__iter__'): + continue + for n in member: + attr = member[n] + if isinstance(attr, plugable.NameSpace) and len(attr) > 0: + self.__traverse_namespace(n, attr, lines, tab + 2) + + +cli_application_commands = ( + help, + console, + show_api, +) + + +class KWCollector(object): + def __init__(self): + object.__setattr__(self, '_KWCollector__d', {}) + + def __setattr__(self, name, value): + if name in self.__d: + v = self.__d[name] + if type(v) is tuple: + value = v + (value,) + else: + value = (v, value) + self.__d[name] = value + object.__setattr__(self, name, value) + + def __todict__(self): + return dict(self.__d) + + +class CLI(object): + """ + All logic for dispatching over command line interface. + """ + + __d = None + __mcl = None + + def __init__(self, api, argv): + self.api = api + self.argv = tuple(argv) + self.__done = set() + + def run(self): + """ + Call `CLI.run_real` in a try/except. + """ + self.bootstrap() + try: + self.run_real() + except KeyboardInterrupt: + print '' + self.api.log.info('operation aborted') + sys.exit() + except errors.IPAError, e: + self.api.log.error(unicode(e)) + sys.exit(e.faultCode) + + def run_real(self): + """ + Parse ``argv`` and potentially run a command. + + This method requires several initialization steps to be completed + first, all of which all automatically called with a single call to + `CLI.finalize()`. The initialization steps are broken into separate + methods simply to make it easy to write unit tests. + + The initialization involves these steps: + + 1. `CLI.parse_globals` parses the global options, which get stored + in ``CLI.options``, and stores the remaining args in + ``CLI.cmd_argv``. + + 2. `CLI.bootstrap` initializes the environment information in + ``CLI.api.env``. + + 3. `CLI.load_plugins` registers all plugins, including the + CLI-specific plugins. + + 4. `CLI.finalize` instantiates all plugins and performs the + remaining initialization needed to use the `plugable.API` + instance. + """ + self.__doing('run_real') + self.finalize() + if self.api.env.mode == 'unit_test': + return + if len(self.cmd_argv) < 1: + self.api.Command.help() + return + key = self.cmd_argv[0] + if key not in self: + raise errors.UnknownCommandError(key) + self.run_cmd(self[key]) + + # FIXME: Stuff that might need special handling still: +# # Now run the command +# try: +# ret = cmd(**kw) +# if callable(cmd.output_for_cli): +# (args, options) = cmd.params_2_args_options(kw) +# cmd.output_for_cli(self.api.Backend.textui, ret, *args, **options) +# return 0 +# except socket.error, e: +# print e[1] +# return 1 +# except errors.GenericError, err: +# code = getattr(err,'faultCode',None) +# faultString = getattr(err,'faultString',None) +# if not code: +# raise err +# if code < errors.IPA_ERROR_BASE: +# print "%s: %s" % (code, faultString) +# else: +# print "%s: %s" % (code, getattr(err,'__doc__','')) +# return 1 +# except StandardError, e: +# print e +# return 2 + + def finalize(self): + """ + Fully initialize ``CLI.api`` `plugable.API` instance. + + This method first calls `CLI.load_plugins` to perform some dependant + initialization steps, after which `plugable.API.finalize` is called. + + Finally, the CLI-specific commands are passed a reference to this + `CLI` instance by calling `frontend.Application.set_application`. + """ + self.__doing('finalize') + self.load_plugins() + self.api.finalize() + for a in self.api.Application(): + a.set_application(self) + assert self.__d is None + self.__d = dict( + (c.name.replace('_', '-'), c) for c in self.api.Command() + ) + self.textui = self.api.Backend.textui + + def load_plugins(self): + """ + Load all standard plugins plus the CLI-specific plugins. + + This method first calls `CLI.bootstrap` to preform some dependant + initialization steps, after which `plugable.API.load_plugins` is + called. + + Finally, all the CLI-specific plugins are registered. + """ + self.__doing('load_plugins') + if 'bootstrap' not in self.__done: + self.bootstrap() + self.api.load_plugins() + for klass in cli_application_commands: + self.api.register(klass) + self.api.register(textui) + + def bootstrap(self): + """ + Initialize the ``CLI.api.env`` environment variables. + + This method first calls `CLI.parse_globals` to perform some dependant + initialization steps. Then, using environment variables that may have + been passed in the global options, the ``overrides`` are constructed + and `plugable.API.bootstrap` is called. + """ + self.__doing('bootstrap') + self.parse_globals() + self.api.bootstrap_with_global_options(self.options, context='cli') + + def parse_globals(self): + """ + Parse out the global options. + + This method parses the global options out of the ``CLI.argv`` instance + attribute, after which two new instance attributes are available: + + 1. ``CLI.options`` - an ``optparse.Values`` instance containing + the global options. + + 2. ``CLI.cmd_argv`` - a tuple containing the remainder of + ``CLI.argv`` after the global options have been consumed. + + The common global options are added using the + `util.add_global_options` function. + """ + self.__doing('parse_globals') + parser = optparse.OptionParser() + parser.disable_interspersed_args() + parser.add_option('-a', dest='prompt_all', action='store_true', + help='Prompt for all missing options interactively') + parser.add_option('-n', dest='interactive', action='store_false', + help='Don\'t prompt for any options interactively') + parser.set_defaults( + prompt_all=False, + interactive=True, + ) + util.add_global_options(parser) + (options, args) = parser.parse_args(list(self.argv)) + self.options = options + self.cmd_argv = tuple(args) + + def __doing(self, name): + if name in self.__done: + raise StandardError( + '%s.%s() already called' % (self.__class__.__name__, name) + ) + self.__done.add(name) + + def run_cmd(self, cmd): + kw = self.parse(cmd) + if self.options.interactive: + self.prompt_interactively(cmd, kw) + self.prompt_for_passwords(cmd, kw) + self.set_defaults(cmd, kw) + result = cmd(**kw) + if callable(cmd.output_for_cli): + for param in cmd.params(): + if isinstance(param, Password): + try: + del kw[param.name] + except KeyError: + pass + (args, options) = cmd.params_2_args_options(kw) + cmd.output_for_cli(self.api.Backend.textui, result, *args, **options) + + def set_defaults(self, cmd, kw): + for param in cmd.params(): + if not kw.get(param.name): + value = param.get_default(**kw) + if value: + kw[param.name] = value + + def prompt_for_passwords(self, cmd, kw): + for param in cmd.params(): + if 'password' not in param.flags: + continue + if kw.get(param.name, False) is True or param.name in cmd.args: + kw[param.name] = self.textui.prompt_password( + param.cli_name + ) + else: + kw.pop(param.name, None) + return kw + + def prompt_interactively(self, cmd, kw): + """ + Interactively prompt for missing or invalid values. + + By default this method will only prompt for *required* Param that + have a missing or invalid value. However, if + ``CLI.options.prompt_all`` is True, this method will prompt for any + params that have a missing or required values, even if the param is + optional. + """ + for param in cmd.params(): + if 'password' in param.flags: + continue + elif param.name not in kw: + if not (param.required or self.options.prompt_all): + continue + default = param.get_default(**kw) + error = None + while True: + if error is not None: + print '>>> %s: %s' % (param.cli_name, error) + raw = self.textui.prompt(param.cli_name, default) + try: + value = param(raw, **kw) + if value is not None: + kw[param.name] = value + break + except errors.ValidationError, e: + error = e.error + return kw + +# FIXME: This should be done as the plugins are loaded +# if self.api.env.server_context: +# try: +# import krbV +# import ldap +# from ipaserver import conn +# from ipaserver.servercore import context +# krbccache = krbV.default_context().default_ccache().name +# context.conn = conn.IPAConn(self.api.env.ldaphost, self.api.env.ldapport, krbccache) +# except ImportError: +# print >> sys.stderr, "There was a problem importing a Python module: %s" % sys.exc_value +# return 2 +# except ldap.LDAPError, e: +# print >> sys.stderr, "There was a problem connecting to the LDAP server: %s" % e[0].get('desc') +# return 2 +# ret = cmd(**kw) +# if callable(cmd.output_for_cli): +# return cmd.output_for_cli(ret) +# else: +# return 0 + + def parse(self, cmd): + parser = self.build_parser(cmd) + (kwc, args) = parser.parse_args( + list(self.cmd_argv[1:]), KWCollector() + ) + kw = kwc.__todict__() + arg_kw = cmd.args_to_kw(*args) + assert set(arg_kw).intersection(kw) == set() + kw.update(arg_kw) + return kw + + def build_parser(self, cmd): + parser = optparse.OptionParser( + usage=self.get_usage(cmd), + ) + for option in cmd.options(): + kw = dict( + dest=option.name, + help=option.doc, + ) + if 'password' in option.flags: + kw['action'] = 'store_true' + elif option.type is bool: + if option.default is True: + kw['action'] = 'store_false' + else: + kw['action'] = 'store_true' + else: + kw['metavar'] = metavar=option.__class__.__name__.upper() + o = optparse.make_option('--%s' % to_cli(option.cli_name), **kw) + parser.add_option(o) + return parser + + def get_usage(self, cmd): + return ' '.join(self.get_usage_iter(cmd)) + + def get_usage_iter(self, cmd): + yield 'Usage: %%prog [global-options] %s' % to_cli(cmd.name) + for arg in cmd.args(): + if 'password' in arg.flags: + continue + name = to_cli(arg.cli_name).upper() + if arg.multivalue: + name = '%s...' % name + if arg.required: + yield name + else: + yield '[%s]' % name + + def __get_mcl(self): + """ + Returns the Max Command Length. + """ + if self.__mcl is None: + if self.__d is None: + return None + self.__mcl = max(len(k) for k in self.__d) + return self.__mcl + mcl = property(__get_mcl) + + def isdone(self, name): + """ + Return True in method named ``name`` has already been called. + """ + return name in self.__done + + def __contains__(self, key): + assert self.__d is not None, 'you must call finalize() first' + return key in self.__d + + def __getitem__(self, key): + assert self.__d is not None, 'you must call finalize() first' + return self.__d[key] diff --git a/ipalib/config.py b/ipalib/config.py new file mode 100644 index 000000000..3544331df --- /dev/null +++ b/ipalib/config.py @@ -0,0 +1,529 @@ +# Authors: +# Martin Nagy <mnagy@redhat.com> +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Process-wide static configuration and environment. + +The standard run-time instance of the `Env` class is initialized early in the +`ipalib` process and is then locked into a read-only state, after which no +further changes can be made to the environment throughout the remaining life +of the process. + +For the per-request thread-local information, see `ipalib.request`. +""" + +from ConfigParser import RawConfigParser, ParsingError +from types import NoneType +import os +from os import path +import sys + +from base import check_name +from constants import CONFIG_SECTION +from constants import TYPE_ERROR, OVERRIDE_ERROR, SET_ERROR, DEL_ERROR + + +class Env(object): + """ + Store and retrieve environment variables. + + First an foremost, the `Env` class provides a handy container for + environment variables. These variables can be both set *and* retrieved + either as attributes *or* as dictionary items. + + For example, you can set a variable as an attribute: + + >>> env = Env() + >>> env.attr = 'I was set as an attribute.' + >>> env.attr + 'I was set as an attribute.' + >>> env['attr'] # Also retrieve as a dictionary item + 'I was set as an attribute.' + + Or you can set a variable as a dictionary item: + + >>> env['item'] = 'I was set as a dictionary item.' + >>> env['item'] + 'I was set as a dictionary item.' + >>> env.item # Also retrieve as an attribute + 'I was set as a dictionary item.' + + The variable names must be valid lower-case Python identifiers that neither + start nor end with an underscore. If your variable name doesn't meet these + criteria, a ``ValueError`` will be raised when you try to set the variable + (compliments of the `base.check_name()` function). For example: + + >>> env.BadName = 'Wont work as an attribute' + Traceback (most recent call last): + ... + ValueError: name must match '^[a-z][_a-z0-9]*[a-z0-9]$'; got 'BadName' + >>> env['BadName'] = 'Also wont work as a dictionary item' + Traceback (most recent call last): + ... + ValueError: name must match '^[a-z][_a-z0-9]*[a-z0-9]$'; got 'BadName' + + The variable values can be ``str``, ``int``, or ``float`` instances, or the + ``True``, ``False``, or ``None`` constants. When the value provided is an + ``str`` instance, some limited automatic type conversion is performed, which + allows values of specific types to be set easily from configuration files or + command-line options. + + So in addition to their actual values, the ``True``, ``False``, and ``None`` + constants can be specified with an ``str`` equal to what ``repr()`` would + return. For example: + + >>> env.true = True + >>> env.also_true = 'True' # Equal to repr(True) + >>> env.true + True + >>> env.also_true + True + + Note that the automatic type conversion is case sensitive. For example: + + >>> env.not_false = 'false' # Not equal to repr(False)! + >>> env.not_false + 'false' + + If an ``str`` value looks like an integer, it's automatically converted to + the ``int`` type. Likewise, if an ``str`` value looks like a floating-point + number, it's automatically converted to the ``float`` type. For example: + + >>> env.lucky = '7' + >>> env.lucky + 7 + >>> env.three_halves = '1.5' + >>> env.three_halves + 1.5 + + Leading and trailing white-space is automatically stripped from ``str`` + values. For example: + + >>> env.message = ' Hello! ' # Surrounded by double spaces + >>> env.message + 'Hello!' + >>> env.number = ' 42 ' # Still converted to an int + >>> env.number + 42 + >>> env.false = ' False ' # Still equal to repr(False) + >>> env.false + False + + Also, empty ``str`` instances are converted to ``None``. For example: + + >>> env.empty = '' + >>> env.empty is None + True + + `Env` variables are all set-once (first-one-wins). Once a variable has been + set, trying to override it will raise an ``AttributeError``. For example: + + >>> env.date = 'First' + >>> env.date = 'Second' + Traceback (most recent call last): + ... + AttributeError: cannot override Env.date value 'First' with 'Second' + + An `Env` instance can be *locked*, after which no further variables can be + set. Trying to set variables on a locked `Env` instance will also raise + an ``AttributeError``. For example: + + >>> env = Env() + >>> env.okay = 'This will work.' + >>> env.__lock__() + >>> env.nope = 'This wont work!' + Traceback (most recent call last): + ... + AttributeError: locked: cannot set Env.nope to 'This wont work!' + + `Env` instances also provide standard container emulation for membership + testing, counting, and iteration. For example: + + >>> env = Env() + >>> 'key1' in env # Has key1 been set? + False + >>> env.key1 = 'value 1' + >>> 'key1' in env + True + >>> env.key2 = 'value 2' + >>> len(env) # How many variables have been set? + 2 + >>> list(env) # What variables have been set? + ['key1', 'key2'] + + Lastly, in addition to all the handy container functionality, the `Env` + class provides high-level methods for bootstraping a fresh `Env` instance + into one containing all the run-time and configuration information needed + by the built-in freeIPA plugins. + + These are the `Env` bootstraping methods, in the order they must be called: + + 1. `Env._bootstrap()` - initialize the run-time variables and then + merge-in variables specified on the command-line. + + 2. `Env._finalize_core()` - merge-in variables from the configuration + files and then merge-in variables from the internal defaults, after + which at least all the standard variables will be set. After this + method is called, the plugins will be loaded, during which + third-party plugins can merge-in defaults for additional variables + they use (likely using the `Env._merge()` method). + + 3. `Env._finalize()` - one last chance to merge-in variables and then + the instance is locked. After this method is called, no more + environment variables can be set during the remaining life of the + process. + + However, normally none of these three bootstraping methods are called + directly and instead only `plugable.API.bootstrap()` is called, which itself + takes care of correctly calling the `Env` bootstrapping methods. + """ + + __locked = False + + def __init__(self): + object.__setattr__(self, '_Env__d', {}) + object.__setattr__(self, '_Env__done', set()) + + def __lock__(self): + """ + Prevent further changes to environment. + """ + if self.__locked is True: + raise StandardError( + '%s.__lock__() already called' % self.__class__.__name__ + ) + object.__setattr__(self, '_Env__locked', True) + + def __islocked__(self): + """ + Return ``True`` if locked. + """ + return self.__locked + + def __setattr__(self, name, value): + """ + Set the attribute named ``name`` to ``value``. + + This just calls `Env.__setitem__()`. + """ + self[name] = value + + def __setitem__(self, key, value): + """ + Set ``key`` to ``value``. + """ + if self.__locked: + raise AttributeError( + SET_ERROR % (self.__class__.__name__, key, value) + ) + check_name(key) + if key in self.__d: + raise AttributeError(OVERRIDE_ERROR % + (self.__class__.__name__, key, self.__d[key], value) + ) + assert not hasattr(self, key) + if isinstance(value, basestring): + value = str(value.strip()) + m = { + 'True': True, + 'False': False, + 'None': None, + '': None, + } + if value in m: + value = m[value] + elif value.isdigit(): + value = int(value) + else: + try: + value = float(value) + except (TypeError, ValueError): + pass + assert type(value) in (str, int, float, bool, NoneType) + object.__setattr__(self, key, value) + self.__d[key] = value + + def __getitem__(self, key): + """ + Return the value corresponding to ``key``. + """ + return self.__d[key] + + def __delattr__(self, name): + """ + Raise an ``AttributeError`` (deletion is never allowed). + + For example: + + >>> env = Env() + >>> env.name = 'A value' + >>> del env.name + Traceback (most recent call last): + ... + AttributeError: locked: cannot delete Env.name + """ + raise AttributeError( + DEL_ERROR % (self.__class__.__name__, name) + ) + + def __contains__(self, key): + """ + Return True if instance contains ``key``; otherwise return False. + """ + return key in self.__d + + def __len__(self): + """ + Return number of variables currently set. + """ + return len(self.__d) + + def __iter__(self): + """ + Iterate through keys in ascending order. + """ + for key in sorted(self.__d): + yield key + + def _merge(self, **kw): + """ + Merge variables from ``kw`` into the environment. + + Any variables in ``kw`` that have already been set will be ignored + (meaning this method will *not* try to override them, which would raise + an exception). + + This method returns a ``(num_set, num_total)`` tuple containing first + the number of variables that were actually set, and second the total + number of variables that were provided. + + For example: + + >>> env = Env() + >>> env._merge(one=1, two=2) + (2, 2) + >>> env._merge(one=1, three=3) + (1, 2) + >>> env._merge(one=1, two=2, three=3) + (0, 3) + + Also see `Env._merge_from_file()`. + + :param kw: Variables provides as keyword arguments. + """ + i = 0 + for (key, value) in kw.iteritems(): + if key not in self: + self[key] = value + i += 1 + return (i, len(kw)) + + def _merge_from_file(self, config_file): + """ + Merge variables from ``config_file`` into the environment. + + Any variables in ``config_file`` that have already been set will be + ignored (meaning this method will *not* try to override them, which + would raise an exception). + + If ``config_file`` does not exist or is not a regular file, or if there + is an error parsing ``config_file``, ``None`` is returned. + + Otherwise this method returns a ``(num_set, num_total)`` tuple + containing first the number of variables that were actually set, and + second the total number of variables found in ``config_file``. + + This method will raise a ``ValueError`` if ``config_file`` is not an + absolute path. For example: + + >>> env = Env() + >>> env._merge_from_file('my/config.conf') + Traceback (most recent call last): + ... + ValueError: config_file must be an absolute path; got 'my/config.conf' + + Also see `Env._merge()`. + + :param config_file: Absolute path of the configuration file to load. + """ + if path.abspath(config_file) != config_file: + raise ValueError( + 'config_file must be an absolute path; got %r' % config_file + ) + if not path.isfile(config_file): + return + parser = RawConfigParser() + try: + parser.read(config_file) + except ParsingError: + return + if not parser.has_section(CONFIG_SECTION): + parser.add_section(CONFIG_SECTION) + items = parser.items(CONFIG_SECTION) + if len(items) == 0: + return (0, 0) + i = 0 + for (key, value) in items: + if key not in self: + self[key] = value + i += 1 + return (i, len(items)) + + def __doing(self, name): + if name in self.__done: + raise StandardError( + '%s.%s() already called' % (self.__class__.__name__, name) + ) + self.__done.add(name) + + def __do_if_not_done(self, name): + if name not in self.__done: + getattr(self, name)() + + def _isdone(self, name): + return name in self.__done + + def _bootstrap(self, **overrides): + """ + Initialize basic environment. + + This method will perform the following steps: + + 1. Initialize certain run-time variables. These run-time variables + are strictly determined by the external environment the process + is running in; they cannot be specified on the command-line nor + in the configuration files. + + 2. Merge-in the variables in ``overrides`` by calling + `Env._merge()`. The intended use of ``overrides`` is to merge-in + variables specified on the command-line. + + 3. Intelligently fill-in the *in_tree*, *context*, *conf*, and + *conf_default* variables if they haven't been set already. + + Also see `Env._finalize_core()`, the next method in the bootstrap + sequence. + + :param overrides: Variables specified via command-line options. + """ + self.__doing('_bootstrap') + + # Set run-time variables: + self.ipalib = path.dirname(path.abspath(__file__)) + self.site_packages = path.dirname(self.ipalib) + self.script = path.abspath(sys.argv[0]) + self.bin = path.dirname(self.script) + self.home = path.abspath(os.environ['HOME']) + self.dot_ipa = path.join(self.home, '.ipa') + self._merge(**overrides) + if 'in_tree' not in self: + if self.bin == self.site_packages and \ + path.isfile(path.join(self.bin, 'setup.py')): + self.in_tree = True + else: + self.in_tree = False + if 'context' not in self: + self.context = 'default' + if self.in_tree: + base = self.dot_ipa + else: + base = path.join('/', 'etc', 'ipa') + if 'conf' not in self: + self.conf = path.join(base, '%s.conf' % self.context) + if 'conf_default' not in self: + self.conf_default = path.join(base, 'default.conf') + if 'conf_dir' not in self: + self.conf_dir = base + + def _finalize_core(self, **defaults): + """ + Complete initialization of standard IPA environment. + + This method will perform the following steps: + + 1. Call `Env._bootstrap()` if it hasn't already been called. + + 2. Merge-in variables from the configuration file ``self.conf`` + (if it exists) by calling `Env._merge_from_file()`. + + 3. Merge-in variables from the defaults configuration file + ``self.conf_default`` (if it exists) by calling + `Env._merge_from_file()`. + + 4. Intelligently fill-in the *in_server* and *log* variables + if they haven't already been set. + + 5. Merge-in the variables in ``defaults`` by calling `Env._merge()`. + In normal circumstances ``defaults`` will simply be those + specified in `constants.DEFAULT_CONFIG`. + + After this method is called, all the environment variables used by all + the built-in plugins will be available. As such, this method should be + called *before* any plugins are loaded. + + After this method has finished, the `Env` instance is still writable + so that 3rd-party plugins can set variables they may require as the + plugins are registered. + + Also see `Env._finalize()`, the final method in the bootstrap sequence. + + :param defaults: Internal defaults for all built-in variables. + """ + self.__doing('_finalize_core') + self.__do_if_not_done('_bootstrap') + if self.__d.get('mode', None) != 'dummy': + self._merge_from_file(self.conf) + self._merge_from_file(self.conf_default) + if 'in_server' not in self: + self.in_server = (self.context == 'server') + if 'log' not in self: + name = '%s.log' % self.context + if self.in_tree or self.context == 'cli': + self.log = path.join(self.dot_ipa, 'log', name) + else: + self.log = path.join('/', 'var', 'log', 'ipa', name) + self._merge(**defaults) + + def _finalize(self, **lastchance): + """ + Finalize and lock environment. + + This method will perform the following steps: + + 1. Call `Env._finalize_core()` if it hasn't already been called. + + 2. Merge-in the variables in ``lastchance`` by calling + `Env._merge()`. + + 3. Lock this `Env` instance, after which no more environment + variables can be set on this instance. Aside from unit-tests + and example code, normally only one `Env` instance is created, + which means that after this step, no more variables can be set + during the remaining life of the process. + + This method should be called after all plugins have been loaded and + after `plugable.API.finalize()` has been called. + + :param lastchance: Any final variables to merge-in before locking. + """ + self.__doing('_finalize') + self.__do_if_not_done('_finalize_core') + self._merge(**lastchance) + self.__lock__() diff --git a/ipalib/constants.py b/ipalib/constants.py new file mode 100644 index 000000000..5687c53e6 --- /dev/null +++ b/ipalib/constants.py @@ -0,0 +1,153 @@ +# Authors: +# Martin Nagy <mnagy@redhat.com> +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +All constants centralised in one file. +""" + +# The parameter system treats all these values as None: +NULLS = (None, '', u'', tuple(), []) + +# regular expression NameSpace member names must match: +NAME_REGEX = r'^[a-z][_a-z0-9]*[a-z0-9]$' + +# Format for ValueError raised when name does not match above regex: +NAME_ERROR = 'name must match %r; got %r' + +# Standard format for TypeError message: +TYPE_ERROR = '%s: need a %r; got %r (a %r)' + +# Stardard format for TypeError message when a callable is expected: +CALLABLE_ERROR = '%s: need a callable; got %r (which is a %r)' + +# Standard format for StandardError message when overriding an attribute: +OVERRIDE_ERROR = 'cannot override %s.%s value %r with %r' + +# Standard format for AttributeError message when a read-only attribute is +# already locked: +SET_ERROR = 'locked: cannot set %s.%s to %r' +DEL_ERROR = 'locked: cannot delete %s.%s' + +# Used for a tab (or indentation level) when formatting for CLI: +CLI_TAB = ' ' # Two spaces + +# The section to read in the config files, i.e. [global] +CONFIG_SECTION = 'global' + + +# Log format for console output +LOG_FORMAT_STDERR = ': '.join([ + '%(name)s', + '%(levelname)s', + '%(message)s', +]) + + +# Log format for console output when env.dubug is True: +LOG_FORMAT_STDERR_DEBUG = ' '.join([ + '%(levelname)s', + '%(message)r', + '%(lineno)d', + '%(filename)s', +]) + + +# Tab-delimited log format for file (easy to opened in a spreadsheet): +LOG_FORMAT_FILE = '\t'.join([ + '%(asctime)s', + '%(levelname)s', + '%(message)r', # Using %r for repr() so message is a single line + '%(lineno)d', + '%(pathname)s', +]) + + +# The default configuration for api.env +# This is a tuple instead of a dict so that it is immutable. +# To create a dict with this config, just "d = dict(DEFAULT_CONFIG)". +DEFAULT_CONFIG = ( + # Domain, realm, basedn: + ('domain', 'example.com'), + ('realm', 'EXAMPLE.COM'), + ('basedn', 'dc=example,dc=com'), + + # LDAP containers: + ('container_accounts', 'cn=accounts'), + ('container_user', 'cn=users,cn=accounts'), + ('container_group', 'cn=groups,cn=accounts'), + ('container_service', 'cn=services,cn=accounts'), + ('container_host', 'cn=computers,cn=accounts'), + ('container_hostgroup', 'cn=hostgroups,cn=accounts'), + ('container_automount', 'cn=automount'), + + # Ports, hosts, and URIs: + ('lite_xmlrpc_port', 8888), + ('lite_webui_port', 9999), + ('xmlrpc_uri', 'http://localhost:8888'), + ('ldap_uri', 'ldap://localhost:389'), + ('ldap_host', 'localhost'), + ('ldap_port', 389), + + # Debugging: + ('verbose', False), + ('debug', False), + ('mode', 'production'), + + # Logging: + ('log_format_stderr', LOG_FORMAT_STDERR), + ('log_format_stderr_debug', LOG_FORMAT_STDERR_DEBUG), + ('log_format_file', LOG_FORMAT_FILE), + + # ******************************************************** + # The remaining keys are never set from the values here! + # ******************************************************** + # + # Env.__init__() or Env._bootstrap() or Env._finalize_core() + # will have filled in all the keys below by the time DEFAULT_CONFIG + # is merged in, so the values below are never actually used. They are + # listed both to provide a big picture and also so DEFAULT_CONFIG contains + # at least all the keys that should be present after Env._finalize_core() + # is called. + # + # Each environment variable below is sent to ``object``, which just happens + # to be an invalid value for an environment variable, so if for some reason + # any of these keys were set from the values here, an exception will be + # raised. + + # Set in Env.__init__(): + ('ipalib', object), # The directory containing ipalib/__init__.py + ('site_packages', object), # The directory contaning ipalib + ('script', object), # sys.argv[0] + ('bin', object), # The directory containing script + ('home', object), # The home directory of user underwhich process is running + ('dot_ipa', object), # ~/.ipa directory + + # Set in Env._bootstrap(): + ('in_tree', object), # Whether or not running in-tree (bool) + ('context', object), # Name of context, default is 'default' + ('conf', object), # Path to config file + ('conf_default', object), # Path to common default config file + ('conf_dir', object), # Directory containing config files + + # Set in Env._finalize_core(): + ('in_server', object), # Whether or not running in-server (bool) + ('log', object), # Path to log file + +) diff --git a/ipalib/crud.py b/ipalib/crud.py new file mode 100644 index 000000000..345fc2700 --- /dev/null +++ b/ipalib/crud.py @@ -0,0 +1,149 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Base classes for standard CRUD operations. +""" + +import backend, frontend, errors + + +class Add(frontend.Method): + def get_args(self): + yield self.obj.primary_key + for arg in self.takes_args: + yield arg + + def get_options(self): + for param in self.obj.params_minus_pk(): + yield param + for option in self.takes_options: + yield option + + +class Get(frontend.Method): + def get_args(self): + yield self.obj.primary_key + + +class Del(frontend.Method): + def get_args(self): + yield self.obj.primary_key + + def get_options(self): + for option in self.takes_options: + yield option + + +class Mod(frontend.Method): + def get_args(self): + yield self.obj.primary_key + + def get_options(self): + for param in self.obj.params_minus_pk(): + yield param.clone(required=False, query=True) + for option in self.takes_options: + yield option + + +class Find(frontend.Method): + def get_args(self): + yield self.obj.primary_key + + def get_options(self): + for param in self.obj.params_minus_pk(): + yield param.clone(required=False, query=True) + for option in self.takes_options: + yield option + + +class CrudBackend(backend.Backend): + """ + Base class defining generic CRUD backend API. + """ + + def create(self, **kw): + """ + Create a new entry. + + This method should take key word arguments representing the + attributes the created entry will have. + + If this methods constructs the primary_key internally, it should raise + an exception if the primary_key was passed. Likewise, if this method + requires the primary_key to be passed in from the caller, it should + raise an exception if the primary key was *not* passed. + + This method should return a dict of the exact entry as it was created + in the backing store, including any automatically created attributes. + """ + raise NotImplementedError('%s.create()' % self.name) + + def retrieve(self, primary_key, attributes): + """ + Retrieve an existing entry. + + This method should take a two arguments: the primary_key of the + entry in question and a list of the attributes to be retrieved. + If the list of attributes is None then all non-operational + attributes will be returned. + + If such an entry exists, this method should return a dict + representing that entry. If no such entry exists, this method + should return None. + """ + raise NotImplementedError('%s.retrieve()' % self.name) + + def update(self, primary_key, **kw): + """ + Update an existing entry. + + This method should take one required argument, the primary_key of the + entry to modify, plus optional keyword arguments for each of the + attributes being updated. + + This method should return a dict representing the entry as it now + exists in the backing store. If no such entry exists, this method + should return None. + """ + raise NotImplementedError('%s.update()' % self.name) + + def delete(self, primary_key): + """ + Delete an existing entry. + + This method should take one required argument, the primary_key of the + entry to delete. + """ + raise NotImplementedError('%s.delete()' % self.name) + + def search(self, **kw): + """ + Return entries matching specific criteria. + + This method should take keyword arguments representing the search + criteria. If a key is the name of an entry attribute, the value + should be treated as a filter on that attribute. The meaning of + keys outside this namespace is left to the implementation. + + This method should return and iterable containing the matched + entries, where each entry is a dict. If no entries are matched, + this method should return an empty iterable. + """ + raise NotImplementedError('%s.search()' % self.name) diff --git a/ipalib/errors.py b/ipalib/errors.py new file mode 100644 index 000000000..beb6342d9 --- /dev/null +++ b/ipalib/errors.py @@ -0,0 +1,465 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty inmsgion +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +All custom errors raised by `ipalib` package. + +Also includes a few utility functions for raising exceptions. +""" + +IPA_ERROR_BASE = 1000 + +TYPE_FORMAT = '%s: need a %r; got %r' + +def raise_TypeError(value, type_, name): + """ + Raises a TypeError with a nicely formatted message and helpful attributes. + + The TypeError raised will have three custom attributes: + + ``value`` - The value (of incorrect type) passed as argument. + + ``type`` - The type expected for the argument. + + ``name`` - The name (identifier) of the argument in question. + + There is no edict that all TypeError should be raised with raise_TypeError, + but when it fits, use it... it makes the unit tests faster to write and + the debugging easier to read. + + Here is an example: + + >>> raise_TypeError(u'Hello, world!', str, 'message') + Traceback (most recent call last): + File "<stdin>", line 1, in <module> + File "ipalib/errors.py", line 65, in raise_TypeError + raise e + TypeError: message: need a <type 'str'>; got u'Hello, world!' + + :param value: The value (of incorrect type) passed as argument. + :param type_: The type expected for the argument. + :param name: The name (identifier) of the argument in question. + """ + + assert type(type_) is type, TYPE_FORMAT % ('type_', type, type_) + assert type(value) is not type_, 'value: %r is a %r' % (value, type_) + assert type(name) is str, TYPE_FORMAT % ('name', str, name) + e = TypeError(TYPE_FORMAT % (name, type_, value)) + setattr(e, 'value', value) + setattr(e, 'type', type_) + setattr(e, 'name', name) + raise e + + +def check_type(value, type_, name, allow_none=False): + assert type(name) is str, TYPE_FORMAT % ('name', str, name) + assert type(type_) is type, TYPE_FORMAT % ('type_', type, type_) + assert type(allow_none) is bool, TYPE_FORMAT % ('allow_none', bool, allow_none) + if value is None and allow_none: + return + if type(value) is not type_: + raise_TypeError(value, type_, name) + return value + + +def check_isinstance(value, type_, name, allow_none=False): + assert type(type_) is type, TYPE_FORMAT % ('type_', type, type_) + assert type(name) is str, TYPE_FORMAT % ('name', str, name) + assert type(allow_none) is bool, TYPE_FORMAT % ('allow_none', bool, allow_none) + if value is None and allow_none: + return + if not isinstance(value, type_): + raise_TypeError(value, type_, name) + return value + + +class IPAError(StandardError): + """ + Base class for all custom IPA errors. + + Use this base class for your custom IPA errors unless there is a + specific reason to subclass from AttributeError, KeyError, etc. + """ + + format = None + faultCode = 1 + + def __init__(self, *args): + self.args = args + + def __str__(self): + """ + Returns the string representation of this exception. + """ + return self.format % self.args + + +class InvocationError(IPAError): + pass + + +class UnknownCommandError(InvocationError): + format = 'unknown command "%s"' + +class NoSuchNamespaceError(InvocationError): + format = 'api has no such namespace: %s' + +def _(text): + return text + + +class SubprocessError(StandardError): + def __init__(self, returncode, argv): + self.returncode = returncode + self.argv = argv + StandardError.__init__(self, + 'return code %d from %r' % (returncode, argv) + ) + +class HandledError(StandardError): + """ + Base class for errors that can be raised across a remote procedure call. + """ + + code = 1 + + def __init__(self, message=None, **kw): + self.kw = kw + if message is None: + message = self.format % kw + StandardError.__init__(self, message) + + +class UnknownError(HandledError): + """ + Raised when the true error is not a handled error. + """ + + format = _('An unknown internal error has occurred') + + +class CommandError(HandledError): + """ + Raised when an unknown command is called client-side. + """ + format = _('Unknown command %(name)r') + + +class RemoteCommandError(HandledError): + format = 'Server at %(uri)r has no command %(name)r' + + +class UnknownHelpError(InvocationError): + format = 'no command nor topic "%s"' + + +class ArgumentError(IPAError): + """ + Raised when a command is called with wrong number of arguments. + """ + + format = '%s %s' + + def __init__(self, command, error): + self.command = command + self.error = error + IPAError.__init__(self, command.name, error) + + +class ValidationError(IPAError): + """ + Base class for all types of validation errors. + """ + + format = 'invalid %r value %r: %s' + + def __init__(self, name, value, error, index=None): + """ + :param name: The name of the value that failed validation. + :param value: The value that failed validation. + :param error: The error message describing the failure. + :param index: If multivalue, index of value in multivalue tuple + """ + assert type(name) is str + assert index is None or (type(index) is int and index >= 0) + self.name = name + self.value = value + self.error = error + self.index = index + IPAError.__init__(self, name, value, error) + + +class ConversionError(ValidationError): + """ + Raised when a value cannot be converted to the correct type. + """ + + def __init__(self, name, value, type_, index=None): + self.type = type_ + ValidationError.__init__(self, name, value, type_.conversion_error, + index=index, + ) + + +class RuleError(ValidationError): + """ + Raised when a value fails a validation rule. + """ + def __init__(self, name, value, error, rule, index=None): + assert callable(rule) + self.rule = rule + ValidationError.__init__(self, name, value, error, index=index) + + +class RequirementError(ValidationError): + """ + Raised when a required option was not provided. + """ + def __init__(self, name): + ValidationError.__init__(self, name, None, 'Required') + + +class RegistrationError(IPAError): + """ + Base class for errors that occur during plugin registration. + """ + + +class SubclassError(RegistrationError): + """ + Raised when registering a plugin that is not a subclass of one of the + allowed bases. + """ + msg = 'plugin %r not subclass of any base in %r' + + def __init__(self, cls, allowed): + self.cls = cls + self.allowed = allowed + + def __str__(self): + return self.msg % (self.cls, self.allowed) + + +class DuplicateError(RegistrationError): + """ + Raised when registering a plugin whose exact class has already been + registered. + """ + msg = '%r at %d was already registered' + + def __init__(self, cls): + self.cls = cls + + def __str__(self): + return self.msg % (self.cls, id(self.cls)) + + +class OverrideError(RegistrationError): + """ + Raised when override=False yet registering a plugin that overrides an + existing plugin in the same namespace. + """ + msg = 'unexpected override of %s.%s with %r (use override=True if intended)' + + def __init__(self, base, cls): + self.base = base + self.cls = cls + + def __str__(self): + return self.msg % (self.base.__name__, self.cls.__name__, self.cls) + + +class MissingOverrideError(RegistrationError): + """ + Raised when override=True yet no preexisting plugin with the same name + and base has been registered. + """ + msg = '%s.%s has not been registered, cannot override with %r' + + def __init__(self, base, cls): + self.base = base + self.cls = cls + + def __str__(self): + return self.msg % (self.base.__name__, self.cls.__name__, self.cls) + +class GenericError(IPAError): + """Base class for our custom exceptions""" + faultCode = 1000 + fromFault = False + def __str__(self): + try: + return str(self.args[0]['args'][0]) + except: + try: + return str(self.args[0]) + except: + return str(self.__dict__) + +class DatabaseError(GenericError): + """A database error has occurred""" + faultCode = 1001 + +class MidairCollision(GenericError): + """Change collided with another change""" + faultCode = 1002 + +class NotFound(GenericError): + """Entry not found""" + faultCode = 1003 + +class DuplicateEntry(GenericError): + """This entry already exists""" + faultCode = 1004 + +class MissingDN(GenericError): + """The distinguished name (DN) is missing""" + faultCode = 1005 + +class EmptyModlist(GenericError): + """No modifications to be performed""" + faultCode = 1006 + +class InputError(GenericError): + """Error on input""" + faultCode = 1007 + +class SameGroupError(InputError): + """You can't add a group to itself""" + faultCode = 1008 + +class NotGroupMember(InputError): + """This entry is not a member of the group""" + faultCode = 1009 + +class AdminsImmutable(InputError): + """The admins group cannot be renamed""" + faultCode = 1010 + +class UsernameTooLong(InputError): + """The requested username is too long""" + faultCode = 1011 + +class PrincipalError(GenericError): + """There is a problem with the kerberos principal""" + faultCode = 1012 + +class MalformedServicePrincipal(PrincipalError): + """The requested service principal is not of the form: service/fully-qualified host name""" + faultCode = 1013 + +class RealmMismatch(PrincipalError): + """The realm for the principal does not match the realm for this IPA server""" + faultCode = 1014 + +class PrincipalRequired(PrincipalError): + """You cannot remove IPA server service principals""" + faultCode = 1015 + +class InactivationError(GenericError): + """This entry cannot be inactivated""" + faultCode = 1016 + +class AlreadyActiveError(InactivationError): + """This entry is already locked""" + faultCode = 1017 + +class AlreadyInactiveError(InactivationError): + """This entry is already unlocked""" + faultCode = 1018 + +class HasNSAccountLock(InactivationError): + """This entry appears to have the nsAccountLock attribute in it so the Class of Service activation/inactivation will not work. You will need to remove the attribute nsAccountLock for this to work.""" + faultCode = 1019 + +class ConnectionError(GenericError): + """Connection to database failed""" + faultCode = 1020 + +class NoCCacheError(GenericError): + """No Kerberos credentials cache is available. Connection cannot be made""" + faultCode = 1021 + +class GSSAPIError(GenericError): + """GSSAPI Authorization error""" + faultCode = 1022 + +class ServerUnwilling(GenericError): + """Account inactivated. Server is unwilling to perform""" + faultCode = 1023 + +class ConfigurationError(GenericError): + """A configuration error occurred""" + faultCode = 1024 + +class DefaultGroup(ConfigurationError): + """You cannot remove the default users group""" + faultCode = 1025 + +class HostService(ConfigurationError): + """You must enroll a host in order to create a host service""" + faultCode = 1026 + +class InsufficientAccess(GenericError): + """You do not have permission to perform this task""" + faultCode = 1027 + +class InvalidUserPrincipal(GenericError): + """Invalid user principal""" + faultCode = 1028 + +class FunctionDeprecated(GenericError): + """Raised by a deprecated function""" + faultCode = 2000 + +def convertFault(fault): + """Convert a fault to the corresponding Exception type, if possible""" + code = getattr(fault,'faultCode',None) + if code is None: + return fault + for v in globals().values(): + if type(v) == type(Exception) and issubclass(v,GenericError) and \ + code == getattr(v,'faultCode',None): + ret = v(fault.faultString) + ret.fromFault = True + return ret + #otherwise... + return fault + +def listFaults(): + """Return a list of faults + + Returns a list of dictionaries whose keys are: + faultCode: the numeric code used in fault conversion + name: the name of the exception + desc: the description of the exception (docstring) + """ + ret = [] + for n,v in globals().items(): + if type(v) == type(Exception) and issubclass(v,GenericError): + code = getattr(v,'faultCode',None) + if code is None: + continue + info = {} + info['faultCode'] = code + info['name'] = n + info['desc'] = getattr(v,'__doc__',None) + ret.append(info) + ret.sort(lambda a,b: cmp(a['faultCode'],b['faultCode'])) + return ret diff --git a/ipalib/errors2.py b/ipalib/errors2.py new file mode 100644 index 000000000..7e2eea058 --- /dev/null +++ b/ipalib/errors2.py @@ -0,0 +1,580 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty inmsgion +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Custom exception classes (some which are RPC transparent). + +`PrivateError` and its subclasses are custom IPA excetions that will *never* be +forwarded in a Remote Procedure Call (RPC) response. + +On the other hand, `PublicError` and its subclasses can be forwarded in an RPC +response. These public errors each carry a unique integer error code as well as +a gettext translated error message (translated at the time the exception is +raised). The purpose of the public errors is to relay information about +*expected* user errors, service availability errors, and so on. They should +*never* be used for *unexpected* programmatic or run-time errors. + +For security reasons it is *extremely* important that arbitrary exceptions *not* +be forwarded in an RPC response. Unexpected exceptions can easily contain +compromising information in their error messages. Any time the server catches +any exception that isn't a `PublicError` subclass, it should raise an +`InternalError`, which itself always has the same, static error message (and +therefore cannot be populated with information about the true exception). + +The public errors are arranging into five main blocks of error code ranges: + + ============= ======================================== + Error codes Exceptions + ============= ======================================== + 1000 - 1999 `AuthenticationError` and its subclasses + 2000 - 2999 `AuthorizationError` and its subclasses + 3000 - 3999 `InvocationError` and its subclasses + 4000 - 4999 `ExecutionError` and its subclasses + 5000 - 5999 `GenericError` and its subclasses + ============= ======================================== + +Within these five blocks some sub-ranges are already allocated for certain types +of error messages, while others are reserved for future use. Here are the +current block assignments: + + - **900-5999** `PublicError` and its subclasses + + - **901 - 907** Assigned to special top-level public errors + + - **908 - 999** *Reserved for future use* + + - **1000 - 1999** `AuthenticationError` and its subclasses + + - **1001 - 1099** Open for general authentication errors + + - **1100 - 1199** `KerberosError` and its subclasses + + - **1200 - 1999** *Reserved for future use* + + - **2000 - 2999** `AuthorizationError` and its subclasses + + - **2001 - 2099** Open for general authorization errors + + - **2100 - 2199** `ACIError` and its subclasses + + - **2200 - 2999** *Reserved for future use* + + - **3000 - 3999** `InvocationError` and its subclasses + + - **3001 - 3099** Open for general invocation errors + + - **3100 - 3199** *Reserved for future use* + + - **4000 - 4999** `ExecutionError` and its subclasses + + - **4001 - 4099** Open for general execution errors + + - **4100 - 4199** `LDAPError` and its subclasses + + - **4300 - 4999** *Reserved for future use* + + - **5000 - 5999** `GenericError` and its subclasses + + - **5001 - 5099** Open for generic errors + + - **5100 - 5999** *Reserved for future use* +""" + +from inspect import isclass +from request import ugettext, ungettext +from constants import TYPE_ERROR + + +class PrivateError(StandardError): + """ + Base class for exceptions that are *never* forwarded in an RPC response. + """ + + format = '' + + def __init__(self, **kw): + self.message = self.format % kw + for (key, value) in kw.iteritems(): + assert not hasattr(self, key), 'conflicting kwarg %s.%s = %r' % ( + self.__class__.__name__, key, value, + ) + setattr(self, key, value) + StandardError.__init__(self, self.message) + + +class SubprocessError(PrivateError): + """ + Raised when ``subprocess.call()`` returns a non-zero exit status. + + This custom exception is needed because Python 2.4 doesn't have the + ``subprocess.CalledProcessError`` exception (which was added in Python 2.5). + + For example: + + >>> raise SubprocessError(returncode=2, argv=('ls', '-lh', '/no-foo/')) + Traceback (most recent call last): + ... + SubprocessError: return code 2 from ('ls', '-lh', '/no-foo/') + + The exit code of the sub-process is available via the ``returncode`` + instance attribute. For example: + + >>> e = SubprocessError(returncode=1, argv=('/bin/false',)) + >>> e.returncode + 1 + >>> e.argv # argv is also available + ('/bin/false',) + """ + + format = 'return code %(returncode)d from %(argv)r' + + +class PluginSubclassError(PrivateError): + """ + Raised when a plugin doesn't subclass from an allowed base. + + For example: + + >>> raise PluginSubclassError(plugin='bad', bases=('base1', 'base2')) + Traceback (most recent call last): + ... + PluginSubclassError: 'bad' not subclass of any base in ('base1', 'base2') + + """ + + format = '%(plugin)r not subclass of any base in %(bases)r' + + +class PluginDuplicateError(PrivateError): + """ + Raised when the same plugin class is registered more than once. + + For example: + + >>> raise PluginDuplicateError(plugin='my_plugin') + Traceback (most recent call last): + ... + PluginDuplicateError: 'my_plugin' was already registered + """ + + format = '%(plugin)r was already registered' + + +class PluginOverrideError(PrivateError): + """ + Raised when a plugin overrides another without using ``override=True``. + + For example: + + >>> raise PluginOverrideError(base='Command', name='env', plugin='my_env') + Traceback (most recent call last): + ... + PluginOverrideError: unexpected override of Command.env with 'my_env' + """ + + format = 'unexpected override of %(base)s.%(name)s with %(plugin)r' + + +class PluginMissingOverrideError(PrivateError): + """ + Raised when a plugin overrides another that has not been registered. + + For example: + + >>> raise PluginMissingOverrideError(base='Command', name='env', plugin='my_env') + Traceback (most recent call last): + ... + PluginMissingOverrideError: Command.env not registered, cannot override with 'my_env' + """ + + format = '%(base)s.%(name)s not registered, cannot override with %(plugin)r' + + + +############################################################################## +# Public errors: + +__messages = [] + +def _(message): + __messages.append(message) + return message + + +class PublicError(StandardError): + """ + **900** Base class for exceptions that can be forwarded in an RPC response. + """ + + errno = 900 + format = None + + def __init__(self, format=None, message=None, **kw): + name = self.__class__.__name__ + if self.format is not None and format is not None: + raise ValueError( + 'non-generic %r needs format=None; got format=%r' % ( + name, format) + ) + if message is None: + if self.format is None: + if format is None: + raise ValueError( + '%s.format is None yet format=None, message=None' % name + ) + self.format = format + self.forwarded = False + self.message = self.format % kw + self.strerror = ugettext(self.format) % kw + else: + if type(message) is not unicode: + raise TypeError( + TYPE_ERROR % ('message', unicode, message, type(message)) + ) + self.forwarded = True + self.message = message + self.strerror = message + for (key, value) in kw.iteritems(): + assert not hasattr(self, key), 'conflicting kwarg %s.%s = %r' % ( + name, key, value, + ) + setattr(self, key, value) + StandardError.__init__(self, self.message) + + +class VersionError(PublicError): + """ + **901** Raised when client and server versions are incompatible. + + For example: + + >>> raise VersionError(cver='2.0', sver='2.1', server='https://localhost') + Traceback (most recent call last): + ... + VersionError: 2.0 client incompatible with 2.1 server at 'https://localhost' + + """ + + errno = 901 + format = _('%(cver)s client incompatible with %(sver)s server at %(server)r') + + +class UnknownError(PublicError): + """ + **902** Raised when client does not know error it caught from server. + + For example: + + >>> raise UnknownError(code=57, server='localhost', error=u'a new error') + ... + Traceback (most recent call last): + ... + UnknownError: unknown error 57 from localhost: a new error + + """ + + errno = 902 + format = _('unknown error %(code)d from %(server)s: %(error)s') + + +class InternalError(PublicError): + """ + **903** Raised to conceal a non-public exception. + + For example: + + >>> raise InternalError() + Traceback (most recent call last): + ... + InternalError: an internal error has occured + """ + + errno = 903 + format = _('an internal error has occured') + + def __init__(self, message=None): + """ + Security issue: ignore any information given to constructor. + """ + PublicError.__init__(self) + + +class ServerInternalError(PublicError): + """ + **904** Raised when client catches an `InternalError` from server. + + For example: + + >>> raise ServerInternalError(server='https://localhost') + Traceback (most recent call last): + ... + ServerInternalError: an internal error has occured on server at 'https://localhost' + """ + + errno = 904 + format = _('an internal error has occured on server at %(server)r') + + +class CommandError(PublicError): + """ + **905** Raised when an unknown command is called. + + For example: + + >>> raise CommandError(name='foobar') + Traceback (most recent call last): + ... + CommandError: unknown command 'foobar' + """ + + errno = 905 + format = _('unknown command %(name)r') + + +class ServerCommandError(PublicError): + """ + **906** Raised when client catches a `CommandError` from server. + + For example: + + >>> e = CommandError(name='foobar') + >>> raise ServerCommandError(error=e.message, server='https://localhost') + Traceback (most recent call last): + ... + ServerCommandError: error on server 'https://localhost': unknown command 'foobar' + """ + + errno = 906 + format = _('error on server %(server)r: %(error)s') + + +class NetworkError(PublicError): + """ + **907** Raised when a network connection cannot be created. + + For example: + + >>> raise NetworkError(uri='ldap://localhost:389') + Traceback (most recent call last): + ... + NetworkError: cannot connect to 'ldap://localhost:389' + """ + + errno = 907 + format = _('cannot connect to %(uri)r') + + +class ServerNetworkError(PublicError): + """ + **908** Raised when client catches a `NetworkError` from server. + + For example: + + >>> e = NetworkError(uri='ldap://localhost:389') + >>> raise ServerNetworkError(error=e.message, server='https://localhost') + Traceback (most recent call last): + ... + ServerNetworkError: error on server 'https://localhost': cannot connect to 'ldap://localhost:389' + """ + + errno = 908 + format = _('error on server %(server)r: %(error)s') + + + +############################################################################## +# 1000 - 1999: Authentication errors +class AuthenticationError(PublicError): + """ + **1000** Base class for authentication errors (*1000 - 1999*). + """ + + errno = 1000 + + +class KerberosError(AuthenticationError): + """ + **1100** Base class for Kerberos authentication errors (*1100 - 1199*). + """ + + errno = 1100 + + + +############################################################################## +# 2000 - 2999: Authorization errors +class AuthorizationError(PublicError): + """ + **2000** Base class for authorization errors (*2000 - 2999*). + """ + + errno = 2000 + + +class ACIError(AuthorizationError): + """ + **2100** Base class for ACI authorization errors (*2100 - 2199*). + """ + + errno = 2100 + + + +############################################################################## +# 3000 - 3999: Invocation errors + +class InvocationError(PublicError): + """ + **3000** Base class for command invocation errors (*3000 - 3999*). + """ + + errno = 3000 + + +class EncodingError(InvocationError): + """ + **3001** Raised when received text is incorrectly encoded. + """ + + errno = 3001 + + +class BinaryEncodingError(InvocationError): + """ + **3002** Raised when received binary data is incorrectly encoded. + """ + + errno = 3002 + + +class ArgumentError(InvocationError): + """ + **3003** Raised when a command is called with wrong number of arguments. + """ + + errno = 3003 + + +class OptionError(InvocationError): + """ + **3004** Raised when a command is called with unknown options. + """ + + errno = 3004 + + +class RequirementError(InvocationError): + """ + **3005** Raised when a required parameter is not provided. + + For example: + + >>> raise RequirementError(name='givenname') + Traceback (most recent call last): + ... + RequirementError: 'givenname' is required + """ + + errno = 3005 + format = _('%(name)r is required') + + +class ConversionError(InvocationError): + """ + **3006** Raised when parameter value can't be converted to correct type. + + For example: + + >>> raise ConversionError(name='age', error='must be an integer') + Traceback (most recent call last): + ... + ConversionError: invalid 'age': must be an integer + """ + + errno = 3006 + format = _('invalid %(name)r: %(error)s') + + +class ValidationError(InvocationError): + """ + **3007** Raised when a parameter value fails a validation rule. + + For example: + + >>> raise ValidationError(name='sn', error='can be at most 128 characters') + Traceback (most recent call last): + ... + ValidationError: invalid 'sn': can be at most 128 characters + """ + + errno = 3007 + format = _('invalid %(name)r: %(error)s') + + + +############################################################################## +# 4000 - 4999: Execution errors + +class ExecutionError(PublicError): + """ + **4000** Base class for execution errors (*4000 - 4999*). + """ + + errno = 4000 + + +class LDAPError(ExecutionError): + """ + **4100** Base class for LDAP execution errors (*4100 - 4199*). + """ + + errno = 4100 + + + +############################################################################## +# 5000 - 5999: Generic errors + +class GenericError(PublicError): + """ + **5000** Base class for errors that don't fit elsewhere (*5000 - 5999*). + """ + + errno = 5000 + + + +def __errors_iter(): + """ + Iterate through all the `PublicError` subclasses. + """ + for (key, value) in globals().items(): + if key.startswith('_') or not isclass(value): + continue + if issubclass(value, PublicError): + yield value + +public_errors = tuple( + sorted(__errors_iter(), key=lambda E: E.errno) +) + +if __name__ == '__main__': + for klass in public_errors: + print '%d\t%s' % (klass.code, klass.__name__) + print '(%d public errors)' % len(public_errors) diff --git a/ipalib/frontend.py b/ipalib/frontend.py new file mode 100644 index 000000000..b30205fe8 --- /dev/null +++ b/ipalib/frontend.py @@ -0,0 +1,696 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Base classes for all front-end plugins. +""" + +import re +import inspect +import plugable +from plugable import lock, check_name +import errors +from errors import check_type, check_isinstance, raise_TypeError +from parameters import create_param, Param, Str, Flag +from util import make_repr + + +RULE_FLAG = 'validation_rule' + +def rule(obj): + assert not hasattr(obj, RULE_FLAG) + setattr(obj, RULE_FLAG, True) + return obj + +def is_rule(obj): + return callable(obj) and getattr(obj, RULE_FLAG, False) is True + + +class Command(plugable.Plugin): + """ + A public IPA atomic operation. + + All plugins that subclass from `Command` will be automatically available + as a CLI command and as an XML-RPC method. + + Plugins that subclass from Command are registered in the ``api.Command`` + namespace. For example: + + >>> from ipalib import create_api + >>> api = create_api() + >>> class my_command(Command): + ... pass + ... + >>> api.register(my_command) + >>> api.finalize() + >>> list(api.Command) + ['my_command'] + >>> api.Command.my_command # doctest:+ELLIPSIS + PluginProxy(Command, ...my_command()) + """ + + __public__ = frozenset(( + 'get_default', + 'convert', + 'normalize', + 'validate', + 'execute', + '__call__', + 'args', + 'options', + 'params', + 'args_to_kw', + 'params_2_args_options', + 'output_for_cli', + )) + takes_options = tuple() + takes_args = tuple() + args = None + options = None + params = None + output_for_cli = None + + def __call__(self, *args, **kw): + """ + Perform validation and then execute the command. + + If not in a server context, the call will be forwarded over + XML-RPC and the executed an the nearest IPA server. + """ + self.debug(make_repr(self.name, *args, **kw)) + if len(args) > 0: + arg_kw = self.args_to_kw(*args) + assert set(arg_kw).intersection(kw) == set() + kw.update(arg_kw) + kw = self.normalize(**kw) + kw = self.convert(**kw) + kw.update(self.get_default(**kw)) + self.validate(**kw) + (args, options) = self.params_2_args_options(kw) + result = self.run(*args, **options) + self.debug('%s result: %r', self.name, result) + return result + + def args_to_kw(self, *values): + """ + Map positional into keyword arguments. + """ + if self.max_args is not None and len(values) > self.max_args: + if self.max_args == 0: + raise errors.ArgumentError(self, 'takes no arguments') + if self.max_args == 1: + raise errors.ArgumentError(self, 'takes at most 1 argument') + raise errors.ArgumentError(self, + 'takes at most %d arguments' % len(self.args) + ) + return dict(self.__args_to_kw_iter(values)) + + def __args_to_kw_iter(self, values): + """ + Generator used by `Command.args_to_kw` method. + """ + multivalue = False + for (i, arg) in enumerate(self.args()): + assert not multivalue + if len(values) > i: + if arg.multivalue: + multivalue = True + if len(values) == i + 1 and type(values[i]) in (list, tuple): + yield (arg.name, values[i]) + else: + yield (arg.name, values[i:]) + else: + yield (arg.name, values[i]) + else: + break + + def args_options_2_params(self, args, options): + pass + + def params_2_args_options(self, params): + """ + Split params into (args, kw). + """ + args = tuple(params.get(name, None) for name in self.args) + options = dict( + (name, params.get(name, None)) for name in self.options + ) + return (args, options) + + def normalize(self, **kw): + """ + Return a dictionary of normalized values. + + For example: + + >>> class my_command(Command): + ... takes_options = ( + ... Param('first', normalizer=lambda value: value.lower()), + ... Param('last'), + ... ) + ... + >>> c = my_command() + >>> c.finalize() + >>> c.normalize(first=u'JOHN', last=u'DOE') + {'last': u'DOE', 'first': u'john'} + """ + return dict( + (k, self.params[k].normalize(v)) for (k, v) in kw.iteritems() + ) + + def convert(self, **kw): + """ + Return a dictionary of values converted to correct type. + + >>> from ipalib import Int + >>> class my_command(Command): + ... takes_args = ( + ... Int('one'), + ... 'two', + ... ) + ... + >>> c = my_command() + >>> c.finalize() + >>> c.convert(one=1, two=2) + {'two': u'2', 'one': 1} + """ + return dict( + (k, self.params[k].convert(v)) for (k, v) in kw.iteritems() + ) + + def get_default(self, **kw): + """ + Return a dictionary of defaults for all missing required values. + + For example: + + >>> from ipalib import Str + >>> class my_command(Command): + ... takes_args = [Str('color', default=u'Red')] + ... + >>> c = my_command() + >>> c.finalize() + >>> c.get_default() + {'color': u'Red'} + >>> c.get_default(color=u'Yellow') + {} + """ + return dict(self.__get_default_iter(kw)) + + def __get_default_iter(self, kw): + """ + Generator method used by `Command.get_default`. + """ + for param in self.params(): + if param.name in kw: + continue + if param.required or param.autofill: + default = param.get_default(**kw) + if default is not None: + yield (param.name, default) + + def validate(self, **kw): + """ + Validate all values. + + If any value fails the validation, `ipalib.errors.ValidationError` + (or a subclass thereof) will be raised. + """ + for param in self.params(): + value = kw.get(param.name, None) + if value is not None: + param.validate(value) + elif param.required: + raise errors.RequirementError(param.name) + + def run(self, *args, **kw): + """ + Dispatch to `Command.execute` or `Command.forward`. + + If running in a server context, `Command.execute` is called and the + actually work this command performs is executed locally. + + If running in a non-server context, `Command.forward` is called, + which forwards this call over XML-RPC to the exact same command + on the nearest IPA server and the actual work this command + performs is executed remotely. + """ + if self.api.env.in_server: + target = self.execute + else: + target = self.forward + object.__setattr__(self, 'run', target) + return target(*args, **kw) + + def execute(self, *args, **kw): + """ + Perform the actual work this command does. + + This method should be implemented only against functionality + in self.api.Backend. For example, a hypothetical + user_add.execute() might be implemented like this: + + >>> class user_add(Command): + ... def execute(self, **kw): + ... return self.api.Backend.ldap.add(**kw) + ... + """ + raise NotImplementedError('%s.execute()' % self.name) + + def forward(self, *args, **kw): + """ + Forward call over XML-RPC to this same command on server. + """ + return self.Backend.xmlrpc.forward_call(self.name, *args, **kw) + + def finalize(self): + """ + Finalize plugin initialization. + + This method creates the ``args``, ``options``, and ``params`` + namespaces. This is not done in `Command.__init__` because + subclasses (like `crud.Add`) might need to access other plugins + loaded in self.api to determine what their custom `Command.get_args` + and `Command.get_options` methods should yield. + """ + self.args = plugable.NameSpace(self.__create_args(), sort=False) + if len(self.args) == 0 or not self.args[-1].multivalue: + self.max_args = len(self.args) + else: + self.max_args = None + self.options = plugable.NameSpace( + (create_param(spec) for spec in self.get_options()), + sort=False + ) + def get_key(p): + if p.required: + if p.default_from is None: + return 0 + return 1 + return 2 + self.params = plugable.NameSpace( + sorted(tuple(self.args()) + tuple(self.options()), key=get_key), + sort=False + ) + super(Command, self).finalize() + + def get_args(self): + """ + Return iterable with arguments for Command.args namespace. + + Subclasses can override this to customize how the arguments + are determined. For an example of why this can be useful, + see `ipalib.crud.Mod`. + """ + return self.takes_args + + def get_options(self): + """ + Return iterable with options for Command.options namespace. + + Subclasses can override this to customize how the options + are determined. For an example of why this can be useful, + see `ipalib.crud.Mod`. + """ + return self.takes_options + + def __create_args(self): + """ + Generator used to create args namespace. + """ + optional = False + multivalue = False + for arg in self.get_args(): + arg = create_param(arg) + if optional and arg.required: + raise ValueError( + '%s: required argument after optional' % arg.name + ) + if multivalue: + raise ValueError( + '%s: only final argument can be multivalue' % arg.name + ) + if not arg.required: + optional = True + if arg.multivalue: + multivalue = True + yield arg + + +class LocalOrRemote(Command): + """ + A command that is explicitly executed locally or remotely. + + This is for commands that makes sense to execute either locally or + remotely to return a perhaps different result. The best example of + this is the `ipalib.plugins.f_misc.env` plugin which returns the + key/value pairs describing the configuration state: it can be + """ + + takes_options = ( + Flag('server?', + doc='Forward to server instead of running locally', + ), + ) + + def run(self, *args, **options): + """ + Dispatch to forward() or execute() based on ``server`` option. + + When running in a client context, this command is executed remotely if + ``options['server']`` is true; otherwise it is executed locally. + + When running in a server context, this command is always executed + locally and the value of ``options['server']`` is ignored. + """ + if options['server'] and not self.env.in_server: + return self.forward(*args, **options) + return self.execute(*args, **options) + + +class Object(plugable.Plugin): + __public__ = frozenset(( + 'backend', + 'methods', + 'properties', + 'params', + 'primary_key', + 'params_minus_pk', + 'get_dn', + )) + backend = None + methods = None + properties = None + params = None + primary_key = None + params_minus_pk = None + + # Can override in subclasses: + backend_name = None + takes_params = tuple() + + def set_api(self, api): + super(Object, self).set_api(api) + self.methods = plugable.NameSpace( + self.__get_attrs('Method'), sort=False + ) + self.properties = plugable.NameSpace( + self.__get_attrs('Property'), sort=False + ) + self.params = plugable.NameSpace( + self.__get_params(), sort=False + ) + pkeys = filter(lambda p: p.primary_key, self.params()) + if len(pkeys) > 1: + raise ValueError( + '%s (Object) has multiple primary keys: %s' % ( + self.name, + ', '.join(p.name for p in pkeys), + ) + ) + if len(pkeys) == 1: + self.primary_key = pkeys[0] + self.params_minus_pk = plugable.NameSpace( + filter(lambda p: not p.primary_key, self.params()), sort=False + ) + + if 'Backend' in self.api and self.backend_name in self.api.Backend: + self.backend = self.api.Backend[self.backend_name] + + def get_dn(self, primary_key): + """ + Construct an LDAP DN from a primary_key. + """ + raise NotImplementedError('%s.get_dn()' % self.name) + + def __get_attrs(self, name): + if name not in self.api: + return + namespace = self.api[name] + assert type(namespace) is plugable.NameSpace + for proxy in namespace(): # Equivalent to dict.itervalues() + if proxy.obj_name == self.name: + yield proxy.__clone__('attr_name') + + def __get_params(self): + props = self.properties.__todict__() + for spec in self.takes_params: + if type(spec) is str: + key = spec.rstrip('?*+') + else: + assert isinstance(spec, Param) + key = spec.name + if key in props: + yield props.pop(key).param + else: + yield create_param(spec) + def get_key(p): + if p.param.required: + if p.param.default_from is None: + return 0 + return 1 + return 2 + for prop in sorted(props.itervalues(), key=get_key): + yield prop.param + + +class Attribute(plugable.Plugin): + """ + Base class implementing the attribute-to-object association. + + `Attribute` plugins are associated with an `Object` plugin to group + a common set of commands that operate on a common set of parameters. + + The association between attribute and object is done using a simple + naming convention: the first part of the plugin class name (up to the + first underscore) is the object name, and rest is the attribute name, + as this table shows: + + =============== =========== ============== + Class name Object name Attribute name + =============== =========== ============== + noun_verb noun verb + user_add user add + user_first_name user first_name + =============== =========== ============== + + For example: + + >>> class user_add(Attribute): + ... pass + ... + >>> instance = user_add() + >>> instance.obj_name + 'user' + >>> instance.attr_name + 'add' + + In practice the `Attribute` class is not used directly, but rather is + only the base class for the `Method` and `Property` classes. Also see + the `Object` class. + """ + __public__ = frozenset(( + 'obj', + 'obj_name', + )) + __obj = None + + def __init__(self): + m = re.match( + '^([a-z][a-z0-9]+)_([a-z][a-z0-9]+)$', + self.__class__.__name__ + ) + assert m + self.__obj_name = m.group(1) + self.__attr_name = m.group(2) + super(Attribute, self).__init__() + + def __get_obj_name(self): + return self.__obj_name + obj_name = property(__get_obj_name) + + def __get_attr_name(self): + return self.__attr_name + attr_name = property(__get_attr_name) + + def __get_obj(self): + """ + Returns the obj instance this attribute is associated with, or None + if no association has been set. + """ + return self.__obj + obj = property(__get_obj) + + def set_api(self, api): + self.__obj = api.Object[self.obj_name] + super(Attribute, self).set_api(api) + + +class Method(Attribute, Command): + """ + A command with an associated object. + + A `Method` plugin must have a corresponding `Object` plugin. The + association between object and method is done through a simple naming + convention: the first part of the method name (up to the first under + score) is the object name, as the examples in this table show: + + ============= =========== ============== + Method name Object name Attribute name + ============= =========== ============== + user_add user add + noun_verb noun verb + door_open_now door open_now + ============= =========== ============== + + There are three different places a method can be accessed. For example, + say you created a `Method` plugin and its corresponding `Object` plugin + like this: + + >>> from ipalib import create_api + >>> api = create_api() + >>> class user_add(Method): + ... def run(self): + ... return 'Added the user!' + ... + >>> class user(Object): + ... pass + ... + >>> api.register(user_add) + >>> api.register(user) + >>> api.finalize() + + First, the ``user_add`` plugin can be accessed through the ``api.Method`` + namespace: + + >>> list(api.Method) + ['user_add'] + >>> api.Method.user_add() # Will call user_add.run() + 'Added the user!' + + Second, because `Method` is a subclass of `Command`, the ``user_add`` + plugin can also be accessed through the ``api.Command`` namespace: + + >>> list(api.Command) + ['user_add'] + >>> api.Command.user_add() # Will call user_add.run() + 'Added the user!' + + And third, ``user_add`` can be accessed as an attribute on the ``user`` + `Object`: + + >>> list(api.Object) + ['user'] + >>> list(api.Object.user.methods) + ['add'] + >>> api.Object.user.methods.add() # Will call user_add.run() + 'Added the user!' + + The `Attribute` base class implements the naming convention for the + attribute-to-object association. Also see the `Object` and the + `Property` classes. + """ + __public__ = Attribute.__public__.union(Command.__public__) + + def __init__(self): + super(Method, self).__init__() + + +class Property(Attribute): + __public__ = frozenset(( + 'rules', + 'param', + 'type', + )).union(Attribute.__public__) + + klass = Str + default = None + default_from = None + normalizer = None + + def __init__(self): + super(Property, self).__init__() + self.rules = tuple( + sorted(self.__rules_iter(), key=lambda f: getattr(f, '__name__')) + ) + self.kwargs = tuple( + sorted(self.__kw_iter(), key=lambda keyvalue: keyvalue[0]) + ) + kw = dict(self.kwargs) + self.param = self.klass(self.attr_name, *self.rules, **kw) + + def __kw_iter(self): + for (key, kind, default) in self.klass.kwargs: + if getattr(self, key, None) is not None: + yield (key, getattr(self, key)) + + def __rules_iter(self): + """ + Iterates through the attributes in this instance to retrieve the + methods implementing validation rules. + """ + for name in dir(self.__class__): + if name.startswith('_'): + continue + base_attr = getattr(self.__class__, name) + if is_rule(base_attr): + attr = getattr(self, name) + if is_rule(attr): + yield attr + + +class Application(Command): + """ + Base class for commands register by an external application. + + Special commands that only apply to a particular application built atop + `ipalib` should subclass from ``Application``. + + Because ``Application`` subclasses from `Command`, plugins that subclass + from ``Application`` with be available in both the ``api.Command`` and + ``api.Application`` namespaces. + """ + + __public__ = frozenset(( + 'application', + 'set_application' + )).union(Command.__public__) + __application = None + + def __get_application(self): + """ + Returns external ``application`` object. + """ + return self.__application + application = property(__get_application) + + def set_application(self, application): + """ + Sets the external application object to ``application``. + """ + if self.__application is not None: + raise AttributeError( + '%s.application can only be set once' % self.name + ) + if application is None: + raise TypeError( + '%s.application cannot be None' % self.name + ) + object.__setattr__(self, '_Application__application', application) + assert self.application is application diff --git a/ipalib/ipauuid.py b/ipalib/ipauuid.py new file mode 100644 index 000000000..9923dc7a9 --- /dev/null +++ b/ipalib/ipauuid.py @@ -0,0 +1,556 @@ +# This is a backport of the Python2.5 uuid module. + +r"""UUID objects (universally unique identifiers) according to RFC 4122. + +This module provides immutable UUID objects (class UUID) and the functions +uuid1(), uuid3(), uuid4(), uuid5() for generating version 1, 3, 4, and 5 +UUIDs as specified in RFC 4122. + +If all you want is a unique ID, you should probably call uuid1() or uuid4(). +Note that uuid1() may compromise privacy since it creates a UUID containing +the computer's network address. uuid4() creates a random UUID. + +Typical usage: + + **Important:** So that the freeIPA Python 2.4 ``uuid`` backport can be + automatically loaded when needed, import the ``uuid`` module like this: + + >>> from ipalib import uuid + + Make a UUID based on the host ID and current time: + + >>> uuid.uuid1() #doctest: +ELLIPSIS + UUID('...') + + Make a UUID using an MD5 hash of a namespace UUID and a name: + + >>> uuid.uuid3(uuid.NAMESPACE_DNS, 'python.org') + UUID('6fa459ea-ee8a-3ca4-894e-db77e160355e') + + Make a random UUID: + + >>> uuid.uuid4() #doctest: +ELLIPSIS + UUID('...') + + Make a UUID using a SHA-1 hash of a namespace UUID and a name: + + >>> uuid.uuid5(uuid.NAMESPACE_DNS, 'python.org') + UUID('886313e1-3b8a-5372-9b90-0c9aee199e5d') + + Make a UUID from a string of hex digits (braces and hyphens ignored): + + >>> x = uuid.UUID('{00010203-0405-0607-0809-0a0b0c0d0e0f}') + >>> x + UUID('00010203-0405-0607-0809-0a0b0c0d0e0f') + + Convert a UUID to a string of hex digits in standard form: + + >>> str(x) + '00010203-0405-0607-0809-0a0b0c0d0e0f' + + Get the raw 16 bytes of the UUID: + + >>> x.bytes + '\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f' + + Make a UUID from a 16-byte string: + + >>> uuid.UUID(bytes=x.bytes) + UUID('00010203-0405-0607-0809-0a0b0c0d0e0f') +""" + +__author__ = 'Ka-Ping Yee <ping@zesty.ca>' + +RESERVED_NCS, RFC_4122, RESERVED_MICROSOFT, RESERVED_FUTURE = [ + 'reserved for NCS compatibility', 'specified in RFC 4122', + 'reserved for Microsoft compatibility', 'reserved for future definition'] + +class UUID(object): + """Instances of the UUID class represent UUIDs as specified in RFC 4122. + UUID objects are immutable, hashable, and usable as dictionary keys. + Converting a UUID to a string with str() yields something in the form + '12345678-1234-1234-1234-123456789abc'. The UUID constructor accepts + five possible forms: a similar string of hexadecimal digits, or a tuple + of six integer fields (with 32-bit, 16-bit, 16-bit, 8-bit, 8-bit, and + 48-bit values respectively) as an argument named 'fields', or a string + of 16 bytes (with all the integer fields in big-endian order) as an + argument named 'bytes', or a string of 16 bytes (with the first three + fields in little-endian order) as an argument named 'bytes_le', or a + single 128-bit integer as an argument named 'int'. + + UUIDs have these read-only attributes: + + bytes the UUID as a 16-byte string (containing the six + integer fields in big-endian byte order) + + bytes_le the UUID as a 16-byte string (with time_low, time_mid, + and time_hi_version in little-endian byte order) + + fields a tuple of the six integer fields of the UUID, + which are also available as six individual attributes + and two derived attributes: + + time_low the first 32 bits of the UUID + time_mid the next 16 bits of the UUID + time_hi_version the next 16 bits of the UUID + clock_seq_hi_variant the next 8 bits of the UUID + clock_seq_low the next 8 bits of the UUID + node the last 48 bits of the UUID + + time the 60-bit timestamp + clock_seq the 14-bit sequence number + + hex the UUID as a 32-character hexadecimal string + + int the UUID as a 128-bit integer + + urn the UUID as a URN as specified in RFC 4122 + + variant the UUID variant (one of the constants RESERVED_NCS, + RFC_4122, RESERVED_MICROSOFT, or RESERVED_FUTURE) + + version the UUID version number (1 through 5, meaningful only + when the variant is RFC_4122) + """ + + def __init__(self, hex=None, bytes=None, bytes_le=None, fields=None, + int=None, version=None): + r"""Create a UUID from either a string of 32 hexadecimal digits, + a string of 16 bytes as the 'bytes' argument, a string of 16 bytes + in little-endian order as the 'bytes_le' argument, a tuple of six + integers (32-bit time_low, 16-bit time_mid, 16-bit time_hi_version, + 8-bit clock_seq_hi_variant, 8-bit clock_seq_low, 48-bit node) as + the 'fields' argument, or a single 128-bit integer as the 'int' + argument. When a string of hex digits is given, curly braces, + hyphens, and a URN prefix are all optional. For example, these + expressions all yield the same UUID: + + UUID('{12345678-1234-5678-1234-567812345678}') + UUID('12345678123456781234567812345678') + UUID('urn:uuid:12345678-1234-5678-1234-567812345678') + UUID(bytes='\x12\x34\x56\x78'*4) + UUID(bytes_le='\x78\x56\x34\x12\x34\x12\x78\x56' + + '\x12\x34\x56\x78\x12\x34\x56\x78') + UUID(fields=(0x12345678, 0x1234, 0x5678, 0x12, 0x34, 0x567812345678)) + UUID(int=0x12345678123456781234567812345678) + + Exactly one of 'hex', 'bytes', 'bytes_le', 'fields', or 'int' must + be given. The 'version' argument is optional; if given, the resulting + UUID will have its variant and version set according to RFC 4122, + overriding the given 'hex', 'bytes', 'bytes_le', 'fields', or 'int'. + """ + + if [hex, bytes, bytes_le, fields, int].count(None) != 4: + raise TypeError('need one of hex, bytes, bytes_le, fields, or int') + if hex is not None: + hex = hex.replace('urn:', '').replace('uuid:', '') + hex = hex.strip('{}').replace('-', '') + if len(hex) != 32: + raise ValueError('badly formed hexadecimal UUID string') + int = long(hex, 16) + if bytes_le is not None: + if len(bytes_le) != 16: + raise ValueError('bytes_le is not a 16-char string') + bytes = (bytes_le[3] + bytes_le[2] + bytes_le[1] + bytes_le[0] + + bytes_le[5] + bytes_le[4] + bytes_le[7] + bytes_le[6] + + bytes_le[8:]) + if bytes is not None: + if len(bytes) != 16: + raise ValueError('bytes is not a 16-char string') + int = long(('%02x'*16) % tuple(map(ord, bytes)), 16) + if fields is not None: + if len(fields) != 6: + raise ValueError('fields is not a 6-tuple') + (time_low, time_mid, time_hi_version, + clock_seq_hi_variant, clock_seq_low, node) = fields + if not 0 <= time_low < 1<<32L: + raise ValueError('field 1 out of range (need a 32-bit value)') + if not 0 <= time_mid < 1<<16L: + raise ValueError('field 2 out of range (need a 16-bit value)') + if not 0 <= time_hi_version < 1<<16L: + raise ValueError('field 3 out of range (need a 16-bit value)') + if not 0 <= clock_seq_hi_variant < 1<<8L: + raise ValueError('field 4 out of range (need an 8-bit value)') + if not 0 <= clock_seq_low < 1<<8L: + raise ValueError('field 5 out of range (need an 8-bit value)') + if not 0 <= node < 1<<48L: + raise ValueError('field 6 out of range (need a 48-bit value)') + clock_seq = (clock_seq_hi_variant << 8L) | clock_seq_low + int = ((time_low << 96L) | (time_mid << 80L) | + (time_hi_version << 64L) | (clock_seq << 48L) | node) + if int is not None: + if not 0 <= int < 1<<128L: + raise ValueError('int is out of range (need a 128-bit value)') + if version is not None: + if not 1 <= version <= 5: + raise ValueError('illegal version number') + # Set the variant to RFC 4122. + int &= ~(0xc000 << 48L) + int |= 0x8000 << 48L + # Set the version number. + int &= ~(0xf000 << 64L) + int |= version << 76L + self.__dict__['int'] = int + + def __cmp__(self, other): + if isinstance(other, UUID): + return cmp(self.int, other.int) + return NotImplemented + + def __hash__(self): + return hash(self.int) + + def __int__(self): + return self.int + + def __repr__(self): + return 'UUID(%r)' % str(self) + + def __setattr__(self, name, value): + raise TypeError('UUID objects are immutable') + + def __str__(self): + hex = '%032x' % self.int + return '%s-%s-%s-%s-%s' % ( + hex[:8], hex[8:12], hex[12:16], hex[16:20], hex[20:]) + + def get_bytes(self): + bytes = '' + for shift in range(0, 128, 8): + bytes = chr((self.int >> shift) & 0xff) + bytes + return bytes + + bytes = property(get_bytes) + + def get_bytes_le(self): + bytes = self.bytes + return (bytes[3] + bytes[2] + bytes[1] + bytes[0] + + bytes[5] + bytes[4] + bytes[7] + bytes[6] + bytes[8:]) + + bytes_le = property(get_bytes_le) + + def get_fields(self): + return (self.time_low, self.time_mid, self.time_hi_version, + self.clock_seq_hi_variant, self.clock_seq_low, self.node) + + fields = property(get_fields) + + def get_time_low(self): + return self.int >> 96L + + time_low = property(get_time_low) + + def get_time_mid(self): + return (self.int >> 80L) & 0xffff + + time_mid = property(get_time_mid) + + def get_time_hi_version(self): + return (self.int >> 64L) & 0xffff + + time_hi_version = property(get_time_hi_version) + + def get_clock_seq_hi_variant(self): + return (self.int >> 56L) & 0xff + + clock_seq_hi_variant = property(get_clock_seq_hi_variant) + + def get_clock_seq_low(self): + return (self.int >> 48L) & 0xff + + clock_seq_low = property(get_clock_seq_low) + + def get_time(self): + return (((self.time_hi_version & 0x0fffL) << 48L) | + (self.time_mid << 32L) | self.time_low) + + time = property(get_time) + + def get_clock_seq(self): + return (((self.clock_seq_hi_variant & 0x3fL) << 8L) | + self.clock_seq_low) + + clock_seq = property(get_clock_seq) + + def get_node(self): + return self.int & 0xffffffffffff + + node = property(get_node) + + def get_hex(self): + return '%032x' % self.int + + hex = property(get_hex) + + def get_urn(self): + return 'urn:uuid:' + str(self) + + urn = property(get_urn) + + def get_variant(self): + if not self.int & (0x8000 << 48L): + return RESERVED_NCS + elif not self.int & (0x4000 << 48L): + return RFC_4122 + elif not self.int & (0x2000 << 48L): + return RESERVED_MICROSOFT + else: + return RESERVED_FUTURE + + variant = property(get_variant) + + def get_version(self): + # The version bits are only meaningful for RFC 4122 UUIDs. + if self.variant == RFC_4122: + return int((self.int >> 76L) & 0xf) + + version = property(get_version) + +def _find_mac(command, args, hw_identifiers, get_index): + import os + for dir in ['', '/sbin/', '/usr/sbin']: + executable = os.path.join(dir, command) + if not os.path.exists(executable): + continue + + try: + # LC_ALL to get English output, 2>/dev/null to + # prevent output on stderr + cmd = 'LC_ALL=C %s %s 2>/dev/null' % (executable, args) + pipe = os.popen(cmd) + except IOError: + continue + + for line in pipe: + words = line.lower().split() + for i in range(len(words)): + if words[i] in hw_identifiers: + return int(words[get_index(i)].replace(':', ''), 16) + return None + +def _ifconfig_getnode(): + """Get the hardware address on Unix by running ifconfig.""" + + # This works on Linux ('' or '-a'), Tru64 ('-av'), but not all Unixes. + for args in ('', '-a', '-av'): + mac = _find_mac('ifconfig', args, ['hwaddr', 'ether'], lambda i: i+1) + if mac: + return mac + + import socket + ip_addr = socket.gethostbyname(socket.gethostname()) + + # Try getting the MAC addr from arp based on our IP address (Solaris). + mac = _find_mac('arp', '-an', [ip_addr], lambda i: -1) + if mac: + return mac + + # This might work on HP-UX. + mac = _find_mac('lanscan', '-ai', ['lan0'], lambda i: 0) + if mac: + return mac + + return None + +def _ipconfig_getnode(): + """Get the hardware address on Windows by running ipconfig.exe.""" + import os, re + dirs = ['', r'c:\windows\system32', r'c:\winnt\system32'] + try: + import ctypes + buffer = ctypes.create_string_buffer(300) + ctypes.windll.kernel32.GetSystemDirectoryA(buffer, 300) + dirs.insert(0, buffer.value.decode('mbcs')) + except: + pass + for dir in dirs: + try: + pipe = os.popen(os.path.join(dir, 'ipconfig') + ' /all') + except IOError: + continue + for line in pipe: + value = line.split(':')[-1].strip().lower() + if re.match('([0-9a-f][0-9a-f]-){5}[0-9a-f][0-9a-f]', value): + return int(value.replace('-', ''), 16) + +def _netbios_getnode(): + """Get the hardware address on Windows using NetBIOS calls. + See http://support.microsoft.com/kb/118623 for details.""" + import win32wnet, netbios + ncb = netbios.NCB() + ncb.Command = netbios.NCBENUM + ncb.Buffer = adapters = netbios.LANA_ENUM() + adapters._pack() + if win32wnet.Netbios(ncb) != 0: + return + adapters._unpack() + for i in range(adapters.length): + ncb.Reset() + ncb.Command = netbios.NCBRESET + ncb.Lana_num = ord(adapters.lana[i]) + if win32wnet.Netbios(ncb) != 0: + continue + ncb.Reset() + ncb.Command = netbios.NCBASTAT + ncb.Lana_num = ord(adapters.lana[i]) + ncb.Callname = '*'.ljust(16) + ncb.Buffer = status = netbios.ADAPTER_STATUS() + if win32wnet.Netbios(ncb) != 0: + continue + status._unpack() + bytes = map(ord, status.adapter_address) + return ((bytes[0]<<40L) + (bytes[1]<<32L) + (bytes[2]<<24L) + + (bytes[3]<<16L) + (bytes[4]<<8L) + bytes[5]) + +# Thanks to Thomas Heller for ctypes and for his help with its use here. + +# If ctypes is available, use it to find system routines for UUID generation. +_uuid_generate_random = _uuid_generate_time = _UuidCreate = None +try: + import ctypes, ctypes.util + _buffer = ctypes.create_string_buffer(16) + + # The uuid_generate_* routines are provided by libuuid on at least + # Linux and FreeBSD, and provided by libc on Mac OS X. + for libname in ['uuid', 'c']: + try: + lib = ctypes.CDLL(ctypes.util.find_library(libname)) + except: + continue + if hasattr(lib, 'uuid_generate_random'): + _uuid_generate_random = lib.uuid_generate_random + if hasattr(lib, 'uuid_generate_time'): + _uuid_generate_time = lib.uuid_generate_time + + # On Windows prior to 2000, UuidCreate gives a UUID containing the + # hardware address. On Windows 2000 and later, UuidCreate makes a + # random UUID and UuidCreateSequential gives a UUID containing the + # hardware address. These routines are provided by the RPC runtime. + # NOTE: at least on Tim's WinXP Pro SP2 desktop box, while the last + # 6 bytes returned by UuidCreateSequential are fixed, they don't appear + # to bear any relationship to the MAC address of any network device + # on the box. + try: + lib = ctypes.windll.rpcrt4 + except: + lib = None + _UuidCreate = getattr(lib, 'UuidCreateSequential', + getattr(lib, 'UuidCreate', None)) +except: + pass + +def _unixdll_getnode(): + """Get the hardware address on Unix using ctypes.""" + _uuid_generate_time(_buffer) + return UUID(bytes=_buffer.raw).node + +def _windll_getnode(): + """Get the hardware address on Windows using ctypes.""" + if _UuidCreate(_buffer) == 0: + return UUID(bytes=_buffer.raw).node + +def _random_getnode(): + """Get a random node ID, with eighth bit set as suggested by RFC 4122.""" + import random + return random.randrange(0, 1<<48L) | 0x010000000000L + +_node = None + +def getnode(): + """Get the hardware address as a 48-bit positive integer. + + The first time this runs, it may launch a separate program, which could + be quite slow. If all attempts to obtain the hardware address fail, we + choose a random 48-bit number with its eighth bit set to 1 as recommended + in RFC 4122. + """ + + global _node + if _node is not None: + return _node + + import sys + if sys.platform == 'win32': + getters = [_windll_getnode, _netbios_getnode, _ipconfig_getnode] + else: + getters = [_unixdll_getnode, _ifconfig_getnode] + + for getter in getters + [_random_getnode]: + try: + _node = getter() + except: + continue + if _node is not None: + return _node + +_last_timestamp = None + +def uuid1(node=None, clock_seq=None): + """Generate a UUID from a host ID, sequence number, and the current time. + If 'node' is not given, getnode() is used to obtain the hardware + address. If 'clock_seq' is given, it is used as the sequence number; + otherwise a random 14-bit sequence number is chosen.""" + + # When the system provides a version-1 UUID generator, use it (but don't + # use UuidCreate here because its UUIDs don't conform to RFC 4122). + if _uuid_generate_time and node is clock_seq is None: + _uuid_generate_time(_buffer) + return UUID(bytes=_buffer.raw) + + global _last_timestamp + import time + nanoseconds = int(time.time() * 1e9) + # 0x01b21dd213814000 is the number of 100-ns intervals between the + # UUID epoch 1582-10-15 00:00:00 and the Unix epoch 1970-01-01 00:00:00. + timestamp = int(nanoseconds/100) + 0x01b21dd213814000L + if timestamp <= _last_timestamp: + timestamp = _last_timestamp + 1 + _last_timestamp = timestamp + if clock_seq is None: + import random + clock_seq = random.randrange(1<<14L) # instead of stable storage + time_low = timestamp & 0xffffffffL + time_mid = (timestamp >> 32L) & 0xffffL + time_hi_version = (timestamp >> 48L) & 0x0fffL + clock_seq_low = clock_seq & 0xffL + clock_seq_hi_variant = (clock_seq >> 8L) & 0x3fL + if node is None: + node = getnode() + return UUID(fields=(time_low, time_mid, time_hi_version, + clock_seq_hi_variant, clock_seq_low, node), version=1) + +def uuid3(namespace, name): + """Generate a UUID from the MD5 hash of a namespace UUID and a name.""" + import md5 + hash = md5.md5(namespace.bytes + name).digest() + return UUID(bytes=hash[:16], version=3) + +def uuid4(): + """Generate a random UUID.""" + + # When the system provides a version-4 UUID generator, use it. + if _uuid_generate_random: + _uuid_generate_random(_buffer) + return UUID(bytes=_buffer.raw) + + # Otherwise, get randomness from urandom or the 'random' module. + try: + import os + return UUID(bytes=os.urandom(16), version=4) + except: + import random + bytes = [chr(random.randrange(256)) for i in range(16)] + return UUID(bytes=bytes, version=4) + +def uuid5(namespace, name): + """Generate a UUID from the SHA-1 hash of a namespace UUID and a name.""" + import sha + hash = sha.sha(namespace.bytes + name).digest() + return UUID(bytes=hash[:16], version=5) + +# The following standard UUIDs are for use with uuid3() or uuid5(). + +NAMESPACE_DNS = UUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_URL = UUID('6ba7b811-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_OID = UUID('6ba7b812-9dad-11d1-80b4-00c04fd430c8') +NAMESPACE_X500 = UUID('6ba7b814-9dad-11d1-80b4-00c04fd430c8') diff --git a/ipalib/parameters.py b/ipalib/parameters.py new file mode 100644 index 000000000..76d88347c --- /dev/null +++ b/ipalib/parameters.py @@ -0,0 +1,990 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Parameter system for command plugins. + +TODO: + + * Change rule call signature to rule(_, value, **kw) so that rules can also + validate relative to other parameter values (e.g., login name as it relates + to first name and last name) + + * Add the _rule_pattern() methods to `Bytes` and `Str` + + * Add maxvalue, minvalue kwargs and rules to `Int` and `Float` +""" + +from types import NoneType +from util import make_repr +from request import ugettext +from plugable import ReadOnly, lock, check_name +from errors2 import ConversionError, RequirementError, ValidationError +from constants import NULLS, TYPE_ERROR, CALLABLE_ERROR + +class DefaultFrom(ReadOnly): + """ + Derive a default value from other supplied values. + + For example, say you wanted to create a default for the user's login from + the user's first and last names. It could be implemented like this: + + >>> login = DefaultFrom(lambda first, last: first[0] + last) + >>> login(first='John', last='Doe') + 'JDoe' + + If you do not explicitly provide keys when you create a `DefaultFrom` + instance, the keys are implicitly derived from your callback by + inspecting ``callback.func_code.co_varnames``. The keys are available + through the ``DefaultFrom.keys`` instance attribute, like this: + + >>> login.keys + ('first', 'last') + + The callback is available through the ``DefaultFrom.callback`` instance + attribute, like this: + + >>> login.callback # doctest:+ELLIPSIS + <function <lambda> at 0x...> + >>> login.callback.func_code.co_varnames # The keys + ('first', 'last') + + The keys can be explicitly provided as optional positional arguments after + the callback. For example, this is equivalent to the ``login`` instance + above: + + >>> login2 = DefaultFrom(lambda a, b: a[0] + b, 'first', 'last') + >>> login2.keys + ('first', 'last') + >>> login2.callback.func_code.co_varnames # Not the keys + ('a', 'b') + >>> login2(first='John', last='Doe') + 'JDoe' + + If any keys are missing when calling your `DefaultFrom` instance, your + callback is not called and ``None`` is returned. For example: + + >>> login(first='John', lastname='Doe') is None + True + >>> login() is None + True + + Any additional keys are simply ignored, like this: + + >>> login(last='Doe', first='John', middle='Whatever') + 'JDoe' + + As above, because `DefaultFrom.__call__` takes only pure keyword + arguments, they can be supplied in any order. + + Of course, the callback need not be a ``lambda`` expression. This third + example is equivalent to both the ``login`` and ``login2`` instances + above: + + >>> def get_login(first, last): + ... return first[0] + last + ... + >>> login3 = DefaultFrom(get_login) + >>> login3.keys + ('first', 'last') + >>> login3.callback.func_code.co_varnames + ('first', 'last') + >>> login3(first='John', last='Doe') + 'JDoe' + """ + + def __init__(self, callback, *keys): + """ + :param callback: The callable to call when all keys are present. + :param keys: Optional keys used for source values. + """ + if not callable(callback): + raise TypeError( + CALLABLE_ERROR % ('callback', callback, type(callback)) + ) + self.callback = callback + if len(keys) == 0: + fc = callback.func_code + self.keys = fc.co_varnames[:fc.co_argcount] + else: + self.keys = keys + for key in self.keys: + if type(key) is not str: + raise TypeError( + TYPE_ERROR % ('keys', str, key, type(key)) + ) + lock(self) + + def __call__(self, **kw): + """ + Call the callback if all keys are present. + + If all keys are present, the callback is called and its return value is + returned. If any keys are missing, ``None`` is returned. + + :param kw: The keyword arguments. + """ + vals = tuple(kw.get(k, None) for k in self.keys) + if None in vals: + return + try: + return self.callback(*vals) + except StandardError: + pass + + +def parse_param_spec(spec): + """ + Parse shorthand ``spec`` into to ``(name, kw)``. + + The ``spec`` string determines the parameter name, whether the parameter is + required, and whether the parameter is multivalue according the following + syntax: + + ====== ===== ======== ========== + Spec Name Required Multivalue + ====== ===== ======== ========== + 'var' 'var' True False + 'var?' 'var' False False + 'var*' 'var' False True + 'var+' 'var' True True + ====== ===== ======== ========== + + For example, + + >>> parse_param_spec('login') + ('login', {'required': True, 'multivalue': False}) + >>> parse_param_spec('gecos?') + ('gecos', {'required': False, 'multivalue': False}) + >>> parse_param_spec('telephone_numbers*') + ('telephone_numbers', {'required': False, 'multivalue': True}) + >>> parse_param_spec('group+') + ('group', {'required': True, 'multivalue': True}) + + :param spec: A spec string. + """ + if type(spec) is not str: + raise TypeError( + TYPE_ERROR % ('spec', str, spec, type(spec)) + ) + if len(spec) < 2: + raise ValueError( + 'spec must be at least 2 characters; got %r' % spec + ) + _map = { + '?': dict(required=False, multivalue=False), + '*': dict(required=False, multivalue=True), + '+': dict(required=True, multivalue=True), + } + end = spec[-1] + if end in _map: + return (spec[:-1], _map[end]) + return (spec, dict(required=True, multivalue=False)) + + +__messages = set() + +def _(message): + __messages.add(message) + return message + + +class Param(ReadOnly): + """ + Base class for all parameters. + """ + + # This is a dummy type so that most of the functionality of Param can be + # unit tested directly without always creating a subclass; however, a real + # (direct) subclass must *always* override this class attribute: + type = NoneType # Ouch, this wont be very useful in the real world! + + # Subclasses should override this with something more specific: + type_error = _('incorrect type') + + kwargs = ( + ('cli_name', str, None), + ('label', callable, None), + ('doc', str, ''), + ('required', bool, True), + ('multivalue', bool, False), + ('primary_key', bool, False), + ('normalizer', callable, None), + ('default_from', DefaultFrom, None), + ('create_default', callable, None), + ('autofill', bool, False), + ('query', bool, False), + ('flags', frozenset, frozenset()), + + # The 'default' kwarg gets appended in Param.__init__(): + # ('default', self.type, None), + ) + + def __init__(self, name, *rules, **kw): + # We keep these values to use in __repr__(): + self.param_spec = name + self.__kw = dict(kw) + + # Merge in kw from parse_param_spec(): + if not ('required' in kw or 'multivalue' in kw): + (name, kw_from_spec) = parse_param_spec(name) + kw.update(kw_from_spec) + self.name = check_name(name) + self.nice = '%s(%r)' % (self.__class__.__name__, self.param_spec) + + # Add 'default' to self.kwargs and makes sure no unknown kw were given: + assert type(self.type) is type + self.kwargs += (('default', self.type, None),) + if not set(t[0] for t in self.kwargs).issuperset(self.__kw): + extra = set(kw) - set(t[0] for t in self.kwargs) + raise TypeError( + '%s: takes no such kwargs: %s' % (self.nice, + ', '.join(repr(k) for k in sorted(extra)) + ) + ) + + # Merge in default for 'cli_name' if not given: + if kw.get('cli_name', None) is None: + kw['cli_name'] = self.name + + # Wrap 'default_from' in a DefaultFrom if not already: + df = kw.get('default_from', None) + if callable(df) and not isinstance(df, DefaultFrom): + kw['default_from'] = DefaultFrom(df) + + # We keep this copy with merged values also to use when cloning: + self.__clonekw = kw + + # Perform type validation on kw, add in class rules: + class_rules = [] + for (key, kind, default) in self.kwargs: + value = kw.get(key, default) + if value is not None: + if kind is frozenset: + if type(value) in (list, tuple): + value = frozenset(value) + elif type(value) is str: + value = frozenset([value]) + if ( + type(kind) is type and type(value) is not kind + or + type(kind) is tuple and not isinstance(value, kind) + ): + raise TypeError( + TYPE_ERROR % (key, kind, value, type(value)) + ) + elif kind is callable and not callable(value): + raise TypeError( + CALLABLE_ERROR % (key, value, type(value)) + ) + if hasattr(self, key): + raise ValueError('kwarg %r conflicts with attribute on %s' % ( + key, self.__class__.__name__) + ) + setattr(self, key, value) + rule_name = '_rule_%s' % key + if value is not None and hasattr(self, rule_name): + class_rules.append(getattr(self, rule_name)) + check_name(self.cli_name) + + # Check that only default_from or create_default was provided: + assert not hasattr(self, '_get_default'), self.nice + if callable(self.default_from): + if callable(self.create_default): + raise ValueError( + '%s: cannot have both %r and %r' % ( + self.nice, 'default_from', 'create_default') + ) + self._get_default = self.default_from + elif callable(self.create_default): + self._get_default = self.create_default + else: + self._get_default = None + + # Check that all the rules are callable + self.class_rules = tuple(class_rules) + self.rules = rules + self.all_rules = self.class_rules + self.rules + for rule in self.all_rules: + if not callable(rule): + raise TypeError( + '%s: rules must be callable; got %r' % (self.nice, rule) + ) + + # And we're done. + lock(self) + + def __repr__(self): + """ + Return an expresion that could construct this `Param` instance. + """ + return make_repr( + self.__class__.__name__, + self.param_spec, + **self.__kw + ) + + def __call__(self, value, **kw): + """ + One stop shopping. + """ + if value in NULLS: + value = self.get_default(**kw) + else: + value = self.convert(self.normalize(value)) + self.validate(value) + return value + + def clone(self, **overrides): + """ + Return a new `Param` instance similar to this one. + """ + kw = dict(self.__clonekw) + kw.update(overrides) + return self.__class__(self.name, **kw) + + def get_label(self): + """ + Return translated label using `request.ugettext`. + """ + if self.label is None: + return self.cli_name.decode('UTF-8') + return self.label(ugettext) + + def normalize(self, value): + """ + Normalize ``value`` using normalizer callback. + + For example: + + >>> param = Param('telephone', + ... normalizer=lambda value: value.replace('.', '-') + ... ) + >>> param.normalize(u'800.123.4567') + u'800-123-4567' + + If this `Param` instance was created with a normalizer callback and + ``value`` is a unicode instance, the normalizer callback is called and + *its* return value is returned. + + On the other hand, if this `Param` instance was *not* created with a + normalizer callback, if ``value`` is *not* a unicode instance, or if an + exception is caught when calling the normalizer callback, ``value`` is + returned unchanged. + + :param value: A proposed value for this parameter. + """ + if self.normalizer is None: + return value + if self.multivalue: + if type(value) in (tuple, list): + return tuple( + self._normalize_scalar(v) for v in value + ) + return (self._normalize_scalar(value),) # Return a tuple + return self._normalize_scalar(value) + + def _normalize_scalar(self, value): + """ + Normalize a scalar value. + + This method is called once for each value in a multivalue. + """ + if type(value) is not unicode: + return value + try: + return self.normalizer(value) + except StandardError: + return value + + def convert(self, value): + """ + Convert ``value`` to the Python type required by this parameter. + + For example: + + >>> scalar = Str('my_scalar') + >>> scalar.type + <type 'unicode'> + >>> scalar.convert(43.2) + u'43.2' + + (Note that `Str` is a subclass of `Param`.) + + All values in `constants.NULLS` will be converted to ``None``. For + example: + + >>> scalar.convert(u'') is None # An empty string + True + >>> scalar.convert([]) is None # An empty list + True + + Likewise, values in `constants.NULLS` will be filtered out of a + multivalue parameter. For example: + + >>> multi = Str('my_multi', multivalue=True) + >>> multi.convert([1.5, '', 17, None, u'Hello']) + (u'1.5', u'17', u'Hello') + >>> multi.convert([None, u'']) is None # Filters to an empty list + True + + Lastly, multivalue parameters will always return a ``tuple`` (assuming + they don't return ``None`` as in the last example above). For example: + + >>> multi.convert(42) # Called with a scalar value + (u'42',) + >>> multi.convert([0, 1]) # Called with a list value + (u'0', u'1') + + Note that how values are converted (and from what types they will be + converted) completely depends upon how a subclass implements its + `Param._convert_scalar()` method. For example, see + `Str._convert_scalar()`. + + :param value: A proposed value for this parameter. + """ + if value in NULLS: + return + if self.multivalue: + if type(value) not in (tuple, list): + value = (value,) + values = tuple( + self._convert_scalar(v, i) for (i, v) in filter( + lambda iv: iv[1] not in NULLS, enumerate(value) + ) + ) + if len(values) == 0: + return + return values + return self._convert_scalar(value) + + def _convert_scalar(self, value, index=None): + """ + Convert a single scalar value. + """ + if type(value) is self.type: + return value + raise ConversionError(name=self.name, index=index, + error=ugettext(self.type_error), + ) + + def validate(self, value): + """ + Check validity of ``value``. + + :param value: A proposed value for this parameter. + """ + if value is None: + if self.required: + raise RequirementError(name=self.name) + return + if self.query: + return + if self.multivalue: + if type(value) is not tuple: + raise TypeError( + TYPE_ERROR % ('value', tuple, value, type(value)) + ) + if len(value) < 1: + raise ValueError('value: empty tuple must be converted to None') + for (i, v) in enumerate(value): + self._validate_scalar(v, i) + else: + self._validate_scalar(value) + + def _validate_scalar(self, value, index=None): + if type(value) is not self.type: + if index is None: + name = 'value' + else: + name = 'value[%d]' % index + raise TypeError( + TYPE_ERROR % (name, self.type, value, type(value)) + ) + if index is not None and type(index) is not int: + raise TypeError( + TYPE_ERROR % ('index', int, index, type(index)) + ) + for rule in self.all_rules: + error = rule(ugettext, value) + if error is not None: + raise ValidationError( + name=self.name, + value=value, + index=index, + error=error, + rule=rule, + ) + + def get_default(self, **kw): + """ + Return the static default or construct and return a dynamic default. + + (In these examples, we will use the `Str` and `Bytes` classes, which + both subclass from `Param`.) + + The *default* static default is ``None``. For example: + + >>> s = Str('my_str') + >>> s.default is None + True + >>> s.get_default() is None + True + + However, you can provide your own static default via the ``default`` + keyword argument when you create your `Param` instance. For example: + + >>> s = Str('my_str', default=u'My Static Default') + >>> s.default + u'My Static Default' + >>> s.get_default() + u'My Static Default' + + If you need to generate a dynamic default from other supplied parameter + values, provide a callback via the ``default_from`` keyword argument. + This callback will be automatically wrapped in a `DefaultFrom` instance + if it isn't one already (see the `DefaultFrom` class for all the gory + details). For example: + + >>> login = Str('login', default=u'my-static-login-default', + ... default_from=lambda first, last: (first[0] + last).lower(), + ... ) + >>> isinstance(login.default_from, DefaultFrom) + True + >>> login.default_from.keys + ('first', 'last') + + Then when all the keys needed by the `DefaultFrom` instance are present, + the dynamic default is constructed and returned. For example: + + >>> kw = dict(last=u'Doe', first=u'John') + >>> login.get_default(**kw) + u'jdoe' + + Or if any keys are missing, your *static* default is returned. + For example: + + >>> kw = dict(first=u'John', department=u'Engineering') + >>> login.get_default(**kw) + u'my-static-login-default' + + The second, less common way to construct a dynamic default is to provide + a callback via the ``create_default`` keyword argument. Unlike a + ``default_from`` callback, your ``create_default`` callback will not get + wrapped in any dispatcher. Instead, it will be called directly, which + means your callback must accept arbitrary keyword arguments, although + whether your callback utilises these values is up to your + implementation. For example: + + >>> def make_csr(**kw): + ... print ' make_csr(%r)' % (kw,) # Note output below + ... return 'Certificate Signing Request' + ... + >>> csr = Bytes('csr', create_default=make_csr) + + Your ``create_default`` callback will be called with whatever keyword + arguments are passed to `Param.get_default()`. For example: + + >>> kw = dict(arbitrary='Keyword', arguments='Here') + >>> csr.get_default(**kw) + make_csr({'arguments': 'Here', 'arbitrary': 'Keyword'}) + 'Certificate Signing Request' + + And your ``create_default`` callback is called even if + `Param.get_default()` is called with *zero* keyword arguments. + For example: + + >>> csr.get_default() + make_csr({}) + 'Certificate Signing Request' + + The ``create_default`` callback will most likely be used as a + pre-execute hook to perform some special client-side operation. For + example, the ``csr`` parameter above might make a call to + ``/usr/bin/openssl``. However, often a ``create_default`` callback + could also be implemented as a ``default_from`` callback. When this is + the case, a ``default_from`` callback should be used as they are more + structured and therefore less error-prone. + + The ``default_from`` and ``create_default`` keyword arguments are + mutually exclusive. If you provide both, a ``ValueError`` will be + raised. For example: + + >>> homedir = Str('home', + ... default_from=lambda login: '/home/%s' % login, + ... create_default=lambda **kw: '/lets/use/this', + ... ) + Traceback (most recent call last): + ... + ValueError: Str('home'): cannot have both 'default_from' and 'create_default' + """ + if self._get_default is not None: + default = self._get_default(**kw) + if default is not None: + try: + return self.convert(self.normalize(default)) + except StandardError: + pass + return self.default + + +class Bool(Param): + """ + A parameter for boolean values (stored in the ``bool`` type). + """ + + type = bool + type_error = _('must be True or False') + + +class Flag(Bool): + """ + A boolean parameter that always gets filled in with a default value. + + This `Bool` subclass forces ``autofill=True`` in `Flag.__init__()`. If no + default is provided, it also fills in a default value of ``False``. + Lastly, unlike the `Bool` class, the default must be either ``True`` or + ``False`` and cannot be ``None``. + + For example: + + >>> flag = Flag('my_flag') + >>> (flag.autofill, flag.default) + (True, False) + + To have a default value of ``True``, create your `Flag` intance with + ``default=True``. For example: + + >>> flag = Flag('my_flag', default=True) + >>> (flag.autofill, flag.default) + (True, True) + + Also note that creating a `Flag` instance with ``autofill=False`` will have + no effect. For example: + + >>> flag = Flag('my_flag', autofill=False) + >>> flag.autofill + True + """ + + def __init__(self, name, *rules, **kw): + kw['autofill'] = True + if 'default' not in kw: + kw['default'] = False + if type(kw['default']) is not bool: + default = kw['default'] + raise TypeError( + TYPE_ERROR % ('default', bool, default, type(default)) + ) + super(Flag, self).__init__(name, *rules, **kw) + + +class Number(Param): + """ + Base class for the `Int` and `Float` parameters. + """ + + def _convert_scalar(self, value, index=None): + """ + Convert a single scalar value. + """ + if type(value) is self.type: + return value + if type(value) in (unicode, int, float): + try: + return self.type(value) + except ValueError: + pass + raise ConversionError(name=self.name, index=index, + error=ugettext(self.type_error), + ) + + +class Int(Number): + """ + A parameter for integer values (stored in the ``int`` type). + """ + + type = int + type_error = _('must be an integer') + + +class Float(Number): + """ + A parameter for floating-point values (stored in the ``float`` type). + """ + + type = float + type_error = _('must be a decimal number') + + +class Data(Param): + """ + Base class for the `Bytes` and `Str` parameters. + + Previously `Str` was as subclass of `Bytes`. Now the common functionality + has been split into this base class so that ``isinstance(foo, Bytes)`` wont + be ``True`` when ``foo`` is actually an `Str` instance (which is confusing). + """ + + kwargs = Param.kwargs + ( + ('minlength', int, None), + ('maxlength', int, None), + ('length', int, None), + ) + + def __init__(self, name, *rules, **kw): + super(Data, self).__init__(name, *rules, **kw) + + if not ( + self.length is None or + (self.minlength is None and self.maxlength is None) + ): + raise ValueError( + '%s: cannot mix length with minlength or maxlength' % self.nice + ) + + if self.minlength is not None and self.minlength < 1: + raise ValueError( + '%s: minlength must be >= 1; got %r' % (self.nice, self.minlength) + ) + + if self.maxlength is not None and self.maxlength < 1: + raise ValueError( + '%s: maxlength must be >= 1; got %r' % (self.nice, self.maxlength) + ) + + if None not in (self.minlength, self.maxlength): + if self.minlength > self.maxlength: + raise ValueError( + '%s: minlength > maxlength (minlength=%r, maxlength=%r)' % ( + self.nice, self.minlength, self.maxlength) + ) + elif self.minlength == self.maxlength: + raise ValueError( + '%s: minlength == maxlength; use length=%d instead' % ( + self.nice, self.minlength) + ) + + +class Bytes(Data): + """ + A parameter for binary data (stored in the ``str`` type). + + This class is named *Bytes* instead of *Str* so it's aligned with the + Python v3 ``(str, unicode) => (bytes, str)`` clean-up. See: + + http://docs.python.org/3.0/whatsnew/3.0.html + """ + + type = str + type_error = _('must be binary data') + + kwargs = Data.kwargs + ( + ('pattern', str, None), + ) + + def _rule_minlength(self, _, value): + """ + Check minlength constraint. + """ + assert type(value) is str + if len(value) < self.minlength: + return _('must be at least %(minlength)d bytes') % dict( + minlength=self.minlength, + ) + + def _rule_maxlength(self, _, value): + """ + Check maxlength constraint. + """ + assert type(value) is str + if len(value) > self.maxlength: + return _('can be at most %(maxlength)d bytes') % dict( + maxlength=self.maxlength, + ) + + def _rule_length(self, _, value): + """ + Check length constraint. + """ + assert type(value) is str + if len(value) != self.length: + return _('must be exactly %(length)d bytes') % dict( + length=self.length, + ) + + +class Str(Data): + """ + A parameter for Unicode text (stored in the ``unicode`` type). + + This class is named *Str* instead of *Unicode* so it's aligned with the + Python v3 ``(str, unicode) => (bytes, str)`` clean-up. See: + + http://docs.python.org/3.0/whatsnew/3.0.html + """ + + type = unicode + type_error = _('must be Unicode text') + + kwargs = Data.kwargs + ( + ('pattern', unicode, None), + ) + + def _convert_scalar(self, value, index=None): + """ + Convert a single scalar value. + """ + if type(value) is self.type: + return value + if type(value) in (int, float): + return self.type(value) + raise ConversionError(name=self.name, index=index, + error=ugettext(self.type_error), + ) + + def _rule_minlength(self, _, value): + """ + Check minlength constraint. + """ + assert type(value) is unicode + if len(value) < self.minlength: + return _('must be at least %(minlength)d characters') % dict( + minlength=self.minlength, + ) + + def _rule_maxlength(self, _, value): + """ + Check maxlength constraint. + """ + assert type(value) is unicode + if len(value) > self.maxlength: + return _('can be at most %(maxlength)d characters') % dict( + maxlength=self.maxlength, + ) + + def _rule_length(self, _, value): + """ + Check length constraint. + """ + assert type(value) is unicode + if len(value) != self.length: + return _('must be exactly %(length)d characters') % dict( + length=self.length, + ) + + +class Password(Str): + """ + A parameter for passwords (stored in the ``unicode`` type). + """ + + +class Enum(Param): + """ + Base class for parameters with enumerable values. + """ + + kwargs = Param.kwargs + ( + ('values', tuple, tuple()), + ) + + def __init__(self, name, *rules, **kw): + super(Enum, self).__init__(name, *rules, **kw) + for (i, v) in enumerate(self.values): + if type(v) is not self.type: + n = '%s values[%d]' % (self.nice, i) + raise TypeError( + TYPE_ERROR % (n, self.type, v, type(v)) + ) + + def _rule_values(self, _, value, **kw): + if value not in self.values: + return _('must be one of %(values)r') % dict( + values=self.values, + ) + + +class BytesEnum(Enum): + """ + Enumerable for binary data (stored in the ``str`` type). + """ + + type = unicode + + +class StrEnum(Enum): + """ + Enumerable for Unicode text (stored in the ``unicode`` type). + + For example: + + >>> enum = StrEnum('my_enum', values=(u'One', u'Two', u'Three')) + >>> enum.validate(u'Two') is None + True + >>> enum.validate(u'Four') + Traceback (most recent call last): + ... + ValidationError: invalid 'my_enum': must be one of (u'One', u'Two', u'Three') + """ + + type = unicode + + +def create_param(spec): + """ + Create an `Str` instance from the shorthand ``spec``. + + This function allows you to create `Str` parameters (the most common) from + a convenient shorthand that defines the parameter name, whether it is + required, and whether it is multivalue. (For the definition of the + shorthand syntax, see the `parse_param_spec()` function.) + + If ``spec`` is an ``str`` instance, it will be used to create a new `Str` + parameter, which will be returned. For example: + + >>> s = create_param('hometown?') + >>> s + Str('hometown?') + >>> (s.name, s.required, s.multivalue) + ('hometown', False, False) + + On the other hand, if ``spec`` is already a `Param` instance, it is + returned unchanged. For example: + + >>> b = Bytes('cert') + >>> create_param(b) is b + True + + As a plugin author, you will not call this function directly (which would + be no more convenient than simply creating the `Str` instance). Instead, + `frontend.Command` will call it for you when it evaluates the + ``takes_args`` and ``takes_options`` attributes, and `frontend.Object` + will call it for you when it evaluates the ``takes_params`` attribute. + + :param spec: A spec string or a `Param` instance. + """ + if isinstance(spec, Param): + return spec + if type(spec) is not str: + raise TypeError( + TYPE_ERROR % ('spec', (str, Param), spec, type(spec)) + ) + return Str(spec) diff --git a/ipalib/plugable.py b/ipalib/plugable.py new file mode 100644 index 000000000..b52db9008 --- /dev/null +++ b/ipalib/plugable.py @@ -0,0 +1,718 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Plugin framework. + +The classes in this module make heavy use of Python container emulation. If +you are unfamiliar with this Python feature, see +http://docs.python.org/ref/sequence-types.html +""" + +import re +import sys +import inspect +import threading +import logging +import os +from os import path +import subprocess +import errors2 +from config import Env +import util +from base import ReadOnly, NameSpace, lock, islocked, check_name +from constants import DEFAULT_CONFIG + + +class SetProxy(ReadOnly): + """ + A read-only container with set/sequence behaviour. + + This container acts as a proxy to an actual set-like object (a set, + frozenset, or dict) that is passed to the constructor. To the extent + possible in Python, this underlying set-like object cannot be modified + through the SetProxy... which just means you wont do it accidentally. + """ + def __init__(self, s): + """ + :param s: The target set-like object (a set, frozenset, or dict) + """ + allowed = (set, frozenset, dict) + if type(s) not in allowed: + raise TypeError('%r not in %r' % (type(s), allowed)) + self.__s = s + lock(self) + + def __len__(self): + """ + Return the number of items in this container. + """ + return len(self.__s) + + def __iter__(self): + """ + Iterate (in ascending order) through keys. + """ + for key in sorted(self.__s): + yield key + + def __contains__(self, key): + """ + Return True if this container contains ``key``. + + :param key: The key to test for membership. + """ + return key in self.__s + + +class DictProxy(SetProxy): + """ + A read-only container with mapping behaviour. + + This container acts as a proxy to an actual mapping object (a dict) that + is passed to the constructor. To the extent possible in Python, this + underlying mapping object cannot be modified through the DictProxy... + which just means you wont do it accidentally. + + Also see `SetProxy`. + """ + def __init__(self, d): + """ + :param d: The target mapping object (a dict) + """ + if type(d) is not dict: + raise TypeError('%r is not %r' % (type(d), dict)) + self.__d = d + super(DictProxy, self).__init__(d) + + def __getitem__(self, key): + """ + Return the value corresponding to ``key``. + + :param key: The key of the value you wish to retrieve. + """ + return self.__d[key] + + def __call__(self): + """ + Iterate (in ascending order by key) through values. + """ + for key in self: + yield self.__d[key] + + +class MagicDict(DictProxy): + """ + A mapping container whose values can be accessed as attributes. + + For example: + + >>> magic = MagicDict({'the_key': 'the value'}) + >>> magic['the_key'] + 'the value' + >>> magic.the_key + 'the value' + + This container acts as a proxy to an actual mapping object (a dict) that + is passed to the constructor. To the extent possible in Python, this + underlying mapping object cannot be modified through the MagicDict... + which just means you wont do it accidentally. + + Also see `DictProxy` and `SetProxy`. + """ + + def __getattr__(self, name): + """ + Return the value corresponding to ``name``. + + :param name: The name of the attribute you wish to retrieve. + """ + try: + return self[name] + except KeyError: + raise AttributeError('no magic attribute %r' % name) + + +class Plugin(ReadOnly): + """ + Base class for all plugins. + """ + __public__ = frozenset() + __proxy__ = True + __api = None + + def __init__(self): + cls = self.__class__ + self.name = cls.__name__ + self.module = cls.__module__ + self.fullname = '%s.%s' % (self.module, self.name) + self.doc = inspect.getdoc(cls) + if self.doc is None: + self.summary = '<%s>' % self.fullname + else: + self.summary = self.doc.split('\n\n', 1)[0] + log = logging.getLogger(self.fullname) + for name in ('debug', 'info', 'warning', 'error', 'critical', 'exception'): + if hasattr(self, name): + raise StandardError( + '%s.%s attribute (%r) conflicts with Plugin logger' % ( + self.name, name, getattr(self, name)) + ) + setattr(self, name, getattr(log, name)) + + def __get_api(self): + """ + Return `API` instance passed to `finalize()`. + + If `finalize()` has not yet been called, None is returned. + """ + return self.__api + api = property(__get_api) + + @classmethod + def implements(cls, arg): + """ + Return True if this class implements ``arg``. + + There are three different ways this method can be called: + + With a <type 'str'> argument, e.g.: + + >>> class base(Plugin): + ... __public__ = frozenset(['attr1', 'attr2']) + ... + >>> base.implements('attr1') + True + >>> base.implements('attr2') + True + >>> base.implements('attr3') + False + + With a <type 'frozenset'> argument, e.g.: + + With any object that has a `__public__` attribute that is + <type 'frozenset'>, e.g.: + + Unlike ProxyTarget.implemented_by(), this returns an abstract answer + because only the __public__ frozenset is checked... a ProxyTarget + need not itself have attributes for all names in __public__ + (subclasses might provide them). + """ + assert type(cls.__public__) is frozenset + if isinstance(arg, str): + return arg in cls.__public__ + if type(getattr(arg, '__public__', None)) is frozenset: + return cls.__public__.issuperset(arg.__public__) + if type(arg) is frozenset: + return cls.__public__.issuperset(arg) + raise TypeError( + "must be str, frozenset, or have frozenset '__public__' attribute" + ) + + @classmethod + def implemented_by(cls, arg): + """ + Return True if ``arg`` implements public interface of this class. + + This classmethod returns True if: + + 1. ``arg`` is an instance of or subclass of this class, and + + 2. ``arg`` (or ``arg.__class__`` if instance) has an attribute for + each name in this class's ``__public__`` frozenset. + + Otherwise, returns False. + + Unlike `Plugin.implements`, this returns a concrete answer because + the attributes of the subclass are checked. + + :param arg: An instance of or subclass of this class. + """ + if inspect.isclass(arg): + subclass = arg + else: + subclass = arg.__class__ + assert issubclass(subclass, cls), 'must be subclass of %r' % cls + for name in cls.__public__: + if not hasattr(subclass, name): + return False + return True + + def finalize(self): + """ + """ + lock(self) + + def set_api(self, api): + """ + Set reference to `API` instance. + """ + assert self.__api is None, 'set_api() can only be called once' + assert api is not None, 'set_api() argument cannot be None' + self.__api = api + if not isinstance(api, API): + return + for name in api: + assert not hasattr(self, name) + setattr(self, name, api[name]) + # FIXME: the 'log' attribute is depreciated. See Plugin.__init__() + for name in ('env', 'context', 'log'): + if hasattr(api, name): + assert not hasattr(self, name) + setattr(self, name, getattr(api, name)) + + def call(self, executable, *args): + """ + Call ``executable`` with ``args`` using subprocess.call(). + + If the call exits with a non-zero exit status, + `ipalib.errors2.SubprocessError` is raised, from which you can retrieve + the exit code by checking the SubprocessError.returncode attribute. + + This method does *not* return what ``executable`` sent to stdout... for + that, use `Plugin.callread()`. + """ + argv = (executable,) + args + self.debug('Calling %r', argv) + code = subprocess.call(argv) + if code != 0: + raise errors2.SubprocessError(returncode=code, argv=argv) + + def __repr__(self): + """ + Return 'module_name.class_name()' representation. + + This representation could be used to instantiate this Plugin + instance given the appropriate environment. + """ + return '%s.%s()' % ( + self.__class__.__module__, + self.__class__.__name__ + ) + + +class PluginProxy(SetProxy): + """ + Allow access to only certain attributes on a `Plugin`. + + Think of a proxy as an agreement that "I will have at most these + attributes". This is different from (although similar to) an interface, + which can be thought of as an agreement that "I will have at least these + attributes". + """ + + __slots__ = ( + '__base', + '__target', + '__name_attr', + '__public__', + 'name', + 'doc', + ) + + def __init__(self, base, target, name_attr='name'): + """ + :param base: A subclass of `Plugin`. + :param target: An instance ``base`` or a subclass of ``base``. + :param name_attr: The name of the attribute on ``target`` from which + to derive ``self.name``. + """ + if not inspect.isclass(base): + raise TypeError( + '`base` must be a class, got %r' % base + ) + if not isinstance(target, base): + raise ValueError( + '`target` must be an instance of `base`, got %r' % target + ) + self.__base = base + self.__target = target + self.__name_attr = name_attr + self.__public__ = base.__public__ + self.name = getattr(target, name_attr) + self.doc = target.doc + assert type(self.__public__) is frozenset + super(PluginProxy, self).__init__(self.__public__) + + def implements(self, arg): + """ + Return True if plugin being proxied implements ``arg``. + + This method simply calls the corresponding `Plugin.implements` + classmethod. + + Unlike `Plugin.implements`, this is not a classmethod as a + `PluginProxy` can only implement anything as an instance. + """ + return self.__base.implements(arg) + + def __clone__(self, name_attr): + """ + Return a `PluginProxy` instance similar to this one. + + The new `PluginProxy` returned will be identical to this one except + the proxy name might be derived from a different attribute on the + target `Plugin`. The same base and target will be used. + """ + return self.__class__(self.__base, self.__target, name_attr) + + def __getitem__(self, key): + """ + Return attribute named ``key`` on target `Plugin`. + + If this proxy allows access to an attribute named ``key``, that + attribute will be returned. If access is not allowed, + KeyError will be raised. + """ + if key in self.__public__: + return getattr(self.__target, key) + raise KeyError('no public attribute %s.%s' % (self.name, key)) + + def __getattr__(self, name): + """ + Return attribute named ``name`` on target `Plugin`. + + If this proxy allows access to an attribute named ``name``, that + attribute will be returned. If access is not allowed, + AttributeError will be raised. + """ + if name in self.__public__: + return getattr(self.__target, name) + raise AttributeError('no public attribute %s.%s' % (self.name, name)) + + def __call__(self, *args, **kw): + """ + Call target `Plugin` and return its return value. + + If `__call__` is not an attribute this proxy allows access to, + KeyError is raised. + """ + return self['__call__'](*args, **kw) + + def __repr__(self): + """ + Return a Python expression that could create this instance. + """ + return '%s(%s, %r)' % ( + self.__class__.__name__, + self.__base.__name__, + self.__target, + ) + + +class Registrar(DictProxy): + """ + Collects plugin classes as they are registered. + + The Registrar does not instantiate plugins... it only implements the + override logic and stores the plugins in a namespace per allowed base + class. + + The plugins are instantiated when `API.finalize()` is called. + """ + def __init__(self, *allowed): + """ + :param allowed: Base classes from which plugins accepted by this + Registrar must subclass. + """ + self.__allowed = dict((base, {}) for base in allowed) + self.__registered = set() + super(Registrar, self).__init__( + dict(self.__base_iter()) + ) + + def __base_iter(self): + for (base, sub_d) in self.__allowed.iteritems(): + assert inspect.isclass(base) + name = base.__name__ + assert not hasattr(self, name) + setattr(self, name, MagicDict(sub_d)) + yield (name, base) + + def __findbases(self, klass): + """ + Iterates through allowed bases that ``klass`` is a subclass of. + + Raises `errors2.PluginSubclassError` if ``klass`` is not a subclass of + any allowed base. + + :param klass: The plugin class to find bases for. + """ + assert inspect.isclass(klass) + found = False + for (base, sub_d) in self.__allowed.iteritems(): + if issubclass(klass, base): + found = True + yield (base, sub_d) + if not found: + raise errors2.PluginSubclassError( + plugin=klass, bases=self.__allowed.keys() + ) + + def __call__(self, klass, override=False): + """ + Register the plugin ``klass``. + + :param klass: A subclass of `Plugin` to attempt to register. + :param override: If true, override an already registered plugin. + """ + if not inspect.isclass(klass): + raise TypeError('plugin must be a class; got %r' % klass) + + # Raise DuplicateError if this exact class was already registered: + if klass in self.__registered: + raise errors2.PluginDuplicateError(plugin=klass) + + # Find the base class or raise SubclassError: + for (base, sub_d) in self.__findbases(klass): + # Check override: + if klass.__name__ in sub_d: + if not override: + # Must use override=True to override: + raise errors2.PluginOverrideError( + base=base.__name__, + name=klass.__name__, + plugin=klass, + ) + else: + if override: + # There was nothing already registered to override: + raise errors2.PluginMissingOverrideError( + base=base.__name__, + name=klass.__name__, + plugin=klass, + ) + + # The plugin is okay, add to sub_d: + sub_d[klass.__name__] = klass + + # The plugin is okay, add to __registered: + self.__registered.add(klass) + + +class LazyContext(object): + """ + On-demand creation of thread-local context attributes. + """ + + def __init__(self, api): + self.__api = api + self.__context = threading.local() + + def __getattr__(self, name): + if name not in self.__context.__dict__: + if name not in self.__api.Context: + raise AttributeError('no Context plugin for %r' % name) + value = self.__api.Context[name].get_value() + self.__context.__dict__[name] = value + return self.__context.__dict__[name] + + def __getitem__(self, key): + return self.__getattr__(key) + + + +class API(DictProxy): + """ + Dynamic API object through which `Plugin` instances are accessed. + """ + + def __init__(self, *allowed): + self.__d = dict() + self.__done = set() + self.register = Registrar(*allowed) + self.env = Env() + self.context = LazyContext(self) + super(API, self).__init__(self.__d) + + def __doing(self, name): + if name in self.__done: + raise StandardError( + '%s.%s() already called' % (self.__class__.__name__, name) + ) + self.__done.add(name) + + def __do_if_not_done(self, name): + if name not in self.__done: + getattr(self, name)() + + def isdone(self, name): + return name in self.__done + + def bootstrap(self, **overrides): + """ + Initialize environment variables and logging. + """ + self.__doing('bootstrap') + self.env._bootstrap(**overrides) + self.env._finalize_core(**dict(DEFAULT_CONFIG)) + log = logging.getLogger('ipa') + object.__setattr__(self, 'log', log) + if self.env.debug: + log.setLevel(logging.DEBUG) + else: + log.setLevel(logging.INFO) + + # Add stderr handler: + stderr = logging.StreamHandler() + format = self.env.log_format_stderr + if self.env.debug: + format = self.env.log_format_stderr_debug + stderr.setLevel(logging.DEBUG) + elif self.env.verbose: + stderr.setLevel(logging.INFO) + else: + stderr.setLevel(logging.WARNING) + stderr.setFormatter(util.LogFormatter(format)) + log.addHandler(stderr) + + # Add file handler: + if self.env.mode in ('dummy', 'unit_test'): + return # But not if in unit-test mode + log_dir = path.dirname(self.env.log) + if not path.isdir(log_dir): + try: + os.makedirs(log_dir) + except OSError: + log.warn('Could not create log_dir %r', log_dir) + return + handler = logging.FileHandler(self.env.log) + handler.setFormatter(util.LogFormatter(self.env.log_format_file)) + if self.env.debug: + handler.setLevel(logging.DEBUG) + else: + handler.setLevel(logging.INFO) + log.addHandler(handler) + + def bootstrap_with_global_options(self, options=None, context=None): + if options is None: + parser = util.add_global_options() + (options, args) = parser.parse_args( + list(s.decode('utf-8') for s in sys.argv[1:]) + ) + overrides = {} + if options.env is not None: + assert type(options.env) is list + for item in options.env: + try: + (key, value) = item.split('=', 1) + except ValueError: + # FIXME: this should raise an IPA exception with an + # error code. + # --Jason, 2008-10-31 + pass + overrides[str(key.strip())] = value.strip() + for key in ('conf', 'debug', 'verbose'): + value = getattr(options, key, None) + if value is not None: + overrides[key] = value + if context is not None: + overrides['context'] = context + self.bootstrap(**overrides) + + def load_plugins(self): + """ + Load plugins from all standard locations. + + `API.bootstrap` will automatically be called if it hasn't been + already. + """ + self.__doing('load_plugins') + self.__do_if_not_done('bootstrap') + if self.env.mode in ('dummy', 'unit_test'): + return + util.import_plugins_subpackage('ipalib') + if self.env.in_server: + util.import_plugins_subpackage('ipaserver') + + def finalize(self): + """ + Finalize the registration, instantiate the plugins. + + `API.bootstrap` will automatically be called if it hasn't been + already. + """ + self.__doing('finalize') + self.__do_if_not_done('load_plugins') + + class PluginInstance(object): + """ + Represents a plugin instance. + """ + + i = 0 + + def __init__(self, klass): + self.created = self.next() + self.klass = klass + self.instance = klass() + self.bases = [] + + @classmethod + def next(cls): + cls.i += 1 + return cls.i + + class PluginInfo(ReadOnly): + def __init__(self, p): + assert isinstance(p, PluginInstance) + self.created = p.created + self.name = p.klass.__name__ + self.module = str(p.klass.__module__) + self.plugin = '%s.%s' % (self.module, self.name) + self.bases = tuple(b.__name__ for b in p.bases) + lock(self) + + plugins = {} + def plugin_iter(base, subclasses): + for klass in subclasses: + assert issubclass(klass, base) + if klass not in plugins: + plugins[klass] = PluginInstance(klass) + p = plugins[klass] + assert base not in p.bases + p.bases.append(base) + if base.__proxy__: + yield PluginProxy(base, p.instance) + else: + yield p.instance + + for name in self.register: + base = self.register[name] + magic = getattr(self.register, name) + namespace = NameSpace( + plugin_iter(base, (magic[k] for k in magic)) + ) + assert not ( + name in self.__d or hasattr(self, name) + ) + self.__d[name] = namespace + object.__setattr__(self, name, namespace) + + for p in plugins.itervalues(): + p.instance.set_api(self) + assert p.instance.api is self + + for p in plugins.itervalues(): + p.instance.finalize() + object.__setattr__(self, '_API__finalized', True) + tuple(PluginInfo(p) for p in plugins.itervalues()) + object.__setattr__(self, 'plugins', + tuple(PluginInfo(p) for p in plugins.itervalues()) + ) diff --git a/ipalib/plugins/__init__.py b/ipalib/plugins/__init__.py new file mode 100644 index 000000000..544429ef3 --- /dev/null +++ b/ipalib/plugins/__init__.py @@ -0,0 +1,25 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Sub-package containing all core plugins. + +By convention, modules with frontend plugins are named f_*.py and modules +with backend plugins are named b_*.py. +""" diff --git a/ipalib/plugins/b_kerberos.py b/ipalib/plugins/b_kerberos.py new file mode 100644 index 000000000..cc8204976 --- /dev/null +++ b/ipalib/plugins/b_kerberos.py @@ -0,0 +1,34 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Backend plugin for Kerberos. + +This wraps the python-kerberos and python-krbV bindings. +""" + +from ipalib import api +from ipalib.backend import Backend + +class krb(Backend): + """ + Kerberos backend plugin. + """ + +api.register(krb) diff --git a/ipalib/plugins/b_xmlrpc.py b/ipalib/plugins/b_xmlrpc.py new file mode 100644 index 000000000..14f2a9bed --- /dev/null +++ b/ipalib/plugins/b_xmlrpc.py @@ -0,0 +1,102 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Backend plugin for XML-RPC client. + +This provides a lightwieght XML-RPC client using Python standard library +``xmlrpclib`` module. +""" + +import xmlrpclib +import socket +import httplib +import kerberos +from ipalib.backend import Backend +from ipalib.util import xmlrpc_marshal +from ipalib import api +from ipalib import errors + +class xmlrpc(Backend): + """ + XML-RPC client backend plugin. + """ + + def get_client(self): + """ + Return an xmlrpclib.ServerProxy instance (the client). + """ + # FIXME: Rob, is there any reason we can't use allow_none=True here? + # Are there any reasonably common XML-RPC client implementations + # that don't support the <nil/> extension? + # See: http://docs.python.org/library/xmlrpclib.html + uri = self.env.xmlrpc_uri + if uri.startswith('https://'): + return xmlrpclib.ServerProxy(uri, + transport=KerbTransport(), + ) + return xmlrpclib.ServerProxy(uri) + + def forward_call(self, name, *args, **kw): + """ + Forward a call over XML-RPC to an IPA server. + """ + self.info('Forwarding %r call to %r' % (name, self.env.xmlrpc_uri)) + client = self.get_client() + command = getattr(client, name) + params = xmlrpc_marshal(*args, **kw) + try: + return command(*params) + except socket.error, e: + raise + except xmlrpclib.Fault, e: + err = errors.convertFault(e) + raise err + return + +api.register(xmlrpc) + +class KerbTransport(xmlrpclib.SafeTransport): + """Handles Kerberos Negotiation authentication to an XML-RPC server.""" + + def get_host_info(self, host): + + host, extra_headers, x509 = xmlrpclib.Transport.get_host_info(self, host) + + # Set the remote host principal + h = host + hostinfo = h.split(':') + service = "HTTP@" + hostinfo[0] + + try: + rc, vc = kerberos.authGSSClientInit(service); + except kerberos.GSSError, e: + raise kerberos.GSSError(e) + + try: + kerberos.authGSSClientStep(vc, ""); + except kerberos.GSSError, e: + raise kerberos.GSSError(e) + + extra_headers = [ + ("Authorization", "negotiate %s" % kerberos.authGSSClientResponse(vc) ) + ] + + return host, extra_headers, x509 diff --git a/ipalib/plugins/f_automount.py b/ipalib/plugins/f_automount.py new file mode 100644 index 000000000..2365ce221 --- /dev/null +++ b/ipalib/plugins/f_automount.py @@ -0,0 +1,563 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for automount. + +RFC 2707bis http://www.padl.com/~lukeh/rfc2307bis.txt +""" + +from ldap import explode_dn +from ipalib import crud, errors +from ipalib import api, Str, Flag, Object, Command + +map_attributes = ['automountMapName', 'description', ] +key_attributes = ['description', 'automountKey', 'automountInformation'] + +def display_entry(textui, entry): + # FIXME: for now delete dn here. In the future pass in the kw to + # output_for_cli() + attr = sorted(entry.keys()) + + for a in attr: + if a != 'dn': + textui.print_plain("%s: %s" % (a, entry[a])) + +def make_automount_dn(mapname): + """ + Construct automount dn from map name. + """ + # FIXME, should this be in b_ldap? + # Experimenting to see what a plugin looks like for a 3rd party who can't + # modify the backend. + import ldap + return 'automountmapname=%s,%s,%s' % ( + ldap.dn.escape_dn_chars(mapname), + api.env.container_automount, + api.env.basedn, + ) + +class automount(Object): + """ + Automount object. + """ + takes_params = ( + Str('automountmapname', + cli_name='mapname', + primary_key=True, + doc='A group of related automount objects', + ), + ) +api.register(automount) + + +class automount_addmap(crud.Add): + 'Add a new automount map.' + + takes_options = ( + Str('description?', + doc='A description of the automount map'), + ) + + def execute(self, mapname, **kw): + """ + Execute the automount-addmap operation. + + Returns the entry as it will be created in LDAP. + + :param mapname: The map name being added. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'automountmapname' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + kw['automountmapname'] = mapname + kw['dn'] = make_automount_dn(mapname) + + kw['objectClass'] = ['automountMap'] + + return ldap.create(**kw) + + def output_for_cli(self, textui, result, map, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Automount map %s added" % map) + +api.register(automount_addmap) + + +class automount_addkey(crud.Add): + 'Add a new automount key.' + takes_options = ( + Str('automountkey', + cli_name='key', + doc='An entry in an automount map'), + Str('automountinformation', + cli_name='info', + doc='Mount information for this key'), + Str('description?', + doc='A description of the mount'), + ) + + def execute(self, mapname, **kw): + """ + Execute the automount-addkey operation. + + Returns the entry as it will be created in LDAP. + + :param mapname: The map name being added to. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'automountmapname' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + # use find_entry_dn instead of make_automap_dn so we can confirm that + # the map exists + map_dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + kw['dn'] = "automountkey=%s,%s" % (kw['automountkey'], map_dn) + + kw['objectClass'] = ['automount'] + + return ldap.create(**kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Automount key added") + +api.register(automount_addkey) + + +class automount_delmap(crud.Del): + 'Delete an automount map.' + def execute(self, mapname, **kw): + """Delete an automount map. This will also remove all of the keys + associated with this map. + + mapname is the automount map to remove + + :param mapname: The map to be removed + :param kw: Not used. + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + keys = api.Command['automount_getkeys'](mapname) + if keys: + for k in keys: + ldap.delete(k.get('dn')) + return ldap.delete(dn) + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + print "Automount map and associated keys deleted" + +api.register(automount_delmap) + + +class automount_delkey(crud.Del): + 'Delete an automount key.' + takes_options = ( + Str('automountkey', + cli_name='key', + doc='The automount key to remove'), + ) + def execute(self, mapname, **kw): + """Delete an automount key. + + key is the automount key to remove + + :param mapname: The automount map containing the key to be removed + :param kw: "key" the key to be removed + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + keys = api.Command['automount_getkeys'](mapname) + keydn = None + keyname = kw.get('automountkey').lower() + if keys: + for k in keys: + if k.get('automountkey').lower() == keyname: + keydn = k.get('dn') + break + if not keydn: + raise errors.NotFound + return ldap.delete(keydn) + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + print "Automount key deleted" + +api.register(automount_delkey) + +class automount_modmap(crud.Mod): + 'Edit an existing automount map.' + takes_options = ( + Str('description?', + doc='A description of the automount map'), + ) + def execute(self, mapname, **kw): + """ + Execute the automount-modmap operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param mapname: The map name to update. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'automountmapname' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + return ldap.update(dn, **kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + print "Automount map updated" + +api.register(automount_modmap) + + +class automount_modkey(crud.Mod): + 'Edit an existing automount key.' + takes_options = ( + Str('automountkey', + cli_name='key', + doc='An entry in an automount map'), + Str('automountinformation?', + cli_name='info', + doc='Mount information for this key'), + Str('description?', + doc='A description of the automount map'), + ) + def execute(self, mapname, **kw): + """ + Execute the automount-modkey operation. + + Returns the entry + + :param mapname: The map name to update. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'automountmapname' not in kw + assert 'dn' not in kw + keyname = kw.get('automountkey').lower() + del kw['automountkey'] + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + keys = api.Command['automount_getkeys'](mapname) + keydn = None + if keys: + for k in keys: + if k.get('automountkey').lower() == keyname: + keydn = k.get('dn') + break + if not keydn: + raise errors.NotFound + return ldap.update(keydn, **kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + print "Automount key updated" + +api.register(automount_modkey) + + +class automount_findmap(crud.Find): + 'Search automount maps.' + takes_options = ( + Flag('all', doc='Retrieve all attributes'), + ) + def execute(self, term, **kw): + ldap = self.api.Backend.ldap + + search_fields = map_attributes + + for s in search_fields: + kw[s] = term + + kw['objectclass'] = 'automountMap' + kw['base'] = api.env.container_automount + if kw.get('all', False): + kw['attributes'] = ['*'] + else: + kw['attributes'] = map_attributes + return ldap.search(**kw) + + def output_for_cli(self, textui, result, *args, **options): + counter = result[0] + entries = result[1:] + if counter == 0: + textui.print_plain("No entries found") + return + elif counter == -1: + textui.print_plain("These results are truncated.") + textui.print_plain("Please refine your search and try again.") + + for e in entries: + display_entry(textui, e) + textui.print_plain("") + +api.register(automount_findmap) + + +class automount_findkey(crud.Find): + 'Search automount keys.' + takes_options = ( + Flag('all?', doc='Retrieve all attributes'), + ) + def get_args(self): + return (Str('automountkey', + cli_name='key', + doc='An entry in an automount map'),) + def execute(self, term, **kw): + ldap = self.api.Backend.ldap + + search_fields = key_attributes + + for s in search_fields: + kw[s] = term + + kw['objectclass'] = 'automount' + kw['base'] = api.env.container_automount + if kw.get('all', False): + kw['attributes'] = ['*'] + else: + kw['attributes'] = key_attributes + return ldap.search(**kw) + def output_for_cli(self, textui, result, *args, **options): + counter = result[0] + entries = result[1:] + if counter == 0: + textui.print_plain("No entries found") + return + elif counter == -1: + textui.print_plain("These results are truncated.") + textui.print_plain("Please refine your search and try again.") + + for e in entries: + display_entry(textui, e) + textui.print_plain("") + +api.register(automount_findkey) + + +class automount_showmap(crud.Get): + 'Examine an existing automount map.' + takes_options = ( + Flag('all?', doc='Retrieve all attributes'), + ) + def execute(self, mapname, **kw): + """ + Execute the automount-showmap operation. + + Returns the entry + + :param mapname: The automount map to retrieve + :param kw: "all" set to True = return all attributes + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + # FIXME: should kw contain the list of attributes to display? + if kw.get('all', False): + return ldap.retrieve(dn) + else: + return ldap.retrieve(dn, map_attributes) + def output_for_cli(self, textui, result, *args, **options): + if result: + display_entry(textui, result) + +api.register(automount_showmap) + + +class automount_showkey(crud.Get): + 'Examine an existing automount key.' + takes_options = ( + Str('automountkey', + cli_name='key', + doc='The automount key to display'), + Flag('all?', doc='Retrieve all attributes'), + ) + def execute(self, mapname, **kw): + """ + Execute the automount-showkey operation. + + Returns the entry + + :param mapname: The mapname to examine + :param kw: "automountkey" the key to retrieve + :param kw: "all" set to True = return all attributes + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + keys = api.Command['automount_getkeys'](mapname) + keyname = kw.get('automountkey').lower() + keydn = None + if keys: + for k in keys: + if k.get('automountkey').lower() == keyname: + keydn = k.get('dn') + break + if not keydn: + raise errors.NotFound + # FIXME: should kw contain the list of attributes to display? + if kw.get('all', False): + return ldap.retrieve(keydn) + else: + return ldap.retrieve(keydn, key_attributes) + def output_for_cli(self, textui, result, *args, **options): + # The automount map name associated with this key is available only + # in the dn. Add it as an attribute to display instead. + if result and not result.get('automountmapname'): + elements = explode_dn(result.get('dn').lower()) + for e in elements: + (attr, value) = e.split('=',1) + if attr == 'automountmapname': + result['automountmapname'] = value + display_entry(textui, result) + +api.register(automount_showkey) + + +class automount_getkeys(Command): + 'Retrieve all keys for an automount map.' + takes_args = ( + Str('automountmapname', + cli_name='mapname', + primary_key=True, + doc='A group of related automount objects', + ), + ) + def execute(self, mapname, **kw): + """ + Execute the automount-getkeys operation. + + Return a list of all automount keys for this mapname + + :param mapname: Retrieve all keys for this mapname + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("automountmapname", mapname, "automountmap", api.env.container_automount) + try: + keys = ldap.get_one_entry(dn, 'objectclass=*', ['automountkey']) + except errors.NotFound: + keys = [] + + return keys + def output_for_cli(self, textui, result, *args, **options): + for k in result: + textui.print_plain('%s' % k.get('automountkey')) + +api.register(automount_getkeys) + + +class automount_getmaps(Command): + 'Retrieve all automount maps' + takes_args = ( + Str('automountmapname?', + cli_name='mapname', + primary_key=True, + doc='A group of related automount objects', + ), + ) + def execute(self, mapname, **kw): + """ + Execute the automount-getmaps operation. + + Return a list of all automount maps. + """ + + ldap = self.api.Backend.ldap + base = api.env.container_automount + "," + api.env.basedn + + if not mapname: + mapname = "auto.master" + search_base = "automountmapname=%s,%s" % (mapname, base) + maps = ldap.get_one_entry(search_base, "objectClass=*", ["*"]) + + return maps + def output_for_cli(self, textui, result, *args, **options): + for k in result: + textui.print_plain('%s: %s' % (k.get('automountinformation'), k.get('automountkey'))) + +api.register(automount_getmaps) + +class automount_addindirectmap(crud.Add): + """ + Add a new automap indirect mount point. + """ + + takes_options = ( + Str('parentmap?', + cli_name='parentmap', + default=u'auto.master', + doc='The parent map to connect this to.', + ), + Str('automountkey', + cli_name='key', + doc='An entry in an automount map', + ), + Str('description?', + doc='A description of the automount map', + ), + ) + + def execute(self, mapname, **kw): + """ + Execute the automount-addindirectmap operation. + + Returns the key entry as it will be created in LDAP. + + This function creates 2 LDAP entries. It creates an + automountmapname entry and an automountkey entry. + + :param mapname: The map name being added. + :param kw['parentmap'] is the top-level map to add this to. + defaulting to auto.master + :param kw['automountkey'] is the mount point + :param kw['description'] is a textual description of this map + """ + mapkw = {} + if kw.get('description'): + mapkw['description'] = kw.get('description') + newmap = api.Command['automount_addmap'](mapname, **mapkw) + + keykw = {'automountkey': kw['automountkey'], 'automountinformation': mapname} + if kw.get('description'): + keykw['description'] = kw.get('description') + newkey = api.Command['automount_addkey'](kw['parentmap'], **keykw) + + return newkey + def output_for_cli(self, textui, result, map, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Indirect automount map %s added" % map) + +api.register(automount_addindirectmap) diff --git a/ipalib/plugins/f_delegation.py b/ipalib/plugins/f_delegation.py new file mode 100644 index 000000000..fbf8cfbff --- /dev/null +++ b/ipalib/plugins/f_delegation.py @@ -0,0 +1,65 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for delegations. +""" + +from ipalib import frontend +from ipalib import crud +from ipalib.frontend import Param +from ipalib import api +from ipalib import errors + +class delegation(frontend.Object): + """ + Delegation object. + """ + takes_params = ( + 'attributes', + 'source', + 'target', + Param('name', primary_key=True) + ) +api.register(delegation) + + +class delegation_add(crud.Add): + 'Add a new delegation.' +api.register(delegation_add) + + +class delegation_del(crud.Del): + 'Delete an existing delegation.' +api.register(delegation_del) + + +class delegation_mod(crud.Mod): + 'Edit an existing delegation.' +api.register(delegation_mod) + + +class delegation_find(crud.Find): + 'Search for a delegation.' +api.register(delegation_find) + + +class delegation_show(crud.Get): + 'Examine an existing delegation.' +api.register(delegation_show) diff --git a/ipalib/plugins/f_group.py b/ipalib/plugins/f_group.py new file mode 100644 index 000000000..740b32f8c --- /dev/null +++ b/ipalib/plugins/f_group.py @@ -0,0 +1,384 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for group (Identity). +""" + +from ipalib import api, crud, errors +from ipalib import Object, Command # Plugin base classes +from ipalib import Str, Int # Parameter types + + +def get_members(members): + """ + Return a list of members. + + It is possible that the value passed in is None. + """ + if members: + members = members.split(',') + else: + members = [] + + return members + +class group(Object): + """ + Group object. + """ + takes_params = ( + Str('description', + doc='A description of this group', + ), + Int('gidnumber?', + cli_name='gid', + doc='The gid to use for this group. If not included one is automatically set.', + ), + Str('cn', + cli_name='name', + primary_key=True, + normalizer=lambda value: value.lower(), + ), + ) +api.register(group) + + +class group_add(crud.Add): + 'Add a new group.' + + def execute(self, cn, **kw): + """ + Execute the group-add operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry as it will be created in LDAP. + + No need to explicitly set gidNumber. The dna_plugin will do this + for us if the value isn't provided by the caller. + + :param cn: The name of the group being added. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'cn' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + kw['cn'] = cn + kw['dn'] = ldap.make_group_dn(cn) + + # Get our configuration + config = ldap.get_ipa_config() + + # some required objectclasses + kw['objectClass'] = config.get('ipagroupobjectclasses') + + return ldap.create(**kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_name(self.name) + textui.print_entry(result) + textui.print_dashed('Added group "%s"' % result['cn']) + +api.register(group_add) + + +class group_del(crud.Del): + 'Delete an existing group.' + def execute(self, cn, **kw): + """ + Delete a group + + The memberOf plugin handles removing the group from any other + groups. + + :param cn: The name of the group being removed + :param kw: Unused + """ + # We have 2 special groups, don't allow them to be removed +# if "admins" == cn.lower() or "editors" == cn.lower(): +# raise ipaerror.gen_exception(ipaerror.CONFIG_REQUIRED_GROUPS) + + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, "posixGroup") + self.log.info("IPA: group-del '%s'" % dn) + + # Don't allow the default user group to be removed + config=ldap.get_ipa_config() + default_group = ldap.find_entry_dn("cn", config.get('ipadefaultprimarygroup'), "posixGroup") + if dn == default_group: + raise errors.DefaultGroup + + return ldap.delete(dn) + + def output_for_cli(self, textui, result, cn): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Deleted group %s" % cn) + +api.register(group_del) + + +class group_mod(crud.Mod): + 'Edit an existing group.' + def execute(self, cn, **kw): + """ + Execute the group-mod operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param cn: The name of the group to update. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'cn' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, "posixGroup") + return ldap.update(dn, **kw) + + def output_for_cli(self, textui, result, cn, **options): + """ + Output result of this command to command line interface. + """ + if result: + textui.print_plain("Group updated") + +api.register(group_mod) + + +class group_find(crud.Find): + 'Search the groups.' + def execute(self, term, **kw): + ldap = self.api.Backend.ldap + + # Pull the list of searchable attributes out of the configuration. + config = ldap.get_ipa_config() + search_fields_conf_str = config.get('ipagroupsearchfields') + search_fields = search_fields_conf_str.split(",") + + search_kw = {} + for s in search_fields: + search_kw[s] = term + + object_type = ldap.get_object_type("cn") + if object_type and not kw.get('objectclass'): + search_kw['objectclass'] = object_type + return ldap.search(**search_kw) + + def output_for_cli(self, textui, result, uid, **options): + counter = result[0] + groups = result[1:] + if counter == 0 or len(groups) == 0: + textui.print_plain("No entries found") + return + if len(groups) == 1: + textui.print_entry(groups[0]) + return + textui.print_name(self.name) + + for g in groups: + textui.print_entry(g) + textui.print_plain('') + if counter == -1: + textui.print_plain("These results are truncated.") + textui.print_plain("Please refine your search and try again.") + textui.print_count(groups, '%d groups matched') + +api.register(group_find) + + +class group_show(crud.Get): + 'Examine an existing group.' + def execute(self, cn, **kw): + """ + Execute the group-show operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param cn: The group name to retrieve. + :param kw: Not used. + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, "posixGroup") + # FIXME: should kw contain the list of attributes to display? + return ldap.retrieve(dn) + + def output_for_cli(self, textui, result, *args, **options): + counter = result[0] + groups = result[1:] + if counter == 0 or len(groups) == 0: + textui.print_plain("No entries found") + return + if len(groups) == 1: + textui.print_entry(groups[0]) + return + textui.print_name(self.name) + for u in groups: + textui.print_plain('%(givenname)s %(sn)s:' % u) + textui.print_entry(u) + textui.print_plain('') + if counter == -1: + textui.print_plain('These results are truncated.') + textui.print_plain('Please refine your search and try again.') + textui.print_count(groups, '%d groups matched') + +api.register(group_show) + + +class group_add_member(Command): + 'Add a member to a group.' + takes_args = ( + Str('group', primary_key=True), + ) + takes_options = ( + Str('users?', doc='comma-separated list of users to add'), + Str('groups?', doc='comma-separated list of groups to add'), + ) + def execute(self, cn, **kw): + """ + Execute the group-add-member operation. + + Returns the updated group entry + + :param cn: The group name to add new members to. + :param kw: groups is a comma-separated list of groups to add + :parem kw: users is a comma-separated list of users to add + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn) + add_failed = [] + to_add = [] + completed = 0 + + members = get_members(kw.get('groups', '')) + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn("cn", m) + to_add.append(member_dn) + except errors.NotFound: + add_failed.append(m) + continue + + members = get_members(kw.get('users', '')) + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn("uid", m) + to_add.append(member_dn) + except errors.NotFound: + add_failed.append(m) + continue + + for member_dn in to_add: + try: + ldap.add_member_to_group(member_dn, dn) + completed+=1 + except: + add_failed.append(member_dn) + + return add_failed + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + if result: + print "These entries failed to add to the group:" + for a in add_failed: + print "\t'%s'" % a + + +api.register(group_add_member) + + +class group_remove_member(Command): + 'Remove a member from a group.' + takes_args = ( + Str('group', primary_key=True), + ) + takes_options = ( + Str('users?', doc='comma-separated list of users to remove'), + Str('groups?', doc='comma-separated list of groups to remove'), + ) + def execute(self, cn, **kw): + """ + Execute the group-remove-member operation. + + Returns the members that could not be added + + :param cn: The group name to add new members to. + :param kw: groups is a comma-separated list of groups to remove + :parem kw: users is a comma-separated list of users to remove + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn) + to_remove = [] + remove_failed = [] + completed = 0 + + members = get_members(kw.get('groups', '')) + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn("cn", m) + to_remove.append(member_dn) + except errors.NotFound: + remove_failed.append(m) + continue + + members = get_members(kw.get('users', '')) + for m in members: + try: + member_dn = ldap.find_entry_dn("uid", m,) + to_remove.append(member_dn) + except errors.NotFound: + remove_failed.append(m) + continue + + for member_dn in to_remove: + try: + ldap.remove_member_from_group(member_dn, dn) + completed+=1 + except: + remove_failed.append(member_dn) + + return remove_failed + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + if result: + print "These entries failed to be removed from the group:" + for a in remove_failed: + print "\t'%s'" % a + +api.register(group_remove_member) diff --git a/ipalib/plugins/f_host.py b/ipalib/plugins/f_host.py new file mode 100644 index 000000000..ea819a77d --- /dev/null +++ b/ipalib/plugins/f_host.py @@ -0,0 +1,286 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for host/machine Identity. +""" + +from ipalib import api, crud, errors, util +from ipalib import Object # Plugin base class +from ipalib import Str, Flag # Parameter types + + +def get_host(hostname): + """ + Try to get the hostname as fully-qualified first, then fall back to + just a host name search. + """ + ldap = api.Backend.ldap + + # Strip off trailing dot + if hostname.endswith('.'): + hostname = hostname[:-1] + try: + dn = ldap.find_entry_dn("cn", hostname, "ipaHost") + except errors.NotFound: + dn = ldap.find_entry_dn("serverhostname", hostname, "ipaHost") + return dn + +def validate_host(ugettext, cn): + """ + Require at least one dot in the hostname (to support localhost.localdomain) + """ + dots = len(cn.split('.')) + if dots < 2: + return 'Fully-qualified hostname required' + return None + +default_attributes = ['cn','description','localityname','nshostlocation','nshardwareplatform','nsosversion'] + +class host(Object): + """ + Host object. + """ + takes_params = ( + Str('cn', validate_host, + cli_name='hostname', + primary_key=True, + normalizer=lambda value: value.lower(), + ), + Str('description?', + doc='Description of the host', + ), + Str('localityname?', + cli_name='locality', + doc='Locality of this host (Baltimore, MD)', + ), + Str('nshostlocation?', + cli_name='location', + doc='Location of this host (e.g. Lab 2)', + ), + Str('nshardwareplatform?', + cli_name='platform', + doc='Hardware platform of this host (e.g. Lenovo T61)', + ), + Str('nsosversion?', + cli_name='os', + doc='Operating System and version on this host (e.g. Fedora 9)', + ), + Str('userpassword?', + cli_name='password', + doc='Set a password to be used in bulk enrollment', + ), + ) +api.register(host) + + +class host_add(crud.Add): + 'Add a new host.' + def execute(self, hostname, **kw): + """ + Execute the host-add operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + If password is set then this is considered a 'bulk' host so we + do not create a kerberos service principal. + + Returns the entry as it will be created in LDAP. + + :param hostname: The name of the host being added. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'cn' not in kw + assert 'dn' not in kw + assert 'krbprincipalname' not in kw + ldap = self.api.Backend.ldap + + kw['cn'] = hostname + kw['serverhostname'] = hostname.split('.',1)[0] + kw['dn'] = ldap.make_host_dn(hostname) + + # FIXME: do a DNS lookup to ensure host exists + + current = util.get_current_principal() + if not current: + raise errors.NotFound('Unable to determine current user') + kw['enrolledby'] = ldap.find_entry_dn("krbPrincipalName", current, "posixAccount") + + # Get our configuration + config = ldap.get_ipa_config() + + # some required objectclasses + # FIXME: add this attribute to cn=ipaconfig + #kw['objectclass'] = config.get('ipahostobjectclasses') + kw['objectclass'] = ['nsHost', 'ipaHost', 'pkiUser'] + + # Ensure the list of objectclasses is lower-case + kw['objectclass'] = map(lambda z: z.lower(), kw.get('objectclass')) + + if not kw.get('userpassword', False): + kw['krbprincipalname'] = "host/%s@%s" % (hostname, self.api.env.realm) + + if 'krbprincipalaux' not in kw.get('objectclass'): + kw['objectclass'].append('krbprincipalaux') + else: + if 'krbprincipalaux' in kw.get('objectclass'): + kw['objectclass'].remove('krbprincipalaux') + + return ldap.create(**kw) + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Host added") + +api.register(host_add) + + +class host_del(crud.Del): + 'Delete an existing host.' + def execute(self, hostname, **kw): + """Delete a host. + + hostname is the name of the host to delete + + :param hostname: The name of the host being removed. + :param kw: Not used. + """ + ldap = self.api.Backend.ldap + dn = get_host(hostname) + return ldap.delete(dn) + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Host deleted") + +api.register(host_del) + + +class host_mod(crud.Mod): + 'Edit an existing host.' + def execute(self, hostname, **kw): + """ + Execute the host-mod operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param hostname: The name of the host to retrieve. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'cn' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + dn = get_host(hostname) + return ldap.update(dn, **kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Host updated") + +api.register(host_mod) + + +class host_find(crud.Find): + 'Search the hosts.' + + takes_options = ( + Flag('all', doc='Retrieve all attributes'), + ) + + # FIXME: This should no longer be needed with the Param.query kwarg. +# def get_args(self): +# """ +# Override Find.get_args() so we can exclude the validation rules +# """ +# yield self.obj.primary_key.__clone__(rules=tuple()) + + def execute(self, term, **kw): + ldap = self.api.Backend.ldap + + # Pull the list of searchable attributes out of the configuration. + #config = ldap.get_ipa_config() + # FIXME: add this attribute to cn=ipaconfig + #search_fields_conf_str = config.get('ipahostsearchfields') + #search_fields = search_fields_conf_str.split(",") + search_fields = ['cn','serverhostname','description','localityname','nshostlocation','nshardwareplatform','nsosversion'] + + search_kw = {} + for s in search_fields: + search_kw[s] = term + + # Can't use ldap.get_object_type() since cn is also used for group dns + search_kw['objectclass'] = "ipaHost" + if kw.get('all', False): + search_kw['attributes'] = ['*'] + else: + search_kw['attributes'] = default_attributes + return ldap.search(**search_kw) + def output_for_cli(self, textui, result, *args, **options): + counter = result[0] + hosts = result[1:] + if counter == 0: + textui.print_plain("No entries found") + return + + for h in hosts: + textui.print_entry(h) + if counter == -1: + textui.print_plain("These results are truncated.") + textui.print_plain("Please refine your search and try again.") +api.register(host_find) + + +class host_show(crud.Get): + 'Examine an existing host.' + takes_options = ( + Flag('all', doc='Display all host attributes'), + ) + def execute(self, hostname, **kw): + """ + Execute the host-show operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param hostname: The login name of the host to retrieve. + :param kw: "all" set to True = return all attributes + """ + ldap = self.api.Backend.ldap + dn = get_host(hostname) + # FIXME: should kw contain the list of attributes to display? + if kw.get('all', False): + return ldap.retrieve(dn) + else: + value = ldap.retrieve(dn, default_attributes) + del value['dn'] + return value + def output_for_cli(self, textui, result, *args, **options): + textui.print_entry(result) + +api.register(host_show) diff --git a/ipalib/plugins/f_hostgroup.py b/ipalib/plugins/f_hostgroup.py new file mode 100644 index 000000000..706712c9a --- /dev/null +++ b/ipalib/plugins/f_hostgroup.py @@ -0,0 +1,354 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for groups of hosts +""" + +from ipalib import api, crud, errors +from ipalib import Object, Command # Plugin base classes +from ipalib import Str # Parameter types + + +hostgroup_filter = "groupofnames)(!(objectclass=posixGroup)" + +def get_members(members): + """ + Return a list of members. + + It is possible that the value passed in is None. + """ + if members: + members = members.split(',') + else: + members = [] + + return members + +class hostgroup(Object): + """ + Host Group object. + """ + takes_params = ( + Str('description', + doc='A description of this group', + ), + Str('cn', + cli_name='name', + primary_key=True, + normalizer=lambda value: value.lower(), + ) + ) +api.register(hostgroup) + + +class hostgroup_add(crud.Add): + 'Add a new group of hosts.' + + def execute(self, cn, **kw): + """ + Execute the hostgroup-add operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry as it will be created in LDAP. + + No need to explicitly set gidNumber. The dna_plugin will do this + for us if the value isn't provided by the caller. + + :param cn: The name of the host group being added. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'cn' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + kw['cn'] = cn + kw['dn'] = ldap.make_hostgroup_dn(cn) + + # Get our configuration + #config = ldap.get_ipa_config() + + # some required objectclasses + # FIXME: get this out of config + kw['objectClass'] = ['groupofnames'] + + return ldap.create(**kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Group added") + +api.register(hostgroup_add) + + +class hostgroup_del(crud.Del): + 'Delete an existing group of hosts.' + def execute(self, cn, **kw): + """ + Delete a group of hosts + + The memberOf plugin handles removing the group from any other + groups. + + :param cn: The name of the group being removed + :param kw: Unused + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, hostgroup_filter) + + return ldap.delete(dn) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_plain("Group deleted") + +api.register(hostgroup_del) + + +class hostgroup_mod(crud.Mod): + 'Edit an existing group of hosts.' + def execute(self, cn, **kw): + """ + Execute the hostgroup-mod operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param cn: The name of the group to update. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'cn' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, hostgroup_filter) + return ldap.update(dn, **kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + texui.print_plain("Group updated") + +api.register(hostgroup_mod) + + +class hostgroup_find(crud.Find): + 'Search the groups of hosts.' + def execute(self, term, **kw): + ldap = self.api.Backend.ldap + + # Pull the list of searchable attributes out of the configuration. + config = ldap.get_ipa_config() + + # FIXME: for now use same search fields as user groups + search_fields_conf_str = config.get('ipagroupsearchfields') + search_fields = search_fields_conf_str.split(",") + + search_kw = {} + for s in search_fields: + search_kw[s] = term + + search_kw['objectclass'] = hostgroup_filter + return ldap.search(**search_kw) + + def output_for_cli(self, textui, result, *args, **options): + counter = result[0] + groups = result[1:] + if counter == 0: + textui.print_plain("No entries found") + return + + for g in groups: + textui.print_entry(g) + + if counter == -1: + textui.print_plain("These results are truncated.") + textui.print_plain("Please refine your search and try again.") + +api.register(hostgroup_find) + + +class hostgroup_show(crud.Get): + 'Examine an existing group of hosts.' + def execute(self, cn, **kw): + """ + Execute the hostgroup-show operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param cn: The group name to retrieve. + :param kw: Not used. + """ + ldap = self.api.Backend.ldap + # FIXME: this works for now but the plan is to add a new objectclass + # type. + dn = ldap.find_entry_dn("cn", cn, hostgroup_filter) + # FIXME: should kw contain the list of attributes to display? + return ldap.retrieve(dn) + + def output_for_cli(self, textui, result, *args, **options): + textui.print_entry(result) + +api.register(hostgroup_show) + + +class hostgroup_add_member(Command): + 'Add a member to a group.' + takes_args = ( + Str('group', primary_key=True), + ) + takes_options = ( + Str('groups?', doc='comma-separated list of host groups to add'), + Str('hosts?', doc='comma-separated list of hosts to add'), + ) + def execute(self, cn, **kw): + """ + Execute the hostgroup-add-member operation. + + Returns the updated group entry + + :param cn: The group name to add new members to. + :param kw: groups is a comma-separated list of host groups to add + :param kw: hosts is a comma-separated list of hosts to add + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, hostgroup_filter) + add_failed = [] + to_add = [] + completed = 0 + + members = get_members(kw.get('groups', '')) + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn("cn", m, hostgroup_filter) + to_add.append(member_dn) + except errors.NotFound: + add_failed.append(m) + continue + + members = get_members(kw.get('hosts', '')) + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn("cn", m, "ipaHost") + to_add.append(member_dn) + except errors.NotFound: + add_failed.append(m) + continue + + for member_dn in to_add: + try: + ldap.add_member_to_group(member_dn, dn) + completed+=1 + except: + add_failed.append(member_dn) + + return add_failed + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + if result: + textui.print_plain("These entries failed to add to the group:") + for a in result: + print "\t'%s'" % a + else: + textui.print_plain("Group membership updated.") + +api.register(hostgroup_add_member) + + +class hostgroup_remove_member(Command): + 'Remove a member from a group.' + takes_args = ( + Str('group', primary_key=True), + ) + takes_options = ( + Str('hosts?', doc='comma-separated list of hosts to add'), + Str('groups?', doc='comma-separated list of groups to remove'), + ) + def execute(self, cn, **kw): + """ + Execute the group-remove-member operation. + + Returns the members that could not be added + + :param cn: The group name to add new members to. + :param kw: groups is a comma-separated list of groups to remove + :param kw: hosts is a comma-separated list of hosts to add + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, hostgroup_filter) + to_remove = [] + remove_failed = [] + completed = 0 + + members = get_members(kw.get('groups', '')) + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn("cn", m, hostgroup_filter) + to_remove.append(member_dn) + except errors.NotFound: + remove_failed.append(m) + continue + + members = get_members(kw.get('hosts', '')) + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn("cn", m, "ipaHost") + to_remove.append(member_dn) + except errors.NotFound: + remove_failed.append(m) + continue + + for member_dn in to_remove: + try: + ldap.remove_member_from_group(member_dn, dn) + completed+=1 + except: + remove_failed.append(member_dn) + + return remove_failed + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + if result: + textui.print_plain("These entries failed to be removed from the group:") + for a in result: + print "\t'%s'" % a + else: + textui.print_plain("Group membership updated.") + +api.register(hostgroup_remove_member) diff --git a/ipalib/plugins/f_misc.py b/ipalib/plugins/f_misc.py new file mode 100644 index 000000000..a2f0fa4e4 --- /dev/null +++ b/ipalib/plugins/f_misc.py @@ -0,0 +1,89 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Misc frontend plugins. +""" + +import re +from ipalib import api, LocalOrRemote + + + +# FIXME: We should not let env return anything in_server +# when mode == 'production'. This would allow an attacker to see the +# configuration of the server, potentially revealing compromising +# information. However, it's damn handy for testing/debugging. +class env(LocalOrRemote): + """Show environment variables""" + + takes_args = ('variables*',) + + def __find_keys(self, variables): + keys = set() + for query in variables: + if '*' in query: + pat = re.compile(query.replace('*', '.*') + '$') + for key in self.env: + if pat.match(key): + keys.add(key) + elif query in self.env: + keys.add(query) + return sorted(keys) + + def execute(self, variables, **options): + if variables is None: + keys = self.env + else: + keys = self.__find_keys(variables) + return tuple( + (key, self.env[key]) for key in keys + ) + + def output_for_cli(self, textui, result, variables, **options): + if len(result) == 0: + return + if len(result) == 1: + textui.print_keyval(result) + return + textui.print_name(self.name) + textui.print_keyval(result) + textui.print_count(result, '%d variables') + +api.register(env) + + +class plugins(LocalOrRemote): + """Show all loaded plugins""" + + def execute(self, **options): + plugins = sorted(self.api.plugins, key=lambda o: o.plugin) + return tuple( + (p.plugin, p.bases) for p in plugins + ) + + def output_for_cli(self, textui, result, **options): + textui.print_name(self.name) + for (plugin, bases) in result: + textui.print_indented( + '%s: %s' % (plugin, ', '.join(bases)) + ) + textui.print_count(result, '%d plugin loaded', '%s plugins loaded') + +api.register(plugins) diff --git a/ipalib/plugins/f_netgroup.py b/ipalib/plugins/f_netgroup.py new file mode 100644 index 000000000..6ee55b0db --- /dev/null +++ b/ipalib/plugins/f_netgroup.py @@ -0,0 +1,461 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugin for netgroups. +""" + +from ipalib import api, crud, errors +from ipalib import Object, Command # Plugin base classes +from ipalib import Str # Parameter types +from ipalib import uuid + +netgroup_base = "cn=ng, cn=alt" +netgroup_filter = "ipaNISNetgroup" +hostgroup_filter = "groupofnames)(!(objectclass=posixGroup)" + +def get_members(members): + """ + Return a list of members. + + It is possible that the value passed in is None. + """ + if members: + members = members.split(',') + else: + members = [] + + return members + +def find_members(ldap, failed, members, attribute, filter=None): + """ + Return 2 lists: one a list of DNs found, one a list of errors + """ + found = [] + for m in members: + if not m: continue + try: + member_dn = ldap.find_entry_dn(attribute, m, filter) + found.append(member_dn) + except errors.NotFound: + failed.append(m) + continue + + return found, failed + +def add_members(ldap, completed, members, dn, memberattr): + add_failed = [] + for member_dn in members: + try: + ldap.add_member_to_group(member_dn, dn, memberattr) + completed+=1 + except: + add_failed.append(member_dn) + + return completed, add_failed + +def add_external(ldap, completed, members, cn): + failed = [] + netgroup = api.Command['netgroup_show'](cn) + external = netgroup.get('externalhost', []) + if not isinstance(external, list): + external = [external] + external_len = len(external) + for m in members: + if not m in external: + external.append(m) + completed+=1 + else: + failed.append(m) + if len(external) > external_len: + kw = {'externalhost': external} + ldap.update(netgroup['dn'], **kw) + + return completed, failed + +def remove_members(ldap, completed, members, dn, memberattr): + remove_failed = [] + for member_dn in members: + try: + ldap.remove_member_from_group(member_dn, dn, memberattr) + completed+=1 + except: + remove_failed.append(member_dn) + + return completed, remove_failed + +def remove_external(ldap, completed, members, cn): + failed = [] + netgroup = api.Command['netgroup_show'](cn) + external = netgroup.get('externalhost', []) + if not isinstance(external, list): + external = [external] + external_len = len(external) + for m in members: + try: + external.remove(m) + completed+=1 + except ValueError: + failed.append(m) + if len(external) < external_len: + kw = {'externalhost': external} + ldap.update(netgroup['dn'], **kw) + + return completed, failed + +class netgroup(Object): + """ + netgroups object. + """ + takes_params = ( + Str('cn', + cli_name='name', + primary_key=True + ), + Str('description', + doc='Description', + ), + Str('nisdomainname?', + cli_name='domainname', + doc='Domain name', + ), + ) +api.register(netgroup) + + +class netgroup_add(crud.Add): + 'Add a new netgroup.' + + def execute(self, cn, **kw): + """ + Execute the netgroup-add operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry as it will be created in LDAP. + + :param cn: The name of the netgroup + :param kw: Keyword arguments for the other LDAP attributes. + """ + self.log.info("IPA: netgroup-add '%s'" % cn) + assert 'cn' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + kw['cn'] = cn +# kw['dn'] = ldap.make_netgroup_dn() + kw['ipauniqueid'] = str(uuid.uuid1()) + kw['dn'] = "ipauniqueid=%s,%s,%s" % (kw['ipauniqueid'], netgroup_base, api.env.basedn) + + if not kw.get('nisdomainname', False): + kw['nisdomainname'] = api.env.domain + + # some required objectclasses + kw['objectClass'] = ['top', 'ipaAssociation', 'ipaNISNetgroup'] + + return ldap.create(**kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_name(self.name) + textui.print_entry(result) + textui.print_dashed('Added netgroup "%s"' % result.get('cn')) + +api.register(netgroup_add) + + +class netgroup_del(crud.Del): + 'Delete an existing netgroup.' + + def execute(self, cn, **kw): + """Delete a netgroup. + + cn is the cn of the netgroup to delete + + The memberOf plugin handles removing the netgroup from any other + groups. + + :param cn: The name of the netgroup being removed. + :param kw: Not used. + """ + self.log.info("IPA: netgroup-del '%s'" % cn) + + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, netgroup_filter, netgroup_base) + return ldap.delete(dn) + + def output_for_cli(self, textui, result, cn): + """ + Output result of this command to command line interface. + """ + textui.print_plain('Deleted net group "%s"' % cn) + +api.register(netgroup_del) + + +class netgroup_mod(crud.Mod): + 'Edit an existing netgroup.' + def execute(self, cn, **kw): + """ + Execute the netgroup-mod operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param cn: The name of the netgroup to retrieve. + :param kw: Keyword arguments for the other LDAP attributes. + """ + self.log.info("IPA: netgroup-mod '%s'" % cn) + assert 'cn' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, netgroup_filter, netgroup_base) + return ldap.update(dn, **kw) + + def output_for_cli(self, textui, result, cn, **options): + """ + Output result of this command to command line interface. + """ + textui.print_name(self.name) + textui.print_entry(result) + textui.print_dashed('Updated netgroup "%s"' % result['cn']) + +api.register(netgroup_mod) + + +class netgroup_find(crud.Find): + 'Search the netgroups.' + def execute(self, term, **kw): + ldap = self.api.Backend.ldap + + search_fields = ['ipauniqueid','description','nisdomainname','cn'] + + search_kw = {} + for s in search_fields: + search_kw[s] = term + + search_kw['objectclass'] = netgroup_filter + search_kw['base'] = netgroup_base + return ldap.search(**search_kw) + + def output_for_cli(self, textui, result, *args, **options): + counter = result[0] + groups = result[1:] + if counter == 0 or len(groups) == 0: + textui.print_plain("No entries found") + return + if len(groups) == 1: + textui.print_entry(groups[0]) + return + textui.print_name(self.name) + for g in groups: + textui.print_entry(g) + textui.print_plain('') + if counter == -1: + textui.print_plain('These results are truncated.') + textui.print_plain('Please refine your search and try again.') + textui.print_count(groups, '%d netgroups matched') + +api.register(netgroup_find) + + +class netgroup_show(crud.Get): + 'Examine an existing netgroup.' + def execute(self, cn, **kw): + """ + Execute the netgroup-show operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param cn: The name of the netgroup to retrieve. + :param kw: Unused + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, netgroup_filter, netgroup_base) + return ldap.retrieve(dn) + + def output_for_cli(self, textui, result, *args, **options): + textui.print_entry(result) + +api.register(netgroup_show) + +class netgroup_add_member(Command): + 'Add a member to a group.' + takes_args = ( + Str('cn', + cli_name='name', + primary_key=True + ), + ) + takes_options = ( + Str('hosts?', doc='comma-separated list of hosts to add'), + Str('hostgroups?', doc='comma-separated list of host groups to add'), + Str('users?', doc='comma-separated list of users to add'), + Str('groups?', doc='comma-separated list of groups to add'), + ) + + def execute(self, cn, **kw): + """ + Execute the netgroup-add-member operation. + + Returns the updated group entry + + :param cn: The netgroup name to add new members to. + :param kw: hosts is a comma-separated list of hosts to add + :param kw: hostgroups is a comma-separated list of host groups to add + :param kw: users is a comma-separated list of users to add + :param kw: groups is a comma-separated list of host to add + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, netgroup_filter, netgroup_base) + add_failed = [] + to_add = [] + completed = 0 + + # Hosts + members = get_members(kw.get('hosts', '')) + (to_add, add_failed) = find_members(ldap, add_failed, members, "cn", "ipaHost") + + # If a host is not found we'll consider it an externalHost. It will + # be up to the user to handle typos + if add_failed: + (completed, failed) = add_external(ldap, completed, add_failed, cn) + add_failed = failed + + (completed, failed) = add_members(ldap, completed, to_add, dn, 'memberhost') + add_failed+=failed + + # Host groups + members = get_members(kw.get('hostgroups', '')) + (to_add, add_failed) = find_members(ldap, add_failed, members, "cn", hostgroup_filter) + (completed, failed) = add_members(ldap, completed, to_add, dn, 'memberhost') + add_failed+=failed + + # User + members = get_members(kw.get('users', '')) + (to_add, add_failed) = find_members(ldap, add_failed, members, "uid") + (completed, failed) = add_members(ldap, completed, to_add, dn, 'memberuser') + add_failed+=failed + + # Groups + members = get_members(kw.get('groups', '')) + (to_add, add_failed) = find_members(ldap, add_failed, members, "cn", "posixGroup") + (completed, failed) = add_members(ldap, completed, to_add, dn, 'memberuser') + add_failed+=failed + + return add_failed + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + if result: + textui.print_plain("These entries failed to add to the group:") + for a in result: + print "\t'%s'" % a + else: + textui.print_plain("netgroup membership updated.") + +api.register(netgroup_add_member) + + +class netgroup_remove_member(Command): + 'Remove a member from a group.' + takes_args = ( + Str('cn', + cli_name='name', + primary_key=True + ), + ) + takes_options = ( + Str('hosts?', doc='comma-separated list of hosts to remove'), + Str('hostgroups?', doc='comma-separated list of groups to remove'), + Str('users?', doc='comma-separated list of users to remove'), + Str('groups?', doc='comma-separated list of groups to remove'), + ) + def execute(self, cn, **kw): + """ + Execute the group-remove-member operation. + + Returns the members that could not be added + + :param cn: The group name to add new members to. + :param kw: hosts is a comma-separated list of hosts to remove + :param kw: hostgroups is a comma-separated list of host groups to remove + :param kw: users is a comma-separated list of users to remove + :param kw: groups is a comma-separated list of host to remove + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", cn, netgroup_filter, netgroup_base) + remove_failed = [] + to_remove = [] + completed = 0 + + # Hosts + members = get_members(kw.get('hosts', '')) + (to_remove, remove_failed) = find_members(ldap, remove_failed, members, "cn", "ipaHost") + + # If a host is not found we'll consider it an externalHost. It will + # be up to the user to handle typos + if remove_failed: + (completed, failed) = remove_external(ldap, completed, remove_failed, cn) + remove_failed = failed + + (completed, failed) = remove_members(ldap, completed, to_remove, dn, 'memberhost') + remove_failed+=failed + + # Host groups + members = get_members(kw.get('hostgroups', '')) + (to_remove, remove_failed) = find_members(ldap, remove_failed, members, "cn", hostgroup_filter) + (completed, failed) = remove_members(ldap, completed, to_remove, dn, 'memberhost') + remove_failed+=failed + + # User + members = get_members(kw.get('users', '')) + (to_remove, remove_failed) = find_members(ldap, remove_failed, members, "uid") + (completed, failed) = remove_members(ldap, completed, to_remove, dn, 'memberuser') + remove_failed+=failed + + # Groups + members = get_members(kw.get('groups', '')) + (to_remove, remove_failed) = find_members(ldap, remove_failed, members, "cn", "posixGroup") + (completed, failed) = remove_members(ldap, completed, to_remove, dn, 'memberuser') + remove_failed+=failed + + return remove_failed + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + if result: + textui.print_plain("These entries failed to be removed from the group:") + for a in result: + print "\t'%s'" % a + else: + textui.print_plain("netgroup membership updated.") + +api.register(netgroup_remove_member) diff --git a/ipalib/plugins/f_passwd.py b/ipalib/plugins/f_passwd.py new file mode 100644 index 000000000..ea78c4c15 --- /dev/null +++ b/ipalib/plugins/f_passwd.py @@ -0,0 +1,70 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for password changes. +""" + +from ipalib import api, errors, util +from ipalib import Command # Plugin base classes +from ipalib import Str, Password # Parameter types + + +class passwd(Command): + 'Edit existing password policy.' + + takes_args = ( + Str('principal', + cli_name='user', + primary_key=True, + default_from=util.get_current_principal, + ), + Password('password'), + ) + + def execute(self, principal, password): + """ + Execute the passwd operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param param uid: The login name of the user being updated. + :param kw: Not used. + """ + if principal.find('@') > 0: + u = principal.split('@') + if len(u) > 2: + raise errors.InvalidUserPrincipal, principal + else: + principal = principal+"@"+self.api.env.realm + dn = self.Backend.ldap.find_entry_dn( + "krbprincipalname", + principal, + "posixAccount" + ) + return self.Backend.ldap.modify_password(dn, newpass=password) + + def output_for_cli(self, textui, result, principal, password): + assert password is None + textui.print_plain('Changed password for "%s"' % principal) + +api.register(passwd) diff --git a/ipalib/plugins/f_pwpolicy.py b/ipalib/plugins/f_pwpolicy.py new file mode 100644 index 000000000..d914ce72a --- /dev/null +++ b/ipalib/plugins/f_pwpolicy.py @@ -0,0 +1,122 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for password policy. +""" + +from ipalib import api +from ipalib import Command # Plugin base classes +from ipalib import Int # Parameter types + + +class pwpolicy_mod(Command): + 'Edit existing password policy.' + takes_options = ( + Int('krbmaxpwdlife?', + cli_name='maxlife', + doc='Max. Password Lifetime (days)' + ), + Int('krbminpwdlife?', + cli_name='minlife', + doc='Min. Password Lifetime (hours)' + ), + Int('krbpwdhistorylength?', + cli_name='history', + doc='Password History Size' + ), + Int('krbpwdmindiffchars?', + cli_name='minclasses', + doc='Min. Number of Character Classes' + ), + Int('krbpwdminlength?', + cli_name='minlength', + doc='Min. Length of Password' + ), + ) + def execute(self, *args, **kw): + """ + Execute the pwpolicy-mod operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param args: This function takes no positional arguments + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'dn' not in kw + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", "accounts", "krbPwdPolicy") + + # The LDAP routines want strings, not ints, so convert a few + # things. Otherwise it sees a string -> int conversion as a change. + for k in kw.iterkeys(): + if k.startswith("krb", 0, 3): + kw[k] = str(kw[k]) + + # Convert hours and days to seconds + if kw.get('krbmaxpwdlife'): + kw['krbmaxpwdlife'] = str(int(kw.get('krbmaxpwdlife')) * 86400) + if kw.get('krbminpwdlife'): + kw['krbminpwdlife'] = str(int(kw.get('krbminpwdlife')) * 3600) + + return ldap.update(dn, **kw) + + def output_for_cli(self, textui, result, *args, **options): + textui.print_plain("Policy modified") + +api.register(pwpolicy_mod) + + +class pwpolicy_show(Command): + 'Retrieve current password policy' + def execute(self, *args, **kw): + """ + Execute the pwpolicy-show operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param args: Not used. + :param kw: Not used. + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("cn", "accounts", "krbPwdPolicy") + + policy = ldap.retrieve(dn) + + # convert some values for display purposes + policy['krbmaxpwdlife'] = str(int(policy.get('krbmaxpwdlife')) / 86400) + policy['krbminpwdlife'] = str(int(policy.get('krbminpwdlife')) / 3600) + + return policy + + def output_for_cli(self, textui, result, *args, **options): + textui.print_plain("Password Policy") + textui.print_plain("Min. Password Lifetime (hours): %s" % result.get('krbminpwdlife')) + textui.print_plain("Max. Password Lifetime (days): %s" % result.get('krbmaxpwdlife')) + textui.print_plain("Min. Number of Character Classes: %s" % result.get('krbpwdmindiffchars')) + textui.print_plain("Min. Length of Password: %s" % result.get('krbpwdminlength')) + textui.print_plain("Password History Size: %s" % result.get('krbpwdhistorylength')) + +api.register(pwpolicy_show) diff --git a/ipalib/plugins/f_ra.py b/ipalib/plugins/f_ra.py new file mode 100644 index 000000000..7ac84e65f --- /dev/null +++ b/ipalib/plugins/f_ra.py @@ -0,0 +1,117 @@ +# Authors: +# Andrew Wnuk <awnuk@redhat.com> +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for IPA-RA PKI operations. +""" + +from ipalib import api, Command, Str, Int + + +class request_certificate(Command): + """ Submit a certificate request. """ + + takes_args = ['csr'] + + takes_options = [Str('request_type?', default=u'pkcs10')] + + def execute(self, csr, **options): + return self.Backend.ra.request_certificate(csr, **options) + + def output_for_cli(self, textui, result, *args, **options): + if isinstance(result, dict) and len(result) > 0: + textui.print_entry(result, 0) + else: + textui.print_plain('Failed to submit a certificate request.') + +api.register(request_certificate) + + +class get_certificate(Command): + """ Retrieve an existing certificate. """ + + takes_args = ['serial_number'] + + def execute(self, serial_number, **options): + return self.Backend.ra.get_certificate(serial_number) + + def output_for_cli(self, textui, result, *args, **options): + if isinstance(result, dict) and len(result) > 0: + textui.print_entry(result, 0) + else: + textui.print_plain('Failed to obtain a certificate.') + +api.register(get_certificate) + + +class check_request_status(Command): + """ Check a request status. """ + + takes_args = ['request_id'] + + + def execute(self, request_id, **options): + return self.Backend.ra.check_request_status(request_id) + + def output_for_cli(self, textui, result, *args, **options): + if isinstance(result, dict) and len(result) > 0: + textui.print_entry(result, 0) + else: + textui.print_plain('Failed to retrieve a request status.') + +api.register(check_request_status) + + +class revoke_certificate(Command): + """ Revoke a certificate. """ + + takes_args = ['serial_number'] + + # FIXME: The default is 0. Is this really an Int param? + takes_options = [Int('revocation_reason?', default=0)] + + + def execute(self, serial_number, **options): + return self.Backend.ra.revoke_certificate(serial_number, **options) + + def output_for_cli(self, textui, result, *args, **options): + if isinstance(result, dict) and len(result) > 0: + textui.print_entry(result, 0) + else: + textui.print_plain('Failed to revoke a certificate.') + +api.register(revoke_certificate) + + +class take_certificate_off_hold(Command): + """ Take a revoked certificate off hold. """ + + takes_args = ['serial_number'] + + def execute(self, serial_number, **options): + return self.Backend.ra.take_certificate_off_hold(serial_number) + + def output_for_cli(self, textui, result, *args, **options): + if isinstance(result, dict) and len(result) > 0: + textui.print_entry(result, 0) + else: + textui.print_plain('Failed to take a revoked certificate off hold.') + +api.register(take_certificate_off_hold) diff --git a/ipalib/plugins/f_service.py b/ipalib/plugins/f_service.py new file mode 100644 index 000000000..06d6a5d08 --- /dev/null +++ b/ipalib/plugins/f_service.py @@ -0,0 +1,204 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for service (Identity). +""" + +from ipalib import api, crud, errors +from ipalib import Object # Plugin base classes +from ipalib import Str, Flag # Parameter types + + +class service(Object): + """ + Service object. + """ + takes_params = ( + Str('principal', primary_key=True), + ) +api.register(service) + + +class service_add(crud.Add): + """ + Add a new service. + """ + + takes_options = ( + Flag('force', + doc='Force a service principal name', + ), + ) + def execute(self, principal, **kw): + """ + Execute the service-add operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry as it will be created in LDAP. + + :param principal: The service to be added in the form: service/hostname + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'krbprincipalname' not in kw + ldap = self.api.Backend.ldap + + force = kw.get('force', False) + try: + del kw['force'] + except: + pass + + # Break down the principal into its component parts, which may or + # may not include the realm. + sp = principal.split('/') + if len(sp) != 2: + raise errors.MalformedServicePrincipal + service = sp[0] + + if service.lower() == "host": + raise errors.HostService + + sr = sp[1].split('@') + if len(sr) == 1: + hostname = sr[0].lower() + realm = self.api.env.realm + elif len(sr) == 2: + hostname = sr[0].lower() + realm = sr[1] + else: + raise MalformedServicePrincipal + + """ + FIXME once DNS client is done + if not force: + fqdn = hostname + "." + rs = dnsclient.query(fqdn, dnsclient.DNS_C_IN, dnsclient.DNS_T_A) + if len(rs) == 0: + self.log.debug("IPA: DNS A record lookup failed for '%s'" % hostname) + raise ipaerror.gen_exception(ipaerror.INPUT_NOT_DNS_A_RECORD) + else: + self.log.debug("IPA: found %d records for '%s'" % (len(rs), hostname)) + """ + + # At some point we'll support multiple realms + if (realm != self.api.env.realm): + raise errors.RealmMismatch + + # Put the principal back together again + princ_name = service + "/" + hostname + "@" + realm + + dn = ldap.make_service_dn(princ_name) + + kw['dn'] = dn + kw['objectClass'] = ['krbPrincipal', 'krbPrincipalAux', 'krbTicketPolicyAux'] + + return ldap.create(**kw) + + def output_to_cli(self, ret): + if ret: + print "Service added" + +api.register(service_add) + + +class service_del(crud.Del): + 'Delete an existing service.' + def execute(self, principal, **kw): + """ + Delete a service principal. + + principal is the krbprincipalname of the entry to delete. + + This should be called with much care. + + :param principal: The service to be added in the form: service/hostname + :param kw: not used + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("krbprincipalname", principal) + return ldap.delete(dn) + + def output_to_cli(self, ret): + if ret: + print "Service removed" + +api.register(service_del) + +# There is no service-mod. The principal itself contains nothing that +# is user-changeable + +class service_find(crud.Find): + 'Search the existing services.' + def execute(self, principal, **kw): + ldap = self.api.Backend.ldap + + search_kw = {} + search_kw['filter'] = "&(objectclass=krbPrincipalAux)(!(objectClass=posixAccount))(!(|(krbprincipalname=kadmin/*)(krbprincipalname=K/M@*)(krbprincipalname=krbtgt/*)))" + search_kw['krbprincipalname'] = principal + + object_type = ldap.get_object_type("krbprincipalname") + if object_type and not kw.get('objectclass'): + search_kw['objectclass'] = object_type + + return ldap.search(**search_kw) + + def output_for_cli(self, textui, result, *args, **options): + counter = result[0] + services = result[1:] + if counter == 0: + textui.print_plain("No entries found") + return + + for s in services: + textui.print_entry(s) + + if counter == -1: + textui.print_plain("These results are truncated.") + textui.print_plain("Please refine your search and try again.") + textui.print_count(services, '%d services matched') + +api.register(service_find) + + +class service_show(crud.Get): + 'Examine an existing service.' + def execute(self, principal, **kw): + """ + Execute the service-show operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param principal: The service principal to retrieve + :param kw: Not used. + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("krbprincipalname", principal) + # FIXME: should kw contain the list of attributes to display? + return ldap.retrieve(dn) + def output_for_cli(self, textui, result, *args, **options): + textui.print_entry(result) + +api.register(service_show) diff --git a/ipalib/plugins/f_user.py b/ipalib/plugins/f_user.py new file mode 100644 index 000000000..506ad14d0 --- /dev/null +++ b/ipalib/plugins/f_user.py @@ -0,0 +1,367 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Frontend plugins for user (Identity). +""" + +from ipalib import api, crud, errors +from ipalib import Object, Command # Plugin base classes +from ipalib import Str, Password, Flag, Int # Parameter types + + +def display_user(user): + # FIXME: for now delete dn here. In the future pass in the kw to + # output_for_cli() + attr = sorted(user.keys()) + # Always have sn following givenname + try: + l = attr.index('givenname') + attr.remove('sn') + attr.insert(l+1, 'sn') + except ValueError: + pass + + for a in attr: + if a != 'dn': + print "%s: %s" % (a, user[a]) + +default_attributes = ['uid','givenname','sn','homeDirectory','loginshell'] + + +class user(Object): + """ + User object. + """ + + takes_params = ( + Str('givenname', + cli_name='first', + doc="User's first name", + ), + Str('sn', + cli_name='last', + doc="User's last name", + ), + Str('uid', + cli_name='user', + primary_key=True, + default_from=lambda givenname, sn: givenname[0] + sn, + normalizer=lambda value: value.lower(), + ), + Str('gecos?', + doc='GECOS field', + default_from=lambda uid: uid, + ), + Str('homedirectory?', + cli_name='home', + doc="User's home directory", + default_from=lambda uid: '/home/%s' % uid, + ), + Str('loginshell?', + cli_name='shell', + default=u'/bin/sh', + doc="User's Login shell", + ), + Str('krbprincipalname?', + cli_name='principal', + doc="User's Kerberos Principal name", + default_from=lambda uid: '%s@%s' % (uid, api.env.realm), + ), + Str('mailaddress?', + cli_name='email', + doc="User's e-mail address", + ), + Password('userpassword?', + cli_name='password', + doc="Set user's password", + ), + Str('groups?', + doc='Add account to one or more groups (comma-separated)', + ), + Int('uidnumber?', + cli_name='uid', + doc='The uid to use for this user. If not included one is automatically set.', + ), + ) + +api.register(user) + + +class user_add(crud.Add): + 'Add a new user.' + + def execute(self, uid, **kw): + """ + Execute the user-add operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry as it will be created in LDAP. + + :param uid: The login name of the user being added. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'uid' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + kw['uid'] = uid + kw['dn'] = ldap.make_user_dn(uid) + + # FIXME: enforce this elsewhere +# if servercore.uid_too_long(kw['uid']): +# raise errors.UsernameTooLong + + # Get our configuration + config = ldap.get_ipa_config() + + # Let us add in some missing attributes + if kw.get('homedirectory') is None: + kw['homedirectory'] = '%s/%s' % (config.get('ipahomesrootdir'), kw.get('uid')) + kw['homedirectory'] = kw['homedirectory'].replace('//', '/') + kw['homedirectory'] = kw['homedirectory'].rstrip('/') + if kw.get('loginshell') is None: + kw['loginshell'] = config.get('ipadefaultloginshell') + if kw.get('gecos') is None: + kw['gecos'] = kw['uid'] + + # If uidnumber is blank the the FDS dna_plugin will automatically + # assign the next value. So we don't have to do anything with it. + + if not kw.get('gidnumber'): + try: + group_dn = ldap.find_entry_dn("cn", config.get('ipadefaultprimarygroup')) + default_group = ldap.retrieve(group_dn, ['dn','gidNumber']) + if default_group: + kw['gidnumber'] = default_group.get('gidnumber') + except errors.NotFound: + # Fake an LDAP error so we can return something useful to the kw + raise errors.NotFound, "The default group for new kws, '%s', cannot be found." % config.get('ipadefaultprimarygroup') + except Exception, e: + # catch everything else + raise e + + if kw.get('krbprincipalname') is None: + kw['krbprincipalname'] = "%s@%s" % (kw.get('uid'), self.api.env.realm) + + # FIXME. This is a hack so we can request separate First and Last + # name in the GUI. + if kw.get('cn') is None: + kw['cn'] = "%s %s" % (kw.get('givenname'), + kw.get('sn')) + + # some required objectclasses + kw['objectClass'] = config.get('ipauserobjectclasses') + + return ldap.create(**kw) + + def output_for_cli(self, textui, result, *args, **options): + """ + Output result of this command to command line interface. + """ + textui.print_name(self.name) + textui.print_entry(result) + textui.print_dashed('Added user "%s"' % result['uid']) + +api.register(user_add) + + +class user_del(crud.Del): + 'Delete an existing user.' + + def execute(self, uid, **kw): + """Delete a user. Not to be confused with inactivate_user. This + makes the entry go away completely. + + uid is the uid of the user to delete + + The memberOf plugin handles removing the user from any other + groups. + + :param uid: The login name of the user being added. + :param kw: Not used. + """ + if uid == "admin": + # FIXME: do we still want a "special" user? + raise SyntaxError("admin required") +# raise ipaerror.gen_exception(ipaerror.INPUT_ADMIN_REQUIRED) + self.log.info("IPA: user-del '%s'" % uid) + + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("uid", uid) + return ldap.delete(dn) + + def output_for_cli(self, textui, result, uid): + """ + Output result of this command to command line interface. + """ + textui.print_plain('Deleted user "%s"' % uid) + +api.register(user_del) + + +class user_mod(crud.Mod): + 'Edit an existing user.' + def execute(self, uid, **kw): + """ + Execute the user-mod operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param uid: The login name of the user to retrieve. + :param kw: Keyword arguments for the other LDAP attributes. + """ + assert 'uid' not in kw + assert 'dn' not in kw + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("uid", uid) + return ldap.update(dn, **kw) + + def output_for_cli(self, textui, result, uid, **options): + """ + Output result of this command to command line interface. + """ + textui.print_name(self.name) + textui.print_entry(result) + textui.print_dashed('Updated user "%s"' % result['uid']) + +api.register(user_mod) + + +class user_find(crud.Find): + 'Search the users.' + takes_options = ( + Flag('all', doc='Retrieve all user attributes'), + ) + def execute(self, term, **kw): + ldap = self.api.Backend.ldap + + # Pull the list of searchable attributes out of the configuration. + config = ldap.get_ipa_config() + search_fields_conf_str = config.get('ipausersearchfields') + search_fields = search_fields_conf_str.split(",") + + search_kw = {} + for s in search_fields: + search_kw[s] = term + + object_type = ldap.get_object_type("uid") + if object_type and not kw.get('objectclass'): + search_kw['objectclass'] = object_type + if kw.get('all', False): + search_kw['attributes'] = ['*'] + else: + search_kw['attributes'] = default_attributes + return ldap.search(**search_kw) + + def output_for_cli(self, textui, result, uid, **options): + counter = result[0] + users = result[1:] + if counter == 0 or len(users) == 0: + textui.print_plain("No entries found") + return + if len(users) == 1: + textui.print_entry(users[0]) + return + textui.print_name(self.name) + for u in users: + gn = u.get('givenname', '') + sn= u.get('sn', '') + textui.print_plain('%s %s:' % (gn, sn)) + textui.print_entry(u) + textui.print_plain('') + if counter == -1: + textui.print_plain('These results are truncated.') + textui.print_plain('Please refine your search and try again.') + textui.print_count(users, '%d users matched') + +api.register(user_find) + + +class user_show(crud.Get): + 'Examine an existing user.' + takes_options = ( + Flag('all', doc='Retrieve all user attributes'), + ) + def execute(self, uid, **kw): + """ + Execute the user-show operation. + + The dn should not be passed as a keyword argument as it is constructed + by this method. + + Returns the entry + + :param uid: The login name of the user to retrieve. + :param kw: "all" set to True = return all attributes + """ + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("uid", uid) + # FIXME: should kw contain the list of attributes to display? + if kw.get('all', False): + return ldap.retrieve(dn) + else: + return ldap.retrieve(dn, default_attributes) + + def output_for_cli(self, textui, result, uid, **options): + if result: + display_user(result) + +api.register(user_show) + +class user_lock(Command): + 'Lock a user account.' + + takes_args = ( + Str('uid', primary_key=True), + ) + + def execute(self, uid, **kw): + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("uid", uid) + return ldap.mark_entry_inactive(dn) + + def output_for_cli(self, textui, result, uid): + if result: + textui.print_plain('Locked user "%s"' % uid) + +api.register(user_lock) + + +class user_unlock(Command): + 'Unlock a user account.' + + takes_args = ( + Str('uid', primary_key=True), + ) + + def execute(self, uid, **kw): + ldap = self.api.Backend.ldap + dn = ldap.find_entry_dn("uid", uid) + return ldap.mark_entry_active(dn) + + def output_for_cli(self, textui, result, uid): + if result: + textui.print_plain('Unlocked user "%s"' % uid) + +api.register(user_unlock) diff --git a/ipalib/request.py b/ipalib/request.py new file mode 100644 index 000000000..6ad7ad35f --- /dev/null +++ b/ipalib/request.py @@ -0,0 +1,71 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty contextrmation +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Per-request thread-local data. +""" + +import threading +import locale +import gettext +from constants import OVERRIDE_ERROR + + +# Thread-local storage of most per-request information +context = threading.local() + + +def ugettext(message): + if hasattr(context, 'ugettext'): + return context.ugettext(message) + return message.decode('UTF-8') + + +def ungettext(singular, plural, n): + if hasattr(context, 'ungettext'): + return context.ungettext(singular, plural, n) + if n == 1: + return singular.decode('UTF-8') + return plural.decode('UTF-8') + + +def set_languages(*languages): + if hasattr(context, 'languages'): + raise StandardError(OVERRIDE_ERROR % + ('context', 'languages', context.languages, languages) + ) + if len(languages) == 0: + languages = locale.getdefaultlocale()[:1] + context.languages = languages + assert type(context.languages) is tuple + + +def create_translation(domain, localedir, *languages): + if hasattr(context, 'ugettext') or hasattr(context, 'ungettext'): + raise StandardError( + 'create_translation() already called in thread %r' % + threading.currentThread().getName() + ) + set_languages(*languages) + translation = gettext.translation(domain, + localedir=localedir, languages=context.languages, fallback=True + ) + context.ugettext = translation.ugettext + context.ungettext = translation.ungettext diff --git a/ipalib/rpc.py b/ipalib/rpc.py new file mode 100644 index 000000000..e7823ef95 --- /dev/null +++ b/ipalib/rpc.py @@ -0,0 +1,207 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +RPC client and shared RPC client/server functionality. + +This module adds some additional functionality on top of the ``xmlrpclib`` +module in the Python standard library. For documentation on the +``xmlrpclib`` module, see: + + http://docs.python.org/library/xmlrpclib.html + +Also see the `ipaserver.rpcserver` module. +""" + +from types import NoneType +import threading +from xmlrpclib import Binary, Fault, dumps, loads +from ipalib.backend import Backend +from ipalib.errors2 import public_errors, PublicError, UnknownError +from ipalib.request import context + + +def xml_wrap(value): + """ + Wrap all ``str`` in ``xmlrpclib.Binary``. + + Because ``xmlrpclib.dumps()`` will itself convert all ``unicode`` instances + into UTF-8 encoded ``str`` instances, we don't do it here. + + So in total, when encoding data for an XML-RPC packet, the following + transformations occur: + + * All ``str`` instances are treated as binary data and are wrapped in + an ``xmlrpclib.Binary()`` instance. + + * Only ``unicode`` instances are treated as character data. They get + converted to UTF-8 encoded ``str`` instances (although as mentioned, + not by this function). + + Also see `xml_unwrap()`. + + :param value: The simple scalar or simple compound value to wrap. + """ + if type(value) in (list, tuple): + return tuple(xml_wrap(v) for v in value) + if type(value) is dict: + return dict( + (k, xml_wrap(v)) for (k, v) in value.iteritems() + ) + if type(value) is str: + return Binary(value) + assert type(value) in (unicode, int, float, bool, NoneType) + return value + + +def xml_unwrap(value, encoding='UTF-8'): + """ + Unwrap all ``xmlrpc.Binary``, decode all ``str`` into ``unicode``. + + When decoding data from an XML-RPC packet, the following transformations + occur: + + * The binary payloads of all ``xmlrpclib.Binary`` instances are + returned as ``str`` instances. + + * All ``str`` instances are treated as UTF-8 encoded Unicode strings. + They are decoded and the resulting ``unicode`` instance is returned. + + Also see `xml_wrap()`. + + :param value: The value to unwrap. + :param encoding: The Unicode encoding to use (defaults to ``'UTF-8'``). + """ + if type(value) in (list, tuple): + return tuple(xml_unwrap(v, encoding) for v in value) + if type(value) is dict: + return dict( + (k, xml_unwrap(v, encoding)) for (k, v) in value.iteritems() + ) + if type(value) is str: + return value.decode(encoding) + if isinstance(value, Binary): + assert type(value.data) is str + return value.data + assert type(value) in (unicode, int, float, bool, NoneType) + return value + + +def xml_dumps(params, methodname=None, methodresponse=False, encoding='UTF-8'): + """ + Encode an XML-RPC data packet, transparently wraping ``params``. + + This function will wrap ``params`` using `xml_wrap()` and will + then encode the XML-RPC data packet using ``xmlrpclib.dumps()`` (from the + Python standard library). + + For documentation on the ``xmlrpclib.dumps()`` function, see: + + http://docs.python.org/library/xmlrpclib.html#convenience-functions + + Also see `xml_loads()`. + + :param params: A ``tuple`` or an ``xmlrpclib.Fault`` instance. + :param methodname: The name of the method to call if this is a request. + :param methodresponse: Set this to ``True`` if this is a response. + :param encoding: The Unicode encoding to use (defaults to ``'UTF-8'``). + """ + if type(params) is tuple: + params = xml_wrap(params) + else: + assert isinstance(params, Fault) + return dumps(params, + methodname=methodname, + methodresponse=methodresponse, + encoding=encoding, + allow_none=True, + ) + + +def xml_loads(data): + """ + Decode the XML-RPC packet in ``data``, transparently unwrapping its params. + + This function will decode the XML-RPC packet in ``data`` using + ``xmlrpclib.loads()`` (from the Python standard library). If ``data`` + contains a fault, ``xmlrpclib.loads()`` will itself raise an + ``xmlrpclib.Fault`` exception. + + Assuming an exception is not raised, this function will then unwrap the + params in ``data`` using `xml_unwrap()`. Finally, a + ``(params, methodname)`` tuple is returned containing the unwrapped params + and the name of the method being called. If the packet contains no method + name, ``methodname`` will be ``None``. + + For documentation on the ``xmlrpclib.loads()`` function, see: + + http://docs.python.org/library/xmlrpclib.html#convenience-functions + + Also see `xml_dumps()`. + + :param data: The XML-RPC packet to decode. + """ + (params, method) = loads(data) + return (xml_unwrap(params), method) + + +class xmlclient(Backend): + """ + Forwarding backend for XML-RPC client. + """ + + def __init__(self): + super(xmlclient, self).__init__() + self.__errors = dict((e.errno, e) for e in public_errors) + + def forward(self, name, *args, **kw): + """ + Forward call to command named ``name`` over XML-RPC. + + This method will encode and forward an XML-RPC request, and will then + decode and return the corresponding XML-RPC response. + + :param command: The name of the command being forwarded. + :param args: Positional arguments to pass to remote command. + :param kw: Keyword arguments to pass to remote command. + """ + if name not in self.Command: + raise ValueError( + '%s.forward(): %r not in api.Command' % (self.name, name) + ) + if not hasattr(context, 'xmlconn'): + raise StandardError( + '%s.forward(%r): need context.xmlconn in thread %r' % ( + self.name, name, threading.currentThread().getName() + ) + ) + command = getattr(context.xmlconn, name) + params = args + (kw,) + try: + response = command(xml_wrap(params)) + return xml_unwrap(response) + except Fault, e: + if e.faultCode in self.__errors: + error = self.__errors[e.faultCode] + raise error(message=e.faultString) + raise UnknownError( + code=e.faultCode, + error=e.faultString, + server=self.env.xmlrpc_uri, + ) diff --git a/ipalib/util.py b/ipalib/util.py new file mode 100644 index 000000000..4a58d7fbc --- /dev/null +++ b/ipalib/util.py @@ -0,0 +1,151 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Various utility functions. +""" + +import os +from os import path +import imp +import optparse +import logging +import time +from types import NoneType +from xmlrpclib import Binary +import krbV + + + +def xmlrpc_marshal(*args, **kw): + """ + Marshal (args, kw) into ((kw,) + args). + """ + kw = dict( + filter(lambda item: item[1] is not None, kw.iteritems()) + ) + args = tuple( + filter(lambda value: value is not None, args) + ) + return ((kw,) + args) + + +def xmlrpc_unmarshal(*params): + """ + Unmarshal (params) into (args, kw). + """ + if len(params) > 0: + kw = params[0] + if type(kw) is not dict: + raise TypeError('first xmlrpc argument must be dict') + else: + kw = {} + return (params[1:], kw) + + +def get_current_principal(): + try: + return krbV.default_context().default_ccache().principal().name + except krbV.Krb5Error: + #TODO: do a kinit + print "Unable to get kerberos principal" + return None + + +# FIXME: This function has no unit test +def find_modules_in_dir(src_dir): + """ + Iterate through module names found in ``src_dir``. + """ + if not (path.abspath(src_dir) == src_dir and path.isdir(src_dir)): + return + if path.islink(src_dir): + return + suffix = '.py' + for name in sorted(os.listdir(src_dir)): + if not name.endswith(suffix): + continue + py_file = path.join(src_dir, name) + if path.islink(py_file) or not path.isfile(py_file): + continue + module = name[:-len(suffix)] + if module == '__init__': + continue + yield module + + +# FIXME: This function has no unit test +def load_plugins_in_dir(src_dir): + """ + Import each Python module found in ``src_dir``. + """ + for module in find_modules_in_dir(src_dir): + imp.load_module(module, *imp.find_module(module, [src_dir])) + + +# FIXME: This function has no unit test +def import_plugins_subpackage(name): + """ + Import everythig in ``plugins`` sub-package of package named ``name``. + """ + try: + plugins = __import__(name + '.plugins').plugins + except ImportError: + return + src_dir = path.dirname(path.abspath(plugins.__file__)) + for name in find_modules_in_dir(src_dir): + full_name = '%s.%s' % (plugins.__name__, name) + __import__(full_name) + + +def add_global_options(parser=None): + """ + Add global options to an optparse.OptionParser instance. + """ + if parser is None: + parser = optparse.OptionParser() + parser.add_option('-e', dest='env', metavar='KEY=VAL', action='append', + help='Set environment variable KEY to VAL', + ) + parser.add_option('-c', dest='conf', metavar='FILE', + help='Load configuration from FILE', + ) + parser.add_option('-d', '--debug', action='store_true', + help='Produce full debuging output', + ) + parser.add_option('-v', '--verbose', action='store_true', + help='Produce more verbose output', + ) + return parser + + +class LogFormatter(logging.Formatter): + """ + Log formatter that uses UTC for all timestamps. + """ + converter = time.gmtime + + +def make_repr(name, *args, **kw): + """ + Construct a standard representation of a class instance. + """ + args = [repr(a) for a in args] + kw = ['%s=%r' % (k, kw[k]) for k in sorted(kw)] + return '%s(%s)' % (name, ', '.join(args + kw)) diff --git a/ipaserver/__init__.py b/ipaserver/__init__.py new file mode 100644 index 000000000..b0be96bd2 --- /dev/null +++ b/ipaserver/__init__.py @@ -0,0 +1,22 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Package containing server backend. +""" diff --git a/ipaserver/conn.py b/ipaserver/conn.py new file mode 100644 index 000000000..fb00ad998 --- /dev/null +++ b/ipaserver/conn.py @@ -0,0 +1,69 @@ +# Authors: Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# + +import krbV +import ldap +import ldap.dn +import ipaldap + +class IPAConn: + def __init__(self, host, port, krbccache, debug=None): + self._conn = None + + # Save the arguments + self._host = host + self._port = port + self._krbccache = krbccache + self._debug = debug + + self._ctx = krbV.default_context() + + ccache = krbV.CCache(name=krbccache, context=self._ctx) + cprinc = ccache.principal() + + self._conn = ipaldap.IPAdmin(host,port,None,None,None,debug) + + # This will bind the connection + try: + self._conn.set_krbccache(krbccache, cprinc.name) + except ldap.UNWILLING_TO_PERFORM, e: + raise e + except Exception, e: + raise e + + def __del__(self): + # take no chances on unreleased connections + self.releaseConn() + + def getConn(self): + return self._conn + + def releaseConn(self): + if self._conn is None: + return + + self._conn.unbind_s() + self._conn = None + + return + +if __name__ == "__main__": + ipaconn = IPAConn("localhost", 389, "FILE:/tmp/krb5cc_500") + x = ipaconn.getConn().getEntry("dc=example,dc=com", ldap.SCOPE_SUBTREE, "uid=admin", ["cn"]) + print "%s" % x diff --git a/ipaserver/context.py b/ipaserver/context.py new file mode 100644 index 000000000..15dd7d908 --- /dev/null +++ b/ipaserver/context.py @@ -0,0 +1,32 @@ +# Authors: Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# + +# This should only be imported once. Importing again will cause the +# a new instance to be created in the same thread + +# To use: +# from ipaserver.context import context +# context.foo = "bar" + +# FIXME: This module is depreciated and code should switch to using +# ipalib.request instead + +import threading + +context = threading.local() diff --git a/ipaserver/ipaldap.py b/ipaserver/ipaldap.py new file mode 100644 index 000000000..4a2e4e31c --- /dev/null +++ b/ipaserver/ipaldap.py @@ -0,0 +1,553 @@ +# Authors: Rich Megginson <richm@redhat.com> +# Rob Crittenden <rcritten@redhat.com +# +# Copyright (C) 2007 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# + +import sys +import os +import os.path +import socket +import ldif +import re +import string +import ldap +import cStringIO +import struct +import ldap.sasl +from ldap.controls import LDAPControl,DecodeControlTuples,EncodeControlTuples +from ldap.ldapobject import SimpleLDAPObject +from ipaserver import ipautil +from ipalib import errors + +# Global variable to define SASL auth +sasl_auth = ldap.sasl.sasl({},'GSSAPI') + +class Entry: + """ + This class represents an LDAP Entry object. An LDAP entry consists of + a DN and a list of attributes. Each attribute consists of a name and + a list of values. In python-ldap, entries are returned as a list of + 2-tuples. Instance variables: + + * dn - string - the string DN of the entry + * data - CIDict - case insensitive dict of the attributes and values + """ + def __init__(self,entrydata): + """data is the raw data returned from the python-ldap result method, which is + a search result entry or a reference or None. + If creating a new empty entry, data is the string DN.""" + if entrydata: + if isinstance(entrydata,tuple): + self.dn = entrydata[0] + self.data = ipautil.CIDict(entrydata[1]) + elif isinstance(entrydata,str) or isinstance(entrydata,unicode): + self.dn = entrydata + self.data = ipautil.CIDict() + else: + self.dn = '' + self.data = ipautil.CIDict() + + def __nonzero__(self): + """This allows us to do tests like if entry: returns false if there is no data, + true otherwise""" + return self.data != None and len(self.data) > 0 + + def hasAttr(self,name): + """Return True if this entry has an attribute named name, False otherwise""" + return self.data and self.data.has_key(name) + + def __getattr__(self,name): + """If name is the name of an LDAP attribute, return the first value for that + attribute - equivalent to getValue - this allows the use of + entry.cn + instead of + entry.getValue('cn') + This also allows us to return None if an attribute is not found rather than + throwing an exception""" + return self.getValue(name) + + def getValues(self,name): + """Get the list (array) of values for the attribute named name""" + return self.data.get(name) + + def getValue(self,name): + """Get the first value for the attribute named name""" + return self.data.get(name,[None])[0] + + def setValue(self, name, *value): + """ + Set a value on this entry. + + The value passed in may be a single value, several values, or a + single sequence. For example: + + * ent.setValue('name', 'value') + * ent.setValue('name', 'value1', 'value2', ..., 'valueN') + * ent.setValue('name', ['value1', 'value2', ..., 'valueN']) + * ent.setValue('name', ('value1', 'value2', ..., 'valueN')) + + Since value is a tuple, we may have to extract a list or tuple from + that tuple as in the last two examples above. + """ + if isinstance(value[0],list) or isinstance(value[0],tuple): + self.data[name] = value[0] + else: + self.data[name] = value + + setValues = setValue + + def delAttr(self, name): + """ + Entirely remove an attribute of this entry. + """ + if self.hasAttr(name): + del self.data[name] + + def toTupleList(self): + """Convert the attrs and values to a list of 2-tuples. The first element + of the tuple is the attribute name. The second element is either a + single value or a list of values.""" + r = [] + for i in self.data.iteritems(): + n = ipautil.utf8_encode_values(i[1]) + r.append((i[0], n)) + return r + + def toDict(self): + """Convert the attrs and values to a dict. The dict is keyed on the + attribute name. The value is either single value or a list of values.""" + result = ipautil.CIDict(self.data) + for i in result.keys(): + result[i] = ipautil.utf8_encode_values(result[i]) + result['dn'] = self.dn + return result + + def __str__(self): + """Convert the Entry to its LDIF representation""" + return self.__repr__() + + # the ldif class base64 encodes some attrs which I would rather see in + # raw form - to encode specific attrs as base64, add them to the list below + ldif.safe_string_re = re.compile('^$') + base64_attrs = ['nsstate', 'krbprincipalkey', 'krbExtraData'] + + def __repr__(self): + """Convert the Entry to its LDIF representation""" + sio = cStringIO.StringIO() + # what's all this then? the unparse method will currently only accept + # a list or a dict, not a class derived from them. self.data is a + # cidict, so unparse barfs on it. I've filed a bug against python-ldap, + # but in the meantime, we have to convert to a plain old dict for + # printing + # I also don't want to see wrapping, so set the line width really high + # (1000) + newdata = {} + newdata.update(self.data) + ldif.LDIFWriter(sio,Entry.base64_attrs,1000).unparse(self.dn,newdata) + return sio.getvalue() + +def wrapper(f,name): + """This is the method that wraps all of the methods of the superclass. + This seems to need to be an unbound method, that's why it's outside + of IPAdmin. Perhaps there is some way to do this with the new + classmethod or staticmethod of 2.4. Basically, we replace every call + to a method in SimpleLDAPObject (the superclass of IPAdmin) with a + call to inner. The f argument to wrapper is the bound method of + IPAdmin (which is inherited from the superclass). Bound means that it + will implicitly be called with the self argument, it is not in the + args list. name is the name of the method to call. If name is a + method that returns entry objects (e.g. result), we wrap the data + returned by an Entry class. If name is a method that takes an entry + argument, we extract the raw data from the entry object to pass in. + """ + def inner(*args, **kargs): + if name == 'result': + objtype, data = f(*args, **kargs) + # data is either a 2-tuple or a list of 2-tuples + # print data + if data: + if isinstance(data,tuple): + return objtype, Entry(data) + elif isinstance(data,list): + return objtype, [Entry(x) for x in data] + else: + raise TypeError, "unknown data type %s returned by result" % type(data) + else: + return objtype, data + elif name.startswith('add'): + # the first arg is self + # the second and third arg are the dn and the data to send + # We need to convert the Entry into the format used by + # python-ldap + ent = args[0] + if isinstance(ent,Entry): + return f(ent.dn, ent.toTupleList(), *args[2:]) + else: + return f(*args, **kargs) + else: + return f(*args, **kargs) + return inner + +class IPAdmin(SimpleLDAPObject): + + def __localinit(self): + """If a CA certificate is provided then it is assumed that we are + doing SSL client authentication with proxy auth. + + If a CA certificate is not present then it is assumed that we are + using a forwarded kerberos ticket for SASL auth. SASL provides + its own encryption. + """ + if self.cacert is not None: + SimpleLDAPObject.__init__(self,'ldaps://%s:%d' % (self.host,self.port)) + else: + SimpleLDAPObject.__init__(self,'ldap://%s:%d' % (self.host,self.port)) + + def __init__(self,host,port=389,cacert=None,bindcert=None,bindkey=None,proxydn=None,debug=None): + """We just set our instance variables and wrap the methods - the real + work is done in __localinit. This is separated out this way so + that we can call it from places other than instance creation + e.g. when we just need to reconnect + """ + if debug and debug.lower() == "on": + ldap.set_option(ldap.OPT_DEBUG_LEVEL,255) + if cacert is not None: + ldap.set_option(ldap.OPT_X_TLS_CACERTFILE,cacert) + if bindcert is not None: + ldap.set_option(ldap.OPT_X_TLS_CERTFILE,bindcert) + if bindkey is not None: + ldap.set_option(ldap.OPT_X_TLS_KEYFILE,bindkey) + + self.__wrapmethods() + self.port = port + self.host = host + self.cacert = cacert + self.bindcert = bindcert + self.bindkey = bindkey + self.proxydn = proxydn + self.suffixes = {} + self.__localinit() + + def __str__(self): + return self.host + ":" + str(self.port) + + def __get_server_controls(self): + """Create the proxy user server control. The control has the form + 0x04 = Octet String + 4|0x80 sets the length of the string length field at 4 bytes + the struct() gets us the length in bytes of string self.proxydn + self.proxydn is the proxy dn to send""" + + if self.proxydn is not None: + proxydn = chr(0x04) + chr(4|0x80) + struct.pack('l', socket.htonl(len(self.proxydn))) + self.proxydn; + + # Create the proxy control + sctrl=[] + sctrl.append(LDAPControl('2.16.840.1.113730.3.4.18',True,proxydn)) + else: + sctrl=None + + return sctrl + + def toLDAPURL(self): + return "ldap://%s:%d/" % (self.host,self.port) + + def set_proxydn(self, proxydn): + self.proxydn = proxydn + + def set_krbccache(self, krbccache, principal): + if krbccache is not None: + os.environ["KRB5CCNAME"] = krbccache + self.sasl_interactive_bind_s("", sasl_auth) + self.principal = principal + self.proxydn = None + + def do_simple_bind(self, binddn="cn=directory manager", bindpw=""): + self.binddn = binddn + self.bindpwd = bindpw + self.simple_bind_s(binddn, bindpw) + + def getEntry(self,*args): + """This wraps the search function. It is common to just get one entry""" + + sctrl = self.__get_server_controls() + + if sctrl is not None: + self.set_option(ldap.OPT_SERVER_CONTROLS, sctrl) + + try: + res = self.search(*args) + objtype, obj = self.result(res) + except ldap.NO_SUCH_OBJECT, e: + raise errors.NotFound, notfound(args) + except ldap.LDAPError, e: + raise errors.DatabaseError, e + + if not obj: + raise errors.NotFound, notfound(args) + + elif isinstance(obj,Entry): + return obj + else: # assume list/tuple + return obj[0] + + def getList(self,*args): + """This wraps the search function to find multiple entries.""" + + sctrl = self.__get_server_controls() + if sctrl is not None: + self.set_option(ldap.OPT_SERVER_CONTROLS, sctrl) + + try: + res = self.search(*args) + objtype, obj = self.result(res) + except (ldap.ADMINLIMIT_EXCEEDED, ldap.SIZELIMIT_EXCEEDED), e: + # Too many results returned by search + raise e + except ldap.LDAPError, e: + raise e + + if not obj: + raise errors.NotFound, notfound(args) + + entries = [] + for s in obj: + entries.append(s) + + return entries + + def getListAsync(self,*args): + """This version performs an asynchronous search, to allow + results even if we hit a limit. + + It returns a list: counter followed by the results. + If the results are truncated, counter will be set to -1. + """ + + sctrl = self.__get_server_controls() + if sctrl is not None: + self.set_option(ldap.OPT_SERVER_CONTROLS, sctrl) + + entries = [] + partial = 0 + + try: + msgid = self.search_ext(*args) + objtype, result_list = self.result(msgid, 0) + while result_list: + for result in result_list: + entries.append(result) + objtype, result_list = self.result(msgid, 0) + except (ldap.ADMINLIMIT_EXCEEDED, ldap.SIZELIMIT_EXCEEDED, + ldap.TIMELIMIT_EXCEEDED), e: + partial = 1 + except ldap.LDAPError, e: + raise e + + if not entries: + raise errors.NotFound, notfound(args) + + if partial == 1: + counter = -1 + else: + counter = len(entries) + + return [counter] + entries + + def addEntry(self,*args): + """This wraps the add function. It assumes that the entry is already + populated with all of the desired objectclasses and attributes""" + + sctrl = self.__get_server_controls() + + try: + if sctrl is not None: + self.set_option(ldap.OPT_SERVER_CONTROLS, sctrl) + self.add_s(*args) + except ldap.ALREADY_EXISTS, e: + raise errors.DuplicateEntry, "Entry already exists" + except ldap.LDAPError, e: + raise errors.DatabaseError, e + return True + + def updateRDN(self, dn, newrdn): + """Wrap the modrdn function.""" + + sctrl = self.__get_server_controls() + + if dn == newrdn: + # no need to report an error + return True + + try: + if sctrl is not None: + self.set_option(ldap.OPT_SERVER_CONTROLS, sctrl) + self.modrdn_s(dn, newrdn, delold=1) + except ldap.LDAPError, e: + raise errors.DatabaseError, e + return True + + def updateEntry(self,dn,oldentry,newentry): + """This wraps the mod function. It assumes that the entry is already + populated with all of the desired objectclasses and attributes""" + + sctrl = self.__get_server_controls() + + modlist = self.generateModList(oldentry, newentry) + + if len(modlist) == 0: + raise errors.EmptyModlist + + try: + if sctrl is not None: + self.set_option(ldap.OPT_SERVER_CONTROLS, sctrl) + self.modify_s(dn, modlist) + # this is raised when a 'delete' attribute isn't found. + # it indicates the previous attribute was removed by another + # update, making the oldentry stale. + except ldap.NO_SUCH_ATTRIBUTE: + raise errors.MidairCollision + except ldap.LDAPError, e: + raise errors.DatabaseError, e + return True + + def generateModList(self, old_entry, new_entry): + """A mod list generator that computes more precise modification lists + than the python-ldap version. This version purposely generates no + REPLACE operations, to deal with multi-user updates more properly.""" + modlist = [] + + old_entry = ipautil.CIDict(old_entry) + new_entry = ipautil.CIDict(new_entry) + + keys = set(map(string.lower, old_entry.keys())) + keys.update(map(string.lower, new_entry.keys())) + + for key in keys: + new_values = new_entry.get(key, []) + if not(isinstance(new_values,list) or isinstance(new_values,tuple)): + new_values = [new_values] + new_values = filter(lambda value:value!=None, new_values) + new_values = set(new_values) + + old_values = old_entry.get(key, []) + if not(isinstance(old_values,list) or isinstance(old_values,tuple)): + old_values = [old_values] + old_values = filter(lambda value:value!=None, old_values) + old_values = set(old_values) + + adds = list(new_values.difference(old_values)) + removes = list(old_values.difference(new_values)) + + if len(removes) > 0: + modlist.append((ldap.MOD_DELETE, key, removes)) + if len(adds) > 0: + modlist.append((ldap.MOD_ADD, key, adds)) + + return modlist + + def inactivateEntry(self,dn,has_key): + """Rather than deleting entries we mark them as inactive. + has_key defines whether the entry already has nsAccountlock + set so we can determine which type of mod operation to run.""" + + sctrl = self.__get_server_controls() + modlist=[] + + if has_key: + operation = ldap.MOD_REPLACE + else: + operation = ldap.MOD_ADD + + modlist.append((operation, "nsAccountlock", "true")) + + try: + if sctrl is not None: + self.set_option(ldap.OPT_SERVER_CONTROLS, sctrl) + self.modify_s(dn, modlist) + except ldap.LDAPError, e: + raise errors.DatabaseError, e + return True + + def deleteEntry(self,*args): + """This wraps the delete function. Use with caution.""" + + sctrl = self.__get_server_controls() + + try: + if sctrl is not None: + self.set_option(ldap.OPT_SERVER_CONTROLS, sctrl) + self.delete_s(*args) + except ldap.INSUFFICIENT_ACCESS, e: + raise errors.InsufficientAccess, e + except ldap.LDAPError, e: + raise errors.DatabaseError, e + return True + + def modifyPassword(self,dn,oldpass,newpass): + """Set the user password using RFC 3062, LDAP Password Modify Extended + Operation. This ends up calling the IPA password slapi plugin + handler so the Kerberos password gets set properly. + + oldpass is not mandatory + """ + + sctrl = self.__get_server_controls() + + try: + if sctrl is not None: + self.set_option(ldap.OPT_SERVER_CONTROLS, sctrl) + self.passwd_s(dn, oldpass, newpass) + except ldap.LDAPError, e: + raise e + return True + + def __wrapmethods(self): + """This wraps all methods of SimpleLDAPObject, so that we can intercept + the methods that deal with entries. Instead of using a raw list of tuples + of lists of hashes of arrays as the entry object, we want to wrap entries + in an Entry class that provides some useful methods""" + for name in dir(self.__class__.__bases__[0]): + attr = getattr(self, name) + if callable(attr): + setattr(self, name, wrapper(attr, name)) + + def normalizeDN(dn): + # not great, but will do until we use a newer version of python-ldap + # that has DN utilities + ary = ldap.explode_dn(dn.lower()) + return ",".join(ary) + normalizeDN = staticmethod(normalizeDN) + +def notfound(args): + """Return a string suitable for displaying as an error when a + search returns no results. + + This just returns whatever is after the equals sign""" + if len(args) > 2: + searchfilter = args[2] + try: + # Python re doesn't do paren counting so the string could + # have a trailing paren "foo)" + target = re.match(r'\(.*=(.*)\)', searchfilter).group(1) + target = target.replace(")","") + except: + target = searchfilter + return "%s not found" % str(target) + else: + return args[0] diff --git a/ipaserver/ipautil.py b/ipaserver/ipautil.py new file mode 100644 index 000000000..6422fe5a6 --- /dev/null +++ b/ipaserver/ipautil.py @@ -0,0 +1,201 @@ +# Authors: Simo Sorce <ssorce@redhat.com> +# +# Copyright (C) 2007 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# + +import string +import xmlrpclib +import re + +def realm_to_suffix(realm_name): + s = realm_name.split(".") + terms = ["dc=" + x.lower() for x in s] + return ",".join(terms) + +class CIDict(dict): + """ + Case-insensitive but case-respecting dictionary. + + This code is derived from python-ldap's cidict.py module, + written by stroeder: http://python-ldap.sourceforge.net/ + + This version extends 'dict' so it works properly with TurboGears. + If you extend UserDict, isinstance(foo, dict) returns false. + """ + + def __init__(self,default=None): + super(CIDict, self).__init__() + self._keys = {} + self.update(default or {}) + + def __getitem__(self,key): + return super(CIDict,self).__getitem__(string.lower(key)) + + def __setitem__(self,key,value): + lower_key = string.lower(key) + self._keys[lower_key] = key + return super(CIDict,self).__setitem__(string.lower(key),value) + + def __delitem__(self,key): + lower_key = string.lower(key) + del self._keys[lower_key] + return super(CIDict,self).__delitem__(string.lower(key)) + + def update(self,dict): + for key in dict.keys(): + self[key] = dict[key] + + def has_key(self,key): + return super(CIDict, self).has_key(string.lower(key)) + + def get(self,key,failobj=None): + try: + return self[key] + except KeyError: + return failobj + + def keys(self): + return self._keys.values() + + def items(self): + result = [] + for k in self._keys.values(): + result.append((k,self[k])) + return result + + def copy(self): + copy = {} + for k in self._keys.values(): + copy[k] = self[k] + return copy + + def iteritems(self): + return self.copy().iteritems() + + def iterkeys(self): + return self.copy().iterkeys() + + def setdefault(self,key,value=None): + try: + return self[key] + except KeyError: + self[key] = value + return value + + def pop(self, key, *args): + try: + value = self[key] + del self[key] + return value + except KeyError: + if len(args) == 1: + return args[0] + raise + + def popitem(self): + (lower_key,value) = super(CIDict,self).popitem() + key = self._keys[lower_key] + del self._keys[lower_key] + + return (key,value) + + +# +# The safe_string_re regexp and needs_base64 function are extracted from the +# python-ldap ldif module, which was +# written by Michael Stroeder <michael@stroeder.com> +# http://python-ldap.sourceforge.net +# +# It was extracted because ipaldap.py is naughtily reaching into the ldif +# module and squashing this regexp. +# +SAFE_STRING_PATTERN = '(^(\000|\n|\r| |:|<)|[\000\n\r\200-\377]+|[ ]+$)' +safe_string_re = re.compile(SAFE_STRING_PATTERN) + +def needs_base64(s): + """ + returns 1 if s has to be base-64 encoded because of special chars + """ + return not safe_string_re.search(s) is None + + +def wrap_binary_data(data): + """Converts all binary data strings into Binary objects for transport + back over xmlrpc.""" + if isinstance(data, str): + if needs_base64(data): + return xmlrpclib.Binary(data) + else: + return data + elif isinstance(data, list) or isinstance(data,tuple): + retval = [] + for value in data: + retval.append(wrap_binary_data(value)) + return retval + elif isinstance(data, dict): + retval = {} + for (k,v) in data.iteritems(): + retval[k] = wrap_binary_data(v) + return retval + else: + return data + + +def unwrap_binary_data(data): + """Converts all Binary objects back into strings.""" + if isinstance(data, xmlrpclib.Binary): + # The data is decoded by the xmlproxy, but is stored + # in a binary object for us. + return str(data) + elif isinstance(data, str): + return data + elif isinstance(data, list) or isinstance(data,tuple): + retval = [] + for value in data: + retval.append(unwrap_binary_data(value)) + return retval + elif isinstance(data, dict): + retval = {} + for (k,v) in data.iteritems(): + retval[k] = unwrap_binary_data(v) + return retval + else: + return data + +def get_gsserror(e): + """A GSSError exception looks differently in python 2.4 than it does + in python 2.5, deal with it.""" + + try: + primary = e[0] + secondary = e[1] + except: + primary = e[0][0] + secondary = e[0][1] + + return (primary[0], secondary[0]) + +def utf8_encode_value(value): + if isinstance(value,unicode): + return value.encode('utf-8') + return value + +def utf8_encode_values(values): + if isinstance(values,list) or isinstance(values,tuple): + return map(utf8_encode_value, values) + else: + return utf8_encode_value(values) diff --git a/ipaserver/mod_python_xmlrpc.py b/ipaserver/mod_python_xmlrpc.py new file mode 100644 index 000000000..9a2960f93 --- /dev/null +++ b/ipaserver/mod_python_xmlrpc.py @@ -0,0 +1,367 @@ +# mod_python script + +# ipaxmlrpc - an XMLRPC interface for ipa. +# Copyright (c) 2007 Red Hat +# +# IPA is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; +# version 2.1 of the License. +# +# This software is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this software; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# +# Based on kojixmlrpc - an XMLRPC interface for koji by +# Mike McLean <mikem@redhat.com> +# +# Authors: +# Rob Crittenden <rcritten@redhat.com> + +""" +Production XML-RPC server using mod_python. +""" + +import sys + + +import time +import traceback +import pprint +from xmlrpclib import Marshaller,loads,dumps,Fault +try: + from mod_python import apache +except ImportError: + pass +import logging + +import ldap +from ipalib import api +from ipalib import config +from ipaserver import conn +from ipaserver.servercore import context +from ipaserver.servercore import ipautil +from ipalib.util import xmlrpc_unmarshal + +import string + +api.load_plugins() + +# Global list of available functions +gfunctions = {} + +def register_function(function, name = None): + if name is None: + name = function.__name__ + gfunctions[name] = function + +class ModXMLRPCRequestHandler(object): + """Simple XML-RPC handler for mod_python environment""" + + def __init__(self): + global gfunctions + + self.funcs = gfunctions + self.traceback = False + #introspection functions + self.register_function(self.ping, name="ping") + self.register_function(self.list_api, name="_listapi") + self.register_function(self.system_listMethods, name="system.listMethods") + self.register_function(self.system_methodSignature, name="system.methodSignature") + self.register_function(self.system_methodHelp, name="system.methodHelp") + self.register_function(self.multiCall) + + def register_function(self, function, name = None): + if name is None: + name = function.__name__ + self.funcs[name] = function + + def register_module(self, instance, prefix=None): + """Register all the public functions in an instance with prefix prepended + + For example + h.register_module(exports,"pub.sys") + will register the methods of exports with names like + pub.sys.method1 + pub.sys.method2 + ...etc + """ + for name in dir(instance): + if name.startswith('_'): + continue + function = getattr(instance, name) + if not callable(function): + continue + if prefix is not None: + name = "%s.%s" %(prefix,name) + self.register_function(function, name=name) + + def register_instance(self,instance): + self.register_module(instance) + + def _marshaled_dispatch(self, data, req): + """Dispatches an XML-RPC method from marshalled (XML) data.""" + + params, method = loads(data) + pythonopts = req.get_options() + + # Populate the Apache environment variables + req.add_common_vars() + + context.opts['remoteuser'] = req.user + + if req.subprocess_env.get("KRB5CCNAME") is not None: + krbccache = req.subprocess_env.get("KRB5CCNAME") + else: + response = dumps(Fault(5, "Did not receive Kerberos credentials.")) + return response + + debuglevel = logging.INFO + if pythonopts.get("IPADebug"): + context.opts['ipadebug'] = pythonopts.get("IPADebug").lower() + + if context.opts['ipadebug'] == "on": + debuglevel = logging.DEBUG + + if not context.opts.get('ipadebug'): + context.opts['ipadebug'] = "off" + + logging.basicConfig(level=debuglevel, + format='[%(asctime)s] [%(levelname)s] %(message)s', + datefmt='%a %b %d %H:%M:%S %Y', + stream=sys.stderr) + + logging.info("Interpreter: %s" % req.interpreter) + + +# if opts['ipadebug'] == "on": +# for o in opts: +# logging.debug("IPA: setting option %s: %s" % (o, opts[o])) +# for e in req.subprocess_env: +# logging.debug("IPA: environment %s: %s" % (e, req.subprocess_env[e])) + + context.conn = conn.IPAConn(api.env.ldaphost, api.env.ldapport, krbccache, context.opts.get('ipadebug')) + + start = time.time() + # generate response + try: + response = self._dispatch(method, params) + # wrap response in a singleton tuple + response = (response,) + response = dumps(response, methodresponse=1, allow_none=1) + except Fault, e: + response = dumps(Fault(e.faultCode, e.faultString)) + except: + self.traceback = True + # report exception back to server + e_class, e = sys.exc_info()[:2] + faultCode = getattr(e_class,'faultCode',1) + tb_str = ''.join(traceback.format_exception(*sys.exc_info())) + faultString = tb_str + response = dumps(Fault(faultCode, faultString)) + + return response + + def _dispatch(self,method,params): + func = self.funcs.get(method,None) + if func is None: + raise Fault(1, "Invalid method: %s" % method) + + params = list(ipautil.unwrap_binary_data(params)) + (args, kw) = xmlrpc_unmarshal(*params) + + ret = func(*args, **kw) + + return ipautil.wrap_binary_data(ret) + + def multiCall(self, calls): + """Execute a multicall. Execute each method call in the calls list, collecting results and errors, and return those as a list.""" + results = [] + for call in calls: + try: + result = self._dispatch(call['methodName'], call['params']) + except Fault, fault: + results.append({'faultCode': fault.faultCode, 'faultString': fault.faultString}) + except: + # transform unknown exceptions into XML-RPC Faults + # don't create a reference to full traceback since this creates + # a circular reference. + exc_type, exc_value = sys.exc_info()[:2] + faultCode = getattr(exc_type, 'faultCode', 1) + faultString = ', '.join(exc_value.args) + trace = traceback.format_exception(*sys.exc_info()) + # traceback is not part of the multicall spec, but we include it for debugging purposes + results.append({'faultCode': faultCode, 'faultString': faultString, 'traceback': trace}) + else: + results.append([result]) + + return results + + def list_api(self): + funcs = [] + for name,func in self.funcs.items(): + #the keys in self.funcs determine the name of the method as seen over xmlrpc + #func.__name__ might differ (e.g. for dotted method names) + args = self._getFuncArgs(func) + doc = None + try: + doc = func.doc + except AttributeError: + doc = func.__doc__ + funcs.append({'name': name, + 'doc': doc, + 'args': args}) + return funcs + + def ping(self): + """Simple test to see if the XML-RPC is up and active.""" + return "pong" + + def _getFuncArgs(self, func): + try: + # Plugins have this + args = list(func.args) + args.append("kw") + except: + # non-plugin functions such as the introspective ones + args = [] + for x in range(0, func.func_code.co_argcount): + if x == 0 and func.func_code.co_varnames[x] == "self": + continue + # opts is a name we tack on internally. Don't publish it. + if func.func_code.co_varnames[x] == "opts": + continue + if func.func_defaults and func.func_code.co_argcount - x <= len(func.func_defaults): + args.append((func.func_code.co_varnames[x], func.func_defaults[x - func.func_code.co_argcount + len(func.func_defaults)])) + else: + args.append(func.func_code.co_varnames[x]) + return args + + def system_listMethods(self): + """List all available XML-RPC methods""" + return self.funcs.keys() + + def system_methodSignature(self, method): + """signatures are not supported""" + #it is not possible to autogenerate this data + return 'signatures not supported' + + def system_methodHelp(self, method): + """Return help on a specific method""" + func = self.funcs.get(method) + if func is None: + return "" + arglist = [] + for arg in self._getFuncArgs(func): + if isinstance(arg,str): + arglist.append(arg) + else: + arglist.append('%s=%s' % (arg[0], arg[1])) + ret = '%s(%s)' % (method, ", ".join(arglist)) + doc = None + try: + doc = func.doc + except AttributeError: + doc = func.__doc__ + if doc: + ret += "\ndescription: %s" % func.__doc__ + return ret + + def handle_request(self,req): + """Handle a single XML-RPC request""" + + # XMLRPC uses POST only. Reject anything else + if req.method != 'POST': + req.allow_methods(['POST'],1) + raise apache.SERVER_RETURN, apache.HTTP_METHOD_NOT_ALLOWED + + # The LDAP connection pool is not thread-safe. Avoid problems and + # force the forked model for now. + if apache.mpm_query(apache.AP_MPMQ_IS_THREADED): + response = dumps(Fault(3, "Apache must use the forked model")) + else: + response = self._marshaled_dispatch(req.read(), req) + + req.content_type = "text/xml" + req.set_content_length(len(response)) + req.write(response) + + +# +# mod_python handler +# + +def handler(req, profiling=False): + h = ModXMLRPCRequestHandler() + + if profiling: + import profile, pstats, StringIO, tempfile + global _profiling_req + _profiling_req = req + temp = tempfile.NamedTemporaryFile() + profile.run("import ipxmlrpc; ipaxmlrpc.handler(ipaxmlrpc._profiling_req, False)", temp.name) + stats = pstats.Stats(temp.name) + strstream = StringIO.StringIO() + sys.stdout = strstream + stats.sort_stats("time") + stats.print_stats() + req.write("<pre>" + strstream.getvalue() + "</pre>") + _profiling_req = None + else: + context.opts = req.get_options() + context.reqs = req + try: + h.handle_request(req) + finally: + # Clean up any per-request data and connections + for k in context.__dict__.keys(): + del context.__dict__[k] + + return apache.OK + +def setup_logger(level): + """Make a global logging object.""" + l = logging.getLogger() + l.setLevel(level) + h = logging.StreamHandler() + f = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s") + h.setFormatter(f) + l.addHandler(h) + + return + +def load_modules(): + """Load all plugins and register the XML-RPC functions we provide. + + Called by mod_python PythonImport + + PythonImport /path/to/ipaxmlrpc.py::load_modules main_interpreter + ... + PythonInterpreter main_interpreter + PythonHandler ipaxmlrpc + """ + + # setup up the logger with a DEBUG level. It may get reset to INFO + # once we start processing requests. We don't have access to the + # Apache configuration yet. + setup_logger(logging.DEBUG) + + api.finalize() + + # Initialize our environment + config.set_default_env(api.env) + env_dict = config.read_config() + env_dict['server_context'] = True + api.env.update(env_dict) + + # Get and register all the methods + for cmd in api.Command: + logging.debug("registering XML-RPC call %s" % cmd) + register_function(api.Command[cmd], cmd) + + return diff --git a/ipaserver/plugins/__init__.py b/ipaserver/plugins/__init__.py new file mode 100644 index 000000000..5737dcb79 --- /dev/null +++ b/ipaserver/plugins/__init__.py @@ -0,0 +1,24 @@ +# Authors: Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Sub-package containing all server plugins. + +By convention, modules with frontend plugins are named f_*.py and modules +with backend plugins are named b_*.py. +""" diff --git a/ipaserver/plugins/b_ldap.py b/ipaserver/plugins/b_ldap.py new file mode 100644 index 000000000..9e06ce51b --- /dev/null +++ b/ipaserver/plugins/b_ldap.py @@ -0,0 +1,334 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Backend plugin for LDAP. + +This wraps the python-ldap bindings. +""" + +import ldap as _ldap +from ipalib import api, Context +from ipalib import errors +from ipalib.crud import CrudBackend +from ipaserver import servercore +from ipaserver import ipaldap + + +class conn(Context): + """ + Thread-local LDAP connection. + """ + + def get_value(self): + return 'it worked' + +api.register(conn) + + +class ldap(CrudBackend): + """ + LDAP backend plugin. + """ + + def __init__(self): + self.dn = _ldap.dn + super(ldap, self).__init__() + + def make_user_dn(self, uid): + """ + Construct user dn from uid. + """ + return 'uid=%s,%s,%s' % ( + self.dn.escape_dn_chars(uid), + self.api.env.container_user, + self.api.env.basedn, + ) + + def make_group_dn(self, cn): + """ + Construct group dn from cn. + """ + return 'cn=%s,%s,%s' % ( + self.dn.escape_dn_chars(cn), + self.api.env.container_group, + self.api.env.basedn, + ) + + def make_hostgroup_dn(self, cn): + """ + Construct group of hosts dn from cn. + """ + return 'cn=%s,%s,%s' % ( + self.dn.escape_dn_chars(cn), + self.api.env.container_hostgroup, + self.api.env.basedn, + ) + + def make_service_dn(self, principal): + """ + Construct service principal dn from principal name + """ + return 'krbprincipalname=%s,%s,%s' % ( + self.dn.escape_dn_chars(principal), + self.api.env.container_service, + self.api.env.basedn, + ) + + def make_host_dn(self, hostname): + """ + Construct host dn from hostname + """ + return 'cn=%s,%s,%s' % ( + self.dn.escape_dn_chars(hostname), + self.api.env.container_host, + self.api.env.basedn, + ) + + def get_object_type(self, attribute): + """ + Based on attribute, make an educated guess as to the type of + object we're looking for. + """ + attribute = attribute.lower() + object_type = None + if attribute == "uid": # User + object_type = "posixAccount" + elif attribute == "cn": # Group + object_type = "posixGroup" + elif attribute == "krbprincipalname": # Service + object_type = "krbPrincipal" + + return object_type + + def find_entry_dn(self, key_attribute, primary_key, object_type=None, base=None): + """ + Find an existing entry's dn from an attribute + """ + key_attribute = key_attribute.lower() + if not object_type: + object_type = self.get_object_type(key_attribute) + if not object_type: + return None + + search_filter = "(&(objectclass=%s)(%s=%s))" % ( + object_type, + key_attribute, + self.dn.escape_dn_chars(primary_key) + ) + + if not base: + base = self.api.env.container_accounts + + search_base = "%s, %s" % (base, self.api.env.basedn) + + entry = servercore.get_sub_entry(search_base, search_filter, ['dn', 'objectclass']) + + return entry.get('dn') + + def get_base_entry(self, searchbase, searchfilter, attrs): + return servercore.get_base_entry(searchbase, searchfilter, attrs) + + def get_sub_entry(self, searchbase, searchfilter, attrs): + return servercore.get_sub_entry(searchbase, searchfilter, attrs) + + def get_one_entry(self, searchbase, searchfilter, attrs): + return servercore.get_one_entry(searchbase, searchfilter, attrs) + + def get_ipa_config(self): + """Return a dictionary of the IPA configuration""" + return servercore.get_ipa_config() + + def mark_entry_active(self, dn): + return servercore.mark_entry_active(dn) + + def mark_entry_inactive(self, dn): + return servercore.mark_entry_inactive(dn) + + def _generate_search_filters(self, **kw): + """Generates a search filter based on a list of words and a list + of fields to search against. + + Returns a tuple of two filters: (exact_match, partial_match) + """ + + # construct search pattern for a single word + # (|(f1=word)(f2=word)...) + exact_pattern = "(|" + for field in kw.keys(): + exact_pattern += "(%s=%s)" % (field, kw[field]) + exact_pattern += ")" + + sub_pattern = "(|" + for field in kw.keys(): + sub_pattern += "(%s=*%s*)" % (field, kw[field]) + sub_pattern += ")" + + # construct the giant match for all words + exact_match_filter = "(&" + exact_pattern + ")" + partial_match_filter = "(|" + sub_pattern + ")" + + return (exact_match_filter, partial_match_filter) + + def modify_password(self, dn, **kw): + return servercore.modify_password(dn, kw.get('oldpass'), kw.get('newpass')) + + def add_member_to_group(self, memberdn, groupdn, memberattr='member'): + """ + Add a new member to a group. + + :param memberdn: the DN of the member to add + :param groupdn: the DN of the group to add a member to + """ + return servercore.add_member_to_group(memberdn, groupdn, memberattr) + + def remove_member_from_group(self, memberdn, groupdn, memberattr='member'): + """ + Remove a new member from a group. + + :param memberdn: the DN of the member to remove + :param groupdn: the DN of the group to remove a member from + """ + return servercore.remove_member_from_group(memberdn, groupdn, memberattr) + + # The CRUD operations + + def strip_none(self, kw): + """ + Remove any None values present in the LDAP attribute dict. + """ + for (key, value) in kw.iteritems(): + if value is None: + continue + if type(value) in (list, tuple): + value = filter( + lambda v: type(v) in (str, unicode, bool, int, float), + value + ) + if len(value) > 0: + yield (key, value) + else: + assert type(value) in (str, unicode, bool, int, float) + yield (key, value) + yield (key, value) + + def create(self, **kw): + if servercore.entry_exists(kw['dn']): + raise errors.DuplicateEntry("entry already exists") + kw = dict(self.strip_none(kw)) + + + entry = ipaldap.Entry(kw['dn']) + + # dn isn't allowed to be in the entry itself + del kw['dn'] + + # Fill in our new entry + for k in kw: + entry.setValues(k, kw[k]) + + servercore.add_entry(entry) + return self.retrieve(entry.dn) + + def retrieve(self, dn, attributes=None): + return servercore.get_entry_by_dn(dn, attributes) + + def update(self, dn, **kw): + result = self.retrieve(dn, ["*"]) + start_keys = kw.keys() + + entry = ipaldap.Entry((dn, servercore.convert_scalar_values(result))) + kw = dict(self.strip_none(kw)) + for k in kw: + entry.setValues(k, kw[k]) + + remove_keys = list(set(start_keys) - set(kw.keys())) + for k in remove_keys: + entry.delAttr(k) + + servercore.update_entry(entry.toDict(), remove_keys) + + return self.retrieve(dn) + + def delete(self, dn): + return servercore.delete_entry(dn) + + def search(self, **kw): + objectclass = kw.get('objectclass') + sfilter = kw.get('filter') + attributes = kw.get('attributes') + base = kw.get('base') + if attributes: + del kw['attributes'] + else: + attributes = ['*'] + if objectclass: + del kw['objectclass'] + if base: + del kw['base'] + if sfilter: + del kw['filter'] + (exact_match_filter, partial_match_filter) = self._generate_search_filters(**kw) + if objectclass: + exact_match_filter = "(&(objectClass=%s)%s)" % (objectclass, exact_match_filter) + partial_match_filter = "(&(objectClass=%s)%s)" % (objectclass, partial_match_filter) + if sfilter: + exact_match_filter = "(%s%s)" % (sfilter, exact_match_filter) + partial_match_filter = "(%s%s)" % (sfilter, partial_match_filter) + + if not base: + base = self.api.env.container_accounts + + search_base = "%s, %s" % (base, self.api.env.basedn) + try: + exact_results = servercore.search(search_base, + exact_match_filter, attributes) + except errors.NotFound: + exact_results = [0] + + try: + partial_results = servercore.search(search_base, + partial_match_filter, attributes) + except errors.NotFound: + partial_results = [0] + + exact_counter = exact_results[0] + partial_counter = partial_results[0] + + exact_results = exact_results[1:] + partial_results = partial_results[1:] + + # Remove exact matches from the partial_match list + exact_dns = set(map(lambda e: e.get('dn'), exact_results)) + partial_results = filter(lambda e: e.get('dn') not in exact_dns, + partial_results) + + if (exact_counter == -1) or (partial_counter == -1): + counter = -1 + else: + counter = len(exact_results) + len(partial_results) + + results = [counter] + for r in exact_results + partial_results: + results.append(r) + + return results + +api.register(ldap) diff --git a/ipaserver/plugins/b_ra.py b/ipaserver/plugins/b_ra.py new file mode 100644 index 000000000..e6a9b63f4 --- /dev/null +++ b/ipaserver/plugins/b_ra.py @@ -0,0 +1,407 @@ +# Authors: +# Andrew Wnuk <awnuk@redhat.com> +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Backend plugin for IPA-RA. + +IPA-RA provides an access to CA to issue, retrieve, and revoke certificates. +IPA-RA plugin provides CA interface via the following methods: + check_request_status to check certificate request status + get_certificate to retrieve an existing certificate + request_certificate to request certificate + revoke_certificate to revoke certificate + take_certificate_off_hold to take certificate off hold +""" + +import os, stat, subprocess +import array +import errno +import binascii +import httplib, urllib +from socket import gethostname + +from ipalib import api, Backend +from ipalib import errors +from ipaserver import servercore +from ipaserver import ipaldap + + +class ra(Backend): + + + def __init__(self): + self.sec_dir = api.env.dot_ipa + os.sep + 'alias' + self.pwd_file = self.sec_dir + os.sep + '.pwd' + self.noise_file = self.sec_dir + os.sep + '.noise' + + self.ca_host = None + self.ca_port = None + self.ca_ssl_port = None + + self.__get_ca_location() + + self.ipa_key_size = "2048" + self.ipa_certificate_nickname = "ipaCert" + self.ca_certificate_nickname = "caCert" + + if not os.path.isdir(self.sec_dir): + os.mkdir(self.sec_dir) + self.__create_pwd_file() + self.__create_nss_db() + self.__import_ca_chain() + self.__request_ipa_certificate(self.__generate_ipa_request()) + super(ra, self).__init__() + + + def check_request_status(self, request_id=None): + """ + Check certificate request status + :param request_id: request ID + """ + self.log.debug("IPA-RA: check_request_status") + return_values = {} + if request_id is not None: + params = urllib.urlencode({'requestId': request_id, 'xmlOutput': 'true'}) + headers = {"Content-type": "application/x-www-form-urlencoded"} + conn = httplib.HTTPConnection(self.ca_host, self.ca_port) + conn.request("POST", "/ca/ee/ca/checkRequest", params, headers) + response = conn.getresponse() + api.log.debug("IPA-RA: response.status: %d response.reason: %s" % (response.status, response.reason)) + data = response.read() + conn.close() + self.log.debug(data) + if data is not None: + request_status = self.__find_substring(data, 'header.status = "', '"') + if request_status is not None: + return_values["status"] = "0" + return_values["request_status"] = request_status + self.log.debug("IPA-RA: request_status: '%s'" % request_status) + serial_number = self.__find_substring(data, 'record.serialNumber="', '"') + if serial_number is not None: + return_values["serial_number"] = "0x"+serial_number + request_id = self.__find_substring(data, 'header.requestId = "', '"') + if request_id is not None: + return_values["request_id"] = request_id + error = self.__find_substring(data, 'fixed.unexpectedError = "', '"') + if error is not None: + return_values["error"] = error + if return_values.has_key("status") is False: + return_values["status"] = "2" + else: + return_values["status"] = "1" + return return_values + + + def get_certificate(self, serial_number=None): + """ + Retrieve an existing certificate + :param serial_number: certificate serial number + """ + self.log.debug("IPA-RA: get_certificate") + issued_certificate = None + return_values = {} + if serial_number is not None: + request_info = ("serialNumber=%s" % serial_number) + self.log.debug("request_info: '%s'" % request_info) + returncode, stdout, stderr = self.__run_sslget(["-e", request_info, "-r", "/ca/agent/ca/displayBySerial", self.ca_host+":"+str(self.ca_ssl_port)]) + self.log.debug("IPA-RA: returncode: %d" % returncode) + if (returncode == 0): + issued_certificate = self.__find_substring(stdout, 'header.certChainBase64 = "', '"') + if issued_certificate is not None: + return_values["status"] = "0" + issued_certificate = issued_certificate.replace("\\r", "") + issued_certificate = issued_certificate.replace("\\n", "") + self.log.debug("IPA-RA: issued_certificate: '%s'" % issued_certificate) + return_values["certificate"] = issued_certificate + else: + return_values["status"] = "1" + revocation_reason = self.__find_substring(stdout, 'header.revocationReason = ', ';') + if revocation_reason is not None: + return_values["revocation_reason"] = revocation_reason + else: + return_values["status"] = str(-returncode) + else: + return_values["status"] = "1" + return return_values + + + def request_certificate(self, certificate_request=None, request_type="pkcs10"): + """ + Submit certificate request + :param certificate_request: certificate request + :param request_type: request type + """ + self.log.debug("IPA-RA: request_certificate") + certificate = None + return_values = {} + if request_type is None: + request_type="pkcs10" + if certificate_request is not None: + request = urllib.quote(certificate_request) + request_info = "profileId=caRAserverCert&cert_request_type="+request_type+"&cert_request="+request+"&xmlOutput=true" + returncode, stdout, stderr = self.__run_sslget(["-e", request_info, "-r", "/ca/ee/ca/profileSubmit", self.ca_host+":"+str(self.ca_ssl_port)]) + self.log.debug("IPA-RA: returncode: %d" % returncode) + if (returncode == 0): + status = self.__find_substring(stdout, "<Status>", "</Status>") + if status is not None: + self.log.debug ("status=%s" % status) + return_values["status"] = status + request_id = self.__find_substring(stdout, "<Id>", "</Id>") + if request_id is not None: + self.log.debug ("request_id=%s" % request_id) + return_values["request_id"] = request_id + serial_number = self.__find_substring(stdout, "<serialno>", "</serialno>") + if serial_number is not None: + self.log.debug ("serial_number=%s" % serial_number) + return_values["serial_number"] = ("0x%s" % serial_number) + subject = self.__find_substring(stdout, "<SubjectDN>", "</SubjectDN>") + if subject is not None: + self.log.debug ("subject=%s" % subject) + return_values["subject"] = subject + certificate = self.__find_substring(stdout, "<b64>", "</b64>") + if certificate is not None: + self.log.debug ("certificate=%s" % certificate) + return_values["certificate"] = certificate + if return_values.has_key("status") is False: + return_values["status"] = "2" + else: + return_values["status"] = str(-returncode) + else: + return_values["status"] = "1" + return return_values + + + def revoke_certificate(self, serial_number=None, revocation_reason=0): + """ + Revoke a certificate + :param serial_number: certificate serial number + :param revocation_reason: revocation reason + revocationr reasons: 0 - unspecified + 1 - key compromise + 2 - ca compromise + 3 - affiliation changed + 4 - superseded + 5 - cessation of operation + 6 - certificate hold + 7 - value 7 is not used + 8 - remove from CRL + 9 - privilege withdrawn + 10 - aa compromise + see RFC 5280 for more details + """ + return_values = {} + self.log.debug("IPA-RA: revoke_certificate") + if revocation_reason is None: + revocation_reason = 0 + if serial_number is not None: + if isinstance(serial_number, int): + serial_number = str(serial_number) + if isinstance(revocation_reason, int): + revocation_reason = str(revocation_reason) + request_info = "op=revoke&revocationReason="+revocation_reason+"&revokeAll=(certRecordId%3D"+serial_number+")&totalRecordCount=1" + returncode, stdout, stderr = self.__run_sslget(["-e", request_info, "-r", "/ca/agent/ca/doRevoke", self.ca_host+":"+str(self.ca_ssl_port)]) + api.log.debug("IPA-RA: returncode: %d" % returncode) + if (returncode == 0): + return_values["status"] = "0" + if (stdout.find('revoked = "yes"') > -1): + return_values["revoked"] = True + else: + return_values["revoked"] = False + else: + return_values["status"] = str(-returncode) + else: + return_values["status"] = "1" + return return_values + + + def take_certificate_off_hold(self, serial_number=None): + """ + Take revoked certificate off hold + :param serial_number: certificate serial number + """ + return_values = {} + self.log.debug("IPA-RA: revoke_certificate") + if serial_number is not None: + if isinstance(serial_number, int): + serial_number = str(serial_number) + request_info = "serialNumber="+serial_number + returncode, stdout, stderr = self.__run_sslget(["-e", request_info, "-r", "/ca/agent/ca/doUnrevoke", self.ca_host+":"+str(self.ca_ssl_port)]) + api.log.debug("IPA-RA: returncode: %d" % returncode) + if (returncode == 0): + if (stdout.find('unrevoked = "yes"') > -1): + return_values["taken_off_hold"] = True + else: + return_values["taken_off_hold"] = False + else: + return_values["status"] = str(-returncode) + else: + return_values["status"] = "1" + return return_values + + + def __find_substring(self, str, str1, str2): + sub_str = None + k0 = len(str) + k1 = str.find(str1) + k2 = len(str1) + if (k0 > 0 and k1 > -1 and k2 > 0 and k0 > k1 + k2): + sub_str = str[k1+k2:] + k3 = len(sub_str) + k4 = sub_str.find(str2) + if (k3 > 0 and k4 > -1 and k3 > k4): + sub_str = sub_str[:k4] + return sub_str + + + def __get_ca_location(self): + if 'ca_host' in api.env: + api.log.debug("ca_host configuration found") + if api.env.ca_host is not None: + self.ca_host = api.env.ca_host + else: + api.log.debug("ca_host configuration not found") + # if CA is not hosted with IPA on the same system and there is no configuration support for 'api.env.ca_host', then set ca_host below + # self.ca_host = "example.com" + if self.ca_host is None: + self.ca_host = gethostname() + api.log.debug("ca_host: %s" % self.ca_host) + + if 'ca_ssl_port' in api.env: + api.log.debug("ca_ssl_port configuration found") + if api.env.ca_ssl_port is not None: + self.ca_ssl_port = api.env.ca_ssl_port + else: + api.log.debug("ca_ssl_port configuration not found") + if self.ca_ssl_port is None: + self.ca_ssl_port = 9443 + api.log.debug("ca_ssl_port: %d" % self.ca_ssl_port) + + if 'ca_port' in api.env: + api.log.debug("ca_port configuration found") + if api.env.ca_port is not None: + self.ca_port = api.env.ca_port + else: + api.log.debug("ca_port configuration not found") + if self.ca_port is None: + self.ca_port = 9080 + api.log.debug("ca_port: %d" % self.ca_port) + + + def __generate_ipa_request(self): + certificate_request = None + if not os.path.isfile(self.noise_file): + self.__create_noise_file() + returncode, stdout, stderr = self.__run_certutil(["-R", "-k", "rsa", "-g", self.ipa_key_size, "-s", "CN=IPA-Subsystem-Certificate,OU=pki-ipa,O=UsersysRedhat-Domain", "-z", self.noise_file, "-a"]) + if os.path.isfile(self.noise_file): + os.unlink(self.noise_file) + if (returncode == 0): + api.log.info("IPA-RA: IPA certificate request generated") + certificate_request = self.__find_substring(stdout, "-----BEGIN NEW CERTIFICATE REQUEST-----", "-----END NEW CERTIFICATE REQUEST-----") + if certificate_request is not None: + api.log.debug("certificate_request=%s" % certificate_request) + else: + api.log.warn("IPA-RA: Error parsing certificate request." % returncode) + else: + api.log.warn("IPA-RA: Error (%d) generating IPA certificate request." % returncode) + return certificate_request + + def __request_ipa_certificate(self, certificate_request=None): + ipa_certificate = None + if certificate_request is not None: + params = urllib.urlencode({'profileId': 'caServerCert', 'cert_request_type': 'pkcs10', 'requestor_name': 'freeIPA', 'cert_request': self.__generate_ipa_request(), 'xmlOutput': 'true'}) + headers = {"Content-type": "application/x-www-form-urlencoded"} + conn = httplib.HTTPConnection(self.ca_host+":"+self.ca_port) + conn.request("POST", "/ca/ee/ca/profileSubmit", params, headers) + response = conn.getresponse() + api.log.debug("IPA-RA: response.status: %d response.reason: '%s'" % (response.status, response.reason)) + data = response.read() + conn.close() + api.log.info("IPA-RA: IPA certificate request submitted to CA: %s" % data) + return ipa_certificate + + def __get_ca_chain(self): + headers = {"Content-type": "application/x-www-form-urlencoded"} + conn = httplib.HTTPConnection(self.ca_host+":"+self.ca_port) + conn.request("POST", "/ca/ee/ca/getCertChain", None, headers) + response = conn.getresponse() + api.log.debug("IPA-RA: response.status: %d response.reason: '%s'" % (response.status, response.reason)) + data = response.read() + conn.close() + certificate_chain = self.__find_substring(data, "<ChainBase64>", "</ChainBase64>") + if certificate_chain is not None: + api.log.info(("IPA-RA: CA chain obtained from CA: %s" % certificate_chain)) + else: + api.log.warn("IPA-RA: Error parsing certificate chain.") + return certificate_chain + + def __import_ca_chain(self): + returncode, stdout, stderr = self.__run_certutil(["-A", "-t", "CT,C,C", "-n", self.ca_certificate_nickname, "-a"], self.__get_ca_chain()) + if (returncode == 0): + api.log.info("IPA-RA: CA chain imported to IPA's NSS DB") + else: + api.log.warn("IPA-RA: Error (%d) importing CA chain to IPA's NSS DB." % returncode) + + def __create_noise_file(self): + noise = array.array('B', os.urandom(128)) + f = open(self.noise_file, "wb") + noise.tofile(f) + f.close() + + def __create_pwd_file(self): + hex_str = binascii.hexlify(os.urandom(10)) + print "urandom: %s" % hex_str + f = os.open(self.pwd_file, os.O_CREAT | os.O_RDWR) + os.write(f, hex_str) + os.close(f) + + def __create_nss_db(self): + returncode, stdout, stderr = self.__run_certutil(["-N"]) + if (returncode == 0): + api.log.info("IPA-RA: NSS DB created") + else: + api.log.warn("IPA-RA: Error (%d) creating NSS DB." % returncode) + + """ + sslget and certutil utilities are used only till Python-NSS completion. + """ + def __run_sslget(self, args, stdin=None): + new_args = ["/usr/bin/sslget", "-d", self.sec_dir, "-w", self.pwd_file, "-n", self.ipa_certificate_nickname] + new_args = new_args + args + return self.__run(new_args, stdin) + + def __run_certutil(self, args, stdin=None): + new_args = ["/usr/bin/certutil", "-d", self.sec_dir, "-f", self.pwd_file] + new_args = new_args + args + return self.__run(new_args, stdin) + + def __run(self, args, stdin=None): + if stdin: + p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True) + stdout,stderr = p.communicate(stdin) + else: + p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True) + stdout,stderr = p.communicate() + + api.log.debug("IPA-RA: returncode: %d args: '%s'" % (p.returncode, ' '.join(args))) + # api.log.debug("IPA-RA: stdout: '%s'" % stdout) + # api.log.debug("IPA-RA: stderr: '%s'" % stderr) + return (p.returncode, stdout, stderr) + +api.register(ra) diff --git a/ipaserver/rpcserver.py b/ipaserver/rpcserver.py new file mode 100644 index 000000000..225173675 --- /dev/null +++ b/ipaserver/rpcserver.py @@ -0,0 +1,67 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +RPC server. + +Also see the `ipalib.rpc` module. +""" + +from xmlrpclib import Fault +from ipalib import Backend +from ipalib.errors2 import PublicError, InternalError, CommandError +from ipalib.rpc import xml_dumps, xml_loads + + +def params_2_args_options(params): + assert type(params) is tuple + if len(params) == 0: + return (tuple(), dict()) + if type(params[-1]) is dict: + return (params[:-1], params[-1]) + return (params, dict()) + + +class xmlserver(Backend): + """ + Execution backend for XML-RPC server. + """ + + def dispatch(self, method, params): + self.debug('Received RPC call to %r', method) + if method not in self.Command: + raise CommandError(name=method) + (args, options) = params_2_args_options(params) + result = self.Command[method](*args, **options) + return (result,) # Must wrap XML-RPC response in a tuple singleton + + def execute(self, data, ccache=None, client_version=None, + client_ip=None, languages=None): + """ + Execute the XML-RPC request in contained in ``data``. + """ + try: + (params, method) = xml_loads(data) + response = self.dispatch(method, params) + except Exception, e: + if not isinstance(e, PublicError): + e = InternalError() + assert isinstance(e, PublicError) + response = Fault(e.errno, e.strerror) + return dumps(response) diff --git a/ipaserver/servercore.py b/ipaserver/servercore.py new file mode 100644 index 000000000..362013401 --- /dev/null +++ b/ipaserver/servercore.py @@ -0,0 +1,467 @@ +# Authors: Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2007 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# + +import ldap +import string +import re +from ipaserver.context import context +from ipaserver import ipaldap +import ipautil +from ipalib import errors +from ipalib import api + +def convert_entry(ent): + entry = dict(ent.data) + entry['dn'] = ent.dn + # For now convert single entry lists to a string for the ui. + # TODO: we need to deal with multi-values better + for key,value in entry.iteritems(): + if isinstance(value,list) or isinstance(value,tuple): + if len(value) == 0: + entry[key] = '' + elif len(value) == 1: + entry[key] = value[0] + return entry + +def convert_scalar_values(orig_dict): + """LDAP update dicts expect all values to be a list (except for dn). + This method converts single entries to a list.""" + new_dict={} + for (k,v) in orig_dict.iteritems(): + if not isinstance(v, list) and k != 'dn': + v = [v] + new_dict[k] = v + + return new_dict + +def generate_match_filters(search_fields, criteria_words): + """Generates a search filter based on a list of words and a list + of fields to search against. + + Returns a tuple of two filters: (exact_match, partial_match)""" + + # construct search pattern for a single word + # (|(f1=word)(f2=word)...) + search_pattern = "(|" + for field in search_fields: + search_pattern += "(" + field + "=%(match)s)" + search_pattern += ")" + gen_search_pattern = lambda word: search_pattern % {'match':word} + + # construct the giant match for all words + exact_match_filter = "(&" + partial_match_filter = "(|" + for word in criteria_words: + exact_match_filter += gen_search_pattern(word) + partial_match_filter += gen_search_pattern("*%s*" % word) + exact_match_filter += ")" + partial_match_filter += ")" + + return (exact_match_filter, partial_match_filter) + +# TODO: rethink the get_entry vs get_list API calls. +# they currently restrict the data coming back without +# restricting scope. For now adding a get_base/sub_entry() +# calls, but the API isn't great. +def get_entry (base, scope, searchfilter, sattrs=None): + """Get a specific entry (with a parametized scope). + Return as a dict of values. + Multi-valued fields are represented as lists. + """ + ent="" + + ent = context.conn.getConn().getEntry(base, scope, searchfilter, sattrs) + + return convert_entry(ent) + +def get_base_entry (base, searchfilter, sattrs=None): + """Get a specific entry (with a scope of BASE). + Return as a dict of values. + Multi-valued fields are represented as lists. + """ + return get_entry(base, ldap.SCOPE_BASE, searchfilter, sattrs) + +def get_sub_entry (base, searchfilter, sattrs=None): + """Get a specific entry (with a scope of SUB). + Return as a dict of values. + Multi-valued fields are represented as lists. + """ + return get_entry(base, ldap.SCOPE_SUBTREE, searchfilter, sattrs) + +def get_one_entry (base, searchfilter, sattrs=None): + """Get the children of an entry (with a scope of ONE). + Return as a list of dict of values. + Multi-valued fields are represented as lists. + """ + return get_list(base, searchfilter, sattrs, ldap.SCOPE_ONELEVEL) + +def get_list (base, searchfilter, sattrs=None, scope=ldap.SCOPE_SUBTREE): + """Gets a list of entries. Each is converted to a dict of values. + Multi-valued fields are represented as lists. + """ + entries = [] + + entries = context.conn.getConn().getList(base, scope, searchfilter, sattrs) + + return map(convert_entry, entries) + +def has_nsaccountlock(dn): + """Check to see if an entry has the nsaccountlock attribute. + This attribute is provided by the Class of Service plugin so + doing a search isn't enough. It is provided by the two + entries cn=inactivated and cn=activated. So if the entry has + the attribute and isn't in either cn=activated or cn=inactivated + then the attribute must be in the entry itself. + + Returns True or False + """ + # First get the entry. If it doesn't have nsaccountlock at all we + # can exit early. + entry = get_entry_by_dn(dn, ['dn', 'nsaccountlock', 'memberof']) + if not entry.get('nsaccountlock'): + return False + + # Now look to see if they are in activated or inactivated + # entry is a member + memberof = entry.get('memberof') + if isinstance(memberof, basestring): + memberof = [memberof] + for m in memberof: + inactivated = m.find("cn=inactivated") + activated = m.find("cn=activated") + # if they are in either group that means that the nsaccountlock + # value comes from there, otherwise it must be in this entry. + if inactivated >= 0 or activated >= 0: + return False + + return True + +# General searches + +def get_entry_by_dn (dn, sattrs=None): + """Get a specific entry. Return as a dict of values. + Multi-valued fields are represented as lists. + """ + searchfilter = "(objectClass=*)" + api.log.info("IPA: get_entry_by_dn '%s'" % dn) + return get_base_entry(dn, searchfilter, sattrs) + +def get_entry_by_cn (cn, sattrs): + """Get a specific entry by cn. Return as a dict of values. + Multi-valued fields are represented as lists. + """ + api.log.info("IPA: get_entry_by_cn '%s'" % cn) +# cn = self.__safe_filter(cn) + searchfilter = "(cn=%s)" % cn + return get_sub_entry("cn=accounts," + api.env.basedn, searchfilter, sattrs) + +def get_user_by_uid(uid, sattrs): + """Get a specific user's entry.""" + # FIXME: should accept a container to look in +# uid = self.__safe_filter(uid) + searchfilter = "(&(uid=%s)(objectclass=posixAccount))" % uid + + return get_sub_entry("cn=accounts," + api.env.basedn, searchfilter, sattrs) + +# User support + +def entry_exists(dn): + """Return True if the entry exists, False otherwise.""" + try: + get_base_entry(dn, "objectclass=*", ['dn','objectclass']) + return True + except errors.NotFound: + return False + +def get_user_by_uid (uid, sattrs): + """Get a specific user's entry. Return as a dict of values. + Multi-valued fields are represented as lists. + """ + + if not isinstance(uid,basestring) or len(uid) == 0: + raise SyntaxError("uid is not a string") +# raise ipaerror.gen_exception(ipaerror.INPUT_INVALID_PARAMETER) + if sattrs is not None and not isinstance(sattrs,list): + raise SyntaxError("sattrs is not a list") +# raise ipaerror.gen_exception(ipaerror.INPUT_INVALID_PARAMETER) + api.log.info("IPA: get_user_by_uid '%s'" % uid) +# uid = self.__safe_filter(uid) + searchfilter = "(uid=" + uid + ")" + return get_sub_entry("cn=accounts," + api.env.basedn, searchfilter, sattrs) + +def uid_too_long(uid): + """Verify that the new uid is within the limits we set. This is a + very narrow test. + + Returns True if it is longer than allowed + False otherwise + """ + if not isinstance(uid,basestring) or len(uid) == 0: + # It is bad, but not too long + return False + api.log.debug("IPA: __uid_too_long(%s)" % uid) + try: + config = get_ipa_config() + maxlen = int(config.get('ipamaxusernamelength', 0)) + if maxlen > 0 and len(uid) > maxlen: + return True + except Exception, e: + api.log.debug("There was a problem " + str(e)) + pass + + return False + +def update_entry (entry, remove_keys=[]): + """Update an LDAP entry + + entry is a dict + remove_keys is a list of attributes to remove from this entry + + This refreshes the record from LDAP in order to obtain the list of + attributes that has changed. It only retrieves the attributes that + are in the update so attributes aren't inadvertantly lost. + """ + assert type(remove_keys) is list + attrs = entry.keys() + o = get_base_entry(entry['dn'], "objectclass=*", attrs + remove_keys) + oldentry = convert_scalar_values(o) + newentry = convert_scalar_values(entry) + + # Should be able to get this from either the old or new entry + # but just in case someone has decided to try changing it, use the + # original + try: + moddn = oldentry['dn'] + except KeyError, e: + # FIXME: return a missing DN error message + raise e + + return context.conn.getConn().updateEntry(moddn, oldentry, newentry) + +def add_entry(entry): + """Add a new entry""" + return context.conn.getConn().addEntry(entry) + +def delete_entry(dn): + """Remove an entry""" + return context.conn.getConn().deleteEntry(dn) + +# FIXME, get time and search limit from cn=ipaconfig +def search(base, filter, attributes, timelimit=1, sizelimit=3000): + """Perform an LDAP query""" + try: + timelimit = float(timelimit) + results = context.conn.getConn().getListAsync(base, ldap.SCOPE_SUBTREE, + filter, attributes, 0, None, None, timelimit, sizelimit) + except ldap.NO_SUCH_OBJECT: + raise errors.NotFound + + counter = results[0] + entries = [counter] + for r in results[1:]: + entries.append(convert_entry(r)) + + return entries + +def uniq_list(x): + """Return a unique list, preserving order and ignoring case""" + myset = {} + return [myset.setdefault(e.lower(),e) for e in x if e.lower() not in myset] + +def get_schema(): + """Retrieves the current LDAP schema from the LDAP server.""" + + schema_entry = get_base_entry("", "objectclass=*", ['dn','subschemasubentry']) + schema_cn = schema_entry.get('subschemasubentry') + schema = get_base_entry(schema_cn, "objectclass=*", ['*']) + + return schema + +def get_objectclasses(): + """Returns a list of available objectclasses that the LDAP + server supports. This parses out the syntax, attributes, etc + and JUST returns a lower-case list of the names.""" + + schema = get_schema() + + objectclasses = schema.get('objectclasses') + + # Convert this list into something more readable + result = [] + for i in range(len(objectclasses)): + oc = objectclasses[i].lower().split(" ") + result.append(oc[3].replace("'","")) + + return result + +def get_ipa_config(): + """Retrieve the IPA configuration""" + searchfilter = "cn=ipaconfig" + try: + config = get_sub_entry("cn=etc," + api.env.basedn, searchfilter) + except ldap.NO_SUCH_OBJECT, e: + # FIXME + raise errors.NotFound + + return config + +def modify_password(dn, oldpass, newpass): + return context.conn.getConn().modifyPassword(dn, oldpass, newpass) + +def mark_entry_active (dn): + """Mark an entry as active in LDAP.""" + + # This can be tricky. The entry itself can be marked inactive + # by being in the inactivated group. It can also be inactivated by + # being the member of an inactive group. + # + # First we try to remove the entry from the inactivated group. Then + # if it is still inactive we have to add it to the activated group + # which will override the group membership. + + res = "" + # First, check the entry status + entry = get_entry_by_dn(dn, ['dn', 'nsAccountlock']) + + if entry.get('nsaccountlock', 'false').lower() == "false": + api.log.debug("IPA: already active") + raise errors.AlreadyActiveError + + if has_nsaccountlock(dn): + api.log.debug("IPA: appears to have the nsaccountlock attribute") + raise errors.HasNSAccountLock + + group = get_entry_by_cn("inactivated", None) + try: + remove_member_from_group(entry.get('dn'), group.get('dn')) + except errors.NotGroupMember: + # Perhaps the user is there as a result of group membership + pass + + # Now they aren't a member of inactivated directly, what is the status + # now? + entry = get_entry_by_dn(dn, ['dn', 'nsAccountlock']) + + if entry.get('nsaccountlock', 'false').lower() == "false": + # great, we're done + api.log.debug("IPA: removing from inactivated did it.") + return True + + # So still inactive, add them to activated + group = get_entry_by_cn("activated", None) + res = add_member_to_group(dn, group.get('dn')) + api.log.debug("IPA: added to activated.") + + return res + +def mark_entry_inactive (dn): + """Mark an entry as inactive in LDAP.""" + + entry = get_entry_by_dn(dn, ['dn', 'nsAccountlock', 'memberOf']) + + if entry.get('nsaccountlock', 'false').lower() == "true": + api.log.debug("IPA: already marked as inactive") + raise errors.AlreadyInactiveError + + if has_nsaccountlock(dn): + api.log.debug("IPA: appears to have the nsaccountlock attribute") + raise errors.HasNSAccountLock + + # First see if they are in the activated group as this will override + # the our inactivation. + group = get_entry_by_cn("activated", None) + try: + remove_member_from_group(dn, group.get('dn')) + except errors.NotGroupMember: + # this is fine, they may not be explicitly in this group + pass + + # Now add them to inactivated + group = get_entry_by_cn("inactivated", None) + res = add_member_to_group(dn, group.get('dn')) + + return res + +def add_member_to_group(member_dn, group_dn, memberattr='member'): + """ + Add a member to an existing group. + """ + api.log.info("IPA: add_member_to_group '%s' to '%s'" % (member_dn, group_dn)) + if member_dn.lower() == group_dn.lower(): + # You can't add a group to itself + raise errors.SameGroupError + + group = get_entry_by_dn(group_dn, None) + if group is None: + raise errors.NotFound + + # check to make sure member_dn exists + member_entry = get_base_entry(member_dn, "(objectClass=*)", ['dn','objectclass']) + if not member_entry: + raise errors.NotFound + + # Add the new member to the group member attribute + members = group.get(memberattr, []) + if isinstance(members, basestring): + members = [members] + members.append(member_dn) + group[memberattr] = members + + try: + return update_entry(group) + except errors.EmptyModlist: + raise + +def remove_member_from_group(member_dn, group_dn, memberattr='member'): + """Remove a member_dn from an existing group.""" + + group = get_entry_by_dn(group_dn, None) + if group is None: + raise errors.NotFound + """ + if group.get('cn') == "admins": + member = get_entry_by_dn(member_dn, ['dn','uid']) + if member.get('uid') == "admin": + raise ipaerror.gen_exception(ipaerror.INPUT_ADMIN_REQUIRED_IN_ADMINS) + """ + api.log.info("IPA: remove_member_from_group '%s' from '%s'" % (member_dn, group_dn)) + + members = group.get(memberattr, False) + if not members: + raise errors.NotGroupMember + + if isinstance(members,basestring): + members = [members] + for i in range(len(members)): + members[i] = ipaldap.IPAdmin.normalizeDN(members[i]) + try: + members.remove(member_dn) + except ValueError: + # member is not in the group + # FIXME: raise more specific error? + raise errors.NotGroupMember + except Exception, e: + raise e + + group[memberattr] = members + + try: + return update_entry(group) + except errors.EmptyModlist: + raise diff --git a/ipaserver/test_client b/ipaserver/test_client new file mode 100755 index 000000000..3b4794d95 --- /dev/null +++ b/ipaserver/test_client @@ -0,0 +1,28 @@ +#!/usr/bin/python + +import xmlrpclib + +def user_find(uid): + try: + args=uid + result = server.user_find(args) + print "returned %s" % result + except xmlrpclib.Fault, e: + print e.faultString + +# main +server = xmlrpclib.ServerProxy("http://localhost:8888/") + +#print server.system.listMethods() +#print server.system.methodHelp("user_add") + +try: + args="jsmith1" + kw = {'givenname':'Joe', 'sn':'Smith'} + result = server.user_add(kw, args) + print "returned %s" % result +except xmlrpclib.Fault, e: + print e.faultString + +#user_find("admin") +#user_find("notfound") diff --git a/ipaserver/updates/automount.update b/ipaserver/updates/automount.update new file mode 100644 index 000000000..13d9a6df0 --- /dev/null +++ b/ipaserver/updates/automount.update @@ -0,0 +1,54 @@ +# +# An automount schema based on RFC 2307-bis. +# +# This schema defines new automount and automountMap objectclasses to represent +# the automount maps and their entries. +# +dn: cn=schema +add:attributeTypes: + ( 1.3.6.1.1.1.1.31 NAME 'automountMapName' + DESC 'automount Map Name' + EQUALITY caseExactIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE + X-ORIGIN 'RFC 2307bis' ) +add:attributeTypes: + ( 1.3.6.1.1.1.1.32 NAME 'automountKey' + DESC 'Automount Key value' + EQUALITY caseExactIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE + X-ORIGIN 'RFC 2307bis' ) +add:attributeTypes: + ( 1.3.6.1.1.1.1.33 NAME 'automountInformation' + DESC 'Automount information' + EQUALITY caseExactIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE + X-ORIGIN 'RFC 2307bis' ) +add:objectClasses: + ( 1.3.6.1.1.1.2.16 NAME 'automountMap' + DESC 'Automount Map information' SUP top + STRUCTURAL MUST automountMapName MAY description + X-ORIGIN 'RFC 2307bis' ) +add:objectClasses: + ( 1.3.6.1.1.1.2.17 NAME 'automount' + DESC 'Automount information' SUP top STRUCTURAL + MUST ( automountKey $ automountInformation ) MAY description + X-ORIGIN 'RFC 2307bis' ) + +# Add the default automount entries + +dn: cn=automount,$SUFFIX +add:objectClass: nsContainer +add:cn: automount + +dn: automountmapname=auto.master,cn=automount,$SUFFIX +add:objectClass: automountMap +add:automountMapName: auto.master + +dn: automountkey=/-,automountmapname=auto.master,cn=automount,$SUFFIX +add:objectClass: automount +add:automountKey: '/-' +add:automountInformation: auto.direct + +dn: automountmapname=auto.direct,cn=automount,$SUFFIX +add:objectClass: automountMap +add:automountMapName: auto.direct diff --git a/ipaserver/updates/groupofhosts.update b/ipaserver/updates/groupofhosts.update new file mode 100644 index 000000000..fb39c5e25 --- /dev/null +++ b/ipaserver/updates/groupofhosts.update @@ -0,0 +1,5 @@ +dn: cn=hostgroups,cn=accounts,$SUFFIX +add:objectClass: top +add:objectClass: nsContainer +add:cn: hostgroups + diff --git a/ipaserver/updates/host.update b/ipaserver/updates/host.update new file mode 100644 index 000000000..f5ecda5ac --- /dev/null +++ b/ipaserver/updates/host.update @@ -0,0 +1,25 @@ +# +# Schema for IPA Hosts +# +dn: cn=schema +add: attributeTypes: + ( 2.16.840.1.113730.3.8.3.10 NAME 'ipaClientVersion' + DESC 'Text string describing client version of the IPA software installed' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 + X-ORIGIN 'IPA v2' ) + +add: attributeTypes: + ( 2.16.840.1.113730.3.8.3.11 NAME 'enrolledBy' + DESC 'DN of administrator who performed manual enrollment of the host' + SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 + X-ORIGIN 'IPA v2' ) +add: objectClasses: + ( 2.16.840.1.113730.3.8.4.2 NAME 'ipaHost' + AUXILIARY + MAY ( userPassword $ ipaClientVersion $ enrolledBy) + X-ORIGIN 'IPA v2' ) +add: objectClasses: + ( 2.5.6.21 NAME 'pkiUser' + SUP top AUXILIARY + MAY ( userCertificate ) + X-ORIGIN 'RFC 2587' ) diff --git a/ipawebui/__init__.py b/ipawebui/__init__.py new file mode 100644 index 000000000..408481a27 --- /dev/null +++ b/ipawebui/__init__.py @@ -0,0 +1,24 @@ +# Authors: Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Package containing web-based UI components. +""" + +import kid +kid.enable_import() diff --git a/ipawebui/controller.py b/ipawebui/controller.py new file mode 100644 index 000000000..a2a270cbd --- /dev/null +++ b/ipawebui/controller.py @@ -0,0 +1,71 @@ +# Authors: Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Controller classes. +""" + +import simplejson +from ipalib.plugable import ReadOnly, lock + + +class Controller(ReadOnly): + exposed = True + + def __init__(self, template=None): + self.template = template + lock(self) + + def output_xhtml(self, **kw): + return self.template.serialize( + output='xhtml-strict', + format='pretty', + **kw + ) + + def output_json(self, **kw): + return simplejson.dumps(kw, sort_keys=True, indent=4) + + def __call__(self, **kw): + json = bool(kw.pop('_format', None) == 'json') + result = self.run(**kw) + assert type(result) is dict + if json or self.template is None: + return self.output_json(**result) + return self.output_xhtml(**result) + + def run(self, **kw): + return {} + + +class Command(Controller): + def __init__(self, command, template=None): + self.command = command + super(Command, self).__init__(template) + + def run(self, **kw): + return dict(command=self.command) + + +class Index(Controller): + def __init__(self, api, template=None): + self.api = api + super(Index, self).__init__(template) + + def run(self): + return dict(api=self.api) diff --git a/ipawebui/mod_python_webui.py b/ipawebui/mod_python_webui.py new file mode 100644 index 000000000..6a889fce6 --- /dev/null +++ b/ipawebui/mod_python_webui.py @@ -0,0 +1,22 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Production Web UI using mod_python. +""" diff --git a/ipawebui/static/mootools-core.js b/ipawebui/static/mootools-core.js new file mode 100644 index 000000000..7e1e482bf --- /dev/null +++ b/ipawebui/static/mootools-core.js @@ -0,0 +1,3946 @@ +/* +Script: Core.js + MooTools - My Object Oriented JavaScript Tools. + +License: + MIT-style license. + +Copyright: + Copyright (c) 2006-2008 [Valerio Proietti](http://mad4milk.net/). + +Code & Documentation: + [The MooTools production team](http://mootools.net/developers/). + +Inspiration: + - Class implementation inspired by [Base.js](http://dean.edwards.name/weblog/2006/03/base/) Copyright (c) 2006 Dean Edwards, [GNU Lesser General Public License](http://opensource.org/licenses/lgpl-license.php) + - Some functionality inspired by [Prototype.js](http://prototypejs.org) Copyright (c) 2005-2007 Sam Stephenson, [MIT License](http://opensource.org/licenses/mit-license.php) +*/ + +var MooTools = { + 'version': '1.2.1', + 'build': '0d4845aab3d9a4fdee2f0d4a6dd59210e4b697cf' +}; + +var Native = function(options){ + options = options || {}; + var name = options.name; + var legacy = options.legacy; + var protect = options.protect; + var methods = options.implement; + var generics = options.generics; + var initialize = options.initialize; + var afterImplement = options.afterImplement || function(){}; + var object = initialize || legacy; + generics = generics !== false; + + object.constructor = Native; + object.$family = {name: 'native'}; + if (legacy && initialize) object.prototype = legacy.prototype; + object.prototype.constructor = object; + + if (name){ + var family = name.toLowerCase(); + object.prototype.$family = {name: family}; + Native.typize(object, family); + } + + var add = function(obj, name, method, force){ + if (!protect || force || !obj.prototype[name]) obj.prototype[name] = method; + if (generics) Native.genericize(obj, name, protect); + afterImplement.call(obj, name, method); + return obj; + }; + + object.alias = function(a1, a2, a3){ + if (typeof a1 == 'string'){ + if ((a1 = this.prototype[a1])) return add(this, a2, a1, a3); + } + for (var a in a1) this.alias(a, a1[a], a2); + return this; + }; + + object.implement = function(a1, a2, a3){ + if (typeof a1 == 'string') return add(this, a1, a2, a3); + for (var p in a1) add(this, p, a1[p], a2); + return this; + }; + + if (methods) object.implement(methods); + + return object; +}; + +Native.genericize = function(object, property, check){ + if ((!check || !object[property]) && typeof object.prototype[property] == 'function') object[property] = function(){ + var args = Array.prototype.slice.call(arguments); + return object.prototype[property].apply(args.shift(), args); + }; +}; + +Native.implement = function(objects, properties){ + for (var i = 0, l = objects.length; i < l; i++) objects[i].implement(properties); +}; + +Native.typize = function(object, family){ + if (!object.type) object.type = function(item){ + return ($type(item) === family); + }; +}; + +(function(){ + var natives = {'Array': Array, 'Date': Date, 'Function': Function, 'Number': Number, 'RegExp': RegExp, 'String': String}; + for (var n in natives) new Native({name: n, initialize: natives[n], protect: true}); + + var types = {'boolean': Boolean, 'native': Native, 'object': Object}; + for (var t in types) Native.typize(types[t], t); + + var generics = { + 'Array': ["concat", "indexOf", "join", "lastIndexOf", "pop", "push", "reverse", "shift", "slice", "sort", "splice", "toString", "unshift", "valueOf"], + 'String': ["charAt", "charCodeAt", "concat", "indexOf", "lastIndexOf", "match", "replace", "search", "slice", "split", "substr", "substring", "toLowerCase", "toUpperCase", "valueOf"] + }; + for (var g in generics){ + for (var i = generics[g].length; i--;) Native.genericize(window[g], generics[g][i], true); + }; +})(); + +var Hash = new Native({ + + name: 'Hash', + + initialize: function(object){ + if ($type(object) == 'hash') object = $unlink(object.getClean()); + for (var key in object) this[key] = object[key]; + return this; + } + +}); + +Hash.implement({ + + forEach: function(fn, bind){ + for (var key in this){ + if (this.hasOwnProperty(key)) fn.call(bind, this[key], key, this); + } + }, + + getClean: function(){ + var clean = {}; + for (var key in this){ + if (this.hasOwnProperty(key)) clean[key] = this[key]; + } + return clean; + }, + + getLength: function(){ + var length = 0; + for (var key in this){ + if (this.hasOwnProperty(key)) length++; + } + return length; + } + +}); + +Hash.alias('forEach', 'each'); + +Array.implement({ + + forEach: function(fn, bind){ + for (var i = 0, l = this.length; i < l; i++) fn.call(bind, this[i], i, this); + } + +}); + +Array.alias('forEach', 'each'); + +function $A(iterable){ + if (iterable.item){ + var array = []; + for (var i = 0, l = iterable.length; i < l; i++) array[i] = iterable[i]; + return array; + } + return Array.prototype.slice.call(iterable); +}; + +function $arguments(i){ + return function(){ + return arguments[i]; + }; +}; + +function $chk(obj){ + return !!(obj || obj === 0); +}; + +function $clear(timer){ + clearTimeout(timer); + clearInterval(timer); + return null; +}; + +function $defined(obj){ + return (obj != undefined); +}; + +function $each(iterable, fn, bind){ + var type = $type(iterable); + ((type == 'arguments' || type == 'collection' || type == 'array') ? Array : Hash).each(iterable, fn, bind); +}; + +function $empty(){}; + +function $extend(original, extended){ + for (var key in (extended || {})) original[key] = extended[key]; + return original; +}; + +function $H(object){ + return new Hash(object); +}; + +function $lambda(value){ + return (typeof value == 'function') ? value : function(){ + return value; + }; +}; + +function $merge(){ + var mix = {}; + for (var i = 0, l = arguments.length; i < l; i++){ + var object = arguments[i]; + if ($type(object) != 'object') continue; + for (var key in object){ + var op = object[key], mp = mix[key]; + mix[key] = (mp && $type(op) == 'object' && $type(mp) == 'object') ? $merge(mp, op) : $unlink(op); + } + } + return mix; +}; + +function $pick(){ + for (var i = 0, l = arguments.length; i < l; i++){ + if (arguments[i] != undefined) return arguments[i]; + } + return null; +}; + +function $random(min, max){ + return Math.floor(Math.random() * (max - min + 1) + min); +}; + +function $splat(obj){ + var type = $type(obj); + return (type) ? ((type != 'array' && type != 'arguments') ? [obj] : obj) : []; +}; + +var $time = Date.now || function(){ + return +new Date; +}; + +function $try(){ + for (var i = 0, l = arguments.length; i < l; i++){ + try { + return arguments[i](); + } catch(e){} + } + return null; +}; + +function $type(obj){ + if (obj == undefined) return false; + if (obj.$family) return (obj.$family.name == 'number' && !isFinite(obj)) ? false : obj.$family.name; + if (obj.nodeName){ + switch (obj.nodeType){ + case 1: return 'element'; + case 3: return (/\S/).test(obj.nodeValue) ? 'textnode' : 'whitespace'; + } + } else if (typeof obj.length == 'number'){ + if (obj.callee) return 'arguments'; + else if (obj.item) return 'collection'; + } + return typeof obj; +}; + +function $unlink(object){ + var unlinked; + switch ($type(object)){ + case 'object': + unlinked = {}; + for (var p in object) unlinked[p] = $unlink(object[p]); + break; + case 'hash': + unlinked = new Hash(object); + break; + case 'array': + unlinked = []; + for (var i = 0, l = object.length; i < l; i++) unlinked[i] = $unlink(object[i]); + break; + default: return object; + } + return unlinked; +}; + + +/* +Script: Browser.js + The Browser Core. Contains Browser initialization, Window and Document, and the Browser Hash. + +License: + MIT-style license. +*/ + +var Browser = $merge({ + + Engine: {name: 'unknown', version: 0}, + + Platform: {name: (window.orientation != undefined) ? 'ipod' : (navigator.platform.match(/mac|win|linux/i) || ['other'])[0].toLowerCase()}, + + Features: {xpath: !!(document.evaluate), air: !!(window.runtime), query: !!(document.querySelector)}, + + Plugins: {}, + + Engines: { + + presto: function(){ + return (!window.opera) ? false : ((arguments.callee.caller) ? 960 : ((document.getElementsByClassName) ? 950 : 925)); + }, + + trident: function(){ + return (!window.ActiveXObject) ? false : ((window.XMLHttpRequest) ? 5 : 4); + }, + + webkit: function(){ + return (navigator.taintEnabled) ? false : ((Browser.Features.xpath) ? ((Browser.Features.query) ? 525 : 420) : 419); + }, + + gecko: function(){ + return (document.getBoxObjectFor == undefined) ? false : ((document.getElementsByClassName) ? 19 : 18); + } + + } + +}, Browser || {}); + +Browser.Platform[Browser.Platform.name] = true; + +Browser.detect = function(){ + + for (var engine in this.Engines){ + var version = this.Engines[engine](); + if (version){ + this.Engine = {name: engine, version: version}; + this.Engine[engine] = this.Engine[engine + version] = true; + break; + } + } + + return {name: engine, version: version}; + +}; + +Browser.detect(); + +Browser.Request = function(){ + return $try(function(){ + return new XMLHttpRequest(); + }, function(){ + return new ActiveXObject('MSXML2.XMLHTTP'); + }); +}; + +Browser.Features.xhr = !!(Browser.Request()); + +Browser.Plugins.Flash = (function(){ + var version = ($try(function(){ + return navigator.plugins['Shockwave Flash'].description; + }, function(){ + return new ActiveXObject('ShockwaveFlash.ShockwaveFlash').GetVariable('$version'); + }) || '0 r0').match(/\d+/g); + return {version: parseInt(version[0] || 0 + '.' + version[1] || 0), build: parseInt(version[2] || 0)}; +})(); + +function $exec(text){ + if (!text) return text; + if (window.execScript){ + window.execScript(text); + } else { + var script = document.createElement('script'); + script.setAttribute('type', 'text/javascript'); + script[(Browser.Engine.webkit && Browser.Engine.version < 420) ? 'innerText' : 'text'] = text; + document.head.appendChild(script); + document.head.removeChild(script); + } + return text; +}; + +Native.UID = 1; + +var $uid = (Browser.Engine.trident) ? function(item){ + return (item.uid || (item.uid = [Native.UID++]))[0]; +} : function(item){ + return item.uid || (item.uid = Native.UID++); +}; + +var Window = new Native({ + + name: 'Window', + + legacy: (Browser.Engine.trident) ? null: window.Window, + + initialize: function(win){ + $uid(win); + if (!win.Element){ + win.Element = $empty; + if (Browser.Engine.webkit) win.document.createElement("iframe"); //fixes safari 2 + win.Element.prototype = (Browser.Engine.webkit) ? window["[[DOMElement.prototype]]"] : {}; + } + win.document.window = win; + return $extend(win, Window.Prototype); + }, + + afterImplement: function(property, value){ + window[property] = Window.Prototype[property] = value; + } + +}); + +Window.Prototype = {$family: {name: 'window'}}; + +new Window(window); + +var Document = new Native({ + + name: 'Document', + + legacy: (Browser.Engine.trident) ? null: window.Document, + + initialize: function(doc){ + $uid(doc); + doc.head = doc.getElementsByTagName('head')[0]; + doc.html = doc.getElementsByTagName('html')[0]; + if (Browser.Engine.trident && Browser.Engine.version <= 4) $try(function(){ + doc.execCommand("BackgroundImageCache", false, true); + }); + if (Browser.Engine.trident) doc.window.attachEvent('onunload', function() { + doc.window.detachEvent('onunload', arguments.callee); + doc.head = doc.html = doc.window = null; + }); + return $extend(doc, Document.Prototype); + }, + + afterImplement: function(property, value){ + document[property] = Document.Prototype[property] = value; + } + +}); + +Document.Prototype = {$family: {name: 'document'}}; + +new Document(document); + + +/* +Script: Array.js + Contains Array Prototypes like each, contains, and erase. + +License: + MIT-style license. +*/ + +Array.implement({ + + every: function(fn, bind){ + for (var i = 0, l = this.length; i < l; i++){ + if (!fn.call(bind, this[i], i, this)) return false; + } + return true; + }, + + filter: function(fn, bind){ + var results = []; + for (var i = 0, l = this.length; i < l; i++){ + if (fn.call(bind, this[i], i, this)) results.push(this[i]); + } + return results; + }, + + clean: function() { + return this.filter($defined); + }, + + indexOf: function(item, from){ + var len = this.length; + for (var i = (from < 0) ? Math.max(0, len + from) : from || 0; i < len; i++){ + if (this[i] === item) return i; + } + return -1; + }, + + map: function(fn, bind){ + var results = []; + for (var i = 0, l = this.length; i < l; i++) results[i] = fn.call(bind, this[i], i, this); + return results; + }, + + some: function(fn, bind){ + for (var i = 0, l = this.length; i < l; i++){ + if (fn.call(bind, this[i], i, this)) return true; + } + return false; + }, + + associate: function(keys){ + var obj = {}, length = Math.min(this.length, keys.length); + for (var i = 0; i < length; i++) obj[keys[i]] = this[i]; + return obj; + }, + + link: function(object){ + var result = {}; + for (var i = 0, l = this.length; i < l; i++){ + for (var key in object){ + if (object[key](this[i])){ + result[key] = this[i]; + delete object[key]; + break; + } + } + } + return result; + }, + + contains: function(item, from){ + return this.indexOf(item, from) != -1; + }, + + extend: function(array){ + for (var i = 0, j = array.length; i < j; i++) this.push(array[i]); + return this; + }, + + getLast: function(){ + return (this.length) ? this[this.length - 1] : null; + }, + + getRandom: function(){ + return (this.length) ? this[$random(0, this.length - 1)] : null; + }, + + include: function(item){ + if (!this.contains(item)) this.push(item); + return this; + }, + + combine: function(array){ + for (var i = 0, l = array.length; i < l; i++) this.include(array[i]); + return this; + }, + + erase: function(item){ + for (var i = this.length; i--; i){ + if (this[i] === item) this.splice(i, 1); + } + return this; + }, + + empty: function(){ + this.length = 0; + return this; + }, + + flatten: function(){ + var array = []; + for (var i = 0, l = this.length; i < l; i++){ + var type = $type(this[i]); + if (!type) continue; + array = array.concat((type == 'array' || type == 'collection' || type == 'arguments') ? Array.flatten(this[i]) : this[i]); + } + return array; + }, + + hexToRgb: function(array){ + if (this.length != 3) return null; + var rgb = this.map(function(value){ + if (value.length == 1) value += value; + return value.toInt(16); + }); + return (array) ? rgb : 'rgb(' + rgb + ')'; + }, + + rgbToHex: function(array){ + if (this.length < 3) return null; + if (this.length == 4 && this[3] == 0 && !array) return 'transparent'; + var hex = []; + for (var i = 0; i < 3; i++){ + var bit = (this[i] - 0).toString(16); + hex.push((bit.length == 1) ? '0' + bit : bit); + } + return (array) ? hex : '#' + hex.join(''); + } + +}); + + +/* +Script: Function.js + Contains Function Prototypes like create, bind, pass, and delay. + +License: + MIT-style license. +*/ + +Function.implement({ + + extend: function(properties){ + for (var property in properties) this[property] = properties[property]; + return this; + }, + + create: function(options){ + var self = this; + options = options || {}; + return function(event){ + var args = options.arguments; + args = (args != undefined) ? $splat(args) : Array.slice(arguments, (options.event) ? 1 : 0); + if (options.event) args = [event || window.event].extend(args); + var returns = function(){ + return self.apply(options.bind || null, args); + }; + if (options.delay) return setTimeout(returns, options.delay); + if (options.periodical) return setInterval(returns, options.periodical); + if (options.attempt) return $try(returns); + return returns(); + }; + }, + + run: function(args, bind){ + return this.apply(bind, $splat(args)); + }, + + pass: function(args, bind){ + return this.create({bind: bind, arguments: args}); + }, + + bind: function(bind, args){ + return this.create({bind: bind, arguments: args}); + }, + + bindWithEvent: function(bind, args){ + return this.create({bind: bind, arguments: args, event: true}); + }, + + attempt: function(args, bind){ + return this.create({bind: bind, arguments: args, attempt: true})(); + }, + + delay: function(delay, bind, args){ + return this.create({bind: bind, arguments: args, delay: delay})(); + }, + + periodical: function(periodical, bind, args){ + return this.create({bind: bind, arguments: args, periodical: periodical})(); + } + +}); + + +/* +Script: Number.js + Contains Number Prototypes like limit, round, times, and ceil. + +License: + MIT-style license. +*/ + +Number.implement({ + + limit: function(min, max){ + return Math.min(max, Math.max(min, this)); + }, + + round: function(precision){ + precision = Math.pow(10, precision || 0); + return Math.round(this * precision) / precision; + }, + + times: function(fn, bind){ + for (var i = 0; i < this; i++) fn.call(bind, i, this); + }, + + toFloat: function(){ + return parseFloat(this); + }, + + toInt: function(base){ + return parseInt(this, base || 10); + } + +}); + +Number.alias('times', 'each'); + +(function(math){ + var methods = {}; + math.each(function(name){ + if (!Number[name]) methods[name] = function(){ + return Math[name].apply(null, [this].concat($A(arguments))); + }; + }); + Number.implement(methods); +})(['abs', 'acos', 'asin', 'atan', 'atan2', 'ceil', 'cos', 'exp', 'floor', 'log', 'max', 'min', 'pow', 'sin', 'sqrt', 'tan']); + + +/* +Script: String.js + Contains String Prototypes like camelCase, capitalize, test, and toInt. + +License: + MIT-style license. +*/ + +String.implement({ + + test: function(regex, params){ + return ((typeof regex == 'string') ? new RegExp(regex, params) : regex).test(this); + }, + + contains: function(string, separator){ + return (separator) ? (separator + this + separator).indexOf(separator + string + separator) > -1 : this.indexOf(string) > -1; + }, + + trim: function(){ + return this.replace(/^\s+|\s+$/g, ''); + }, + + clean: function(){ + return this.replace(/\s+/g, ' ').trim(); + }, + + camelCase: function(){ + return this.replace(/-\D/g, function(match){ + return match.charAt(1).toUpperCase(); + }); + }, + + hyphenate: function(){ + return this.replace(/[A-Z]/g, function(match){ + return ('-' + match.charAt(0).toLowerCase()); + }); + }, + + capitalize: function(){ + return this.replace(/\b[a-z]/g, function(match){ + return match.toUpperCase(); + }); + }, + + escapeRegExp: function(){ + return this.replace(/([-.*+?^${}()|[\]\/\\])/g, '\\$1'); + }, + + toInt: function(base){ + return parseInt(this, base || 10); + }, + + toFloat: function(){ + return parseFloat(this); + }, + + hexToRgb: function(array){ + var hex = this.match(/^#?(\w{1,2})(\w{1,2})(\w{1,2})$/); + return (hex) ? hex.slice(1).hexToRgb(array) : null; + }, + + rgbToHex: function(array){ + var rgb = this.match(/\d{1,3}/g); + return (rgb) ? rgb.rgbToHex(array) : null; + }, + + stripScripts: function(option){ + var scripts = ''; + var text = this.replace(/<script[^>]*>([\s\S]*?)<\/script>/gi, function(){ + scripts += arguments[1] + '\n'; + return ''; + }); + if (option === true) $exec(scripts); + else if ($type(option) == 'function') option(scripts, text); + return text; + }, + + substitute: function(object, regexp){ + return this.replace(regexp || (/\\?\{([^{}]+)\}/g), function(match, name){ + if (match.charAt(0) == '\\') return match.slice(1); + return (object[name] != undefined) ? object[name] : ''; + }); + } + +}); + + +/* +Script: Hash.js + Contains Hash Prototypes. Provides a means for overcoming the JavaScript practical impossibility of extending native Objects. + +License: + MIT-style license. +*/ + +Hash.implement({ + + has: Object.prototype.hasOwnProperty, + + keyOf: function(value){ + for (var key in this){ + if (this.hasOwnProperty(key) && this[key] === value) return key; + } + return null; + }, + + hasValue: function(value){ + return (Hash.keyOf(this, value) !== null); + }, + + extend: function(properties){ + Hash.each(properties, function(value, key){ + Hash.set(this, key, value); + }, this); + return this; + }, + + combine: function(properties){ + Hash.each(properties, function(value, key){ + Hash.include(this, key, value); + }, this); + return this; + }, + + erase: function(key){ + if (this.hasOwnProperty(key)) delete this[key]; + return this; + }, + + get: function(key){ + return (this.hasOwnProperty(key)) ? this[key] : null; + }, + + set: function(key, value){ + if (!this[key] || this.hasOwnProperty(key)) this[key] = value; + return this; + }, + + empty: function(){ + Hash.each(this, function(value, key){ + delete this[key]; + }, this); + return this; + }, + + include: function(key, value){ + var k = this[key]; + if (k == undefined) this[key] = value; + return this; + }, + + map: function(fn, bind){ + var results = new Hash; + Hash.each(this, function(value, key){ + results.set(key, fn.call(bind, value, key, this)); + }, this); + return results; + }, + + filter: function(fn, bind){ + var results = new Hash; + Hash.each(this, function(value, key){ + if (fn.call(bind, value, key, this)) results.set(key, value); + }, this); + return results; + }, + + every: function(fn, bind){ + for (var key in this){ + if (this.hasOwnProperty(key) && !fn.call(bind, this[key], key)) return false; + } + return true; + }, + + some: function(fn, bind){ + for (var key in this){ + if (this.hasOwnProperty(key) && fn.call(bind, this[key], key)) return true; + } + return false; + }, + + getKeys: function(){ + var keys = []; + Hash.each(this, function(value, key){ + keys.push(key); + }); + return keys; + }, + + getValues: function(){ + var values = []; + Hash.each(this, function(value){ + values.push(value); + }); + return values; + }, + + toQueryString: function(base){ + var queryString = []; + Hash.each(this, function(value, key){ + if (base) key = base + '[' + key + ']'; + var result; + switch ($type(value)){ + case 'object': result = Hash.toQueryString(value, key); break; + case 'array': + var qs = {}; + value.each(function(val, i){ + qs[i] = val; + }); + result = Hash.toQueryString(qs, key); + break; + default: result = key + '=' + encodeURIComponent(value); + } + if (value != undefined) queryString.push(result); + }); + + return queryString.join('&'); + } + +}); + +Hash.alias({keyOf: 'indexOf', hasValue: 'contains'}); + + +/* +Script: Event.js + Contains the Event Native, to make the event object completely crossbrowser. + +License: + MIT-style license. +*/ + +var Event = new Native({ + + name: 'Event', + + initialize: function(event, win){ + win = win || window; + var doc = win.document; + event = event || win.event; + if (event.$extended) return event; + this.$extended = true; + var type = event.type; + var target = event.target || event.srcElement; + while (target && target.nodeType == 3) target = target.parentNode; + + if (type.test(/key/)){ + var code = event.which || event.keyCode; + var key = Event.Keys.keyOf(code); + if (type == 'keydown'){ + var fKey = code - 111; + if (fKey > 0 && fKey < 13) key = 'f' + fKey; + } + key = key || String.fromCharCode(code).toLowerCase(); + } else if (type.match(/(click|mouse|menu)/i)){ + doc = (!doc.compatMode || doc.compatMode == 'CSS1Compat') ? doc.html : doc.body; + var page = { + x: event.pageX || event.clientX + doc.scrollLeft, + y: event.pageY || event.clientY + doc.scrollTop + }; + var client = { + x: (event.pageX) ? event.pageX - win.pageXOffset : event.clientX, + y: (event.pageY) ? event.pageY - win.pageYOffset : event.clientY + }; + if (type.match(/DOMMouseScroll|mousewheel/)){ + var wheel = (event.wheelDelta) ? event.wheelDelta / 120 : -(event.detail || 0) / 3; + } + var rightClick = (event.which == 3) || (event.button == 2); + var related = null; + if (type.match(/over|out/)){ + switch (type){ + case 'mouseover': related = event.relatedTarget || event.fromElement; break; + case 'mouseout': related = event.relatedTarget || event.toElement; + } + if (!(function(){ + while (related && related.nodeType == 3) related = related.parentNode; + return true; + }).create({attempt: Browser.Engine.gecko})()) related = false; + } + } + + return $extend(this, { + event: event, + type: type, + + page: page, + client: client, + rightClick: rightClick, + + wheel: wheel, + + relatedTarget: related, + target: target, + + code: code, + key: key, + + shift: event.shiftKey, + control: event.ctrlKey, + alt: event.altKey, + meta: event.metaKey + }); + } + +}); + +Event.Keys = new Hash({ + 'enter': 13, + 'up': 38, + 'down': 40, + 'left': 37, + 'right': 39, + 'esc': 27, + 'space': 32, + 'backspace': 8, + 'tab': 9, + 'delete': 46 +}); + +Event.implement({ + + stop: function(){ + return this.stopPropagation().preventDefault(); + }, + + stopPropagation: function(){ + if (this.event.stopPropagation) this.event.stopPropagation(); + else this.event.cancelBubble = true; + return this; + }, + + preventDefault: function(){ + if (this.event.preventDefault) this.event.preventDefault(); + else this.event.returnValue = false; + return this; + } + +}); + + +/* +Script: Class.js + Contains the Class Function for easily creating, extending, and implementing reusable Classes. + +License: + MIT-style license. +*/ + +var Class = new Native({ + + name: 'Class', + + initialize: function(properties){ + properties = properties || {}; + var klass = function(){ + for (var key in this){ + if ($type(this[key]) != 'function') this[key] = $unlink(this[key]); + } + this.constructor = klass; + if (Class.prototyping) return this; + var instance = (this.initialize) ? this.initialize.apply(this, arguments) : this; + if (this.options && this.options.initialize) this.options.initialize.call(this); + return instance; + }; + + for (var mutator in Class.Mutators){ + if (!properties[mutator]) continue; + properties = Class.Mutators[mutator](properties, properties[mutator]); + delete properties[mutator]; + } + + $extend(klass, this); + klass.constructor = Class; + klass.prototype = properties; + return klass; + } + +}); + +Class.Mutators = { + + Extends: function(self, klass){ + Class.prototyping = klass.prototype; + var subclass = new klass; + delete subclass.parent; + subclass = Class.inherit(subclass, self); + delete Class.prototyping; + return subclass; + }, + + Implements: function(self, klasses){ + $splat(klasses).each(function(klass){ + Class.prototying = klass; + $extend(self, ($type(klass) == 'class') ? new klass : klass); + delete Class.prototyping; + }); + return self; + } + +}; + +Class.extend({ + + inherit: function(object, properties){ + var caller = arguments.callee.caller; + for (var key in properties){ + var override = properties[key]; + var previous = object[key]; + var type = $type(override); + if (previous && type == 'function'){ + if (override != previous){ + if (caller){ + override.__parent = previous; + object[key] = override; + } else { + Class.override(object, key, override); + } + } + } else if(type == 'object'){ + object[key] = $merge(previous, override); + } else { + object[key] = override; + } + } + + if (caller) object.parent = function(){ + return arguments.callee.caller.__parent.apply(this, arguments); + }; + + return object; + }, + + override: function(object, name, method){ + var parent = Class.prototyping; + if (parent && object[name] != parent[name]) parent = null; + var override = function(){ + var previous = this.parent; + this.parent = parent ? parent[name] : object[name]; + var value = method.apply(this, arguments); + this.parent = previous; + return value; + }; + object[name] = override; + } + +}); + +Class.implement({ + + implement: function(){ + var proto = this.prototype; + $each(arguments, function(properties){ + Class.inherit(proto, properties); + }); + return this; + } + +}); + + +/* +Script: Class.Extras.js + Contains Utility Classes that can be implemented into your own Classes to ease the execution of many common tasks. + +License: + MIT-style license. +*/ + +var Chain = new Class({ + + $chain: [], + + chain: function(){ + this.$chain.extend(Array.flatten(arguments)); + return this; + }, + + callChain: function(){ + return (this.$chain.length) ? this.$chain.shift().apply(this, arguments) : false; + }, + + clearChain: function(){ + this.$chain.empty(); + return this; + } + +}); + +var Events = new Class({ + + $events: {}, + + addEvent: function(type, fn, internal){ + type = Events.removeOn(type); + if (fn != $empty){ + this.$events[type] = this.$events[type] || []; + this.$events[type].include(fn); + if (internal) fn.internal = true; + } + return this; + }, + + addEvents: function(events){ + for (var type in events) this.addEvent(type, events[type]); + return this; + }, + + fireEvent: function(type, args, delay){ + type = Events.removeOn(type); + if (!this.$events || !this.$events[type]) return this; + this.$events[type].each(function(fn){ + fn.create({'bind': this, 'delay': delay, 'arguments': args})(); + }, this); + return this; + }, + + removeEvent: function(type, fn){ + type = Events.removeOn(type); + if (!this.$events[type]) return this; + if (!fn.internal) this.$events[type].erase(fn); + return this; + }, + + removeEvents: function(events){ + if ($type(events) == 'object'){ + for (var type in events) this.removeEvent(type, events[type]); + return this; + } + if (events) events = Events.removeOn(events); + for (var type in this.$events){ + if (events && events != type) continue; + var fns = this.$events[type]; + for (var i = fns.length; i--; i) this.removeEvent(type, fns[i]); + } + return this; + } + +}); + +Events.removeOn = function(string){ + return string.replace(/^on([A-Z])/, function(full, first) { + return first.toLowerCase(); + }); +}; + +var Options = new Class({ + + setOptions: function(){ + this.options = $merge.run([this.options].extend(arguments)); + if (!this.addEvent) return this; + for (var option in this.options){ + if ($type(this.options[option]) != 'function' || !(/^on[A-Z]/).test(option)) continue; + this.addEvent(option, this.options[option]); + delete this.options[option]; + } + return this; + } + +}); + + +/* +Script: Element.js + One of the most important items in MooTools. Contains the dollar function, the dollars function, and an handful of cross-browser, + time-saver methods to let you easily work with HTML Elements. + +License: + MIT-style license. +*/ + +var Element = new Native({ + + name: 'Element', + + legacy: window.Element, + + initialize: function(tag, props){ + var konstructor = Element.Constructors.get(tag); + if (konstructor) return konstructor(props); + if (typeof tag == 'string') return document.newElement(tag, props); + return $(tag).set(props); + }, + + afterImplement: function(key, value){ + Element.Prototype[key] = value; + if (Array[key]) return; + Elements.implement(key, function(){ + var items = [], elements = true; + for (var i = 0, j = this.length; i < j; i++){ + var returns = this[i][key].apply(this[i], arguments); + items.push(returns); + if (elements) elements = ($type(returns) == 'element'); + } + return (elements) ? new Elements(items) : items; + }); + } + +}); + +Element.Prototype = {$family: {name: 'element'}}; + +Element.Constructors = new Hash; + +var IFrame = new Native({ + + name: 'IFrame', + + generics: false, + + initialize: function(){ + var params = Array.link(arguments, {properties: Object.type, iframe: $defined}); + var props = params.properties || {}; + var iframe = $(params.iframe) || false; + var onload = props.onload || $empty; + delete props.onload; + props.id = props.name = $pick(props.id, props.name, iframe.id, iframe.name, 'IFrame_' + $time()); + iframe = new Element(iframe || 'iframe', props); + var onFrameLoad = function(){ + var host = $try(function(){ + return iframe.contentWindow.location.host; + }); + if (host && host == window.location.host){ + var win = new Window(iframe.contentWindow); + new Document(iframe.contentWindow.document); + $extend(win.Element.prototype, Element.Prototype); + } + onload.call(iframe.contentWindow, iframe.contentWindow.document); + }; + (window.frames[props.id]) ? onFrameLoad() : iframe.addListener('load', onFrameLoad); + return iframe; + } + +}); + +var Elements = new Native({ + + initialize: function(elements, options){ + options = $extend({ddup: true, cash: true}, options); + elements = elements || []; + if (options.ddup || options.cash){ + var uniques = {}, returned = []; + for (var i = 0, l = elements.length; i < l; i++){ + var el = $.element(elements[i], !options.cash); + if (options.ddup){ + if (uniques[el.uid]) continue; + uniques[el.uid] = true; + } + returned.push(el); + } + elements = returned; + } + return (options.cash) ? $extend(elements, this) : elements; + } + +}); + +Elements.implement({ + + filter: function(filter, bind){ + if (!filter) return this; + return new Elements(Array.filter(this, (typeof filter == 'string') ? function(item){ + return item.match(filter); + } : filter, bind)); + } + +}); + +Document.implement({ + + newElement: function(tag, props){ + if (Browser.Engine.trident && props){ + ['name', 'type', 'checked'].each(function(attribute){ + if (!props[attribute]) return; + tag += ' ' + attribute + '="' + props[attribute] + '"'; + if (attribute != 'checked') delete props[attribute]; + }); + tag = '<' + tag + '>'; + } + return $.element(this.createElement(tag)).set(props); + }, + + newTextNode: function(text){ + return this.createTextNode(text); + }, + + getDocument: function(){ + return this; + }, + + getWindow: function(){ + return this.window; + } + +}); + +Window.implement({ + + $: function(el, nocash){ + if (el && el.$family && el.uid) return el; + var type = $type(el); + return ($[type]) ? $[type](el, nocash, this.document) : null; + }, + + $$: function(selector){ + if (arguments.length == 1 && typeof selector == 'string') return this.document.getElements(selector); + var elements = []; + var args = Array.flatten(arguments); + for (var i = 0, l = args.length; i < l; i++){ + var item = args[i]; + switch ($type(item)){ + case 'element': elements.push(item); break; + case 'string': elements.extend(this.document.getElements(item, true)); + } + } + return new Elements(elements); + }, + + getDocument: function(){ + return this.document; + }, + + getWindow: function(){ + return this; + } + +}); + +$.string = function(id, nocash, doc){ + id = doc.getElementById(id); + return (id) ? $.element(id, nocash) : null; +}; + +$.element = function(el, nocash){ + $uid(el); + if (!nocash && !el.$family && !(/^object|embed$/i).test(el.tagName)){ + var proto = Element.Prototype; + for (var p in proto) el[p] = proto[p]; + }; + return el; +}; + +$.object = function(obj, nocash, doc){ + if (obj.toElement) return $.element(obj.toElement(doc), nocash); + return null; +}; + +$.textnode = $.whitespace = $.window = $.document = $arguments(0); + +Native.implement([Element, Document], { + + getElement: function(selector, nocash){ + return $(this.getElements(selector, true)[0] || null, nocash); + }, + + getElements: function(tags, nocash){ + tags = tags.split(','); + var elements = []; + var ddup = (tags.length > 1); + tags.each(function(tag){ + var partial = this.getElementsByTagName(tag.trim()); + (ddup) ? elements.extend(partial) : elements = partial; + }, this); + return new Elements(elements, {ddup: ddup, cash: !nocash}); + } + +}); + +(function(){ + +var collected = {}, storage = {}; +var props = {input: 'checked', option: 'selected', textarea: (Browser.Engine.webkit && Browser.Engine.version < 420) ? 'innerHTML' : 'value'}; + +var get = function(uid){ + return (storage[uid] || (storage[uid] = {})); +}; + +var clean = function(item, retain){ + if (!item) return; + var uid = item.uid; + if (Browser.Engine.trident){ + if (item.clearAttributes){ + var clone = retain && item.cloneNode(false); + item.clearAttributes(); + if (clone) item.mergeAttributes(clone); + } else if (item.removeEvents){ + item.removeEvents(); + } + if ((/object/i).test(item.tagName)){ + for (var p in item){ + if (typeof item[p] == 'function') item[p] = $empty; + } + Element.dispose(item); + } + } + if (!uid) return; + collected[uid] = storage[uid] = null; +}; + +var purge = function(){ + Hash.each(collected, clean); + if (Browser.Engine.trident) $A(document.getElementsByTagName('object')).each(clean); + if (window.CollectGarbage) CollectGarbage(); + collected = storage = null; +}; + +var walk = function(element, walk, start, match, all, nocash){ + var el = element[start || walk]; + var elements = []; + while (el){ + if (el.nodeType == 1 && (!match || Element.match(el, match))){ + if (!all) return $(el, nocash); + elements.push(el); + } + el = el[walk]; + } + return (all) ? new Elements(elements, {ddup: false, cash: !nocash}) : null; +}; + +var attributes = { + 'html': 'innerHTML', + 'class': 'className', + 'for': 'htmlFor', + 'text': (Browser.Engine.trident || (Browser.Engine.webkit && Browser.Engine.version < 420)) ? 'innerText' : 'textContent' +}; +var bools = ['compact', 'nowrap', 'ismap', 'declare', 'noshade', 'checked', 'disabled', 'readonly', 'multiple', 'selected', 'noresize', 'defer']; +var camels = ['value', 'accessKey', 'cellPadding', 'cellSpacing', 'colSpan', 'frameBorder', 'maxLength', 'readOnly', 'rowSpan', 'tabIndex', 'useMap']; + +Hash.extend(attributes, bools.associate(bools)); +Hash.extend(attributes, camels.associate(camels.map(String.toLowerCase))); + +var inserters = { + + before: function(context, element){ + if (element.parentNode) element.parentNode.insertBefore(context, element); + }, + + after: function(context, element){ + if (!element.parentNode) return; + var next = element.nextSibling; + (next) ? element.parentNode.insertBefore(context, next) : element.parentNode.appendChild(context); + }, + + bottom: function(context, element){ + element.appendChild(context); + }, + + top: function(context, element){ + var first = element.firstChild; + (first) ? element.insertBefore(context, first) : element.appendChild(context); + } + +}; + +inserters.inside = inserters.bottom; + +Hash.each(inserters, function(inserter, where){ + + where = where.capitalize(); + + Element.implement('inject' + where, function(el){ + inserter(this, $(el, true)); + return this; + }); + + Element.implement('grab' + where, function(el){ + inserter($(el, true), this); + return this; + }); + +}); + +Element.implement({ + + set: function(prop, value){ + switch ($type(prop)){ + case 'object': + for (var p in prop) this.set(p, prop[p]); + break; + case 'string': + var property = Element.Properties.get(prop); + (property && property.set) ? property.set.apply(this, Array.slice(arguments, 1)) : this.setProperty(prop, value); + } + return this; + }, + + get: function(prop){ + var property = Element.Properties.get(prop); + return (property && property.get) ? property.get.apply(this, Array.slice(arguments, 1)) : this.getProperty(prop); + }, + + erase: function(prop){ + var property = Element.Properties.get(prop); + (property && property.erase) ? property.erase.apply(this) : this.removeProperty(prop); + return this; + }, + + setProperty: function(attribute, value){ + var key = attributes[attribute]; + if (value == undefined) return this.removeProperty(attribute); + if (key && bools[attribute]) value = !!value; + (key) ? this[key] = value : this.setAttribute(attribute, '' + value); + return this; + }, + + setProperties: function(attributes){ + for (var attribute in attributes) this.setProperty(attribute, attributes[attribute]); + return this; + }, + + getProperty: function(attribute){ + var key = attributes[attribute]; + var value = (key) ? this[key] : this.getAttribute(attribute, 2); + return (bools[attribute]) ? !!value : (key) ? value : value || null; + }, + + getProperties: function(){ + var args = $A(arguments); + return args.map(this.getProperty, this).associate(args); + }, + + removeProperty: function(attribute){ + var key = attributes[attribute]; + (key) ? this[key] = (key && bools[attribute]) ? false : '' : this.removeAttribute(attribute); + return this; + }, + + removeProperties: function(){ + Array.each(arguments, this.removeProperty, this); + return this; + }, + + hasClass: function(className){ + return this.className.contains(className, ' '); + }, + + addClass: function(className){ + if (!this.hasClass(className)) this.className = (this.className + ' ' + className).clean(); + return this; + }, + + removeClass: function(className){ + this.className = this.className.replace(new RegExp('(^|\\s)' + className + '(?:\\s|$)'), '$1'); + return this; + }, + + toggleClass: function(className){ + return this.hasClass(className) ? this.removeClass(className) : this.addClass(className); + }, + + adopt: function(){ + Array.flatten(arguments).each(function(element){ + element = $(element, true); + if (element) this.appendChild(element); + }, this); + return this; + }, + + appendText: function(text, where){ + return this.grab(this.getDocument().newTextNode(text), where); + }, + + grab: function(el, where){ + inserters[where || 'bottom']($(el, true), this); + return this; + }, + + inject: function(el, where){ + inserters[where || 'bottom'](this, $(el, true)); + return this; + }, + + replaces: function(el){ + el = $(el, true); + el.parentNode.replaceChild(this, el); + return this; + }, + + wraps: function(el, where){ + el = $(el, true); + return this.replaces(el).grab(el, where); + }, + + getPrevious: function(match, nocash){ + return walk(this, 'previousSibling', null, match, false, nocash); + }, + + getAllPrevious: function(match, nocash){ + return walk(this, 'previousSibling', null, match, true, nocash); + }, + + getNext: function(match, nocash){ + return walk(this, 'nextSibling', null, match, false, nocash); + }, + + getAllNext: function(match, nocash){ + return walk(this, 'nextSibling', null, match, true, nocash); + }, + + getFirst: function(match, nocash){ + return walk(this, 'nextSibling', 'firstChild', match, false, nocash); + }, + + getLast: function(match, nocash){ + return walk(this, 'previousSibling', 'lastChild', match, false, nocash); + }, + + getParent: function(match, nocash){ + return walk(this, 'parentNode', null, match, false, nocash); + }, + + getParents: function(match, nocash){ + return walk(this, 'parentNode', null, match, true, nocash); + }, + + getChildren: function(match, nocash){ + return walk(this, 'nextSibling', 'firstChild', match, true, nocash); + }, + + getWindow: function(){ + return this.ownerDocument.window; + }, + + getDocument: function(){ + return this.ownerDocument; + }, + + getElementById: function(id, nocash){ + var el = this.ownerDocument.getElementById(id); + if (!el) return null; + for (var parent = el.parentNode; parent != this; parent = parent.parentNode){ + if (!parent) return null; + } + return $.element(el, nocash); + }, + + getSelected: function(){ + return new Elements($A(this.options).filter(function(option){ + return option.selected; + })); + }, + + getComputedStyle: function(property){ + if (this.currentStyle) return this.currentStyle[property.camelCase()]; + var computed = this.getDocument().defaultView.getComputedStyle(this, null); + return (computed) ? computed.getPropertyValue([property.hyphenate()]) : null; + }, + + toQueryString: function(){ + var queryString = []; + this.getElements('input, select, textarea', true).each(function(el){ + if (!el.name || el.disabled) return; + var value = (el.tagName.toLowerCase() == 'select') ? Element.getSelected(el).map(function(opt){ + return opt.value; + }) : ((el.type == 'radio' || el.type == 'checkbox') && !el.checked) ? null : el.value; + $splat(value).each(function(val){ + if (typeof val != 'undefined') queryString.push(el.name + '=' + encodeURIComponent(val)); + }); + }); + return queryString.join('&'); + }, + + clone: function(contents, keepid){ + contents = contents !== false; + var clone = this.cloneNode(contents); + var clean = function(node, element){ + if (!keepid) node.removeAttribute('id'); + if (Browser.Engine.trident){ + node.clearAttributes(); + node.mergeAttributes(element); + node.removeAttribute('uid'); + if (node.options){ + var no = node.options, eo = element.options; + for (var j = no.length; j--;) no[j].selected = eo[j].selected; + } + } + var prop = props[element.tagName.toLowerCase()]; + if (prop && element[prop]) node[prop] = element[prop]; + }; + + if (contents){ + var ce = clone.getElementsByTagName('*'), te = this.getElementsByTagName('*'); + for (var i = ce.length; i--;) clean(ce[i], te[i]); + } + + clean(clone, this); + return $(clone); + }, + + destroy: function(){ + Element.empty(this); + Element.dispose(this); + clean(this, true); + return null; + }, + + empty: function(){ + $A(this.childNodes).each(function(node){ + Element.destroy(node); + }); + return this; + }, + + dispose: function(){ + return (this.parentNode) ? this.parentNode.removeChild(this) : this; + }, + + hasChild: function(el){ + el = $(el, true); + if (!el) return false; + if (Browser.Engine.webkit && Browser.Engine.version < 420) return $A(this.getElementsByTagName(el.tagName)).contains(el); + return (this.contains) ? (this != el && this.contains(el)) : !!(this.compareDocumentPosition(el) & 16); + }, + + match: function(tag){ + return (!tag || (tag == this) || (Element.get(this, 'tag') == tag)); + } + +}); + +Native.implement([Element, Window, Document], { + + addListener: function(type, fn){ + if (type == 'unload'){ + var old = fn, self = this; + fn = function(){ + self.removeListener('unload', fn); + old(); + }; + } else { + collected[this.uid] = this; + } + if (this.addEventListener) this.addEventListener(type, fn, false); + else this.attachEvent('on' + type, fn); + return this; + }, + + removeListener: function(type, fn){ + if (this.removeEventListener) this.removeEventListener(type, fn, false); + else this.detachEvent('on' + type, fn); + return this; + }, + + retrieve: function(property, dflt){ + var storage = get(this.uid), prop = storage[property]; + if (dflt != undefined && prop == undefined) prop = storage[property] = dflt; + return $pick(prop); + }, + + store: function(property, value){ + var storage = get(this.uid); + storage[property] = value; + return this; + }, + + eliminate: function(property){ + var storage = get(this.uid); + delete storage[property]; + return this; + } + +}); + +window.addListener('unload', purge); + +})(); + +Element.Properties = new Hash; + +Element.Properties.style = { + + set: function(style){ + this.style.cssText = style; + }, + + get: function(){ + return this.style.cssText; + }, + + erase: function(){ + this.style.cssText = ''; + } + +}; + +Element.Properties.tag = { + + get: function(){ + return this.tagName.toLowerCase(); + } + +}; + +Element.Properties.html = (function(){ + var wrapper = document.createElement('div'); + + var translations = { + table: [1, '<table>', '</table>'], + select: [1, '<select>', '</select>'], + tbody: [2, '<table><tbody>', '</tbody></table>'], + tr: [3, '<table><tbody><tr>', '</tr></tbody></table>'] + }; + translations.thead = translations.tfoot = translations.tbody; + + var html = { + set: function(){ + var html = Array.flatten(arguments).join(''); + var wrap = Browser.Engine.trident && translations[this.get('tag')]; + if (wrap){ + var first = wrapper; + first.innerHTML = wrap[1] + html + wrap[2]; + for (var i = wrap[0]; i--;) first = first.firstChild; + this.empty().adopt(first.childNodes); + } else { + this.innerHTML = html; + } + } + }; + + html.erase = html.set; + + return html; +})(); + +if (Browser.Engine.webkit && Browser.Engine.version < 420) Element.Properties.text = { + get: function(){ + if (this.innerText) return this.innerText; + var temp = this.ownerDocument.newElement('div', {html: this.innerHTML}).inject(this.ownerDocument.body); + var text = temp.innerText; + temp.destroy(); + return text; + } +}; + + +/* +Script: Element.Event.js + Contains Element methods for dealing with events, and custom Events. + +License: + MIT-style license. +*/ + +Element.Properties.events = {set: function(events){ + this.addEvents(events); +}}; + +Native.implement([Element, Window, Document], { + + addEvent: function(type, fn){ + var events = this.retrieve('events', {}); + events[type] = events[type] || {'keys': [], 'values': []}; + if (events[type].keys.contains(fn)) return this; + events[type].keys.push(fn); + var realType = type, custom = Element.Events.get(type), condition = fn, self = this; + if (custom){ + if (custom.onAdd) custom.onAdd.call(this, fn); + if (custom.condition){ + condition = function(event){ + if (custom.condition.call(this, event)) return fn.call(this, event); + return true; + }; + } + realType = custom.base || realType; + } + var defn = function(){ + return fn.call(self); + }; + var nativeEvent = Element.NativeEvents[realType]; + if (nativeEvent){ + if (nativeEvent == 2){ + defn = function(event){ + event = new Event(event, self.getWindow()); + if (condition.call(self, event) === false) event.stop(); + }; + } + this.addListener(realType, defn); + } + events[type].values.push(defn); + return this; + }, + + removeEvent: function(type, fn){ + var events = this.retrieve('events'); + if (!events || !events[type]) return this; + var pos = events[type].keys.indexOf(fn); + if (pos == -1) return this; + events[type].keys.splice(pos, 1); + var value = events[type].values.splice(pos, 1)[0]; + var custom = Element.Events.get(type); + if (custom){ + if (custom.onRemove) custom.onRemove.call(this, fn); + type = custom.base || type; + } + return (Element.NativeEvents[type]) ? this.removeListener(type, value) : this; + }, + + addEvents: function(events){ + for (var event in events) this.addEvent(event, events[event]); + return this; + }, + + removeEvents: function(events){ + if ($type(events) == 'object'){ + for (var type in events) this.removeEvent(type, events[type]); + return this; + } + var attached = this.retrieve('events'); + if (!attached) return this; + if (!events){ + for (var type in attached) this.removeEvents(type); + this.eliminate('events'); + } else if (attached[events]){ + while (attached[events].keys[0]) this.removeEvent(events, attached[events].keys[0]); + attached[events] = null; + } + return this; + }, + + fireEvent: function(type, args, delay){ + var events = this.retrieve('events'); + if (!events || !events[type]) return this; + events[type].keys.each(function(fn){ + fn.create({'bind': this, 'delay': delay, 'arguments': args})(); + }, this); + return this; + }, + + cloneEvents: function(from, type){ + from = $(from); + var fevents = from.retrieve('events'); + if (!fevents) return this; + if (!type){ + for (var evType in fevents) this.cloneEvents(from, evType); + } else if (fevents[type]){ + fevents[type].keys.each(function(fn){ + this.addEvent(type, fn); + }, this); + } + return this; + } + +}); + +Element.NativeEvents = { + click: 2, dblclick: 2, mouseup: 2, mousedown: 2, contextmenu: 2, //mouse buttons + mousewheel: 2, DOMMouseScroll: 2, //mouse wheel + mouseover: 2, mouseout: 2, mousemove: 2, selectstart: 2, selectend: 2, //mouse movement + keydown: 2, keypress: 2, keyup: 2, //keyboard + focus: 2, blur: 2, change: 2, reset: 2, select: 2, submit: 2, //form elements + load: 1, unload: 1, beforeunload: 2, resize: 1, move: 1, DOMContentLoaded: 1, readystatechange: 1, //window + error: 1, abort: 1, scroll: 1 //misc +}; + +(function(){ + +var $check = function(event){ + var related = event.relatedTarget; + if (related == undefined) return true; + if (related === false) return false; + return ($type(this) != 'document' && related != this && related.prefix != 'xul' && !this.hasChild(related)); +}; + +Element.Events = new Hash({ + + mouseenter: { + base: 'mouseover', + condition: $check + }, + + mouseleave: { + base: 'mouseout', + condition: $check + }, + + mousewheel: { + base: (Browser.Engine.gecko) ? 'DOMMouseScroll' : 'mousewheel' + } + +}); + +})(); + + +/* +Script: Element.Style.js + Contains methods for interacting with the styles of Elements in a fashionable way. + +License: + MIT-style license. +*/ + +Element.Properties.styles = {set: function(styles){ + this.setStyles(styles); +}}; + +Element.Properties.opacity = { + + set: function(opacity, novisibility){ + if (!novisibility){ + if (opacity == 0){ + if (this.style.visibility != 'hidden') this.style.visibility = 'hidden'; + } else { + if (this.style.visibility != 'visible') this.style.visibility = 'visible'; + } + } + if (!this.currentStyle || !this.currentStyle.hasLayout) this.style.zoom = 1; + if (Browser.Engine.trident) this.style.filter = (opacity == 1) ? '' : 'alpha(opacity=' + opacity * 100 + ')'; + this.style.opacity = opacity; + this.store('opacity', opacity); + }, + + get: function(){ + return this.retrieve('opacity', 1); + } + +}; + +Element.implement({ + + setOpacity: function(value){ + return this.set('opacity', value, true); + }, + + getOpacity: function(){ + return this.get('opacity'); + }, + + setStyle: function(property, value){ + switch (property){ + case 'opacity': return this.set('opacity', parseFloat(value)); + case 'float': property = (Browser.Engine.trident) ? 'styleFloat' : 'cssFloat'; + } + property = property.camelCase(); + if ($type(value) != 'string'){ + var map = (Element.Styles.get(property) || '@').split(' '); + value = $splat(value).map(function(val, i){ + if (!map[i]) return ''; + return ($type(val) == 'number') ? map[i].replace('@', Math.round(val)) : val; + }).join(' '); + } else if (value == String(Number(value))){ + value = Math.round(value); + } + this.style[property] = value; + return this; + }, + + getStyle: function(property){ + switch (property){ + case 'opacity': return this.get('opacity'); + case 'float': property = (Browser.Engine.trident) ? 'styleFloat' : 'cssFloat'; + } + property = property.camelCase(); + var result = this.style[property]; + if (!$chk(result)){ + result = []; + for (var style in Element.ShortStyles){ + if (property != style) continue; + for (var s in Element.ShortStyles[style]) result.push(this.getStyle(s)); + return result.join(' '); + } + result = this.getComputedStyle(property); + } + if (result){ + result = String(result); + var color = result.match(/rgba?\([\d\s,]+\)/); + if (color) result = result.replace(color[0], color[0].rgbToHex()); + } + if (Browser.Engine.presto || (Browser.Engine.trident && !$chk(parseInt(result)))){ + if (property.test(/^(height|width)$/)){ + var values = (property == 'width') ? ['left', 'right'] : ['top', 'bottom'], size = 0; + values.each(function(value){ + size += this.getStyle('border-' + value + '-width').toInt() + this.getStyle('padding-' + value).toInt(); + }, this); + return this['offset' + property.capitalize()] - size + 'px'; + } + if ((Browser.Engine.presto) && String(result).test('px')) return result; + if (property.test(/(border(.+)Width|margin|padding)/)) return '0px'; + } + return result; + }, + + setStyles: function(styles){ + for (var style in styles) this.setStyle(style, styles[style]); + return this; + }, + + getStyles: function(){ + var result = {}; + Array.each(arguments, function(key){ + result[key] = this.getStyle(key); + }, this); + return result; + } + +}); + +Element.Styles = new Hash({ + left: '@px', top: '@px', bottom: '@px', right: '@px', + width: '@px', height: '@px', maxWidth: '@px', maxHeight: '@px', minWidth: '@px', minHeight: '@px', + backgroundColor: 'rgb(@, @, @)', backgroundPosition: '@px @px', color: 'rgb(@, @, @)', + fontSize: '@px', letterSpacing: '@px', lineHeight: '@px', clip: 'rect(@px @px @px @px)', + margin: '@px @px @px @px', padding: '@px @px @px @px', border: '@px @ rgb(@, @, @) @px @ rgb(@, @, @) @px @ rgb(@, @, @)', + borderWidth: '@px @px @px @px', borderStyle: '@ @ @ @', borderColor: 'rgb(@, @, @) rgb(@, @, @) rgb(@, @, @) rgb(@, @, @)', + zIndex: '@', 'zoom': '@', fontWeight: '@', textIndent: '@px', opacity: '@' +}); + +Element.ShortStyles = {margin: {}, padding: {}, border: {}, borderWidth: {}, borderStyle: {}, borderColor: {}}; + +['Top', 'Right', 'Bottom', 'Left'].each(function(direction){ + var Short = Element.ShortStyles; + var All = Element.Styles; + ['margin', 'padding'].each(function(style){ + var sd = style + direction; + Short[style][sd] = All[sd] = '@px'; + }); + var bd = 'border' + direction; + Short.border[bd] = All[bd] = '@px @ rgb(@, @, @)'; + var bdw = bd + 'Width', bds = bd + 'Style', bdc = bd + 'Color'; + Short[bd] = {}; + Short.borderWidth[bdw] = Short[bd][bdw] = All[bdw] = '@px'; + Short.borderStyle[bds] = Short[bd][bds] = All[bds] = '@'; + Short.borderColor[bdc] = Short[bd][bdc] = All[bdc] = 'rgb(@, @, @)'; +}); + + +/* +Script: Element.Dimensions.js + Contains methods to work with size, scroll, or positioning of Elements and the window object. + +License: + MIT-style license. + +Credits: + - Element positioning based on the [qooxdoo](http://qooxdoo.org/) code and smart browser fixes, [LGPL License](http://www.gnu.org/licenses/lgpl.html). + - Viewport dimensions based on [YUI](http://developer.yahoo.com/yui/) code, [BSD License](http://developer.yahoo.com/yui/license.html). +*/ + +(function(){ + +Element.implement({ + + scrollTo: function(x, y){ + if (isBody(this)){ + this.getWindow().scrollTo(x, y); + } else { + this.scrollLeft = x; + this.scrollTop = y; + } + return this; + }, + + getSize: function(){ + if (isBody(this)) return this.getWindow().getSize(); + return {x: this.offsetWidth, y: this.offsetHeight}; + }, + + getScrollSize: function(){ + if (isBody(this)) return this.getWindow().getScrollSize(); + return {x: this.scrollWidth, y: this.scrollHeight}; + }, + + getScroll: function(){ + if (isBody(this)) return this.getWindow().getScroll(); + return {x: this.scrollLeft, y: this.scrollTop}; + }, + + getScrolls: function(){ + var element = this, position = {x: 0, y: 0}; + while (element && !isBody(element)){ + position.x += element.scrollLeft; + position.y += element.scrollTop; + element = element.parentNode; + } + return position; + }, + + getOffsetParent: function(){ + var element = this; + if (isBody(element)) return null; + if (!Browser.Engine.trident) return element.offsetParent; + while ((element = element.parentNode) && !isBody(element)){ + if (styleString(element, 'position') != 'static') return element; + } + return null; + }, + + getOffsets: function(){ + if (Browser.Engine.trident){ + var bound = this.getBoundingClientRect(), html = this.getDocument().documentElement; + return { + x: bound.left + html.scrollLeft - html.clientLeft, + y: bound.top + html.scrollTop - html.clientTop + }; + } + + var element = this, position = {x: 0, y: 0}; + if (isBody(this)) return position; + + while (element && !isBody(element)){ + position.x += element.offsetLeft; + position.y += element.offsetTop; + + if (Browser.Engine.gecko){ + if (!borderBox(element)){ + position.x += leftBorder(element); + position.y += topBorder(element); + } + var parent = element.parentNode; + if (parent && styleString(parent, 'overflow') != 'visible'){ + position.x += leftBorder(parent); + position.y += topBorder(parent); + } + } else if (element != this && Browser.Engine.webkit){ + position.x += leftBorder(element); + position.y += topBorder(element); + } + + element = element.offsetParent; + } + if (Browser.Engine.gecko && !borderBox(this)){ + position.x -= leftBorder(this); + position.y -= topBorder(this); + } + return position; + }, + + getPosition: function(relative){ + if (isBody(this)) return {x: 0, y: 0}; + var offset = this.getOffsets(), scroll = this.getScrolls(); + var position = {x: offset.x - scroll.x, y: offset.y - scroll.y}; + var relativePosition = (relative && (relative = $(relative))) ? relative.getPosition() : {x: 0, y: 0}; + return {x: position.x - relativePosition.x, y: position.y - relativePosition.y}; + }, + + getCoordinates: function(element){ + if (isBody(this)) return this.getWindow().getCoordinates(); + var position = this.getPosition(element), size = this.getSize(); + var obj = {left: position.x, top: position.y, width: size.x, height: size.y}; + obj.right = obj.left + obj.width; + obj.bottom = obj.top + obj.height; + return obj; + }, + + computePosition: function(obj){ + return {left: obj.x - styleNumber(this, 'margin-left'), top: obj.y - styleNumber(this, 'margin-top')}; + }, + + position: function(obj){ + return this.setStyles(this.computePosition(obj)); + } + +}); + +Native.implement([Document, Window], { + + getSize: function(){ + var win = this.getWindow(); + if (Browser.Engine.presto || Browser.Engine.webkit) return {x: win.innerWidth, y: win.innerHeight}; + var doc = getCompatElement(this); + return {x: doc.clientWidth, y: doc.clientHeight}; + }, + + getScroll: function(){ + var win = this.getWindow(); + var doc = getCompatElement(this); + return {x: win.pageXOffset || doc.scrollLeft, y: win.pageYOffset || doc.scrollTop}; + }, + + getScrollSize: function(){ + var doc = getCompatElement(this); + var min = this.getSize(); + return {x: Math.max(doc.scrollWidth, min.x), y: Math.max(doc.scrollHeight, min.y)}; + }, + + getPosition: function(){ + return {x: 0, y: 0}; + }, + + getCoordinates: function(){ + var size = this.getSize(); + return {top: 0, left: 0, bottom: size.y, right: size.x, height: size.y, width: size.x}; + } + +}); + +// private methods + +var styleString = Element.getComputedStyle; + +function styleNumber(element, style){ + return styleString(element, style).toInt() || 0; +}; + +function borderBox(element){ + return styleString(element, '-moz-box-sizing') == 'border-box'; +}; + +function topBorder(element){ + return styleNumber(element, 'border-top-width'); +}; + +function leftBorder(element){ + return styleNumber(element, 'border-left-width'); +}; + +function isBody(element){ + return (/^(?:body|html)$/i).test(element.tagName); +}; + +function getCompatElement(element){ + var doc = element.getDocument(); + return (!doc.compatMode || doc.compatMode == 'CSS1Compat') ? doc.html : doc.body; +}; + +})(); + +//aliases + +Native.implement([Window, Document, Element], { + + getHeight: function(){ + return this.getSize().y; + }, + + getWidth: function(){ + return this.getSize().x; + }, + + getScrollTop: function(){ + return this.getScroll().y; + }, + + getScrollLeft: function(){ + return this.getScroll().x; + }, + + getScrollHeight: function(){ + return this.getScrollSize().y; + }, + + getScrollWidth: function(){ + return this.getScrollSize().x; + }, + + getTop: function(){ + return this.getPosition().y; + }, + + getLeft: function(){ + return this.getPosition().x; + } + +}); + + +/* +Script: Selectors.js + Adds advanced CSS Querying capabilities for targeting elements. Also includes pseudoselectors support. + +License: + MIT-style license. +*/ + +Native.implement([Document, Element], { + + getElements: function(expression, nocash){ + expression = expression.split(','); + var items, local = {}; + for (var i = 0, l = expression.length; i < l; i++){ + var selector = expression[i], elements = Selectors.Utils.search(this, selector, local); + if (i != 0 && elements.item) elements = $A(elements); + items = (i == 0) ? elements : (items.item) ? $A(items).concat(elements) : items.concat(elements); + } + return new Elements(items, {ddup: (expression.length > 1), cash: !nocash}); + } + +}); + +Element.implement({ + + match: function(selector){ + if (!selector || (selector == this)) return true; + var tagid = Selectors.Utils.parseTagAndID(selector); + var tag = tagid[0], id = tagid[1]; + if (!Selectors.Filters.byID(this, id) || !Selectors.Filters.byTag(this, tag)) return false; + var parsed = Selectors.Utils.parseSelector(selector); + return (parsed) ? Selectors.Utils.filter(this, parsed, {}) : true; + } + +}); + +var Selectors = {Cache: {nth: {}, parsed: {}}}; + +Selectors.RegExps = { + id: (/#([\w-]+)/), + tag: (/^(\w+|\*)/), + quick: (/^(\w+|\*)$/), + splitter: (/\s*([+>~\s])\s*([a-zA-Z#.*:\[])/g), + combined: (/\.([\w-]+)|\[(\w+)(?:([!*^$~|]?=)(["']?)([^\4]*?)\4)?\]|:([\w-]+)(?:\(["']?(.*?)?["']?\)|$)/g) +}; + +Selectors.Utils = { + + chk: function(item, uniques){ + if (!uniques) return true; + var uid = $uid(item); + if (!uniques[uid]) return uniques[uid] = true; + return false; + }, + + parseNthArgument: function(argument){ + if (Selectors.Cache.nth[argument]) return Selectors.Cache.nth[argument]; + var parsed = argument.match(/^([+-]?\d*)?([a-z]+)?([+-]?\d*)?$/); + if (!parsed) return false; + var inta = parseInt(parsed[1]); + var a = (inta || inta === 0) ? inta : 1; + var special = parsed[2] || false; + var b = parseInt(parsed[3]) || 0; + if (a != 0){ + b--; + while (b < 1) b += a; + while (b >= a) b -= a; + } else { + a = b; + special = 'index'; + } + switch (special){ + case 'n': parsed = {a: a, b: b, special: 'n'}; break; + case 'odd': parsed = {a: 2, b: 0, special: 'n'}; break; + case 'even': parsed = {a: 2, b: 1, special: 'n'}; break; + case 'first': parsed = {a: 0, special: 'index'}; break; + case 'last': parsed = {special: 'last-child'}; break; + case 'only': parsed = {special: 'only-child'}; break; + default: parsed = {a: (a - 1), special: 'index'}; + } + + return Selectors.Cache.nth[argument] = parsed; + }, + + parseSelector: function(selector){ + if (Selectors.Cache.parsed[selector]) return Selectors.Cache.parsed[selector]; + var m, parsed = {classes: [], pseudos: [], attributes: []}; + while ((m = Selectors.RegExps.combined.exec(selector))){ + var cn = m[1], an = m[2], ao = m[3], av = m[5], pn = m[6], pa = m[7]; + if (cn){ + parsed.classes.push(cn); + } else if (pn){ + var parser = Selectors.Pseudo.get(pn); + if (parser) parsed.pseudos.push({parser: parser, argument: pa}); + else parsed.attributes.push({name: pn, operator: '=', value: pa}); + } else if (an){ + parsed.attributes.push({name: an, operator: ao, value: av}); + } + } + if (!parsed.classes.length) delete parsed.classes; + if (!parsed.attributes.length) delete parsed.attributes; + if (!parsed.pseudos.length) delete parsed.pseudos; + if (!parsed.classes && !parsed.attributes && !parsed.pseudos) parsed = null; + return Selectors.Cache.parsed[selector] = parsed; + }, + + parseTagAndID: function(selector){ + var tag = selector.match(Selectors.RegExps.tag); + var id = selector.match(Selectors.RegExps.id); + return [(tag) ? tag[1] : '*', (id) ? id[1] : false]; + }, + + filter: function(item, parsed, local){ + var i; + if (parsed.classes){ + for (i = parsed.classes.length; i--; i){ + var cn = parsed.classes[i]; + if (!Selectors.Filters.byClass(item, cn)) return false; + } + } + if (parsed.attributes){ + for (i = parsed.attributes.length; i--; i){ + var att = parsed.attributes[i]; + if (!Selectors.Filters.byAttribute(item, att.name, att.operator, att.value)) return false; + } + } + if (parsed.pseudos){ + for (i = parsed.pseudos.length; i--; i){ + var psd = parsed.pseudos[i]; + if (!Selectors.Filters.byPseudo(item, psd.parser, psd.argument, local)) return false; + } + } + return true; + }, + + getByTagAndID: function(ctx, tag, id){ + if (id){ + var item = (ctx.getElementById) ? ctx.getElementById(id, true) : Element.getElementById(ctx, id, true); + return (item && Selectors.Filters.byTag(item, tag)) ? [item] : []; + } else { + return ctx.getElementsByTagName(tag); + } + }, + + search: function(self, expression, local){ + var splitters = []; + + var selectors = expression.trim().replace(Selectors.RegExps.splitter, function(m0, m1, m2){ + splitters.push(m1); + return ':)' + m2; + }).split(':)'); + + var items, filtered, item; + + for (var i = 0, l = selectors.length; i < l; i++){ + + var selector = selectors[i]; + + if (i == 0 && Selectors.RegExps.quick.test(selector)){ + items = self.getElementsByTagName(selector); + continue; + } + + var splitter = splitters[i - 1]; + + var tagid = Selectors.Utils.parseTagAndID(selector); + var tag = tagid[0], id = tagid[1]; + + if (i == 0){ + items = Selectors.Utils.getByTagAndID(self, tag, id); + } else { + var uniques = {}, found = []; + for (var j = 0, k = items.length; j < k; j++) found = Selectors.Getters[splitter](found, items[j], tag, id, uniques); + items = found; + } + + var parsed = Selectors.Utils.parseSelector(selector); + + if (parsed){ + filtered = []; + for (var m = 0, n = items.length; m < n; m++){ + item = items[m]; + if (Selectors.Utils.filter(item, parsed, local)) filtered.push(item); + } + items = filtered; + } + + } + + return items; + + } + +}; + +Selectors.Getters = { + + ' ': function(found, self, tag, id, uniques){ + var items = Selectors.Utils.getByTagAndID(self, tag, id); + for (var i = 0, l = items.length; i < l; i++){ + var item = items[i]; + if (Selectors.Utils.chk(item, uniques)) found.push(item); + } + return found; + }, + + '>': function(found, self, tag, id, uniques){ + var children = Selectors.Utils.getByTagAndID(self, tag, id); + for (var i = 0, l = children.length; i < l; i++){ + var child = children[i]; + if (child.parentNode == self && Selectors.Utils.chk(child, uniques)) found.push(child); + } + return found; + }, + + '+': function(found, self, tag, id, uniques){ + while ((self = self.nextSibling)){ + if (self.nodeType == 1){ + if (Selectors.Utils.chk(self, uniques) && Selectors.Filters.byTag(self, tag) && Selectors.Filters.byID(self, id)) found.push(self); + break; + } + } + return found; + }, + + '~': function(found, self, tag, id, uniques){ + while ((self = self.nextSibling)){ + if (self.nodeType == 1){ + if (!Selectors.Utils.chk(self, uniques)) break; + if (Selectors.Filters.byTag(self, tag) && Selectors.Filters.byID(self, id)) found.push(self); + } + } + return found; + } + +}; + +Selectors.Filters = { + + byTag: function(self, tag){ + return (tag == '*' || (self.tagName && self.tagName.toLowerCase() == tag)); + }, + + byID: function(self, id){ + return (!id || (self.id && self.id == id)); + }, + + byClass: function(self, klass){ + return (self.className && self.className.contains(klass, ' ')); + }, + + byPseudo: function(self, parser, argument, local){ + return parser.call(self, argument, local); + }, + + byAttribute: function(self, name, operator, value){ + var result = Element.prototype.getProperty.call(self, name); + if (!result) return (operator == '!='); + if (!operator || value == undefined) return true; + switch (operator){ + case '=': return (result == value); + case '*=': return (result.contains(value)); + case '^=': return (result.substr(0, value.length) == value); + case '$=': return (result.substr(result.length - value.length) == value); + case '!=': return (result != value); + case '~=': return result.contains(value, ' '); + case '|=': return result.contains(value, '-'); + } + return false; + } + +}; + +Selectors.Pseudo = new Hash({ + + // w3c pseudo selectors + + checked: function(){ + return this.checked; + }, + + empty: function(){ + return !(this.innerText || this.textContent || '').length; + }, + + not: function(selector){ + return !Element.match(this, selector); + }, + + contains: function(text){ + return (this.innerText || this.textContent || '').contains(text); + }, + + 'first-child': function(){ + return Selectors.Pseudo.index.call(this, 0); + }, + + 'last-child': function(){ + var element = this; + while ((element = element.nextSibling)){ + if (element.nodeType == 1) return false; + } + return true; + }, + + 'only-child': function(){ + var prev = this; + while ((prev = prev.previousSibling)){ + if (prev.nodeType == 1) return false; + } + var next = this; + while ((next = next.nextSibling)){ + if (next.nodeType == 1) return false; + } + return true; + }, + + 'nth-child': function(argument, local){ + argument = (argument == undefined) ? 'n' : argument; + var parsed = Selectors.Utils.parseNthArgument(argument); + if (parsed.special != 'n') return Selectors.Pseudo[parsed.special].call(this, parsed.a, local); + var count = 0; + local.positions = local.positions || {}; + var uid = $uid(this); + if (!local.positions[uid]){ + var self = this; + while ((self = self.previousSibling)){ + if (self.nodeType != 1) continue; + count ++; + var position = local.positions[$uid(self)]; + if (position != undefined){ + count = position + count; + break; + } + } + local.positions[uid] = count; + } + return (local.positions[uid] % parsed.a == parsed.b); + }, + + // custom pseudo selectors + + index: function(index){ + var element = this, count = 0; + while ((element = element.previousSibling)){ + if (element.nodeType == 1 && ++count > index) return false; + } + return (count == index); + }, + + even: function(argument, local){ + return Selectors.Pseudo['nth-child'].call(this, '2n+1', local); + }, + + odd: function(argument, local){ + return Selectors.Pseudo['nth-child'].call(this, '2n', local); + } + +}); + + +/* +Script: Domready.js + Contains the domready custom event. + +License: + MIT-style license. +*/ + +Element.Events.domready = { + + onAdd: function(fn){ + if (Browser.loaded) fn.call(this); + } + +}; + +(function(){ + + var domready = function(){ + if (Browser.loaded) return; + Browser.loaded = true; + window.fireEvent('domready'); + document.fireEvent('domready'); + }; + + if (Browser.Engine.trident){ + var temp = document.createElement('div'); + (function(){ + ($try(function(){ + temp.doScroll('left'); + return $(temp).inject(document.body).set('html', 'temp').dispose(); + })) ? domready() : arguments.callee.delay(50); + })(); + } else if (Browser.Engine.webkit && Browser.Engine.version < 525){ + (function(){ + (['loaded', 'complete'].contains(document.readyState)) ? domready() : arguments.callee.delay(50); + })(); + } else { + window.addEvent('load', domready); + document.addEvent('DOMContentLoaded', domready); + } + +})(); + + +/* +Script: JSON.js + JSON encoder and decoder. + +License: + MIT-style license. + +See Also: + <http://www.json.org/> +*/ + +var JSON = new Hash({ + + $specialChars: {'\b': '\\b', '\t': '\\t', '\n': '\\n', '\f': '\\f', '\r': '\\r', '"' : '\\"', '\\': '\\\\'}, + + $replaceChars: function(chr){ + return JSON.$specialChars[chr] || '\\u00' + Math.floor(chr.charCodeAt() / 16).toString(16) + (chr.charCodeAt() % 16).toString(16); + }, + + encode: function(obj){ + switch ($type(obj)){ + case 'string': + return '"' + obj.replace(/[\x00-\x1f\\"]/g, JSON.$replaceChars) + '"'; + case 'array': + return '[' + String(obj.map(JSON.encode).filter($defined)) + ']'; + case 'object': case 'hash': + var string = []; + Hash.each(obj, function(value, key){ + var json = JSON.encode(value); + if (json) string.push(JSON.encode(key) + ':' + json); + }); + return '{' + string + '}'; + case 'number': case 'boolean': return String(obj); + case false: return 'null'; + } + return null; + }, + + decode: function(string, secure){ + if ($type(string) != 'string' || !string.length) return null; + if (secure && !(/^[,:{}\[\]0-9.\-+Eaeflnr-u \n\r\t]*$/).test(string.replace(/\\./g, '@').replace(/"[^"\\\n\r]*"/g, ''))) return null; + return eval('(' + string + ')'); + } + +}); + +Native.implement([Hash, Array, String, Number], { + + toJSON: function(){ + return JSON.encode(this); + } + +}); + + +/* +Script: Cookie.js + Class for creating, loading, and saving browser Cookies. + +License: + MIT-style license. + +Credits: + Based on the functions by Peter-Paul Koch (http://quirksmode.org). +*/ + +var Cookie = new Class({ + + Implements: Options, + + options: { + path: false, + domain: false, + duration: false, + secure: false, + document: document + }, + + initialize: function(key, options){ + this.key = key; + this.setOptions(options); + }, + + write: function(value){ + value = encodeURIComponent(value); + if (this.options.domain) value += '; domain=' + this.options.domain; + if (this.options.path) value += '; path=' + this.options.path; + if (this.options.duration){ + var date = new Date(); + date.setTime(date.getTime() + this.options.duration * 24 * 60 * 60 * 1000); + value += '; expires=' + date.toGMTString(); + } + if (this.options.secure) value += '; secure'; + this.options.document.cookie = this.key + '=' + value; + return this; + }, + + read: function(){ + var value = this.options.document.cookie.match('(?:^|;)\\s*' + this.key.escapeRegExp() + '=([^;]*)'); + return (value) ? decodeURIComponent(value[1]) : null; + }, + + dispose: function(){ + new Cookie(this.key, $merge(this.options, {duration: -1})).write(''); + return this; + } + +}); + +Cookie.write = function(key, value, options){ + return new Cookie(key, options).write(value); +}; + +Cookie.read = function(key){ + return new Cookie(key).read(); +}; + +Cookie.dispose = function(key, options){ + return new Cookie(key, options).dispose(); +}; + + +/* +Script: Swiff.js + Wrapper for embedding SWF movies. Supports (and fixes) External Interface Communication. + +License: + MIT-style license. + +Credits: + Flash detection & Internet Explorer + Flash Player 9 fix inspired by SWFObject. +*/ + +var Swiff = new Class({ + + Implements: [Options], + + options: { + id: null, + height: 1, + width: 1, + container: null, + properties: {}, + params: { + quality: 'high', + allowScriptAccess: 'always', + wMode: 'transparent', + swLiveConnect: true + }, + callBacks: {}, + vars: {} + }, + + toElement: function(){ + return this.object; + }, + + initialize: function(path, options){ + this.instance = 'Swiff_' + $time(); + + this.setOptions(options); + options = this.options; + var id = this.id = options.id || this.instance; + var container = $(options.container); + + Swiff.CallBacks[this.instance] = {}; + + var params = options.params, vars = options.vars, callBacks = options.callBacks; + var properties = $extend({height: options.height, width: options.width}, options.properties); + + var self = this; + + for (var callBack in callBacks){ + Swiff.CallBacks[this.instance][callBack] = (function(option){ + return function(){ + return option.apply(self.object, arguments); + }; + })(callBacks[callBack]); + vars[callBack] = 'Swiff.CallBacks.' + this.instance + '.' + callBack; + } + + params.flashVars = Hash.toQueryString(vars); + if (Browser.Engine.trident){ + properties.classid = 'clsid:D27CDB6E-AE6D-11cf-96B8-444553540000'; + params.movie = path; + } else { + properties.type = 'application/x-shockwave-flash'; + properties.data = path; + } + var build = '<object id="' + id + '"'; + for (var property in properties) build += ' ' + property + '="' + properties[property] + '"'; + build += '>'; + for (var param in params){ + if (params[param]) build += '<param name="' + param + '" value="' + params[param] + '" />'; + } + build += '</object>'; + this.object = ((container) ? container.empty() : new Element('div')).set('html', build).firstChild; + }, + + replaces: function(element){ + element = $(element, true); + element.parentNode.replaceChild(this.toElement(), element); + return this; + }, + + inject: function(element){ + $(element, true).appendChild(this.toElement()); + return this; + }, + + remote: function(){ + return Swiff.remote.apply(Swiff, [this.toElement()].extend(arguments)); + } + +}); + +Swiff.CallBacks = {}; + +Swiff.remote = function(obj, fn){ + var rs = obj.CallFunction('<invoke name="' + fn + '" returntype="javascript">' + __flash__argumentsToXML(arguments, 2) + '</invoke>'); + return eval(rs); +}; + + +/* +Script: Fx.js + Contains the basic animation logic to be extended by all other Fx Classes. + +License: + MIT-style license. +*/ + +var Fx = new Class({ + + Implements: [Chain, Events, Options], + + options: { + /* + onStart: $empty, + onCancel: $empty, + onComplete: $empty, + */ + fps: 50, + unit: false, + duration: 500, + link: 'ignore' + }, + + initialize: function(options){ + this.subject = this.subject || this; + this.setOptions(options); + this.options.duration = Fx.Durations[this.options.duration] || this.options.duration.toInt(); + var wait = this.options.wait; + if (wait === false) this.options.link = 'cancel'; + }, + + getTransition: function(){ + return function(p){ + return -(Math.cos(Math.PI * p) - 1) / 2; + }; + }, + + step: function(){ + var time = $time(); + if (time < this.time + this.options.duration){ + var delta = this.transition((time - this.time) / this.options.duration); + this.set(this.compute(this.from, this.to, delta)); + } else { + this.set(this.compute(this.from, this.to, 1)); + this.complete(); + } + }, + + set: function(now){ + return now; + }, + + compute: function(from, to, delta){ + return Fx.compute(from, to, delta); + }, + + check: function(caller){ + if (!this.timer) return true; + switch (this.options.link){ + case 'cancel': this.cancel(); return true; + case 'chain': this.chain(caller.bind(this, Array.slice(arguments, 1))); return false; + } + return false; + }, + + start: function(from, to){ + if (!this.check(arguments.callee, from, to)) return this; + this.from = from; + this.to = to; + this.time = 0; + this.transition = this.getTransition(); + this.startTimer(); + this.onStart(); + return this; + }, + + complete: function(){ + if (this.stopTimer()) this.onComplete(); + return this; + }, + + cancel: function(){ + if (this.stopTimer()) this.onCancel(); + return this; + }, + + onStart: function(){ + this.fireEvent('start', this.subject); + }, + + onComplete: function(){ + this.fireEvent('complete', this.subject); + if (!this.callChain()) this.fireEvent('chainComplete', this.subject); + }, + + onCancel: function(){ + this.fireEvent('cancel', this.subject).clearChain(); + }, + + pause: function(){ + this.stopTimer(); + return this; + }, + + resume: function(){ + this.startTimer(); + return this; + }, + + stopTimer: function(){ + if (!this.timer) return false; + this.time = $time() - this.time; + this.timer = $clear(this.timer); + return true; + }, + + startTimer: function(){ + if (this.timer) return false; + this.time = $time() - this.time; + this.timer = this.step.periodical(Math.round(1000 / this.options.fps), this); + return true; + } + +}); + +Fx.compute = function(from, to, delta){ + return (to - from) * delta + from; +}; + +Fx.Durations = {'short': 250, 'normal': 500, 'long': 1000}; + + +/* +Script: Fx.CSS.js + Contains the CSS animation logic. Used by Fx.Tween, Fx.Morph, Fx.Elements. + +License: + MIT-style license. +*/ + +Fx.CSS = new Class({ + + Extends: Fx, + + //prepares the base from/to object + + prepare: function(element, property, values){ + values = $splat(values); + var values1 = values[1]; + if (!$chk(values1)){ + values[1] = values[0]; + values[0] = element.getStyle(property); + } + var parsed = values.map(this.parse); + return {from: parsed[0], to: parsed[1]}; + }, + + //parses a value into an array + + parse: function(value){ + value = $lambda(value)(); + value = (typeof value == 'string') ? value.split(' ') : $splat(value); + return value.map(function(val){ + val = String(val); + var found = false; + Fx.CSS.Parsers.each(function(parser, key){ + if (found) return; + var parsed = parser.parse(val); + if ($chk(parsed)) found = {value: parsed, parser: parser}; + }); + found = found || {value: val, parser: Fx.CSS.Parsers.String}; + return found; + }); + }, + + //computes by a from and to prepared objects, using their parsers. + + compute: function(from, to, delta){ + var computed = []; + (Math.min(from.length, to.length)).times(function(i){ + computed.push({value: from[i].parser.compute(from[i].value, to[i].value, delta), parser: from[i].parser}); + }); + computed.$family = {name: 'fx:css:value'}; + return computed; + }, + + //serves the value as settable + + serve: function(value, unit){ + if ($type(value) != 'fx:css:value') value = this.parse(value); + var returned = []; + value.each(function(bit){ + returned = returned.concat(bit.parser.serve(bit.value, unit)); + }); + return returned; + }, + + //renders the change to an element + + render: function(element, property, value, unit){ + element.setStyle(property, this.serve(value, unit)); + }, + + //searches inside the page css to find the values for a selector + + search: function(selector){ + if (Fx.CSS.Cache[selector]) return Fx.CSS.Cache[selector]; + var to = {}; + Array.each(document.styleSheets, function(sheet, j){ + var href = sheet.href; + if (href && href.contains('://') && !href.contains(document.domain)) return; + var rules = sheet.rules || sheet.cssRules; + Array.each(rules, function(rule, i){ + if (!rule.style) return; + var selectorText = (rule.selectorText) ? rule.selectorText.replace(/^\w+/, function(m){ + return m.toLowerCase(); + }) : null; + if (!selectorText || !selectorText.test('^' + selector + '$')) return; + Element.Styles.each(function(value, style){ + if (!rule.style[style] || Element.ShortStyles[style]) return; + value = String(rule.style[style]); + to[style] = (value.test(/^rgb/)) ? value.rgbToHex() : value; + }); + }); + }); + return Fx.CSS.Cache[selector] = to; + } + +}); + +Fx.CSS.Cache = {}; + +Fx.CSS.Parsers = new Hash({ + + Color: { + parse: function(value){ + if (value.match(/^#[0-9a-f]{3,6}$/i)) return value.hexToRgb(true); + return ((value = value.match(/(\d+),\s*(\d+),\s*(\d+)/))) ? [value[1], value[2], value[3]] : false; + }, + compute: function(from, to, delta){ + return from.map(function(value, i){ + return Math.round(Fx.compute(from[i], to[i], delta)); + }); + }, + serve: function(value){ + return value.map(Number); + } + }, + + Number: { + parse: parseFloat, + compute: Fx.compute, + serve: function(value, unit){ + return (unit) ? value + unit : value; + } + }, + + String: { + parse: $lambda(false), + compute: $arguments(1), + serve: $arguments(0) + } + +}); + + +/* +Script: Fx.Tween.js + Formerly Fx.Style, effect to transition any CSS property for an element. + +License: + MIT-style license. +*/ + +Fx.Tween = new Class({ + + Extends: Fx.CSS, + + initialize: function(element, options){ + this.element = this.subject = $(element); + this.parent(options); + }, + + set: function(property, now){ + if (arguments.length == 1){ + now = property; + property = this.property || this.options.property; + } + this.render(this.element, property, now, this.options.unit); + return this; + }, + + start: function(property, from, to){ + if (!this.check(arguments.callee, property, from, to)) return this; + var args = Array.flatten(arguments); + this.property = this.options.property || args.shift(); + var parsed = this.prepare(this.element, this.property, args); + return this.parent(parsed.from, parsed.to); + } + +}); + +Element.Properties.tween = { + + set: function(options){ + var tween = this.retrieve('tween'); + if (tween) tween.cancel(); + return this.eliminate('tween').store('tween:options', $extend({link: 'cancel'}, options)); + }, + + get: function(options){ + if (options || !this.retrieve('tween')){ + if (options || !this.retrieve('tween:options')) this.set('tween', options); + this.store('tween', new Fx.Tween(this, this.retrieve('tween:options'))); + } + return this.retrieve('tween'); + } + +}; + +Element.implement({ + + tween: function(property, from, to){ + this.get('tween').start(arguments); + return this; + }, + + fade: function(how){ + var fade = this.get('tween'), o = 'opacity', toggle; + how = $pick(how, 'toggle'); + switch (how){ + case 'in': fade.start(o, 1); break; + case 'out': fade.start(o, 0); break; + case 'show': fade.set(o, 1); break; + case 'hide': fade.set(o, 0); break; + case 'toggle': + var flag = this.retrieve('fade:flag', this.get('opacity') == 1); + fade.start(o, (flag) ? 0 : 1); + this.store('fade:flag', !flag); + toggle = true; + break; + default: fade.start(o, arguments); + } + if (!toggle) this.eliminate('fade:flag'); + return this; + }, + + highlight: function(start, end){ + if (!end){ + end = this.retrieve('highlight:original', this.getStyle('background-color')); + end = (end == 'transparent') ? '#fff' : end; + } + var tween = this.get('tween'); + tween.start('background-color', start || '#ffff88', end).chain(function(){ + this.setStyle('background-color', this.retrieve('highlight:original')); + tween.callChain(); + }.bind(this)); + return this; + } + +}); + + +/* +Script: Fx.Morph.js + Formerly Fx.Styles, effect to transition any number of CSS properties for an element using an object of rules, or CSS based selector rules. + +License: + MIT-style license. +*/ + +Fx.Morph = new Class({ + + Extends: Fx.CSS, + + initialize: function(element, options){ + this.element = this.subject = $(element); + this.parent(options); + }, + + set: function(now){ + if (typeof now == 'string') now = this.search(now); + for (var p in now) this.render(this.element, p, now[p], this.options.unit); + return this; + }, + + compute: function(from, to, delta){ + var now = {}; + for (var p in from) now[p] = this.parent(from[p], to[p], delta); + return now; + }, + + start: function(properties){ + if (!this.check(arguments.callee, properties)) return this; + if (typeof properties == 'string') properties = this.search(properties); + var from = {}, to = {}; + for (var p in properties){ + var parsed = this.prepare(this.element, p, properties[p]); + from[p] = parsed.from; + to[p] = parsed.to; + } + return this.parent(from, to); + } + +}); + +Element.Properties.morph = { + + set: function(options){ + var morph = this.retrieve('morph'); + if (morph) morph.cancel(); + return this.eliminate('morph').store('morph:options', $extend({link: 'cancel'}, options)); + }, + + get: function(options){ + if (options || !this.retrieve('morph')){ + if (options || !this.retrieve('morph:options')) this.set('morph', options); + this.store('morph', new Fx.Morph(this, this.retrieve('morph:options'))); + } + return this.retrieve('morph'); + } + +}; + +Element.implement({ + + morph: function(props){ + this.get('morph').start(props); + return this; + } + +}); + + +/* +Script: Fx.Transitions.js + Contains a set of advanced transitions to be used with any of the Fx Classes. + +License: + MIT-style license. + +Credits: + Easing Equations by Robert Penner, <http://www.robertpenner.com/easing/>, modified and optimized to be used with MooTools. +*/ + +Fx.implement({ + + getTransition: function(){ + var trans = this.options.transition || Fx.Transitions.Sine.easeInOut; + if (typeof trans == 'string'){ + var data = trans.split(':'); + trans = Fx.Transitions; + trans = trans[data[0]] || trans[data[0].capitalize()]; + if (data[1]) trans = trans['ease' + data[1].capitalize() + (data[2] ? data[2].capitalize() : '')]; + } + return trans; + } + +}); + +Fx.Transition = function(transition, params){ + params = $splat(params); + return $extend(transition, { + easeIn: function(pos){ + return transition(pos, params); + }, + easeOut: function(pos){ + return 1 - transition(1 - pos, params); + }, + easeInOut: function(pos){ + return (pos <= 0.5) ? transition(2 * pos, params) / 2 : (2 - transition(2 * (1 - pos), params)) / 2; + } + }); +}; + +Fx.Transitions = new Hash({ + + linear: $arguments(0) + +}); + +Fx.Transitions.extend = function(transitions){ + for (var transition in transitions) Fx.Transitions[transition] = new Fx.Transition(transitions[transition]); +}; + +Fx.Transitions.extend({ + + Pow: function(p, x){ + return Math.pow(p, x[0] || 6); + }, + + Expo: function(p){ + return Math.pow(2, 8 * (p - 1)); + }, + + Circ: function(p){ + return 1 - Math.sin(Math.acos(p)); + }, + + Sine: function(p){ + return 1 - Math.sin((1 - p) * Math.PI / 2); + }, + + Back: function(p, x){ + x = x[0] || 1.618; + return Math.pow(p, 2) * ((x + 1) * p - x); + }, + + Bounce: function(p){ + var value; + for (var a = 0, b = 1; 1; a += b, b /= 2){ + if (p >= (7 - 4 * a) / 11){ + value = b * b - Math.pow((11 - 6 * a - 11 * p) / 4, 2); + break; + } + } + return value; + }, + + Elastic: function(p, x){ + return Math.pow(2, 10 * --p) * Math.cos(20 * p * Math.PI * (x[0] || 1) / 3); + } + +}); + +['Quad', 'Cubic', 'Quart', 'Quint'].each(function(transition, i){ + Fx.Transitions[transition] = new Fx.Transition(function(p){ + return Math.pow(p, [i + 2]); + }); +}); + + +/* +Script: Request.js + Powerful all purpose Request Class. Uses XMLHTTPRequest. + +License: + MIT-style license. +*/ + +var Request = new Class({ + + Implements: [Chain, Events, Options], + + options: {/* + onRequest: $empty, + onComplete: $empty, + onCancel: $empty, + onSuccess: $empty, + onFailure: $empty, + onException: $empty,*/ + url: '', + data: '', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'text/javascript, text/html, application/xml, text/xml, */*' + }, + async: true, + format: false, + method: 'post', + link: 'ignore', + isSuccess: null, + emulation: true, + urlEncoded: true, + encoding: 'utf-8', + evalScripts: false, + evalResponse: false + }, + + initialize: function(options){ + this.xhr = new Browser.Request(); + this.setOptions(options); + this.options.isSuccess = this.options.isSuccess || this.isSuccess; + this.headers = new Hash(this.options.headers); + }, + + onStateChange: function(){ + if (this.xhr.readyState != 4 || !this.running) return; + this.running = false; + this.status = 0; + $try(function(){ + this.status = this.xhr.status; + }.bind(this)); + if (this.options.isSuccess.call(this, this.status)){ + this.response = {text: this.xhr.responseText, xml: this.xhr.responseXML}; + this.success(this.response.text, this.response.xml); + } else { + this.response = {text: null, xml: null}; + this.failure(); + } + this.xhr.onreadystatechange = $empty; + }, + + isSuccess: function(){ + return ((this.status >= 200) && (this.status < 300)); + }, + + processScripts: function(text){ + if (this.options.evalResponse || (/(ecma|java)script/).test(this.getHeader('Content-type'))) return $exec(text); + return text.stripScripts(this.options.evalScripts); + }, + + success: function(text, xml){ + this.onSuccess(this.processScripts(text), xml); + }, + + onSuccess: function(){ + this.fireEvent('complete', arguments).fireEvent('success', arguments).callChain(); + }, + + failure: function(){ + this.onFailure(); + }, + + onFailure: function(){ + this.fireEvent('complete').fireEvent('failure', this.xhr); + }, + + setHeader: function(name, value){ + this.headers.set(name, value); + return this; + }, + + getHeader: function(name){ + return $try(function(){ + return this.xhr.getResponseHeader(name); + }.bind(this)); + }, + + check: function(caller){ + if (!this.running) return true; + switch (this.options.link){ + case 'cancel': this.cancel(); return true; + case 'chain': this.chain(caller.bind(this, Array.slice(arguments, 1))); return false; + } + return false; + }, + + send: function(options){ + if (!this.check(arguments.callee, options)) return this; + this.running = true; + + var type = $type(options); + if (type == 'string' || type == 'element') options = {data: options}; + + var old = this.options; + options = $extend({data: old.data, url: old.url, method: old.method}, options); + var data = options.data, url = options.url, method = options.method; + + switch ($type(data)){ + case 'element': data = $(data).toQueryString(); break; + case 'object': case 'hash': data = Hash.toQueryString(data); + } + + if (this.options.format){ + var format = 'format=' + this.options.format; + data = (data) ? format + '&' + data : format; + } + + if (this.options.emulation && ['put', 'delete'].contains(method)){ + var _method = '_method=' + method; + data = (data) ? _method + '&' + data : _method; + method = 'post'; + } + + if (this.options.urlEncoded && method == 'post'){ + var encoding = (this.options.encoding) ? '; charset=' + this.options.encoding : ''; + this.headers.set('Content-type', 'application/x-www-form-urlencoded' + encoding); + } + + if (data && method == 'get'){ + url = url + (url.contains('?') ? '&' : '?') + data; + data = null; + } + + this.xhr.open(method.toUpperCase(), url, this.options.async); + + this.xhr.onreadystatechange = this.onStateChange.bind(this); + + this.headers.each(function(value, key){ + try { + this.xhr.setRequestHeader(key, value); + } catch (e){ + this.fireEvent('exception', [key, value]); + } + }, this); + + this.fireEvent('request'); + this.xhr.send(data); + if (!this.options.async) this.onStateChange(); + return this; + }, + + cancel: function(){ + if (!this.running) return this; + this.running = false; + this.xhr.abort(); + this.xhr.onreadystatechange = $empty; + this.xhr = new Browser.Request(); + this.fireEvent('cancel'); + return this; + } + +}); + +(function(){ + +var methods = {}; +['get', 'post', 'put', 'delete', 'GET', 'POST', 'PUT', 'DELETE'].each(function(method){ + methods[method] = function(){ + var params = Array.link(arguments, {url: String.type, data: $defined}); + return this.send($extend(params, {method: method.toLowerCase()})); + }; +}); + +Request.implement(methods); + +})(); + +Element.Properties.send = { + + set: function(options){ + var send = this.retrieve('send'); + if (send) send.cancel(); + return this.eliminate('send').store('send:options', $extend({ + data: this, link: 'cancel', method: this.get('method') || 'post', url: this.get('action') + }, options)); + }, + + get: function(options){ + if (options || !this.retrieve('send')){ + if (options || !this.retrieve('send:options')) this.set('send', options); + this.store('send', new Request(this.retrieve('send:options'))); + } + return this.retrieve('send'); + } + +}; + +Element.implement({ + + send: function(url){ + var sender = this.get('send'); + sender.send({data: this, url: url || sender.options.url}); + return this; + } + +}); + + +/* +Script: Request.HTML.js + Extends the basic Request Class with additional methods for interacting with HTML responses. + +License: + MIT-style license. +*/ + +Request.HTML = new Class({ + + Extends: Request, + + options: { + update: false, + evalScripts: true, + filter: false + }, + + processHTML: function(text){ + var match = text.match(/<body[^>]*>([\s\S]*?)<\/body>/i); + text = (match) ? match[1] : text; + + var container = new Element('div'); + + return $try(function(){ + var root = '<root>' + text + '</root>', doc; + if (Browser.Engine.trident){ + doc = new ActiveXObject('Microsoft.XMLDOM'); + doc.async = false; + doc.loadXML(root); + } else { + doc = new DOMParser().parseFromString(root, 'text/xml'); + } + root = doc.getElementsByTagName('root')[0]; + for (var i = 0, k = root.childNodes.length; i < k; i++){ + var child = Element.clone(root.childNodes[i], true, true); + if (child) container.grab(child); + } + return container; + }) || container.set('html', text); + }, + + success: function(text){ + var options = this.options, response = this.response; + + response.html = text.stripScripts(function(script){ + response.javascript = script; + }); + + var temp = this.processHTML(response.html); + + response.tree = temp.childNodes; + response.elements = temp.getElements('*'); + + if (options.filter) response.tree = response.elements.filter(options.filter); + if (options.update) $(options.update).empty().set('html', response.html); + if (options.evalScripts) $exec(response.javascript); + + this.onSuccess(response.tree, response.elements, response.html, response.javascript); + } + +}); + +Element.Properties.load = { + + set: function(options){ + var load = this.retrieve('load'); + if (load) load.cancel(); + return this.eliminate('load').store('load:options', $extend({data: this, link: 'cancel', update: this, method: 'get'}, options)); + }, + + get: function(options){ + if (options || ! this.retrieve('load')){ + if (options || !this.retrieve('load:options')) this.set('load', options); + this.store('load', new Request.HTML(this.retrieve('load:options'))); + } + return this.retrieve('load'); + } + +}; + +Element.implement({ + + load: function(){ + this.get('load').send(Array.link(arguments, {data: Object.type, url: String.type})); + return this; + } + +}); + + +/* +Script: Request.JSON.js + Extends the basic Request Class with additional methods for sending and receiving JSON data. + +License: + MIT-style license. +*/ + +Request.JSON = new Class({ + + Extends: Request, + + options: { + secure: true + }, + + initialize: function(options){ + this.parent(options); + this.headers.extend({'Accept': 'application/json', 'X-Request': 'JSON'}); + }, + + success: function(text){ + this.response.json = JSON.decode(text, this.options.secure); + this.onSuccess(this.response.json, text); + } + +}); diff --git a/ipawebui/templates/__init__.py b/ipawebui/templates/__init__.py new file mode 100644 index 000000000..10d4cf8ca --- /dev/null +++ b/ipawebui/templates/__init__.py @@ -0,0 +1,21 @@ +# Authors: Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Sub-package containing Kid templates. +""" diff --git a/ipawebui/templates/form.kid b/ipawebui/templates/form.kid new file mode 100644 index 000000000..640157005 --- /dev/null +++ b/ipawebui/templates/form.kid @@ -0,0 +1,16 @@ +<?xml version='1.0' encoding='utf-8'?> +<html xmlns:py="http://purl.org/kid/ns#"> + +<head> + <title>Hello</title> +</head> + +<body> + <table> + <tr py:for="param in command.params()"> + <td py:content="param.name"/> + </tr> + </table> +</body> + +</html> diff --git a/ipawebui/templates/main.kid b/ipawebui/templates/main.kid new file mode 100644 index 000000000..692f2b575 --- /dev/null +++ b/ipawebui/templates/main.kid @@ -0,0 +1,14 @@ +<?xml version='1.0' encoding='utf-8'?> +<html xmlns:py="http://purl.org/kid/ns#"> + +<head> + <title>FreeIPA</title> +</head> + +<body> + <p py:for="name in api.Command"> + <a href="${name}" py:content="name"/> + </p> +</body> + +</html> diff --git a/lite-webui.py b/lite-webui.py new file mode 100755 index 000000000..74f6e0f70 --- /dev/null +++ b/lite-webui.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python + +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +In-tree Web UI using cherrypy. +""" + +from cherrypy import expose, config, quickstart +from ipawebui.templates import form, main +from ipawebui import controller +from ipalib import api + +api.load_plugins() +api.finalize() + + +class root(object): + index = controller.Index(api, main) + + def __init__(self): + for cmd in api.Command(): + ctr = controller.Command(cmd, form) + setattr(self, cmd.name, ctr) + + +if __name__ == '__main__': + quickstart(root()) diff --git a/lite-xmlrpc.py b/lite-xmlrpc.py new file mode 100755 index 000000000..ee03adae7 --- /dev/null +++ b/lite-xmlrpc.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python + +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +In-tree XML-RPC server using SimpleXMLRPCServer. +""" + +import sys +import SimpleXMLRPCServer +import logging +import xmlrpclib +import re +import threading +import commands +from ipalib import api +from ipalib import config +from ipaserver import conn +from ipaserver.servercore import context +from ipalib.util import xmlrpc_unmarshal +import traceback +import krbV + +class StoppableXMLRPCServer(SimpleXMLRPCServer.SimpleXMLRPCServer): + """Override of TIME_WAIT""" + allow_reuse_address = True + + def serve_forever(self): + self.stop = False + while not self.stop: + self.handle_request() + +class LoggingSimpleXMLRPCRequestHandler(SimpleXMLRPCServer.SimpleXMLRPCRequestHandler): + """Overides the default SimpleXMLRPCRequestHander to support logging. + Logs client IP and the XML request and response. + """ + + def parse(self, given): + """Convert the incoming arguments into the format IPA expects""" + args = [] + kw = {} + for g in given: + kw[g] = unicode(given[g]) + return (args, kw) + + def _dispatch(self, method, params): + """ + Dispatches the XML-RPC method. + + Methods beginning with an '_' are considered private and will + not be called. + """ + if method not in funcs: + logger.error('no such method %r', method) + raise Exception('method "%s" is not supported' % method) + func = funcs[method] + krbccache = krbV.default_context().default_ccache().name + context.conn = conn.IPAConn( + api.env.ldap_host, + api.env.ldap_port, + krbccache, + ) + logger.info('calling %s', method) + (args, kw) = xmlrpc_unmarshal(*params) + return func(*args, **kw) + + def _marshaled_dispatch(self, data, dispatch_method = None): + try: + params, method = xmlrpclib.loads(data) + + # generate response + if dispatch_method is not None: + response = dispatch_method(method, params) + else: + response = self._dispatch(method, params) + # wrap response in a singleton tuple + response = (response,) + response = xmlrpclib.dumps(response, methodresponse=1) + except: + # report exception back to client. This is needed to report + # tracebacks found in server code. + e_class, e = sys.exc_info()[:2] + # FIXME, need to get this number from somewhere... + faultCode = getattr(e_class,'faultCode',1) + tb_str = ''.join(traceback.format_exception(*sys.exc_info())) + faultString = tb_str + response = xmlrpclib.dumps(xmlrpclib.Fault(faultCode, faultString)) + + return response + + def do_POST(self): + clientIP, port = self.client_address + # Log client IP and Port + logger.info('Client IP: %s - Port: %s' % (clientIP, port)) + try: + # get arguments + data = self.rfile.read(int(self.headers["content-length"])) + + # unmarshal the XML data + params, method = xmlrpclib.loads(data) + logger.info('Call to %s(%s) from %s:%s', method, + ', '.join(repr(p) for p in params), + clientIP, port + ) + + # Log client request + logger.debug('Client request: \n%s\n' % data) + + response = self._marshaled_dispatch( + data, getattr(self, '_dispatch', None)) + + # Log server response + logger.debug('Server response: \n%s\n' % response) + except Exception, e: + # This should only happen if the module is buggy + # internal error, report as HTTP server error + print e + self.send_response(500) + self.end_headers() + else: + # got a valid XML-RPC response + self.send_response(200) + self.send_header("Content-type", "text/xml") + self.send_header("Content-length", str(len(response))) + self.end_headers() + self.wfile.write(response) + + # shut down the connection + self.wfile.flush() + self.connection.shutdown(1) + + +if __name__ == '__main__': + api.bootstrap_with_global_options(context='server') + api.finalize() + logger = api.log + + # Set up the server + XMLRPCServer = StoppableXMLRPCServer( + ('', api.env.lite_xmlrpc_port), + LoggingSimpleXMLRPCRequestHandler + ) + XMLRPCServer.register_introspection_functions() + + # Get and register all the methods + + for cmd in api.Command: + logger.debug('registering %s', cmd) + XMLRPCServer.register_function(api.Command[cmd], cmd) + funcs = XMLRPCServer.funcs + + logger.info('Logging to file %r', api.env.log) + logger.info('Listening on port %d', api.env.lite_xmlrpc_port) + try: + XMLRPCServer.serve_forever() + except KeyboardInterrupt: + XMLRPCServer.server_close() + logger.info('Server shutdown.') diff --git a/make-doc b/make-doc new file mode 100755 index 000000000..a9376e8a7 --- /dev/null +++ b/make-doc @@ -0,0 +1,29 @@ +#!/bin/bash + +# Hackish script to generate documentation using epydoc + +sources="ipalib ipaserver ipawebui tests" +out="./freeipa2-dev-doc" + +init="./ipalib/__init__.py" +echo "Looking for $init" +if [[ ! -f $init ]] +then + echo "Error: You do not appear to be in the project directory" + exit 1 +fi +echo "You appear to be in the project directory" + +# Documentation +if [[ -d $out ]] +then + echo "Removing old $out directory" + rm -r $out +fi +echo "Creating documentation in $out" + +epydoc -v --html --no-frames --include-log \ + --name="FreeIPA v2 developer documentation" \ + --docformat=restructuredtext \ + --output=$out \ + $sources diff --git a/make-test b/make-test new file mode 100755 index 000000000..1a401635c --- /dev/null +++ b/make-test @@ -0,0 +1,32 @@ +#!/bin/bash + +# Script to run nosetests under multiple versions of Python + +versions="python2.4 python2.5 python2.6" + +for name in $versions +do + executable="/usr/bin/$name" + if [[ -f $executable ]]; then + echo "[ $name: Starting tests... ]" + ((runs += 1)) + if $executable /usr/bin/nosetests -v --with-doctest + then + echo "[ $name: Tests OK ]" + else + echo "[ $name: Tests FAILED ]" + ((failures += 1)) + fi + else + echo "[ $name: Not found ]" + fi + echo "" +done + +if [ $failures ]; then + echo "[ Ran under $runs version(s); FAILED under $failures version(s) ]" + echo "FAIL!" + exit $failures +else + echo "[ Ran under $runs version(s); all OK ]" +fi diff --git a/setup.py b/setup.py new file mode 100755 index 000000000..ad903748a --- /dev/null +++ b/setup.py @@ -0,0 +1,46 @@ +#!/usr/bin/python + +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Python-level packaging using distutils. +""" + +from distutils.core import setup + +setup( + name='freeipa', + version='1.99.0', + license='GPLv2+', + url='http://freeipa.org/', + packages=[ + 'ipalib', + 'ipalib.plugins', + 'ipaserver', + 'ipaserver.plugins', + 'ipawebui', + 'ipawebui.templates', + ], + package_data={ + 'ipawebui.templates': ['*.kid'], + 'ipawebui': ['static/*'], + }, + scripts=['ipa'], +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..4550e6bcd --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,22 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Package containing all unit tests. +""" diff --git a/tests/data.py b/tests/data.py new file mode 100644 index 000000000..cf646ea9d --- /dev/null +++ b/tests/data.py @@ -0,0 +1,38 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Data frequently used in the unit tests, especially Unicode related tests. +""" + +import struct + + +# A string that should have bytes 'x\00' through '\xff': +binary_bytes = ''.join(struct.pack('B', d) for d in xrange(256)) +assert '\x00' in binary_bytes and '\xff' in binary_bytes +assert type(binary_bytes) is str and len(binary_bytes) == 256 + +# A UTF-8 encoded str: +utf8_bytes = '\xd0\x9f\xd0\xb0\xd0\xb2\xd0\xb5\xd0\xbb' + +# The same UTF-8 data decoded (a unicode instance): +unicode_str = u'\u041f\u0430\u0432\u0435\u043b' +assert utf8_bytes.decode('UTF-8') == unicode_str +assert unicode_str.encode('UTF-8') == utf8_bytes diff --git a/tests/test_ipalib/__init__.py b/tests/test_ipalib/__init__.py new file mode 100644 index 000000000..113881ebf --- /dev/null +++ b/tests/test_ipalib/__init__.py @@ -0,0 +1,22 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Sub-package containing unit tests for `ipalib` package. +""" diff --git a/tests/test_ipalib/test_backend.py b/tests/test_ipalib/test_backend.py new file mode 100644 index 000000000..88bd2da47 --- /dev/null +++ b/tests/test_ipalib/test_backend.py @@ -0,0 +1,55 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib.backend` module. +""" + +from ipalib import backend, plugable, errors +from tests.util import ClassChecker, raises + + +class test_Backend(ClassChecker): + """ + Test the `ipalib.backend.Backend` class. + """ + + _cls = backend.Backend + + def test_class(self): + assert self.cls.__bases__ == (plugable.Plugin,) + assert self.cls.__proxy__ is False + + +class test_Context(ClassChecker): + """ + Test the `ipalib.backend.Context` class. + """ + + _cls = backend.Context + + def test_get_value(self): + """ + Test the `ipalib.backend.Context.get_value` method. + """ + class Subclass(self.cls): + pass + o = Subclass() + e = raises(NotImplementedError, o.get_value) + assert str(e) == 'Subclass.get_value()' diff --git a/tests/test_ipalib/test_base.py b/tests/test_ipalib/test_base.py new file mode 100644 index 000000000..ce88f23f8 --- /dev/null +++ b/tests/test_ipalib/test_base.py @@ -0,0 +1,352 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib.base` module. +""" + +from tests.util import ClassChecker, raises +from ipalib.constants import NAME_REGEX, NAME_ERROR +from ipalib.constants import TYPE_ERROR, SET_ERROR, DEL_ERROR, OVERRIDE_ERROR +from ipalib import base + + +class test_ReadOnly(ClassChecker): + """ + Test the `ipalib.base.ReadOnly` class + """ + _cls = base.ReadOnly + + def test_lock(self): + """ + Test the `ipalib.base.ReadOnly.__lock__` method. + """ + o = self.cls() + assert o._ReadOnly__locked is False + o.__lock__() + assert o._ReadOnly__locked is True + e = raises(AssertionError, o.__lock__) # Can only be locked once + assert str(e) == '__lock__() can only be called once' + assert o._ReadOnly__locked is True # This should still be True + + def test_islocked(self): + """ + Test the `ipalib.base.ReadOnly.__islocked__` method. + """ + o = self.cls() + assert o.__islocked__() is False + o.__lock__() + assert o.__islocked__() is True + + def test_setattr(self): + """ + Test the `ipalib.base.ReadOnly.__setattr__` method. + """ + o = self.cls() + o.attr1 = 'Hello, world!' + assert o.attr1 == 'Hello, world!' + o.__lock__() + for name in ('attr1', 'attr2'): + e = raises(AttributeError, setattr, o, name, 'whatever') + assert str(e) == SET_ERROR % ('ReadOnly', name, 'whatever') + assert o.attr1 == 'Hello, world!' + + def test_delattr(self): + """ + Test the `ipalib.base.ReadOnly.__delattr__` method. + """ + o = self.cls() + o.attr1 = 'Hello, world!' + o.attr2 = 'How are you?' + assert o.attr1 == 'Hello, world!' + assert o.attr2 == 'How are you?' + del o.attr1 + assert not hasattr(o, 'attr1') + o.__lock__() + e = raises(AttributeError, delattr, o, 'attr2') + assert str(e) == DEL_ERROR % ('ReadOnly', 'attr2') + assert o.attr2 == 'How are you?' + + +def test_lock(): + """ + Test the `ipalib.base.lock` function + """ + f = base.lock + + # Test with ReadOnly instance: + o = base.ReadOnly() + assert o.__islocked__() is False + assert f(o) is o + assert o.__islocked__() is True + e = raises(AssertionError, f, o) + assert str(e) == 'already locked: %r' % o + + # Test with another class implemented locking protocol: + class Lockable(object): + __locked = False + def __lock__(self): + self.__locked = True + def __islocked__(self): + return self.__locked + o = Lockable() + assert o.__islocked__() is False + assert f(o) is o + assert o.__islocked__() is True + e = raises(AssertionError, f, o) + assert str(e) == 'already locked: %r' % o + + # Test with a class incorrectly implementing the locking protocol: + class Broken(object): + def __lock__(self): + pass + def __islocked__(self): + return False + o = Broken() + e = raises(AssertionError, f, o) + assert str(e) == 'failed to lock: %r' % o + + +def test_islocked(): + """ + Test the `ipalib.base.islocked` function. + """ + f = base.islocked + + # Test with ReadOnly instance: + o = base.ReadOnly() + assert f(o) is False + o.__lock__() + assert f(o) is True + + # Test with another class implemented locking protocol: + class Lockable(object): + __locked = False + def __lock__(self): + self.__locked = True + def __islocked__(self): + return self.__locked + o = Lockable() + assert f(o) is False + o.__lock__() + assert f(o) is True + + # Test with a class incorrectly implementing the locking protocol: + class Broken(object): + __lock__ = False + def __islocked__(self): + return False + o = Broken() + e = raises(AssertionError, f, o) + assert str(e) == 'no __lock__() method: %r' % o + + +def test_check_name(): + """ + Test the `ipalib.base.check_name` function. + """ + f = base.check_name + okay = [ + 'user_add', + 'stuff2junk', + 'sixty9', + ] + nope = [ + '_user_add', + '__user_add', + 'user_add_', + 'user_add__', + '_user_add_', + '__user_add__', + '60nine', + ] + for name in okay: + assert name is f(name) + e = raises(TypeError, f, unicode(name)) + assert str(e) == TYPE_ERROR % ('name', str, unicode(name), unicode) + for name in nope: + e = raises(ValueError, f, name) + assert str(e) == NAME_ERROR % (NAME_REGEX, name) + for name in okay: + e = raises(ValueError, f, name.upper()) + assert str(e) == NAME_ERROR % (NAME_REGEX, name.upper()) + + +def membername(i): + return 'member%03d' % i + + +class DummyMember(object): + def __init__(self, i): + self.i = i + self.name = membername(i) + + +def gen_members(*indexes): + return tuple(DummyMember(i) for i in indexes) + + +class test_NameSpace(ClassChecker): + """ + Test the `ipalib.base.NameSpace` class. + """ + _cls = base.NameSpace + + def new(self, count, sort=True): + members = tuple(DummyMember(i) for i in xrange(count, 0, -1)) + assert len(members) == count + o = self.cls(members, sort=sort) + return (o, members) + + def test_init(self): + """ + Test the `ipalib.base.NameSpace.__init__` method. + """ + o = self.cls([]) + assert len(o) == 0 + assert list(o) == [] + assert list(o()) == [] + + # Test members as attribute and item: + for cnt in (3, 42): + for sort in (True, False): + (o, members) = self.new(cnt, sort=sort) + assert len(members) == cnt + for m in members: + assert getattr(o, m.name) is m + assert o[m.name] is m + + # Test that TypeError is raised if sort is not a bool: + e = raises(TypeError, self.cls, [], sort=None) + assert str(e) == TYPE_ERROR % ('sort', bool, None, type(None)) + + # Test that AttributeError is raised with duplicate member name: + members = gen_members(0, 1, 2, 1, 3) + e = raises(AttributeError, self.cls, members) + assert str(e) == OVERRIDE_ERROR % ( + 'NameSpace', membername(1), members[1], members[3] + ) + + def test_len(self): + """ + Test the `ipalib.base.NameSpace.__len__` method. + """ + for count in (5, 18, 127): + (o, members) = self.new(count) + assert len(o) == count + (o, members) = self.new(count, sort=False) + assert len(o) == count + + def test_iter(self): + """ + Test the `ipalib.base.NameSpace.__iter__` method. + """ + (o, members) = self.new(25) + assert list(o) == sorted(m.name for m in members) + (o, members) = self.new(25, sort=False) + assert list(o) == list(m.name for m in members) + + def test_call(self): + """ + Test the `ipalib.base.NameSpace.__call__` method. + """ + (o, members) = self.new(25) + assert list(o()) == sorted(members, key=lambda m: m.name) + (o, members) = self.new(25, sort=False) + assert tuple(o()) == members + + def test_contains(self): + """ + Test the `ipalib.base.NameSpace.__contains__` method. + """ + yes = (99, 3, 777) + no = (9, 333, 77) + for sort in (True, False): + members = gen_members(*yes) + o = self.cls(members, sort=sort) + for i in yes: + assert membername(i) in o + assert membername(i).upper() not in o + for i in no: + assert membername(i) not in o + + def test_getitem(self): + """ + Test the `ipalib.base.NameSpace.__getitem__` method. + """ + cnt = 17 + for sort in (True, False): + (o, members) = self.new(cnt, sort=sort) + assert len(members) == cnt + if sort is True: + members = tuple(sorted(members, key=lambda m: m.name)) + + # Test str keys: + for m in members: + assert o[m.name] is m + e = raises(KeyError, o.__getitem__, 'nope') + + # Test int indexes: + for i in xrange(cnt): + assert o[i] is members[i] + e = raises(IndexError, o.__getitem__, cnt) + + # Test negative int indexes: + for i in xrange(1, cnt + 1): + assert o[-i] is members[-i] + e = raises(IndexError, o.__getitem__, -(cnt + 1)) + + # Test slicing: + assert o[3:] == members[3:] + assert o[:10] == members[:10] + assert o[3:10] == members[3:10] + assert o[-9:] == members[-9:] + assert o[:-4] == members[:-4] + assert o[-9:-4] == members[-9:-4] + + # Test that TypeError is raised with wrong type + e = raises(TypeError, o.__getitem__, 3.0) + assert str(e) == TYPE_ERROR % ('key', (str, int, slice), 3.0, float) + + def test_repr(self): + """ + Test the `ipalib.base.NameSpace.__repr__` method. + """ + for cnt in (0, 1, 2): + for sort in (True, False): + (o, members) = self.new(cnt, sort=sort) + if cnt == 1: + assert repr(o) == \ + 'NameSpace(<%d member>, sort=%r)' % (cnt, sort) + else: + assert repr(o) == \ + 'NameSpace(<%d members>, sort=%r)' % (cnt, sort) + + def test_todict(self): + """ + Test the `ipalib.base.NameSpace.__todict__` method. + """ + for cnt in (3, 101): + for sort in (True, False): + (o, members) = self.new(cnt, sort=sort) + d = o.__todict__() + assert d == dict((m.name, m) for m in members) + + # Test that a copy is returned: + assert o.__todict__() is not d diff --git a/tests/test_ipalib/test_cli.py b/tests/test_ipalib/test_cli.py new file mode 100644 index 000000000..56297fdf7 --- /dev/null +++ b/tests/test_ipalib/test_cli.py @@ -0,0 +1,277 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib.cli` module. +""" + +from tests.util import raises, get_api, ClassChecker +from ipalib import cli, plugable, frontend, backend + + +class test_textui(ClassChecker): + _cls = cli.textui + + def test_max_col_width(self): + """ + Test the `ipalib.cli.textui.max_col_width` method. + """ + o = self.cls() + e = raises(TypeError, o.max_col_width, 'hello') + assert str(e) == 'rows: need %r or %r; got %r' % (list, tuple, 'hello') + rows = [ + 'hello', + 'naughty', + 'nurse', + ] + assert o.max_col_width(rows) == len('naughty') + rows = ( + ( 'a', 'bbb', 'ccccc'), + ('aa', 'bbbb', 'cccccc'), + ) + assert o.max_col_width(rows, col=0) == 2 + assert o.max_col_width(rows, col=1) == 4 + assert o.max_col_width(rows, col=2) == 6 + + +def test_to_cli(): + """ + Test the `ipalib.cli.to_cli` function. + """ + f = cli.to_cli + assert f('initialize') == 'initialize' + assert f('user_add') == 'user-add' + + +def test_from_cli(): + """ + Test the `ipalib.cli.from_cli` function. + """ + f = cli.from_cli + assert f('initialize') == 'initialize' + assert f('user-add') == 'user_add' + + +def get_cmd_name(i): + return 'cmd_%d' % i + + +class DummyCommand(object): + def __init__(self, name): + self.__name = name + + def __get_name(self): + return self.__name + name = property(__get_name) + + +class DummyAPI(object): + def __init__(self, cnt): + self.__cmd = plugable.NameSpace(self.__cmd_iter(cnt)) + + def __get_cmd(self): + return self.__cmd + Command = property(__get_cmd) + + def __cmd_iter(self, cnt): + for i in xrange(cnt): + yield DummyCommand(get_cmd_name(i)) + + def finalize(self): + pass + + def register(self, *args, **kw): + pass + + +config_cli = """ +[global] + +from_cli_conf = set in cli.conf +""" + +config_default = """ +[global] + +from_default_conf = set in default.conf + +# Make sure cli.conf is loaded first: +from_cli_conf = overridden in default.conf +""" + + +class test_CLI(ClassChecker): + """ + Test the `ipalib.cli.CLI` class. + """ + _cls = cli.CLI + + def new(self, argv=tuple()): + (api, home) = get_api() + o = self.cls(api, argv) + assert o.api is api + return (o, api, home) + + def check_cascade(self, *names): + (o, api, home) = self.new() + method = getattr(o, names[0]) + for name in names: + assert o.isdone(name) is False + method() + for name in names: + assert o.isdone(name) is True + e = raises(StandardError, method) + assert str(e) == 'CLI.%s() already called' % names[0] + + def test_init(self): + """ + Test the `ipalib.cli.CLI.__init__` method. + """ + argv = ['-v', 'user-add', '--first=Jonh', '--last=Doe'] + (o, api, home) = self.new(argv) + assert o.api is api + assert o.argv == tuple(argv) + + def test_run_real(self): + """ + Test the `ipalib.cli.CLI.run_real` method. + """ + self.check_cascade( + 'run_real', + 'finalize', + 'load_plugins', + 'bootstrap', + 'parse_globals' + ) + + def test_finalize(self): + """ + Test the `ipalib.cli.CLI.finalize` method. + """ + self.check_cascade( + 'finalize', + 'load_plugins', + 'bootstrap', + 'parse_globals' + ) + + (o, api, home) = self.new() + assert api.isdone('finalize') is False + assert 'Command' not in api + o.finalize() + assert api.isdone('finalize') is True + assert list(api.Command) == \ + sorted(k.__name__ for k in cli.cli_application_commands) + + def test_load_plugins(self): + """ + Test the `ipalib.cli.CLI.load_plugins` method. + """ + self.check_cascade( + 'load_plugins', + 'bootstrap', + 'parse_globals' + ) + (o, api, home) = self.new() + assert api.isdone('load_plugins') is False + o.load_plugins() + assert api.isdone('load_plugins') is True + + def test_bootstrap(self): + """ + Test the `ipalib.cli.CLI.bootstrap` method. + """ + self.check_cascade( + 'bootstrap', + 'parse_globals' + ) + # Test with empty argv + (o, api, home) = self.new() + keys = tuple(api.env) + assert api.isdone('bootstrap') is False + o.bootstrap() + assert api.isdone('bootstrap') is True + e = raises(StandardError, o.bootstrap) + assert str(e) == 'CLI.bootstrap() already called' + assert api.env.verbose is False + assert api.env.context == 'cli' + keys = tuple(api.env) + added = ( + 'my_key', + 'from_default_conf', + 'from_cli_conf' + ) + for key in added: + assert key not in api.env + assert key not in keys + + # Test with a populated argv + argv = ['-e', 'my_key=my_val,whatever=Hello'] + (o, api, home) = self.new(argv) + home.write(config_default, '.ipa', 'default.conf') + home.write(config_cli, '.ipa', 'cli.conf') + o.bootstrap() + assert api.env.my_key == 'my_val,whatever=Hello' + assert api.env.from_default_conf == 'set in default.conf' + assert api.env.from_cli_conf == 'set in cli.conf' + assert list(api.env) == sorted(keys + added) + + def test_parse_globals(self): + """ + Test the `ipalib.cli.CLI.parse_globals` method. + """ + # Test with empty argv: + (o, api, home) = self.new() + assert not hasattr(o, 'options') + assert not hasattr(o, 'cmd_argv') + assert o.isdone('parse_globals') is False + o.parse_globals() + assert o.isdone('parse_globals') is True + assert o.options.prompt_all is False + assert o.options.interactive is True + assert o.options.verbose is None + assert o.options.conf is None + assert o.options.env is None + assert o.cmd_argv == tuple() + e = raises(StandardError, o.parse_globals) + assert str(e) == 'CLI.parse_globals() already called' + + # Test with a populated argv: + argv = ('-a', '-n', '-v', '-c', '/my/config.conf', '-e', 'my_key=my_val') + cmd_argv = ('user-add', '--first', 'John', '--last', 'Doe') + (o, api, home) = self.new(argv + cmd_argv) + assert not hasattr(o, 'options') + assert not hasattr(o, 'cmd_argv') + assert o.isdone('parse_globals') is False + o.parse_globals() + assert o.isdone('parse_globals') is True + assert o.options.prompt_all is True + assert o.options.interactive is False + assert o.options.verbose is True + assert o.options.conf == '/my/config.conf' + assert o.options.env == ['my_key=my_val'] + assert o.cmd_argv == cmd_argv + e = raises(StandardError, o.parse_globals) + assert str(e) == 'CLI.parse_globals() already called' + + # Test with multiple -e args: + argv = ('-e', 'key1=val1', '-e', 'key2=val2') + (o, api, home) = self.new(argv) + o.parse_globals() + assert o.options.env == ['key1=val1', 'key2=val2'] diff --git a/tests/test_ipalib/test_config.py b/tests/test_ipalib/test_config.py new file mode 100644 index 000000000..d3109f7b3 --- /dev/null +++ b/tests/test_ipalib/test_config.py @@ -0,0 +1,608 @@ +# Authors: +# Martin Nagy <mnagy@redhat.com> +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib.config` module. +""" + +import os +from os import path +import sys +from tests.util import raises, setitem, delitem, ClassChecker +from tests.util import getitem, setitem, delitem +from tests.util import TempDir, TempHome +from ipalib.constants import TYPE_ERROR, OVERRIDE_ERROR, SET_ERROR, DEL_ERROR +from ipalib.constants import NAME_REGEX, NAME_ERROR +from ipalib import config, constants, base + + +# Valid environment variables in (key, raw, value) tuples: +# key: the name of the environment variable +# raw: the value being set (possibly a string repr) +# value: the expected value after the lightweight conversion +good_vars = ( + ('a_string', 'Hello world!', 'Hello world!'), + ('trailing_whitespace', ' value ', 'value'), + ('an_int', 42, 42), + ('int_repr', ' 42 ', 42), + ('a_float', 3.14, 3.14), + ('float_repr', ' 3.14 ', 3.14), + ('true', True, True), + ('true_repr', ' True ', True), + ('false', False, False), + ('false_repr', ' False ', False), + ('none', None, None), + ('none_repr', ' None ', None), + ('empty', '', None), + + # These verify that the implied conversion is case-sensitive: + ('not_true', ' true ', 'true'), + ('not_false', ' false ', 'false'), + ('not_none', ' none ', 'none'), +) + + +bad_names = ( + ('CamelCase', 'value'), + ('_leading_underscore', 'value'), + ('trailing_underscore_', 'value'), +) + + +# Random base64-encoded data to simulate a misbehaving config file. +config_bad = """ +/9j/4AAQSkZJRgABAQEAlgCWAAD//gAIT2xpdmVy/9sAQwAQCwwODAoQDg0OEhEQExgoGhgWFhgx +IyUdKDozPTw5Mzg3QEhcTkBEV0U3OFBtUVdfYmdoZz5NcXlwZHhcZWdj/8AACwgAlgB0AQERAP/E +ABsAAAEFAQEAAAAAAAAAAAAAAAQAAQIDBQYH/8QAMhAAAgICAAUDBAIABAcAAAAAAQIAAwQRBRIh +MUEGE1EiMmFxFIEVI0LBFjNSYnKRof/aAAgBAQAAPwDCtzmNRr1o/MEP1D6f7kdkRakgBsAtoQhk +xls/y3Z113I11mhiUc1ewCf1Oq4anJgINdhLhQoextfedmYrenfcvdzaFQnYAE08XhONTWEK8+js +Fpo1oqAKoAA8CWjoJJTHM8kJ5jsiOiszAKD1+IV/hmW76rosbfnlh1Pp3Mah2srCnXQE9YXiel/c +p5r7uVj2CwxPTuFjjmdLbteNwmrLwsYe3TjsD8cmjKV43ycy+3o76D4llFuXmuCoZEPczXVOSsLv +f5lgGpNZLxJL2jnvMar0/wAOp6jHDH/uO4RViY9f/KpRdfC6k3R9fRyj+pRZVkWKqF10e+hCKaFq +XlH/ALlmhK7Met/uUGZ5ow8XL57lU8/Yt4lx4jUOJphLobTe/wDaHeZLxHXtJEya9o5lFzCqpmPY +CUYoPtDfc9TLj0G5jZvHaMFirAs++oEHq9U4rbNiMp8a6wO/1Zbzn2alC+Nx8P1JfdeBboA+AILx +rin8pfbA1ynvKuFUXZOXXkLbzOp2R56andL2G45MmO0RPWWLEe8GzaffoKb/ADI44Pt9ZXxAuuFa +axtgp0BOSPCcviNX8n3Aw8KTNHB4FiY9StkobLWHVSeghq8M4bkAhKKyV6Hl8RV8MwMZG1Uuz3Jn +IcUQJlMFGlJ6D4hfpymy7iChHKqvVtefxO7Ai1txLBIn7pcojN3jGVhQO0ZgCNfM5ZHycTLycSkr +yhtqD4Bmrfw5cuqsm6xHXyp1seRLcHCp4dQy1bOzslj1MzeJ5dVFnuMVdgOiHxOWzrmyMg2Nrbde +k3vR2OTddcd6A5R8GdZqOo67k4wXrLAQPMRKnzImMZEzm+P1nFz6cxQeVujagWR6jsYiqivlH/Ux +1M+7jWY30i7QHx1gF11tjGyxiSfmVc+503pPidVROHYNNY21b/adVZZySo3uOo1qIZQYd9RCzfYm +TUk/qW71LjGkTA+IYiZmM1T9N9j8Gee5+McXJem0/Wp8GUK6KOi7b5MgzFjsxpJHZGDKSCOxE3cD +OvsxbbLc9lsT7Vc73KX4ln3q1ZyVrPx2J/uAjLyan37z7B+Zp4vqPJqKi0K4EvzvUt1qBMdfb+T5 +gycfzkXXuc35InfE6nO8Y9SjFc1Yqh2Hdj2mH/xFxR26XgD/AMRJf45mWMqW5bBD3KqAZlZtb++7 +kEqTsHe//sG1CcTBvy7OWpD+Sewhz8CyKCTYAQPiGV0LVWPdxqQNADQ6zL4nWq2gopU6+ofmA8x3 +1MlvfeIGbnBeCHitRt94IFbRGus2U9H08v13sT+BNHjeX/D4bY4OmP0rPPbHLMWJ2Yy2EDQjVsos +BdeYDx8wo5L5KpSdLWPAE1+G8NrFtBKgOAXPTf6mzViql5ZBoE87eJZkKbOQ8m+Yjf5EBzcO621y +GCqD0H41Obzq7U6vzM577HTXgzPPeOIvM1eB59nD8xXVj7bHTr8iej1MtlauvUMNgzi/V2ctliYy +HYTq37nMExpZXRZYpZVJUdzNjg+FXYwZgdhv6nVVUJU/uH7iNf1CARrtF0IB113M7jTNVjFl2xJA +5ROey88OrVOugOy67TDs+89NRKdSYILdRC8ZQVJ+PHyJs4fqe3EoFPLzBexPxOdusa2xndiWY7JM +qMUNrzOTAfHC9XO9/E3vT9blVJB0o2Zu3MAoYrsL13Ii0Muw3XvJG9KkDOeqjf6gWcw5A33XN9nX +tOeyMRFWy3Jch+bX7mXmCsW/5RBXUoHaOIRi2asAJ0IRbjqzll3o/EAaRiltDojgv2E1aePmhEWq +rsNHZ7wir1K/8Y1vUCSCAd+IXiZ9b1gLYvN07trXTUD4rxN2TkUgEts8p2NDtD0t5MVGchr2Xe99 +hMPNvD1LX5J2TuZhGyYwBijjfiHU5bJXrnYfqBRtRtSbIBWG3+xI6HiLUWz8xA9RuaVNrMAPfB5x +r6v9MLr4S1il7LaxyjY69Jl5eG+Kyhiv1jYIMGYMO8etGscKoJJ8Cbp4bVg4ivaq22t3G/tmRYo5 +zyjQ+JRFFET01GB0Yid9YiYh1l9KgEHqT8Tco/hewA/NzgdQdwTNGNTY3uU2crL9HN00ZlovNzfV +oCanBrBRk1rpCHPUkQjjYoW4GtwAw30MDpuxvbAvpJceR5mXFFEY0W4o4mpg0XNXutQxPUHxLb8q +7mRDyszLr6esz8u++9wL2LcvQb8RXCkhBV3A6mR5rEVSrdFPT8SBLMdsdmWe6P8AUAx+TB4oooxi +i1Jmt0+5dfuOLbANB2H6MjzNzc2zv5ji1g2+5/MYnbb+Yh+T0kubUY940UUbUWtRpJN8w1CfebkK +WfUu+/mDOAGOjsRo0UkIo+pPl6Rckl7ehuR1INGAj9u0kW2nXvK45YlQp1odukaICSAjgSQWf//Z +""" + + +# A config file that tries to override some standard vars: +config_override = """ +[global] + +key0 = var0 +home = /home/sweet/home +key1 = var1 +site_packages = planet +key2 = var2 +key3 = var3 +""" + + +# A config file that tests the automatic type conversion +config_good = """ +[global] + +string = Hello world! +null = None +yes = True +no = False +number = 42 +floating = 3.14 +""" + + +# A default config file to make sure it does not overwrite the explicit one +config_default = """ +[global] + +yes = Hello +not_in_other = foo_bar +""" + + +class test_Env(ClassChecker): + """ + Test the `ipalib.config.Env` class. + """ + + _cls = config.Env + + def test_init(self): + """ + Test the `ipalib.config.Env.__init__` method. + """ + o = self.cls() + assert list(o) == [] + assert len(o) == 0 + assert o.__islocked__() is False + + def test_lock(self): + """ + Test the `ipalib.config.Env.__lock__` method. + """ + o = self.cls() + assert o.__islocked__() is False + o.__lock__() + assert o.__islocked__() is True + e = raises(StandardError, o.__lock__) + assert str(e) == 'Env.__lock__() already called' + + # Also test with base.lock() function: + o = self.cls() + assert o.__islocked__() is False + assert base.lock(o) is o + assert o.__islocked__() is True + e = raises(AssertionError, base.lock, o) + assert str(e) == 'already locked: %r' % o + + def test_islocked(self): + """ + Test the `ipalib.config.Env.__islocked__` method. + """ + o = self.cls() + assert o.__islocked__() is False + assert base.islocked(o) is False + o.__lock__() + assert o.__islocked__() is True + assert base.islocked(o) is True + + def test_setattr(self): + """ + Test the `ipalib.config.Env.__setattr__` method. + """ + o = self.cls() + for (name, raw, value) in good_vars: + # Test setting the value: + setattr(o, name, raw) + result = getattr(o, name) + assert type(result) is type(value) + assert result == value + assert result is o[name] + + # Test that value cannot be overridden once set: + e = raises(AttributeError, setattr, o, name, raw) + assert str(e) == OVERRIDE_ERROR % ('Env', name, value, raw) + + # Test that values cannot be set once locked: + o = self.cls() + o.__lock__() + for (name, raw, value) in good_vars: + e = raises(AttributeError, setattr, o, name, raw) + assert str(e) == SET_ERROR % ('Env', name, raw) + + # Test that name is tested with check_name(): + o = self.cls() + for (name, value) in bad_names: + e = raises(ValueError, setattr, o, name, value) + assert str(e) == NAME_ERROR % (NAME_REGEX, name) + + def test_setitem(self): + """ + Test the `ipalib.config.Env.__setitem__` method. + """ + o = self.cls() + for (key, raw, value) in good_vars: + # Test setting the value: + o[key] = raw + result = o[key] + assert type(result) is type(value) + assert result == value + assert result is getattr(o, key) + + # Test that value cannot be overridden once set: + e = raises(AttributeError, o.__setitem__, key, raw) + assert str(e) == OVERRIDE_ERROR % ('Env', key, value, raw) + + # Test that values cannot be set once locked: + o = self.cls() + o.__lock__() + for (key, raw, value) in good_vars: + e = raises(AttributeError, o.__setitem__, key, raw) + assert str(e) == SET_ERROR % ('Env', key, raw) + + # Test that name is tested with check_name(): + o = self.cls() + for (key, value) in bad_names: + e = raises(ValueError, o.__setitem__, key, value) + assert str(e) == NAME_ERROR % (NAME_REGEX, key) + + def test_getitem(self): + """ + Test the `ipalib.config.Env.__getitem__` method. + """ + o = self.cls() + value = 'some value' + o.key = value + assert o.key is value + assert o['key'] is value + for name in ('one', 'two'): + e = raises(KeyError, getitem, o, name) + assert str(e) == repr(name) + + def test_delattr(self): + """ + Test the `ipalib.config.Env.__delattr__` method. + + This also tests that ``__delitem__`` is not implemented. + """ + o = self.cls() + o.one = 1 + assert o.one == 1 + for key in ('one', 'two'): + e = raises(AttributeError, delattr, o, key) + assert str(e) == DEL_ERROR % ('Env', key) + e = raises(AttributeError, delitem, o, key) + assert str(e) == '__delitem__' + + def test_contains(self): + """ + Test the `ipalib.config.Env.__contains__` method. + """ + o = self.cls() + items = [ + ('one', 1), + ('two', 2), + ('three', 3), + ('four', 4), + ] + for (key, value) in items: + assert key not in o + o[key] = value + assert key in o + + def test_len(self): + """ + Test the `ipalib.config.Env.__len__` method. + """ + o = self.cls() + assert len(o) == 0 + for i in xrange(1, 11): + key = 'key%d' % i + value = 'value %d' % i + o[key] = value + assert o[key] is value + assert len(o) == i + + def test_iter(self): + """ + Test the `ipalib.config.Env.__iter__` method. + """ + o = self.cls() + default_keys = tuple(o) + keys = ('one', 'two', 'three', 'four', 'five') + for key in keys: + o[key] = 'the value' + assert list(o) == sorted(keys + default_keys) + + def test_merge(self): + """ + Test the `ipalib.config.Env._merge` method. + """ + group1 = ( + ('key1', 'value 1'), + ('key2', 'value 2'), + ('key3', 'value 3'), + ('key4', 'value 4'), + ) + group2 = ( + ('key0', 'Value 0'), + ('key2', 'Value 2'), + ('key4', 'Value 4'), + ('key5', 'Value 5'), + ) + o = self.cls() + assert o._merge(**dict(group1)) == (4, 4) + assert len(o) == 4 + assert list(o) == list(key for (key, value) in group1) + for (key, value) in group1: + assert getattr(o, key) is value + assert o[key] is value + assert o._merge(**dict(group2)) == (2, 4) + assert len(o) == 6 + expected = dict(group2) + expected.update(dict(group1)) + assert list(o) == sorted(expected) + assert expected['key2'] == 'value 2' # And not 'Value 2' + for (key, value) in expected.iteritems(): + assert getattr(o, key) is value + assert o[key] is value + assert o._merge(**expected) == (0, 6) + assert len(o) == 6 + assert list(o) == sorted(expected) + + def test_merge_from_file(self): + """ + Test the `ipalib.config.Env._merge_from_file` method. + """ + tmp = TempDir() + assert callable(tmp.join) + + # Test a config file that doesn't exist + no_exist = tmp.join('no_exist.conf') + assert not path.exists(no_exist) + o = self.cls() + o._bootstrap() + keys = tuple(o) + orig = dict((k, o[k]) for k in o) + assert o._merge_from_file(no_exist) is None + assert tuple(o) == keys + + # Test an empty config file + empty = tmp.touch('empty.conf') + assert path.isfile(empty) + assert o._merge_from_file(empty) == (0, 0) + assert tuple(o) == keys + + # Test a mal-formed config file: + bad = tmp.join('bad.conf') + open(bad, 'w').write(config_bad) + assert path.isfile(bad) + assert o._merge_from_file(bad) is None + assert tuple(o) == keys + + # Test a valid config file that tries to override + override = tmp.join('override.conf') + open(override, 'w').write(config_override) + assert path.isfile(override) + assert o._merge_from_file(override) == (4, 6) + for (k, v) in orig.items(): + assert o[k] is v + assert list(o) == sorted(keys + ('key0', 'key1', 'key2', 'key3')) + for i in xrange(4): + assert o['key%d' % i] == ('var%d' % i) + keys = tuple(o) + + # Test a valid config file with type conversion + good = tmp.join('good.conf') + open(good, 'w').write(config_good) + assert path.isfile(good) + assert o._merge_from_file(good) == (6, 6) + added = ('string', 'null', 'yes', 'no', 'number', 'floating') + assert list(o) == sorted(keys + added) + assert o.string == 'Hello world!' + assert o.null is None + assert o.yes is True + assert o.no is False + assert o.number == 42 + assert o.floating == 3.14 + + def new(self): + """ + Set os.environ['HOME'] to a tempdir. + + Returns tuple with new Env instance and the TempHome instance. This + helper method is used in testing the bootstrap related methods below. + """ + home = TempHome() + return (self.cls(), home) + + def bootstrap(self, **overrides): + """ + Helper method used in testing bootstrap related methods below. + """ + (o, home) = self.new() + assert o._isdone('_bootstrap') is False + o._bootstrap(**overrides) + assert o._isdone('_bootstrap') is True + e = raises(StandardError, o._bootstrap) + assert str(e) == 'Env._bootstrap() already called' + return (o, home) + + def test_bootstrap(self): + """ + Test the `ipalib.config.Env._bootstrap` method. + """ + # Test defaults created by _bootstrap(): + (o, home) = self.new() + o._bootstrap() + ipalib = path.dirname(path.abspath(config.__file__)) + assert o.ipalib == ipalib + assert o.site_packages == path.dirname(ipalib) + assert o.script == path.abspath(sys.argv[0]) + assert o.bin == path.dirname(path.abspath(sys.argv[0])) + assert o.home == home.path + assert o.dot_ipa == home.join('.ipa') + assert o.in_tree is False + assert o.context == 'default' + assert o.conf == '/etc/ipa/default.conf' + assert o.conf_default == o.conf + + # Test overriding values created by _bootstrap() + (o, home) = self.bootstrap(in_tree='True', context='server') + assert o.in_tree is True + assert o.context == 'server' + assert o.conf == home.join('.ipa', 'server.conf') + (o, home) = self.bootstrap(conf='/my/wacky/whatever.conf') + assert o.in_tree is False + assert o.context == 'default' + assert o.conf == '/my/wacky/whatever.conf' + assert o.conf_default == '/etc/ipa/default.conf' + (o, home) = self.bootstrap(conf_default='/my/wacky/default.conf') + assert o.in_tree is False + assert o.context == 'default' + assert o.conf == '/etc/ipa/default.conf' + assert o.conf_default == '/my/wacky/default.conf' + + # Test various overrides and types conversion + kw = dict( + yes=True, + no=False, + num=42, + msg='Hello, world!', + ) + override = dict( + (k, u' %s ' % v) for (k, v) in kw.items() + ) + (o, home) = self.new() + for key in kw: + assert key not in o + o._bootstrap(**override) + for (key, value) in kw.items(): + assert getattr(o, key) == value + assert o[key] == value + + def finalize_core(self, **defaults): + """ + Helper method used in testing `Env._finalize_core`. + """ + (o, home) = self.new() + assert o._isdone('_finalize_core') is False + o._finalize_core(**defaults) + assert o._isdone('_finalize_core') is True + e = raises(StandardError, o._finalize_core) + assert str(e) == 'Env._finalize_core() already called' + return (o, home) + + def test_finalize_core(self): + """ + Test the `ipalib.config.Env._finalize_core` method. + """ + # Check that calls cascade up the chain: + (o, home) = self.new() + assert o._isdone('_bootstrap') is False + assert o._isdone('_finalize_core') is False + assert o._isdone('_finalize') is False + o._finalize_core() + assert o._isdone('_bootstrap') is True + assert o._isdone('_finalize_core') is True + assert o._isdone('_finalize') is False + + # Check that it can't be called twice: + e = raises(StandardError, o._finalize_core) + assert str(e) == 'Env._finalize_core() already called' + + # Check that _bootstrap() did its job: + (o, home) = self.bootstrap() + assert 'in_tree' in o + assert 'conf' in o + assert 'context' in o + + # Check that keys _finalize_core() will set are not set yet: + assert 'log' not in o + assert 'in_server' not in o + + # Check that _finalize_core() did its job: + o._finalize_core() + assert 'in_server' in o + assert 'log' in o + assert o.in_tree is False + assert o.context == 'default' + assert o.in_server is False + assert o.log == '/var/log/ipa/default.log' + + # Check log is in ~/.ipa/log when context='cli' + (o, home) = self.bootstrap(context='cli') + o._finalize_core() + assert o.in_tree is False + assert o.log == home.join('.ipa', 'log', 'cli.log') + + # Check **defaults can't set in_server nor log: + (o, home) = self.bootstrap(in_server='True') + o._finalize_core(in_server=False) + assert o.in_server is True + (o, home) = self.bootstrap(log='/some/silly/log') + o._finalize_core(log='/a/different/log') + assert o.log == '/some/silly/log' + + # Test loading config file, plus test some in-tree stuff + (o, home) = self.bootstrap(in_tree=True, context='server') + for key in ('yes', 'no', 'number'): + assert key not in o + home.write(config_good, '.ipa', 'server.conf') + home.write(config_default, '.ipa', 'default.conf') + o._finalize_core() + assert o.in_tree is True + assert o.context == 'server' + assert o.in_server is True + assert o.log == home.join('.ipa', 'log', 'server.log') + assert o.yes is True + assert o.no is False + assert o.number == 42 + assert o.not_in_other == 'foo_bar' + + # Test using DEFAULT_CONFIG: + defaults = dict(constants.DEFAULT_CONFIG) + (o, home) = self.finalize_core(**defaults) + assert list(o) == sorted(defaults) + for (key, value) in defaults.items(): + if value is object: + continue + assert o[key] is value, value + + def test_finalize(self): + """ + Test the `ipalib.config.Env._finalize` method. + """ + # Check that calls cascade up the chain: + (o, home) = self.new() + assert o._isdone('_bootstrap') is False + assert o._isdone('_finalize_core') is False + assert o._isdone('_finalize') is False + o._finalize() + assert o._isdone('_bootstrap') is True + assert o._isdone('_finalize_core') is True + assert o._isdone('_finalize') is True + + # Check that it can't be called twice: + e = raises(StandardError, o._finalize) + assert str(e) == 'Env._finalize() already called' + + # Check that _finalize() calls __lock__() + (o, home) = self.new() + assert o.__islocked__() is False + o._finalize() + assert o.__islocked__() is True + e = raises(StandardError, o.__lock__) + assert str(e) == 'Env.__lock__() already called' + + # Check that **lastchance works + (o, home) = self.finalize_core() + key = 'just_one_more_key' + value = 'with one more value' + lastchance = {key: value} + assert key not in o + assert o._isdone('_finalize') is False + o._finalize(**lastchance) + assert key in o + assert o[key] is value diff --git a/tests/test_ipalib/test_crud.py b/tests/test_ipalib/test_crud.py new file mode 100644 index 000000000..ad391e2ea --- /dev/null +++ b/tests/test_ipalib/test_crud.py @@ -0,0 +1,237 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib.crud` module. +""" + +from tests.util import read_only, raises, get_api, ClassChecker +from ipalib import crud, frontend, plugable, config + + +class CrudChecker(ClassChecker): + """ + Class for testing base classes in `ipalib.crud`. + """ + + def get_api(self, args=tuple(), options={}): + """ + Return a finalized `ipalib.plugable.API` instance. + """ + assert self.cls.__bases__ == (frontend.Method,) + (api, home) = get_api() + class user(frontend.Object): + takes_params = ( + 'givenname', + 'sn', + frontend.Param('uid', primary_key=True), + 'initials', + ) + class user_verb(self.cls): + takes_args = args + takes_options = options + api.register(user) + api.register(user_verb) + api.finalize() + return api + + +class test_Add(CrudChecker): + """ + Test the `ipalib.crud.Add` class. + """ + + _cls = crud.Add + + def test_get_args(self): + """ + Test the `ipalib.crud.Add.get_args` method. + """ + api = self.get_api() + assert list(api.Method.user_verb.args) == ['uid'] + assert api.Method.user_verb.args.uid.required is True + api = self.get_api(args=('extra?',)) + assert list(api.Method.user_verb.args) == ['uid', 'extra'] + assert api.Method.user_verb.args.uid.required is True + assert api.Method.user_verb.args.extra.required is False + + def test_get_options(self): + """ + Test the `ipalib.crud.Add.get_options` method. + """ + api = self.get_api() + assert list(api.Method.user_verb.options) == \ + ['givenname', 'sn', 'initials'] + for param in api.Method.user_verb.options(): + assert param.required is True + api = self.get_api(options=('extra?',)) + assert list(api.Method.user_verb.options) == \ + ['givenname', 'sn', 'initials', 'extra'] + assert api.Method.user_verb.options.extra.required is False + + +class test_Get(CrudChecker): + """ + Test the `ipalib.crud.Get` class. + """ + + _cls = crud.Get + + def test_get_args(self): + """ + Test the `ipalib.crud.Get.get_args` method. + """ + api = self.get_api() + assert list(api.Method.user_verb.args) == ['uid'] + assert api.Method.user_verb.args.uid.required is True + + def test_get_options(self): + """ + Test the `ipalib.crud.Get.get_options` method. + """ + api = self.get_api() + assert list(api.Method.user_verb.options) == [] + assert len(api.Method.user_verb.options) == 0 + + +class test_Del(CrudChecker): + """ + Test the `ipalib.crud.Del` class. + """ + + _cls = crud.Del + + def test_get_args(self): + """ + Test the `ipalib.crud.Del.get_args` method. + """ + api = self.get_api() + assert list(api.Method.user_verb.args) == ['uid'] + assert api.Method.user_verb.args.uid.required is True + + def test_get_options(self): + """ + Test the `ipalib.crud.Del.get_options` method. + """ + api = self.get_api() + assert list(api.Method.user_verb.options) == [] + assert len(api.Method.user_verb.options) == 0 + + +class test_Mod(CrudChecker): + """ + Test the `ipalib.crud.Mod` class. + """ + + _cls = crud.Mod + + def test_get_args(self): + """ + Test the `ipalib.crud.Mod.get_args` method. + """ + api = self.get_api() + assert list(api.Method.user_verb.args) == ['uid'] + assert api.Method.user_verb.args.uid.required is True + + def test_get_options(self): + """ + Test the `ipalib.crud.Mod.get_options` method. + """ + api = self.get_api() + assert list(api.Method.user_verb.options) == \ + ['givenname', 'sn', 'initials'] + for param in api.Method.user_verb.options(): + assert param.required is False + + +class test_Find(CrudChecker): + """ + Test the `ipalib.crud.Find` class. + """ + + _cls = crud.Find + + def test_get_args(self): + """ + Test the `ipalib.crud.Find.get_args` method. + """ + api = self.get_api() + assert list(api.Method.user_verb.args) == ['uid'] + assert api.Method.user_verb.args.uid.required is True + + def test_get_options(self): + """ + Test the `ipalib.crud.Find.get_options` method. + """ + api = self.get_api() + assert list(api.Method.user_verb.options) == \ + ['givenname', 'sn', 'initials'] + for param in api.Method.user_verb.options(): + assert param.required is False + + +class test_CrudBackend(ClassChecker): + """ + Test the `ipalib.crud.CrudBackend` class. + """ + + _cls = crud.CrudBackend + + def get_subcls(self): + class ldap(self.cls): + pass + return ldap + + def check_method(self, name, *args): + o = self.cls() + e = raises(NotImplementedError, getattr(o, name), *args) + assert str(e) == 'CrudBackend.%s()' % name + sub = self.subcls() + e = raises(NotImplementedError, getattr(sub, name), *args) + assert str(e) == 'ldap.%s()' % name + + def test_create(self): + """ + Test the `ipalib.crud.CrudBackend.create` method. + """ + self.check_method('create') + + def test_retrieve(self): + """ + Test the `ipalib.crud.CrudBackend.retrieve` method. + """ + self.check_method('retrieve', 'primary key', 'attribute') + + def test_update(self): + """ + Test the `ipalib.crud.CrudBackend.update` method. + """ + self.check_method('update', 'primary key') + + def test_delete(self): + """ + Test the `ipalib.crud.CrudBackend.delete` method. + """ + self.check_method('delete', 'primary key') + + def test_search(self): + """ + Test the `ipalib.crud.CrudBackend.search` method. + """ + self.check_method('search') diff --git a/tests/test_ipalib/test_error2.py b/tests/test_ipalib/test_error2.py new file mode 100644 index 000000000..cd13ba775 --- /dev/null +++ b/tests/test_ipalib/test_error2.py @@ -0,0 +1,371 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib.error2` module. +""" + +import re +import inspect +from tests.util import assert_equal, raises, dummy_ugettext +from ipalib import errors2, request +from ipalib.constants import TYPE_ERROR + + +class PrivateExceptionTester(object): + _klass = None + __klass = None + + def __get_klass(self): + if self.__klass is None: + self.__klass = self._klass + assert issubclass(self.__klass, StandardError) + assert issubclass(self.__klass, errors2.PrivateError) + assert not issubclass(self.__klass, errors2.PublicError) + return self.__klass + klass = property(__get_klass) + + def new(self, **kw): + for (key, value) in kw.iteritems(): + assert not hasattr(self.klass, key), key + inst = self.klass(**kw) + assert isinstance(inst, StandardError) + assert isinstance(inst, errors2.PrivateError) + assert isinstance(inst, self.klass) + assert not isinstance(inst, errors2.PublicError) + for (key, value) in kw.iteritems(): + assert getattr(inst, key) is value + assert str(inst) == self.klass.format % kw + assert inst.message == str(inst) + return inst + + +class test_PrivateError(PrivateExceptionTester): + """ + Test the `ipalib.errors2.PrivateError` exception. + """ + _klass = errors2.PrivateError + + def test_init(self): + """ + Test the `ipalib.errors2.PrivateError.__init__` method. + """ + inst = self.klass(key1='Value 1', key2='Value 2') + assert inst.key1 == 'Value 1' + assert inst.key2 == 'Value 2' + assert str(inst) == '' + + # Test subclass and use of format: + class subclass(self.klass): + format = '%(true)r %(text)r %(number)r' + + kw = dict(true=True, text='Hello!', number=18) + inst = subclass(**kw) + assert inst.true is True + assert inst.text is kw['text'] + assert inst.number is kw['number'] + assert str(inst) == subclass.format % kw + + # Test via PrivateExceptionTester.new() + inst = self.new(**kw) + assert isinstance(inst, self.klass) + assert inst.true is True + assert inst.text is kw['text'] + assert inst.number is kw['number'] + + +class test_SubprocessError(PrivateExceptionTester): + """ + Test the `ipalib.errors2.SubprocessError` exception. + """ + + _klass = errors2.SubprocessError + + def test_init(self): + """ + Test the `ipalib.errors2.SubprocessError.__init__` method. + """ + inst = self.new(returncode=1, argv=('/bin/false',)) + assert inst.returncode == 1 + assert inst.argv == ('/bin/false',) + assert str(inst) == "return code 1 from ('/bin/false',)" + assert inst.message == str(inst) + + +class test_PluginSubclassError(PrivateExceptionTester): + """ + Test the `ipalib.errors2.PluginSubclassError` exception. + """ + + _klass = errors2.PluginSubclassError + + def test_init(self): + """ + Test the `ipalib.errors2.PluginSubclassError.__init__` method. + """ + inst = self.new(plugin='bad', bases=('base1', 'base2')) + assert inst.plugin == 'bad' + assert inst.bases == ('base1', 'base2') + assert str(inst) == \ + "'bad' not subclass of any base in ('base1', 'base2')" + assert inst.message == str(inst) + + +class test_PluginDuplicateError(PrivateExceptionTester): + """ + Test the `ipalib.errors2.PluginDuplicateError` exception. + """ + + _klass = errors2.PluginDuplicateError + + def test_init(self): + """ + Test the `ipalib.errors2.PluginDuplicateError.__init__` method. + """ + inst = self.new(plugin='my_plugin') + assert inst.plugin == 'my_plugin' + assert str(inst) == "'my_plugin' was already registered" + assert inst.message == str(inst) + + +class test_PluginOverrideError(PrivateExceptionTester): + """ + Test the `ipalib.errors2.PluginOverrideError` exception. + """ + + _klass = errors2.PluginOverrideError + + def test_init(self): + """ + Test the `ipalib.errors2.PluginOverrideError.__init__` method. + """ + inst = self.new(base='Base', name='cmd', plugin='my_cmd') + assert inst.base == 'Base' + assert inst.name == 'cmd' + assert inst.plugin == 'my_cmd' + assert str(inst) == "unexpected override of Base.cmd with 'my_cmd'" + assert inst.message == str(inst) + + +class test_PluginMissingOverrideError(PrivateExceptionTester): + """ + Test the `ipalib.errors2.PluginMissingOverrideError` exception. + """ + + _klass = errors2.PluginMissingOverrideError + + def test_init(self): + """ + Test the `ipalib.errors2.PluginMissingOverrideError.__init__` method. + """ + inst = self.new(base='Base', name='cmd', plugin='my_cmd') + assert inst.base == 'Base' + assert inst.name == 'cmd' + assert inst.plugin == 'my_cmd' + assert str(inst) == "Base.cmd not registered, cannot override with 'my_cmd'" + assert inst.message == str(inst) + + + +############################################################################## +# Unit tests for public errors: + +class PublicExceptionTester(object): + _klass = None + __klass = None + + def __get_klass(self): + if self.__klass is None: + self.__klass = self._klass + assert issubclass(self.__klass, StandardError) + assert issubclass(self.__klass, errors2.PublicError) + assert not issubclass(self.__klass, errors2.PrivateError) + assert type(self.__klass.errno) is int + assert 900 <= self.__klass.errno <= 5999 + return self.__klass + klass = property(__get_klass) + + def new(self, format=None, message=None, **kw): + # Test that TypeError is raised if message isn't unicode: + e = raises(TypeError, self.klass, message='The message') + assert str(e) == TYPE_ERROR % ('message', unicode, 'The message', str) + + # Test the instance: + for (key, value) in kw.iteritems(): + assert not hasattr(self.klass, key), key + inst = self.klass(format=format, message=message, **kw) + assert isinstance(inst, StandardError) + assert isinstance(inst, errors2.PublicError) + assert isinstance(inst, self.klass) + assert not isinstance(inst, errors2.PrivateError) + for (key, value) in kw.iteritems(): + assert getattr(inst, key) is value + return inst + + +class test_PublicError(PublicExceptionTester): + """ + Test the `ipalib.errors2.PublicError` exception. + """ + _klass = errors2.PublicError + + def test_init(self): + """ + Test the `ipalib.errors2.PublicError.__init__` method. + """ + context = request.context + message = u'The translated, interpolated message' + format = 'key=%(key1)r and key2=%(key2)r' + uformat = u'Translated key=%(key1)r and key2=%(key2)r' + val1 = 'Value 1' + val2 = 'Value 2' + kw = dict(key1=val1, key2=val2) + + assert not hasattr(context, 'ugettext') + + # Test with format=str, message=None + dummy = dummy_ugettext(uformat) + context.ugettext = dummy + inst = self.klass(format, **kw) + assert dummy.message is format # Means ugettext() called + assert inst.format is format + assert_equal(inst.message, format % kw) + assert_equal(inst.strerror, uformat % kw) + assert inst.forwarded is False + assert inst.key1 is val1 + assert inst.key2 is val2 + + # Test with format=None, message=unicode + dummy = dummy_ugettext(uformat) + context.ugettext = dummy + inst = self.klass(message=message, **kw) + assert not hasattr(dummy, 'message') # Means ugettext() not called + assert inst.format is None + assert inst.message is message + assert inst.strerror is message + assert inst.forwarded is True + assert inst.key1 is val1 + assert inst.key2 is val2 + + # Test with format=None, message=str + e = raises(TypeError, self.klass, message='the message', **kw) + assert str(e) == TYPE_ERROR % ('message', unicode, 'the message', str) + + # Test with format=None, message=None + e = raises(ValueError, self.klass, **kw) + assert str(e) == \ + 'PublicError.format is None yet format=None, message=None' + + + ###################################### + # Test via PublicExceptionTester.new() + + # Test with format=str, message=None + dummy = dummy_ugettext(uformat) + context.ugettext = dummy + inst = self.new(format, **kw) + assert isinstance(inst, self.klass) + assert dummy.message is format # Means ugettext() called + assert inst.format is format + assert_equal(inst.message, format % kw) + assert_equal(inst.strerror, uformat % kw) + assert inst.forwarded is False + assert inst.key1 is val1 + assert inst.key2 is val2 + + # Test with format=None, message=unicode + dummy = dummy_ugettext(uformat) + context.ugettext = dummy + inst = self.new(message=message, **kw) + assert isinstance(inst, self.klass) + assert not hasattr(dummy, 'message') # Means ugettext() not called + assert inst.format is None + assert inst.message is message + assert inst.strerror is message + assert inst.forwarded is True + assert inst.key1 is val1 + assert inst.key2 is val2 + + + ################## + # Test a subclass: + class subclass(self.klass): + format = '%(true)r %(text)r %(number)r' + + uformat = u'Translated %(true)r %(text)r %(number)r' + kw = dict(true=True, text='Hello!', number=18) + + dummy = dummy_ugettext(uformat) + context.ugettext = dummy + + # Test with format=str, message=None + e = raises(ValueError, subclass, format, **kw) + assert str(e) == 'non-generic %r needs format=None; got format=%r' % ( + 'subclass', format) + + # Test with format=None, message=None: + inst = subclass(**kw) + assert dummy.message is subclass.format # Means ugettext() called + assert inst.format is subclass.format + assert_equal(inst.message, subclass.format % kw) + assert_equal(inst.strerror, uformat % kw) + assert inst.forwarded is False + assert inst.true is True + assert inst.text is kw['text'] + assert inst.number is kw['number'] + + # Test with format=None, message=unicode: + dummy = dummy_ugettext(uformat) + context.ugettext = dummy + inst = subclass(message=message, **kw) + assert not hasattr(dummy, 'message') # Means ugettext() not called + assert inst.format is subclass.format + assert inst.message is message + assert inst.strerror is message + assert inst.forwarded is True + assert inst.true is True + assert inst.text is kw['text'] + assert inst.number is kw['number'] + del context.ugettext + + +def test_public_errors(): + """ + Test the `ipalib.errors2.public_errors` module variable. + """ + i = 0 + for klass in errors2.public_errors: + assert issubclass(klass, StandardError) + assert issubclass(klass, errors2.PublicError) + assert not issubclass(klass, errors2.PrivateError) + assert type(klass.errno) is int + assert 900 <= klass.errno <= 5999 + doc = inspect.getdoc(klass) + assert doc is not None, 'need class docstring for %s' % klass.__name__ + m = re.match(r'^\*{2}(\d+)\*{2} ', doc) + assert m is not None, "need '**ERRNO**' in %s docstring" % klass.__name__ + errno = int(m.group(1)) + assert errno == klass.errno, ( + 'docstring=%r but errno=%r in %s' % (errno, klass.errno, klass.__name__) + ) + + # Test format + if klass.format is not None: + assert klass.format is errors2.__messages[i] + i += 1 diff --git a/tests/test_ipalib/test_errors.py b/tests/test_ipalib/test_errors.py new file mode 100644 index 000000000..f1dd5dc8e --- /dev/null +++ b/tests/test_ipalib/test_errors.py @@ -0,0 +1,289 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib.errors` module. +""" + +from tests.util import raises, ClassChecker +from ipalib import errors + + +type_format = '%s: need a %r; got %r' + + +def check_TypeError(f, value, type_, name, **kw): + e = raises(TypeError, f, value, type_, name, **kw) + assert e.value is value + assert e.type is type_ + assert e.name is name + assert str(e) == type_format % (name, type_, value) + + +def test_raise_TypeError(): + """ + Test the `ipalib.errors.raise_TypeError` function. + """ + f = errors.raise_TypeError + value = 'Hello.' + type_ = unicode + name = 'message' + + check_TypeError(f, value, type_, name) + + # name not an str + fail_name = 42 + e = raises(AssertionError, f, value, type_, fail_name) + assert str(e) == type_format % ('name', str, fail_name), str(e) + + # type_ not a type: + fail_type = unicode() + e = raises(AssertionError, f, value, fail_type, name) + assert str(e) == type_format % ('type_', type, fail_type) + + # type(value) is type_: + fail_value = u'How are you?' + e = raises(AssertionError, f, fail_value, type_, name) + assert str(e) == 'value: %r is a %r' % (fail_value, type_) + + +def test_check_type(): + """ + Test the `ipalib.errors.check_type` function. + """ + f = errors.check_type + value = 'How are you?' + type_ = str + name = 'greeting' + + # Should pass: + assert value is f(value, type_, name) + assert None is f(None, type_, name, allow_none=True) + + # Should raise TypeError + check_TypeError(f, None, type_, name) + check_TypeError(f, value, basestring, name) + check_TypeError(f, value, unicode, name) + + # name not an str + fail_name = unicode(name) + e = raises(AssertionError, f, value, type_, fail_name) + assert str(e) == type_format % ('name', str, fail_name) + + # type_ not a type: + fail_type = 42 + e = raises(AssertionError, f, value, fail_type, name) + assert str(e) == type_format % ('type_', type, fail_type) + + # allow_none not a bool: + fail_bool = 0 + e = raises(AssertionError, f, value, type_, name, allow_none=fail_bool) + assert str(e) == type_format % ('allow_none', bool, fail_bool) + + +def test_check_isinstance(): + """ + Test the `ipalib.errors.check_isinstance` function. + """ + f = errors.check_isinstance + value = 'How are you?' + type_ = str + name = 'greeting' + + # Should pass: + assert value is f(value, type_, name) + assert value is f(value, basestring, name) + assert None is f(None, type_, name, allow_none=True) + + # Should raise TypeError + check_TypeError(f, None, type_, name) + check_TypeError(f, value, unicode, name) + + # name not an str + fail_name = unicode(name) + e = raises(AssertionError, f, value, type_, fail_name) + assert str(e) == type_format % ('name', str, fail_name) + + # type_ not a type: + fail_type = 42 + e = raises(AssertionError, f, value, fail_type, name) + assert str(e) == type_format % ('type_', type, fail_type) + + # allow_none not a bool: + fail_bool = 0 + e = raises(AssertionError, f, value, type_, name, allow_none=fail_bool) + assert str(e) == type_format % ('allow_none', bool, fail_bool) + + +class test_IPAError(ClassChecker): + """ + Test the `ipalib.errors.IPAError` exception. + """ + _cls = errors.IPAError + + def test_class(self): + """ + Test the `ipalib.errors.IPAError` exception. + """ + assert self.cls.__bases__ == (StandardError,) + + def test_init(self): + """ + Test the `ipalib.errors.IPAError.__init__` method. + """ + args = ('one fish', 'two fish') + e = self.cls(*args) + assert e.args == args + assert self.cls().args == tuple() + + def test_str(self): + """ + Test the `ipalib.errors.IPAError.__str__` method. + """ + f = 'The %s color is %s.' + class custom_error(self.cls): + format = f + for args in [('sexiest', 'red'), ('most-batman-like', 'black')]: + e = custom_error(*args) + assert e.args == args + assert str(e) == f % args + + +class test_ValidationError(ClassChecker): + """ + Test the `ipalib.errors.ValidationError` exception. + """ + _cls = errors.ValidationError + + def test_class(self): + """ + Test the `ipalib.errors.ValidationError` exception. + """ + assert self.cls.__bases__ == (errors.IPAError,) + + def test_init(self): + """ + Test the `ipalib.errors.ValidationError.__init__` method. + """ + name = 'login' + value = 'Whatever' + error = 'Must be lowercase.' + for index in (None, 3): + e = self.cls(name, value, error, index=index) + assert e.name is name + assert e.value is value + assert e.error is error + assert e.index is index + assert str(e) == 'invalid %r value %r: %s' % (name, value, error) + # Check that index default is None: + assert self.cls(name, value, error).index is None + # Check non str name raises AssertionError: + raises(AssertionError, self.cls, unicode(name), value, error) + # Check non int index raises AssertionError: + raises(AssertionError, self.cls, name, value, error, index=5.0) + # Check negative index raises AssertionError: + raises(AssertionError, self.cls, name, value, error, index=-2) + + +class test_ConversionError(ClassChecker): + """ + Test the `ipalib.errors.ConversionError` exception. + """ + _cls = errors.ConversionError + + def test_class(self): + """ + Test the `ipalib.errors.ConversionError` exception. + """ + assert self.cls.__bases__ == (errors.ValidationError,) + + def test_init(self): + """ + Test the `ipalib.errors.ConversionError.__init__` method. + """ + name = 'some_arg' + value = '42.0' + class type_(object): + conversion_error = 'Not an integer' + for index in (None, 7): + e = self.cls(name, value, type_, index=index) + assert e.name is name + assert e.value is value + assert e.type is type_ + assert e.error is type_.conversion_error + assert e.index is index + assert str(e) == 'invalid %r value %r: %s' % (name, value, + type_.conversion_error) + # Check that index default is None: + assert self.cls(name, value, type_).index is None + + +class test_RuleError(ClassChecker): + """ + Test the `ipalib.errors.RuleError` exception. + """ + _cls = errors.RuleError + + def test_class(self): + """ + Test the `ipalib.errors.RuleError` exception. + """ + assert self.cls.__bases__ == (errors.ValidationError,) + + def test_init(self): + """ + Test the `ipalib.errors.RuleError.__init__` method. + """ + name = 'whatever' + value = 'The smallest weird number.' + def my_rule(value): + return 'Value is bad.' + error = my_rule(value) + for index in (None, 42): + e = self.cls(name, value, error, my_rule, index=index) + assert e.name is name + assert e.value is value + assert e.error is error + assert e.rule is my_rule + # Check that index default is None: + assert self.cls(name, value, error, my_rule).index is None + + +class test_RequirementError(ClassChecker): + """ + Test the `ipalib.errors.RequirementError` exception. + """ + _cls = errors.RequirementError + + def test_class(self): + """ + Test the `ipalib.errors.RequirementError` exception. + """ + assert self.cls.__bases__ == (errors.ValidationError,) + + def test_init(self): + """ + Test the `ipalib.errors.RequirementError.__init__` method. + """ + name = 'givenname' + e = self.cls(name) + assert e.name is name + assert e.value is None + assert e.error == 'Required' + assert e.index is None diff --git a/tests/test_ipalib/test_frontend.py b/tests/test_ipalib/test_frontend.py new file mode 100644 index 000000000..071a70fd5 --- /dev/null +++ b/tests/test_ipalib/test_frontend.py @@ -0,0 +1,771 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib.frontend` module. +""" + +from tests.util import raises, getitem, no_set, no_del, read_only +from tests.util import check_TypeError, ClassChecker, create_test_api +from tests.util import assert_equal +from ipalib.constants import TYPE_ERROR +from ipalib import frontend, backend, plugable, errors2, errors, parameters, config + + +def test_RULE_FLAG(): + assert frontend.RULE_FLAG == 'validation_rule' + + +def test_rule(): + """ + Test the `ipalib.frontend.rule` function. + """ + flag = frontend.RULE_FLAG + rule = frontend.rule + def my_func(): + pass + assert not hasattr(my_func, flag) + rule(my_func) + assert getattr(my_func, flag) is True + @rule + def my_func2(): + pass + assert getattr(my_func2, flag) is True + + +def test_is_rule(): + """ + Test the `ipalib.frontend.is_rule` function. + """ + is_rule = frontend.is_rule + flag = frontend.RULE_FLAG + + class no_call(object): + def __init__(self, value): + if value is not None: + assert value in (True, False) + setattr(self, flag, value) + + class call(no_call): + def __call__(self): + pass + + assert is_rule(call(True)) + assert not is_rule(no_call(True)) + assert not is_rule(call(False)) + assert not is_rule(call(None)) + + +class test_Command(ClassChecker): + """ + Test the `ipalib.frontend.Command` class. + """ + + _cls = frontend.Command + + def get_subcls(self): + """ + Return a standard subclass of `ipalib.frontend.Command`. + """ + class Rule(object): + def __init__(self, name): + self.name = name + + def __call__(self, _, value): + if value != self.name: + return _('must equal %r') % self.name + + default_from = parameters.DefaultFrom( + lambda arg: arg, + 'default_from' + ) + normalizer = lambda value: value.lower() + + class example(self.cls): + takes_options = ( + parameters.Str('option0', Rule('option0'), + normalizer=normalizer, + default_from=default_from, + ), + parameters.Str('option1', Rule('option1'), + normalizer=normalizer, + default_from=default_from, + ), + ) + return example + + def get_instance(self, args=tuple(), options=tuple()): + """ + Helper method used to test args and options. + """ + class example(self.cls): + takes_args = args + takes_options = options + o = example() + o.finalize() + return o + + def test_class(self): + """ + Test the `ipalib.frontend.Command` class. + """ + assert self.cls.__bases__ == (plugable.Plugin,) + assert self.cls.takes_options == tuple() + assert self.cls.takes_args == tuple() + + def test_get_args(self): + """ + Test the `ipalib.frontend.Command.get_args` method. + """ + assert list(self.cls().get_args()) == [] + args = ('login', 'stuff') + o = self.get_instance(args=args) + assert o.get_args() is args + + def test_get_options(self): + """ + Test the `ipalib.frontend.Command.get_options` method. + """ + assert list(self.cls().get_options()) == [] + options = ('verbose', 'debug') + o = self.get_instance(options=options) + assert o.get_options() is options + + def test_args(self): + """ + Test the ``ipalib.frontend.Command.args`` instance attribute. + """ + assert 'args' in self.cls.__public__ # Public + assert self.cls().args is None + o = self.cls() + o.finalize() + assert type(o.args) is plugable.NameSpace + assert len(o.args) == 0 + args = ('destination', 'source?') + ns = self.get_instance(args=args).args + assert type(ns) is plugable.NameSpace + assert len(ns) == len(args) + assert list(ns) == ['destination', 'source'] + assert type(ns.destination) is parameters.Str + assert type(ns.source) is parameters.Str + assert ns.destination.required is True + assert ns.destination.multivalue is False + assert ns.source.required is False + assert ns.source.multivalue is False + + # Test TypeError: + e = raises(TypeError, self.get_instance, args=(u'whatever',)) + assert str(e) == TYPE_ERROR % ( + 'spec', (str, parameters.Param), u'whatever', unicode) + + # Test ValueError, required after optional: + e = raises(ValueError, self.get_instance, args=('arg1?', 'arg2')) + assert str(e) == 'arg2: required argument after optional' + + # Test ValueError, scalar after multivalue: + e = raises(ValueError, self.get_instance, args=('arg1+', 'arg2')) + assert str(e) == 'arg2: only final argument can be multivalue' + + def test_max_args(self): + """ + Test the ``ipalib.frontend.Command.max_args`` instance attribute. + """ + o = self.get_instance() + assert o.max_args == 0 + o = self.get_instance(args=('one?',)) + assert o.max_args == 1 + o = self.get_instance(args=('one', 'two?')) + assert o.max_args == 2 + o = self.get_instance(args=('one', 'multi+',)) + assert o.max_args is None + o = self.get_instance(args=('one', 'multi*',)) + assert o.max_args is None + + def test_options(self): + """ + Test the ``ipalib.frontend.Command.options`` instance attribute. + """ + assert 'options' in self.cls.__public__ # Public + assert self.cls().options is None + o = self.cls() + o.finalize() + assert type(o.options) is plugable.NameSpace + assert len(o.options) == 0 + options = ('target', 'files*') + ns = self.get_instance(options=options).options + assert type(ns) is plugable.NameSpace + assert len(ns) == len(options) + assert list(ns) == ['target', 'files'] + assert type(ns.target) is parameters.Str + assert type(ns.files) is parameters.Str + assert ns.target.required is True + assert ns.target.multivalue is False + assert ns.files.required is False + assert ns.files.multivalue is True + + def test_convert(self): + """ + Test the `ipalib.frontend.Command.convert` method. + """ + assert 'convert' in self.cls.__public__ # Public + kw = dict( + option0=u'1.5', + option1=u'7', + ) + o = self.subcls() + o.finalize() + for (key, value) in o.convert(**kw).iteritems(): + assert_equal(unicode(kw[key]), value) + + def test_normalize(self): + """ + Test the `ipalib.frontend.Command.normalize` method. + """ + assert 'normalize' in self.cls.__public__ # Public + kw = dict( + option0=u'OPTION0', + option1=u'OPTION1', + ) + norm = dict((k, v.lower()) for (k, v) in kw.items()) + sub = self.subcls() + sub.finalize() + assert sub.normalize(**kw) == norm + + def test_get_default(self): + """ + Test the `ipalib.frontend.Command.get_default` method. + """ + assert 'get_default' in self.cls.__public__ # Public + # FIXME: Add an updated unit tests for get_default() + + def test_validate(self): + """ + Test the `ipalib.frontend.Command.validate` method. + """ + assert 'validate' in self.cls.__public__ # Public + + sub = self.subcls() + sub.finalize() + + # Check with valid values + okay = dict( + option0=u'option0', + option1=u'option1', + another_option='some value', + ) + sub.validate(**okay) + + # Check with an invalid value + fail = dict(okay) + fail['option0'] = u'whatever' + e = raises(errors2.ValidationError, sub.validate, **fail) + assert_equal(e.name, 'option0') + assert_equal(e.value, u'whatever') + assert_equal(e.error, u"must equal 'option0'") + assert e.rule.__class__.__name__ == 'Rule' + assert e.index is None + + # Check with a missing required arg + fail = dict(okay) + fail.pop('option1') + e = raises(errors.RequirementError, sub.validate, **fail) + assert e.name == 'option1' + assert e.value is None + assert e.index is None + + def test_execute(self): + """ + Test the `ipalib.frontend.Command.execute` method. + """ + assert 'execute' in self.cls.__public__ # Public + o = self.cls() + e = raises(NotImplementedError, o.execute) + assert str(e) == 'Command.execute()' + + def test_args_to_kw(self): + """ + Test the `ipalib.frontend.Command.args_to_kw` method. + """ + assert 'args_to_kw' in self.cls.__public__ # Public + o = self.get_instance(args=('one', 'two?')) + assert o.args_to_kw(1) == dict(one=1) + assert o.args_to_kw(1, 2) == dict(one=1, two=2) + + o = self.get_instance(args=('one', 'two*')) + assert o.args_to_kw(1) == dict(one=1) + assert o.args_to_kw(1, 2) == dict(one=1, two=(2,)) + assert o.args_to_kw(1, 2, 3) == dict(one=1, two=(2, 3)) + + o = self.get_instance(args=('one', 'two+')) + assert o.args_to_kw(1) == dict(one=1) + assert o.args_to_kw(1, 2) == dict(one=1, two=(2,)) + assert o.args_to_kw(1, 2, 3) == dict(one=1, two=(2, 3)) + + o = self.get_instance() + e = raises(errors.ArgumentError, o.args_to_kw, 1) + assert str(e) == 'example takes no arguments' + + o = self.get_instance(args=('one?',)) + e = raises(errors.ArgumentError, o.args_to_kw, 1, 2) + assert str(e) == 'example takes at most 1 argument' + + o = self.get_instance(args=('one', 'two?')) + e = raises(errors.ArgumentError, o.args_to_kw, 1, 2, 3) + assert str(e) == 'example takes at most 2 arguments' + + def test_params_2_args_options(self): + """ + Test the `ipalib.frontend.Command.params_2_args_options` method. + """ + assert 'params_2_args_options' in self.cls.__public__ # Public + o = self.get_instance(args=['one'], options=['two']) + assert o.params_2_args_options({}) == ((None,), dict(two=None)) + assert o.params_2_args_options(dict(one=1)) == ((1,), dict(two=None)) + assert o.params_2_args_options(dict(two=2)) == ((None,), dict(two=2)) + assert o.params_2_args_options(dict(two=2, one=1)) == \ + ((1,), dict(two=2)) + + def test_run(self): + """ + Test the `ipalib.frontend.Command.run` method. + """ + class my_cmd(self.cls): + def execute(self, *args, **kw): + return ('execute', args, kw) + + def forward(self, *args, **kw): + return ('forward', args, kw) + + args = ('Hello,', 'world,') + kw = dict(how_are='you', on_this='fine day?') + + # Test in server context: + (api, home) = create_test_api(in_server=True) + api.finalize() + o = my_cmd() + o.set_api(api) + assert o.run.im_func is self.cls.run.im_func + assert ('execute', args, kw) == o.run(*args, **kw) + assert o.run.im_func is my_cmd.execute.im_func + + # Test in non-server context + (api, home) = create_test_api(in_server=False) + api.finalize() + o = my_cmd() + o.set_api(api) + assert o.run.im_func is self.cls.run.im_func + assert ('forward', args, kw) == o.run(*args, **kw) + assert o.run.im_func is my_cmd.forward.im_func + + +class test_LocalOrRemote(ClassChecker): + """ + Test the `ipalib.frontend.LocalOrRemote` class. + """ + _cls = frontend.LocalOrRemote + + def test_init(self): + """ + Test the `ipalib.frontend.LocalOrRemote.__init__` method. + """ + o = self.cls() + o.finalize() + assert list(o.args) == [] + assert list(o.options) == ['server'] + op = o.options.server + assert op.required is False + assert op.default is False + + def test_run(self): + """ + Test the `ipalib.frontend.LocalOrRemote.run` method. + """ + class example(self.cls): + takes_args = ['key?'] + + def forward(self, *args, **options): + return ('forward', args, options) + + def execute(self, *args, **options): + return ('execute', args, options) + + # Test when in_server=False: + (api, home) = create_test_api(in_server=False) + api.register(example) + api.finalize() + cmd = api.Command.example + assert cmd() == ('execute', (None,), dict(server=False)) + assert cmd(u'var') == ('execute', (u'var',), dict(server=False)) + assert cmd(server=True) == ('forward', (None,), dict(server=True)) + assert cmd(u'var', server=True) == \ + ('forward', (u'var',), dict(server=True)) + + # Test when in_server=True (should always call execute): + (api, home) = create_test_api(in_server=True) + api.register(example) + api.finalize() + cmd = api.Command.example + assert cmd() == ('execute', (None,), dict(server=False)) + assert cmd(u'var') == ('execute', (u'var',), dict(server=False)) + assert cmd(server=True) == ('execute', (None,), dict(server=True)) + assert cmd(u'var', server=True) == \ + ('execute', (u'var',), dict(server=True)) + + +class test_Object(ClassChecker): + """ + Test the `ipalib.frontend.Object` class. + """ + _cls = frontend.Object + + def test_class(self): + """ + Test the `ipalib.frontend.Object` class. + """ + assert self.cls.__bases__ == (plugable.Plugin,) + assert self.cls.backend is None + assert self.cls.methods is None + assert self.cls.properties is None + assert self.cls.params is None + assert self.cls.params_minus_pk is None + assert self.cls.takes_params == tuple() + + def test_init(self): + """ + Test the `ipalib.frontend.Object.__init__` method. + """ + o = self.cls() + assert o.backend is None + assert o.methods is None + assert o.properties is None + assert o.params is None + assert o.params_minus_pk is None + assert o.properties is None + + def test_set_api(self): + """ + Test the `ipalib.frontend.Object.set_api` method. + """ + # Setup for test: + class DummyAttribute(object): + def __init__(self, obj_name, attr_name, name=None): + self.obj_name = obj_name + self.attr_name = attr_name + if name is None: + self.name = '%s_%s' % (obj_name, attr_name) + else: + self.name = name + self.param = frontend.create_param(attr_name) + + def __clone__(self, attr_name): + return self.__class__( + self.obj_name, + self.attr_name, + getattr(self, attr_name) + ) + + def get_attributes(cnt, format): + for name in ['other', 'user', 'another']: + for i in xrange(cnt): + yield DummyAttribute(name, format % i) + + cnt = 10 + formats = dict( + methods='method_%d', + properties='property_%d', + ) + + + _d = dict( + Method=plugable.NameSpace( + get_attributes(cnt, formats['methods']) + ), + Property=plugable.NameSpace( + get_attributes(cnt, formats['properties']) + ), + ) + api = plugable.MagicDict(_d) + assert len(api.Method) == cnt * 3 + assert len(api.Property) == cnt * 3 + + class user(self.cls): + pass + + # Actually perform test: + o = user() + o.set_api(api) + assert read_only(o, 'api') is api + for name in ['methods', 'properties']: + namespace = getattr(o, name) + assert isinstance(namespace, plugable.NameSpace) + assert len(namespace) == cnt + f = formats[name] + for i in xrange(cnt): + attr_name = f % i + attr = namespace[attr_name] + assert isinstance(attr, DummyAttribute) + assert attr is getattr(namespace, attr_name) + assert attr.obj_name == 'user' + assert attr.attr_name == attr_name + assert attr.name == attr_name + + # Test params instance attribute + o = self.cls() + o.set_api(api) + ns = o.params + assert type(ns) is plugable.NameSpace + assert len(ns) == 0 + class example(self.cls): + takes_params = ('banana', 'apple') + o = example() + o.set_api(api) + ns = o.params + assert type(ns) is plugable.NameSpace + assert len(ns) == 2, repr(ns) + assert list(ns) == ['banana', 'apple'] + for p in ns(): + assert type(p) is parameters.Str + assert p.required is True + assert p.multivalue is False + + def test_primary_key(self): + """ + Test the `ipalib.frontend.Object.primary_key` attribute. + """ + (api, home) = create_test_api() + api.finalize() + + # Test with no primary keys: + class example1(self.cls): + takes_params = ( + 'one', + 'two', + ) + o = example1() + o.set_api(api) + assert o.primary_key is None + assert o.params_minus_pk is None + + # Test with 1 primary key: + class example2(self.cls): + takes_params = ( + 'one', + 'two', + parameters.Str('three', primary_key=True), + 'four', + ) + o = example2() + o.set_api(api) + pk = o.primary_key + assert type(pk) is parameters.Str + assert pk.name == 'three' + assert pk.primary_key is True + assert o.params[2] is o.primary_key + assert isinstance(o.params_minus_pk, plugable.NameSpace) + assert list(o.params_minus_pk) == ['one', 'two', 'four'] + + # Test with multiple primary_key: + class example3(self.cls): + takes_params = ( + parameters.Str('one', primary_key=True), + parameters.Str('two', primary_key=True), + 'three', + parameters.Str('four', primary_key=True), + ) + o = example3() + e = raises(ValueError, o.set_api, api) + assert str(e) == \ + 'example3 (Object) has multiple primary keys: one, two, four' + + def test_backend(self): + """ + Test the `ipalib.frontend.Object.backend` attribute. + """ + (api, home) = create_test_api() + class ldap(backend.Backend): + whatever = 'It worked!' + api.register(ldap) + class user(frontend.Object): + backend_name = 'ldap' + api.register(user) + api.finalize() + b = api.Object.user.backend + assert isinstance(b, ldap) + assert b.whatever == 'It worked!' + + def test_get_dn(self): + """ + Test the `ipalib.frontend.Object.get_dn` method. + """ + assert 'get_dn' in self.cls.__public__ # Public + o = self.cls() + e = raises(NotImplementedError, o.get_dn, 'primary key') + assert str(e) == 'Object.get_dn()' + class user(self.cls): + pass + o = user() + e = raises(NotImplementedError, o.get_dn, 'primary key') + assert str(e) == 'user.get_dn()' + + +class test_Attribute(ClassChecker): + """ + Test the `ipalib.frontend.Attribute` class. + """ + _cls = frontend.Attribute + + def test_class(self): + """ + Test the `ipalib.frontend.Attribute` class. + """ + assert self.cls.__bases__ == (plugable.Plugin,) + assert type(self.cls.obj) is property + assert type(self.cls.obj_name) is property + assert type(self.cls.attr_name) is property + + def test_init(self): + """ + Test the `ipalib.frontend.Attribute.__init__` method. + """ + class user_add(self.cls): + pass + o = user_add() + assert read_only(o, 'obj') is None + assert read_only(o, 'obj_name') == 'user' + assert read_only(o, 'attr_name') == 'add' + + def test_set_api(self): + """ + Test the `ipalib.frontend.Attribute.set_api` method. + """ + user_obj = 'The user frontend.Object instance' + class api(object): + Object = dict(user=user_obj) + class user_add(self.cls): + pass + o = user_add() + assert read_only(o, 'api') is None + assert read_only(o, 'obj') is None + o.set_api(api) + assert read_only(o, 'api') is api + assert read_only(o, 'obj') is user_obj + + +class test_Method(ClassChecker): + """ + Test the `ipalib.frontend.Method` class. + """ + _cls = frontend.Method + + def test_class(self): + """ + Test the `ipalib.frontend.Method` class. + """ + assert self.cls.__bases__ == (frontend.Attribute, frontend.Command) + assert self.cls.implements(frontend.Command) + assert self.cls.implements(frontend.Attribute) + + def test_init(self): + """ + Test the `ipalib.frontend.Method.__init__` method. + """ + class user_add(self.cls): + pass + o = user_add() + assert o.name == 'user_add' + assert o.obj_name == 'user' + assert o.attr_name == 'add' + assert frontend.Command.implemented_by(o) + assert frontend.Attribute.implemented_by(o) + + +class test_Property(ClassChecker): + """ + Test the `ipalib.frontend.Property` class. + """ + _cls = frontend.Property + + def get_subcls(self): + """ + Return a standard subclass of `ipalib.frontend.Property`. + """ + class user_givenname(self.cls): + 'User first name' + + @frontend.rule + def rule0_lowercase(self, value): + if not value.islower(): + return 'Must be lowercase' + return user_givenname + + def test_class(self): + """ + Test the `ipalib.frontend.Property` class. + """ + assert self.cls.__bases__ == (frontend.Attribute,) + assert self.cls.klass is parameters.Str + + def test_init(self): + """ + Test the `ipalib.frontend.Property.__init__` method. + """ + o = self.subcls() + assert len(o.rules) == 1 + assert o.rules[0].__name__ == 'rule0_lowercase' + param = o.param + assert isinstance(param, parameters.Str) + assert param.name == 'givenname' + assert param.doc == 'User first name' + + +class test_Application(ClassChecker): + """ + Test the `ipalib.frontend.Application` class. + """ + _cls = frontend.Application + + def test_class(self): + """ + Test the `ipalib.frontend.Application` class. + """ + assert self.cls.__bases__ == (frontend.Command,) + assert type(self.cls.application) is property + + def test_application(self): + """ + Test the `ipalib.frontend.Application.application` property. + """ + assert 'application' in self.cls.__public__ # Public + assert 'set_application' in self.cls.__public__ # Public + app = 'The external application' + class example(self.cls): + 'A subclass' + for o in (self.cls(), example()): + assert read_only(o, 'application') is None + e = raises(TypeError, o.set_application, None) + assert str(e) == ( + '%s.application cannot be None' % o.__class__.__name__ + ) + o.set_application(app) + assert read_only(o, 'application') is app + e = raises(AttributeError, o.set_application, app) + assert str(e) == ( + '%s.application can only be set once' % o.__class__.__name__ + ) + assert read_only(o, 'application') is app diff --git a/tests/test_ipalib/test_parameters.py b/tests/test_ipalib/test_parameters.py new file mode 100644 index 000000000..261e14811 --- /dev/null +++ b/tests/test_ipalib/test_parameters.py @@ -0,0 +1,994 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib.parameters` module. +""" + +from types import NoneType +from inspect import isclass +from tests.util import raises, ClassChecker, read_only +from tests.util import dummy_ugettext, assert_equal +from tests.data import binary_bytes, utf8_bytes, unicode_str +from ipalib import parameters, request, errors2 +from ipalib.constants import TYPE_ERROR, CALLABLE_ERROR, NULLS + + +class test_DefaultFrom(ClassChecker): + """ + Test the `ipalib.parameters.DefaultFrom` class. + """ + _cls = parameters.DefaultFrom + + def test_init(self): + """ + Test the `ipalib.parameters.DefaultFrom.__init__` method. + """ + def callback(*args): + return args + keys = ('givenname', 'sn') + o = self.cls(callback, *keys) + assert read_only(o, 'callback') is callback + assert read_only(o, 'keys') == keys + lam = lambda first, last: first[0] + last + o = self.cls(lam) + assert read_only(o, 'keys') == ('first', 'last') + + # Test that TypeError is raised when callback isn't callable: + e = raises(TypeError, self.cls, 'whatever') + assert str(e) == CALLABLE_ERROR % ('callback', 'whatever', str) + + # Test that TypeError is raised when a key isn't an str: + e = raises(TypeError, self.cls, callback, 'givenname', 17) + assert str(e) == TYPE_ERROR % ('keys', str, 17, int) + + def test_call(self): + """ + Test the `ipalib.parameters.DefaultFrom.__call__` method. + """ + def callback(givenname, sn): + return givenname[0] + sn[0] + keys = ('givenname', 'sn') + o = self.cls(callback, *keys) + kw = dict( + givenname='John', + sn='Public', + hello='world', + ) + assert o(**kw) == 'JP' + assert o() is None + for key in ('givenname', 'sn'): + kw_copy = dict(kw) + del kw_copy[key] + assert o(**kw_copy) is None + + # Test using implied keys: + o = self.cls(lambda first, last: first[0] + last) + assert o(first='john', last='doe') == 'jdoe' + assert o(first='', last='doe') is None + assert o(one='john', two='doe') is None + + # Test that co_varnames slice is used: + def callback2(first, last): + letter = first[0] + return letter + last + o = self.cls(callback2) + assert o.keys == ('first', 'last') + assert o(first='john', last='doe') == 'jdoe' + + +def test_parse_param_spec(): + """ + Test the `ipalib.parameters.parse_param_spec` function. + """ + f = parameters.parse_param_spec + assert f('name') == ('name', dict(required=True, multivalue=False)) + assert f('name?') == ('name', dict(required=False, multivalue=False)) + assert f('name*') == ('name', dict(required=False, multivalue=True)) + assert f('name+') == ('name', dict(required=True, multivalue=True)) + + # Make sure other "funny" endings are *not* treated special: + assert f('name^') == ('name^', dict(required=True, multivalue=False)) + + # Test that TypeError is raised if spec isn't an str: + e = raises(TypeError, f, u'name?') + assert str(e) == TYPE_ERROR % ('spec', str, u'name?', unicode) + + # Test that ValueError is raised if len(spec) < 2: + e = raises(ValueError, f, 'n') + assert str(e) == "spec must be at least 2 characters; got 'n'" + + +class DummyRule(object): + def __init__(self, error=None): + assert error is None or type(error) is unicode + self.error = error + self.reset() + + def __call__(self, *args): + self.calls.append(args) + return self.error + + def reset(self): + self.calls = [] + + +class test_Param(ClassChecker): + """ + Test the `ipalib.parameters.Param` class. + """ + _cls = parameters.Param + + def test_init(self): + """ + Test the `ipalib.parameters.Param.__init__` method. + """ + name = 'my_param' + o = self.cls(name) + assert o.param_spec is name + assert o.name is name + assert o.nice == "Param('my_param')" + assert o.__islocked__() is True + + # Test default rules: + assert o.rules == tuple() + assert o.class_rules == tuple() + assert o.all_rules == tuple() + + # Test default kwarg values: + assert o.cli_name is name + assert o.label is None + assert o.doc == '' + assert o.required is True + assert o.multivalue is False + assert o.primary_key is False + assert o.normalizer is None + assert o.default is None + assert o.default_from is None + assert o.create_default is None + assert o._get_default is None + assert o.autofill is False + assert o.query is False + assert o.flags == frozenset() + + # Test that ValueError is raised when a kwarg from a subclass + # conflicts with an attribute: + class Subclass(self.cls): + kwargs = self.cls.kwargs + ( + ('convert', callable, None), + ) + e = raises(ValueError, Subclass, name) + assert str(e) == "kwarg 'convert' conflicts with attribute on Subclass" + + # Test type validation of keyword arguments: + class Subclass(self.cls): + kwargs = self.cls.kwargs + ( + ('extra1', bool, True), + ('extra2', str, 'Hello'), + ('extra3', (int, float), 42), + ('extra4', callable, lambda whatever: whatever + 7), + ) + o = Subclass('my_param') # Test with no **kw: + for (key, kind, default) in o.kwargs: + # Test with a type invalid for all: + value = object() + kw = {key: value} + e = raises(TypeError, Subclass, 'my_param', **kw) + if kind is callable: + assert str(e) == CALLABLE_ERROR % (key, value, type(value)) + else: + assert str(e) == TYPE_ERROR % (key, kind, value, type(value)) + # Test with None: + kw = {key: None} + Subclass('my_param', **kw) + + # Test when using unknown kwargs: + e = raises(TypeError, self.cls, 'my_param', + flags=['hello', 'world'], + whatever=u'Hooray!', + ) + assert str(e) == \ + "Param('my_param'): takes no such kwargs: 'whatever'" + e = raises(TypeError, self.cls, 'my_param', great='Yes', ape='he is!') + assert str(e) == \ + "Param('my_param'): takes no such kwargs: 'ape', 'great'" + + # Test that ValueError is raised if you provide both default_from and + # create_default: + e = raises(ValueError, self.cls, 'my_param', + default_from=lambda first, last: first[0] + last, + create_default=lambda **kw: 'The Default' + ) + assert str(e) == '%s: cannot have both %r and %r' % ( + "Param('my_param')", 'default_from', 'create_default', + ) + + # Test that _get_default gets set: + call1 = lambda first, last: first[0] + last + call2 = lambda **kw: 'The Default' + o = self.cls('my_param', default_from=call1) + assert o.default_from.callback is call1 + assert o._get_default is o.default_from + o = self.cls('my_param', create_default=call2) + assert o.create_default is call2 + assert o._get_default is call2 + + def test_repr(self): + """ + Test the `ipalib.parameters.Param.__repr__` method. + """ + for name in ['name', 'name?', 'name*', 'name+']: + o = self.cls(name) + assert repr(o) == 'Param(%r)' % name + o = self.cls('name', required=False) + assert repr(o) == "Param('name', required=False)" + o = self.cls('name', multivalue=True) + assert repr(o) == "Param('name', multivalue=True)" + + def test_clone(self): + """ + Test the `ipalib.parameters.Param.clone` method. + """ + # Test with the defaults + orig = self.cls('my_param') + clone = orig.clone() + assert clone is not orig + assert type(clone) is self.cls + assert clone.name is orig.name + for (key, kind, default) in self.cls.kwargs: + assert getattr(clone, key) is getattr(orig, key) + + # Test with a param spec: + orig = self.cls('my_param*') + assert orig.param_spec == 'my_param*' + clone = orig.clone() + assert clone.param_spec == 'my_param' + assert clone is not orig + assert type(clone) is self.cls + for (key, kind, default) in self.cls.kwargs: + assert getattr(clone, key) is getattr(orig, key) + + # Test with overrides: + orig = self.cls('my_param*') + assert orig.required is False + assert orig.multivalue is True + clone = orig.clone(required=True) + assert clone is not orig + assert type(clone) is self.cls + assert clone.required is True + assert clone.multivalue is True + assert clone.param_spec == 'my_param' + assert clone.name == 'my_param' + + def test_get_label(self): + """ + Test the `ipalib.parameters.get_label` method. + """ + context = request.context + cli_name = 'the_cli_name' + message = 'The Label' + label = lambda _: _(message) + o = self.cls('name', cli_name=cli_name, label=label) + assert o.label is label + + ## Scenario 1: label=callable (a lambda form) + + # Test with no context.ugettext: + assert not hasattr(context, 'ugettext') + assert_equal(o.get_label(), u'The Label') + + # Test with dummy context.ugettext: + assert not hasattr(context, 'ugettext') + dummy = dummy_ugettext() + context.ugettext = dummy + assert o.get_label() is dummy.translation + assert dummy.message is message + del context.ugettext + + ## Scenario 2: label=None + o = self.cls('name', cli_name=cli_name) + assert o.label is None + + # Test with no context.ugettext: + assert not hasattr(context, 'ugettext') + assert_equal(o.get_label(), u'the_cli_name') + + # Test with dummy context.ugettext: + assert not hasattr(context, 'ugettext') + dummy = dummy_ugettext() + context.ugettext = dummy + assert_equal(o.get_label(), u'the_cli_name') + assert not hasattr(dummy, 'message') + + # Cleanup + del context.ugettext + assert not hasattr(context, 'ugettext') + + def test_convert(self): + """ + Test the `ipalib.parameters.Param.convert` method. + """ + okay = ('Hello', u'Hello', 0, 4.2, True, False) + class Subclass(self.cls): + def _convert_scalar(self, value, index=None): + return value + + # Test when multivalue=False: + o = Subclass('my_param') + for value in NULLS: + assert o.convert(value) is None + for value in okay: + assert o.convert(value) is value + + # Test when multivalue=True: + o = Subclass('my_param', multivalue=True) + for value in NULLS: + assert o.convert(value) is None + assert o.convert(okay) == okay + assert o.convert(NULLS) is None + assert o.convert(okay + NULLS) == okay + assert o.convert(NULLS + okay) == okay + for value in okay: + assert o.convert(value) == (value,) + assert o.convert([None, value]) == (value,) + assert o.convert([value, None]) == (value,) + + def test_convert_scalar(self): + """ + Test the `ipalib.parameters.Param._convert_scalar` method. + """ + dummy = dummy_ugettext() + + # Test with correct type: + o = self.cls('my_param') + assert o._convert_scalar(None) is None + assert dummy.called() is False + # Test with incorrect type + e = raises(errors2.ConversionError, o._convert_scalar, 'hello', index=17) + + def test_validate(self): + """ + Test the `ipalib.parameters.Param.validate` method. + """ + + # Test in default state (with no rules, no kwarg): + o = self.cls('my_param') + e = raises(errors2.RequirementError, o.validate, None) + assert e.name == 'my_param' + + # Test with required=False + o = self.cls('my_param', required=False) + assert o.required is False + assert o.validate(None) is None + + # Test with query=True: + o = self.cls('my_param', query=True) + assert o.query is True + e = raises(errors2.RequirementError, o.validate, None) + assert_equal(e.name, 'my_param') + + # Test with multivalue=True: + o = self.cls('my_param', multivalue=True) + e = raises(TypeError, o.validate, []) + assert str(e) == TYPE_ERROR % ('value', tuple, [], list) + e = raises(ValueError, o.validate, tuple()) + assert str(e) == 'value: empty tuple must be converted to None' + + # Test with wrong (scalar) type: + e = raises(TypeError, o.validate, (None, None, 42, None)) + assert str(e) == TYPE_ERROR % ('value[2]', NoneType, 42, int) + o = self.cls('my_param') + e = raises(TypeError, o.validate, 'Hello') + assert str(e) == TYPE_ERROR % ('value', NoneType, 'Hello', str) + + class Example(self.cls): + type = int + + # Test with some rules and multivalue=False + pass1 = DummyRule() + pass2 = DummyRule() + fail = DummyRule(u'no good') + o = Example('example', pass1, pass2) + assert o.multivalue is False + assert o.validate(11) is None + assert pass1.calls == [(request.ugettext, 11)] + assert pass2.calls == [(request.ugettext, 11)] + pass1.reset() + pass2.reset() + o = Example('example', pass1, pass2, fail) + e = raises(errors2.ValidationError, o.validate, 42) + assert e.name == 'example' + assert e.error == u'no good' + assert e.index is None + assert pass1.calls == [(request.ugettext, 42)] + assert pass2.calls == [(request.ugettext, 42)] + assert fail.calls == [(request.ugettext, 42)] + + # Test with some rules and multivalue=True + pass1 = DummyRule() + pass2 = DummyRule() + fail = DummyRule(u'this one is not good') + o = Example('example', pass1, pass2, multivalue=True) + assert o.multivalue is True + assert o.validate((3, 9)) is None + assert pass1.calls == [ + (request.ugettext, 3), + (request.ugettext, 9), + ] + assert pass2.calls == [ + (request.ugettext, 3), + (request.ugettext, 9), + ] + pass1.reset() + pass2.reset() + o = Example('multi_example', pass1, pass2, fail, multivalue=True) + assert o.multivalue is True + e = raises(errors2.ValidationError, o.validate, (3, 9)) + assert e.name == 'multi_example' + assert e.error == u'this one is not good' + assert e.index == 0 + assert pass1.calls == [(request.ugettext, 3)] + assert pass2.calls == [(request.ugettext, 3)] + assert fail.calls == [(request.ugettext, 3)] + + def test_validate_scalar(self): + """ + Test the `ipalib.parameters.Param._validate_scalar` method. + """ + class MyParam(self.cls): + type = bool + okay = DummyRule() + o = MyParam('my_param', okay) + + # Test that TypeError is appropriately raised: + e = raises(TypeError, o._validate_scalar, 0) + assert str(e) == TYPE_ERROR % ('value', bool, 0, int) + e = raises(TypeError, o._validate_scalar, 'Hi', index=4) + assert str(e) == TYPE_ERROR % ('value[4]', bool, 'Hi', str) + e = raises(TypeError, o._validate_scalar, True, index=3.0) + assert str(e) == TYPE_ERROR % ('index', int, 3.0, float) + + # Test with passing rule: + assert o._validate_scalar(True, index=None) is None + assert o._validate_scalar(False, index=None) is None + assert okay.calls == [ + (request.ugettext, True), + (request.ugettext, False), + ] + + # Test with a failing rule: + okay = DummyRule() + fail = DummyRule(u'this describes the error') + o = MyParam('my_param', okay, fail) + e = raises(errors2.ValidationError, o._validate_scalar, True) + assert e.name == 'my_param' + assert e.error == u'this describes the error' + assert e.index is None + e = raises(errors2.ValidationError, o._validate_scalar, False, index=2) + assert e.name == 'my_param' + assert e.error == u'this describes the error' + assert e.index == 2 + assert okay.calls == [ + (request.ugettext, True), + (request.ugettext, False), + ] + assert fail.calls == [ + (request.ugettext, True), + (request.ugettext, False), + ] + + def test_get_default(self): + """ + Test the `ipalib.parameters.Param._get_default` method. + """ + class PassThrough(object): + value = None + + def __call__(self, value): + assert self.value is None + assert value is not None + self.value = value + return value + + def reset(self): + assert self.value is not None + self.value = None + + class Str(self.cls): + type = unicode + + def __init__(self, name, **kw): + self._convert_scalar = PassThrough() + super(Str, self).__init__(name, **kw) + + # Test with only a static default: + o = Str('my_str', + normalizer=PassThrough(), + default=u'Static Default', + ) + assert_equal(o.get_default(), u'Static Default') + assert o._convert_scalar.value is None + assert o.normalizer.value is None + + # Test with default_from: + o = Str('my_str', + normalizer=PassThrough(), + default=u'Static Default', + default_from=lambda first, last: first[0] + last, + ) + assert_equal(o.get_default(), u'Static Default') + assert o._convert_scalar.value is None + assert o.normalizer.value is None + default = o.get_default(first=u'john', last='doe') + assert_equal(default, u'jdoe') + assert o._convert_scalar.value is default + assert o.normalizer.value is default + + # Test with create_default: + o = Str('my_str', + normalizer=PassThrough(), + default=u'Static Default', + create_default=lambda **kw: u'The created default', + ) + default = o.get_default(first=u'john', last='doe') + assert_equal(default, u'The created default') + assert o._convert_scalar.value is default + assert o.normalizer.value is default + + +class test_Flag(ClassChecker): + """ + Test the `ipalib.parameters.Flag` class. + """ + _cls = parameters.Flag + + def test_init(self): + """ + Test the `ipalib.parameters.Flag.__init__` method. + """ + # Test with no kwargs: + o = self.cls('my_flag') + assert o.type is bool + assert isinstance(o, parameters.Bool) + assert o.autofill is True + assert o.default is False + + # Test that TypeError is raise if default is not a bool: + e = raises(TypeError, self.cls, 'my_flag', default=None) + assert str(e) == TYPE_ERROR % ('default', bool, None, NoneType) + + # Test with autofill=False, default=True + o = self.cls('my_flag', autofill=False, default=True) + assert o.autofill is True + assert o.default is True + + # Test when cloning: + orig = self.cls('my_flag') + for clone in [orig.clone(), orig.clone(autofill=False)]: + assert clone.autofill is True + assert clone.default is False + assert clone is not orig + assert type(clone) is self.cls + + # Test when cloning with default=True/False + orig = self.cls('my_flag') + assert orig.clone().default is False + assert orig.clone(default=True).default is True + orig = self.cls('my_flag', default=True) + assert orig.clone().default is True + assert orig.clone(default=False).default is False + + +class test_Data(ClassChecker): + """ + Test the `ipalib.parameters.Data` class. + """ + _cls = parameters.Data + + def test_init(self): + """ + Test the `ipalib.parameters.Data.__init__` method. + """ + o = self.cls('my_data') + assert o.type is NoneType + assert o.rules == tuple() + assert o.class_rules == tuple() + assert o.all_rules == tuple() + assert o.minlength is None + assert o.maxlength is None + assert o.length is None + assert not hasattr(o, 'pattern') + + # Test mixing length with minlength or maxlength: + o = self.cls('my_data', length=5) + assert o.length == 5 + permutations = [ + dict(minlength=3), + dict(maxlength=7), + dict(minlength=3, maxlength=7), + ] + for kw in permutations: + o = self.cls('my_data', **kw) + for (key, value) in kw.iteritems(): + assert getattr(o, key) == value + e = raises(ValueError, self.cls, 'my_data', length=5, **kw) + assert str(e) == \ + "Data('my_data'): cannot mix length with minlength or maxlength" + + # Test when minlength or maxlength are less than 1: + e = raises(ValueError, self.cls, 'my_data', minlength=0) + assert str(e) == "Data('my_data'): minlength must be >= 1; got 0" + e = raises(ValueError, self.cls, 'my_data', maxlength=0) + assert str(e) == "Data('my_data'): maxlength must be >= 1; got 0" + + # Test when minlength > maxlength: + e = raises(ValueError, self.cls, 'my_data', minlength=22, maxlength=15) + assert str(e) == \ + "Data('my_data'): minlength > maxlength (minlength=22, maxlength=15)" + + # Test when minlength == maxlength + e = raises(ValueError, self.cls, 'my_data', minlength=7, maxlength=7) + assert str(e) == \ + "Data('my_data'): minlength == maxlength; use length=7 instead" + + +class test_Bytes(ClassChecker): + """ + Test the `ipalib.parameters.Bytes` class. + """ + _cls = parameters.Bytes + + def test_init(self): + """ + Test the `ipalib.parameters.Bytes.__init__` method. + """ + o = self.cls('my_bytes') + assert o.type is str + assert o.rules == tuple() + assert o.class_rules == tuple() + assert o.all_rules == tuple() + assert o.minlength is None + assert o.maxlength is None + assert o.length is None + assert o.pattern is None + + # Test mixing length with minlength or maxlength: + o = self.cls('my_bytes', length=5) + assert o.length == 5 + assert len(o.class_rules) == 1 + assert len(o.rules) == 0 + assert len(o.all_rules) == 1 + permutations = [ + dict(minlength=3), + dict(maxlength=7), + dict(minlength=3, maxlength=7), + ] + for kw in permutations: + o = self.cls('my_bytes', **kw) + assert len(o.class_rules) == len(kw) + assert len(o.rules) == 0 + assert len(o.all_rules) == len(kw) + for (key, value) in kw.iteritems(): + assert getattr(o, key) == value + e = raises(ValueError, self.cls, 'my_bytes', length=5, **kw) + assert str(e) == \ + "Bytes('my_bytes'): cannot mix length with minlength or maxlength" + + # Test when minlength or maxlength are less than 1: + e = raises(ValueError, self.cls, 'my_bytes', minlength=0) + assert str(e) == "Bytes('my_bytes'): minlength must be >= 1; got 0" + e = raises(ValueError, self.cls, 'my_bytes', maxlength=0) + assert str(e) == "Bytes('my_bytes'): maxlength must be >= 1; got 0" + + # Test when minlength > maxlength: + e = raises(ValueError, self.cls, 'my_bytes', minlength=22, maxlength=15) + assert str(e) == \ + "Bytes('my_bytes'): minlength > maxlength (minlength=22, maxlength=15)" + + # Test when minlength == maxlength + e = raises(ValueError, self.cls, 'my_bytes', minlength=7, maxlength=7) + assert str(e) == \ + "Bytes('my_bytes'): minlength == maxlength; use length=7 instead" + + def test_rule_minlength(self): + """ + Test the `ipalib.parameters.Bytes._rule_minlength` method. + """ + o = self.cls('my_bytes', minlength=3) + assert o.minlength == 3 + rule = o._rule_minlength + translation = u'minlength=%(minlength)r' + dummy = dummy_ugettext(translation) + assert dummy.translation is translation + + # Test with passing values: + for value in ('abc', 'four', '12345'): + assert rule(dummy, value) is None + assert dummy.called() is False + + # Test with failing values: + for value in ('', 'a', '12'): + assert_equal( + rule(dummy, value), + translation % dict(minlength=3) + ) + assert dummy.message == 'must be at least %(minlength)d bytes' + assert dummy.called() is True + dummy.reset() + + def test_rule_maxlength(self): + """ + Test the `ipalib.parameters.Bytes._rule_maxlength` method. + """ + o = self.cls('my_bytes', maxlength=4) + assert o.maxlength == 4 + rule = o._rule_maxlength + translation = u'maxlength=%(maxlength)r' + dummy = dummy_ugettext(translation) + assert dummy.translation is translation + + # Test with passing values: + for value in ('ab', '123', 'four'): + assert rule(dummy, value) is None + assert dummy.called() is False + + # Test with failing values: + for value in ('12345', 'sixsix'): + assert_equal( + rule(dummy, value), + translation % dict(maxlength=4) + ) + assert dummy.message == 'can be at most %(maxlength)d bytes' + assert dummy.called() is True + dummy.reset() + + def test_rule_length(self): + """ + Test the `ipalib.parameters.Bytes._rule_length` method. + """ + o = self.cls('my_bytes', length=4) + assert o.length == 4 + rule = o._rule_length + translation = u'length=%(length)r' + dummy = dummy_ugettext(translation) + assert dummy.translation is translation + + # Test with passing values: + for value in ('1234', 'four'): + assert rule(dummy, value) is None + assert dummy.called() is False + + # Test with failing values: + for value in ('ab', '123', '12345', 'sixsix'): + assert_equal( + rule(dummy, value), + translation % dict(length=4), + ) + assert dummy.message == 'must be exactly %(length)d bytes' + assert dummy.called() is True + dummy.reset() + + +class test_Str(ClassChecker): + """ + Test the `ipalib.parameters.Str` class. + """ + _cls = parameters.Str + + def test_init(self): + """ + Test the `ipalib.parameters.Str.__init__` method. + """ + o = self.cls('my_str') + assert o.type is unicode + assert o.minlength is None + assert o.maxlength is None + assert o.length is None + assert o.pattern is None + + def test_convert_scalar(self): + """ + Test the `ipalib.parameters.Str._convert_scalar` method. + """ + o = self.cls('my_str') + mthd = o._convert_scalar + for value in (u'Hello', 42, 1.2): + assert mthd(value) == unicode(value) + for value in [True, 'Hello', (u'Hello',), [42.3], dict(one=1)]: + e = raises(errors2.ConversionError, mthd, value) + assert e.name == 'my_str' + assert e.index is None + assert_equal(e.error, u'must be Unicode text') + e = raises(errors2.ConversionError, mthd, value, index=18) + assert e.name == 'my_str' + assert e.index == 18 + assert_equal(e.error, u'must be Unicode text') + + def test_rule_minlength(self): + """ + Test the `ipalib.parameters.Str._rule_minlength` method. + """ + o = self.cls('my_str', minlength=3) + assert o.minlength == 3 + rule = o._rule_minlength + translation = u'minlength=%(minlength)r' + dummy = dummy_ugettext(translation) + assert dummy.translation is translation + + # Test with passing values: + for value in (u'abc', u'four', u'12345'): + assert rule(dummy, value) is None + assert dummy.called() is False + + # Test with failing values: + for value in (u'', u'a', u'12'): + assert_equal( + rule(dummy, value), + translation % dict(minlength=3) + ) + assert dummy.message == 'must be at least %(minlength)d characters' + assert dummy.called() is True + dummy.reset() + + def test_rule_maxlength(self): + """ + Test the `ipalib.parameters.Str._rule_maxlength` method. + """ + o = self.cls('my_str', maxlength=4) + assert o.maxlength == 4 + rule = o._rule_maxlength + translation = u'maxlength=%(maxlength)r' + dummy = dummy_ugettext(translation) + assert dummy.translation is translation + + # Test with passing values: + for value in (u'ab', u'123', u'four'): + assert rule(dummy, value) is None + assert dummy.called() is False + + # Test with failing values: + for value in (u'12345', u'sixsix'): + assert_equal( + rule(dummy, value), + translation % dict(maxlength=4) + ) + assert dummy.message == 'can be at most %(maxlength)d characters' + assert dummy.called() is True + dummy.reset() + + def test_rule_length(self): + """ + Test the `ipalib.parameters.Str._rule_length` method. + """ + o = self.cls('my_str', length=4) + assert o.length == 4 + rule = o._rule_length + translation = u'length=%(length)r' + dummy = dummy_ugettext(translation) + assert dummy.translation is translation + + # Test with passing values: + for value in (u'1234', u'four'): + assert rule(dummy, value) is None + assert dummy.called() is False + + # Test with failing values: + for value in (u'ab', u'123', u'12345', u'sixsix'): + assert_equal( + rule(dummy, value), + translation % dict(length=4), + ) + assert dummy.message == 'must be exactly %(length)d characters' + assert dummy.called() is True + dummy.reset() + + +class test_StrEnum(ClassChecker): + """ + Test the `ipalib.parameters.StrEnum` class. + """ + _cls = parameters.StrEnum + + def test_init(self): + """ + Test the `ipalib.parameters.StrEnum.__init__` method. + """ + values = (u'Hello', u'naughty', u'nurse!') + o = self.cls('my_strenum', values=values) + assert o.type is unicode + assert o.values is values + assert o.class_rules == (o._rule_values,) + assert o.rules == tuple() + assert o.all_rules == (o._rule_values,) + + badvalues = (u'Hello', 'naughty', u'nurse!') + e = raises(TypeError, self.cls, 'my_enum', values=badvalues) + assert str(e) == TYPE_ERROR % ( + "StrEnum('my_enum') values[1]", unicode, 'naughty', str + ) + + def test_rules_values(self): + """ + Test the `ipalib.parameters.StrEnum._rule_values` method. + """ + values = (u'Hello', u'naughty', u'nurse!') + o = self.cls('my_enum', values=values) + rule = o._rule_values + translation = u'values=%(values)s' + dummy = dummy_ugettext(translation) + + # Test with passing values: + for v in values: + assert rule(dummy, v) is None + assert dummy.called() is False + + # Test with failing values: + for val in (u'Howdy', u'quiet', u'library!'): + assert_equal( + rule(dummy, val), + translation % dict(values=values), + ) + assert_equal(dummy.message, 'must be one of %(values)r') + dummy.reset() + + +def test_create_param(): + """ + Test the `ipalib.parameters.create_param` function. + """ + f = parameters.create_param + + # Test that Param instances are returned unchanged: + params = ( + parameters.Param('one?'), + parameters.Int('two+'), + parameters.Str('three*'), + parameters.Bytes('four'), + ) + for p in params: + assert f(p) is p + + # Test that the spec creates an Str instance: + for spec in ('one?', 'two+', 'three*', 'four'): + (name, kw) = parameters.parse_param_spec(spec) + p = f(spec) + assert p.param_spec is spec + assert p.name == name + assert p.required is kw['required'] + assert p.multivalue is kw['multivalue'] + + # Test that TypeError is raised when spec is neither a Param nor a str: + for spec in (u'one', 42, parameters.Param, parameters.Str): + e = raises(TypeError, f, spec) + assert str(e) == \ + TYPE_ERROR % ('spec', (str, parameters.Param), spec, type(spec)) + + +def test_messages(): + """ + Test module level message in `ipalib.parameters`. + """ + for name in dir(parameters): + if name.startswith('_'): + continue + attr = getattr(parameters, name) + if not (isclass(attr) and issubclass(attr, parameters.Param)): + continue + assert type(attr.type_error) is str + assert attr.type_error in parameters.__messages diff --git a/tests/test_ipalib/test_plugable.py b/tests/test_ipalib/test_plugable.py new file mode 100644 index 000000000..c6c84fa16 --- /dev/null +++ b/tests/test_ipalib/test_plugable.py @@ -0,0 +1,756 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib.plugable` module. +""" + +import inspect +from tests.util import raises, no_set, no_del, read_only +from tests.util import getitem, setitem, delitem +from tests.util import ClassChecker, create_test_api +from ipalib import plugable, errors, errors2 + + +class test_SetProxy(ClassChecker): + """ + Test the `ipalib.plugable.SetProxy` class. + """ + _cls = plugable.SetProxy + + def test_class(self): + """ + Test the `ipalib.plugable.SetProxy` class. + """ + assert self.cls.__bases__ == (plugable.ReadOnly,) + + def test_init(self): + """ + Test the `ipalib.plugable.SetProxy.__init__` method. + """ + okay = (set, frozenset, dict) + fail = (list, tuple) + for t in okay: + self.cls(t()) + raises(TypeError, self.cls, t) + for t in fail: + raises(TypeError, self.cls, t()) + raises(TypeError, self.cls, t) + + def test_SetProxy(self): + """ + Test container emulation of `ipalib.plugable.SetProxy` class. + """ + def get_key(i): + return 'key_%d' % i + + cnt = 10 + target = set() + proxy = self.cls(target) + for i in xrange(cnt): + key = get_key(i) + + # Check initial state + assert len(proxy) == len(target) + assert list(proxy) == sorted(target) + assert key not in proxy + assert key not in target + + # Add and test again + target.add(key) + assert len(proxy) == len(target) + assert list(proxy) == sorted(target) + assert key in proxy + assert key in target + + +class test_DictProxy(ClassChecker): + """ + Test the `ipalib.plugable.DictProxy` class. + """ + _cls = plugable.DictProxy + + def test_class(self): + """ + Test the `ipalib.plugable.DictProxy` class. + """ + assert self.cls.__bases__ == (plugable.SetProxy,) + + def test_init(self): + """ + Test the `ipalib.plugable.DictProxy.__init__` method. + """ + self.cls(dict()) + raises(TypeError, self.cls, dict) + fail = (set, frozenset, list, tuple) + for t in fail: + raises(TypeError, self.cls, t()) + raises(TypeError, self.cls, t) + + def test_DictProxy(self): + """ + Test container emulation of `ipalib.plugable.DictProxy` class. + """ + def get_kv(i): + return ( + 'key_%d' % i, + 'val_%d' % i, + ) + cnt = 10 + target = dict() + proxy = self.cls(target) + for i in xrange(cnt): + (key, val) = get_kv(i) + + # Check initial state + assert len(proxy) == len(target) + assert list(proxy) == sorted(target) + assert list(proxy()) == [target[k] for k in sorted(target)] + assert key not in proxy + raises(KeyError, getitem, proxy, key) + + # Add and test again + target[key] = val + assert len(proxy) == len(target) + assert list(proxy) == sorted(target) + assert list(proxy()) == [target[k] for k in sorted(target)] + + # Verify TypeError is raised trying to set/del via proxy + raises(TypeError, setitem, proxy, key, val) + raises(TypeError, delitem, proxy, key) + + +class test_MagicDict(ClassChecker): + """ + Test the `ipalib.plugable.MagicDict` class. + """ + _cls = plugable.MagicDict + + def test_class(self): + """ + Test the `ipalib.plugable.MagicDict` class. + """ + assert self.cls.__bases__ == (plugable.DictProxy,) + for non_dict in ('hello', 69, object): + raises(TypeError, self.cls, non_dict) + + def test_MagicDict(self): + """ + Test container emulation of `ipalib.plugable.MagicDict` class. + """ + cnt = 10 + keys = [] + d = dict() + dictproxy = self.cls(d) + for i in xrange(cnt): + key = 'key_%d' % i + val = 'val_%d' % i + keys.append(key) + + # Test thet key does not yet exist + assert len(dictproxy) == i + assert key not in dictproxy + assert not hasattr(dictproxy, key) + raises(KeyError, getitem, dictproxy, key) + raises(AttributeError, getattr, dictproxy, key) + + # Test that items/attributes cannot be set on dictproxy: + raises(TypeError, setitem, dictproxy, key, val) + raises(AttributeError, setattr, dictproxy, key, val) + + # Test that additions in d are reflected in dictproxy: + d[key] = val + assert len(dictproxy) == i + 1 + assert key in dictproxy + assert hasattr(dictproxy, key) + assert dictproxy[key] is val + assert read_only(dictproxy, key) is val + + # Test __iter__ + assert list(dictproxy) == keys + + for key in keys: + # Test that items cannot be deleted through dictproxy: + raises(TypeError, delitem, dictproxy, key) + raises(AttributeError, delattr, dictproxy, key) + + # Test that deletions in d are reflected in dictproxy + del d[key] + assert len(dictproxy) == len(d) + assert key not in dictproxy + raises(KeyError, getitem, dictproxy, key) + raises(AttributeError, getattr, dictproxy, key) + + +class test_Plugin(ClassChecker): + """ + Test the `ipalib.plugable.Plugin` class. + """ + _cls = plugable.Plugin + + def test_class(self): + """ + Test the `ipalib.plugable.Plugin` class. + """ + assert self.cls.__bases__ == (plugable.ReadOnly,) + assert self.cls.__public__ == frozenset() + assert type(self.cls.api) is property + + def test_init(self): + """ + Test the `ipalib.plugable.Plugin.__init__` method. + """ + o = self.cls() + assert o.name == 'Plugin' + assert o.module == 'ipalib.plugable' + assert o.fullname == 'ipalib.plugable.Plugin' + assert o.doc == inspect.getdoc(self.cls) + class some_subclass(self.cls): + """ + Do sub-classy things. + + Although it doesn't know how to comport itself and is not for mixed + company, this class *is* useful as we all need a little sub-class + now and then. + + One more paragraph. + """ + o = some_subclass() + assert o.name == 'some_subclass' + assert o.module == __name__ + assert o.fullname == '%s.some_subclass' % __name__ + assert o.doc == inspect.getdoc(some_subclass) + assert o.summary == 'Do sub-classy things.' + class another_subclass(self.cls): + pass + o = another_subclass() + assert o.doc is None + assert o.summary == '<%s>' % o.fullname + + # Test that Plugin makes sure the subclass hasn't defined attributes + # whose names conflict with the logger methods set in Plugin.__init__(): + class check(self.cls): + info = 'whatever' + e = raises(StandardError, check) + assert str(e) == \ + "check.info attribute ('whatever') conflicts with Plugin logger" + + def test_implements(self): + """ + Test the `ipalib.plugable.Plugin.implements` classmethod. + """ + class example(self.cls): + __public__ = frozenset(( + 'some_method', + 'some_property', + )) + class superset(self.cls): + __public__ = frozenset(( + 'some_method', + 'some_property', + 'another_property', + )) + class subset(self.cls): + __public__ = frozenset(( + 'some_property', + )) + class any_object(object): + __public__ = frozenset(( + 'some_method', + 'some_property', + )) + + for ex in (example, example()): + # Test using str: + assert ex.implements('some_method') + assert not ex.implements('another_method') + + # Test using frozenset: + assert ex.implements(frozenset(['some_method'])) + assert not ex.implements( + frozenset(['some_method', 'another_method']) + ) + + # Test using another object/class with __public__ frozenset: + assert ex.implements(example) + assert ex.implements(example()) + + assert ex.implements(subset) + assert not subset.implements(ex) + + assert not ex.implements(superset) + assert superset.implements(ex) + + assert ex.implements(any_object) + assert ex.implements(any_object()) + + def test_implemented_by(self): + """ + Test the `ipalib.plugable.Plugin.implemented_by` classmethod. + """ + class base(self.cls): + __public__ = frozenset(( + 'attr0', + 'attr1', + 'attr2', + )) + + class okay(base): + def attr0(self): + pass + def __get_attr1(self): + assert False # Make sure property isn't accesed on instance + attr1 = property(__get_attr1) + attr2 = 'hello world' + another_attr = 'whatever' + + class fail(base): + def __init__(self): + # Check that class, not instance is inspected: + self.attr2 = 'hello world' + def attr0(self): + pass + def __get_attr1(self): + assert False # Make sure property isn't accesed on instance + attr1 = property(__get_attr1) + another_attr = 'whatever' + + # Test that AssertionError is raised trying to pass something not + # subclass nor instance of base: + raises(AssertionError, base.implemented_by, object) + + # Test on subclass with needed attributes: + assert base.implemented_by(okay) is True + assert base.implemented_by(okay()) is True + + # Test on subclass *without* needed attributes: + assert base.implemented_by(fail) is False + assert base.implemented_by(fail()) is False + + def test_set_api(self): + """ + Test the `ipalib.plugable.Plugin.set_api` method. + """ + api = 'the api instance' + o = self.cls() + assert o.api is None + e = raises(AssertionError, o.set_api, None) + assert str(e) == 'set_api() argument cannot be None' + o.set_api(api) + assert o.api is api + e = raises(AssertionError, o.set_api, api) + assert str(e) == 'set_api() can only be called once' + + def test_finalize(self): + """ + Test the `ipalib.plugable.Plugin.finalize` method. + """ + o = self.cls() + assert not o.__islocked__() + o.finalize() + assert o.__islocked__() + + def test_call(self): + """ + Test the `ipalib.plugable.Plugin.call` method. + """ + o = self.cls() + o.call('/bin/true') is None + e = raises(errors2.SubprocessError, o.call, '/bin/false') + assert e.returncode == 1 + assert e.argv == ('/bin/false',) + + +class test_PluginProxy(ClassChecker): + """ + Test the `ipalib.plugable.PluginProxy` class. + """ + _cls = plugable.PluginProxy + + def test_class(self): + """ + Test the `ipalib.plugable.PluginProxy` class. + """ + assert self.cls.__bases__ == (plugable.SetProxy,) + + def test_proxy(self): + """ + Test proxy behaviour of `ipalib.plugable.PluginProxy` instance. + """ + # Setup: + class base(object): + __public__ = frozenset(( + 'public_0', + 'public_1', + '__call__', + )) + + def public_0(self): + return 'public_0' + + def public_1(self): + return 'public_1' + + def __call__(self, caller): + return 'ya called it, %s.' % caller + + def private_0(self): + return 'private_0' + + def private_1(self): + return 'private_1' + + class plugin(base): + name = 'user_add' + attr_name = 'add' + doc = 'add a new user' + + # Test that TypeError is raised when base is not a class: + raises(TypeError, self.cls, base(), None) + + # Test that ValueError is raised when target is not instance of base: + raises(ValueError, self.cls, base, object()) + + # Test with correct arguments: + i = plugin() + p = self.cls(base, i) + assert read_only(p, 'name') is plugin.name + assert read_only(p, 'doc') == plugin.doc + assert list(p) == sorted(base.__public__) + + # Test normal methods: + for n in xrange(2): + pub = 'public_%d' % n + priv = 'private_%d' % n + assert getattr(i, pub)() == pub + assert getattr(p, pub)() == pub + assert hasattr(p, pub) + assert getattr(i, priv)() == priv + assert not hasattr(p, priv) + + # Test __call__: + value = 'ya called it, dude.' + assert i('dude') == value + assert p('dude') == value + assert callable(p) + + # Test name_attr='name' kw arg + i = plugin() + p = self.cls(base, i, 'attr_name') + assert read_only(p, 'name') == 'add' + + def test_implements(self): + """ + Test the `ipalib.plugable.PluginProxy.implements` method. + """ + class base(object): + __public__ = frozenset() + name = 'base' + doc = 'doc' + @classmethod + def implements(cls, arg): + return arg + 7 + + class sub(base): + @classmethod + def implements(cls, arg): + """ + Defined to make sure base.implements() is called, not + target.implements() + """ + return arg + + o = sub() + p = self.cls(base, o) + assert p.implements(3) == 10 + + def test_clone(self): + """ + Test the `ipalib.plugable.PluginProxy.__clone__` method. + """ + class base(object): + __public__ = frozenset() + class sub(base): + name = 'some_name' + doc = 'doc' + label = 'another_name' + + p = self.cls(base, sub()) + assert read_only(p, 'name') == 'some_name' + c = p.__clone__('label') + assert isinstance(c, self.cls) + assert c is not p + assert read_only(c, 'name') == 'another_name' + + +def test_Registrar(): + """ + Test the `ipalib.plugable.Registrar` class + """ + class Base1(object): + pass + class Base2(object): + pass + class Base3(object): + pass + class plugin1(Base1): + pass + class plugin2(Base2): + pass + class plugin3(Base3): + pass + + # Test creation of Registrar: + r = plugable.Registrar(Base1, Base2) + + # Test __iter__: + assert list(r) == ['Base1', 'Base2'] + + # Test __hasitem__, __getitem__: + for base in [Base1, Base2]: + name = base.__name__ + assert name in r + assert r[name] is base + magic = getattr(r, name) + assert type(magic) is plugable.MagicDict + assert len(magic) == 0 + + # Check that TypeError is raised trying to register something that isn't + # a class: + p = plugin1() + e = raises(TypeError, r, p) + assert str(e) == 'plugin must be a class; got %r' % p + + # Check that SubclassError is raised trying to register a class that is + # not a subclass of an allowed base: + e = raises(errors2.PluginSubclassError, r, plugin3) + assert e.plugin is plugin3 + + # Check that registration works + r(plugin1) + assert len(r.Base1) == 1 + assert r.Base1['plugin1'] is plugin1 + assert r.Base1.plugin1 is plugin1 + + # Check that DuplicateError is raised trying to register exact class + # again: + e = raises(errors2.PluginDuplicateError, r, plugin1) + assert e.plugin is plugin1 + + # Check that OverrideError is raised trying to register class with same + # name and same base: + orig1 = plugin1 + class base1_extended(Base1): + pass + class plugin1(base1_extended): + pass + e = raises(errors2.PluginOverrideError, r, plugin1) + assert e.base == 'Base1' + assert e.name == 'plugin1' + assert e.plugin is plugin1 + + # Check that overriding works + r(plugin1, override=True) + assert len(r.Base1) == 1 + assert r.Base1.plugin1 is plugin1 + assert r.Base1.plugin1 is not orig1 + + # Check that MissingOverrideError is raised trying to override a name + # not yet registerd: + e = raises(errors2.PluginMissingOverrideError, r, plugin2, override=True) + assert e.base == 'Base2' + assert e.name == 'plugin2' + assert e.plugin is plugin2 + + # Test that another plugin can be registered: + assert len(r.Base2) == 0 + r(plugin2) + assert len(r.Base2) == 1 + assert r.Base2.plugin2 is plugin2 + + # Setup to test more registration: + class plugin1a(Base1): + pass + r(plugin1a) + + class plugin1b(Base1): + pass + r(plugin1b) + + class plugin2a(Base2): + pass + r(plugin2a) + + class plugin2b(Base2): + pass + r(plugin2b) + + # Again test __hasitem__, __getitem__: + for base in [Base1, Base2]: + name = base.__name__ + assert name in r + assert r[name] is base + magic = getattr(r, name) + assert len(magic) == 3 + for key in magic: + klass = magic[key] + assert getattr(magic, key) is klass + assert issubclass(klass, base) + + +class test_API(ClassChecker): + """ + Test the `ipalib.plugable.API` class. + """ + + _cls = plugable.API + + def test_API(self): + """ + Test the `ipalib.plugable.API` class. + """ + assert issubclass(plugable.API, plugable.ReadOnly) + + # Setup the test bases, create the API: + class base0(plugable.Plugin): + __public__ = frozenset(( + 'method', + )) + + def method(self, n): + return n + + class base1(plugable.Plugin): + __public__ = frozenset(( + 'method', + )) + + def method(self, n): + return n + 1 + + api = plugable.API(base0, base1) + api.env.mode = 'unit_test' + api.env.in_tree = True + r = api.register + assert isinstance(r, plugable.Registrar) + assert read_only(api, 'register') is r + + class base0_plugin0(base0): + pass + r(base0_plugin0) + + class base0_plugin1(base0): + pass + r(base0_plugin1) + + class base0_plugin2(base0): + pass + r(base0_plugin2) + + class base1_plugin0(base1): + pass + r(base1_plugin0) + + class base1_plugin1(base1): + pass + r(base1_plugin1) + + class base1_plugin2(base1): + pass + r(base1_plugin2) + + # Test API instance: + assert api.isdone('bootstrap') is False + assert api.isdone('finalize') is False + api.finalize() + assert api.isdone('bootstrap') is True + assert api.isdone('finalize') is True + + def get_base(b): + return 'base%d' % b + + def get_plugin(b, p): + return 'base%d_plugin%d' % (b, p) + + for b in xrange(2): + base_name = get_base(b) + ns = getattr(api, base_name) + assert isinstance(ns, plugable.NameSpace) + assert read_only(api, base_name) is ns + assert len(ns) == 3 + for p in xrange(3): + plugin_name = get_plugin(b, p) + proxy = ns[plugin_name] + assert isinstance(proxy, plugable.PluginProxy) + assert proxy.name == plugin_name + assert read_only(ns, plugin_name) is proxy + assert read_only(proxy, 'method')(7) == 7 + b + + # Test that calling finilize again raises AssertionError: + e = raises(StandardError, api.finalize) + assert str(e) == 'API.finalize() already called', str(e) + + # Test with base class that doesn't request a proxy + class NoProxy(plugable.Plugin): + __proxy__ = False + api = plugable.API(NoProxy) + api.env.mode = 'unit_test' + class plugin0(NoProxy): + pass + api.register(plugin0) + class plugin1(NoProxy): + pass + api.register(plugin1) + api.finalize() + names = ['plugin0', 'plugin1'] + assert list(api.NoProxy) == names + for name in names: + plugin = api.NoProxy[name] + assert getattr(api.NoProxy, name) is plugin + assert isinstance(plugin, plugable.Plugin) + assert not isinstance(plugin, plugable.PluginProxy) + + def test_bootstrap(self): + """ + Test the `ipalib.plugable.API.bootstrap` method. + """ + (o, home) = create_test_api() + assert o.env._isdone('_bootstrap') is False + assert o.env._isdone('_finalize_core') is False + assert o.isdone('bootstrap') is False + o.bootstrap(my_test_override='Hello, world!') + assert o.isdone('bootstrap') is True + assert o.env._isdone('_bootstrap') is True + assert o.env._isdone('_finalize_core') is True + assert o.env.my_test_override == 'Hello, world!' + e = raises(StandardError, o.bootstrap) + assert str(e) == 'API.bootstrap() already called' + + def test_load_plugins(self): + """ + Test the `ipalib.plugable.API.load_plugins` method. + """ + (o, home) = create_test_api() + assert o.isdone('bootstrap') is False + assert o.isdone('load_plugins') is False + o.load_plugins() + assert o.isdone('bootstrap') is True + assert o.isdone('load_plugins') is True + e = raises(StandardError, o.load_plugins) + assert str(e) == 'API.load_plugins() already called' diff --git a/tests/test_ipalib/test_request.py b/tests/test_ipalib/test_request.py new file mode 100644 index 000000000..f26c270a7 --- /dev/null +++ b/tests/test_ipalib/test_request.py @@ -0,0 +1,161 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty contextrmation +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib.request` module. +""" + +import threading +import locale +from tests.util import raises, assert_equal +from tests.util import TempDir, dummy_ugettext, dummy_ungettext +from ipalib.constants import OVERRIDE_ERROR +from ipalib import request + + +def test_ugettext(): + """ + Test the `ipalib.request.ugettext` function. + """ + f = request.ugettext + context = request.context + message = 'Hello, world!' + + # Test with no context.ugettext: + assert not hasattr(context, 'ugettext') + assert_equal(f(message), u'Hello, world!') + + # Test with dummy context.ugettext: + assert not hasattr(context, 'ugettext') + dummy = dummy_ugettext() + context.ugettext = dummy + assert f(message) is dummy.translation + assert dummy.message is message + + # Cleanup + del context.ugettext + assert not hasattr(context, 'ugettext') + + +def test_ungettext(): + """ + Test the `ipalib.request.ungettext` function. + """ + f = request.ungettext + context = request.context + singular = 'Goose' + plural = 'Geese' + + # Test with no context.ungettext: + assert not hasattr(context, 'ungettext') + assert_equal(f(singular, plural, 1), u'Goose') + assert_equal(f(singular, plural, 2), u'Geese') + + # Test singular with dummy context.ungettext + assert not hasattr(context, 'ungettext') + dummy = dummy_ungettext() + context.ungettext = dummy + assert f(singular, plural, 1) is dummy.translation_singular + assert dummy.singular is singular + assert dummy.plural is plural + assert dummy.n == 1 + del context.ungettext + assert not hasattr(context, 'ungettext') + + # Test plural with dummy context.ungettext + assert not hasattr(context, 'ungettext') + dummy = dummy_ungettext() + context.ungettext = dummy + assert f(singular, plural, 2) is dummy.translation_plural + assert dummy.singular is singular + assert dummy.plural is plural + assert dummy.n == 2 + del context.ungettext + assert not hasattr(context, 'ungettext') + + +def test_set_languages(): + """ + Test the `ipalib.request.set_languages` function. + """ + f = request.set_languages + c = request.context + langs = ('ru', 'en') + + # Test that StandardError is raised if languages has already been set: + assert not hasattr(c, 'languages') + c.languages = None + e = raises(StandardError, f, *langs) + assert str(e) == OVERRIDE_ERROR % ('context', 'languages', None, langs) + del c.languages + + # Test setting the languages: + assert not hasattr(c, 'languages') + f(*langs) + assert c.languages == langs + del c.languages + + # Test setting language from locale.getdefaultlocale() + assert not hasattr(c, 'languages') + f() + assert c.languages == locale.getdefaultlocale()[:1] + del c.languages + assert not hasattr(c, 'languages') + + +def test_create_translation(): + """ + Test the `ipalib.request.create_translation` function. + """ + f = request.create_translation + c = request.context + t = TempDir() + + # Test that StandardError is raised if ugettext or ungettext: + assert not (hasattr(c, 'ugettext') or hasattr(c, 'ungettext')) + for name in ('ugettext', 'ungettext'): + setattr(c, name, None) + e = raises(StandardError, f, 'ipa', None) + assert str(e) == ( + 'create_translation() already called in thread %r' % + threading.currentThread().getName() + ) + delattr(c, name) + + # Test using default language: + assert not hasattr(c, 'ugettext') + assert not hasattr(c, 'ungettext') + assert not hasattr(c, 'languages') + f('ipa', t.path) + assert hasattr(c, 'ugettext') + assert hasattr(c, 'ungettext') + assert c.languages == locale.getdefaultlocale()[:1] + del c.ugettext + del c.ungettext + del c.languages + + # Test using explicit languages: + langs = ('de', 'es') + f('ipa', t.path, *langs) + assert hasattr(c, 'ugettext') + assert hasattr(c, 'ungettext') + assert c.languages == langs + del c.ugettext + del c.ungettext + del c.languages diff --git a/tests/test_ipalib/test_rpc.py b/tests/test_ipalib/test_rpc.py new file mode 100644 index 000000000..296e9bc1c --- /dev/null +++ b/tests/test_ipalib/test_rpc.py @@ -0,0 +1,249 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib.rpc` module. +""" + +import threading +from xmlrpclib import Binary, Fault, dumps, loads +from tests.util import raises, assert_equal, PluginTester, DummyClass +from tests.data import binary_bytes, utf8_bytes, unicode_str +from ipalib.frontend import Command +from ipalib.request import context +from ipalib import rpc, errors2 + + +std_compound = (binary_bytes, utf8_bytes, unicode_str) + + +def dump_n_load(value): + (param, method) = loads( + dumps((value,), allow_none=True) + ) + return param[0] + + +def round_trip(value): + return rpc.xml_unwrap( + dump_n_load(rpc.xml_wrap(value)) + ) + + +def test_round_trip(): + """ + Test `ipalib.rpc.xml_wrap` and `ipalib.rpc.xml_unwrap`. + + This tests the two functions together with ``xmlrpclib.dumps()`` and + ``xmlrpclib.loads()`` in a full wrap/dumps/loads/unwrap round trip. + """ + # We first test that our assumptions about xmlrpclib module in the Python + # standard library are correct: + assert_equal(dump_n_load(utf8_bytes), unicode_str) + assert_equal(dump_n_load(unicode_str), unicode_str) + assert_equal(dump_n_load(Binary(binary_bytes)).data, binary_bytes) + assert isinstance(dump_n_load(Binary(binary_bytes)), Binary) + assert type(dump_n_load('hello')) is str + assert type(dump_n_load(u'hello')) is str + assert_equal(dump_n_load(''), '') + assert_equal(dump_n_load(u''), '') + assert dump_n_load(None) is None + + # Now we test our wrap and unwrap methods in combination with dumps, loads: + # All str should come back str (because they get wrapped in + # xmlrpclib.Binary(). All unicode should come back unicode because str + # explicity get decoded by rpc.xml_unwrap() if they weren't already + # decoded by xmlrpclib.loads(). + assert_equal(round_trip(utf8_bytes), utf8_bytes) + assert_equal(round_trip(unicode_str), unicode_str) + assert_equal(round_trip(binary_bytes), binary_bytes) + assert type(round_trip('hello')) is str + assert type(round_trip(u'hello')) is unicode + assert_equal(round_trip(''), '') + assert_equal(round_trip(u''), u'') + assert round_trip(None) is None + compound = [utf8_bytes, None, binary_bytes, (None, unicode_str), + dict(utf8=utf8_bytes, chars=unicode_str, data=binary_bytes) + ] + assert round_trip(compound) == tuple(compound) + + +def test_xml_wrap(): + """ + Test the `ipalib.rpc.xml_wrap` function. + """ + f = rpc.xml_wrap + assert f([]) == tuple() + assert f({}) == dict() + b = f('hello') + assert isinstance(b, Binary) + assert b.data == 'hello' + u = f(u'hello') + assert type(u) is unicode + assert u == u'hello' + value = f([dict(one=False, two=u'hello'), None, 'hello']) + + +def test_xml_unwrap(): + """ + Test the `ipalib.rpc.xml_unwrap` function. + """ + f = rpc.xml_unwrap + assert f([]) == tuple() + assert f({}) == dict() + value = f(Binary(utf8_bytes)) + assert type(value) is str + assert value == utf8_bytes + assert f(utf8_bytes) == unicode_str + assert f(unicode_str) == unicode_str + value = f([True, Binary('hello'), dict(one=1, two=utf8_bytes, three=None)]) + assert value == (True, 'hello', dict(one=1, two=unicode_str, three=None)) + assert type(value[1]) is str + assert type(value[2]['two']) is unicode + + +def test_xml_dumps(): + """ + Test the `ipalib.rpc.xml_dumps` function. + """ + f = rpc.xml_dumps + params = (binary_bytes, utf8_bytes, unicode_str, None) + + # Test serializing an RPC request: + data = f(params, 'the_method') + (p, m) = loads(data) + assert_equal(m, u'the_method') + assert type(p) is tuple + assert rpc.xml_unwrap(p) == params + + # Test serializing an RPC response: + data = f((params,), methodresponse=True) + (tup, m) = loads(data) + assert m is None + assert len(tup) == 1 + assert type(tup) is tuple + assert rpc.xml_unwrap(tup[0]) == params + + # Test serializing an RPC response containing a Fault: + fault = Fault(69, unicode_str) + data = f(fault, methodresponse=True) + e = raises(Fault, loads, data) + assert e.faultCode == 69 + assert_equal(e.faultString, unicode_str) + + +def test_xml_loads(): + """ + Test the `ipalib.rpc.xml_loads` function. + """ + f = rpc.xml_loads + params = (binary_bytes, utf8_bytes, unicode_str, None) + wrapped = rpc.xml_wrap(params) + + # Test un-serializing an RPC request: + data = dumps(wrapped, 'the_method', allow_none=True) + (p, m) = f(data) + assert_equal(m, u'the_method') + assert_equal(p, params) + + # Test un-serializing an RPC response: + data = dumps((wrapped,), methodresponse=True, allow_none=True) + (tup, m) = f(data) + assert m is None + assert len(tup) == 1 + assert type(tup) is tuple + assert_equal(tup[0], params) + + # Test un-serializing an RPC response containing a Fault: + fault = Fault(69, unicode_str) + data = dumps(fault, methodresponse=True, allow_none=True) + e = raises(Fault, f, data) + assert e.faultCode == 69 + assert_equal(e.faultString, unicode_str) + + +class test_xmlclient(PluginTester): + """ + Test the `ipalib.rpc.xmlclient` plugin. + """ + _plugin = rpc.xmlclient + + def test_forward(self): + """ + Test the `ipalib.rpc.xmlclient.forward` method. + """ + class user_add(Command): + pass + + # Test that ValueError is raised when forwarding a command that is not + # in api.Command: + (o, api, home) = self.instance('Backend', in_server=False) + e = raises(ValueError, o.forward, 'user_add') + assert str(e) == '%s.forward(): %r not in api.Command' % ( + 'xmlclient', 'user_add' + ) + + # Test that StandardError is raised when context.xmlconn does not exist: + (o, api, home) = self.instance('Backend', user_add, in_server=False) + e = raises(StandardError, o.forward, 'user_add') + assert str(e) == '%s.forward(%r): need context.xmlconn in thread %r' % ( + 'xmlclient', 'user_add', threading.currentThread().getName() + ) + + args = (binary_bytes, utf8_bytes, unicode_str) + kw = dict(one=binary_bytes, two=utf8_bytes, three=unicode_str) + params = args + (kw,) + result = (unicode_str, binary_bytes, utf8_bytes) + context.xmlconn = DummyClass( + ( + 'user_add', + (rpc.xml_wrap(params),), + {}, + rpc.xml_wrap(result), + ), + ( + 'user_add', + (rpc.xml_wrap(params),), + {}, + Fault(3005, u"'four' is required"), # RequirementError + ), + ( + 'user_add', + (rpc.xml_wrap(params),), + {}, + Fault(700, u'no such error'), # There is no error 700 + ), + + ) + + # Test with a successful return value: + assert o.forward('user_add', *args, **kw) == result + + # Test with an errno the client knows: + e = raises(errors2.RequirementError, o.forward, 'user_add', *args, **kw) + assert_equal(e.message, u"'four' is required") + + # Test with an errno the client doesn't know + e = raises(errors2.UnknownError, o.forward, 'user_add', *args, **kw) + assert_equal(e.code, 700) + assert_equal(e.error, u'no such error') + + assert context.xmlconn._calledall() is True + + del context.xmlconn diff --git a/tests/test_ipalib/test_util.py b/tests/test_ipalib/test_util.py new file mode 100644 index 000000000..6729fcda5 --- /dev/null +++ b/tests/test_ipalib/test_util.py @@ -0,0 +1,61 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib.util` module. +""" + +from tests.util import raises +from ipalib import util + + +def test_xmlrpc_marshal(): + """ + Test the `ipalib.util.xmlrpc_marshal` function. + """ + f = util.xmlrpc_marshal + assert f() == ({},) + assert f('one', 'two') == ({}, 'one', 'two') + assert f(one=1, two=2) == (dict(one=1, two=2),) + assert f('one', 'two', three=3, four=4) == \ + (dict(three=3, four=4), 'one', 'two') + + +def test_xmlrpc_unmarshal(): + """ + Test the `ipalib.util.xmlrpc_unmarshal` function. + """ + f = util.xmlrpc_unmarshal + assert f() == (tuple(), {}) + assert f({}, 'one', 'two') == (('one', 'two'), {}) + assert f(dict(one=1, two=2)) == (tuple(), dict(one=1, two=2)) + assert f(dict(three=3, four=4), 'one', 'two') == \ + (('one', 'two'), dict(three=3, four=4)) + + +def test_make_repr(): + """ + Test the `ipalib.util.make_repr` function. + """ + f = util.make_repr + assert f('my') == 'my()' + assert f('my', True, u'hello') == "my(True, u'hello')" + assert f('my', one=1, two='two') == "my(one=1, two='two')" + assert f('my', None, 3, dog='animal', apple='fruit') == \ + "my(None, 3, apple='fruit', dog='animal')" diff --git a/tests/test_ipaserver/__init__.py b/tests/test_ipaserver/__init__.py new file mode 100644 index 000000000..56a6c533c --- /dev/null +++ b/tests/test_ipaserver/__init__.py @@ -0,0 +1,22 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Sub-package containing unit tests for `ipaserver` package. +""" diff --git a/tests/test_ipaserver/test_rpcserver.py b/tests/test_ipaserver/test_rpcserver.py new file mode 100644 index 000000000..48c1d36ef --- /dev/null +++ b/tests/test_ipaserver/test_rpcserver.py @@ -0,0 +1,79 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipaserver.rpc` module. +""" + +from tests.util import create_test_api, raises, PluginTester +from tests.data import unicode_str +from ipalib import errors2, Command +from ipaserver import rpcserver + + +def test_params_2_args_options(): + """ + Test the `ipaserver.rpcserver.params_2_args_options` function. + """ + f = rpcserver.params_2_args_options + args = ('Hello', u'world!') + options = dict(one=1, two=u'Two', three='Three') + assert f(tuple()) == (tuple(), dict()) + assert f(args) == (args, dict()) + assert f((options,)) == (tuple(), options) + assert f(args + (options,)) == (args, options) + assert f((options,) + args) == ((options,) + args, dict()) + + +class test_xmlserver(PluginTester): + """ + Test the `ipaserver.rpcserver.xmlserver` plugin. + """ + + _plugin = rpcserver.xmlserver + + def test_dispatch(self): + """ + Test the `ipaserver.rpcserver.xmlserver.dispatch` method. + """ + (o, api, home) = self.instance('Backend', in_server=True) + e = raises(errors2.CommandError, o.dispatch, 'echo', tuple()) + assert e.name == 'echo' + + class echo(Command): + takes_args = ['arg1', 'arg2+'] + takes_options = ['option1?', 'option2?'] + def execute(self, *args, **options): + assert type(args[1]) is tuple + return args + (options,) + + (o, api, home) = self.instance('Backend', echo, in_server=True) + def call(params): + response = o.dispatch('echo', params) + assert type(response) is tuple and len(response) == 1 + return response[0] + arg1 = unicode_str + arg2 = (u'Hello', unicode_str, u'world!') + options = dict(option1=u'How are you?', option2=unicode_str) + assert call((arg1, arg2, options)) == (arg1, arg2, options) + assert call((arg1,) + arg2 + (options,)) == (arg1, arg2, options) + + + def test_execute(self): + (o, api, home) = self.instance('Backend', in_server=True) diff --git a/tests/test_ipawebui/__init__.py b/tests/test_ipawebui/__init__.py new file mode 100644 index 000000000..f739a8563 --- /dev/null +++ b/tests/test_ipawebui/__init__.py @@ -0,0 +1,21 @@ +# Authors: Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Sub-package containing unit tests for `ipawebui` package. +""" diff --git a/tests/test_ipawebui/test_controllers.py b/tests/test_ipawebui/test_controllers.py new file mode 100644 index 000000000..e236d1a0b --- /dev/null +++ b/tests/test_ipawebui/test_controllers.py @@ -0,0 +1,70 @@ +# Authors: Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipawebui.controller` module. +""" + +from ipawebui import controller + + + +class test_Controller(object): + """ + Test the `controller.Controller` class. + """ + + def test_init(self): + """ + Test the `ipawebui.controller.Controller.__init__()` method. + """ + o = controller.Controller() + assert o.template is None + template = 'The template.' + o = controller.Controller(template) + assert o.template is template + + def test_output_xhtml(self): + """ + Test the `ipawebui.controller.Controller.output_xhtml` method. + """ + class Template(object): + def __init__(self): + self.calls = 0 + self.kw = {} + + def serialize(self, **kw): + self.calls += 1 + self.kw = kw + return dict(kw) + + d = dict(output='xhtml-strict', format='pretty') + t = Template() + o = controller.Controller(t) + assert o.output_xhtml() == d + assert t.calls == 1 + + def test_output_json(self): + """ + Test the `ipawebui.controller.Controller.output_json` method. + """ + o = controller.Controller() + assert o.output_json() == '{}' + e = '{\n "age": 27, \n "first": "John", \n "last": "Doe"\n}' + j = o.output_json(last='Doe', first='John', age=27) + assert j == e diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 000000000..7d7038c1a --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,148 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `tests.util` module. +""" + +import util + + +class Prop(object): + def __init__(self, *ops): + self.__ops = frozenset(ops) + self.__prop = 'prop value' + + def __get_prop(self): + if 'get' not in self.__ops: + raise AttributeError('get prop') + return self.__prop + + def __set_prop(self, value): + if 'set' not in self.__ops: + raise AttributeError('set prop') + self.__prop = value + + def __del_prop(self): + if 'del' not in self.__ops: + raise AttributeError('del prop') + self.__prop = None + + prop = property(__get_prop, __set_prop, __del_prop) + + +def test_yes_raised(): + f = util.raises + + class SomeError(Exception): + pass + + class AnotherError(Exception): + pass + + def callback1(): + 'raises correct exception' + raise SomeError() + + def callback2(): + 'raises wrong exception' + raise AnotherError() + + def callback3(): + 'raises no exception' + + f(SomeError, callback1) + + raised = False + try: + f(SomeError, callback2) + except AnotherError: + raised = True + assert raised + + raised = False + try: + f(SomeError, callback3) + except util.ExceptionNotRaised: + raised = True + assert raised + + +def test_no_set(): + # Tests that it works when prop cannot be set: + util.no_set(Prop('get', 'del'), 'prop') + + # Tests that ExceptionNotRaised is raised when prop *can* be set: + raised = False + try: + util.no_set(Prop('set'), 'prop') + except util.ExceptionNotRaised: + raised = True + assert raised + + +def test_no_del(): + # Tests that it works when prop cannot be deleted: + util.no_del(Prop('get', 'set'), 'prop') + + # Tests that ExceptionNotRaised is raised when prop *can* be set: + raised = False + try: + util.no_del(Prop('del'), 'prop') + except util.ExceptionNotRaised: + raised = True + assert raised + + +def test_read_only(): + # Test that it works when prop is read only: + assert util.read_only(Prop('get'), 'prop') == 'prop value' + + # Test that ExceptionNotRaised is raised when prop can be set: + raised = False + try: + util.read_only(Prop('get', 'set'), 'prop') + except util.ExceptionNotRaised: + raised = True + assert raised + + # Test that ExceptionNotRaised is raised when prop can be deleted: + raised = False + try: + util.read_only(Prop('get', 'del'), 'prop') + except util.ExceptionNotRaised: + raised = True + assert raised + + # Test that ExceptionNotRaised is raised when prop can be both set and + # deleted: + raised = False + try: + util.read_only(Prop('get', 'del'), 'prop') + except util.ExceptionNotRaised: + raised = True + assert raised + + # Test that AttributeError is raised when prop can't be read: + raised = False + try: + util.read_only(Prop(), 'prop') + except AttributeError: + raised = True + assert raised diff --git a/tests/test_xmlrpc/__init__.py b/tests/test_xmlrpc/__init__.py new file mode 100644 index 000000000..043007b5e --- /dev/null +++ b/tests/test_xmlrpc/__init__.py @@ -0,0 +1,22 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Sub-package containing unit tests for `xmlrpc` package. +""" diff --git a/tests/test_xmlrpc/test_automount_plugin.py b/tests/test_xmlrpc/test_automount_plugin.py new file mode 100644 index 000000000..522ee689a --- /dev/null +++ b/tests/test_xmlrpc/test_automount_plugin.py @@ -0,0 +1,243 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib/plugins/f_automount' module. +""" + +import sys +from xmlrpc_test import XMLRPC_test +from ipalib import api +from ipalib import errors +from ipalib.cli import CLI + +try: + api.finalize() +except StandardError: + pass + +class test_Service(XMLRPC_test): + """ + Test the `f_automount` plugin. + """ + mapname='testmap' + keyname='testkey' + keyname2='secondkey' + description='description of map' + info='ro' + map_kw={'automountmapname': mapname, 'description': description} + key_kw={'automountmapname': mapname, 'automountkey': keyname, 'automountinformation': info} + key_kw2={'automountmapname': mapname, 'automountkey': keyname2, 'automountinformation': info} + + def test_add_1map(self): + """ + Test adding a map `xmlrpc.automount_addmap` method. + """ + res = api.Command['automount_addmap'](**self.map_kw) + assert res + assert res.get('automountmapname','') == self.mapname + + def test_add_2key(self): + """ + Test adding a key using `xmlrpc.automount_addkey` method. + """ + res = api.Command['automount_addkey'](**self.key_kw2) + assert res + assert res.get('automountkey','') == self.keyname2 + + def test_add_3key(self): + """ + Test adding a key using `xmlrpc.automount_addkey` method. + """ + res = api.Command['automount_addkey'](**self.key_kw) + assert res + assert res.get('automountkey','') == self.keyname + + def test_add_4key(self): + """ + Test adding a duplicate key using `xmlrpc.automount_addkey` method. + """ + try: + res = api.Command['automount_addkey'](**self.key_kw) + except errors.DuplicateEntry: + pass + else: + assert False + + def test_doshowmap(self): + """ + Test the `xmlrpc.automount_showmap` method. + """ + res = api.Command['automount_showmap'](self.mapname) + assert res + assert res.get('automountmapname','') == self.mapname + + def test_findmap(self): + """ + Test the `xmlrpc.automount_findmap` method. + """ + res = api.Command['automount_findmap'](self.mapname) + assert res + assert len(res) == 2 + assert res[1].get('automountmapname','') == self.mapname + + def test_doshowkey(self): + """ + Test the `xmlrpc.automount_showkey` method. + """ + showkey_kw={'automountmapname': self.mapname, 'automountkey': self.keyname} + res = api.Command['automount_showkey'](**showkey_kw) + assert res + assert res.get('automountkey','') == self.keyname + assert res.get('automountinformation','') == self.info + + def test_findkey(self): + """ + Test the `xmlrpc.automount_findkey` method. + """ + res = api.Command['automount_findkey'](self.keyname) + assert res + assert len(res) == 2 + assert res[1].get('automountkey','') == self.keyname + assert res[1].get('automountinformation','') == self.info + + def test_modkey(self): + """ + Test the `xmlrpc.automount_modkey` method. + """ + self.key_kw['automountinformation'] = 'rw' + self.key_kw['description'] = 'new description' + res = api.Command['automount_modkey'](**self.key_kw) + assert res + assert res.get('automountkey','') == self.keyname + assert res.get('automountinformation','') == 'rw' + assert res.get('description','') == 'new description' + + def test_modmap(self): + """ + Test the `xmlrpc.automount_modmap` method. + """ + self.map_kw['description'] = 'new description' + res = api.Command['automount_modmap'](**self.map_kw) + assert res + assert res.get('automountmapname','') == self.mapname + assert res.get('description','') == 'new description' + + def test_remove1key(self): + """ + Test the `xmlrpc.automount_delkey` method. + """ + delkey_kw={'automountmapname': self.mapname, 'automountkey': self.keyname} + res = api.Command['automount_delkey'](**delkey_kw) + assert res == True + + # Verify that it is gone + try: + res = api.Command['automount_showkey'](**delkey_kw) + except errors.NotFound: + pass + else: + assert False + + def test_remove2map(self): + """ + Test the `xmlrpc.automount_delmap` method. + """ + res = api.Command['automount_delmap'](self.mapname) + assert res == True + + # Verify that it is gone + try: + res = api.Command['automount_showmap'](self.mapname) + except errors.NotFound: + pass + else: + assert False + + def test_remove3map(self): + """ + Test that the `xmlrpc.automount_delmap` method removes all keys + """ + # Verify that the second key we added is gone + key_kw={'automountmapname': self.mapname, 'automountkey': self.keyname2} + try: + res = api.Command['automount_showkey'](**key_kw) + except errors.NotFound: + pass + else: + assert False + +class test_Indirect(XMLRPC_test): + """ + Test the `f_automount` plugin Indirect map function. + """ + mapname='auto.home' + keyname='/home' + parentmap='auto.master' + description='Home directories' + map_kw={'automountkey': keyname, 'parentmap': parentmap, 'description': description} + + def test_add_indirect(self): + """ + Test adding an indirect map. + """ + res = api.Command['automount_addindirectmap'](self.mapname, **self.map_kw) + assert res + assert res.get('automountinformation','') == self.mapname + + def test_doshowkey(self): + """ + Test the `xmlrpc.automount_showkey` method. + """ + showkey_kw={'automountmapname': self.parentmap, 'automountkey': self.keyname} + res = api.Command['automount_showkey'](**showkey_kw) + assert res + assert res.get('automountkey','') == self.keyname + + def test_remove_key(self): + """ + Remove the indirect key /home + """ + delkey_kw={'automountmapname': self.parentmap, 'automountkey': self.keyname} + res = api.Command['automount_delkey'](**delkey_kw) + assert res == True + + # Verify that it is gone + try: + res = api.Command['automount_showkey'](**delkey_kw) + except errors.NotFound: + pass + else: + assert False + + def test_remove_map(self): + """ + Remove the indirect map for auto.home + """ + res = api.Command['automount_delmap'](self.mapname) + assert res == True + + # Verify that it is gone + try: + res = api.Command['automount_showmap'](self.mapname) + except errors.NotFound: + pass + else: + assert False + diff --git a/tests/test_xmlrpc/test_group_plugin.py b/tests/test_xmlrpc/test_group_plugin.py new file mode 100644 index 000000000..2b16cc8a5 --- /dev/null +++ b/tests/test_xmlrpc/test_group_plugin.py @@ -0,0 +1,178 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib/plugins/f_group` module. +""" + +import sys +from xmlrpc_test import XMLRPC_test +from ipalib import api +from ipalib import errors +from ipalib.cli import CLI + +try: + api.finalize() +except StandardError: + pass + +class test_Group(XMLRPC_test): + """ + Test the `f_group` plugin. + """ + cn='testgroup' + cn2='testgroup2' + description='This is a test' + kw={'description':description,'cn':cn} + + def test_add(self): + """ + Test the `xmlrpc.group_add` method. + """ + res = api.Command['group_add'](**self.kw) + assert res + assert res.get('description','') == self.description + assert res.get('cn','') == self.cn + + def test_add2(self): + """ + Test the `xmlrpc.group_add` method duplicate detection. + """ + try: + res = api.Command['group_add'](**self.kw) + except errors.DuplicateEntry: + pass + + def test_add2(self): + """ + Test the `xmlrpc.group_add` method. + """ + self.kw['cn'] = self.cn2 + res = api.Command['group_add'](**self.kw) + assert res + assert res.get('description','') == self.description + assert res.get('cn','') == self.cn2 + + def test_add_member(self): + """ + Test the `xmlrpc.group_add_member` method. + """ + kw={} + kw['groups'] = self.cn2 + res = api.Command['group_add_member'](self.cn, **kw) + assert res == [] + + def test_add_member2(self): + """ + Test the `xmlrpc.group_add_member` with a non-existent member + """ + kw={} + kw['groups'] = "notfound" + res = api.Command['group_add_member'](self.cn, **kw) + # an error isn't thrown, the list of failed members is returned + assert res != [] + + def test_doshow(self): + """ + Test the `xmlrpc.group_show` method. + """ + res = api.Command['group_show'](self.cn) + assert res + assert res.get('description','') == self.description + assert res.get('cn','') == self.cn + assert res.get('member','').startswith('cn=%s' % self.cn2) + + def test_find(self): + """ + Test the `xmlrpc.group_find` method. + """ + res = api.Command['group_find'](self.cn) + assert res + assert len(res) == 3 + assert res[1].get('description','') == self.description + assert res[1].get('cn','') == self.cn + + def test_mod(self): + """ + Test the `xmlrpc.group_mod` method. + """ + modkw = self.kw + modkw['cn'] = self.cn + modkw['description'] = 'New description' + res = api.Command['group_mod'](**modkw) + assert res + assert res.get('description','') == 'New description' + + # Ok, double-check that it was changed + res = api.Command['group_show'](self.cn) + assert res + assert res.get('description','') == 'New description' + assert res.get('cn','') == self.cn + + def test_remove_member(self): + """ + Test the `xmlrpc.group_remove_member` method. + """ + kw={} + kw['groups'] = self.cn2 + res = api.Command['group_remove_member'](self.cn, **kw) + + res = api.Command['group_show'](self.cn) + assert res + assert res.get('member','') == '' + + def test_remove_member2(self): + """ + Test the `xmlrpc.group_remove_member` method with non-member + """ + kw={} + kw['groups'] = "notfound" + # an error isn't thrown, the list of failed members is returned + res = api.Command['group_remove_member'](self.cn, **kw) + assert res != [] + + def test_remove_x(self): + """ + Test the `xmlrpc.group_del` method. + """ + res = api.Command['group_del'](self.cn) + assert res == True + + # Verify that it is gone + try: + res = api.Command['group_show'](self.cn) + except errors.NotFound: + pass + else: + assert False + + def test_remove_x2(self): + """ + Test the `xmlrpc.group_del` method. + """ + res = api.Command['group_del'](self.cn2) + assert res == True + + # Verify that it is gone + try: + res = api.Command['group_show'](self.cn2) + except errors.NotFound: + pass + else: + assert False diff --git a/tests/test_xmlrpc/test_host_plugin.py b/tests/test_xmlrpc/test_host_plugin.py new file mode 100644 index 000000000..515cd703d --- /dev/null +++ b/tests/test_xmlrpc/test_host_plugin.py @@ -0,0 +1,128 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib/plugins/f_host` module. +""" + +import sys +from xmlrpc_test import XMLRPC_test +from ipalib import api +from ipalib import errors +from ipalib.cli import CLI + +try: + api.finalize() +except StandardError: + pass + +class test_Host(XMLRPC_test): + """ + Test the `f_host` plugin. + """ + cn='ipaexample.%s' % api.env.domain + description='Test host' + localityname='Undisclosed location' + kw={'cn': cn, 'description': description, 'localityname': localityname} + + def test_add(self): + """ + Test the `xmlrpc.host_add` method. + """ + res = api.Command['host_add'](**self.kw) + assert res + assert res.get('description','') == self.description + assert res.get('cn','') == self.cn + assert res.get('l','') == self.localityname + + def test_doshow_all(self): + """ + Test the `xmlrpc.host_show` method with all attributes. + """ + kw={'cn':self.cn, 'all': True} + res = api.Command['host_show'](**kw) + assert res + assert res.get('description','') == self.description + assert res.get('cn','') == self.cn + assert res.get('l','') == self.localityname + + def test_doshow_minimal(self): + """ + Test the `xmlrpc.host_show` method with default attributes. + """ + kw={'cn':self.cn} + res = api.Command['host_show'](**kw) + assert res + assert res.get('description','') == self.description + assert res.get('cn','') == self.cn + assert res.get('localityname','') == self.localityname + + def test_find_all(self): + """ + Test the `xmlrpc.host_find` method with all attributes. + """ + kw={'cn':self.cn, 'all': True} + res = api.Command['host_find'](**kw) + assert res + assert len(res) == 2 + assert res[1].get('description','') == self.description + assert res[1].get('cn','') == self.cn + assert res[1].get('l','') == self.localityname + + def test_find_minimal(self): + """ + Test the `xmlrpc.host_find` method with default attributes. + """ + res = api.Command['host_find'](self.cn) + assert res + assert len(res) == 2 + assert res[1].get('description','') == self.description + assert res[1].get('cn','') == self.cn + assert res[1].get('localityname','') == self.localityname + + def test_mod(self): + """ + Test the `xmlrpc.host_mod` method. + """ + newdesc='Updated host' + modkw={'cn': self.cn, 'description': newdesc} + res = api.Command['host_mod'](**modkw) + assert res + assert res.get('description','') == newdesc + + # Ok, double-check that it was changed + res = api.Command['host_show'](self.cn) + assert res + assert res.get('description','') == newdesc + assert res.get('cn','') == self.cn + + def test_remove(self): + """ + Test the `xmlrpc.host_del` method. + """ + res = api.Command['host_del'](self.cn) + assert res == True + + # Verify that it is gone + try: + res = api.Command['host_show'](self.cn) + except errors.NotFound: + pass + else: + assert False diff --git a/tests/test_xmlrpc/test_hostgroup_plugin.py b/tests/test_xmlrpc/test_hostgroup_plugin.py new file mode 100644 index 000000000..9180c1dd1 --- /dev/null +++ b/tests/test_xmlrpc/test_hostgroup_plugin.py @@ -0,0 +1,149 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib/plugins/f_hostgroup` module. +""" + +import sys +from xmlrpc_test import XMLRPC_test +from ipalib import api +from ipalib import errors +from ipalib.cli import CLI + +try: + api.finalize() +except StandardError: + pass + +class test_Host(XMLRPC_test): + """ + Test the `f_hostgroup` plugin. + """ + cn='testgroup' + description='Test host group' + kw={'cn': cn, 'description': description} + + host_cn='ipaexample.%s' % api.env.domain + host_description='Test host' + host_localityname='Undisclosed location' + + def test_add(self): + """ + Test the `xmlrpc.hostgroup_add` method. + """ + res = api.Command['hostgroup_add'](**self.kw) + assert res + assert res.get('description','') == self.description + assert res.get('cn','') == self.cn + + def test_addhost(self): + """ + Add a host to test add/remove member. + """ + kw={'cn': self.host_cn, 'description': self.host_description, 'localityname': self.host_localityname} + res = api.Command['host_add'](**kw) + assert res + assert res.get('description','') == self.host_description + assert res.get('cn','') == self.host_cn + + def test_addmember(self): + """ + Test the `xmlrpc.hostgroup_add_member` method. + """ + kw={} + kw['hosts'] = self.host_cn + res = api.Command['hostgroup_add_member'](self.cn, **kw) + assert res == [] + + def test_doshow(self): + """ + Test the `xmlrpc.hostgroup_show` method. + """ + res = api.Command['hostgroup_show'](self.cn) + assert res + assert res.get('description','') == self.description + assert res.get('cn','') == self.cn + assert res.get('member','').startswith('cn=%s' % self.host_cn) + + def test_find(self): + """ + Test the `xmlrpc.hostgroup_find` method. + """ + res = api.Command['hostgroup_find'](self.cn) + assert res + assert len(res) == 2 + assert res[1].get('description','') == self.description + assert res[1].get('cn','') == self.cn + assert res[1].get('member','').startswith('cn=%s' % self.host_cn) + + def test_mod(self): + """ + Test the `xmlrpc.hostgroup_mod` method. + """ + newdesc='Updated host group' + modkw={'cn': self.cn, 'description': newdesc} + res = api.Command['hostgroup_mod'](**modkw) + assert res + assert res.get('description','') == newdesc + + # Ok, double-check that it was changed + res = api.Command['hostgroup_show'](self.cn) + assert res + assert res.get('description','') == newdesc + assert res.get('cn','') == self.cn + + def test_member_remove(self): + """ + Test the `xmlrpc.hostgroup_remove_member` method. + """ + kw={} + kw['hosts'] = self.host_cn + res = api.Command['hostgroup_remove_member'](self.cn, **kw) + assert res == [] + + def test_remove(self): + """ + Test the `xmlrpc.hostgroup_del` method. + """ + res = api.Command['hostgroup_del'](self.cn) + assert res == True + + # Verify that it is gone + try: + res = api.Command['hostgroup_show'](self.cn) + except errors.NotFound: + pass + else: + assert False + + def test_removehost(self): + """ + Test the `xmlrpc.host_del` method. + """ + res = api.Command['host_del'](self.host_cn) + assert res == True + + # Verify that it is gone + try: + res = api.Command['host_show'](self.host_cn) + except errors.NotFound: + pass + else: + assert False diff --git a/tests/test_xmlrpc/test_netgroup_plugin.py b/tests/test_xmlrpc/test_netgroup_plugin.py new file mode 100644 index 000000000..3d3e4afff --- /dev/null +++ b/tests/test_xmlrpc/test_netgroup_plugin.py @@ -0,0 +1,320 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2009 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib/plugins/f_netgroup` module. +""" + +import sys +from xmlrpc_test import XMLRPC_test +from ipalib import api +from ipalib import errors +from ipalib.cli import CLI + +try: + api.finalize() +except StandardError: + pass + +def is_member_of(members, candidate): + if not isinstance(members, list): + members = [members] + for m in members: + if m.startswith(candidate): + return True + return False + +class test_Netgroup(XMLRPC_test): + """ + Test the `f_netgroup` plugin. + """ + ng_cn='ng1' + ng_description='Netgroup' + ng_kw={'cn': ng_cn, 'description': ng_description} + + host_cn='ipaexample.%s' % api.env.domain + host_description='Test host' + host_localityname='Undisclosed location' + host_kw={'cn': host_cn, 'description': host_description, 'localityname': host_localityname} + + hg_cn='ng1' + hg_description='Netgroup' + hg_kw={'cn': hg_cn, 'description': hg_description} + + user_uid='jexample' + user_givenname='Jim' + user_sn='Example' + user_home='/home/%s' % user_uid + user_kw={'givenname':user_givenname,'sn':user_sn,'uid':user_uid,'homedirectory':user_home} + + group_cn='testgroup' + group_description='This is a test' + group_kw={'description':group_description,'cn':group_cn} + + def test_add(self): + """ + Test the `xmlrpc.netgroup_add` method. + """ + res = api.Command['netgroup_add'](**self.ng_kw) + assert res + assert res.get('description','') == self.ng_description + assert res.get('cn','') == self.ng_cn + + def test_adddata(self): + """ + Add the data needed to do additional testing. + """ + + # Add a host + res = api.Command['host_add'](**self.host_kw) + assert res + assert res.get('description','') == self.host_description + assert res.get('cn','') == self.host_cn + + # Add a hostgroup + res = api.Command['hostgroup_add'](**self.hg_kw) + assert res + assert res.get('description','') == self.hg_description + assert res.get('cn','') == self.hg_cn + + # Add a user + res = api.Command['user_add'](**self.user_kw) + assert res + assert res.get('givenname','') == self.user_givenname + assert res.get('uid','') == self.user_uid + + # Add a group + res = api.Command['group_add'](**self.group_kw) + assert res + assert res.get('description','') == self.group_description + assert res.get('cn','') == self.group_cn + + def test_addmembers(self): + """ + Test the `xmlrpc.netgroup_add_member` method. + """ + kw={} + kw['hosts'] = self.host_cn + res = api.Command['netgroup_add_member'](self.ng_cn, **kw) + assert res == [] + + kw={} + kw['hostgroups'] = self.hg_cn + res = api.Command['netgroup_add_member'](self.ng_cn, **kw) + assert res == [] + + kw={} + kw['users'] = self.user_uid + res = api.Command['netgroup_add_member'](self.ng_cn, **kw) + assert res == [] + + kw={} + kw['groups'] = self.group_cn + res = api.Command['netgroup_add_member'](self.ng_cn, **kw) + assert res == [] + + def test_addmembers2(self): + """ + Test the `xmlrpc.netgroup_add_member` method again to test dupes. + """ + kw={} + kw['hosts'] = self.host_cn + res = api.Command['netgroup_add_member'](self.ng_cn, **kw) + assert is_member_of(res, 'cn=%s' % self.host_cn) + + kw={} + kw['hostgroups'] = self.hg_cn + res = api.Command['netgroup_add_member'](self.ng_cn, **kw) + assert is_member_of(res, 'cn=%s' % self.hg_cn) + + kw={} + kw['users'] = self.user_uid + res = api.Command['netgroup_add_member'](self.ng_cn, **kw) + assert is_member_of(res, 'uid=%s' % self.user_uid) + + kw={} + kw['groups'] = self.group_cn + res = api.Command['netgroup_add_member'](self.ng_cn, **kw) + assert is_member_of(res, 'cn=%s' % self.group_cn) + + def test_addexternalmembers(self): + """ + Test adding external hosts + """ + kw={} + kw['hosts'] = "nosuchhost" + res = api.Command['netgroup_add_member'](self.ng_cn, **kw) + assert res == [] + res = api.Command['netgroup_show'](self.ng_cn) + assert res + assert is_member_of(res.get('externalhost',[]), kw['hosts']) + + def test_doshow(self): + """ + Test the `xmlrpc.netgroup_show` method. + """ + res = api.Command['netgroup_show'](self.ng_cn) + assert res + assert res.get('description','') == self.ng_description + assert res.get('cn','') == self.ng_cn + assert is_member_of(res.get('memberhost',[]), 'cn=%s' % self.host_cn) + assert is_member_of(res.get('memberhost',[]), 'cn=%s' % self.hg_cn) + assert is_member_of(res.get('memberuser',[]), 'uid=%s' % self.user_uid) + assert is_member_of(res.get('memberuser',[]), 'cn=%s' % self.group_cn) + + def test_find(self): + """ + Test the `xmlrpc.hostgroup_find` method. + """ + res = api.Command['netgroup_find'](self.ng_cn) + assert res + assert len(res) == 2 + assert res[1].get('description','') == self.ng_description + assert res[1].get('cn','') == self.ng_cn + + def test_mod(self): + """ + Test the `xmlrpc.hostgroup_mod` method. + """ + newdesc='Updated host group' + modkw={'cn': self.ng_cn, 'description': newdesc} + res = api.Command['netgroup_mod'](**modkw) + assert res + assert res.get('description','') == newdesc + + # Ok, double-check that it was changed + res = api.Command['netgroup_show'](self.ng_cn) + assert res + assert res.get('description','') == newdesc + assert res.get('cn','') == self.ng_cn + + def test_member_remove(self): + """ + Test the `xmlrpc.hostgroup_remove_member` method. + """ + kw={} + kw['hosts'] = self.host_cn + res = api.Command['netgroup_remove_member'](self.ng_cn, **kw) + assert res == [] + + kw={} + kw['hostgroups'] = self.hg_cn + res = api.Command['netgroup_remove_member'](self.ng_cn, **kw) + assert res == [] + + kw={} + kw['users'] = self.user_uid + res = api.Command['netgroup_remove_member'](self.ng_cn, **kw) + assert res == [] + + kw={} + kw['groups'] = self.group_cn + res = api.Command['netgroup_remove_member'](self.ng_cn, **kw) + assert res == [] + + def test_member_remove2(self): + """ + Test the `xmlrpc.netgroup_remove_member` method again to test not found. + """ + kw={} + kw['hosts'] = self.host_cn + res = api.Command['netgroup_remove_member'](self.ng_cn, **kw) + assert is_member_of(res, 'cn=%s' % self.host_cn) + + kw={} + kw['hostgroups'] = self.hg_cn + res = api.Command['netgroup_remove_member'](self.ng_cn, **kw) + assert is_member_of(res, 'cn=%s' % self.hg_cn) + + kw={} + kw['users'] = self.user_uid + res = api.Command['netgroup_remove_member'](self.ng_cn, **kw) + assert is_member_of(res, 'uid=%s' % self.user_uid) + + kw={} + kw['groups'] = self.group_cn + res = api.Command['netgroup_remove_member'](self.ng_cn, **kw) + assert is_member_of(res, 'cn=%s' % self.group_cn) + + def test_remove(self): + """ + Test the `xmlrpc.netgroup_del` method. + """ + res = api.Command['netgroup_del'](self.ng_cn) + assert res == True + + # Verify that it is gone + try: + res = api.Command['netgroup_show'](self.ng_cn) + except errors.NotFound: + pass + else: + assert False + + def test_removedata(self): + """ + Remove the test data we added + """ + # Remove the host + res = api.Command['host_del'](self.host_cn) + assert res == True + + # Verify that it is gone + try: + res = api.Command['host_show'](self.host_cn) + except errors.NotFound: + pass + else: + assert False + + # Remove the hostgroup + res = api.Command['hostgroup_del'](self.hg_cn) + assert res == True + + # Verify that it is gone + try: + res = api.Command['hostgroup_show'](self.hg_cn) + except errors.NotFound: + pass + else: + assert False + + # Remove the user + res = api.Command['user_del'](self.user_uid) + assert res == True + + # Verify that it is gone + try: + res = api.Command['user_show'](self.user_uid) + except errors.NotFound: + pass + else: + assert False + + # Remove the group + res = api.Command['group_del'](self.group_cn) + assert res == True + + # Verify that it is gone + try: + res = api.Command['group_show'](self.group_cn) + except errors.NotFound: + pass + else: + assert False diff --git a/tests/test_xmlrpc/test_service_plugin.py b/tests/test_xmlrpc/test_service_plugin.py new file mode 100644 index 000000000..0a843d36e --- /dev/null +++ b/tests/test_xmlrpc/test_service_plugin.py @@ -0,0 +1,93 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib/plugins/f_service` module. +""" + +import sys +from xmlrpc_test import XMLRPC_test +from ipalib import api +from ipalib import errors +from ipalib.cli import CLI + +try: + api.finalize() +except StandardError: + pass + +class test_Service(XMLRPC_test): + """ + Test the `f_service` plugin. + """ + principal='HTTP/ipatest.%s@%s' % (api.env.domain, api.env.realm) + hostprincipal='host/ipatest.%s@%s' % (api.env.domain, api.env.realm) + kw={'principal':principal} + + def test_add(self): + """ + Test adding a HTTP principal using the `xmlrpc.service_add` method. + """ + res = api.Command['service_add'](**self.kw) + assert res + assert res.get('krbprincipalname','') == self.principal + + def test_add_host(self): + """ + Test adding a host principal using `xmlrpc.service_add` method. + """ + kw={'principal':self.hostprincipal} + try: + res = api.Command['service_add'](**kw) + except errors.HostService: + pass + else: + assert False + + def test_doshow(self): + """ + Test the `xmlrpc.service_show` method. + """ + res = api.Command['service_show'](self.principal) + assert res + assert res.get('krbprincipalname','') == self.principal + + def test_find(self): + """ + Test the `xmlrpc.service_find` method. + """ + res = api.Command['service_find'](self.principal) + assert res + assert len(res) == 2 + assert res[1].get('krbprincipalname','') == self.principal + + def test_remove(self): + """ + Test the `xmlrpc.service_del` method. + """ + res = api.Command['service_del'](self.principal) + assert res == True + + # Verify that it is gone + try: + res = api.Command['service_show'](self.principal) + except errors.NotFound: + pass + else: + assert False diff --git a/tests/test_xmlrpc/test_user_plugin.py b/tests/test_xmlrpc/test_user_plugin.py new file mode 100644 index 000000000..0189aa5ac --- /dev/null +++ b/tests/test_xmlrpc/test_user_plugin.py @@ -0,0 +1,151 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Test the `ipalib/plugins/f_user` module. +""" + +import sys +from xmlrpc_test import XMLRPC_test +from ipalib import api +from ipalib import errors +from ipalib.cli import CLI + +try: + api.finalize() +except StandardError: + pass + +class test_User(XMLRPC_test): + """ + Test the `f_user` plugin. + """ + uid='jexample' + givenname='Jim' + sn='Example' + home='/home/%s' % uid + principalname='%s@%s' % (uid, api.env.realm) + kw={'givenname':givenname,'sn':sn,'uid':uid,'homedirectory':home} + + def test_add(self): + """ + Test the `xmlrpc.user_add` method. + """ + res = api.Command['user_add'](**self.kw) + assert res + assert res.get('givenname','') == self.givenname + assert res.get('sn','') == self.sn + assert res.get('uid','') == self.uid + assert res.get('homedirectory','') == self.home + + def test_add2(self): + """ + Test the `xmlrpc.user_add` method duplicate detection. + """ + try: + res = api.Command['user_add'](**self.kw) + except errors.DuplicateEntry: + pass + + def test_doshow(self): + """ + Test the `xmlrpc.user_show` method. + """ + kw={'uid':self.uid, 'all': True} + res = api.Command['user_show'](**kw) + assert res + assert res.get('givenname','') == self.givenname + assert res.get('sn','') == self.sn + assert res.get('uid','') == self.uid + assert res.get('homedirectory','') == self.home + assert res.get('krbprincipalname','') == self.principalname + + def test_find_all(self): + """ + Test the `xmlrpc.user_find` method with all attributes. + """ + kw={'uid':self.uid, 'all': True} + res = api.Command['user_find'](**kw) + assert res + assert len(res) == 2 + assert res[1].get('givenname','') == self.givenname + assert res[1].get('sn','') == self.sn + assert res[1].get('uid','') == self.uid + assert res[1].get('homedirectory','') == self.home + assert res[1].get('krbprincipalname','') == self.principalname + + def test_find_minimal(self): + """ + Test the `xmlrpc.user_find` method with minimal attributes. + """ + res = api.Command['user_find'](self.uid) + assert res + assert len(res) == 2 + assert res[1].get('givenname','') == self.givenname + assert res[1].get('sn','') == self.sn + assert res[1].get('uid','') == self.uid + assert res[1].get('homedirectory','') == self.home + assert res[1].get('krbprincipalname', None) == None + + def test_lock(self): + """ + Test the `xmlrpc.user_lock` method. + """ + res = api.Command['user_lock'](self.uid) + assert res == True + + def test_lockoff(self): + """ + Test the `xmlrpc.user_unlock` method. + """ + res = api.Command['user_unlock'](self.uid) + assert res == True + + def test_mod(self): + """ + Test the `xmlrpc.user_mod` method. + """ + modkw = self.kw + modkw['givenname'] = 'Finkle' + res = api.Command['user_mod'](**modkw) + assert res + assert res.get('givenname','') == 'Finkle' + assert res.get('sn','') == self.sn + + # Ok, double-check that it was changed + res = api.Command['user_show'](self.uid) + assert res + assert res.get('givenname','') == 'Finkle' + assert res.get('sn','') == self.sn + assert res.get('uid','') == self.uid + + def test_remove(self): + """ + Test the `xmlrpc.user_del` method. + """ + res = api.Command['user_del'](self.uid) + assert res == True + + # Verify that it is gone + try: + res = api.Command['user_show'](self.uid) + except errors.NotFound: + pass + else: + assert False diff --git a/tests/test_xmlrpc/xmlrpc_test.py b/tests/test_xmlrpc/xmlrpc_test.py new file mode 100644 index 000000000..744c0c277 --- /dev/null +++ b/tests/test_xmlrpc/xmlrpc_test.py @@ -0,0 +1,49 @@ +# Authors: +# Rob Crittenden <rcritten@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Base class for all XML-RPC tests +""" + +import sys +import socket +import nose +from ipalib import api +from ipalib import errors +from ipalib.cli import CLI + +try: + api.finalize() +except StandardError: + pass + +class XMLRPC_test: + """ + Base class for all XML-RPC plugin tests + """ + + def setUp(self): + # FIXME: changing Plugin.name from a property to an instance attribute + # somehow broke this. + try: + res = api.Command['user_show']('notfound') + except socket.error: + raise nose.SkipTest + except errors.NotFound: + pass diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 000000000..f5899dfab --- /dev/null +++ b/tests/util.py @@ -0,0 +1,391 @@ +# Authors: +# Jason Gerard DeRose <jderose@redhat.com> +# +# Copyright (C) 2008 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License as +# published by the Free Software Foundation; version 2 only +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +""" +Common utility functions and classes for unit tests. +""" + +import inspect +import os +from os import path +import tempfile +import shutil +import ipalib +from ipalib.plugable import Plugin +from ipalib.request import context + + + +class TempDir(object): + def __init__(self): + self.__path = tempfile.mkdtemp(prefix='ipa.tests.') + assert self.path == self.__path + + def __get_path(self): + assert path.abspath(self.__path) == self.__path + assert self.__path.startswith('/tmp/ipa.tests.') + assert path.isdir(self.__path) and not path.islink(self.__path) + return self.__path + path = property(__get_path) + + def rmtree(self): + if self.__path is not None: + shutil.rmtree(self.path) + self.__path = None + + def makedirs(self, *parts): + d = self.join(*parts) + if not path.exists(d): + os.makedirs(d) + assert path.isdir(d) and not path.islink(d) + return d + + def touch(self, *parts): + d = self.makedirs(*parts[:-1]) + f = path.join(d, parts[-1]) + assert not path.exists(f) + open(f, 'w').close() + assert path.isfile(f) and not path.islink(f) + return f + + def write(self, content, *parts): + d = self.makedirs(*parts[:-1]) + f = path.join(d, parts[-1]) + assert not path.exists(f) + open(f, 'w').write(content) + assert path.isfile(f) and not path.islink(f) + return f + + def join(self, *parts): + return path.join(self.path, *parts) + + def __del__(self): + self.rmtree() + + +class TempHome(TempDir): + def __init__(self): + super(TempHome, self).__init__() + self.__home = os.environ['HOME'] + os.environ['HOME'] = self.path + + +class ExceptionNotRaised(Exception): + """ + Exception raised when an *expected* exception is *not* raised during a + unit test. + """ + msg = 'expected %s' + + def __init__(self, expected): + self.expected = expected + + def __str__(self): + return self.msg % self.expected.__name__ + + +def assert_equal(val1, val2): + """ + Assert ``val1`` and ``val2`` are the same type and of equal value. + """ + assert type(val1) is type(val2), '%r != %r' % (val1, val2) + assert val1 == val2, '%r != %r' % (val1, val2) + + +def raises(exception, callback, *args, **kw): + """ + Tests that the expected exception is raised; raises ExceptionNotRaised + if test fails. + """ + raised = False + try: + callback(*args, **kw) + except exception, e: + raised = True + if not raised: + raise ExceptionNotRaised(exception) + return e + + +def getitem(obj, key): + """ + Works like getattr but for dictionary interface. Use this in combination + with raises() to test that, for example, KeyError is raised. + """ + return obj[key] + + +def setitem(obj, key, value): + """ + Works like setattr but for dictionary interface. Use this in combination + with raises() to test that, for example, TypeError is raised. + """ + obj[key] = value + + +def delitem(obj, key): + """ + Works like delattr but for dictionary interface. Use this in combination + with raises() to test that, for example, TypeError is raised. + """ + del obj[key] + + +def no_set(obj, name, value='some_new_obj'): + """ + Tests that attribute cannot be set. + """ + raises(AttributeError, setattr, obj, name, value) + + +def no_del(obj, name): + """ + Tests that attribute cannot be deleted. + """ + raises(AttributeError, delattr, obj, name) + + +def read_only(obj, name, value='some_new_obj'): + """ + Tests that attribute is read-only. Returns attribute. + """ + # Test that it cannot be set: + no_set(obj, name, value) + + # Test that it cannot be deleted: + no_del(obj, name) + + # Return the attribute + return getattr(obj, name) + + +def is_prop(prop): + return type(prop) is property + + +class ClassChecker(object): + __cls = None + __subcls = None + + def __get_cls(self): + if self.__cls is None: + self.__cls = self._cls + assert inspect.isclass(self.__cls) + return self.__cls + cls = property(__get_cls) + + def __get_subcls(self): + if self.__subcls is None: + self.__subcls = self.get_subcls() + assert inspect.isclass(self.__subcls) + return self.__subcls + subcls = property(__get_subcls) + + def get_subcls(self): + raise NotImplementedError( + self.__class__.__name__, + 'get_subcls()' + ) + + def tearDown(self): + """ + nose tear-down fixture. + """ + for name in ('ugettext', 'ungettext'): + if hasattr(context, name): + delattr(context, name) + + + + + + + +def check_TypeError(value, type_, name, callback, *args, **kw): + """ + Tests a standard TypeError raised with `errors.raise_TypeError`. + """ + e = raises(TypeError, callback, *args, **kw) + assert e.value is value + assert e.type is type_ + assert e.name == name + assert type(e.name) is str + assert str(e) == ipalib.errors.TYPE_FORMAT % (name, type_, value) + return e + + +def get_api(**kw): + """ + Returns (api, home) tuple. + + This function returns a tuple containing an `ipalib.plugable.API` + instance and a `TempHome` instance. + """ + home = TempHome() + api = ipalib.create_api(mode='unit_test') + api.env.in_tree = True + for (key, value) in kw.iteritems(): + api.env[key] = value + return (api, home) + + +def create_test_api(**kw): + """ + Returns (api, home) tuple. + + This function returns a tuple containing an `ipalib.plugable.API` + instance and a `TempHome` instance. + """ + home = TempHome() + api = ipalib.create_api(mode='unit_test') + api.env.in_tree = True + for (key, value) in kw.iteritems(): + api.env[key] = value + return (api, home) + + +class PluginTester(object): + __plugin = None + + def __get_plugin(self): + if self.__plugin is None: + self.__plugin = self._plugin + assert issubclass(self.__plugin, Plugin) + return self.__plugin + plugin = property(__get_plugin) + + def register(self, *plugins, **kw): + """ + Create a testing api and register ``self.plugin``. + + This method returns an (api, home) tuple. + + :param plugins: Additional \*plugins to register. + :param kw: Additional \**kw args to pass to `create_test_api`. + """ + (api, home) = create_test_api(**kw) + api.register(self.plugin) + for p in plugins: + api.register(p) + return (api, home) + + def finalize(self, *plugins, **kw): + (api, home) = self.register(*plugins, **kw) + api.finalize() + return (api, home) + + def instance(self, namespace, *plugins, **kw): + (api, home) = self.finalize(*plugins, **kw) + o = api[namespace][self.plugin.__name__] + return (o, api, home) + + +class dummy_ugettext(object): + __called = False + + def __init__(self, translation=None): + if translation is None: + translation = u'The translation' + self.translation = translation + assert type(self.translation) is unicode + + def __call__(self, message): + assert self.__called is False + self.__called = True + assert type(message) is str + assert not hasattr(self, 'message') + self.message = message + assert type(self.translation) is unicode + return self.translation + + def called(self): + return self.__called + + def reset(self): + assert type(self.translation) is unicode + assert type(self.message) is str + del self.message + assert self.__called is True + self.__called = False + + +class dummy_ungettext(object): + __called = False + + def __init__(self): + self.translation_singular = u'The singular translation' + self.translation_plural = u'The plural translation' + + def __call__(self, singular, plural, n): + assert type(singular) is str + assert type(plural) is str + assert type(n) is int + assert self.__called is False + self.__called = True + self.singular = singular + self.plural = plural + self.n = n + if n == 1: + return self.translation_singular + return self.translation_plural + + +class DummyMethod(object): + def __init__(self, callback, name): + self.__callback = callback + self.__name = name + + def __call__(self, *args, **kw): + return self.__callback(self.__name, args, kw) + + +class DummyClass(object): + def __init__(self, *calls): + self.__calls = calls + self.__i = 0 + for (name, args, kw, result) in calls: + method = DummyMethod(self.__process, name) + setattr(self, name, method) + + def __process(self, name_, args_, kw_): + if self.__i >= len(self.__calls): + raise AssertionError( + 'extra call: %s, %r, %r' % (name, args, kw) + ) + (name, args, kw, result) = self.__calls[self.__i] + self.__i += 1 + i = self.__i + if name_ != name: + raise AssertionError( + 'call %d should be to method %r; got %r' % (i, name, name_) + ) + if args_ != args: + raise AssertionError( + 'call %d to %r should have args %r; got %r' % (i, name, args, args_) + ) + if kw_ != kw: + raise AssertionError( + 'call %d to %r should have kw %r, got %r' % (i, name, kw, kw_) + ) + if isinstance(result, Exception): + raise result + return result + + def _calledall(self): + return self.__i == len(self.__calls) |