python-redmine-2.2.1/0000755000076500000240000000000013435744437015770 5ustar maxtepkeevstaff00000000000000python-redmine-2.2.1/python_redmine.egg-info/0000755000076500000240000000000013435744437022506 5ustar maxtepkeevstaff00000000000000python-redmine-2.2.1/python_redmine.egg-info/PKG-INFO0000644000076500000240000012322613435744437023611 0ustar maxtepkeevstaff00000000000000Metadata-Version: 1.2 Name: python-redmine Version: 2.2.1 Summary: Library for communicating with a Redmine project management application Home-page: https://github.com/maxtepkeev/python-redmine Author: Maxim Tepkeev Author-email: support@python-redmine.com License: Apache 2.0 Project-URL: Documentation, https://python-redmine.com Description: Python-Redmine ============== .. image:: https://badge.fury.io/py/python-redmine.svg :target: https://badge.fury.io/py/python-redmine .. image:: https://img.shields.io/travis/maxtepkeev/python-redmine/master.svg :target: https://travis-ci.org/maxtepkeev/python-redmine .. image:: https://img.shields.io/coveralls/maxtepkeev/python-redmine/master.svg :target: https://coveralls.io/r/maxtepkeev/python-redmine?branch=master Python-Redmine is a library for communicating with a `Redmine `__ project management application. Redmine exposes some of it's data via `REST API `__ for which Python-Redmine provides a simple but powerful Pythonic API inspired by a well-known `Django ORM `__: .. code-block:: python >>> from redminelib import Redmine >>> redmine = Redmine('http://demo.redmine.org', username='foo', password='bar') >>> project = redmine.project.get('vacation') >>> project.id 30404 >>> project.identifier 'vacation' >>> project.created_on datetime.datetime(2013, 12, 31, 13, 27, 47) >>> project.issues >>> project.issues[0] >>> dir(project.issues[0]) ['assigned_to', 'author', 'created_on', 'description', 'done_ratio', 'due_date', 'estimated_hours', 'id', 'priority', 'project', 'relations', 'start_date', 'status', 'subject', 'time_entries', 'tracker', 'updated_on'] >>> project.issues[0].subject 'Vacation' >>> project.issues[0].time_entries Features -------- * Supports 100% of Redmine API * Supports external Redmine plugins API * Supports Python 2.7, 3.4 - 3.7, PyPy and PyPy3 * Supports different request engines * Extendable via custom resources and custom request engines * Extensively documented * Provides ORM-style Pythonic API * And many more... Installation ------------ Standard Edition ++++++++++++++++ The recommended way to install is from Python Package Index (PyPI) with `pip `__: .. code-block:: bash $ pip install python-redmine Pro Edition +++++++++++ License for a Pro Edition can be bought `here `__. You will receive an email with all the details regarding Pro Edition installation process. Documentation ------------- Documentation is available at https://python-redmine.com. Contacts and Support -------------------- Support for Standard Edition is provided via `GitHub `__ only, while support for Pro Edition is provided both via `GitHub `__ and support@python-redmine.com. Be sure to write from email that was specified during the purchase procedure. Copyright and License --------------------- Python-Redmine Standard Edition is licensed under Apache 2.0 license. Python-Redmine Pro Edition is licensed under the Python-Redmine Pro Edition 1.0 license. Check the `License `__ for details. Changelog --------- 2.2.1 (2019-02-28) ++++++++++++++++++ **Bugfixes**: - ProjectMembership resource ``group`` attribute was returned as a dict instead of being converted to Resource object (`Issue #220 `__) (thanks to `Samuel Harmer `__) 2.2.0 (2019-01-13) ++++++++++++++++++ **Improvements**: - ``PerformanceWarning`` will be issued when Python-Redmine does some unnecessary work under the hood to fix the clients code problems **Changes**: - *Backwards Incompatible:* Removed vendored Requests package and make it an external dependency as Requests did the same with it's own dependencies - *Backwards Incompatible:* Removed Python 2.6 and 3.3 support as they're not supported by Requests anymore **Bugfixes**: - ``Redmine.upload()`` fails under certain circumstances when used with a file-like object and it contains unicode instead of bytes (`Issue #216 `__) - ``Redmine.session()`` doesn't restore previous engine if fails (`Issue #211 `__) (thanks to `Dmitry Logvinenko `__) 2.1.1 (2018-05-02) ++++++++++++++++++ - Fix PyPI package 2.1.0 (2018-05-02) ++++++++++++++++++ This release concentrates mostly on stability and adds small features here and there. Some of them are backwards incompatible and are marked as such. They shouldn't affect many users since most of them were used internally by Python-Redmine. A support for the Files API has been finally added, but please be sure to check it's documentation as the implementation on the Redmine side is horrible and there are things to keep in mind while working with Files API. Lastly, only until the end of May 2018 there is a chance to buy a Pro Edition for only 14.99$ instead of the usual 24.99$, this is your chance to get an edition with additional features for a good price and to support the further development of Python-Redmine, more info `here `_. **New Features**: - Files API support (`Issue #117 `__) **Improvements**: - *Backwards Incompatible:* ResourceSet's ``filter()`` method became more advanced. It is now possible to filter on all available resource attributes, to follow resource relationships and apply lookups to the filters (see `docs `__ for details) - ResourceManager class has been refactored: * ``manager_class`` attribute on the ``Resource`` class can now be used to assign a separate ``ResourceManager`` to a resource, that allows outsourcing a resource specific functionality to a separate manager class (see ``WikiPageManager`` as an example) * *Backwards Incompatible:* ``request()`` method has been removed * ``_construct_*_url()``, ``_prepare_*_request()``, ``_process_*_response()`` methods have been added for create, update and delete methods to allow a fine-grained control over these operations - Ability to upload file-like objects (`Issue #186 `__) (thanks to `hjpotter92 `__) - Support for retrieving project's time entry activities (see `docs `__ for details) - Attachment ``update()`` operation support (requires Redmine >= 3.4.0) - ``Resource.save()`` now accepts ``**attrs`` that need to be changed/set and returns ``self`` instead of a boolean ``True``, which makes it chainable, so you can now do something like ``project.save(name='foo', description='bar').export('txt', '/home/foo')`` - ``get`` operation support for News, Query, Enumeration, IssueStatus, Tracker, CustomField, ContactTag, DealStatus, DealCategory and CRMQuery resources - ``include`` param in ``get``, ``all`` and ``filter`` operations now accepts lists and tuples instead of comma-separated string which is still accepted for backward compatibility reasons, i.e. one can use ``include=['foo', 'bar']`` instead of ``include='foo,bar'`` - It is now possible to use ``None`` and ``0`` in addition to ``''`` in ``assigned_to_id`` attribute in Issue resource if an assignee needs to be removed from an issue **Changes**: - *Backwards Incompatible:* Issue ``all`` operation now really returns all issues, i.e. both open and closed, instead of only returning open issues in previous versions due to the respect to Redmine's standard behaviour - *Backwards Incompatible:* Instead of only returning a token string, ``upload()`` method was modified to return a dict that contains all the data for an upload returned from Redmine, i.e. id and token for Redmine >= 3.4.0, token only for Redmine < 3.4.0. Also it is now possible to use this token and pass it using a ``token`` key instead of the ``path`` key with path to the file in ``uploads`` parameter when doing an upload, this gives more control over the uploading process if needed - *Backwards Incompatible:* Removed ``resource_paths`` argument from Redmine object since ``ResourceManager`` now uses a special resource registry, to which, all resources that inherit from any Python-Redmine resource are being automatically added - *Backwards Incompatible:* Removed ``container_many`` in favor of ``container_filter``, ``container_create`` and ``container_update`` attributes on ``Resource`` object to allow more fine-grained resource setup - *Backwards Incompatible:* ``return_raw`` parameter on ``engine.request()`` and ``engine.process_response()`` methods has been removed in favor of ``return_raw_response`` attribute on engine object - Updated bundled requests library to v2.15.1 **Bugfixes**: - Support 204 status code when deleting a resource (`Issue #189 `__) (thanks to `dotSlashLu `__) - Raise ``ValidationError`` instead of not helpful ``TypeError`` exception when trying to create a WikiPage resource that already exists (`Issue #182 `__) - Enumeration, Version, Group and Notes ``custom_fields`` attribute was returned as a list of dicts instead of being converted to ``ResourceSet`` object - Downloads were downloaded fully into memory instead of being streamed as needed - ``ResourceRequirementsError`` exception was broken since v2.0.0 - RedmineUP CRM Contact and Deal resources export functionality didn't work - RedmineUP CRM Contact and Deal resources sometimes weren't converted to Resource objects using Search API **Documentation**: - Mentioned support for ``generate_password`` and ``send_information`` in User's resource create/update methods, ``status`` in User's resource update method, ``parent_id`` in Issue's filter method and ``include`` in Issue's all method 2.0.2 (2017-04-19) ++++++++++++++++++ **Bugfixes**: - Filter doesn't work when there are > 100 resources requested (`Issue #175 `__) (thanks to `niwatolli3 `__) 2.0.1 (2017-04-10) ++++++++++++++++++ - Fix PyPI package 2.0.0 (2017-04-10) ++++++++++++++++++ This version brings a lot of new features and changes, some of them are backward-incompatible, so please look carefully at the changelog below to find out what needs to be changed in your code to make it work with this version. Also Python-Redmine now comes in 2 editions: Standard and Pro, please have a look at this `document `__ for more details. Documentation was also significantly rewritten, so it is recommended to reread it even if you are an experienced Python-Redmine user. **New Features**: - RedmineUP `Checklist plugin `__ support - `Request Engines `__ support. It is now possible to create engines to define how requests to Redmine are made, e.g. synchronous (one by one) or asynchronous using threads or processes etc - ``redmine.session()`` context manager which allows to temporary redefine engine's behaviour - Search API support (`Issue #138 `__) - Export functionality (`Issue #58 `__) - REDMINE_USE_EXTERNAL_REQUESTS environmental variable for emergency cases which allows to use external requests instead of bundled one even if external requests version is lower than the bundled one - Wrong HTTP protocol usage detector, e.g. one use HTTP when HTTPS should be used **Improvements**: - ResourceSet objects were completely rewritten: * ``ResourceSet`` object that was already sliced now supports reslicing * ``ResourceSet`` object's ``delete()``, ``update()``, ``filter()`` and ``get()`` methods have been optimized for speed * ``ResourceSet`` object's ``delete()`` and ``update()`` methods now call the corresponding Resource's ``pre_*()`` and ``post_*()`` methods * ``ResourceSet`` object's ``get()`` and ``filter()`` methods now supports non-integer id's, e.g. WikiPage's title can now be used with it * *Backwards Incompatible:* ``ValuesResourceSet`` class has been removed * *Backwards Incompatible:* ``ResourceSet.values()`` method now returns an iterable of dicts instead of ``ValuesResourceSet`` object * ``ResourceSet.values_list()`` method has been added which returns an iterable of tuples with Resource values or single values if flattened, i.e. ``flat=True`` - New ``Resource`` object methods: * ``delete()`` deletes current resource from Redmine * ``pre_delete()`` and ``post_delete()`` can be used to execute tasks that should be done before/after deleting the resource through ``delete()`` method * ``bulk_decode()``, ``bulk_encode()``, ``decode()`` and ``encode()`` which are used to translate attributes of the resource to/from Python/Redmine - Attachment ``delete()`` method support (requires Redmine >= 3.3.0) - RedmineUP CRM Note resource now provides ``type`` attribute which shows text representation of ``type_id`` - RedmineUP CRM DealStatus resource now provides ``status`` attribute which shows text representation of ``status_type`` - WikiPage resource now provides ``project_id`` attribute - Unicode handling was significantly rewritten and shouldn't cause any more troubles - ``UnknownError`` exception now contains ``status_code`` attribute which can be used to handle the exception instead of parsing code from exception's text - Sync engine's speed improved to 8-12% depending on the amount of resources fetched **Changes**: - *Backwards Incompatible:* Renamed package name from ``redmine`` to ``redminelib`` - Resource class attributes that were previously tuples are now lists - *Backwards Incompatible:* ``_Resource`` class renamed to ``Resource`` - *Backwards Incompatible:* ``Redmine.custom_resource_paths`` keyword argument renamed to ``resource_paths`` - *Backwards Incompatible:* ``Redmine.download()`` method now returns a `requests.Response `__ object directly instead of ``iter_content()`` method if a ``savepath`` param wasn't provided, this gives user even more control over response data - *Backwards Incompatible:* ``Resource.refresh()`` now really refreshes itself instead of returning a new refreshed resource, to get the previous behaviour use ``itself`` param, e.g. ``Resource.refresh(itself=False)`` - *Backwards Incompatible:* Removed Python 3.2 support - *Backwards Incompatible:* Removed ``container_filter``, ``container_create`` and ``container_update`` attributes on ``Resource`` object in favor of ``container_many`` attribute - *Backwards Incompatible:* Removed ``Resource.translate_params()`` and ``ResourceManager.prepare_params()`` in favor of ``Resource.bulk_decode()`` - *Backwards Incompatible:* Removed ``is_unicode()``, ``is_string()`` and ``to_string()`` from ``redminelib.utilities`` - Updated bundled requests library to v2.13.0 **Bugfixes**: - Infinite loop when uploading zero-length files (`Issue #152 `__) - Unsupported Redmine resource error while trying to use Python-Redmine without installation (`Issue #156 `__) - It was impossible to set ``data``, ``params`` and ``headers`` via ``requests`` keyword argument on Redmine object - Calling ``str()`` or ``repr()`` on a Resource was giving incorrect results if exception raising was turned off for a resource **Documentation**: - Switched to the alabaster theme - Added new sections: * `Editions `__ * `Introduction `__ * `Request Engines `__ - Added info about Issue Journals (`Issue #120 `__) - Added note about open/closed issues (`Issue #136 `__) - Added note about regexp custom field filter (`Issue #164 `__) - Added some new information here and there 1.5.1 (2016-03-27) ++++++++++++++++++ - Changed: Updated bundled requests package to 2.9.1 - Changed: `Issue #124 `__ (``project.url`` now uses ``identifier`` rather than ``id`` to generate url for the project resource) - Fixed: `Issue #122 `__ (``ValidationError`` for empty custom field values was possible under some circumstances with Redmine < 2.5.0) - Fixed: `Issue #112 `__ (``UnicodeEncodeError`` on Python 2 if ``resource_id`` was of ``unicode`` type) (thanks to `Digenis `__) 1.5.0 (2015-11-26) ++++++++++++++++++ - Added: Documented support for new fields and values in User, Issue and IssueRelation resources - Added: `Issue #109 `__ (Smart imports for vendored packages (see `docs `__ for details) - Added: `Issue #115 `__ (File upload support for WikiPage resource) 1.4.0 (2015-10-18) ++++++++++++++++++ - Added: `Requests `__ is now embedded into Python-Redmine - Added: Python-Redmine is now embeddable to other libraries - Fixed: Previous release was broken on PyPI 1.3.0 (2015-10-18) ++++++++++++++++++ - Added: `Issue #108 `__ (Tests are now built-in into source package distributed via PyPI) 1.2.0 (2015-07-09) ++++++++++++++++++ - Added: `wheel `__ support - Added: `Issue #93 `__ (``JSONDecodeError`` exception now contains a ``response`` attribute which can be inspected to identify the cause of the exception) - Added: `Issue #98 `__ (Support for setting WikiPage resource parent title and converting parent attribute to Resource object instead of being a dict) 1.1.2 (2015-05-20) ++++++++++++++++++ - Fixed: `Issue #90 `__ (Python-Redmine fails to install on systems with LC_ALL=C) (thanks to `spikergit1 `__) 1.1.1 (2015-03-26) ++++++++++++++++++ - Fixed: `Issue #85 `__ (Python-Redmine was trying to convert field to date/datetime even when it shouldn't, i.e. if a field looked like YYYY-MM-DD but wasn't actually a date/datetime field, e.g. wiki page title or issue subject) 1.1.0 (2015-02-20) ++++++++++++++++++ - Added: PyPy2/3 is now officially supported - Added: Introduced ``enabled_modules`` on demand include in Project resource - Fixed: `Issue #78 `__ (Redmine <2.5.2 returns only single tracker instead of a list of all available trackers when requested from a CustomField resource which caused an Exception in Python-Redmine, see `this `__ for details) - Fixed: `Issue #80 `__ (If a project is read-only or doesn't have CRM plugin enabled, an attempt to add/remove Contact resource to/from it will lead to improper error message) - Fixed: `Issue #81 `__ (Contact's resource ``tag_list`` attribute was always splitted into single chars) (thanks to `Alexander Loechel `__) 1.0.3 (2015-02-03) ++++++++++++++++++ - Fixed: `Issue #72 `__ (If an exception is raised during JSON decoding process, it should be catched and reraised as Python-Redmine's own exception, i.e ``redmine.exceptions.JSONDecodeError``) - Fixed: `Issue #76 `__ (It was impossible to retrieve more than 100 resources for resources which don't support limit/offset natively by Redmine, i.e. this functionality is emulated by Python-Redmine, e.g. WikiPage, Groups, Roles etc) 1.0.2 (2014-11-13) ++++++++++++++++++ - Fixed: `Issue #55 `__ (TypeError was raised during processing validation errors from Redmine when one of the errors was returned as a list) - Fixed: `Issue #59 `__ (Raise ForbiddenError when a 403 is encountered) (thanks to `Rick Harris `__) - Fixed: `Issue #64 `__ (Redmine and Resource classes weren't picklable) (thanks to `Rick Harris `__) - Fixed: A ResourceSet object with a limit=100, actually returned 125 Resource objects 1.0.1 (2014-09-23) ++++++++++++++++++ - Fixed: `Issue #50 `__ (IssueJournal's ``notes`` attribute was converted to Note resource by mistake, bug was introduced in v1.0.0) 1.0.0 (2014-09-22) ++++++++++++++++++ - Added: Support for the `CRM plugin `__ resources: * `Contact `__ * `ContactTag `__ * `Note `__ * `Deal `__ * `DealStatus `__ * `DealCategory `__ * `CrmQuery `__ - Added: Introduced new relations for the following resource objects: * Project - time_entries, deals, contacts and deal_categories relations * User - issues, time_entries, deals and contacts relations * Tracker - issues relation * IssueStatus - issues relation - Added: Introduced a ``values()`` method in a ResourceSet which returns ValuesResourceSet - a ResourceSet subclass that returns dictionaries when used as an iterable, rather than resource-instance objects (see `docs `__ for details) - Added: Introduced ``update()`` and ``delete()`` methods in a ResourceSet object which allow to bulk update or bulk delete all resources in a ResourceSet object (see `docs `__ for details) - Fixed: It was impossible to use ResourceSet's ``get()`` and ``filter()`` methods with WikiPage resource - Fixed: Several small fixes and enhancements here and there 0.9.0 (2014-09-11) ++++++++++++++++++ - Added: Introduced support for file downloads (see `docs `__ for details) - Added: Introduced new ``_Resource.requirements`` class attribute where all Redmine plugins required by resource should be listed (preparations to support non-native resources) - Added: New exceptions: * ResourceRequirementsError - Fixed: It was impossible to set a custom field of date/datetime type using date/datetime Python objects - Fixed: `Issue #46 `__ (A UnicodeEncodeError was raised in Python 2.x while trying to access a ``url`` property of a WikiPage resource if it contained non-ascii characters) 0.8.4 (2014-08-08) ++++++++++++++++++ - Added: Support for anonymous Attachment resource (i.e. attachment with ``id`` attr only) - Fixed: `Issue #42 `__ (It was impossible to create a Project resource via ``new()`` method) 0.8.3 (2014-08-01) ++++++++++++++++++ - Fixed: `Issue #39 `__ (It was impossible to save custom_fields in User resource via ``new()`` method) 0.8.2 (2014-05-27) ++++++++++++++++++ - Added: ResourceSet's ``get()`` method now supports a ``default`` keyword argument which is returned when a requested Resource can't be found in a ResourceSet and defaults to ``None``, previously this was hardcoded to ``None`` - Added: It is now possible to use ``getattr()`` with default value without raising a ``ResourceAttrError`` when calling non-existent resource attribute, see `Issue #30 `__ for details (thanks to `hsum `__) - Fixed: `Issue #31 `__ (Unlimited recursion was possible in some situations when on demand includes were used) 0.8.1 (2014-04-02) ++++++++++++++++++ - Added: New exceptions: * RequestEntityTooLargeError * UnknownError - Fixed: `Issue #27 `__ (Project and Issue resources ``parent`` attribute was returned as a dict instead of being converted to Resource object) 0.8.0 (2014-03-27) ++++++++++++++++++ - Added: Introduced the detection of conflicting packages, i.e. if a conflicting package is found (PyRedmineWS at this time is the only one), the installation procedure will be aborted and a warning message will be shown with the detailed description of the problem - Added: Introduced new ``_Resource._members`` class attribute where all instance attributes which are not started with underscore should be listed. This will resolve recursion issues in custom resources because of how ``__setattr__()`` works in Python - Changed: ``_Resource.attributes`` renamed to ``_Resource._attributes`` - Fixed: Python-Redmine was unable to upload any binary files - Fixed: `Issue #20 `__ (Lowered Requests version requirements. Python-Redmine now requires Requests starting from 0.12.1 instead of 2.1.0 in previous versions) - Fixed: `Issue #23 `__ (File uploads via ``update()`` method didn't work) 0.7.2 (2014-03-17) ++++++++++++++++++ - Fixed: `Issue #19 `__ (Resources obtained via ``filter()`` and ``all()`` methods have incomplete url attribute) - Fixed: Redmine server url with forward slash could cause errors in rare cases - Fixed: Python-Redmine was incorrectly raising ``ResourceAttrError`` when trying to call ``repr()`` on a News resource 0.7.1 (2014-03-14) ++++++++++++++++++ - Fixed: `Issue #16 `__ (When a resource was created via a ``new()`` method, the next resource created after that inherited all the attribute values of the previous resource) 0.7.0 (2014-03-12) ++++++++++++++++++ - Added: WikiPage resource now automatically requests all of it's available attributes from Redmine in case if some of them are not available in an existent resource object - Added: Support for setting date/datetime resource attributes using date/datetime Python objects - Added: Support for using date/datetime Python objects in all ResourceManager methods, i.e. ``new()``, ``create()``, ``update()``, ``delete()``, ``get()``, ``all()``, ``filter()`` - Fixed: `Issue #14 `__ (Python-Redmine was incorrectly raising ``ResourceAttrError`` when trying to call ``repr()``, ``str()`` and ``int()`` on resources, created via ``new()`` method) 0.6.2 (2014-03-09) ++++++++++++++++++ - Fixed: Project resource ``status`` attribute was converted to IssueStatus resource by mistake 0.6.1 (2014-02-27) ++++++++++++++++++ - Fixed: `Issue #10 `__ (Python Redmine was incorrectly raising ``ResourceAttrError`` while creating some resources via ``new()`` method) 0.6.0 (2014-02-19) ++++++++++++++++++ - Added: ``Redmine.auth()`` shortcut for the case if we just want to check if user provided valid auth credentials, can be used for user authentication on external resource based on Redmine user database (see `docs `__ for details) - Fixed: ``JSONDecodeError`` was raised in some Redmine versions during some create/update operations (thanks to `0x55aa `__) - Fixed: User resource ``status`` attribute was converted to IssueStatus resource by mistake 0.5.0 (2014-02-09) ++++++++++++++++++ - Added: An ability to create custom resources which allow to easily redefine the behaviour of existing resources (see `docs `__ for details) - Added: An ability to add/remove watcher to/from issue (see `docs `__ for details) - Added: An ability to add/remove users to/from group (see `docs `__ for details) 0.4.0 (2014-02-08) ++++++++++++++++++ - Added: New exceptions: * ConflictError * ReadonlyAttrError * ResultSetTotalCountError * CustomFieldValueError - Added: Update functionality via ``update()`` and ``save()`` methods for resources (see `docs `__ for details): * User * Group * IssueCategory * Version * TimeEntry * ProjectMembership * WikiPage * Project * Issue - Added: Limit/offset support via ``all()`` and ``filter()`` methods for resources that doesn't support that feature via Redmine: * IssueRelation * Version * WikiPage * IssueStatus * Tracker * Enumeration * IssueCategory * Role * Group * CustomField - Added: On demand includes, e.g. in addition to ``redmine.group.get(1, include='users')`` users for a group can also be retrieved on demand via ``group.users`` if include wasn't set (see `docs `__ for details) - Added: ``total_count`` attribute to ResourceSet object which holds the total number of resources for the current resource type available in Redmine (thanks to `Andrei Avram `__) - Added: An ability to return ``None`` instead of raising a ``ResourceAttrError`` for all or selected resource objects via ``raise_attr_exception`` kwarg on Redmine object (see `docs `__ for details or `Issue #6 `__) - Added: ``pre_create()``, ``post_create()``, ``pre_update()``, ``post_update()`` resource object methods which can be used to execute tasks that should be done before/after creating/updating the resource through ``save()`` method - Added: Allow to create resources in alternative way via ``new()`` method (see `docs `__ for details) - Added: Allow daterange TimeEntry resource filtering via ``from_date`` and ``to_date`` keyword arguments (thanks to `Antoni Aloy `__) - Added: An ability to retrieve Issue version via ``version`` attribute in addition to ``fixed_version`` to be more obvious - Changed: Documentation for resources rewritten from scratch to be more understandable - Fixed: Saving custom fields to Redmine didn't work in some situations - Fixed: Issue's ``fixed_version`` attribute was retrieved as dict instead of Version resource object - Fixed: Resource relations were requested from Redmine every time instead of caching the result after first request - Fixed: `Issue #2 `__ (limit/offset as keyword arguments were broken) - Fixed: `Issue #5 `__ (Version resource ``status`` attribute was converted to IssueStatus resource by mistake) (thanks to `Andrei Avram `__) - Fixed: A lot of small fixes, enhancements and refactoring here and there 0.3.1 (2014-01-23) ++++++++++++++++++ - Added: An ability to pass Requests parameters as a dictionary via ``requests`` keyword argument on Redmine initialization, i.e. Redmine('\http://redmine.url', requests={}). - Fixed: `Issue #1 `__ (unable to connect to Redmine server with invalid ssl certificate). 0.3.0 (2014-01-18) ++++++++++++++++++ - Added: Delete functionality via ``delete()`` method for resources (see `docs `__ for details): * User * Group * IssueCategory * Version * TimeEntry * IssueRelation * ProjectMembership * WikiPage * Project * Issue - Changed: ResourceManager ``get()`` method now raises a ``ValidationError`` exception if required keyword arguments aren't passed 0.2.0 (2014-01-16) ++++++++++++++++++ - Added: New exceptions: * ServerError * NoFileError * ValidationError * VersionMismatchError * ResourceNoFieldsProvidedError * ResourceNotFoundError - Added: Create functionality via ``create()`` method for resources (see `docs `__ for details): * User * Group * IssueCategory * Version * TimeEntry * IssueRelation * ProjectMembership * WikiPage * Project * Issue - Added: File upload support, see ``upload()`` method in Redmine class - Added: Integer representation to all resources, i.e. ``__int__()`` - Added: Informal string representation to all resources, i.e. ``__str__()`` - Changed: Renamed ``version`` attribute to ``redmine_version`` in all resources to avoid name intersections - Changed: ResourceManager ``get()`` method now raises a ``ResourceNotFoundError`` exception if resource wasn't found instead of returning None in previous versions - Changed: reimplemented fix for ``__repr__()`` from 0.1.1 - Fixed: Conversion of issue priorities to enumeration resource object didn't work 0.1.1 (2014-01-10) ++++++++++++++++++ - Added: Python 2.6 support - Changed: WikiPage resource ``refresh()`` method now automatically determines it's project_id - Fixed: Resource representation, i.e. ``__repr__()``, was broken in Python 2.7 - Fixed: ``dir()`` call on a resource object didn't work in Python 3.2 0.1.0 (2014-01-09) ++++++++++++++++++ - Initial release Keywords: redmine redmineup redminecrm redminelib easyredmine Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: License :: OSI Approved :: Apache Software License Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Topic :: Utilities Classifier: Topic :: Internet :: WWW/HTTP Classifier: Intended Audience :: Developers Classifier: Environment :: Console Classifier: Environment :: Web Environment Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.* python-redmine-2.2.1/python_redmine.egg-info/not-zip-safe0000644000076500000240000000000113435744437024734 0ustar maxtepkeevstaff00000000000000 python-redmine-2.2.1/python_redmine.egg-info/SOURCES.txt0000644000076500000240000000163013435744437024372 0ustar maxtepkeevstaff00000000000000.coveragerc CHANGELOG.rst LICENSE MANIFEST.in NOTICE README.rst setup.cfg setup.py python_redmine.egg-info/PKG-INFO python_redmine.egg-info/SOURCES.txt python_redmine.egg-info/dependency_links.txt python_redmine.egg-info/not-zip-safe python_redmine.egg-info/requires.txt python_redmine.egg-info/top_level.txt redminelib/__init__.py redminelib/exceptions.py redminelib/lookups.py redminelib/resultsets.py redminelib/utilities.py redminelib/version.py redminelib/engines/__init__.py redminelib/engines/base.py redminelib/engines/sync.py redminelib/managers/__init__.py redminelib/managers/base.py redminelib/managers/standard.py redminelib/resources/__init__.py redminelib/resources/base.py redminelib/resources/standard.py tests/__init__.py tests/test_engines.py tests/test_managers.py tests/test_redmine.py tests/test_resources_standard.py tests/test_resultsets.py tests/responses/__init__.py tests/responses/standard.pypython-redmine-2.2.1/python_redmine.egg-info/requires.txt0000644000076500000240000000002113435744437025077 0ustar maxtepkeevstaff00000000000000requests>=2.20.0 python-redmine-2.2.1/python_redmine.egg-info/top_level.txt0000644000076500000240000000001313435744437025232 0ustar maxtepkeevstaff00000000000000redminelib python-redmine-2.2.1/python_redmine.egg-info/dependency_links.txt0000644000076500000240000000000113435744437026554 0ustar maxtepkeevstaff00000000000000 python-redmine-2.2.1/PKG-INFO0000644000076500000240000012322613435744437017073 0ustar maxtepkeevstaff00000000000000Metadata-Version: 1.2 Name: python-redmine Version: 2.2.1 Summary: Library for communicating with a Redmine project management application Home-page: https://github.com/maxtepkeev/python-redmine Author: Maxim Tepkeev Author-email: support@python-redmine.com License: Apache 2.0 Project-URL: Documentation, https://python-redmine.com Description: Python-Redmine ============== .. image:: https://badge.fury.io/py/python-redmine.svg :target: https://badge.fury.io/py/python-redmine .. image:: https://img.shields.io/travis/maxtepkeev/python-redmine/master.svg :target: https://travis-ci.org/maxtepkeev/python-redmine .. image:: https://img.shields.io/coveralls/maxtepkeev/python-redmine/master.svg :target: https://coveralls.io/r/maxtepkeev/python-redmine?branch=master Python-Redmine is a library for communicating with a `Redmine `__ project management application. Redmine exposes some of it's data via `REST API `__ for which Python-Redmine provides a simple but powerful Pythonic API inspired by a well-known `Django ORM `__: .. code-block:: python >>> from redminelib import Redmine >>> redmine = Redmine('http://demo.redmine.org', username='foo', password='bar') >>> project = redmine.project.get('vacation') >>> project.id 30404 >>> project.identifier 'vacation' >>> project.created_on datetime.datetime(2013, 12, 31, 13, 27, 47) >>> project.issues >>> project.issues[0] >>> dir(project.issues[0]) ['assigned_to', 'author', 'created_on', 'description', 'done_ratio', 'due_date', 'estimated_hours', 'id', 'priority', 'project', 'relations', 'start_date', 'status', 'subject', 'time_entries', 'tracker', 'updated_on'] >>> project.issues[0].subject 'Vacation' >>> project.issues[0].time_entries Features -------- * Supports 100% of Redmine API * Supports external Redmine plugins API * Supports Python 2.7, 3.4 - 3.7, PyPy and PyPy3 * Supports different request engines * Extendable via custom resources and custom request engines * Extensively documented * Provides ORM-style Pythonic API * And many more... Installation ------------ Standard Edition ++++++++++++++++ The recommended way to install is from Python Package Index (PyPI) with `pip `__: .. code-block:: bash $ pip install python-redmine Pro Edition +++++++++++ License for a Pro Edition can be bought `here `__. You will receive an email with all the details regarding Pro Edition installation process. Documentation ------------- Documentation is available at https://python-redmine.com. Contacts and Support -------------------- Support for Standard Edition is provided via `GitHub `__ only, while support for Pro Edition is provided both via `GitHub `__ and support@python-redmine.com. Be sure to write from email that was specified during the purchase procedure. Copyright and License --------------------- Python-Redmine Standard Edition is licensed under Apache 2.0 license. Python-Redmine Pro Edition is licensed under the Python-Redmine Pro Edition 1.0 license. Check the `License `__ for details. Changelog --------- 2.2.1 (2019-02-28) ++++++++++++++++++ **Bugfixes**: - ProjectMembership resource ``group`` attribute was returned as a dict instead of being converted to Resource object (`Issue #220 `__) (thanks to `Samuel Harmer `__) 2.2.0 (2019-01-13) ++++++++++++++++++ **Improvements**: - ``PerformanceWarning`` will be issued when Python-Redmine does some unnecessary work under the hood to fix the clients code problems **Changes**: - *Backwards Incompatible:* Removed vendored Requests package and make it an external dependency as Requests did the same with it's own dependencies - *Backwards Incompatible:* Removed Python 2.6 and 3.3 support as they're not supported by Requests anymore **Bugfixes**: - ``Redmine.upload()`` fails under certain circumstances when used with a file-like object and it contains unicode instead of bytes (`Issue #216 `__) - ``Redmine.session()`` doesn't restore previous engine if fails (`Issue #211 `__) (thanks to `Dmitry Logvinenko `__) 2.1.1 (2018-05-02) ++++++++++++++++++ - Fix PyPI package 2.1.0 (2018-05-02) ++++++++++++++++++ This release concentrates mostly on stability and adds small features here and there. Some of them are backwards incompatible and are marked as such. They shouldn't affect many users since most of them were used internally by Python-Redmine. A support for the Files API has been finally added, but please be sure to check it's documentation as the implementation on the Redmine side is horrible and there are things to keep in mind while working with Files API. Lastly, only until the end of May 2018 there is a chance to buy a Pro Edition for only 14.99$ instead of the usual 24.99$, this is your chance to get an edition with additional features for a good price and to support the further development of Python-Redmine, more info `here `_. **New Features**: - Files API support (`Issue #117 `__) **Improvements**: - *Backwards Incompatible:* ResourceSet's ``filter()`` method became more advanced. It is now possible to filter on all available resource attributes, to follow resource relationships and apply lookups to the filters (see `docs `__ for details) - ResourceManager class has been refactored: * ``manager_class`` attribute on the ``Resource`` class can now be used to assign a separate ``ResourceManager`` to a resource, that allows outsourcing a resource specific functionality to a separate manager class (see ``WikiPageManager`` as an example) * *Backwards Incompatible:* ``request()`` method has been removed * ``_construct_*_url()``, ``_prepare_*_request()``, ``_process_*_response()`` methods have been added for create, update and delete methods to allow a fine-grained control over these operations - Ability to upload file-like objects (`Issue #186 `__) (thanks to `hjpotter92 `__) - Support for retrieving project's time entry activities (see `docs `__ for details) - Attachment ``update()`` operation support (requires Redmine >= 3.4.0) - ``Resource.save()`` now accepts ``**attrs`` that need to be changed/set and returns ``self`` instead of a boolean ``True``, which makes it chainable, so you can now do something like ``project.save(name='foo', description='bar').export('txt', '/home/foo')`` - ``get`` operation support for News, Query, Enumeration, IssueStatus, Tracker, CustomField, ContactTag, DealStatus, DealCategory and CRMQuery resources - ``include`` param in ``get``, ``all`` and ``filter`` operations now accepts lists and tuples instead of comma-separated string which is still accepted for backward compatibility reasons, i.e. one can use ``include=['foo', 'bar']`` instead of ``include='foo,bar'`` - It is now possible to use ``None`` and ``0`` in addition to ``''`` in ``assigned_to_id`` attribute in Issue resource if an assignee needs to be removed from an issue **Changes**: - *Backwards Incompatible:* Issue ``all`` operation now really returns all issues, i.e. both open and closed, instead of only returning open issues in previous versions due to the respect to Redmine's standard behaviour - *Backwards Incompatible:* Instead of only returning a token string, ``upload()`` method was modified to return a dict that contains all the data for an upload returned from Redmine, i.e. id and token for Redmine >= 3.4.0, token only for Redmine < 3.4.0. Also it is now possible to use this token and pass it using a ``token`` key instead of the ``path`` key with path to the file in ``uploads`` parameter when doing an upload, this gives more control over the uploading process if needed - *Backwards Incompatible:* Removed ``resource_paths`` argument from Redmine object since ``ResourceManager`` now uses a special resource registry, to which, all resources that inherit from any Python-Redmine resource are being automatically added - *Backwards Incompatible:* Removed ``container_many`` in favor of ``container_filter``, ``container_create`` and ``container_update`` attributes on ``Resource`` object to allow more fine-grained resource setup - *Backwards Incompatible:* ``return_raw`` parameter on ``engine.request()`` and ``engine.process_response()`` methods has been removed in favor of ``return_raw_response`` attribute on engine object - Updated bundled requests library to v2.15.1 **Bugfixes**: - Support 204 status code when deleting a resource (`Issue #189 `__) (thanks to `dotSlashLu `__) - Raise ``ValidationError`` instead of not helpful ``TypeError`` exception when trying to create a WikiPage resource that already exists (`Issue #182 `__) - Enumeration, Version, Group and Notes ``custom_fields`` attribute was returned as a list of dicts instead of being converted to ``ResourceSet`` object - Downloads were downloaded fully into memory instead of being streamed as needed - ``ResourceRequirementsError`` exception was broken since v2.0.0 - RedmineUP CRM Contact and Deal resources export functionality didn't work - RedmineUP CRM Contact and Deal resources sometimes weren't converted to Resource objects using Search API **Documentation**: - Mentioned support for ``generate_password`` and ``send_information`` in User's resource create/update methods, ``status`` in User's resource update method, ``parent_id`` in Issue's filter method and ``include`` in Issue's all method 2.0.2 (2017-04-19) ++++++++++++++++++ **Bugfixes**: - Filter doesn't work when there are > 100 resources requested (`Issue #175 `__) (thanks to `niwatolli3 `__) 2.0.1 (2017-04-10) ++++++++++++++++++ - Fix PyPI package 2.0.0 (2017-04-10) ++++++++++++++++++ This version brings a lot of new features and changes, some of them are backward-incompatible, so please look carefully at the changelog below to find out what needs to be changed in your code to make it work with this version. Also Python-Redmine now comes in 2 editions: Standard and Pro, please have a look at this `document `__ for more details. Documentation was also significantly rewritten, so it is recommended to reread it even if you are an experienced Python-Redmine user. **New Features**: - RedmineUP `Checklist plugin `__ support - `Request Engines `__ support. It is now possible to create engines to define how requests to Redmine are made, e.g. synchronous (one by one) or asynchronous using threads or processes etc - ``redmine.session()`` context manager which allows to temporary redefine engine's behaviour - Search API support (`Issue #138 `__) - Export functionality (`Issue #58 `__) - REDMINE_USE_EXTERNAL_REQUESTS environmental variable for emergency cases which allows to use external requests instead of bundled one even if external requests version is lower than the bundled one - Wrong HTTP protocol usage detector, e.g. one use HTTP when HTTPS should be used **Improvements**: - ResourceSet objects were completely rewritten: * ``ResourceSet`` object that was already sliced now supports reslicing * ``ResourceSet`` object's ``delete()``, ``update()``, ``filter()`` and ``get()`` methods have been optimized for speed * ``ResourceSet`` object's ``delete()`` and ``update()`` methods now call the corresponding Resource's ``pre_*()`` and ``post_*()`` methods * ``ResourceSet`` object's ``get()`` and ``filter()`` methods now supports non-integer id's, e.g. WikiPage's title can now be used with it * *Backwards Incompatible:* ``ValuesResourceSet`` class has been removed * *Backwards Incompatible:* ``ResourceSet.values()`` method now returns an iterable of dicts instead of ``ValuesResourceSet`` object * ``ResourceSet.values_list()`` method has been added which returns an iterable of tuples with Resource values or single values if flattened, i.e. ``flat=True`` - New ``Resource`` object methods: * ``delete()`` deletes current resource from Redmine * ``pre_delete()`` and ``post_delete()`` can be used to execute tasks that should be done before/after deleting the resource through ``delete()`` method * ``bulk_decode()``, ``bulk_encode()``, ``decode()`` and ``encode()`` which are used to translate attributes of the resource to/from Python/Redmine - Attachment ``delete()`` method support (requires Redmine >= 3.3.0) - RedmineUP CRM Note resource now provides ``type`` attribute which shows text representation of ``type_id`` - RedmineUP CRM DealStatus resource now provides ``status`` attribute which shows text representation of ``status_type`` - WikiPage resource now provides ``project_id`` attribute - Unicode handling was significantly rewritten and shouldn't cause any more troubles - ``UnknownError`` exception now contains ``status_code`` attribute which can be used to handle the exception instead of parsing code from exception's text - Sync engine's speed improved to 8-12% depending on the amount of resources fetched **Changes**: - *Backwards Incompatible:* Renamed package name from ``redmine`` to ``redminelib`` - Resource class attributes that were previously tuples are now lists - *Backwards Incompatible:* ``_Resource`` class renamed to ``Resource`` - *Backwards Incompatible:* ``Redmine.custom_resource_paths`` keyword argument renamed to ``resource_paths`` - *Backwards Incompatible:* ``Redmine.download()`` method now returns a `requests.Response `__ object directly instead of ``iter_content()`` method if a ``savepath`` param wasn't provided, this gives user even more control over response data - *Backwards Incompatible:* ``Resource.refresh()`` now really refreshes itself instead of returning a new refreshed resource, to get the previous behaviour use ``itself`` param, e.g. ``Resource.refresh(itself=False)`` - *Backwards Incompatible:* Removed Python 3.2 support - *Backwards Incompatible:* Removed ``container_filter``, ``container_create`` and ``container_update`` attributes on ``Resource`` object in favor of ``container_many`` attribute - *Backwards Incompatible:* Removed ``Resource.translate_params()`` and ``ResourceManager.prepare_params()`` in favor of ``Resource.bulk_decode()`` - *Backwards Incompatible:* Removed ``is_unicode()``, ``is_string()`` and ``to_string()`` from ``redminelib.utilities`` - Updated bundled requests library to v2.13.0 **Bugfixes**: - Infinite loop when uploading zero-length files (`Issue #152 `__) - Unsupported Redmine resource error while trying to use Python-Redmine without installation (`Issue #156 `__) - It was impossible to set ``data``, ``params`` and ``headers`` via ``requests`` keyword argument on Redmine object - Calling ``str()`` or ``repr()`` on a Resource was giving incorrect results if exception raising was turned off for a resource **Documentation**: - Switched to the alabaster theme - Added new sections: * `Editions `__ * `Introduction `__ * `Request Engines `__ - Added info about Issue Journals (`Issue #120 `__) - Added note about open/closed issues (`Issue #136 `__) - Added note about regexp custom field filter (`Issue #164 `__) - Added some new information here and there 1.5.1 (2016-03-27) ++++++++++++++++++ - Changed: Updated bundled requests package to 2.9.1 - Changed: `Issue #124 `__ (``project.url`` now uses ``identifier`` rather than ``id`` to generate url for the project resource) - Fixed: `Issue #122 `__ (``ValidationError`` for empty custom field values was possible under some circumstances with Redmine < 2.5.0) - Fixed: `Issue #112 `__ (``UnicodeEncodeError`` on Python 2 if ``resource_id`` was of ``unicode`` type) (thanks to `Digenis `__) 1.5.0 (2015-11-26) ++++++++++++++++++ - Added: Documented support for new fields and values in User, Issue and IssueRelation resources - Added: `Issue #109 `__ (Smart imports for vendored packages (see `docs `__ for details) - Added: `Issue #115 `__ (File upload support for WikiPage resource) 1.4.0 (2015-10-18) ++++++++++++++++++ - Added: `Requests `__ is now embedded into Python-Redmine - Added: Python-Redmine is now embeddable to other libraries - Fixed: Previous release was broken on PyPI 1.3.0 (2015-10-18) ++++++++++++++++++ - Added: `Issue #108 `__ (Tests are now built-in into source package distributed via PyPI) 1.2.0 (2015-07-09) ++++++++++++++++++ - Added: `wheel `__ support - Added: `Issue #93 `__ (``JSONDecodeError`` exception now contains a ``response`` attribute which can be inspected to identify the cause of the exception) - Added: `Issue #98 `__ (Support for setting WikiPage resource parent title and converting parent attribute to Resource object instead of being a dict) 1.1.2 (2015-05-20) ++++++++++++++++++ - Fixed: `Issue #90 `__ (Python-Redmine fails to install on systems with LC_ALL=C) (thanks to `spikergit1 `__) 1.1.1 (2015-03-26) ++++++++++++++++++ - Fixed: `Issue #85 `__ (Python-Redmine was trying to convert field to date/datetime even when it shouldn't, i.e. if a field looked like YYYY-MM-DD but wasn't actually a date/datetime field, e.g. wiki page title or issue subject) 1.1.0 (2015-02-20) ++++++++++++++++++ - Added: PyPy2/3 is now officially supported - Added: Introduced ``enabled_modules`` on demand include in Project resource - Fixed: `Issue #78 `__ (Redmine <2.5.2 returns only single tracker instead of a list of all available trackers when requested from a CustomField resource which caused an Exception in Python-Redmine, see `this `__ for details) - Fixed: `Issue #80 `__ (If a project is read-only or doesn't have CRM plugin enabled, an attempt to add/remove Contact resource to/from it will lead to improper error message) - Fixed: `Issue #81 `__ (Contact's resource ``tag_list`` attribute was always splitted into single chars) (thanks to `Alexander Loechel `__) 1.0.3 (2015-02-03) ++++++++++++++++++ - Fixed: `Issue #72 `__ (If an exception is raised during JSON decoding process, it should be catched and reraised as Python-Redmine's own exception, i.e ``redmine.exceptions.JSONDecodeError``) - Fixed: `Issue #76 `__ (It was impossible to retrieve more than 100 resources for resources which don't support limit/offset natively by Redmine, i.e. this functionality is emulated by Python-Redmine, e.g. WikiPage, Groups, Roles etc) 1.0.2 (2014-11-13) ++++++++++++++++++ - Fixed: `Issue #55 `__ (TypeError was raised during processing validation errors from Redmine when one of the errors was returned as a list) - Fixed: `Issue #59 `__ (Raise ForbiddenError when a 403 is encountered) (thanks to `Rick Harris `__) - Fixed: `Issue #64 `__ (Redmine and Resource classes weren't picklable) (thanks to `Rick Harris `__) - Fixed: A ResourceSet object with a limit=100, actually returned 125 Resource objects 1.0.1 (2014-09-23) ++++++++++++++++++ - Fixed: `Issue #50 `__ (IssueJournal's ``notes`` attribute was converted to Note resource by mistake, bug was introduced in v1.0.0) 1.0.0 (2014-09-22) ++++++++++++++++++ - Added: Support for the `CRM plugin `__ resources: * `Contact `__ * `ContactTag `__ * `Note `__ * `Deal `__ * `DealStatus `__ * `DealCategory `__ * `CrmQuery `__ - Added: Introduced new relations for the following resource objects: * Project - time_entries, deals, contacts and deal_categories relations * User - issues, time_entries, deals and contacts relations * Tracker - issues relation * IssueStatus - issues relation - Added: Introduced a ``values()`` method in a ResourceSet which returns ValuesResourceSet - a ResourceSet subclass that returns dictionaries when used as an iterable, rather than resource-instance objects (see `docs `__ for details) - Added: Introduced ``update()`` and ``delete()`` methods in a ResourceSet object which allow to bulk update or bulk delete all resources in a ResourceSet object (see `docs `__ for details) - Fixed: It was impossible to use ResourceSet's ``get()`` and ``filter()`` methods with WikiPage resource - Fixed: Several small fixes and enhancements here and there 0.9.0 (2014-09-11) ++++++++++++++++++ - Added: Introduced support for file downloads (see `docs `__ for details) - Added: Introduced new ``_Resource.requirements`` class attribute where all Redmine plugins required by resource should be listed (preparations to support non-native resources) - Added: New exceptions: * ResourceRequirementsError - Fixed: It was impossible to set a custom field of date/datetime type using date/datetime Python objects - Fixed: `Issue #46 `__ (A UnicodeEncodeError was raised in Python 2.x while trying to access a ``url`` property of a WikiPage resource if it contained non-ascii characters) 0.8.4 (2014-08-08) ++++++++++++++++++ - Added: Support for anonymous Attachment resource (i.e. attachment with ``id`` attr only) - Fixed: `Issue #42 `__ (It was impossible to create a Project resource via ``new()`` method) 0.8.3 (2014-08-01) ++++++++++++++++++ - Fixed: `Issue #39 `__ (It was impossible to save custom_fields in User resource via ``new()`` method) 0.8.2 (2014-05-27) ++++++++++++++++++ - Added: ResourceSet's ``get()`` method now supports a ``default`` keyword argument which is returned when a requested Resource can't be found in a ResourceSet and defaults to ``None``, previously this was hardcoded to ``None`` - Added: It is now possible to use ``getattr()`` with default value without raising a ``ResourceAttrError`` when calling non-existent resource attribute, see `Issue #30 `__ for details (thanks to `hsum `__) - Fixed: `Issue #31 `__ (Unlimited recursion was possible in some situations when on demand includes were used) 0.8.1 (2014-04-02) ++++++++++++++++++ - Added: New exceptions: * RequestEntityTooLargeError * UnknownError - Fixed: `Issue #27 `__ (Project and Issue resources ``parent`` attribute was returned as a dict instead of being converted to Resource object) 0.8.0 (2014-03-27) ++++++++++++++++++ - Added: Introduced the detection of conflicting packages, i.e. if a conflicting package is found (PyRedmineWS at this time is the only one), the installation procedure will be aborted and a warning message will be shown with the detailed description of the problem - Added: Introduced new ``_Resource._members`` class attribute where all instance attributes which are not started with underscore should be listed. This will resolve recursion issues in custom resources because of how ``__setattr__()`` works in Python - Changed: ``_Resource.attributes`` renamed to ``_Resource._attributes`` - Fixed: Python-Redmine was unable to upload any binary files - Fixed: `Issue #20 `__ (Lowered Requests version requirements. Python-Redmine now requires Requests starting from 0.12.1 instead of 2.1.0 in previous versions) - Fixed: `Issue #23 `__ (File uploads via ``update()`` method didn't work) 0.7.2 (2014-03-17) ++++++++++++++++++ - Fixed: `Issue #19 `__ (Resources obtained via ``filter()`` and ``all()`` methods have incomplete url attribute) - Fixed: Redmine server url with forward slash could cause errors in rare cases - Fixed: Python-Redmine was incorrectly raising ``ResourceAttrError`` when trying to call ``repr()`` on a News resource 0.7.1 (2014-03-14) ++++++++++++++++++ - Fixed: `Issue #16 `__ (When a resource was created via a ``new()`` method, the next resource created after that inherited all the attribute values of the previous resource) 0.7.0 (2014-03-12) ++++++++++++++++++ - Added: WikiPage resource now automatically requests all of it's available attributes from Redmine in case if some of them are not available in an existent resource object - Added: Support for setting date/datetime resource attributes using date/datetime Python objects - Added: Support for using date/datetime Python objects in all ResourceManager methods, i.e. ``new()``, ``create()``, ``update()``, ``delete()``, ``get()``, ``all()``, ``filter()`` - Fixed: `Issue #14 `__ (Python-Redmine was incorrectly raising ``ResourceAttrError`` when trying to call ``repr()``, ``str()`` and ``int()`` on resources, created via ``new()`` method) 0.6.2 (2014-03-09) ++++++++++++++++++ - Fixed: Project resource ``status`` attribute was converted to IssueStatus resource by mistake 0.6.1 (2014-02-27) ++++++++++++++++++ - Fixed: `Issue #10 `__ (Python Redmine was incorrectly raising ``ResourceAttrError`` while creating some resources via ``new()`` method) 0.6.0 (2014-02-19) ++++++++++++++++++ - Added: ``Redmine.auth()`` shortcut for the case if we just want to check if user provided valid auth credentials, can be used for user authentication on external resource based on Redmine user database (see `docs `__ for details) - Fixed: ``JSONDecodeError`` was raised in some Redmine versions during some create/update operations (thanks to `0x55aa `__) - Fixed: User resource ``status`` attribute was converted to IssueStatus resource by mistake 0.5.0 (2014-02-09) ++++++++++++++++++ - Added: An ability to create custom resources which allow to easily redefine the behaviour of existing resources (see `docs `__ for details) - Added: An ability to add/remove watcher to/from issue (see `docs `__ for details) - Added: An ability to add/remove users to/from group (see `docs `__ for details) 0.4.0 (2014-02-08) ++++++++++++++++++ - Added: New exceptions: * ConflictError * ReadonlyAttrError * ResultSetTotalCountError * CustomFieldValueError - Added: Update functionality via ``update()`` and ``save()`` methods for resources (see `docs `__ for details): * User * Group * IssueCategory * Version * TimeEntry * ProjectMembership * WikiPage * Project * Issue - Added: Limit/offset support via ``all()`` and ``filter()`` methods for resources that doesn't support that feature via Redmine: * IssueRelation * Version * WikiPage * IssueStatus * Tracker * Enumeration * IssueCategory * Role * Group * CustomField - Added: On demand includes, e.g. in addition to ``redmine.group.get(1, include='users')`` users for a group can also be retrieved on demand via ``group.users`` if include wasn't set (see `docs `__ for details) - Added: ``total_count`` attribute to ResourceSet object which holds the total number of resources for the current resource type available in Redmine (thanks to `Andrei Avram `__) - Added: An ability to return ``None`` instead of raising a ``ResourceAttrError`` for all or selected resource objects via ``raise_attr_exception`` kwarg on Redmine object (see `docs `__ for details or `Issue #6 `__) - Added: ``pre_create()``, ``post_create()``, ``pre_update()``, ``post_update()`` resource object methods which can be used to execute tasks that should be done before/after creating/updating the resource through ``save()`` method - Added: Allow to create resources in alternative way via ``new()`` method (see `docs `__ for details) - Added: Allow daterange TimeEntry resource filtering via ``from_date`` and ``to_date`` keyword arguments (thanks to `Antoni Aloy `__) - Added: An ability to retrieve Issue version via ``version`` attribute in addition to ``fixed_version`` to be more obvious - Changed: Documentation for resources rewritten from scratch to be more understandable - Fixed: Saving custom fields to Redmine didn't work in some situations - Fixed: Issue's ``fixed_version`` attribute was retrieved as dict instead of Version resource object - Fixed: Resource relations were requested from Redmine every time instead of caching the result after first request - Fixed: `Issue #2 `__ (limit/offset as keyword arguments were broken) - Fixed: `Issue #5 `__ (Version resource ``status`` attribute was converted to IssueStatus resource by mistake) (thanks to `Andrei Avram `__) - Fixed: A lot of small fixes, enhancements and refactoring here and there 0.3.1 (2014-01-23) ++++++++++++++++++ - Added: An ability to pass Requests parameters as a dictionary via ``requests`` keyword argument on Redmine initialization, i.e. Redmine('\http://redmine.url', requests={}). - Fixed: `Issue #1 `__ (unable to connect to Redmine server with invalid ssl certificate). 0.3.0 (2014-01-18) ++++++++++++++++++ - Added: Delete functionality via ``delete()`` method for resources (see `docs `__ for details): * User * Group * IssueCategory * Version * TimeEntry * IssueRelation * ProjectMembership * WikiPage * Project * Issue - Changed: ResourceManager ``get()`` method now raises a ``ValidationError`` exception if required keyword arguments aren't passed 0.2.0 (2014-01-16) ++++++++++++++++++ - Added: New exceptions: * ServerError * NoFileError * ValidationError * VersionMismatchError * ResourceNoFieldsProvidedError * ResourceNotFoundError - Added: Create functionality via ``create()`` method for resources (see `docs `__ for details): * User * Group * IssueCategory * Version * TimeEntry * IssueRelation * ProjectMembership * WikiPage * Project * Issue - Added: File upload support, see ``upload()`` method in Redmine class - Added: Integer representation to all resources, i.e. ``__int__()`` - Added: Informal string representation to all resources, i.e. ``__str__()`` - Changed: Renamed ``version`` attribute to ``redmine_version`` in all resources to avoid name intersections - Changed: ResourceManager ``get()`` method now raises a ``ResourceNotFoundError`` exception if resource wasn't found instead of returning None in previous versions - Changed: reimplemented fix for ``__repr__()`` from 0.1.1 - Fixed: Conversion of issue priorities to enumeration resource object didn't work 0.1.1 (2014-01-10) ++++++++++++++++++ - Added: Python 2.6 support - Changed: WikiPage resource ``refresh()`` method now automatically determines it's project_id - Fixed: Resource representation, i.e. ``__repr__()``, was broken in Python 2.7 - Fixed: ``dir()`` call on a resource object didn't work in Python 3.2 0.1.0 (2014-01-09) ++++++++++++++++++ - Initial release Keywords: redmine redmineup redminecrm redminelib easyredmine Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable Classifier: License :: OSI Approved :: Apache Software License Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Topic :: Utilities Classifier: Topic :: Internet :: WWW/HTTP Classifier: Intended Audience :: Developers Classifier: Environment :: Console Classifier: Environment :: Web Environment Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3.4 Classifier: Programming Language :: Python :: 3.5 Classifier: Programming Language :: Python :: 3.6 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.* python-redmine-2.2.1/redminelib/0000755000076500000240000000000013435744437020102 5ustar maxtepkeevstaff00000000000000python-redmine-2.2.1/redminelib/resultsets.py0000644000076500000240000002342613413625241022663 0ustar maxtepkeevstaff00000000000000""" Defines ResourceSet objects that can be used to represent a set of resources. """ import operator import functools import itertools from . import lookups, utilities, exceptions class BaseResourceSet(object): """ Defines basic functionality for a ResourceSet object. """ def __init__(self, manager, resources=None, limit=0, offset=0, total_count=None): """ :param managers.ResourceManager manager: (required). ResourceManager object. :param resources: (optional). Iterable of resources. :type resources: list or tuple :param int limit: (optional). Resource limit. :param int offset: (optional). Resource offset. :param int total_count: (optional). How many resources are there available in Redmine. """ self.manager = manager self.limit = limit self.offset = offset self._resources = resources self._total_count = total_count self._is_sliced = False @property def total_count(self): """ Returns total count of available resources in Redmine, this is known only after ResourceSet evaluation. """ if self._total_count is None: if self._resources is None: raise exceptions.ResultSetTotalCountError else: self._total_count = len(self) return self._total_count def export(self, fmt, savepath=None, filename=None): """ Exports all resources from resource set to requested format if Resource supports that. :param string fmt: (required). Format to use for export, e.g. atom, csv, txt, pdf, html etc. :param string savepath: (optional). Path where to save the file. :param string filename: (optional). Name that will be used for the file. """ if self.manager.resource_class.query_all_export is None: raise exceptions.ExportNotSupported formatter = utilities.MemorizeFormatter() url = self.manager.redmine.url + formatter.format( self.manager.resource_class.query_all_export, format=fmt, **self.manager.params) try: return self.manager.redmine.download(url, savepath, filename, params=formatter.unused_kwargs) except exceptions.UnknownError as e: if e.status_code == 406: raise exceptions.ExportFormatNotSupportedError raise e def _resource_cls(self, cls, resources, **kwargs): """ Returns a new resource set class instance defined by cls, filled with resources and loaded with kwargs. :param any cls: (required). Resource set class. :param resources: (required). Iterable of resources. :type resources: list or tuple :param dict kwargs: (optional). Additional keyword arguments if any. """ return cls(self.manager, resources=resources, limit=self.limit, offset=self.offset, total_count=self._total_count, **kwargs) def __getitem__(self, item): """ Sets limit and offset or returns a Resource by requested index. """ if isinstance(item, slice): self.limit = item.stop self.offset = item.start self._is_sliced = True elif isinstance(item, int): try: return next(itertools.islice(self, item, item + 1)) except StopIteration: raise exceptions.ResourceSetIndexError if self._resources is not None and self._is_sliced: return self._resource_cls(self.__class__, [resource for resource in BaseResourceSet.__iter__(self)]) return self def __iter__(self): """ Returns requested resources in a lazy fashion. """ # If this is the first time we are evaluating the ResourceSet # all the hard part will be done by the active Engine object if self._resources is None: self.manager.params.setdefault('limit', self.limit) self.manager.params.setdefault('offset', self.offset) try: self._resources, self._total_count = self.manager.redmine.engine.bulk_request( 'get', self.manager.url, self.manager.container, **self.manager.params) except exceptions.ResourceNotFoundError as e: if self.manager.resource_class.requirements: raise exceptions.ResourceRequirementsError(self.manager.resource_class.requirements) raise e resources = self._resources # Otherwise ResourceSet object should handle slicing by itself elif self._is_sliced: offset = self.offset or None if not self.limit: limit = None elif self.limit and not self.offset: limit = self.limit else: limit = self.limit + self.offset resources = self._resources[offset:limit] else: resources = self._resources self._is_sliced = False return (resource for resource in resources) def __len__(self): """ Allows len() to be called on a ResourceSet object. """ return sum(1 for _ in self) def __repr__(self): """ Official representation of a ResourceSet object. """ return '<{0}.{1} object with {2} resources>'.format( self.__class__.__module__, self.__class__.__name__, self.manager.resource_class.__name__) class ResourceSet(BaseResourceSet): """ Represents a set of Redmine resources as objects. """ def get(self, resource_id, default=None): """ Returns a single Resource from a ResourceSet by resource id. :param resource_id: (required). Resource id. :type resource_id: int or string :param none default: (optional). What to return if Resource wasn't found. """ for resource in super(ResourceSet, self).__iter__(): if resource_id == resource[self.manager.resource_class.internal_id_key]: return self.manager.to_resource(resource) return default def filter(self, **filters): """ Returns a new filtered ResourceSet with requested filters applied. :param dict filters: (required). Filters used for resources retrieval. """ if not filters: raise exceptions.ResourceNoFiltersProvidedError reducers = [] for f in filters: fields = f.split('__') lookup = fields[-1] reducer = { 'fields': fields, 'value': filters[f], 'lookup': lookups.registry['exact'], 'lookup_name': lookup, 'filter_name': f, } if lookup in lookups.registry: reducer['fields'] = fields[:-1] reducer['lookup'] = lookups.registry[lookup] reducers.append(reducer) resources = [] for resource in super(ResourceSet, self).__iter__(): for r in reducers: try: if not r['lookup'](functools.reduce(operator.getitem, r['fields'], resource), r['value']): break except KeyError: break except TypeError: raise exceptions.ResourceSetFilterLookupError(r['lookup_name'], r['filter_name']) else: resources.append(resource) return self._resource_cls(ResourceSet, resources) def update(self, **fields): """ Updates fields of all resources in a ResourceSet with the given values. :param dict fields: (optional). Fields in resources that will be updated. """ resources = [] for resource in self: for field in fields: setattr(resource, field, fields[field]) resources.append(resource.save().raw()) return self._resource_cls(ResourceSet, resources) def delete(self): """ Deletes all resources in a ResourceSet. """ for resource in self: resource.delete() self._resources = None return True def values(self, *fields): """ Returns ResourceSet as an iterable of dictionaries. :param fields: (optional). Iterable which sets field names each resource will contain. :type fields: list or tuple """ if fields: for resource in super(ResourceSet, self).__iter__(): yield {field: resource[field] for field in fields if field in resource} else: for resource in super(ResourceSet, self).__iter__(): yield resource def values_list(self, *fields, **kwargs): """ Returns ResourceSet as an iterable of tuples with Resource values or single values if flattened. :param fields: (optional). Iterable which sets field names each resource will contain. :type fields: list or tuple :param dict kwargs: (optional). If fields contain single field, setting flat=True will flatten the result. """ flat = kwargs.pop('flat', False) if fields: if flat and len(fields) == 1: for resource in super(ResourceSet, self).__iter__(): yield resource.get(fields[0]) else: for resource in super(ResourceSet, self).__iter__(): yield tuple(resource[field] for field in fields if field in resource) else: for resource in super(ResourceSet, self).__iter__(): yield tuple(resource.values()) def __iter__(self): """ Returns requested resources in a lazy fashion. """ return (self.manager.to_resource(resource) for resource in super(ResourceSet, self).__iter__()) python-redmine-2.2.1/redminelib/version.py0000644000076500000240000000002613435744225022132 0ustar maxtepkeevstaff00000000000000__version__ = '2.2.1' python-redmine-2.2.1/redminelib/resources/0000755000076500000240000000000013435744437022114 5ustar maxtepkeevstaff00000000000000python-redmine-2.2.1/redminelib/resources/standard.py0000644000076500000240000004474313433762110024264 0ustar maxtepkeevstaff00000000000000""" Defines standard Redmine resources and resource mappings. """ from __future__ import unicode_literals from distutils.version import LooseVersion from . import BaseResource from .. import managers, exceptions class Project(BaseResource): redmine_version = '1.0' container_all = 'projects' container_one = 'project' container_create = 'project' container_update = 'project' query_all_export = '/projects.{format}' query_all = '/projects.json' query_one = '/projects/{0}.json' query_create = '/projects.json' query_update = '/projects/{0}.json' query_delete = '/projects/{0}.json' search_hints = ['project'] _repr = [['id', 'name'], ['title']] _includes = ['trackers', 'issue_categories', 'enabled_modules', 'time_entry_activities'] _relations = ['wiki_pages', 'memberships', 'issue_categories', 'time_entries', 'versions', 'news', 'issues', 'files'] _unconvertible = BaseResource._unconvertible + ['identifier', 'status'] _update_readonly = BaseResource._update_readonly + ['identifier'] _resource_set_map = { 'custom_fields': 'CustomField', 'trackers': 'Tracker', 'issue_categories': 'IssueCategory', 'wiki_pages': 'WikiPage', 'memberships': 'ProjectMembership', 'time_entries': 'TimeEntry', 'versions': 'Version', 'news': 'News', 'issues': 'Issue', 'files': 'File', } _single_attr_id_map = {'parent_id': 'parent'} _multiple_attr_id_map = {'tracker_ids': 'trackers'} @property def url(self): return self.manager.redmine.url + self.query_one.format(self.identifier)[:-5] @classmethod def encode(cls, attr, value, manager): if attr == 'enabled_modules': return attr, [module['name'] for module in value] return super(Project, cls).encode(attr, value, manager) class Issue(BaseResource): redmine_version = '1.0' container_all = 'issues' container_one = 'issue' container_filter = 'issues' container_create = 'issue' container_update = 'issue' query_all_export = '/issues.{format}' query_one_export = '/issues/{0}.{format}' query_all = '/issues.json?status_id=*' query_one = '/issues/{0}.json' query_filter = '/issues.json' query_create = '/projects/{project_id}/issues.json' query_update = '/issues/{0}.json' query_delete = '/issues/{0}.json' search_hints = ['issue', 'issue closed'] _repr = [['id', 'subject'], ['title'], ['id']] _includes = ['children', 'attachments', 'relations', 'changesets', 'journals', 'watchers'] _relations = ['relations', 'time_entries'] _unconvertible = BaseResource._unconvertible + ['subject', 'notes'] _create_readonly = BaseResource._create_readonly + ['spent_hours'] _update_readonly = _create_readonly[:] _resource_map = { 'project': 'Project', 'tracker': 'Tracker', 'status': 'IssueStatus', 'priority': 'Enumeration', 'author': 'User', 'assigned_to': 'User', 'category': 'IssueCategory', 'fixed_version': 'Version', } _resource_set_map = { 'custom_fields': 'CustomField', 'attachments': 'Attachment', 'journals': 'IssueJournal', 'children': 'Issue', 'relations': 'IssueRelation', 'watchers': 'User', 'time_entries': 'TimeEntry', } _single_attr_id_map = { 'project_id': 'project', 'tracker_id': 'tracker', 'status_id': 'status', 'priority_id': 'priority', 'category_id': 'category', 'fixed_version_id': 'fixed_version', 'assigned_to_id': 'assigned_to', 'parent_issue_id': 'parent', } _multiple_attr_id_map = {'watcher_user_ids': 'watchers'} class Watcher: """ An issue watcher implementation. """ def __init__(self, issue): self._redmine = issue.manager.redmine self._issue_id = issue.internal_id if self._redmine.ver is not None and LooseVersion(str(self._redmine.ver)) < LooseVersion('2.3'): raise exceptions.ResourceVersionMismatchError def add(self, user_id): """ Adds user to issue watchers list. :param int user_id: (required). User id. """ url = '{0}/issues/{1}/watchers.json'.format(self._redmine.url, self._issue_id) return self._redmine.engine.request('post', url, data={'user_id': user_id}) def remove(self, user_id): """ Removes user from issue watchers list. :param int user_id: (required). User id. """ url = '{0}/issues/{1}/watchers/{2}.json'.format(self._redmine.url, self._issue_id, user_id) return self._redmine.engine.request('delete', url) def __getattr__(self, attr): if attr == 'watcher': return Issue.Watcher(self) if attr == 'version': attr = 'fixed_version' return super(Issue, self).__getattr__(attr) def __setattr__(self, attr, value): if attr == 'version_id': attr = 'fixed_version_id' super(Issue, self).__setattr__(attr, value) @classmethod def decode(cls, attr, value, manager): if attr == 'version_id': return 'fixed_version_id', value elif attr == 'assigned_to_id' and value in (None, 0): return attr, '' elif attr == 'checklists': return 'checklists_attributes', value return super(Issue, cls).decode(attr, value, manager) class TimeEntry(BaseResource): redmine_version = '1.1' container_all = 'time_entries' container_one = 'time_entry' container_filter = 'time_entries' container_create = 'time_entry' container_update = 'time_entry' query_all_export = '/time_entries.{format}' query_all = '/time_entries.json' query_one = '/time_entries/{0}.json' query_filter = '/time_entries.json' query_create = '/time_entries.json' query_update = '/time_entries/{0}.json' query_delete = '/time_entries/{0}.json' _repr = [['id']] _resource_map = {'project': 'Project', 'issue': 'Issue', 'user': 'User', 'activity': 'Enumeration'} _resource_set_map = {'custom_fields': 'CustomField'} _single_attr_id_map = {'issue_id': 'issue', 'activity_id': 'activity'} @classmethod def decode(cls, attr, value, manager): if attr == 'from_date': attr = 'from' elif attr == 'to_date': attr = 'to' return super(TimeEntry, cls).decode(attr, value, manager) class Enumeration(BaseResource): redmine_version = '2.2' container_filter = '{resource}' query_filter = '/enumerations/{resource}.json' _resource_set_map = {'custom_fields': 'CustomField'} @property def url(self): return '{0}/enumerations/{1}/edit'.format(self.manager.redmine.url, self.internal_id) class Attachment(BaseResource): redmine_version = '1.3' container_one = 'attachment' container_update = 'attachment' query_one = '/attachments/{0}.json' query_update = '/attachments/{0}.json' query_delete = '/attachments/{0}.json' http_method_update = 'patch' _repr = [['id', 'filename'], ['id']] _resource_map = {'author': 'User'} def download(self, savepath=None, filename=None): return self.manager.redmine.download(self.content_url, savepath, filename) class File(Attachment): redmine_version = '3.4' container_filter = 'files' container_create = 'file' query_filter = '/projects/{project_id}/files.json' query_create = '/projects/{project_id}/files.json' manager_class = managers.FileManager _resource_map = {'author': 'User', 'version': 'Version'} @classmethod def decode(cls, attr, value, manager): if attr == 'path': return 'token', manager.redmine.upload(value)['token'] return super(File, cls).decode(attr, value, manager) class IssueJournal(BaseResource): redmine_version = '1.0' _repr = [['id']] _unconvertible = ['notes'] _resource_map = {'user': 'User'} class WikiPage(BaseResource): internal_id_key = 'title' redmine_version = '2.2' container_filter = 'wiki_pages' container_one = 'wiki_page' container_create = 'wiki_page' container_update = 'wiki_page' query_one_export = '/projects/{project_id}/wiki/{0}.{format}' query_filter = '/projects/{project_id}/wiki/index.json' query_one = '/projects/{project_id}/wiki/{0}.json' query_create = '/projects/{project_id}/wiki/{title}.json' query_update = '/projects/{project_id}/wiki/{0}.json' query_delete = '/projects/{project_id}/wiki/{0}.json' search_hints = ['wiki-page'] http_method_create = 'put' manager_class = managers.WikiPageManager _repr = [['title']] _includes = ['attachments'] _unconvertible = BaseResource._unconvertible + ['title', 'text'] _create_readonly = BaseResource._create_readonly + ['version'] _update_readonly = _create_readonly[:] _resource_map = {'author': 'User'} _resource_set_map = {'attachments': 'Attachment'} _single_attr_id_map = {'project_id': 'project'} @classmethod def encode(cls, attr, value, manager): if attr == 'parent': value = manager.new_manager(cls.__name__, project_id=manager.params.get('project_id', 0)).to_resource(value) return attr, value return super(WikiPage, cls).encode(attr, value, manager) def refresh(self, **params): return super(WikiPage, self).refresh(**dict(params, project_id=self.project_id)) def post_update(self): self._encoded_attrs['version'] = self._decoded_attrs['version'] = self._decoded_attrs.get('version', 0) + 1 def delete(self, **params): return super(WikiPage, self).delete(**dict(params, project_id=self.project_id)) def export_url(self, fmt): return self.manager.redmine.url + self.query_one_export.format( self.internal_id, project_id=self.project_id, format=fmt) @property def project_id(self): return self.manager.params.get('project_id', 0) @property def url(self): return self.manager.redmine.url + self.query_one.format(self.internal_id, project_id=self.project_id)[:-5] def __getattr__(self, attr): # If a text attribute of a resource is missing, we should # refresh a resource automatically for user's convenience if attr == 'text' and attr not in self._decoded_attrs: self._decoded_attrs[attr] = self.refresh(itself=False).raw()[attr] return super(WikiPage, self).__getattr__(attr) def __int__(self): return self.version class ProjectMembership(BaseResource): redmine_version = '1.4' container_filter = 'memberships' container_one = 'membership' container_update = 'membership' container_create = 'membership' query_filter = '/projects/{project_id}/memberships.json' query_one = '/memberships/{0}.json' query_create = '/projects/{project_id}/memberships.json' query_update = '/memberships/{0}.json' query_delete = '/memberships/{0}.json' _repr = [['id']] _create_readonly = BaseResource._create_readonly + ['user', 'roles'] _update_readonly = _create_readonly[:] _resource_map = {'project': 'Project', 'user': 'User', 'group': 'Group'} _resource_set_map = {'roles': 'Role'} _single_attr_id_map = {'project_id': 'project', 'user_id': 'users'} _multiple_attr_id_map = {'role_ids': 'roles'} class IssueCategory(BaseResource): redmine_version = '1.3' container_filter = 'issue_categories' container_one = 'issue_category' container_update = 'issue_category' container_create = 'issue_category' query_filter = '/projects/{project_id}/issue_categories.json' query_one = '/issue_categories/{0}.json' query_create = '/projects/{project_id}/issue_categories.json' query_update = '/issue_categories/{0}.json' query_delete = '/issue_categories/{0}.json' _resource_map = {'project': 'Project', 'assigned_to': 'User'} class IssueRelation(BaseResource): redmine_version = '1.3' container_filter = 'relations' container_one = 'relation' container_create = 'relation' query_filter = '/issues/{issue_id}/relations.json' query_one = '/relations/{0}.json' query_create = '/issues/{issue_id}/relations.json' query_delete = '/relations/{0}.json' _repr = [['id']] _single_attr_id_map = {'issue_id': 'issue'} class Version(BaseResource): redmine_version = '1.3' container_filter = 'versions' container_one = 'version' container_create = 'version' container_update = 'version' query_filter = '/projects/{project_id}/versions.json' query_one = '/versions/{0}.json' query_create = '/projects/{project_id}/versions.json' query_update = '/versions/{0}.json' query_delete = '/versions/{0}.json' _unconvertible = ['status'] _resource_map = {'project': 'Project'} _resource_set_map = {'custom_fields': 'CustomField'} _single_attr_id_map = {'project_id': 'project'} class User(BaseResource): redmine_version = '1.1' container_all = 'users' container_one = 'user' container_filter = 'users' container_create = 'user' container_update = 'user' query_all = '/users.json' query_one = '/users/{0}.json' query_filter = '/users.json' query_create = '/users.json' query_update = '/users/{0}.json' query_delete = '/users/{0}.json' _repr = [['id', 'firstname', 'lastname'], ['id', 'name']] _includes = ['memberships', 'groups'] _relations = ['issues', 'time_entries'] _relations_name = 'assigned_to' _unconvertible = ['status'] _create_readonly = BaseResource._create_readonly + ['api_key', 'last_login_on'] _update_readonly = _create_readonly[:] _resource_set_map = { 'custom_fields': 'CustomField', 'groups': 'Group', 'memberships': 'ProjectMembership', 'issues': 'Issue', 'time_entries': 'TimeEntry', } def __getattr__(self, attr): if attr == 'time_entries' and attr not in self._encoded_attrs: self._relations_name = 'user' value = super(User, self).__getattr__(attr) self._relations_name = 'assigned_to' return value return super(User, self).__getattr__(attr) class Group(BaseResource): redmine_version = '2.1' container_all = 'groups' container_one = 'group' container_create = 'group' container_update = 'group' query_all = '/groups.json' query_one = '/groups/{0}.json' query_create = '/groups.json' query_update = '/groups/{0}.json' query_delete = '/groups/{0}.json' _includes = ['memberships', 'users'] _resource_set_map = {'memberships': 'ProjectMembership', 'users': 'User', 'custom_fields': 'CustomField'} _multiple_attr_id_map = {'user_ids': 'users'} class User: """ A group user implementation. """ def __init__(self, group): self._redmine = group.manager.redmine self._group_id = group.internal_id def add(self, user_id): """ Adds user to a group. :param int user_id: (required). User id. """ url = '{0}/groups/{1}/users.json'.format(self._redmine.url, self._group_id) return self._redmine.engine.request('post', url, data={'user_id': user_id}) def remove(self, user_id): """ Removes user from a group. :param int user_id: (required). User id. """ url = '{0}/groups/{1}/users/{2}.json'.format(self._redmine.url, self._group_id, user_id) return self._redmine.engine.request('delete', url) def __getattr__(self, attr): if attr == 'user': return Group.User(self) return super(Group, self).__getattr__(attr) class Role(BaseResource): redmine_version = '1.4' container_all = 'roles' container_one = 'role' query_all = '/roles.json' query_one = '/roles/{0}.json' class News(BaseResource): redmine_version = '1.1' container_all = 'news' container_filter = 'news' query_all_export = '/news.{format}' query_all = '/news.json' query_filter = '/news.json' search_hints = ['news'] _repr = [['id', 'title']] _resource_map = {'project': 'Project', 'author': 'User'} @property def url(self): return '{0}/news/{1}'.format(self.manager.redmine.url, self.internal_id) class IssueStatus(BaseResource): redmine_version = '1.3' container_all = 'issue_statuses' query_all = '/issue_statuses.json' _relations = ['issues'] _relations_name = 'status' _resource_set_map = {'issues': 'Issue'} @property def url(self): return '{0}/issue_statuses/{1}/edit'.format(self.manager.redmine.url, self.internal_id) class Tracker(BaseResource): redmine_version = '1.3' container_all = 'trackers' query_all = '/trackers.json' _relations = ['issues'] _resource_set_map = {'issues': 'Issue'} @property def url(self): return '{0}/trackers/{1}/edit'.format(self.manager.redmine.url, self.internal_id) class Query(BaseResource): redmine_version = '1.3' container_all = 'queries' query_all = '/queries.json' @property def url(self): return '{0}/projects/{1}/issues?query_id={2}'.format( self.manager.redmine.url, self._decoded_attrs.get('project_id', 0), self.internal_id) class CustomField(BaseResource): redmine_version = '2.4' container_all = 'custom_fields' query_all = '/custom_fields.json' _resource_set_map = {'trackers': 'Tracker', 'roles': 'Role'} def __getattr__(self, attr): # If custom field was created after the creation of the resource, # i.e. project, and it's not used in the resource, there will be # no value attribute defined, that is why we need to return '' or # we'll get an exception if attr == 'value' and attr not in self._decoded_attrs: return '' return super(CustomField, self).__getattr__(attr) @classmethod def encode(cls, attr, value, manager): # Redmine <2.5.2 returns only single tracker instead of a list of # all available trackers, see http://www.redmine.org/issues/16739 # for details if attr == 'trackers' and 'tracker' in value: value = [value['tracker']] return super(CustomField, cls).encode(attr, value, manager) @property def url(self): return '{0}/custom_fields/{1}/edit'.format(self.manager.redmine.url, self.internal_id) python-redmine-2.2.1/redminelib/resources/__init__.py0000644000076500000240000000054113416607614024217 0ustar maxtepkeevstaff00000000000000""" Defines Redmine resources. """ from .base import BaseResource, registry from .standard import (Project, Issue, TimeEntry, Enumeration, Attachment, File, IssueJournal, WikiPage, ProjectMembership, IssueCategory, IssueRelation, Version, User, Group, Role, News, IssueStatus, Tracker, Query, CustomField) python-redmine-2.2.1/redminelib/resources/base.py0000644000076500000240000004325613413626756023411 0ustar maxtepkeevstaff00000000000000""" Defines base Redmine resource class and it's infrastructure. """ from __future__ import unicode_literals from datetime import date, datetime from .. import managers, utilities, exceptions registry = {} class Registrar(type): """ A resource that implements this metaclass, i.e. all resources that inherit from BaseResource, will be added to a resource registry to be managed by it's ResourceManager. Resource classes which name starts with Base are considered base classes and not added to the registry. """ def __new__(mcs, name, bases, attrs): cls = super(Registrar, mcs).__new__(mcs, name, bases, attrs) if name.startswith('Base'): # base classes shouldn't be added to the registry return cls if name not in registry: # a name maybe already added to registry by other classes registry[name] = {} for attr in ('_attach_includes', '_attach_relations'): class_attr_name = attr[7:] registry_attr_name = attr[1:] if registry_attr_name in registry[name]: mcs.update_cls_attr(cls, class_attr_name, registry[name][registry_attr_name].keys()) mcs.update_cls_attr(cls, '_resource_set_map', registry[name][registry_attr_name]) if not isinstance(getattr(cls, attr), dict): continue for resource_name, value in getattr(cls, attr).items(): if resource_name not in registry: registry[resource_name] = {} if registry_attr_name not in registry[resource_name]: registry[resource_name][registry_attr_name] = {} registry[resource_name][registry_attr_name][value] = name if 'class' in registry[resource_name]: mcs.update_cls_attr(registry[resource_name]['class'], class_attr_name, [value]) mcs.update_cls_attr(registry[resource_name]['class'], '_resource_set_map', {value: name}) return registry[name].setdefault('class', cls) @staticmethod def update_cls_attr(cls, name, value): """ Updates class attribute's value by first copying the current value and then updating it with new value. We need that to be sure that each resource class has its own copy of the value. :param any cls: (required). Resource class. :param string name: (required). Attribute name. :param any value: (optional). Attribute value. """ attr = getattr(cls, name, None) if isinstance(attr, list): value = list(attr) + list(value) elif isinstance(attr, dict): value = dict(attr, **value) else: return setattr(cls, name, value) @utilities.fix_unicode class BaseResource(utilities.with_metaclass(Registrar)): """ Implementation of Redmine resource. """ internal_id_key = 'id' redmine_version = None requirements = [] container_all = None container_one = None container_filter = None container_create = None container_update = None query_all_export = None query_one_export = None query_all = None query_one = None query_filter = None query_create = None query_update = None query_delete = None search_hints = None http_method_create = 'post' http_method_update = 'put' http_method_delete = 'delete' manager_class = managers.ResourceManager _repr = [['id', 'name']] _includes = [] _relations = [] _relations_name = None _unconvertible = ['name', 'description'] _members = ['manager'] _create_readonly = ['id', 'created_on', 'updated_on', 'author', 'user', 'project', 'issue'] _update_readonly = _create_readonly[:] _attach_includes = None _attach_relations = None _resource_map = {} # Resources that should become a Resource object _resource_set_map = {} # Resources that should become a ResourceSet object _single_attr_id_map = {} # Resource attributes that should set another resource id to its value _multiple_attr_id_map = {} # Resource attributes should set another resource ids to their value def __init__(self, manager, attributes): """ :param managers.ResourceManager manager: (required). Manager object. :param dict attributes: (required). Resource attributes. """ relations_includes = self._relations + self._includes self.manager = manager self._create_readonly += relations_includes self._update_readonly += relations_includes self._decoded_attrs = dict(dict.fromkeys(relations_includes), **attributes) self._encoded_attrs = {} self._changes = {} if self._relations_name is None: self._relations_name = self.__class__.__name__.lower() def __getitem__(self, item): """ Provides a dictionary-like access to Resource attributes. """ return getattr(self, item) def __setitem__(self, item, value): """ Provides a dictionary-like setter for Resource attributes. """ return setattr(self, item, value) def __getattr__(self, attr): """ Returns the requested attribute and makes a conversion if needed. """ if attr.startswith('_'): raise AttributeError # If this isn't the first time attribute access we can return it from cache encoded = self._encoded_attrs.get(attr) if encoded is not None: return encoded # Else this is the first time access and we need to encode the attribute decoded = self._decoded_attrs.get(attr) if decoded is not None: attr, encoded = self.encode(attr, decoded, self.manager) elif attr in self._relations: filters = {'{0}_id'.format(self._relations_name): self.internal_id} encoded = self.manager.new_manager(self._resource_set_map[attr]).filter(**filters) elif attr in self._includes: attr, encoded = self.encode(attr, self.refresh(itself=False, include=attr).raw()[attr] or [], self.manager) # In case of successful encoding we put it to a cache and return if encoded is not None: self._encoded_attrs[attr] = encoded return encoded # Else we return the defaults if this is a new item or throw an exception if self.is_new(): return 0 if attr in ('id', 'version') else '' raise_attr_exception = self.manager.redmine.raise_attr_exception if isinstance(raise_attr_exception, bool) and raise_attr_exception: raise exceptions.ResourceAttrError elif isinstance(raise_attr_exception, (list, tuple)) and self.__class__.__name__ in raise_attr_exception: raise exceptions.ResourceAttrError return None def __setattr__(self, attr, value): """ Sets the requested attribute. """ if attr in self._members or attr.startswith('_'): return super(BaseResource, self).__setattr__(attr, value) elif attr in self._create_readonly and self.is_new(): raise exceptions.ReadonlyAttrError elif attr in self._update_readonly and not self.is_new(): raise exceptions.ReadonlyAttrError elif attr == 'custom_fields': try: new = {field['id']: self.bulk_decode(field, self.manager) for field in value} except (TypeError, KeyError): raise exceptions.CustomFieldValueError for i, field in enumerate(self._decoded_attrs.setdefault('custom_fields', [])): if field['id'] in new: self._decoded_attrs['custom_fields'][i] = new.pop(field['id']) self._decoded_attrs['custom_fields'].extend(list(new.values())) self._changes[attr] = self._decoded_attrs['custom_fields'] else: decoded_attr, decoded_value = self.decode(attr, value, self.manager) self._changes[decoded_attr] = decoded_value self._decoded_attrs[attr] = decoded_value if attr in self._single_attr_id_map: self._decoded_attrs[self._single_attr_id_map[attr]] = {'id': decoded_value} elif attr in self._multiple_attr_id_map: self._decoded_attrs[self._multiple_attr_id_map[attr]] = [{'id': attr_id} for attr_id in decoded_value] # When we set an attribute we put it's decoded value only to a _decoded_attrs # dict because it may never be accessed again, that is why we don't waste time # on the encode process but only clean the cache, and in case if it will be # accessed, the encoding process will be run automatically by __getattr__ self._encoded_attrs.pop(attr, None) @classmethod def decode(cls, attr, value, manager): """ Decodes a single attr, value pair from Python representation to the needed Redmine representation. :param string attr: (required). Attribute name. :param any value: (required). Attribute value. :param managers.ResourceManager manager: (required). Manager object. """ type_ = type(value) if type_ is date: return attr, value.strftime(manager.redmine.date_format) elif type_ is datetime: return attr, value.strftime(manager.redmine.datetime_format) if attr == 'uploads': for index, attachment in enumerate(value): if 'token' not in attachment: value[index]['token'] = manager.redmine.upload(attachment.pop('path', ''))['token'] return attr, value elif attr == 'include' and isinstance(value, (list, tuple)): return attr, ','.join(value) return attr, value @classmethod def encode(cls, attr, value, manager): """ Encodes a single attr, value pair retrieved from Redmine to the needed Python representation. :param string attr: (required). Attribute name. :param any value: (required). Attribute value. :param managers.ResourceManager manager: (required). Manager object. """ if attr in cls._unconvertible: return attr, value elif attr in cls._resource_map: return attr, manager.new_manager(cls._resource_map[attr]).to_resource(value) elif attr in cls._resource_set_map: return attr, manager.new_manager(cls._resource_set_map[attr]).to_resource_set(value) elif attr == 'parent': return attr, manager.new_manager(cls.__name__).to_resource(value) try: try: return attr, datetime.strptime(value, manager.redmine.datetime_format) except (TypeError, ValueError): return attr, datetime.strptime(value, manager.redmine.date_format).date() except (TypeError, ValueError): return attr, value @classmethod def bulk_decode(cls, attrs, manager): """ Decodes resource data from Python representation to the needed Redmine representation. :param dict attrs: (required). Attributes in the form of key, value pairs. :param managers.ResourceManager manager: (required). Manager object. """ return dict(cls.decode(attr, attrs[attr], manager) for attr in attrs) @classmethod def bulk_encode(cls, attrs, manager): """ Encodes resource data retrieved from Redmine to the needed Python representation. :param dict attrs: (required). Attributes in the form of key, value pairs. :param managers.ResourceManager manager: (required). Manager object. """ return dict(cls.encode(attr, attrs[attr], manager) for attr in attrs) def raw(self): """ Returns resource data as it was received from Redmine. """ return self._decoded_attrs def refresh(self, itself=True, **params): """ Reloads resource data from Redmine. :param bool itself: (optional). Whether to refresh itself or return a new resource. :param dict params: (optional). Parameters used for resource retrieval. """ resource = self.manager.get(self.internal_id, **params) if itself: self._encoded_attrs = {} self._decoded_attrs = resource.raw() else: return resource def pre_create(self): """ Tasks that should be done before creating the Resource. """ pass def post_create(self): """ Tasks that should be done after creating the Resource. """ pass def pre_update(self): """ Tasks that should be done before updating the Resource. """ pass def post_update(self): """ Tasks that should be done after updating the Resource. """ pass def pre_delete(self): """ Tasks that should be done before deleting the Resource. """ pass def post_delete(self): """ Tasks that should be done after deleting the Resource. """ pass def save(self, **attrs): """ Creates or updates a Resource. :param dict attrs: (optional). Attrs to be set for a resource before create/update operation. """ for attr in attrs: setattr(self, attr, attrs[attr]) if not self.is_new(): self.pre_update() self.manager.update(self.internal_id, **self._changes) self._decoded_attrs['updated_on'] = datetime.utcnow().strftime(self.manager.redmine.datetime_format) self.post_update() else: self.pre_create() self._decoded_attrs = self.manager.create(**self._changes).raw() self.post_create() self._changes = {} return self def delete(self, **params): """ Deletes Resource from Redmine. :param dict params: (optional). Parameters used for resource deletion. """ self.pre_delete() response = self.manager.delete(self.internal_id, **params) self.post_delete() return response def export(self, fmt, savepath=None, filename=None): """ Exports Resource to requested format if Resource supports that. :param string fmt: (required). Format to use for export, e.g. atom, csv, txt, pdf, html etc. :param string savepath: (optional). Path where to save the file. :param string filename: (optional). Name that will be used for the file. """ url = self.export_url(fmt) if url is None: raise exceptions.ExportNotSupported try: return self.manager.redmine.download(url, savepath, filename) except exceptions.UnknownError as e: if e.status_code == 406: raise exceptions.ExportFormatNotSupportedError raise e def export_url(self, fmt): """ Returns export URL for the Resource according to format. :param string fmt: (required). Export format, e.g. atom, csv, txt, pdf, html etc. """ if self.query_one_export is not None: return self.manager.redmine.url + self.query_one_export.format(self.internal_id, format=fmt) return None @property def url(self): """ Returns full URL to the Resource for humans if there is one. """ if self.query_one is not None: return self.manager.redmine.url + self.query_one.format(self.internal_id)[:-5] return None @property def internal_id(self): """ Returns identifier of the Resource for usage in internals of the library. """ return getattr(self, self.internal_id_key) def is_new(self): """ Checks if Resource was just created and not yet saved to Redmine or it is an existing Resource. """ return False if 'id' in self._decoded_attrs or 'created_on' in self._decoded_attrs else True def __dir__(self): """ Allows dir() to be called on a Resource object and shows Resource attributes. """ return list(self._decoded_attrs.keys()) def __iter__(self): """ Provides a way to iterate through Resource attributes and its values. """ return iter(self._decoded_attrs.items()) def __int__(self): """ Integer representation of a Resource object. """ return self.id def _representation(self, target): """ Prepares values which should be used in either __str__ or __repr__ methods. :param string target: (required). Target of representation. """ _str_, _repr_ = [], [] for attrs in self._repr: for attr in reversed(attrs): value = getattr(self, attr, None) if value is None: break _repr_.insert(0, value) if attr != 'id': _str_.insert(0, value) if len(_repr_) > 0: break if self.is_new() and len(_repr_) > 2: _str_ = _str_[:-1] _repr_ = _repr_[:-1] return _str_ or [str(_repr_[0])] if target == 'str' else _repr_ def __str__(self): """ Informal representation of a Resource object. """ return ' '.join(self._representation('str')) def __repr__(self): """ Official representation of a Resource object. """ values = self._representation('repr') view = ' 0: view += ' "{0}"'.format(' '.join(values)) return view + '>' python-redmine-2.2.1/redminelib/managers/0000755000076500000240000000000013435744437021677 5ustar maxtepkeevstaff00000000000000python-redmine-2.2.1/redminelib/managers/standard.py0000644000076500000240000000132713272121747024044 0ustar maxtepkeevstaff00000000000000""" Defines standard Redmine resources managers. """ from . import ResourceManager from .. import exceptions class WikiPageManager(ResourceManager): def _process_create_response(self, request, response): if response is True: raise exceptions.ValidationError('Resource already exists') # issue #182 return super(WikiPageManager, self)._process_create_response(request, response) class FileManager(ResourceManager): def _process_create_response(self, request, response): if response is True: response = {self.container: {'id': int(request[self.container]['token'].split('.')[0])}} return super(FileManager, self)._process_create_response(request, response) python-redmine-2.2.1/redminelib/managers/__init__.py0000644000076500000240000000016713272320756024005 0ustar maxtepkeevstaff00000000000000""" Defines manager classes. """ from .base import ResourceManager from .standard import WikiPageManager, FileManager python-redmine-2.2.1/redminelib/managers/base.py0000644000076500000240000002544213413621246023156 0ustar maxtepkeevstaff00000000000000""" Defines base Redmine resource manager class and it's infrastructure. """ from .. import utilities, resultsets, exceptions class ResourceManager(object): """ Manages given Redmine resource class with the help of redmine object. """ def __init__(self, redmine, resource_class): """ :param redmine.Redmine redmine: (required). Redmine object. :param resources.BaseResource resource_class: (required). Resource class. """ self.url = '' self.params = {} self.container = None self.redmine = redmine self.resource_class = resource_class def to_resource(self, resource): """ Converts resource data to Resource object. :param dict resource: (required). Resource data. """ return self.resource_class(self, resource) def to_resource_set(self, resources): """ Converts an iterable with resources data to ResourceSet object. :param resources: (required). Resource data. :type resources: list or tuple """ return resultsets.ResourceSet(self, resources) def new(self): """ Returns new empty Resource object. """ return self.to_resource({}) def new_manager(self, resource_name, **params): """ Returns new ResourceManager object. :param string resource_name: (required). Resource name. :param dict params: (optional). Parameters used for resources retrieval. """ manager = getattr(self.redmine, resource_name) manager.params = params return manager def get(self, resource_id, **params): """ Returns a Resource object from Redmine by resource id. :param resource_id: (required). Resource id. :type resource_id: int or string :param dict params: (optional). Parameters used for resource retrieval. """ if self.resource_class.query_one is None or self.resource_class.container_one is None: operation = self.all if self.resource_class.query_all else self.filter resource = operation(**params).get(resource_id, None) if resource is None: raise exceptions.ResourceNotFoundError return resource try: self.url = self.redmine.url + self.resource_class.query_one.format(resource_id, **params) except KeyError as exception: raise exceptions.ValidationError('{0} argument is required'.format(exception)) self.params = self.resource_class.bulk_decode(params, self) self.container = self.resource_class.container_one try: return self.to_resource(self.redmine.engine.request('get', self.url, params=self.params)[self.container]) except exceptions.ResourceNotFoundError as e: if self.resource_class.requirements: raise exceptions.ResourceRequirementsError(self.resource_class.requirements) raise e def all(self, **params): """ Returns a ResourceSet object with all Resource objects. :param dict params: (optional). Parameters used for resources retrieval. """ if self.resource_class.query_all is None or self.resource_class.container_all is None: raise exceptions.ResourceBadMethodError self.url = self.redmine.url + self.resource_class.query_all self.params = self.resource_class.bulk_decode(params, self) self.container = self.resource_class.container_all return resultsets.ResourceSet(self) def filter(self, **filters): """ Returns a ResourceSet object with Resource objects filtered by a dict of filters. :param dict filters: (optional). Filters used for resources retrieval. """ if self.resource_class.query_filter is None or self.resource_class.container_filter is None: raise exceptions.ResourceBadMethodError if not filters: raise exceptions.ResourceNoFiltersProvidedError try: self.url = self.redmine.url + self.resource_class.query_filter.format(**filters) self.container = self.resource_class.container_filter.format(**filters) except KeyError: raise exceptions.ResourceFilterError self.params = self.resource_class.bulk_decode(filters, self) return resultsets.ResourceSet(self) def _construct_create_url(self, path): """ Constructs URL for create method. :param string path: absolute URL path. """ return self.redmine.url + path def _prepare_create_request(self, request): """ Makes the necessary preparations for create request data. :param dict request: Request data. """ return {self.container: self.resource_class.bulk_decode(request, self)} def create(self, **fields): """ Creates a new resource in Redmine and returns created Resource object on success. :param dict fields: (optional). Fields used for resource creation. """ if self.resource_class.query_create is None or self.resource_class.container_create is None: raise exceptions.ResourceBadMethodError if not fields: raise exceptions.ResourceNoFieldsProvidedError formatter = utilities.MemorizeFormatter() try: url = self._construct_create_url(formatter.format(self.resource_class.query_create, **fields)) except KeyError as e: raise exceptions.ValidationError('{0} field is required'.format(e)) self.params = formatter.used_kwargs self.container = self.resource_class.container_create request = self._prepare_create_request(formatter.unused_kwargs) response = self.redmine.engine.request(self.resource_class.http_method_create, url, data=request) resource = self._process_create_response(request, response) self.url = self.redmine.url + self.resource_class.query_one.format(resource.internal_id, **fields) return resource def _process_create_response(self, request, response): """ Processes create response and constructs resource object. :param dict request: Original request data. :param any response: Response received from Redmine for this request data. """ return self.to_resource(response[self.container]) def _construct_update_url(self, path): """ Constructs URL for update method. :param string path: absolute URL path. """ return self.redmine.url + path def _prepare_update_request(self, request): """ Makes the necessary preparations for update request data. :param dict request: Request data. """ return {self.resource_class.container_update: self.resource_class.bulk_decode(request, self)} def update(self, resource_id, **fields): """ Updates a Resource object by resource id. :param resource_id: (required). Resource id. :type resource_id: int or string :param dict fields: (optional). Fields that will be updated for the resource. """ if self.resource_class.query_update is None or self.resource_class.container_update is None: raise exceptions.ResourceBadMethodError if not fields: raise exceptions.ResourceNoFieldsProvidedError formatter = utilities.MemorizeFormatter() try: query_update = formatter.format(self.resource_class.query_update, resource_id, **fields) except KeyError as e: param = e.args[0] if param in self.params: fields[param] = self.params[param] query_update = formatter.format(self.resource_class.query_update, resource_id, **fields) else: raise exceptions.ValidationError('{0} argument is required'.format(e)) url = self._construct_update_url(query_update) request = self._prepare_update_request(formatter.unused_kwargs) response = self.redmine.engine.request(self.resource_class.http_method_update, url, data=request) return self._process_update_response(request, response) def _process_update_response(self, request, response): """ Processes update response. :param dict request: Original request data. :param any response: Response received from Redmine for this request data. """ return response def _construct_delete_url(self, path): """ Constructs URL for delete method. :param string path: absolute URL path. """ return self.redmine.url + path def _prepare_delete_request(self, request): """ Makes the necessary preparations for delete request data. :param dict request: Request data. """ return self.resource_class.bulk_decode(request, self) def delete(self, resource_id, **params): """ Deletes a Resource object by resource id. :param resource_id: (required). Resource id. :type resource_id: int or string :param dict params: (optional). Parameters used for resource deletion. """ if self.resource_class.query_delete is None: raise exceptions.ResourceBadMethodError try: url = self._construct_delete_url(self.resource_class.query_delete.format(resource_id, **params)) except KeyError as e: raise exceptions.ValidationError('{0} argument is required'.format(e)) request = self._prepare_delete_request(params) response = self.redmine.engine.request(self.resource_class.http_method_delete, url, params=request) return self._process_delete_response(request, response) def _process_delete_response(self, request, response): """ Processes delete response. :param dict request: Original request data. :param any response: Response received from Redmine for this request data. """ return response def search(self, query, **options): """ Searches for Resources using a query. :param string query: (required). What to search. :param dict options: (optional). Dictionary of search options. """ if self.resource_class.search_hints is None: raise exceptions.ResourceBadMethodError container = self.resource_class.container_all or self.resource_class.container_filter results = self.redmine.search(query, **dict(resources=[container], **options)) return results.get(container) if results is not None else results def __repr__(self): """ Official representation of a ResourceManager object. """ return ''.format( self.__class__.__name__, self.resource_class.__name__) python-redmine-2.2.1/redminelib/__init__.py0000644000076500000240000002160713413334565022212 0ustar maxtepkeevstaff00000000000000""" Provides public API. """ import os import io import sys import inspect import warnings import contextlib from distutils.version import LooseVersion from . import managers, exceptions, engines, utilities, resources from .version import __version__ class Redmine(object): """ Entry point for all requests. """ def __init__(self, url, **kwargs): """ :param string url: (required). Redmine location. :param string key: (optional). API key used for authentication. :param string version: (optional). Redmine version. :param string username: (optional). Username used for authentication. :param string password: (optional). Password used for authentication. :param dict requests: (optional). Connection options. :param string impersonate: (optional). Username to impersonate. :param string date_format: (optional). Formatting directives for date format. :param string datetime_format: (optional). Formatting directives for datetime format. :param raise_attr_exception: (optional). Control over resource attribute access exception raising. :type raise_attr_exception: bool or tuple :param cls engine: (optional). Engine that will be used to make requests to Redmine. :param bool return_raw_response (optional). Whether engine should return raw or json encoded responses. """ self.url = url.rstrip('/') self.ver = kwargs.get('version', None) self.date_format = kwargs.get('date_format', '%Y-%m-%d') self.datetime_format = kwargs.get('datetime_format', '%Y-%m-%dT%H:%M:%SZ') self.raise_attr_exception = kwargs.get('raise_attr_exception', True) engine = kwargs.get('engine', engines.DefaultEngine) if not inspect.isclass(engine) or not issubclass(engine, engines.BaseEngine): raise exceptions.EngineClassError self.engine = engine(**kwargs) def __getattr__(self, resource_name): """ Returns a ResourceManager object for the requested resource. :param string resource_name: (required). Resource name. """ if resource_name.startswith('_'): raise AttributeError resource_name = ''.join(word[0].upper() + word[1:] for word in str(resource_name).split('_')) try: resource_class = resources.registry[resource_name]['class'] except KeyError: raise exceptions.ResourceError if self.ver is not None and LooseVersion(str(self.ver)) < LooseVersion(resource_class.redmine_version): raise exceptions.ResourceVersionMismatchError return resource_class.manager_class(self, resource_class) @contextlib.contextmanager def session(self, **options): """ Initiates a temporary session with a copy of the current engine but with new options. :param dict options: (optional). Engine's options for a session. """ engine = self.engine self.engine = engine.__class__( requests=utilities.merge_dicts(engine.requests, options.pop('requests', {})), **options) try: yield self except exceptions.BaseRedmineError as e: raise e finally: self.engine = engine def upload(self, f): """ Uploads file from file path / file stream to Redmine and returns an assigned token. :param f: (required). File path / stream that will be uploaded. :type f: string or file-like object """ if self.ver is not None and LooseVersion(str(self.ver)) < LooseVersion('1.4.0'): raise exceptions.VersionMismatchError('File uploading') url = '{0}/uploads.json'.format(self.url) headers = {'Content-Type': 'application/octet-stream'} # There're myriads of file-like object implementations here and there and some of them don't have # a "read" method, which is wrong, but that's what we have, on the other hand it looks like all of # them implement a "close" method, that's why we check for it here. Also we don't want to close the # stream ourselves as we have no idea of what the client is going to do with it afterwards, so we # leave the closing part to the client or to the garbage collector if hasattr(f, 'close'): try: c = f.read(0) except (AttributeError, TypeError): raise exceptions.FileObjectError # We need to send bytes over the socket, so in case a file-like object contains a unicode # object underneath, we need to convert it to bytes, otherwise we'll get an exception if isinstance(c, str if sys.version_info[0] >= 3 else unicode): warnings.warn("File-like object contains unicode, hence an additional step is performed to convert " "it's content to bytes, please consider switching to bytes to eliminate this warning", exceptions.PerformanceWarning) f = io.BytesIO(f.read().encode('utf-8')) stream = f close = False else: if not os.path.isfile(f) or os.path.getsize(f) == 0: raise exceptions.NoFileError stream = open(f, 'rb') close = True response = self.engine.request('post', url, data=stream, headers=headers) if close: stream.close() return response['upload'] def download(self, url, savepath=None, filename=None, params=None): """ Downloads file from Redmine and saves it to savepath or returns a response directly for maximum control over file processing. :param string url: (required). URL of the file that will be downloaded. :param string savepath: (optional). Path where to save the file. :param string filename: (optional). Name that will be used for the file. :param dict params: (optional). Params to send in the query string. """ with self.session(requests={'stream': True}, return_raw_response=True): response = self.engine.request('get', url, params=params or {}) # If a savepath wasn't provided we return a response directly # so a user can have maximum control over response data if savepath is None: return response try: from urlparse import urlsplit except ImportError: from urllib.parse import urlsplit if filename is None: filename = urlsplit(url)[2].split('/')[-1] if not filename: raise exceptions.FileUrlError savepath = os.path.join(savepath, filename) with open(savepath, 'wb') as f: for chunk in response.iter_content(1024): f.write(chunk) return savepath def auth(self): """ Shortcut for the case if we just want to check if user provided valid auth credentials. """ return self.user.get('current') def search(self, query, **options): """ Interface to Redmine Search API :param string query: (required). What to search. :param dict options: (optional). Dictionary of search options. """ if self.ver is not None and LooseVersion(str(self.ver)) < LooseVersion('3.0.0'): raise exceptions.VersionMismatchError('Search functionality') container_map, manager_map, results = {}, {}, {'unknown': {}} for resource in options.pop('resources', []): options[resource] = True options['q'] = query for name, details in resources.registry.items(): if details['class'].search_hints is not None: container = details['class'].container_all or details['class'].container_filter for hint in details['class'].search_hints: container_map[hint] = container manager_map[container] = getattr(self, name) raw_resources, _ = self.engine.bulk_request('get', '{0}/search.json'.format(self.url), 'results', **options) for resource in raw_resources: if resource['type'] in container_map: container = container_map[resource['type']] if container not in results: results[container] = [] results[container].append(resource) else: if resource['type'] not in results['unknown']: results['unknown'][resource['type']] = [] results['unknown'][resource['type']].append(resource) del resource['type'] # all resources are already sorted by type so we don't need it if not results['unknown']: del results['unknown'] for container in results: if container in manager_map: results[container] = manager_map[container].to_resource_set(results[container]) return results or None python-redmine-2.2.1/redminelib/lookups.py0000644000076500000240000000241313271377037022144 0ustar maxtepkeevstaff00000000000000""" Defines lookup classes to be used in ResultSet's filter method. """ from . import utilities registry = {} class Registrar(type): """ A lookup class that implements this metaclass, i.e. all lookup classes that inherit from Lookup, will be added to a lookup registry to be used in ResultSet's filter method. Lookup class, at minimum, should define a lookup_name attribute and implement a __call__ method, otherwise it will be considered a base class and won't be added to the registry. """ def __new__(mcs, name, bases, attrs): cls = super(Registrar, mcs).__new__(mcs, name, bases, attrs) if attrs['lookup_name'] is None: # base classes shouldn't be added to the registry return cls registry[attrs['lookup_name']] = cls() return cls class Lookup(utilities.with_metaclass(Registrar)): lookup_name = None def __call__(self, resource_value, requested_value): raise NotImplementedError class Exact(Lookup): lookup_name = 'exact' def __call__(self, resource_value, requested_value): return resource_value == requested_value class In(Lookup): lookup_name = 'in' def __call__(self, resource_value, requested_values): return resource_value in requested_values python-redmine-2.2.1/redminelib/engines/0000755000076500000240000000000013435744437021532 5ustar maxtepkeevstaff00000000000000python-redmine-2.2.1/redminelib/engines/sync.py0000644000076500000240000000102713416342637023053 0ustar maxtepkeevstaff00000000000000""" Synchronous blocking engine that processes requests one by one. """ import requests from . import BaseEngine class SyncEngine(BaseEngine): @staticmethod def create_session(**params): session = requests.Session() for param in params: setattr(session, param, params[param]) return session def process_bulk_request(self, method, url, container, bulk_params): return [resource for params in bulk_params for resource in self.request(method, url, params=params)[container]] python-redmine-2.2.1/redminelib/engines/__init__.py0000644000076500000240000000024213416607614023633 0ustar maxtepkeevstaff00000000000000""" Defines engines for processing requests/responses to/from Redmine. """ from .base import BaseEngine from .sync import SyncEngine DefaultEngine = SyncEngine python-redmine-2.2.1/redminelib/engines/base.py0000644000076500000240000001641313412700341023000 0ustar maxtepkeevstaff00000000000000""" Base engine that defines common behaviour and settings for all engines. """ import json from .. import exceptions class BaseEngine(object): chunk = 100 def __init__(self, **options): """ :param string key: (optional). API key used for authentication. :param string username: (optional). Username used for authentication. :param string password: (optional). Password used for authentication. :param dict requests: (optional). Connection options. :param string impersonate: (optional). Username to impersonate. :param bool return_raw_response (optional). Whether to return raw or json encoded responses. """ self.return_raw_response = options.pop('return_raw_response', False) self.requests = dict(dict(headers={}, params={}, data={}), **options.get('requests', {})) if options.get('impersonate') is not None: self.requests['headers']['X-Redmine-Switch-User'] = options['impersonate'] # We would like to be authenticated by API key by default if options.get('key') is not None: self.requests['params']['key'] = options['key'] elif options.get('username') is not None and options.get('password') is not None: self.requests['auth'] = (options['username'], options['password']) self.session = self.create_session(**self.requests) @staticmethod def create_session(**params): """ Creates a session object that will be used to make requests to Redmine. :param dict params: (optional). Session params. """ raise NotImplementedError @staticmethod def construct_request_kwargs(method, headers, params, data): """ Constructs kwargs that will be used in all requests to Redmine. :param string method: (required). HTTP verb to use for the request. :param dict headers: (required). HTTP headers to send with the request. :param dict params: (required). Params to send in the query string. :param data: (required). Data to send in the body of the request. :type data: dict, bytes or file-like object """ kwargs = {'data': data or {}, 'params': params or {}, 'headers': headers or {}} if method in ('post', 'put', 'patch') and 'Content-Type' not in kwargs['headers']: kwargs['data'] = json.dumps(data) kwargs['headers']['Content-Type'] = 'application/json' return kwargs def request(self, method, url, headers=None, params=None, data=None): """ Makes a single request to Redmine and returns processed response. :param string method: (required). HTTP verb to use for the request. :param string url: (required). URL of the request. :param dict headers: (optional). HTTP headers to send with the request. :param dict params: (optional). Params to send in the query string. :param data: (optional). Data to send in the body of the request. :type data: dict, bytes or file-like object """ kwargs = self.construct_request_kwargs(method, headers, params, data) return self.process_response(self.session.request(method, url, **kwargs)) def bulk_request(self, method, url, container, **params): """ Makes needed preparations before launching the active engine's request process. :param string method: (required). HTTP verb to use for the request. :param string url: (required). URL of the request. :param string container: (required). Key in the response that should be used to access retrieved resources. :param dict params: (optional). Params that should be used for resource retrieval. """ limit = params.get('limit') or 0 offset = params.get('offset') or 0 response = self.request(method, url, params=dict(params, limit=limit or self.chunk, offset=offset)) # Resource supports limit/offset on Redmine level if all(response.get(param) is not None for param in ('total_count', 'limit', 'offset')): total_count = response['total_count'] results = response[container] limit = limit or total_count if limit > self.chunk: bulk_params = [] for num in range(limit - self.chunk, 0, -self.chunk): offset += self.chunk limit -= self.chunk bulk_params.append(dict(params, offset=offset, limit=limit)) # If we need to make just one more request, there's no point in async if len(bulk_params) == 1: results.extend(self.request(method, url, params=bulk_params[0])[container]) else: results.extend(self.process_bulk_request(method, url, container, bulk_params)) # We have to mimic limit/offset if a resource # doesn't support this feature on Redmine level else: total_count = len(response[container]) results = response[container][offset:None if limit == 0 else limit + offset] return results, total_count def process_bulk_request(self, method, url, container, bulk_params): """ Makes several requests in blocking or non-blocking fashion depending on the engine. :param string method: (required). HTTP verb to use for the request. :param string url: (required). URL of the request. :param string container: (required). Key in the response that should be used to access retrieved resources. :param list bulk_params: (required). Params that should be used for resource retrieval. """ raise NotImplementedError def process_response(self, response): """ Processes response received from Redmine. :param obj response: (required). Response object with response details. """ if response.history: r = response.history[0] if r.is_redirect and r.request.url.startswith('http://') and response.request.url.startswith('https://'): raise exceptions.HTTPProtocolError status_code = response.status_code if status_code in (200, 201, 204): if self.return_raw_response: return response elif not response.content.strip(): return True else: try: return response.json() except (ValueError, TypeError): raise exceptions.JSONDecodeError(response) elif status_code == 401: raise exceptions.AuthError elif status_code == 403: raise exceptions.ForbiddenError elif status_code == 404: raise exceptions.ResourceNotFoundError elif status_code == 409: raise exceptions.ConflictError elif status_code == 412: raise exceptions.ImpersonateError elif status_code == 413: raise exceptions.RequestEntityTooLargeError elif status_code == 422: errors = response.json()['errors'] raise exceptions.ValidationError(', '.join(': '.join(e) if isinstance(e, list) else e for e in errors)) elif status_code == 500: raise exceptions.ServerError raise exceptions.UnknownError(status_code) python-redmine-2.2.1/redminelib/exceptions.py0000644000076500000240000002044213413202400022606 0ustar maxtepkeevstaff00000000000000""" Python-Redmine tries it's best to provide human readable errors in all situations. This is a list of all exceptions or warnings that Python-Redmine can throw/raise. """ from . import utilities @utilities.fix_unicode class BaseRedmineWarning(Warning): """ Base warning class for Redmine warnings. """ class PerformanceWarning(BaseRedmineWarning): """ Warning raised when there's a possible performance impact. """ @utilities.fix_unicode class BaseRedmineError(Exception): """ Base exception class for Redmine exceptions. """ class ResourceError(BaseRedmineError): """ Unsupported Redmine resource exception. """ def __init__(self): super(ResourceError, self).__init__('Unsupported Redmine resource') class NoFileError(BaseRedmineError): """ File doesn't exist or is empty exception. """ def __init__(self): super(NoFileError, self).__init__("Can't upload a file that doesn't exist or is empty") class FileObjectError(BaseRedmineError): """ File-like object isn't supported as it doesn't support the read(size) method. """ def __init__(self): super(FileObjectError, self).__init__("File-like object doesn't support the read(size) method") class ResourceNotFoundError(BaseRedmineError): """ Requested resource doesn't exist. """ def __init__(self): super(ResourceNotFoundError, self).__init__("Requested resource doesn't exist") class ConflictError(BaseRedmineError): """ Resource version on the server is newer than on the client. """ def __init__(self): super(ConflictError, self).__init__('Resource version on the server is newer than on the client') class AuthError(BaseRedmineError): """ Invalid authentication details. """ def __init__(self): super(AuthError, self).__init__('Invalid authentication details') class ImpersonateError(BaseRedmineError): """ Invalid impersonate login provided. """ def __init__(self): super(ImpersonateError, self).__init__("Impersonate login provided doesn't exist or isn't active") class ServerError(BaseRedmineError): """ Redmine internal error. """ def __init__(self): super(ServerError, self).__init__('Redmine returned internal error, check Redmine logs for details') class RequestEntityTooLargeError(BaseRedmineError): """ Size of the request exceeds the capacity limit on the server. """ def __init__(self): super(RequestEntityTooLargeError, self).__init__( "The requested resource doesn't allow POST requests or the size of the request exceeds the capacity limit") class UnknownError(BaseRedmineError): """ Redmine returned unknown error. """ def __init__(self, status_code): self.status_code = status_code super(UnknownError, self).__init__( 'Redmine returned unknown error with the status code {0}'.format(status_code)) class ValidationError(BaseRedmineError): """ Redmine validation errors occured on create/update resource. """ def __init__(self, error): super(ValidationError, self).__init__(error) class ResourceSetIndexError(BaseRedmineError): """ Index doesn't exist in the ResourceSet. """ def __init__(self): super(ResourceSetIndexError, self).__init__('Resource not available by requested index') class ResourceSetFilterLookupError(BaseRedmineError): """ Resource set filter method received an invalid lookup in one of the filters. """ def __init__(self, lookup, f): super(ResourceSetFilterLookupError, self).__init__( 'Received an invalid lookup "{0}" in "{1}" filter'.format(lookup, f)) class ResourceBadMethodError(BaseRedmineError): """ Resource doesn't support the requested method. """ def __init__(self): super(ResourceBadMethodError, self).__init__("Resource doesn't support the requested method") class ResourceFilterError(BaseRedmineError): """ Resource doesn't support requested filter(s). """ def __init__(self): super(ResourceFilterError, self).__init__("Resource doesn't support requested filter(s)") class ResourceNoFiltersProvidedError(BaseRedmineError): """ No filter(s) provided. """ def __init__(self): super(ResourceNoFiltersProvidedError, self).__init__('Resource needs some filters to be filtered on') class ResourceNoFieldsProvidedError(BaseRedmineError): """ No field(s) provided. """ def __init__(self): super(ResourceNoFieldsProvidedError, self).__init__( 'Resource needs some fields to be set to be created/updated') class ResourceAttrError(BaseRedmineError, AttributeError): """ Resource doesn't have the requested attribute. """ def __init__(self): super(ResourceAttrError, self).__init__("Resource doesn't have the requested attribute") class ReadonlyAttrError(BaseRedmineError): """ Resource can't set attribute that is read only. """ def __init__(self): super(ReadonlyAttrError, self).__init__("Can't set read only attribute") class VersionMismatchError(BaseRedmineError): """ Feature isn't supported on specified Redmine version. """ def __init__(self, feature): super(VersionMismatchError, self).__init__("{0} isn't supported on specified Redmine version".format(feature)) class ResourceVersionMismatchError(VersionMismatchError): """ Resource isn't supported on specified Redmine version. """ def __init__(self): super(ResourceVersionMismatchError, self).__init__('Resource') class ResultSetTotalCountError(BaseRedmineError): """ ResultSet hasn't been yet evaluated and cannot yield a total_count. """ def __init__(self): super(ResultSetTotalCountError, self).__init__('Total count is unknown before evaluation') class CustomFieldValueError(BaseRedmineError): """ Custom fields should be passed as a list of dictionaries. """ def __init__(self): super(CustomFieldValueError, self).__init__( "Custom fields should be passed as a list of dictionaries in the form of [{'id': 1, 'value': 'foo'}]") class ResourceRequirementsError(BaseRedmineError): """ Resource requires specified Redmine plugin(s) to function. """ def __init__(self, requirements): super(ResourceRequirementsError, self).__init__( 'The following requirements must be installed for resource to function: {0}'.format( ', '.join(' >= '.join(req) if isinstance(req, (list, tuple)) else req for req in requirements))) class FileUrlError(BaseRedmineError): """ URL provided to download a file can't be parsed. """ def __init__(self): super(FileUrlError, self).__init__("URL provided to download a file can't be parsed") class ForbiddenError(BaseRedmineError): """ Requested resource is forbidden. """ def __init__(self): super(ForbiddenError, self).__init__('Requested resource is forbidden') class JSONDecodeError(BaseRedmineError): """ Unable to decode received JSON. """ def __init__(self, response): self.response = response super(JSONDecodeError, self).__init__( 'Unable to decode received JSON, you can inspect exception\'s ' '"response" attribute to find out what the response was') class ExportNotSupported(BaseRedmineError): """ Export functionality not supported by resource. """ def __init__(self): super(ExportNotSupported, self).__init__('Export functionality not supported by resource') class ExportFormatNotSupportedError(BaseRedmineError): """ The given format isn't supported by resource. """ def __init__(self): super(ExportFormatNotSupportedError, self).__init__( "The given format isn't supported by resource") class HTTPProtocolError(BaseRedmineError): """ Wrong HTTP protocol usage. """ def __init__(self): super(HTTPProtocolError, self).__init__('Redmine url should start with HTTPS and not with HTTP') class EngineClassError(BaseRedmineError): """ Engine isn't a class or isn't a BaseEngine subclass. """ def __init__(self): super(EngineClassError, self).__init__("Engine isn't a class or isn't a BaseEngine subclass") python-redmine-2.2.1/redminelib/utilities.py0000644000076500000240000000415613412732672022466 0ustar maxtepkeevstaff00000000000000""" Provides helper utilities. """ import sys import copy import string import functools def fix_unicode(cls): """ A class decorator that defines __unicode__, makes __str__ and __repr__ return a utf-8 encoded string and encodes unicode exception messages to utf-8 encoded strings under Python 2. Does nothing under Python 3. :param class cls: (required). A class where unicode should be fixed. """ if sys.version_info[0] >= 3: return cls def decorator(fn): @functools.wraps(fn, assigned=('__name__', '__doc__')) def wrapper(self, *args, **kwargs): if fn.__name__ == '__init__': return fn(self, *[arg.encode('utf-8') if isinstance(arg, unicode) else arg for arg in args], **kwargs) return fn(self).encode('utf-8') return wrapper if issubclass(cls, Exception): cls.__init__ = decorator(cls.__init__) return cls cls.__unicode__ = cls.__str__ cls.__str__ = decorator(cls.__unicode__) cls.__repr__ = decorator(cls.__repr__) return cls def with_metaclass(meta, *bases): """ Create a base class with a metaclass. """ class MetaClass(meta): def __new__(cls, name, this_bases, dct): return meta(name, bases, dct) return type.__new__(MetaClass, 'temporary_class', (), {}) def merge_dicts(a, b): """ Merges dicts a and b recursively into a new dict. :param dict a: (required). :param dict b: (required). """ result = copy.deepcopy(a) for key, value in b.items(): if isinstance(value, dict): result[key] = merge_dicts(value, a.get(key, {})) else: result[key] = value return result class MemorizeFormatter(string.Formatter): """ Memorizes all arguments, used during string formatting. """ def __init__(self): self.used_kwargs = {} self.unused_kwargs = {} def check_unused_args(self, used_args, args, kwargs): for item in used_args: if item in kwargs: self.used_kwargs[item] = kwargs.pop(item) self.unused_kwargs = kwargs python-redmine-2.2.1/LICENSE0000644000076500000240000000105113416607614016764 0ustar maxtepkeevstaff00000000000000Copyright 2019 Maxim Tepkeev Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.python-redmine-2.2.1/tests/0000755000076500000240000000000013435744437017132 5ustar maxtepkeevstaff00000000000000python-redmine-2.2.1/tests/responses/0000755000076500000240000000000013435744437021153 5ustar maxtepkeevstaff00000000000000python-redmine-2.2.1/tests/responses/standard.py0000644000076500000240000000577013272074405023324 0ustar maxtepkeevstaff00000000000000responses = { 'project': { 'get': {'project': {'name': 'Foo', 'id': 1, 'identifier': 'foo'}}, 'all': {'projects': [{'name': 'Foo', 'id': 1}, {'name': 'Bar', 'id': 2}]}, }, 'issue': { 'get': {'issue': {'subject': 'Foo', 'id': 1}}, 'all': {'issues': [{'subject': 'Foo', 'id': 1}, {'subject': 'Bar', 'id': 2}]}, 'filter': {'issues': [{'subject': 'Foo', 'id': 1}, {'subject': 'Bar', 'id': 2}]}, }, 'time_entry': { 'get': {'time_entry': {'hours': 2, 'id': 1}}, 'all': {'time_entries': [{'hours': 3, 'id': 1}, {'hours': 4, 'id': 2}]}, 'filter': {'time_entries': [{'hours': 3, 'id': 1}, {'hours': 4, 'id': 2}]}, }, 'enumeration': { 'filter': {'time_entry_activities': [{'name': 'Foo', 'id': 1}, {'name': 'Bar', 'id': 2}]}, }, 'attachment': { 'get': {'attachment': {'filename': 'foo.jpg', 'id': 1}}, }, 'file': { 'filter': {'files': [{'filename': 'foo.jpg', 'id': 1}, {'filename': 'bar.jpg', 'id': 2}]}, }, 'wiki_page': { 'get': {'wiki_page': {'title': 'Foo', 'version': 1}}, 'filter': {'wiki_pages': [{'title': 'Foo', 'version': 1}, {'title': 'Bar', 'version': 2}]}, }, 'project_membership': { 'get': {'membership': {'id': 1}}, 'filter': {'memberships': [{'id': 1}, {'id': 2}]}, }, 'issue_category': { 'get': {'issue_category': {'id': 1, 'name': 'Foo'}}, 'filter': {'issue_categories': [{'id': 1, 'name': 'Foo'}, {'id': 2, 'name': 'Bar'}]}, }, 'issue_relation': { 'get': {'relation': {'id': 1}}, 'filter': {'relations': [{'id': 1}, {'id': 2}]}, }, 'version': { 'get': {'version': {'id': 1, 'name': 'Foo'}}, 'filter': {'versions': [{'id': 1, 'name': 'Foo'}, {'id': 2, 'name': 'Bar'}]}, }, 'user': { 'get': {'user': {'firstname': 'John', 'lastname': 'Smith', 'id': 1}}, 'all': {'users': [{'firstname': 'John', 'id': 1}, {'firstname': 'Jack', 'id': 2}]}, 'filter': {'users': [{'firstname': 'John', 'id': 1}, {'firstname': 'Jack', 'id': 2}]}, }, 'group': { 'get': {'group': {'name': 'Foo', 'id': 1}}, 'all': {'groups': [{'name': 'Foo', 'id': 1}, {'name': 'Bar', 'id': 2}]}, }, 'role': { 'get': {'role': {'name': 'Foo', 'id': 1}}, 'all': {'roles': [{'name': 'Foo', 'id': 1}, {'name': 'Bar', 'id': 2}]}, }, 'news': { 'all': {'news': [{'title': 'Foo', 'id': 1}, {'title': 'Bar', 'id': 2}]}, 'filter': {'news': [{'title': 'Foo', 'id': 1}, {'title': 'Bar', 'id': 2}]}, }, 'issue_status': { 'all': {'issue_statuses': [{'name': 'Foo', 'id': 1}, {'name': 'Bar', 'id': 2}]}, }, 'tracker': { 'all': {'trackers': [{'name': 'Foo', 'id': 1}, {'name': 'Bar', 'id': 2}]}, }, 'query': { 'all': {'queries': [{'name': 'Foo', 'id': 1}, {'name': 'Bar', 'id': 2}]}, }, 'custom_field': { 'all': {'custom_fields': [{'name': 'Foo', 'id': 1}, {'name': 'Bar', 'id': 2}]}, }, } python-redmine-2.2.1/tests/responses/__init__.py0000644000076500000240000000004013416607614023250 0ustar maxtepkeevstaff00000000000000from .standard import responses python-redmine-2.2.1/tests/test_engines.py0000644000076500000240000001117413072752270022166 0ustar maxtepkeevstaff00000000000000from . import mock, BaseRedmineTestCase, Redmine from redminelib import engines, exceptions class BaseEngineTestCase(BaseRedmineTestCase): def test_engine_init(self): redmine = Redmine(self.url, key='123', impersonate='jsmith', requests={'foo': 'bar'}) self.assertEqual(redmine.engine.requests['params']['key'], '123') self.assertEqual(redmine.engine.requests['headers']['X-Redmine-Switch-User'], 'jsmith') self.assertEqual(redmine.engine.requests['foo'], 'bar') redmine = Redmine(self.url, username='john', password='qwerty') self.assertEqual(redmine.engine.requests['auth'], ('john', 'qwerty')) def test_successful_response_via_username_password(self): self.redmine.engine.requests['auth'] = ('john', 'qwerty') self.response.status_code = 200 self.response.json.return_value = {'success': True} self.assertEqual(self.redmine.engine.request('get', self.url)['success'], True) def test_successful_response_via_api_key(self): self.redmine.engine.requests['params']['key'] = '123' self.response.status_code = 200 self.response.json.return_value = {'success': True} self.assertEqual(self.redmine.engine.request('get', self.url)['success'], True) def test_successful_response_via_put_method(self): self.response.status_code = 200 self.response.content = '' self.assertEqual(self.redmine.engine.request('put', self.url), True) def test_session_not_implemented_exception(self): self.assertRaises(NotImplementedError, lambda: engines.BaseEngine()) def test_process_bulk_request_not_implemented_exception(self): self.redmine.engine = type('FooEngine', (engines.BaseEngine,), {'create_session': lambda obj, **kwargs: None})() self.assertRaises(NotImplementedError, lambda: self.redmine.engine.process_bulk_request( 'get', '/foo', 'bar', {})) def test_conflict_error_exception(self): self.response.status_code = 409 self.assertRaises(exceptions.ConflictError, lambda: self.redmine.engine.request('put', self.url)) def test_json_decode_error_exception(self): self.response.status_code = 200 self.response.json = mock.Mock(side_effect=ValueError) self.assertRaises(exceptions.JSONDecodeError, lambda: self.redmine.engine.request('get', self.url)) def test_auth_error_exception(self): self.response.status_code = 401 self.assertRaises(exceptions.AuthError, lambda: self.redmine.engine.request('get', self.url)) def test_forbidden_error_exception(self): self.response.status_code = 403 self.assertRaises(exceptions.ForbiddenError, lambda: self.redmine.engine.request('get', self.url)) def test_impersonate_error_exception(self): self.response.status_code = 412 self.assertRaises(exceptions.ImpersonateError, lambda: self.redmine.engine.request('get', self.url)) def test_server_error_exception(self): self.response.status_code = 500 self.assertRaises(exceptions.ServerError, lambda: self.redmine.engine.request('post', self.url)) def test_request_entity_too_large_error_exception(self): self.response.status_code = 413 self.assertRaises(exceptions.RequestEntityTooLargeError, lambda: self.redmine.engine.request('post', self.url)) def test_validation_error_exception(self): self.response.status_code = 422 self.response.json.return_value = {'errors': ['foo', 'bar', ['foo', 'bar']]} self.assertRaises(exceptions.ValidationError, lambda: self.redmine.engine.request('post', self.url)) def test_not_found_error_exception(self): self.response.status_code = 404 self.assertRaises(exceptions.ResourceNotFoundError, lambda: self.redmine.engine.request('get', self.url)) def test_unknown_error_exception(self): self.response.status_code = 888 self.assertRaises(exceptions.UnknownError, lambda: self.redmine.engine.request('get', self.url)) def test_http_protocol_exception(self): self.response.history = [mock.Mock()] self.redmine.url = 'http://foo.bar' self.assertRaises(exceptions.HTTPProtocolError, lambda: self.redmine.engine.request('get', self.url)) def test_engine_is_picklable(self): import pickle self.redmine.engine.requests['params']['key'] = '123' self.redmine.engine.requests['headers']['X-Redmine-Switch-User'] = 'jsmith' redmine = pickle.loads(pickle.dumps(self.redmine)) self.assertEqual(redmine.engine.requests['params']['key'], '123') self.assertEqual(redmine.engine.requests['headers']['X-Redmine-Switch-User'], 'jsmith') python-redmine-2.2.1/tests/__init__.py0000644000076500000240000000154613413337003021230 0ustar maxtepkeevstaff00000000000000import unittest try: from unittest import mock except ImportError: import mock from redminelib import Redmine class BaseRedmineTestCase(unittest.TestCase): url = 'http://foo.bar' patch_prefix = 'patch' patch_targets = {'requests': 'redminelib.engines.sync.requests.Session.request'} def setUp(self): self.redmine = Redmine(self.url) self.response = mock.Mock(status_code=200, history=[]) for target, path in self.patch_targets.items(): setattr(self, '{0}_{1}'.format(self.patch_prefix, target), mock.patch(path, return_value=self.response).start()) self.addCleanup(mock.patch.stopall) def set_patch_side_effect(self, side_effect): for target in self.patch_targets: getattr(self, '{0}_{1}'.format(self.patch_prefix, target)).side_effect = side_effect python-redmine-2.2.1/tests/test_managers.py0000644000076500000240000003100013413631403022312 0ustar maxtepkeevstaff00000000000000import warnings from . import mock, BaseRedmineTestCase from .responses import responses from redminelib import managers, resources, resultsets, exceptions class FooResource(resources.Project): pass class ResourceManagerTestCase(BaseRedmineTestCase): def test_has_custom_repr(self): self.assertEqual(repr(self.redmine.user), '') def test_supports_additional_resources(self): self.assertIsInstance(self.redmine.foo_resource, managers.ResourceManager) def test_not_supported_resource_exception(self): self.assertRaises(exceptions.ResourceError, lambda: self.redmine.foobar) def test_not_supported_version_exception(self): self.redmine.ver = '0.0.1' self.assertRaises(exceptions.ResourceVersionMismatchError, lambda: self.redmine.project) def test_convert_dict_to_resource_object(self): project = self.redmine.project.to_resource(responses['project']['get']['project']) self.assertIsInstance(project, resources.Project) self.assertEqual(project.name, 'Foo') self.assertEqual(project.identifier, 'foo') self.assertEqual(project.id, 1) def test_convert_dicts_to_resource_set_object(self): resourceset = self.redmine.project.to_resource_set([ {'name': 'Foo', 'identifier': 'foo', 'id': 1}, {'name': 'Bar', 'identifier': 'bar', 'id': 2} ]) self.assertIsInstance(resourceset, resultsets.ResourceSet) self.assertEqual(resourceset[0].name, 'Foo') self.assertEqual(resourceset[0].identifier, 'foo') self.assertEqual(resourceset[0].id, 1) self.assertEqual(resourceset[1].name, 'Bar') self.assertEqual(resourceset[1].identifier, 'bar') self.assertEqual(resourceset[1].id, 2) def test_get_single_resource(self): self.response.json.return_value = responses['project']['get'] project = self.redmine.project.get('foo') self.assertEqual(project.name, 'Foo') self.assertEqual(project.identifier, 'foo') self.assertEqual(project.id, 1) def test_get_single_resource_via_all(self): self.response.json.return_value = responses['tracker']['all'] tracker = self.redmine.tracker.get(1) self.assertEqual(tracker.id, 1) self.assertEqual(tracker.name, 'Foo') def test_get_single_resource_via_filter(self): self.response.json.return_value = responses['enumeration']['filter'] enumeration = self.redmine.enumeration.get(1, resource='time_entry_activities') self.assertEqual(enumeration.id, 1) self.assertEqual(enumeration.name, 'Foo') def test_get_unicode_resource(self): unicode_name = b'\xcf\x86oo'.decode('utf-8') self.response.json.return_value = {'project': {'name': unicode_name, 'identifier': unicode_name, 'id': 1}} project = self.redmine.project.get(unicode_name) self.assertEqual(project.name, unicode_name) self.assertEqual(project.identifier, unicode_name) self.assertEqual(project.id, 1) def test_get_all_resources(self): self.assertIsInstance(self.redmine.project.all(), resultsets.ResourceSet) def test_get_filtered_resources(self): self.assertIsInstance(self.redmine.issue.filter(project_id='foo'), resultsets.ResourceSet) def test_decode_params(self): from datetime import date, datetime time_entries = self.redmine.time_entry.filter(from_date=date(2014, 3, 9), to_date=date(2014, 3, 10)) self.assertEqual(time_entries.manager.params['from'], '2014-03-09') self.assertEqual(time_entries.manager.params['to'], '2014-03-10') time_entries = self.redmine.time_entry.filter(from_date=datetime(2014, 3, 9), to_date=datetime(2014, 3, 10)) self.assertEqual(time_entries.manager.params['from'], '2014-03-09T00:00:00Z') self.assertEqual(time_entries.manager.params['to'], '2014-03-10T00:00:00Z') def test_create_resource(self): self.response.status_code = 201 self.response.json.return_value = responses['user']['get'] user = self.redmine.user.create(firstname='John', lastname='Smith') self.assertEqual(user.firstname, 'John') self.assertEqual(user.lastname, 'Smith') def test_create_unicode_resource(self): unicode_name = b'\xcf\x86oo'.decode('utf-8') self.response.status_code = 201 self.response.json.return_value = {'wiki_page': {'title': unicode_name, 'project_id': 1}} wiki_page = self.redmine.wiki_page.create(title=unicode_name, project_id=1) self.assertEqual(wiki_page.title, unicode_name) self.assertEqual(wiki_page.project_id, 1) @mock.patch('os.path.isfile', mock.Mock()) @mock.patch('os.path.getsize', mock.Mock()) @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_create_resource_with_uploads(self): self.response.status_code = 201 self.response.json.return_value = { 'upload': {'id': 1, 'token': '123456'}, 'issue': {'subject': 'Foo', 'project_id': 1, 'id': 1} } issue = self.redmine.issue.create(project_id=1, subject='Foo', uploads=[{'path': 'foo'}]) self.assertEqual(issue.project_id, 1) self.assertEqual(issue.subject, 'Foo') def test_create_resource_with_stream_uploads(self): from io import StringIO self.response.status_code = 201 self.response.json.return_value = { 'upload': {'id': 1, 'token': '123456'}, 'issue': {'subject': 'Foo', 'project_id': 1, 'id': 1} } stream = StringIO(b'\xcf\x86oo'.decode('utf-8')) with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') issue = self.redmine.issue.create(project_id=1, subject='Foo', uploads=[{'path': stream}]) self.assertEquals(len(w), 1) self.assertIs(w[0].category, exceptions.PerformanceWarning) self.assertEqual(issue.project_id, 1) self.assertEqual(issue.subject, 'Foo') def test_create_empty_resource(self): project = self.redmine.project.new() defaults = dict.fromkeys(project._includes + project._relations) self.assertEqual(project._decoded_attrs, defaults) self.assertEqual(repr(project), '') def test_update_resource(self): self.response.content = '' manager = self.redmine.wiki_page manager.params['project_id'] = 1 self.assertEqual(manager.update(b'\xcf\x86oo'.decode('utf-8'), title='Bar'), True) @mock.patch('os.path.isfile', mock.Mock()) @mock.patch('os.path.getsize', mock.Mock()) @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_update_resource_with_uploads(self): self.set_patch_side_effect([ mock.Mock(status_code=201, history=[], **{'json.return_value': {'upload': {'id': 1, 'token': '123456'}}}), mock.Mock(status_code=200, history=[], content='') ]) self.assertEqual(self.redmine.issue.update(1, subject='Bar', uploads=[{'path': 'foo'}]), True) def test_update_resource_with_stream_uploads(self): from io import StringIO self.set_patch_side_effect([ mock.Mock(status_code=201, history=[], **{'json.return_value': {'upload': {'id': 1, 'token': '123456'}}}), mock.Mock(status_code=200, history=[], content='') ]) stream = StringIO(b'\xcf\x86oo'.decode('utf-8')) with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') self.assertEqual(self.redmine.issue.update(1, subject='Bar', uploads=[{'path': stream}]), True) self.assertEquals(len(w), 1) self.assertIs(w[0].category, exceptions.PerformanceWarning) def test_delete_resource(self): self.response.content = '' self.assertEqual(self.redmine.wiki_page.delete(b'\xcf\x86oo'.decode('utf-8'), project_id=1), True) def test_delete_resource_returns_204(self): self.response.status_code = 204 self.response.content = '' self.assertEqual(self.redmine.wiki_page.delete(b'\xcf\x86oo'.decode('utf-8'), project_id=1), True) def test_resource_get_method_unsupported_exception(self): self.assertRaises(exceptions.ResourceBadMethodError, lambda: self.redmine.issue_journal.get(1)) def test_resource_all_method_unsupported_exception(self): self.assertRaises(exceptions.ResourceBadMethodError, lambda: self.redmine.attachment.all()) def test_resource_filter_method_unsupported_exception(self): self.assertRaises(exceptions.ResourceBadMethodError, lambda: self.redmine.project.filter()) def test_resource_create_method_unsupported_exception(self): self.assertRaises(exceptions.ResourceBadMethodError, lambda: self.redmine.query.create()) def test_resource_update_method_unsupported_exception(self): self.assertRaises(exceptions.ResourceBadMethodError, lambda: self.redmine.query.update(1)) def test_resource_delete_method_unsupported_exception(self): self.assertRaises(exceptions.ResourceBadMethodError, lambda: self.redmine.query.delete(1)) def test_resource_search_method_unsupported_exception(self): self.assertRaises(exceptions.ResourceBadMethodError, lambda: self.redmine.query.search('foo')) def test_filter_no_filters_exception(self): self.assertRaises(exceptions.ResourceNoFiltersProvidedError, lambda: self.redmine.issue.filter()) def test_filter_unknown_filters_exception(self): self.assertRaises(exceptions.ResourceFilterError, lambda: self.redmine.version.filter(foo='bar')) def test_create_no_fields_exception(self): self.assertRaises(exceptions.ResourceNoFieldsProvidedError, lambda: self.redmine.user.create()) def test_update_no_fields_exception(self): self.assertRaises(exceptions.ResourceNoFieldsProvidedError, lambda: self.redmine.project.update(1)) def test_get_validation_exception(self): self.assertRaises(exceptions.ValidationError, lambda: self.redmine.wiki_page.get('foo')) def test_get_notfound_exception(self): self.response.json.return_value = responses['tracker']['all'] self.assertRaises(exceptions.ResourceNotFoundError, lambda: self.redmine.tracker.get(999)) def test_create_validation_exception(self): self.assertRaises(exceptions.ValidationError, lambda: self.redmine.issue_category.create(foo='bar')) def test_update_validation_exception(self): self.assertRaises(exceptions.ValidationError, lambda: self.redmine.wiki_page.update('Foo', title='Bar')) def test_delete_validation_exception(self): self.assertRaises(exceptions.ValidationError, lambda: self.redmine.wiki_page.delete('Foo')) def test_manager_is_picklable(self): import pickle project = self.redmine.project project.url = 'foo' project.params = {'foo': 'bar'} unpickled_project = pickle.loads(pickle.dumps(project)) self.assertEqual(project.url, unpickled_project.url) self.assertEqual(project.params['foo'], unpickled_project.params['foo']) def test_create_validation_exception_via_put(self): self.response.content = '' self.assertRaises(exceptions.ValidationError, lambda: self.redmine.wiki_page.create(project_id=1, title='Foo')) def test_reraises_not_found_exception(self): self.response.status_code = 404 self.assertRaises(exceptions.ResourceNotFoundError, lambda: self.redmine.project.get('non-existent-project')) self.assertRaises(exceptions.ResourceNotFoundError, lambda: list(self.redmine.project.all())) def test_resource_requirements_exception(self): FooResource.requirements = ('foo plugin', ('bar plugin', '1.2.3'),) self.response.status_code = 404 self.assertRaises(exceptions.ResourceRequirementsError, lambda: self.redmine.foo_resource.get(1)) self.assertRaises(exceptions.ResourceRequirementsError, lambda: list(self.redmine.foo_resource.all())) def test_search(self): self.response.json.return_value = {'total_count': 1, 'offset': 0, 'limit': 0, 'results': [ {'id': 1, 'title': 'Foo', 'type': 'issue'}]} results = self.redmine.issue.search('foo') self.assertIsInstance(results['issues'], resultsets.ResourceSet) self.assertEqual(len(results['issues']), 1) def test_search_returns_none_if_nothing_found(self): self.response.json.return_value = {'total_count': 0, 'offset': 0, 'limit': 0, 'results': []} self.assertIsNone(self.redmine.issue.search('foo')) python-redmine-2.2.1/tests/test_resultsets.py0000644000076500000240000002103513271377037022755 0ustar maxtepkeevstaff00000000000000from . import mock, BaseRedmineTestCase from redminelib import exceptions response = { 'issues': [ {'subject': 'Foo', 'id': 1, 'tracker_id': 1}, {'subject': 'Bar', 'id': 2, 'tracker_id': 2}, {'subject': 'Baz', 'id': 3, 'tracker_id': 3}, ] } class ResultSetTestCase(BaseRedmineTestCase): def setUp(self): super(ResultSetTestCase, self).setUp() self.response.json = mock.Mock(return_value=response) def test_has_custom_repr(self): self.assertEqual(repr(self.redmine.issue.all()), '') def test_offset_limit_all(self): self.response.json.return_value = dict(total_count=3, limit=0, offset=0, **response) issues = self.redmine.issue.all() self.assertEqual(issues.limit, 0) self.assertEqual(issues.offset, 0) self.assertEqual(issues[0].id, 1) self.assertEqual(issues[1].id, 2) self.assertEqual(issues[2].id, 3) def test_offset_limit(self): self.response.json.return_value = { 'total_count': 2, 'limit': 300, 'offset': 1, 'issues': response['issues'][1:3]} issues = self.redmine.issue.all()[1:300] self.assertEqual(issues.limit, 300) self.assertEqual(issues.offset, 1) self.assertEqual(issues[0].id, 2) self.assertEqual(issues[1].id, 3) self.assertEqual(issues[:1][0].id, 2) self.assertEqual(issues[1:][0].id, 3) self.assertEqual(issues[1:1][0].id, 3) def test_offset_limit_mimic(self): issues = self.redmine.issue.all()[1:3] self.assertEqual(issues.limit, 3) self.assertEqual(issues.offset, 1) self.assertEqual(issues[0].id, 2) self.assertEqual(issues[1].id, 3) def test_total_count(self): self.response.json.return_value = dict(total_count=3, limit=0, offset=0, **response) issues = self.redmine.issue.all() len(issues) self.assertEqual(issues.total_count, 3) def test_total_count_mimic(self): response_with_cf = {'issue': dict(custom_fields=[{'id': 1, 'value': 0}], **response['issues'][0])} self.response.json.return_value = response_with_cf issue = self.redmine.issue.get(1) self.assertEqual(issue.custom_fields.total_count, 1) def test_total_count_raise_exception_if_not_evaluated(self): self.assertRaises(exceptions.ResultSetTotalCountError, lambda: self.redmine.issue.all().total_count) def test_resultset_is_empty(self): self.response.json.return_value = {'limit': 100, 'issues': [], 'total_count': 0, 'offset': 0} issues = self.redmine.issue.all() self.assertEqual(len(issues), 0) self.assertEqual(list(issues), []) def test_sliced_resultset_is_empty(self): self.response.json.return_value = {'limit': 100, 'issues': [], 'total_count': 0, 'offset': 0} issues = self.redmine.issue.all()[:200] self.assertEqual(len(issues), 0) self.assertEqual(list(issues), []) def test_supports_iteration(self): issues = list(self.redmine.issue.all()) self.assertEqual(issues[0].subject, 'Foo') self.assertEqual(issues[0].id, 1) self.assertEqual(issues[1].subject, 'Bar') self.assertEqual(issues[1].id, 2) def test_supports_len(self): self.assertEqual(len(self.redmine.issue.all()), 3) def test_get_method_resource_found(self): issues = self.redmine.issue.all().get(2) self.assertEqual(issues.id, 2) def test_get_method_resource_not_found(self): issues = self.redmine.issue.all().get(6) self.assertEqual(issues, None) def test_filter_method_nonexistant_attributes(self): issues = self.redmine.issue.all().filter(id=1, foo__exact=1) self.assertEqual(len(issues), 0) def test_filter_exact_lookup(self): issues = self.redmine.issue.all().filter(id=1, tracker_id__exact=1) self.assertEqual(issues[0].id, 1) self.assertEqual(len(issues), 1) def test_filter_in_lookup(self): issues = self.redmine.issue.all().filter(id__in=(1, 3)) self.assertEqual(issues[0].id, 1) self.assertEqual(issues[1].id, 3) self.assertEqual(len(issues), 2) def test_update_method(self): issues = self.redmine.issue.all().update(subject='FooBar') self.assertEqual(issues[0].subject, 'FooBar') self.assertEqual(issues[1].subject, 'FooBar') self.assertEqual(issues[2].subject, 'FooBar') def test_delete_method(self): self.assertEqual(self.redmine.issue.all().delete(), True) def test_resourceset_is_picklable(self): import pickle issues = self.redmine.issue.all() unpickled_issues = pickle.loads(pickle.dumps(issues)) self.assertEqual(issues[0]['subject'], unpickled_issues[0]['subject']) self.assertEqual(issues[1]['subject'], unpickled_issues[1]['subject']) self.assertEqual(issues[2]['subject'], unpickled_issues[2]['subject']) def test_values_method(self): issues = list(self.redmine.issue.all().values()) self.assertEqual(issues[0]['subject'], 'Foo') self.assertEqual(issues[0]['id'], 1) self.assertEqual(issues[1]['subject'], 'Bar') self.assertEqual(issues[1]['id'], 2) self.assertEqual(issues[2]['subject'], 'Baz') self.assertEqual(issues[2]['id'], 3) def test_values_method_with_fields(self): issues = list(self.redmine.issue.all().values('subject', 'id')) self.assertEqual(len(issues[0]), 2) self.assertEqual(issues[0]['subject'], 'Foo') self.assertEqual(issues[0]['id'], 1) self.assertEqual(len(issues[1]), 2) self.assertEqual(issues[1]['subject'], 'Bar') self.assertEqual(issues[1]['id'], 2) self.assertEqual(len(issues[2]), 2) self.assertEqual(issues[2]['subject'], 'Baz') self.assertEqual(issues[2]['id'], 3) def test_values_list_method(self): issues = list(self.redmine.issue.all().values_list()) self.assertIn('Foo', issues[0]) self.assertIn(1, issues[0]) self.assertIn('Bar', issues[1]) self.assertIn(2, issues[1]) self.assertIn('Baz', issues[2]) self.assertIn(3, issues[2]) def test_values_list_method_with_fields(self): issues = list(self.redmine.issue.all().values_list('id', 'subject')) self.assertEqual(len(issues[0]), 2) self.assertEqual(issues[0][0], 1) self.assertEqual(issues[0][1], 'Foo') self.assertEqual(len(issues[1]), 2) self.assertEqual(issues[1][0], 2) self.assertEqual(issues[1][1], 'Bar') self.assertEqual(len(issues[2]), 2) self.assertEqual(issues[2][0], 3) self.assertEqual(issues[2][1], 'Baz') def test_values_list_method_flattened(self): issues = list(self.redmine.issue.all().values_list('id', flat=True)) self.assertEqual(issues[0], 1) self.assertEqual(issues[1], 2) self.assertEqual(issues[2], 3) @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_export(self): self.response.iter_content = lambda chunk_size: (str(num) for num in range(0, 5)) self.assertEqual(self.redmine.issue.all().export('txt', '/foo/bar'), '/foo/bar/issues.txt') def test_export_not_supported_exception(self): self.assertRaises(exceptions.ExportNotSupported, lambda: self.redmine.custom_field.all().export('pdf')) def test_export_format_not_supported_exception(self): self.response.status_code = 406 self.assertRaises(exceptions.ExportFormatNotSupportedError, lambda: self.redmine.issue.all().export('foo')) def test_export_reraises_unknown_error(self): self.response.status_code = 999 self.assertRaises(exceptions.UnknownError, lambda: self.redmine.issue.all().export('foo')) def test_filter_no_filters_exception(self): self.assertRaises(exceptions.ResourceNoFiltersProvidedError, lambda: self.redmine.issue.all().filter()) def test_filter_lookup_exception(self): self.assertRaises(exceptions.ResourceSetFilterLookupError, lambda: self.redmine.issue.all().filter(id__bar=1)) def test_filter_bad_lookup_class_definition(self): from redminelib import lookups type('Foo', (lookups.Lookup,), {'lookup_name': 'foo'}) self.assertRaises(NotImplementedError, lambda: self.redmine.issue.all().filter(id__foo=1)) del lookups.registry['foo'] def test_index_error_exception(self): self.assertRaises(exceptions.ResourceSetIndexError, lambda: self.redmine.issue.all()[6]) python-redmine-2.2.1/tests/test_redmine.py0000644000076500000240000001752313413631403022156 0ustar maxtepkeevstaff00000000000000import warnings from . import mock, BaseRedmineTestCase, Redmine from redminelib import engines, resultsets, exceptions class RedmineTestCase(BaseRedmineTestCase): def test_default_attributes(self): self.assertEqual(self.redmine.url, self.url) self.assertEqual(self.redmine.ver, None) self.assertEqual(self.redmine.date_format, '%Y-%m-%d') self.assertEqual(self.redmine.datetime_format, '%Y-%m-%dT%H:%M:%SZ') self.assertEqual(self.redmine.raise_attr_exception, True) self.assertEqual(self.redmine.engine.__class__, engines.DefaultEngine) def test_set_attributes_through_kwargs(self): FooEngine = type('FooEngine', (engines.BaseEngine,), {'create_session': lambda obj, **kwargs: None}) redmine = Redmine(self.url, version='1.0', date_format='format', datetime_format='format', raise_attr_exception=False, engine=FooEngine) self.assertEqual(redmine.url, self.url) self.assertEqual(redmine.ver, '1.0') self.assertEqual(redmine.date_format, 'format') self.assertEqual(redmine.datetime_format, 'format') self.assertEqual(redmine.raise_attr_exception, False) self.assertEqual(redmine.engine.__class__, FooEngine) def test_engine_class_exception(self): self.assertRaises(exceptions.EngineClassError, lambda: Redmine(self.url, engine=type('Foo', (object,), {}))) def test_session_impersonate(self): with self.redmine.session(impersonate='jsmith'): self.assertEqual(self.redmine.engine.requests['headers']['X-Redmine-Switch-User'], 'jsmith') self.assertRaises(KeyError, lambda: self.redmine.engine.requests['headers']['X-Redmine-Switch-User']) def test_session_key(self): with self.redmine.session(key='opa'): self.assertEqual(self.redmine.engine.requests['params']['key'], 'opa') self.assertRaises(KeyError, lambda: self.redmine.engine.requests['params']['key']) def test_session_username_password(self): with self.redmine.session(username='john', password='smith'): self.assertEqual(self.redmine.engine.requests['auth'], ('john', 'smith')) self.assertRaises(KeyError, lambda: self.redmine.engine.requests['auth']) def test_session_requests(self): self.redmine.engine.requests['cert'] = ('bar', 'baz') requests = {'verify': False, 'timeout': 2, 'cert': ('foo', 'bar'), 'params': {'foo': 'bar'}} with self.redmine.session(key='secret', requests=requests): self.assertEqual(self.redmine.engine.requests['params'], dict(key='secret', **requests['params'])) self.assertEqual(self.redmine.engine.requests['verify'], requests['verify']) self.assertEqual(self.redmine.engine.requests['timeout'], requests['timeout']) self.assertEqual(self.redmine.engine.requests['cert'], requests['cert']) self.assertEqual(self.redmine.engine.requests['params'], {}) self.assertEqual(self.redmine.engine.requests['cert'], ('bar', 'baz')) self.assertRaises(KeyError, lambda: self.redmine.engine.requests['verify']) self.assertRaises(KeyError, lambda: self.redmine.engine.requests['timeout']) @mock.patch('os.path.isfile', mock.Mock()) @mock.patch('os.path.getsize', mock.Mock()) @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_successful_file_upload(self): self.response.status_code = 201 self.response.json.return_value = {'upload': {'id': 1, 'token': '123456'}} self.assertEqual(self.redmine.upload('foo')['token'], '123456') def test_successful_filestream_upload(self): from io import StringIO self.response.status_code = 201 self.response.json.return_value = {'upload': {'id': 1, 'token': '456789'}} with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') self.assertEqual(self.redmine.upload(StringIO(b'\xcf\x86oo'.decode('utf-8')))['token'], '456789') self.assertEquals(len(w), 1) self.assertIs(w[0].category, exceptions.PerformanceWarning) @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_successful_file_download(self): self.response.status_code = 200 self.response.iter_content = lambda chunk_size: (str(num) for num in range(0, 5)) self.assertEqual(self.redmine.download('http://foo/bar.txt', '/some/path'), '/some/path/bar.txt') def test_successful_in_memory_file_download(self): self.response.status_code = 200 self.response.iter_content = lambda: (str(num) for num in range(0, 5)) self.assertEqual(''.join(self.redmine.download('http://foo/bar.txt').iter_content()), '01234') def test_file_url_exception(self): self.response.status_code = 200 self.assertRaises(exceptions.FileUrlError, lambda: self.redmine.download('http://bad_url', '/some/path')) def test_file_upload_no_file_exception(self): self.assertRaises(exceptions.NoFileError, lambda: self.redmine.upload('foo',)) def test_file_upload_file_object_exception(self): f = type('FileObject', (), {'close': lambda obj: None})() self.assertRaises(exceptions.FileObjectError, lambda: self.redmine.upload(f)) def test_file_upload_not_supported_exception(self): self.redmine.ver = '1.0.0' self.assertRaises(exceptions.VersionMismatchError, lambda: self.redmine.upload('foo',)) def test_auth(self): self.redmine.username = 'john' self.redmine.password = 'qwerty' self.response.status_code = 200 self.response.json.return_value = {'user': {'firstname': 'John', 'lastname': 'Smith', 'id': 1}} self.assertEqual(self.redmine.auth().firstname, 'John') def test_search(self): self.response.json.return_value = {'total_count': 6, 'offset': 0, 'limit': 0, 'results': [ {'id': 1, 'title': 'Foo', 'type': 'issue'}, {'id': 2, 'title': 'Bar', 'type': 'issue closed'}, {'id': 3, 'title': 'Foo', 'type': 'project'}, {'id': 4, 'title': 'Foo', 'type': 'news'}, {'id': 5, 'title': 'Foo', 'type': 'wiki-page'}, {'id': 6, 'title': 'Foo', 'type': 'document'}, ]} results = self.redmine.search('foo') self.assertIsInstance(results['issues'], resultsets.ResourceSet) self.assertEqual(len(results['issues']), 2) self.assertIsInstance(results['projects'], resultsets.ResourceSet) self.assertEqual(len(results['projects']), 1) self.assertIsInstance(results['news'], resultsets.ResourceSet) self.assertEqual(len(results['news']), 1) self.assertIsInstance(results['wiki_pages'], resultsets.ResourceSet) self.assertEqual(len(results['wiki_pages']), 1) self.assertIsInstance(results['unknown'], dict) self.assertEqual(len(results['unknown']['document']), 1) def test_search_without_unknown(self): self.response.json.return_value = {'total_count': 1, 'offset': 0, 'limit': 0, 'results': [ {'id': 1, 'title': 'Foo', 'type': 'issue'}]} results = self.redmine.search('foo') self.assertIsInstance(results['issues'], resultsets.ResourceSet) self.assertEqual(len(results['issues']), 1) def test_search_not_supported_exception(self): self.redmine.ver = '1.0.0' self.assertRaises(exceptions.VersionMismatchError, lambda: self.redmine.search('foo')) def test_redmine_is_picklable(self): import pickle redmine = pickle.loads(pickle.dumps(self.redmine)) self.assertEqual(redmine.url, self.redmine.url) self.assertEqual(redmine.ver, self.redmine.ver) self.assertEqual(redmine.date_format, self.redmine.date_format) self.assertEqual(redmine.datetime_format, self.redmine.datetime_format) self.assertEqual(redmine.raise_attr_exception, self.redmine.raise_attr_exception) python-redmine-2.2.1/tests/test_resources_standard.py0000644000076500000240000017103313272121747024432 0ustar maxtepkeevstaff00000000000000from . import mock, BaseRedmineTestCase from .responses import responses from redminelib import resources, resultsets, exceptions class StandardResourcesTestCase(BaseRedmineTestCase): def test_supports_dictionary_like_attribute_retrieval(self): self.response.json.return_value = responses['project']['get'] project = self.redmine.project.get(1) self.assertEqual(project['id'], 1) self.assertEqual(project['name'], 'Foo') def test_supports_url_retrieval(self): self.response.json.return_value = responses['project']['get'] self.assertEqual(self.redmine.project.get(1).url, '{0}/projects/foo'.format(self.url)) def test_supports_export_url_retrieval(self): self.response.json.return_value = responses['issue']['get'] self.assertEqual(self.redmine.issue.get(1).export_url('pdf'), '{0}/issues/1.pdf'.format(self.url)) self.response.json.return_value = responses['attachment']['get'] self.assertEqual(self.redmine.attachment.get(1).export_url('pdf'), None) @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_export(self): self.response.json.return_value = responses['issue']['get'] self.response.iter_content = lambda chunk_size: (str(num) for num in range(0, 5)) self.assertEqual(self.redmine.issue.get(1).export('txt', '/foo/bar'), '/foo/bar/1.txt') def test_export_not_supported_exception(self): self.response.json.return_value = responses['attachment']['get'] self.assertRaises(exceptions.ExportNotSupported, lambda: self.redmine.attachment.get(1).export('pdf')) def test_export_format_not_supported_exception(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) self.response.status_code = 406 self.assertRaises(exceptions.ExportFormatNotSupportedError, lambda: issue.export('foo')) def test_export_reraises_unknown_error(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) self.response.status_code = 999 self.assertRaises(exceptions.UnknownError, lambda: issue.export('foo')) def test_supports_internal_id(self): self.response.json.return_value = responses['project']['get'] self.assertEqual(self.redmine.project.get(1).internal_id, 1) def test_supports_setting_of_attributes(self): project = self.redmine.project.new() project.name = 'Foo' project.description = 'Bar' self.assertEqual(project.name, 'Foo') self.assertEqual(project.description, 'Bar') def test_supports_setting_of_date_datetime_attributes(self): from datetime import date, datetime issue = self.redmine.issue.new() issue.start_date = date(2014, 3, 9) self.assertEqual(issue.start_date, date(2014, 3, 9)) self.assertEqual(issue._decoded_attrs['start_date'], '2014-03-09') self.assertEqual(issue._changes['start_date'], '2014-03-09') issue.start_date = datetime(2014, 3, 9, 20, 2, 2) self.assertEqual(issue._decoded_attrs['start_date'], '2014-03-09T20:02:02Z') self.assertEqual(issue._changes['start_date'], '2014-03-09T20:02:02Z') self.assertEqual(issue.start_date, datetime(2014, 3, 9, 20, 2, 2)) def test_supports_setting_of_attributes_via_dict(self): project = self.redmine.project.new() project['name'] = 'Foo' project['description'] = 'Bar' self.assertEqual(project.name, 'Foo') self.assertEqual(project.description, 'Bar') def test_setting_create_readonly_attrs_raises_exception(self): with self.assertRaises(exceptions.ReadonlyAttrError): project = self.redmine.project.new() project.id = 1 def test_setting_update_readonly_attrs_raises_exception(self): with self.assertRaises(exceptions.ReadonlyAttrError): self.response.json.return_value = responses['project']['get'] project = self.redmine.project.get(1) project.identifier = 1 def test_control_raising_of_resource_attr_exception(self): self.response.json.return_value = responses['project']['get'] self.redmine.raise_attr_exception = False self.assertEqual(self.redmine.project.get(1).foo, None) self.redmine.raise_attr_exception = ('Project',) self.assertRaises(exceptions.ResourceAttrError, lambda: self.redmine.project.get(1).foo) self.redmine.raise_attr_exception = True self.assertRaises(exceptions.ResourceAttrError, lambda: self.redmine.project.get(1).foo) def test_saving_new_resource_creates_it(self): self.response.status_code = 201 self.response.json.return_value = responses['project']['get'] project = self.redmine.project.new() project.name = 'Foo' self.assertIsInstance(project.save(), resources.Project) self.assertEqual(project.id, 1) project = self.redmine.project.new().save(name='Foo') self.assertIsInstance(project, resources.Project) self.assertEqual(project.id, 1) def test_saving_existing_resource_updates_it(self): self.response.json.return_value = responses['project']['get'] project = self.redmine.project.get(1) project.name = 'Bar' self.assertIsInstance(project.save(), resources.Project) self.response.json.return_value = {'project': {'id': 1, 'name': 'Bar'}} project.refresh() self.assertEqual(project.name, 'Bar') def test_custom_int(self): self.response.json.return_value = responses['project']['get'] self.assertEqual(int(self.redmine.project.get(1)), 1) def test_custom_str(self): self.response.json.return_value = responses['project']['get'] self.assertEqual(str(self.redmine.project.get(1)), 'Foo') def test_custom_repr(self): self.response.json.return_value = responses['project']['get'] self.assertEqual(repr(self.redmine.project.get(1)), '') def test_can_refresh_itself(self): self.response.json.return_value = responses['project']['get'] project = self.redmine.project.get(1) self.assertEqual(project.id, 1) self.assertEqual(project.name, 'Foo') self.response.json.return_value = {'project': {'id': 2, 'name': 'Bar'}} project.refresh() self.assertEqual(project.id, 2) self.assertEqual(project.name, 'Bar') def test_bulk_decode(self): from datetime import date, datetime encoded = {'start_date': date(2014, 3, 9), 'created_at': datetime(2014, 3, 9, 20, 2, 2), 'include': ['a', 'b']} decoded = self.redmine.project.resource_class.bulk_decode(encoded, self.redmine.project) self.assertEqual(decoded['start_date'], '2014-03-09') self.assertEqual(decoded['created_at'], '2014-03-09T20:02:02Z') self.assertEqual(decoded['include'], 'a,b') def test_bulk_encode(self): from datetime import date, datetime decoded = {'start_date': '2014-03-09', 'created_at': '2014-03-09T20:02:02Z'} encoded = self.redmine.project.resource_class.bulk_encode(decoded, self.redmine.project) self.assertEqual(encoded['start_date'], date(2014, 3, 9)) self.assertEqual(encoded['created_at'], datetime(2014, 3, 9, 20, 2, 2)) def test_resource_dict_is_converted_to_resource_object(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) issue._decoded_attrs['author'] = {'id': 1, 'name': 'John Smith'} self.assertEqual(repr(issue.author), '') def test_resource_list_of_dicts_is_converted_to_resource_set(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) issue._decoded_attrs['custom_fields'] = [{'id': 1, 'name': 'Foo'}, {'id': 2, 'name': 'Bar'}] self.assertEqual( repr(issue.custom_fields), '' ) def test_dir_returns_resource_attributes(self): self.response.json.return_value = responses['issue']['get'] attributes = dir(self.redmine.issue.get(1)) self.assertIn('id', attributes) self.assertIn('subject', attributes) self.assertIn('relations', attributes) self.assertIn('time_entries', attributes) def test_supports_iteration(self): self.response.json.return_value = responses['project']['get'] project = list(self.redmine.project.get(1)) self.assertIn(('name', 'Foo'), project) self.assertIn(('id', 1), project) def test_setting_custom_field_raises_exception_if_not_list_of_dicts(self): self.response.json.return_value = {'project': {'name': 'Foo', 'id': 1, 'custom_fields': [{'id': 1}]}} project = self.redmine.project.get(1) with self.assertRaises(exceptions.CustomFieldValueError): project.custom_fields = 'foo' def test_resource_is_picklable(self): import pickle self.response.json.return_value = responses['project']['get'] project = self.redmine.project.get(1) unpickled_project = pickle.loads(pickle.dumps(project)) self.assertEqual(project.id, unpickled_project.id) self.assertEqual(project.name, unpickled_project.name) def test_project_version(self): self.assertEqual(self.redmine.project.resource_class.redmine_version, '1.0') def test_project_get(self): self.response.json.return_value = responses['project']['get'] project = self.redmine.project.get(1) self.assertEqual(project.id, 1) self.assertEqual(project.name, 'Foo') def test_project_all(self): self.response.json.return_value = responses['project']['all'] projects = self.redmine.project.all() self.assertEqual(projects[0].id, 1) self.assertEqual(projects[0].name, 'Foo') self.assertEqual(projects[1].id, 2) self.assertEqual(projects[1].name, 'Bar') def test_project_create(self): self.response.status_code = 201 self.response.json.return_value = responses['project']['get'] project = self.redmine.project.create(name='Foo', identifier='foo') self.assertEqual(project.id, 1) self.assertEqual(project.name, 'Foo') def test_project_delete(self): self.response.json.return_value = responses['project']['get'] project = self.redmine.project.get(1) self.response.content = '' self.assertEqual(project.delete(), True) self.assertEqual(self.redmine.project.delete(1), True) def test_project_update(self): self.response.json.return_value = { 'project': {'name': 'Foo', 'id': 1, 'custom_fields': [{'id': 1, 'value': 'foo'}]}} project = self.redmine.project.get(1) project.homepage = 'http://foo.bar' project.parent_id = 3 project.custom_fields = [{'id': 1, 'value': 'bar'}] self.assertIsInstance(project.save(), resources.Project) self.assertEqual(project.custom_fields[0].value, 'bar') def test_project_relations(self): self.response.json.return_value = responses['project']['get'] project = self.redmine.project.get(1) self.assertIsInstance(project.wiki_pages, resultsets.ResourceSet) self.assertIsInstance(project.memberships, resultsets.ResourceSet) self.assertIsInstance(project.issue_categories, resultsets.ResourceSet) self.assertIsInstance(project.time_entries, resultsets.ResourceSet) self.assertIsInstance(project.versions, resultsets.ResourceSet) self.assertIsInstance(project.news, resultsets.ResourceSet) self.assertIsInstance(project.issues, resultsets.ResourceSet) self.assertIsInstance(project.files, resultsets.ResourceSet) def test_project_includes(self): response_includes = responses['project']['get'] self.response.json.return_value = response_includes project = self.redmine.project.get(1) response_includes['project'].update(responses['issue_category']['filter']) self.response.json.return_value = response_includes self.assertIsInstance(project.issue_categories, resultsets.ResourceSet) response_includes['project'].update(responses['tracker']['all']) self.response.json.return_value = response_includes self.assertIsInstance(project.trackers, resultsets.ResourceSet) response_includes['project'].update({'enabled_modules': [{'id': 36, 'name': 'issue_tracking'}]}) self.response.json.return_value = response_includes self.assertEqual(project.enabled_modules, ['issue_tracking']) response_includes['project'].update({'time_entry_activities': [{'id': 1, 'name': 'developing'}]}) self.response.json.return_value = response_includes self.assertEqual(project.time_entry_activities, [{'id': 1, 'name': 'developing'}]) def test_project_returns_status_without_conversion(self): self.response.json.return_value = {'project': {'name': 'Foo', 'id': 1, 'status': 1}} project = self.redmine.project.get(1) self.assertEqual(project.status, 1) def test_project_is_new(self): project = self.redmine.project.new() self.assertEqual(int(project), 0) self.assertEqual(str(project), '') self.assertEqual(repr(project), '') def test_project_url(self): self.response.json.return_value = responses['project']['get'] self.assertEqual(self.redmine.project.get(1).url, '{0}/projects/foo'.format(self.url)) @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_project_export(self): self.response.json.return_value = responses['project']['all'] self.response.iter_content = lambda chunk_size: (str(num) for num in range(0, 5)) self.assertEqual(self.redmine.project.all().export('txt', '/foo/bar'), '/foo/bar/projects.txt') def test_project_parent_converts_to_resource(self): self.response.json.return_value = {'project': {'name': 'Foo', 'id': 1, 'parent': {'id': 2}}} parent = self.redmine.project.get(1).parent self.assertIsInstance(parent, resources.Project) self.assertEqual(parent.id, 2) def test_issue_version(self): self.assertEqual(self.redmine.issue.resource_class.redmine_version, '1.0') def test_issue_get(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) self.assertEqual(issue.id, 1) self.assertEqual(issue.subject, 'Foo') def test_issue_all(self): self.response.json.return_value = responses['issue']['all'] issues = self.redmine.issue.all() self.assertEqual(issues[0].id, 1) self.assertEqual(issues[0].subject, 'Foo') self.assertEqual(issues[1].id, 2) self.assertEqual(issues[1].subject, 'Bar') def test_issue_filter(self): self.response.json.return_value = responses['issue']['filter'] issues = self.redmine.issue.filter(project_id=1) self.assertEqual(issues[0].id, 1) self.assertEqual(issues[0].subject, 'Foo') self.assertEqual(issues[1].id, 2) self.assertEqual(issues[1].subject, 'Bar') def test_issue_create(self): self.response.status_code = 201 self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.create(project_id='bar', subject='Foo', version_id=1, checklists=[]) self.assertEqual(issue.id, 1) self.assertEqual(issue.subject, 'Foo') def test_issue_delete(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) self.response.content = '' self.assertEqual(issue.delete(), True) self.assertEqual(self.redmine.issue.delete(1), True) def test_issue_update(self): self.response.json.return_value = { 'issue': {'name': 'Foo', 'id': 1, 'custom_fields': [{'id': 1, 'value': 'foo'}]}} issue = self.redmine.issue.get(1) issue.subject = 'Foo' issue.description = 'foobar' issue.custom_fields = [{'id': 1, 'value': 'bar'}] self.assertIsInstance(issue.save(), resources.Issue) self.assertEqual(issue.custom_fields[0].value, 'bar') def test_issue_relations(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) self.assertIsInstance(issue.relations, resultsets.ResourceSet) self.assertIsInstance(issue.time_entries, resultsets.ResourceSet) def test_issue_includes(self): response_includes = responses['issue']['get'] self.response.json.return_value = response_includes issue = self.redmine.issue.get(1) response_includes['issue']['children'] = responses['issue']['all']['issues'] self.response.json.return_value = response_includes self.assertIsInstance(issue.children, resultsets.ResourceSet) response_includes['issue']['attachments'] = responses['attachment']['get'] self.response.json.return_value = response_includes self.assertIsInstance(issue.attachments, resultsets.ResourceSet) response_includes['issue']['relations'] = responses['issue_relation']['get']['relation'] self.response.json.return_value = response_includes self.assertIsInstance(issue.relations, resultsets.ResourceSet) response_includes['issue']['journals'] = [{'id': 1}, {'id': 2}] self.response.json.return_value = response_includes self.assertIsInstance(issue.journals, resultsets.ResourceSet) response_includes['issue']['watchers'] = responses['user']['all']['users'] self.response.json.return_value = response_includes self.assertIsInstance(issue.watchers, resultsets.ResourceSet) def test_issue_add_watcher_raises_exception_if_wrong_version(self): self.response.json.return_value = responses['issue']['get'] self.redmine.ver = '2.2.0' issue = self.redmine.issue.get(1) self.assertRaises(exceptions.ResourceVersionMismatchError, lambda: issue.watcher.add(1)) def test_issue_add_watcher(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) self.response.content = '' self.assertEqual(issue.watcher.add(1), True) def test_issue_remove_watcher(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) self.response.content = '' self.assertEqual(issue.watcher.remove(1), True) def test_issue_custom_repr(self): self.response.json.return_value = responses['issue']['get'] self.assertEqual(repr(self.redmine.issue.get(1)), '') def test_issue_custom_repr_without_subject(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) del issue['_decoded_attrs']['subject'] self.assertEqual(repr(issue), '') def test_issue_custom_str(self): self.response.json.return_value = responses['issue']['get'] self.assertEqual(str(self.redmine.issue.get(1)), 'Foo') def test_issue_custom_str_without_subject(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) del issue['_decoded_attrs']['subject'] self.assertEqual(str(issue), '1') def test_issue_journals(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) issue._decoded_attrs['journals'] = [{'id': 1}] self.assertEqual(str(issue.journals[0]), '1') self.assertEqual(repr(issue.journals[0]), '') def test_issue_journals_url(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) issue._decoded_attrs['journals'] = [{'id': 1}] self.assertEqual(issue.journals[0].url, None) def test_issue_version_can_be_retrieved_via_version_attribute(self): self.response.json.return_value = { 'issue': {'subject': 'Foo', 'id': 1, 'fixed_version': {'id': 1, 'name': 'Foo'}}} issue = self.redmine.issue.get(1) self.assertIsInstance(issue.version, resources.Version) def test_issue_version_can_be_set_via_version_attribute(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) issue.version_id = 1 self.assertEqual(issue.fixed_version.id, 1) def test_issue_assigned_to_id_can_be_removed_via_none(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) issue.assigned_to_id = None self.assertEqual(issue.assigned_to_id, '') def test_issue_assigned_to_id_can_be_removed_via_zero(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) issue.assigned_to_id = 0 self.assertEqual(issue.assigned_to_id, '') def test_issue_is_new(self): issue = self.redmine.issue.new() self.assertEqual(int(issue), 0) self.assertEqual(str(issue), '') self.assertEqual(repr(issue), '') def test_issue_url(self): self.response.json.return_value = responses['issue']['get'] self.assertEqual(self.redmine.issue.get(1).url, '{0}/issues/1'.format(self.url)) @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_issue_export(self): self.response.json.return_value = responses['issue']['all'] self.response.iter_content = lambda chunk_size: (str(num) for num in range(0, 5)) self.assertEqual(self.redmine.issue.all().export('txt', '/foo/bar'), '/foo/bar/issues.txt') self.response.json.return_value = responses['issue']['get'] self.assertEqual(self.redmine.issue.get(1).export('txt', '/foo/bar'), '/foo/bar/1.txt') def test_issue_parent_converts_to_resource(self): self.response.json.return_value = {'issue': {'subject': 'Foo', 'id': 1, 'parent': {'id': 2}}} parent = self.redmine.issue.get(1).parent self.assertIsInstance(parent, resources.Issue) self.assertEqual(parent.id, 2) def test_time_entry_version(self): self.assertEqual(self.redmine.time_entry.resource_class.redmine_version, '1.1') def test_time_entry_get(self): self.response.json.return_value = responses['time_entry']['get'] time_entry = self.redmine.time_entry.get(1) self.assertEqual(time_entry.id, 1) self.assertEqual(time_entry.hours, 2) def test_time_entry_all(self): self.response.json.return_value = responses['time_entry']['all'] time_entries = self.redmine.time_entry.all() self.assertEqual(time_entries[0].id, 1) self.assertEqual(time_entries[0].hours, 3) self.assertEqual(time_entries[1].id, 2) self.assertEqual(time_entries[1].hours, 4) def test_time_entry_filter(self): self.response.json.return_value = responses['time_entry']['filter'] time_entries = self.redmine.time_entry.filter(issue_id=1) self.assertEqual(time_entries[0].id, 1) self.assertEqual(time_entries[0].hours, 3) self.assertEqual(time_entries[1].id, 2) self.assertEqual(time_entries[1].hours, 4) def test_time_entry_create(self): self.response.status_code = 201 self.response.json.return_value = responses['time_entry']['get'] time_entry = self.redmine.time_entry.create(issue_id=1, hours=2) self.assertEqual(time_entry.id, 1) self.assertEqual(time_entry.hours, 2) def test_time_entry_delete(self): self.response.json.return_value = responses['time_entry']['get'] time_entry = self.redmine.time_entry.get(1) self.response.content = '' self.assertEqual(time_entry.delete(), True) self.assertEqual(self.redmine.time_entry.delete(1), True) def test_time_entry_update(self): self.response.json.return_value = { 'time_entry': {'hours': 2, 'id': 1, 'issue': {'id': 1}, 'activity': {'id': 1}}} time_entry = self.redmine.time_entry.get(1) time_entry.hours = 3 time_entry.issue_id = 2 time_entry.activity_id = 2 self.assertIsInstance(time_entry.save(), resources.TimeEntry) def test_time_entry_translate_params(self): manager = self.redmine.time_entry manager.filter(from_date='2013-12-30', to_date='2013-12-31') self.assertIn('from', manager.params) self.assertIn('to', manager.params) def test_time_entry_custom_str(self): self.response.json.return_value = responses['time_entry']['get'] self.assertEqual(str(self.redmine.time_entry.get(1)), '1') def test_time_entry_custom_repr(self): self.response.json.return_value = responses['time_entry']['get'] self.assertEqual(repr(self.redmine.time_entry.get(1)), '') def test_time_entry_is_new(self): time_entry = self.redmine.time_entry.new() self.assertEqual(int(time_entry), 0) self.assertEqual(str(time_entry), '0') self.assertEqual(repr(time_entry), '') def test_time_entry_url(self): self.response.json.return_value = responses['time_entry']['get'] self.assertEqual(self.redmine.time_entry.get(1).url, '{0}/time_entries/1'.format(self.url)) @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_time_entry_export(self): self.response.json.return_value = responses['time_entry']['all'] self.response.iter_content = lambda chunk_size: (str(num) for num in range(0, 5)) self.assertEqual(self.redmine.time_entry.all().export('txt', '/foo/bar'), '/foo/bar/time_entries.txt') def test_enumeration_version(self): self.assertEqual(self.redmine.enumeration.resource_class.redmine_version, '2.2') def test_enumeration_get(self): self.response.json.return_value = responses['enumeration']['filter'] enumeration = self.redmine.enumeration.get(1, resource='time_entry_activities') self.assertEqual(enumeration.id, 1) self.assertEqual(enumeration.name, 'Foo') def test_enumeration_filter(self): self.response.json.return_value = responses['enumeration']['filter'] enumerations = self.redmine.enumeration.filter(resource='time_entry_activities') self.assertEqual(enumerations[0].id, 1) self.assertEqual(enumerations[0].name, 'Foo') self.assertEqual(enumerations[1].id, 2) self.assertEqual(enumerations[1].name, 'Bar') def test_enumeration_url(self): self.response.json.return_value = responses['enumeration']['filter'] self.assertEqual( self.redmine.enumeration.filter(resource='time_entry_activities')[0].url, '{0}/enumerations/1/edit'.format(self.url) ) def test_attachment_version(self): self.assertEqual(self.redmine.attachment.resource_class.redmine_version, '1.3') def test_attachment_get(self): self.response.json.return_value = responses['attachment']['get'] attachment = self.redmine.attachment.get(1) self.assertEqual(attachment.id, 1) self.assertEqual(attachment.filename, 'foo.jpg') def test_attachment_update(self): self.response.json.return_value = responses['attachment']['get'] attachment = self.redmine.attachment.get(1) attachment.filename = 'bar.jpg' self.assertIsInstance(attachment.save(), resources.Attachment) def test_attachment_delete(self): self.response.json.return_value = responses['attachment']['get'] attachment = self.redmine.attachment.get(1) self.response.content = '' self.assertEqual(attachment.delete(), True) self.assertEqual(self.redmine.attachment.delete(1), True) def test_attachment_custom_str(self): self.response.json.return_value = responses['attachment']['get'] self.assertEqual(str(self.redmine.attachment.get(1)), 'foo.jpg') def test_attachment_custom_str_without_filename(self): self.response.json.return_value = responses['attachment']['get'] attachment = self.redmine.attachment.get(1) del attachment['_decoded_attrs']['filename'] self.assertEqual(str(attachment), '1') def test_attachment_custom_repr(self): self.response.json.return_value = responses['attachment']['get'] self.assertEqual(repr(self.redmine.attachment.get(1)), '') def test_attachment_custom_repr_without_subject(self): self.response.json.return_value = responses['attachment']['get'] attachment = self.redmine.attachment.get(1) del attachment['_decoded_attrs']['filename'] self.assertEqual(repr(attachment), '') def test_attachment_url(self): self.response.json.return_value = responses['attachment']['get'] self.assertEqual(self.redmine.attachment.get(1).url, '{0}/attachments/1'.format(self.url)) @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_attachment_download(self): response = responses['attachment']['get'] response['attachment']['content_url'] = 'http://foo/bar.txt' self.response.json.return_value = response self.response.iter_content = lambda chunk_size: (str(num) for num in range(0, 5)) self.assertEqual(self.redmine.attachment.get(1).download('/some/path'), '/some/path/bar.txt') def test_file_version(self): self.assertEqual(self.redmine.file.resource_class.redmine_version, '3.4') def test_file_get(self): self.response.json.return_value = responses['attachment']['get'] f = self.redmine.file.get(1) self.assertEqual(f.id, 1) self.assertEqual(f.filename, 'foo.jpg') def test_file_filter(self): self.response.json.return_value = responses['file']['filter'] files = self.redmine.file.filter(project_id=1) self.assertEqual(files[0].id, 1) self.assertEqual(files[0].filename, 'foo.jpg') self.assertEqual(files[1].id, 2) self.assertEqual(files[1].filename, 'bar.jpg') @mock.patch('os.path.isfile', mock.Mock()) @mock.patch('os.path.getsize', mock.Mock()) @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_file_create(self): self.set_patch_side_effect([ mock.Mock(status_code=201, history=[], **{'json.return_value': {'upload': {'id': 1, 'token': '1.1234'}}}), mock.Mock(status_code=200, history=[], content='') ]) f = self.redmine.file.create(project_id=1, filename='foo.jpg', path='foo', return_complete=False) self.assertEqual(f.id, 1) self.assertRaises(exceptions.ResourceAttrError, lambda: f.filename) def test_file_update(self): self.response.json.return_value = responses['attachment']['get'] f = self.redmine.file.get(1) f.filename = 'bar.jpg' self.assertIsInstance(f.save(), resources.File) def test_file_delete(self): self.response.json.return_value = responses['attachment']['get'] f = self.redmine.file.get(1) self.response.content = '' self.assertEqual(f.delete(), True) self.assertEqual(self.redmine.file.delete(1), True) def test_file_custom_str(self): self.response.json.return_value = responses['attachment']['get'] self.assertEqual(str(self.redmine.file.get(1)), 'foo.jpg') def test_file_custom_str_without_filename(self): self.response.json.return_value = responses['attachment']['get'] f = self.redmine.file.get(1) del f['_decoded_attrs']['filename'] self.assertEqual(str(f), '1') def test_file_custom_repr(self): self.response.json.return_value = responses['attachment']['get'] self.assertEqual(repr(self.redmine.file.get(1)), '') def test_file_custom_repr_without_subject(self): self.response.json.return_value = responses['attachment']['get'] f = self.redmine.file.get(1) del f['_decoded_attrs']['filename'] self.assertEqual(repr(f), '') def test_file_url(self): self.response.json.return_value = responses['attachment']['get'] self.assertEqual(self.redmine.file.get(1).url, '{0}/attachments/1'.format(self.url)) @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_file_download(self): response = responses['attachment']['get'] response['attachment']['content_url'] = 'http://foo/bar.txt' self.response.json.return_value = response self.response.iter_content = lambda chunk_size: (str(num) for num in range(0, 5)) self.assertEqual(self.redmine.file.get(1).download('/some/path'), '/some/path/bar.txt') def test_wiki_page_version(self): self.assertEqual(self.redmine.wiki_page.resource_class.redmine_version, '2.2') def test_wiki_page_get(self): self.response.json.return_value = responses['wiki_page']['get'] wiki_page = self.redmine.wiki_page.get('Foo', project_id=1) self.assertEqual(wiki_page.title, 'Foo') def test_wiki_page_filter(self): self.response.json.return_value = responses['wiki_page']['filter'] wiki_pages = self.redmine.wiki_page.filter(project_id=1) self.assertEqual(wiki_pages[0].title, 'Foo') self.assertEqual(wiki_pages[1].title, 'Bar') def test_wiki_page_create(self): self.response.status_code = 201 self.response.json.return_value = responses['wiki_page']['get'] wiki_page = self.redmine.wiki_page.create(project_id='foo', title='Foo') self.assertEqual(wiki_page.title, 'Foo') def test_wiki_page_delete(self): self.response.json.return_value = responses['wiki_page']['get'] wiki_page = self.redmine.wiki_page.get('Foo', project_id=1) self.response.content = '' self.assertEqual(wiki_page.delete(), True) self.assertEqual(self.redmine.wiki_page.delete('Foo', project_id=1), True) def test_wiki_page_update(self): self.response.json.return_value = \ {'wiki_page': {'title': 'Foo', 'version': 1, 'created_on': '2012-06-27T12:48:15Z'}} wiki_page = self.redmine.wiki_page.get('Foo', project_id=1) wiki_page.text = 'Foo' self.assertIsInstance(wiki_page.save(), resources.WikiPage) self.assertEqual(wiki_page.version, 2) def test_wiki_page_refresh_by_title(self): self.response.json.return_value = responses['wiki_page']['get'] wiki_page = self.redmine.wiki_page.get('Foo', project_id=1) self.assertEqual(wiki_page.title, 'Foo') self.response.json.return_value = {'wiki_page': {'title': 'Bar'}} wiki_page.refresh() self.assertEqual(wiki_page.title, 'Bar') def test_wiki_page_refreshes_itself_if_text_attribute_not_exists(self): self.response.json.return_value = {'wiki_page': {'title': 'Foo', 'created_on': '2012-06-27T12:48:15Z'}} wiki_page = self.redmine.wiki_page.get('Foo', project_id=1) self.response.json.return_value = {'wiki_page': {'title': 'Foo', 'text': 'foo'}} self.assertEqual(wiki_page.text, 'foo') def test_wiki_page_supports_internal_id(self): self.response.json.return_value = responses['wiki_page']['get'] self.assertEqual(self.redmine.wiki_page.get('Foo', project_id=1).internal_id, 'Foo') def test_wiki_page_custom_int(self): self.response.json.return_value = responses['wiki_page']['get'] self.assertEqual(int(self.redmine.wiki_page.get('Foo', project_id=1)), 1) def test_wiki_page_custom_str(self): self.response.json.return_value = responses['wiki_page']['get'] self.assertEqual(str(self.redmine.wiki_page.get('Foo', project_id=1)), 'Foo') def test_wiki_page_custom_repr(self): self.response.json.return_value = responses['wiki_page']['get'] self.assertEqual(repr(self.redmine.wiki_page.get('Foo', project_id=1)), '') def test_wiki_page_includes(self): response_includes = responses['wiki_page']['get'] self.response.json.return_value = response_includes wiki_page = self.redmine.wiki_page.get('Foo', project_id=1) response_includes['wiki_page']['attachments'] = responses['attachment']['get']['attachment'] self.response.json.return_value = response_includes self.assertIsInstance(wiki_page.attachments, resultsets.ResourceSet) def test_wiki_page_is_new(self): wiki_page = self.redmine.wiki_page.new() self.assertEqual(int(wiki_page), 0) self.assertEqual(str(wiki_page), '') self.assertEqual(repr(wiki_page), '') def test_wiki_page_url(self): self.response.json.return_value = responses['wiki_page']['get'] self.assertEqual( self.redmine.wiki_page.get('Foo', project_id='Foo').url, '{0}/projects/Foo/wiki/Foo'.format(self.url) ) @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_wiki_page_export(self): self.response.json.return_value = responses['wiki_page']['get'] self.response.iter_content = lambda chunk_size: (str(num) for num in range(0, 5)) self.assertEqual(self.redmine.wiki_page.get('Foo', project_id='Foo').export('txt', '/foo'), '/foo/Foo.txt') def test_wiki_page_parent_converts_to_resource(self): self.response.json.return_value = {'wiki_page': {'title': 'Foo', 'project_id': 1, 'parent': {'title': 'Bar'}}} parent = self.redmine.wiki_page.get('Foo', project_id=1).parent self.assertIsInstance(parent, resources.WikiPage) self.assertEqual(parent.title, 'Bar') def test_project_membership_version(self): self.assertEqual(self.redmine.project_membership.resource_class.redmine_version, '1.4') def test_project_membership_get(self): self.response.json.return_value = responses['project_membership']['get'] membership = self.redmine.project_membership.get(1) self.assertEqual(membership.id, 1) def test_project_membership_filter(self): self.response.json.return_value = responses['project_membership']['filter'] memberships = self.redmine.project_membership.filter(project_id=1) self.assertEqual(memberships[0].id, 1) self.assertEqual(memberships[1].id, 2) def test_project_membership_create(self): self.response.status_code = 201 self.response.json.return_value = responses['project_membership']['get'] membership = self.redmine.project_membership.create(project_id='foo', user_id=1, role_ids=[1, 2]) self.assertEqual(membership.id, 1) def test_project_membership_delete(self): self.response.json.return_value = responses['project_membership']['get'] membership = self.redmine.project_membership.get(1) self.response.content = '' self.assertEqual(membership.delete(), True) self.assertEqual(self.redmine.project_membership.delete(1), True) def test_project_membership_update(self): self.response.json.return_value = responses['project_membership']['get'] membership = self.redmine.project_membership.get(1) membership.role_ids = [1, 2] self.assertIsInstance(membership.save(), resources.ProjectMembership) self.assertEqual(membership.roles[0].id, 1) self.assertEqual(membership.roles[1].id, 2) def test_project_membership_custom_str(self): self.response.json.return_value = responses['project_membership']['get'] self.assertEqual(str(self.redmine.project_membership.get(1)), '1') def test_project_membership_custom_repr(self): self.response.json.return_value = responses['project_membership']['get'] self.assertEqual(repr(self.redmine.project_membership.get(1)), '') def test_project_membership_is_new(self): membership = self.redmine.project_membership.new() self.assertEqual(int(membership), 0) self.assertEqual(str(membership), '0') self.assertEqual(repr(membership), '') def test_project_membership_url(self): self.response.json.return_value = responses['project_membership']['get'] self.assertEqual(self.redmine.project_membership.get(1).url, '{0}/memberships/1'.format(self.url)) def test_issue_category_version(self): self.assertEqual(self.redmine.issue_category.resource_class.redmine_version, '1.3') def test_issue_category_get(self): self.response.json.return_value = responses['issue_category']['get'] issue_category = self.redmine.issue_category.get(1) self.assertEqual(issue_category.id, 1) self.assertEqual(issue_category.name, 'Foo') def test_issue_category_filter(self): self.response.json.return_value = responses['issue_category']['filter'] categories = self.redmine.issue_category.filter(project_id=1) self.assertEqual(categories[0].id, 1) self.assertEqual(categories[0].name, 'Foo') self.assertEqual(categories[1].id, 2) self.assertEqual(categories[1].name, 'Bar') def test_issue_category_create(self): self.response.status_code = 201 self.response.json.return_value = responses['issue_category']['get'] category = self.redmine.issue_category.create(project_id='foo', name='Foo') self.assertEqual(category.name, 'Foo') def test_issue_category_delete(self): self.response.json.return_value = responses['issue_category']['get'] category = self.redmine.issue_category.get(1) self.response.content = '' self.assertEqual(category.delete(), True) self.assertEqual(self.redmine.issue_category.delete(1), True) def test_issue_category_update(self): self.response.json.return_value = responses['issue_category']['get'] category = self.redmine.issue_category.get(1) category.name = 'Bar' self.assertIsInstance(category.save(), resources.IssueCategory) def test_issue_category_is_new(self): category = self.redmine.issue_category.new() self.assertEqual(int(category), 0) self.assertEqual(str(category), '') self.assertEqual(repr(category), '') def test_issue_category_url(self): self.response.json.return_value = responses['issue_category']['get'] self.assertEqual(self.redmine.issue_category.get(1).url, '{0}/issue_categories/1'.format(self.url)) def test_issue_relation_version(self): self.assertEqual(self.redmine.issue_relation.resource_class.redmine_version, '1.3') def test_issue_relation_get(self): self.response.json.return_value = responses['issue_relation']['get'] relation = self.redmine.issue_relation.get(1) self.assertEqual(relation.id, 1) def test_issue_relation_filter(self): self.response.json.return_value = responses['issue_relation']['filter'] relations = self.redmine.issue_relation.filter(issue_id=1) self.assertEqual(relations[0].id, 1) self.assertEqual(relations[1].id, 2) def test_issue_relation_create(self): self.response.status_code = 201 self.response.json.return_value = responses['issue_relation']['get'] relation = self.redmine.issue_relation.create(issue_id=1, issue_to_id=2) self.assertEqual(relation.id, 1) def test_issue_relation_delete(self): self.response.json.return_value = responses['issue_relation']['get'] relation = self.redmine.issue_relation.get(1) self.response.content = '' self.assertEqual(relation.delete(), True) self.assertEqual(self.redmine.issue_relation.delete(1), True) def test_issue_relation_custom_str(self): self.response.json.return_value = responses['issue_relation']['get'] self.assertEqual(str(self.redmine.issue_relation.get(1)), '1') def test_issue_relation_custom_repr(self): self.response.json.return_value = responses['issue_relation']['get'] self.assertEqual(repr(self.redmine.issue_relation.get(1)), '') def test_issue_relation_is_new(self): relation = self.redmine.issue_relation.new() self.assertEqual(int(relation), 0) self.assertEqual(str(relation), '0') self.assertEqual(repr(relation), '') def test_issue_relation_url(self): self.response.json.return_value = responses['issue_relation']['get'] self.assertEqual(self.redmine.issue_relation.get(1).url, '{0}/relations/1'.format(self.url)) def test_version_version(self): self.assertEqual(self.redmine.version.resource_class.redmine_version, '1.3') def test_version_get(self): self.response.json.return_value = responses['version']['get'] version = self.redmine.version.get(1) self.assertEqual(version.id, 1) self.assertEqual(version.name, 'Foo') def test_version_filter(self): self.response.json.return_value = responses['version']['filter'] versions = self.redmine.version.filter(project_id=1) self.assertEqual(versions[0].id, 1) self.assertEqual(versions[0].name, 'Foo') self.assertEqual(versions[1].id, 2) self.assertEqual(versions[1].name, 'Bar') def test_version_create(self): self.response.status_code = 201 self.response.json.return_value = responses['version']['get'] version = self.redmine.version.create(project_id='foo', name='Foo') self.assertEqual(version.name, 'Foo') def test_version_delete(self): self.response.json.return_value = responses['version']['get'] version = self.redmine.version.get(1) self.response.content = '' self.assertEqual(version.delete(), True) self.assertEqual(self.redmine.version.delete(1), True) def test_version_update(self): self.response.json.return_value = responses['version']['get'] version = self.redmine.version.get(1) version.name = 'Bar' self.assertIsInstance(version.save(), resources.Version) def test_version_returns_status_without_conversion(self): self.response.json.return_value = {'version': {'id': 1, 'name': 'Foo', 'status': 'foo'}} version = self.redmine.version.get(1) self.assertEqual(version.status, 'foo') def test_version_is_new(self): version = self.redmine.version.new() self.assertEqual(int(version), 0) self.assertEqual(str(version), '') self.assertEqual(repr(version), '') def test_version_url(self): self.response.json.return_value = responses['version']['get'] self.assertEqual(self.redmine.version.get(1).url, '{0}/versions/1'.format(self.url)) def test_user_version(self): self.assertEqual(self.redmine.user.resource_class.redmine_version, '1.1') def test_user_get(self): self.response.json.return_value = responses['user']['get'] user = self.redmine.user.get(1) self.assertEqual(user.id, 1) self.assertEqual(user.firstname, 'John') def test_user_all(self): self.response.json.return_value = responses['user']['all'] users = self.redmine.user.all() self.assertEqual(users[0].id, 1) self.assertEqual(users[0].firstname, 'John') self.assertEqual(users[1].id, 2) self.assertEqual(users[1].firstname, 'Jack') def test_user_filter(self): self.response.json.return_value = responses['user']['filter'] users = self.redmine.user.filter(status_id=2) self.assertEqual(users[0].id, 1) self.assertEqual(users[0].firstname, 'John') self.assertEqual(users[1].id, 2) self.assertEqual(users[1].firstname, 'Jack') def test_user_create(self): self.response.status_code = 201 self.response.json.return_value = responses['user']['get'] user = self.redmine.user.create(firstname='John', lastname='Smith') self.assertEqual(user.firstname, 'John') self.assertEqual(user.lastname, 'Smith') def test_user_delete(self): self.response.json.return_value = responses['user']['get'] user = self.redmine.user.get(1) self.response.content = '' self.assertEqual(user.delete(), True) self.assertEqual(self.redmine.user.delete(1), True) def test_user_update(self): self.response.json.return_value = responses['user']['get'] user = self.redmine.user.get(1) user.lastname = 'Foo' user.firstname = 'Bar' self.assertIsInstance(user.save(), resources.User) def test_user_custom_str(self): self.response.json.return_value = responses['user']['get'] self.assertEqual(str(self.redmine.user.get(1)), 'John Smith') def test_user_custom_repr(self): self.response.json.return_value = responses['user']['get'] self.assertEqual(repr(self.redmine.user.get(1)), '') def test_user_relations(self): self.response.json.return_value = responses['user']['get'] user = self.redmine.user.get(1) self.assertIsInstance(user.issues, resultsets.ResourceSet) self.assertIsInstance(user.time_entries, resultsets.ResourceSet) def test_user_includes(self): response_includes = responses['user']['get'] self.response.json.return_value = response_includes user = self.redmine.user.get(1) response_includes['user']['memberships'] = responses['project_membership']['filter']['memberships'] self.response.json.return_value = response_includes self.assertIsInstance(user.memberships, resultsets.ResourceSet) response_includes['user']['groups'] = responses['group']['all']['groups'] self.response.json.return_value = response_includes self.assertIsInstance(user.groups, resultsets.ResourceSet) def test_user_returns_status_without_conversion(self): self.response.json.return_value = {'user': {'firstname': 'John', 'lastname': 'Smith', 'id': 1, 'status': 1}} user = self.redmine.user.get(1) self.assertEqual(user.status, 1) def test_user_is_new(self): user = self.redmine.user.new() self.assertEqual(int(user), 0) self.assertEqual(str(user), '') self.assertEqual(repr(user), '') def test_user_url(self): self.response.json.return_value = responses['user']['get'] self.assertEqual(self.redmine.user.get(1).url, '{0}/users/1'.format(self.url)) def test_group_version(self): self.assertEqual(self.redmine.group.resource_class.redmine_version, '2.1') def test_group_get(self): self.response.json.return_value = responses['group']['get'] group = self.redmine.group.get(1) self.assertEqual(group.id, 1) self.assertEqual(group.name, 'Foo') def test_group_all(self): self.response.json.return_value = responses['group']['all'] groups = self.redmine.group.all() self.assertEqual(groups[0].id, 1) self.assertEqual(groups[0].name, 'Foo') self.assertEqual(groups[1].id, 2) self.assertEqual(groups[1].name, 'Bar') def test_group_create(self): self.response.status_code = 201 self.response.json.return_value = responses['group']['get'] group = self.redmine.group.create(name='Foo') self.assertEqual(group.name, 'Foo') def test_group_delete(self): self.response.json.return_value = responses['group']['get'] group = self.redmine.group.get(1) self.response.content = '' self.assertEqual(group.delete(), True) self.assertEqual(self.redmine.group.delete(1), True) def test_group_update(self): self.response.json.return_value = responses['group']['get'] group = self.redmine.group.get(1) group.name = 'Bar' self.assertIsInstance(group.save(), resources.Group) def test_group_includes(self): response_includes = responses['group']['get'] self.response.json.return_value = response_includes group = self.redmine.group.get(1) response_includes['group']['memberships'] = responses['project_membership']['filter']['memberships'] self.response.json.return_value = response_includes self.assertIsInstance(group.memberships, resultsets.ResourceSet) response_includes['group']['users'] = responses['user']['all']['users'] self.response.json.return_value = response_includes self.assertIsInstance(group.users, resultsets.ResourceSet) def test_group_add_user(self): self.response.json.return_value = responses['group']['get'] group = self.redmine.group.get(1) self.response.content = '' self.assertEqual(group.user.add(1), True) def test_group_remove_user(self): self.response.json.return_value = responses['group']['get'] group = self.redmine.group.get(1) self.response.content = '' self.assertEqual(group.user.remove(1), True) def test_group_is_new(self): group = self.redmine.group.new() self.assertEqual(int(group), 0) self.assertEqual(str(group), '') self.assertEqual(repr(group), '') def test_group_url(self): self.response.json.return_value = responses['group']['get'] self.assertEqual(self.redmine.group.get(1).url, '{0}/groups/1'.format(self.url)) def test_role_version(self): self.assertEqual(self.redmine.role.resource_class.redmine_version, '1.4') def test_role_get(self): self.response.json.return_value = responses['role']['get'] role = self.redmine.role.get(1) self.assertEqual(role.id, 1) self.assertEqual(role.name, 'Foo') def test_role_all(self): self.response.json.return_value = responses['role']['all'] roles = self.redmine.role.all() self.assertEqual(roles[0].id, 1) self.assertEqual(roles[0].name, 'Foo') self.assertEqual(roles[1].id, 2) self.assertEqual(roles[1].name, 'Bar') def test_role_url(self): self.response.json.return_value = responses['role']['get'] self.assertEqual(self.redmine.role.get(1).url, '{0}/roles/1'.format(self.url)) def test_news_version(self): self.assertEqual(self.redmine.news.resource_class.redmine_version, '1.1') def test_news_get(self): self.response.json.return_value = responses['news']['all'] news = self.redmine.news.get(1) self.assertEqual(news.id, 1) self.assertEqual(news.title, 'Foo') def test_news_all(self): self.response.json.return_value = responses['news']['all'] news = self.redmine.news.all() self.assertEqual(news[0].id, 1) self.assertEqual(news[0].title, 'Foo') self.assertEqual(news[1].id, 2) self.assertEqual(news[1].title, 'Bar') def test_news_filter(self): self.response.json.return_value = responses['news']['filter'] news = self.redmine.news.filter(project_id=1) self.assertEqual(news[0].id, 1) self.assertEqual(news[0].title, 'Foo') self.assertEqual(news[1].id, 2) self.assertEqual(news[1].title, 'Bar') def test_news_url(self): self.response.json.return_value = responses['news']['filter'] self.assertEqual(self.redmine.news.filter(project_id=1)[0].url, '{0}/news/1'.format(self.url)) @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_news_export(self): self.response.json.return_value = responses['news']['all'] self.response.iter_content = lambda chunk_size: (str(num) for num in range(0, 5)) self.assertEqual(self.redmine.news.all().export('txt', '/foo/bar'), '/foo/bar/news.txt') def test_news_str(self): self.response.json.return_value = responses['news']['filter'] self.assertEqual(str(self.redmine.news.filter(project_id=1)[0]), 'Foo') def test_news_repr(self): self.response.json.return_value = responses['news']['filter'] self.assertEqual(repr(self.redmine.news.filter(project_id=1)[0]), '') def test_issue_status_version(self): self.assertEqual(self.redmine.issue_status.resource_class.redmine_version, '1.3') def test_issue_status_get(self): self.response.json.return_value = responses['issue_status']['all'] status = self.redmine.issue_status.get(1) self.assertEqual(status.id, 1) self.assertEqual(status.name, 'Foo') def test_issue_status_all(self): self.response.json.return_value = responses['issue_status']['all'] statuses = self.redmine.issue_status.all() self.assertEqual(statuses[0].id, 1) self.assertEqual(statuses[0].name, 'Foo') self.assertEqual(statuses[1].id, 2) self.assertEqual(statuses[1].name, 'Bar') def test_issue_status_url(self): self.response.json.return_value = responses['issue_status']['all'] self.assertEqual(self.redmine.issue_status.all()[0].url, '{0}/issue_statuses/1/edit'.format(self.url)) def test_tracker_version(self): self.assertEqual(self.redmine.tracker.resource_class.redmine_version, '1.3') def test_tracker_get(self): self.response.json.return_value = responses['tracker']['all'] tracker = self.redmine.tracker.get(1) self.assertEqual(tracker.id, 1) self.assertEqual(tracker.name, 'Foo') def test_tracker_all(self): self.response.json.return_value = responses['tracker']['all'] trackers = self.redmine.tracker.all() self.assertEqual(trackers[0].id, 1) self.assertEqual(trackers[0].name, 'Foo') self.assertEqual(trackers[1].id, 2) self.assertEqual(trackers[1].name, 'Bar') def test_tracker_url(self): self.response.json.return_value = responses['tracker']['all'] self.assertEqual(self.redmine.tracker.all()[0].url, '{0}/trackers/1/edit'.format(self.url)) def test_query_version(self): self.assertEqual(self.redmine.query.resource_class.redmine_version, '1.3') def test_query_get(self): self.response.json.return_value = responses['query']['all'] query = self.redmine.query.get(1) self.assertEqual(query.id, 1) self.assertEqual(query.name, 'Foo') def test_query_all(self): self.response.json.return_value = responses['query']['all'] queries = self.redmine.query.all() self.assertEqual(queries[0].id, 1) self.assertEqual(queries[0].name, 'Foo') self.assertEqual(queries[1].id, 2) self.assertEqual(queries[1].name, 'Bar') def test_query_url(self): self.response.json.return_value = responses['query']['all'] self.assertEqual(self.redmine.query.all()[0].url, '{0}/projects/0/issues?query_id=1'.format(self.url)) def test_custom_field_version(self): self.assertEqual(self.redmine.custom_field.resource_class.redmine_version, '2.4') def test_custom_field_get(self): self.response.json.return_value = responses['custom_field']['all'] field = self.redmine.custom_field.get(1) self.assertEqual(field.id, 1) self.assertEqual(field.name, 'Foo') def test_custom_field_all(self): self.response.json.return_value = responses['custom_field']['all'] fields = self.redmine.custom_field.all() self.assertEqual(fields[0].id, 1) self.assertEqual(fields[0].name, 'Foo') self.assertEqual(fields[1].id, 2) self.assertEqual(fields[1].name, 'Bar') def test_custom_field_return_value_even_if_there_is_none(self): self.response.json.return_value = responses['custom_field']['all'] fields = self.redmine.custom_field.all() self.assertEqual(fields[0].id, 1) self.assertEqual(fields[0].name, 'Foo') self.assertEqual(fields[0].value, '') def test_custom_field_returns_single_tracker_instead_of_multiple_trackers(self): self.response.json.return_value = { 'custom_fields': [{'name': 'Foo', 'id': 1, 'trackers': {'tracker': {'id': 1, 'name': 'Bar'}}}]} fields = self.redmine.custom_field.all() self.assertEqual(fields[0].trackers[0].id, 1) self.assertEqual(fields[0].trackers[0].name, 'Bar') def test_custom_field_url(self): self.response.json.return_value = responses['custom_field']['all'] self.assertEqual(self.redmine.custom_field.all()[0].url, '{0}/custom_fields/1/edit'.format(self.url)) python-redmine-2.2.1/MANIFEST.in0000644000076500000240000000013113416350667017516 0ustar maxtepkeevstaff00000000000000recursive-include tests *.py include .coveragerc README.rst CHANGELOG.rst LICENSE NOTICE python-redmine-2.2.1/.coveragerc0000644000076500000240000000014713416342251020076 0ustar maxtepkeevstaff00000000000000[run] source = redminelib [report] show_missing = True omit = */python?.?/* */site-packages/* python-redmine-2.2.1/NOTICE0000644000076500000240000000220013416342542016654 0ustar maxtepkeevstaff00000000000000Python-Redmine includes code from several Python libraries. Six License =========== Copyright (c) 2010-2019 Benjamin Peterson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. python-redmine-2.2.1/setup.py0000644000076500000240000000427213416351405017473 0ustar maxtepkeevstaff00000000000000import sys from setuptools import setup, find_packages from setuptools.command.test import test try: import multiprocessing # https://bugs.python.org/issue15881 except ImportError: pass class NoseTests(test): def finalize_options(self): test.finalize_options(self) self.test_args = [] self.test_suite = True def run_tests(self): import nose nose.run_exit(argv=['nosetests']) tests_require = ['nose', 'coverage'] if sys.version_info[:2] < (3, 3): tests_require.append('mock') exec(open('redminelib/version.py').read()) setup( name='python-redmine', version=globals()['__version__'], packages=find_packages(exclude=('tests', 'tests.*')), url='https://github.com/maxtepkeev/python-redmine', project_urls={ 'Documentation': 'https://python-redmine.com', }, license='Apache 2.0', author='Maxim Tepkeev', author_email='support@python-redmine.com', description='Library for communicating with a Redmine project management application', long_description=open('README.rst').read() + '\n\n' + open('CHANGELOG.rst').read(), keywords='redmine redmineup redminecrm redminelib easyredmine', python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', install_requires=['requests>=2.20.0'], tests_require=tests_require, cmdclass={'test': NoseTests}, zip_safe=False, classifiers=[ 'Development Status :: 5 - Production/Stable', 'License :: OSI Approved :: Apache Software License', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Utilities', 'Topic :: Internet :: WWW/HTTP', 'Intended Audience :: Developers', 'Environment :: Console', 'Environment :: Web Environment', 'Operating System :: OS Independent', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy' ], ) python-redmine-2.2.1/setup.cfg0000644000076500000240000000033513435744437017612 0ustar maxtepkeevstaff00000000000000[nosetests] with-coverage = 1 cover-erase = 1 cover-package = redminelib [metadata] license_files = .coveragerc README.rst CHANGELOG.rst LICENSE NOTICE [bdist_wheel] universal = 1 [egg_info] tag_build = tag_date = 0 python-redmine-2.2.1/README.rst0000644000076500000240000000641013413634420017442 0ustar maxtepkeevstaff00000000000000Python-Redmine ============== .. image:: https://badge.fury.io/py/python-redmine.svg :target: https://badge.fury.io/py/python-redmine .. image:: https://img.shields.io/travis/maxtepkeev/python-redmine/master.svg :target: https://travis-ci.org/maxtepkeev/python-redmine .. image:: https://img.shields.io/coveralls/maxtepkeev/python-redmine/master.svg :target: https://coveralls.io/r/maxtepkeev/python-redmine?branch=master Python-Redmine is a library for communicating with a `Redmine `__ project management application. Redmine exposes some of it's data via `REST API `__ for which Python-Redmine provides a simple but powerful Pythonic API inspired by a well-known `Django ORM `__: .. code-block:: python >>> from redminelib import Redmine >>> redmine = Redmine('http://demo.redmine.org', username='foo', password='bar') >>> project = redmine.project.get('vacation') >>> project.id 30404 >>> project.identifier 'vacation' >>> project.created_on datetime.datetime(2013, 12, 31, 13, 27, 47) >>> project.issues >>> project.issues[0] >>> dir(project.issues[0]) ['assigned_to', 'author', 'created_on', 'description', 'done_ratio', 'due_date', 'estimated_hours', 'id', 'priority', 'project', 'relations', 'start_date', 'status', 'subject', 'time_entries', 'tracker', 'updated_on'] >>> project.issues[0].subject 'Vacation' >>> project.issues[0].time_entries Features -------- * Supports 100% of Redmine API * Supports external Redmine plugins API * Supports Python 2.7, 3.4 - 3.7, PyPy and PyPy3 * Supports different request engines * Extendable via custom resources and custom request engines * Extensively documented * Provides ORM-style Pythonic API * And many more... Installation ------------ Standard Edition ++++++++++++++++ The recommended way to install is from Python Package Index (PyPI) with `pip `__: .. code-block:: bash $ pip install python-redmine Pro Edition +++++++++++ License for a Pro Edition can be bought `here `__. You will receive an email with all the details regarding Pro Edition installation process. Documentation ------------- Documentation is available at https://python-redmine.com. Contacts and Support -------------------- Support for Standard Edition is provided via `GitHub `__ only, while support for Pro Edition is provided both via `GitHub `__ and support@python-redmine.com. Be sure to write from email that was specified during the purchase procedure. Copyright and License --------------------- Python-Redmine Standard Edition is licensed under Apache 2.0 license. Python-Redmine Pro Edition is licensed under the Python-Redmine Pro Edition 1.0 license. Check the `License `__ for details. python-redmine-2.2.1/CHANGELOG.rst0000644000076500000240000007635513435744225020024 0ustar maxtepkeevstaff00000000000000Changelog --------- 2.2.1 (2019-02-28) ++++++++++++++++++ **Bugfixes**: - ProjectMembership resource ``group`` attribute was returned as a dict instead of being converted to Resource object (`Issue #220 `__) (thanks to `Samuel Harmer `__) 2.2.0 (2019-01-13) ++++++++++++++++++ **Improvements**: - ``PerformanceWarning`` will be issued when Python-Redmine does some unnecessary work under the hood to fix the clients code problems **Changes**: - *Backwards Incompatible:* Removed vendored Requests package and make it an external dependency as Requests did the same with it's own dependencies - *Backwards Incompatible:* Removed Python 2.6 and 3.3 support as they're not supported by Requests anymore **Bugfixes**: - ``Redmine.upload()`` fails under certain circumstances when used with a file-like object and it contains unicode instead of bytes (`Issue #216 `__) - ``Redmine.session()`` doesn't restore previous engine if fails (`Issue #211 `__) (thanks to `Dmitry Logvinenko `__) 2.1.1 (2018-05-02) ++++++++++++++++++ - Fix PyPI package 2.1.0 (2018-05-02) ++++++++++++++++++ This release concentrates mostly on stability and adds small features here and there. Some of them are backwards incompatible and are marked as such. They shouldn't affect many users since most of them were used internally by Python-Redmine. A support for the Files API has been finally added, but please be sure to check it's documentation as the implementation on the Redmine side is horrible and there are things to keep in mind while working with Files API. Lastly, only until the end of May 2018 there is a chance to buy a Pro Edition for only 14.99$ instead of the usual 24.99$, this is your chance to get an edition with additional features for a good price and to support the further development of Python-Redmine, more info `here `_. **New Features**: - Files API support (`Issue #117 `__) **Improvements**: - *Backwards Incompatible:* ResourceSet's ``filter()`` method became more advanced. It is now possible to filter on all available resource attributes, to follow resource relationships and apply lookups to the filters (see `docs `__ for details) - ResourceManager class has been refactored: * ``manager_class`` attribute on the ``Resource`` class can now be used to assign a separate ``ResourceManager`` to a resource, that allows outsourcing a resource specific functionality to a separate manager class (see ``WikiPageManager`` as an example) * *Backwards Incompatible:* ``request()`` method has been removed * ``_construct_*_url()``, ``_prepare_*_request()``, ``_process_*_response()`` methods have been added for create, update and delete methods to allow a fine-grained control over these operations - Ability to upload file-like objects (`Issue #186 `__) (thanks to `hjpotter92 `__) - Support for retrieving project's time entry activities (see `docs `__ for details) - Attachment ``update()`` operation support (requires Redmine >= 3.4.0) - ``Resource.save()`` now accepts ``**attrs`` that need to be changed/set and returns ``self`` instead of a boolean ``True``, which makes it chainable, so you can now do something like ``project.save(name='foo', description='bar').export('txt', '/home/foo')`` - ``get`` operation support for News, Query, Enumeration, IssueStatus, Tracker, CustomField, ContactTag, DealStatus, DealCategory and CRMQuery resources - ``include`` param in ``get``, ``all`` and ``filter`` operations now accepts lists and tuples instead of comma-separated string which is still accepted for backward compatibility reasons, i.e. one can use ``include=['foo', 'bar']`` instead of ``include='foo,bar'`` - It is now possible to use ``None`` and ``0`` in addition to ``''`` in ``assigned_to_id`` attribute in Issue resource if an assignee needs to be removed from an issue **Changes**: - *Backwards Incompatible:* Issue ``all`` operation now really returns all issues, i.e. both open and closed, instead of only returning open issues in previous versions due to the respect to Redmine's standard behaviour - *Backwards Incompatible:* Instead of only returning a token string, ``upload()`` method was modified to return a dict that contains all the data for an upload returned from Redmine, i.e. id and token for Redmine >= 3.4.0, token only for Redmine < 3.4.0. Also it is now possible to use this token and pass it using a ``token`` key instead of the ``path`` key with path to the file in ``uploads`` parameter when doing an upload, this gives more control over the uploading process if needed - *Backwards Incompatible:* Removed ``resource_paths`` argument from Redmine object since ``ResourceManager`` now uses a special resource registry, to which, all resources that inherit from any Python-Redmine resource are being automatically added - *Backwards Incompatible:* Removed ``container_many`` in favor of ``container_filter``, ``container_create`` and ``container_update`` attributes on ``Resource`` object to allow more fine-grained resource setup - *Backwards Incompatible:* ``return_raw`` parameter on ``engine.request()`` and ``engine.process_response()`` methods has been removed in favor of ``return_raw_response`` attribute on engine object - Updated bundled requests library to v2.15.1 **Bugfixes**: - Support 204 status code when deleting a resource (`Issue #189 `__) (thanks to `dotSlashLu `__) - Raise ``ValidationError`` instead of not helpful ``TypeError`` exception when trying to create a WikiPage resource that already exists (`Issue #182 `__) - Enumeration, Version, Group and Notes ``custom_fields`` attribute was returned as a list of dicts instead of being converted to ``ResourceSet`` object - Downloads were downloaded fully into memory instead of being streamed as needed - ``ResourceRequirementsError`` exception was broken since v2.0.0 - RedmineUP CRM Contact and Deal resources export functionality didn't work - RedmineUP CRM Contact and Deal resources sometimes weren't converted to Resource objects using Search API **Documentation**: - Mentioned support for ``generate_password`` and ``send_information`` in User's resource create/update methods, ``status`` in User's resource update method, ``parent_id`` in Issue's filter method and ``include`` in Issue's all method 2.0.2 (2017-04-19) ++++++++++++++++++ **Bugfixes**: - Filter doesn't work when there are > 100 resources requested (`Issue #175 `__) (thanks to `niwatolli3 `__) 2.0.1 (2017-04-10) ++++++++++++++++++ - Fix PyPI package 2.0.0 (2017-04-10) ++++++++++++++++++ This version brings a lot of new features and changes, some of them are backward-incompatible, so please look carefully at the changelog below to find out what needs to be changed in your code to make it work with this version. Also Python-Redmine now comes in 2 editions: Standard and Pro, please have a look at this `document `__ for more details. Documentation was also significantly rewritten, so it is recommended to reread it even if you are an experienced Python-Redmine user. **New Features**: - RedmineUP `Checklist plugin `__ support - `Request Engines `__ support. It is now possible to create engines to define how requests to Redmine are made, e.g. synchronous (one by one) or asynchronous using threads or processes etc - ``redmine.session()`` context manager which allows to temporary redefine engine's behaviour - Search API support (`Issue #138 `__) - Export functionality (`Issue #58 `__) - REDMINE_USE_EXTERNAL_REQUESTS environmental variable for emergency cases which allows to use external requests instead of bundled one even if external requests version is lower than the bundled one - Wrong HTTP protocol usage detector, e.g. one use HTTP when HTTPS should be used **Improvements**: - ResourceSet objects were completely rewritten: * ``ResourceSet`` object that was already sliced now supports reslicing * ``ResourceSet`` object's ``delete()``, ``update()``, ``filter()`` and ``get()`` methods have been optimized for speed * ``ResourceSet`` object's ``delete()`` and ``update()`` methods now call the corresponding Resource's ``pre_*()`` and ``post_*()`` methods * ``ResourceSet`` object's ``get()`` and ``filter()`` methods now supports non-integer id's, e.g. WikiPage's title can now be used with it * *Backwards Incompatible:* ``ValuesResourceSet`` class has been removed * *Backwards Incompatible:* ``ResourceSet.values()`` method now returns an iterable of dicts instead of ``ValuesResourceSet`` object * ``ResourceSet.values_list()`` method has been added which returns an iterable of tuples with Resource values or single values if flattened, i.e. ``flat=True`` - New ``Resource`` object methods: * ``delete()`` deletes current resource from Redmine * ``pre_delete()`` and ``post_delete()`` can be used to execute tasks that should be done before/after deleting the resource through ``delete()`` method * ``bulk_decode()``, ``bulk_encode()``, ``decode()`` and ``encode()`` which are used to translate attributes of the resource to/from Python/Redmine - Attachment ``delete()`` method support (requires Redmine >= 3.3.0) - RedmineUP CRM Note resource now provides ``type`` attribute which shows text representation of ``type_id`` - RedmineUP CRM DealStatus resource now provides ``status`` attribute which shows text representation of ``status_type`` - WikiPage resource now provides ``project_id`` attribute - Unicode handling was significantly rewritten and shouldn't cause any more troubles - ``UnknownError`` exception now contains ``status_code`` attribute which can be used to handle the exception instead of parsing code from exception's text - Sync engine's speed improved to 8-12% depending on the amount of resources fetched **Changes**: - *Backwards Incompatible:* Renamed package name from ``redmine`` to ``redminelib`` - Resource class attributes that were previously tuples are now lists - *Backwards Incompatible:* ``_Resource`` class renamed to ``Resource`` - *Backwards Incompatible:* ``Redmine.custom_resource_paths`` keyword argument renamed to ``resource_paths`` - *Backwards Incompatible:* ``Redmine.download()`` method now returns a `requests.Response `__ object directly instead of ``iter_content()`` method if a ``savepath`` param wasn't provided, this gives user even more control over response data - *Backwards Incompatible:* ``Resource.refresh()`` now really refreshes itself instead of returning a new refreshed resource, to get the previous behaviour use ``itself`` param, e.g. ``Resource.refresh(itself=False)`` - *Backwards Incompatible:* Removed Python 3.2 support - *Backwards Incompatible:* Removed ``container_filter``, ``container_create`` and ``container_update`` attributes on ``Resource`` object in favor of ``container_many`` attribute - *Backwards Incompatible:* Removed ``Resource.translate_params()`` and ``ResourceManager.prepare_params()`` in favor of ``Resource.bulk_decode()`` - *Backwards Incompatible:* Removed ``is_unicode()``, ``is_string()`` and ``to_string()`` from ``redminelib.utilities`` - Updated bundled requests library to v2.13.0 **Bugfixes**: - Infinite loop when uploading zero-length files (`Issue #152 `__) - Unsupported Redmine resource error while trying to use Python-Redmine without installation (`Issue #156 `__) - It was impossible to set ``data``, ``params`` and ``headers`` via ``requests`` keyword argument on Redmine object - Calling ``str()`` or ``repr()`` on a Resource was giving incorrect results if exception raising was turned off for a resource **Documentation**: - Switched to the alabaster theme - Added new sections: * `Editions `__ * `Introduction `__ * `Request Engines `__ - Added info about Issue Journals (`Issue #120 `__) - Added note about open/closed issues (`Issue #136 `__) - Added note about regexp custom field filter (`Issue #164 `__) - Added some new information here and there 1.5.1 (2016-03-27) ++++++++++++++++++ - Changed: Updated bundled requests package to 2.9.1 - Changed: `Issue #124 `__ (``project.url`` now uses ``identifier`` rather than ``id`` to generate url for the project resource) - Fixed: `Issue #122 `__ (``ValidationError`` for empty custom field values was possible under some circumstances with Redmine < 2.5.0) - Fixed: `Issue #112 `__ (``UnicodeEncodeError`` on Python 2 if ``resource_id`` was of ``unicode`` type) (thanks to `Digenis `__) 1.5.0 (2015-11-26) ++++++++++++++++++ - Added: Documented support for new fields and values in User, Issue and IssueRelation resources - Added: `Issue #109 `__ (Smart imports for vendored packages (see `docs `__ for details) - Added: `Issue #115 `__ (File upload support for WikiPage resource) 1.4.0 (2015-10-18) ++++++++++++++++++ - Added: `Requests `__ is now embedded into Python-Redmine - Added: Python-Redmine is now embeddable to other libraries - Fixed: Previous release was broken on PyPI 1.3.0 (2015-10-18) ++++++++++++++++++ - Added: `Issue #108 `__ (Tests are now built-in into source package distributed via PyPI) 1.2.0 (2015-07-09) ++++++++++++++++++ - Added: `wheel `__ support - Added: `Issue #93 `__ (``JSONDecodeError`` exception now contains a ``response`` attribute which can be inspected to identify the cause of the exception) - Added: `Issue #98 `__ (Support for setting WikiPage resource parent title and converting parent attribute to Resource object instead of being a dict) 1.1.2 (2015-05-20) ++++++++++++++++++ - Fixed: `Issue #90 `__ (Python-Redmine fails to install on systems with LC_ALL=C) (thanks to `spikergit1 `__) 1.1.1 (2015-03-26) ++++++++++++++++++ - Fixed: `Issue #85 `__ (Python-Redmine was trying to convert field to date/datetime even when it shouldn't, i.e. if a field looked like YYYY-MM-DD but wasn't actually a date/datetime field, e.g. wiki page title or issue subject) 1.1.0 (2015-02-20) ++++++++++++++++++ - Added: PyPy2/3 is now officially supported - Added: Introduced ``enabled_modules`` on demand include in Project resource - Fixed: `Issue #78 `__ (Redmine <2.5.2 returns only single tracker instead of a list of all available trackers when requested from a CustomField resource which caused an Exception in Python-Redmine, see `this `__ for details) - Fixed: `Issue #80 `__ (If a project is read-only or doesn't have CRM plugin enabled, an attempt to add/remove Contact resource to/from it will lead to improper error message) - Fixed: `Issue #81 `__ (Contact's resource ``tag_list`` attribute was always splitted into single chars) (thanks to `Alexander Loechel `__) 1.0.3 (2015-02-03) ++++++++++++++++++ - Fixed: `Issue #72 `__ (If an exception is raised during JSON decoding process, it should be catched and reraised as Python-Redmine's own exception, i.e ``redmine.exceptions.JSONDecodeError``) - Fixed: `Issue #76 `__ (It was impossible to retrieve more than 100 resources for resources which don't support limit/offset natively by Redmine, i.e. this functionality is emulated by Python-Redmine, e.g. WikiPage, Groups, Roles etc) 1.0.2 (2014-11-13) ++++++++++++++++++ - Fixed: `Issue #55 `__ (TypeError was raised during processing validation errors from Redmine when one of the errors was returned as a list) - Fixed: `Issue #59 `__ (Raise ForbiddenError when a 403 is encountered) (thanks to `Rick Harris `__) - Fixed: `Issue #64 `__ (Redmine and Resource classes weren't picklable) (thanks to `Rick Harris `__) - Fixed: A ResourceSet object with a limit=100, actually returned 125 Resource objects 1.0.1 (2014-09-23) ++++++++++++++++++ - Fixed: `Issue #50 `__ (IssueJournal's ``notes`` attribute was converted to Note resource by mistake, bug was introduced in v1.0.0) 1.0.0 (2014-09-22) ++++++++++++++++++ - Added: Support for the `CRM plugin `__ resources: * `Contact `__ * `ContactTag `__ * `Note `__ * `Deal `__ * `DealStatus `__ * `DealCategory `__ * `CrmQuery `__ - Added: Introduced new relations for the following resource objects: * Project - time_entries, deals, contacts and deal_categories relations * User - issues, time_entries, deals and contacts relations * Tracker - issues relation * IssueStatus - issues relation - Added: Introduced a ``values()`` method in a ResourceSet which returns ValuesResourceSet - a ResourceSet subclass that returns dictionaries when used as an iterable, rather than resource-instance objects (see `docs `__ for details) - Added: Introduced ``update()`` and ``delete()`` methods in a ResourceSet object which allow to bulk update or bulk delete all resources in a ResourceSet object (see `docs `__ for details) - Fixed: It was impossible to use ResourceSet's ``get()`` and ``filter()`` methods with WikiPage resource - Fixed: Several small fixes and enhancements here and there 0.9.0 (2014-09-11) ++++++++++++++++++ - Added: Introduced support for file downloads (see `docs `__ for details) - Added: Introduced new ``_Resource.requirements`` class attribute where all Redmine plugins required by resource should be listed (preparations to support non-native resources) - Added: New exceptions: * ResourceRequirementsError - Fixed: It was impossible to set a custom field of date/datetime type using date/datetime Python objects - Fixed: `Issue #46 `__ (A UnicodeEncodeError was raised in Python 2.x while trying to access a ``url`` property of a WikiPage resource if it contained non-ascii characters) 0.8.4 (2014-08-08) ++++++++++++++++++ - Added: Support for anonymous Attachment resource (i.e. attachment with ``id`` attr only) - Fixed: `Issue #42 `__ (It was impossible to create a Project resource via ``new()`` method) 0.8.3 (2014-08-01) ++++++++++++++++++ - Fixed: `Issue #39 `__ (It was impossible to save custom_fields in User resource via ``new()`` method) 0.8.2 (2014-05-27) ++++++++++++++++++ - Added: ResourceSet's ``get()`` method now supports a ``default`` keyword argument which is returned when a requested Resource can't be found in a ResourceSet and defaults to ``None``, previously this was hardcoded to ``None`` - Added: It is now possible to use ``getattr()`` with default value without raising a ``ResourceAttrError`` when calling non-existent resource attribute, see `Issue #30 `__ for details (thanks to `hsum `__) - Fixed: `Issue #31 `__ (Unlimited recursion was possible in some situations when on demand includes were used) 0.8.1 (2014-04-02) ++++++++++++++++++ - Added: New exceptions: * RequestEntityTooLargeError * UnknownError - Fixed: `Issue #27 `__ (Project and Issue resources ``parent`` attribute was returned as a dict instead of being converted to Resource object) 0.8.0 (2014-03-27) ++++++++++++++++++ - Added: Introduced the detection of conflicting packages, i.e. if a conflicting package is found (PyRedmineWS at this time is the only one), the installation procedure will be aborted and a warning message will be shown with the detailed description of the problem - Added: Introduced new ``_Resource._members`` class attribute where all instance attributes which are not started with underscore should be listed. This will resolve recursion issues in custom resources because of how ``__setattr__()`` works in Python - Changed: ``_Resource.attributes`` renamed to ``_Resource._attributes`` - Fixed: Python-Redmine was unable to upload any binary files - Fixed: `Issue #20 `__ (Lowered Requests version requirements. Python-Redmine now requires Requests starting from 0.12.1 instead of 2.1.0 in previous versions) - Fixed: `Issue #23 `__ (File uploads via ``update()`` method didn't work) 0.7.2 (2014-03-17) ++++++++++++++++++ - Fixed: `Issue #19 `__ (Resources obtained via ``filter()`` and ``all()`` methods have incomplete url attribute) - Fixed: Redmine server url with forward slash could cause errors in rare cases - Fixed: Python-Redmine was incorrectly raising ``ResourceAttrError`` when trying to call ``repr()`` on a News resource 0.7.1 (2014-03-14) ++++++++++++++++++ - Fixed: `Issue #16 `__ (When a resource was created via a ``new()`` method, the next resource created after that inherited all the attribute values of the previous resource) 0.7.0 (2014-03-12) ++++++++++++++++++ - Added: WikiPage resource now automatically requests all of it's available attributes from Redmine in case if some of them are not available in an existent resource object - Added: Support for setting date/datetime resource attributes using date/datetime Python objects - Added: Support for using date/datetime Python objects in all ResourceManager methods, i.e. ``new()``, ``create()``, ``update()``, ``delete()``, ``get()``, ``all()``, ``filter()`` - Fixed: `Issue #14 `__ (Python-Redmine was incorrectly raising ``ResourceAttrError`` when trying to call ``repr()``, ``str()`` and ``int()`` on resources, created via ``new()`` method) 0.6.2 (2014-03-09) ++++++++++++++++++ - Fixed: Project resource ``status`` attribute was converted to IssueStatus resource by mistake 0.6.1 (2014-02-27) ++++++++++++++++++ - Fixed: `Issue #10 `__ (Python Redmine was incorrectly raising ``ResourceAttrError`` while creating some resources via ``new()`` method) 0.6.0 (2014-02-19) ++++++++++++++++++ - Added: ``Redmine.auth()`` shortcut for the case if we just want to check if user provided valid auth credentials, can be used for user authentication on external resource based on Redmine user database (see `docs `__ for details) - Fixed: ``JSONDecodeError`` was raised in some Redmine versions during some create/update operations (thanks to `0x55aa `__) - Fixed: User resource ``status`` attribute was converted to IssueStatus resource by mistake 0.5.0 (2014-02-09) ++++++++++++++++++ - Added: An ability to create custom resources which allow to easily redefine the behaviour of existing resources (see `docs `__ for details) - Added: An ability to add/remove watcher to/from issue (see `docs `__ for details) - Added: An ability to add/remove users to/from group (see `docs `__ for details) 0.4.0 (2014-02-08) ++++++++++++++++++ - Added: New exceptions: * ConflictError * ReadonlyAttrError * ResultSetTotalCountError * CustomFieldValueError - Added: Update functionality via ``update()`` and ``save()`` methods for resources (see `docs `__ for details): * User * Group * IssueCategory * Version * TimeEntry * ProjectMembership * WikiPage * Project * Issue - Added: Limit/offset support via ``all()`` and ``filter()`` methods for resources that doesn't support that feature via Redmine: * IssueRelation * Version * WikiPage * IssueStatus * Tracker * Enumeration * IssueCategory * Role * Group * CustomField - Added: On demand includes, e.g. in addition to ``redmine.group.get(1, include='users')`` users for a group can also be retrieved on demand via ``group.users`` if include wasn't set (see `docs `__ for details) - Added: ``total_count`` attribute to ResourceSet object which holds the total number of resources for the current resource type available in Redmine (thanks to `Andrei Avram `__) - Added: An ability to return ``None`` instead of raising a ``ResourceAttrError`` for all or selected resource objects via ``raise_attr_exception`` kwarg on Redmine object (see `docs `__ for details or `Issue #6 `__) - Added: ``pre_create()``, ``post_create()``, ``pre_update()``, ``post_update()`` resource object methods which can be used to execute tasks that should be done before/after creating/updating the resource through ``save()`` method - Added: Allow to create resources in alternative way via ``new()`` method (see `docs `__ for details) - Added: Allow daterange TimeEntry resource filtering via ``from_date`` and ``to_date`` keyword arguments (thanks to `Antoni Aloy `__) - Added: An ability to retrieve Issue version via ``version`` attribute in addition to ``fixed_version`` to be more obvious - Changed: Documentation for resources rewritten from scratch to be more understandable - Fixed: Saving custom fields to Redmine didn't work in some situations - Fixed: Issue's ``fixed_version`` attribute was retrieved as dict instead of Version resource object - Fixed: Resource relations were requested from Redmine every time instead of caching the result after first request - Fixed: `Issue #2 `__ (limit/offset as keyword arguments were broken) - Fixed: `Issue #5 `__ (Version resource ``status`` attribute was converted to IssueStatus resource by mistake) (thanks to `Andrei Avram `__) - Fixed: A lot of small fixes, enhancements and refactoring here and there 0.3.1 (2014-01-23) ++++++++++++++++++ - Added: An ability to pass Requests parameters as a dictionary via ``requests`` keyword argument on Redmine initialization, i.e. Redmine('\http://redmine.url', requests={}). - Fixed: `Issue #1 `__ (unable to connect to Redmine server with invalid ssl certificate). 0.3.0 (2014-01-18) ++++++++++++++++++ - Added: Delete functionality via ``delete()`` method for resources (see `docs `__ for details): * User * Group * IssueCategory * Version * TimeEntry * IssueRelation * ProjectMembership * WikiPage * Project * Issue - Changed: ResourceManager ``get()`` method now raises a ``ValidationError`` exception if required keyword arguments aren't passed 0.2.0 (2014-01-16) ++++++++++++++++++ - Added: New exceptions: * ServerError * NoFileError * ValidationError * VersionMismatchError * ResourceNoFieldsProvidedError * ResourceNotFoundError - Added: Create functionality via ``create()`` method for resources (see `docs `__ for details): * User * Group * IssueCategory * Version * TimeEntry * IssueRelation * ProjectMembership * WikiPage * Project * Issue - Added: File upload support, see ``upload()`` method in Redmine class - Added: Integer representation to all resources, i.e. ``__int__()`` - Added: Informal string representation to all resources, i.e. ``__str__()`` - Changed: Renamed ``version`` attribute to ``redmine_version`` in all resources to avoid name intersections - Changed: ResourceManager ``get()`` method now raises a ``ResourceNotFoundError`` exception if resource wasn't found instead of returning None in previous versions - Changed: reimplemented fix for ``__repr__()`` from 0.1.1 - Fixed: Conversion of issue priorities to enumeration resource object didn't work 0.1.1 (2014-01-10) ++++++++++++++++++ - Added: Python 2.6 support - Changed: WikiPage resource ``refresh()`` method now automatically determines it's project_id - Fixed: Resource representation, i.e. ``__repr__()``, was broken in Python 2.7 - Fixed: ``dir()`` call on a resource object didn't work in Python 3.2 0.1.0 (2014-01-09) ++++++++++++++++++ - Initial release