././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1711898121.8135822 python-redmine-2.5.0/0000755000076500000240000000000014602277012014555 5ustar00maxtepkeevstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1547289769.0 python-redmine-2.5.0/.coveragerc0000644000076500000240000000014713416342251016700 0ustar00maxtepkeevstaff[run] source = redminelib [report] show_missing = True omit = */python?.?/* */site-packages/* ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711896404.0 python-redmine-2.5.0/CHANGELOG.rst0000644000076500000240000011756014602273524016614 0ustar00maxtepkeevstaffChangelog --------- 2.5.0 (2024-03-31) ++++++++++++++++++ **Deprecations**: - Requests version required >= 2.31.0 **New Features**: - *Pro Edition:* RedmineUP `Products plugin `__ support - Issue copying (see `docs `__ for details) (`Issue #203 `__) **Improvements**: - Migrated CI to GitHub Actions, also we now test not only on Linux, but on macOS and Windows as well - ``dir(resource)`` and ``list(resource)`` now also show properties of an object - Support for ``issues_assigned`` and ``issues_authored`` relations in User object (`Issue #317 `__) - Original filename will be used as a filename for all uploaded files if a path was provided and filename wasn't set - *Pro Edition:* Added support for RedmineUP Contact avatar add/update operations (see `docs `__ for details) - *Pro Edition:* Added support for RedmineUP DealCategory ``create()``, ``update()``, ``delete()`` operations (see `docs `__ for details) - *Pro Edition:* RedmineUP CrmQuery resource now supports ``invoices`` and ``expenses`` relation attributes - ``PerformanceWarning`` will be issued when Python-Redmine does some unnecessary redirects before the actual request is made **Changes**: - *Backwards Incompatible:* API key is now being sent in the X-Redmine-API-Key header instead of the key GET parameter which makes things more secure in case of a failed connection, but it might created issues for servers that don't do custom request header forwarding by default, so be sure to check your web server before upgrading (`Issue #328 `__ and `Issue #330 `__) (thanks to `Tom Misilo `__ and `Ricardo Branco `__) - *Backwards Incompatible:* User ``all`` operation now really returns all users, i.e. not only active, but locked, registered and anonymous as well instead of only returning just active users in previous versions due to the respect to Redmine's standard behaviour (`Issue #327 `__) **Bugfixes**: - Tests were failing on Windows OS - Tests were failing on Python 3.12 (`Issue #332 `__) (thanks to `Michał Górny `__) - Some closed Issues weren't converted to Resource objects using ``redmine.search()`` - *Pro Edition:* RedmineUP Invoice resource ``order`` attribute was returned as a dict instead of being converted to Resource object - *Pro Edition:* RedmineUP CrmQuery resource ``deals`` and ``contacts`` relation attributes didn't work - *Pro Edition:* RedmineUP DealStatus resource ``deals`` relation attribute didn't work **Documentation**: - Mentioned support for ``author_id`` in Issue's resource filter operation 2.4.0 (2023-01-18) ++++++++++++++++++ **Deprecations**: - Requests version required >= 2.28.2 - Removed Python 2.7, 3.5, 3.6 support as it's not supported by Requests anymore - Removed support for ``python setup.py test`` as it became deprecated by setuptools **New Features**: - *Pro Edition:* RedmineUP `Helpdesk plugin `__ support (`Issue #116 `__) - *Pro Edition:* RedmineUP `Invoices plugin `__ support (`Issue #301 `__) - Timezone support (see `docs `__ for details) (`Issue #271 `__) **Improvements**: - Added support for Python 3.10, 3.11 and latest PyPy - Added support for `allowed_statuses` to ``include`` param and on demand includes for Issue resource (requires Redmine >= 5.0.0) - Added support for `issue_custom_fields` to ``include`` param and on demand includes for Project resource (requires Redmine >= 4.2.0) - Added support for `comments` and `attachments` to ``include`` param and on demand includes for News resource (requires Redmine >= 4.1.0) - *Pro Edition:* Added support for RedmineUP Contact `projects` to ``include`` param and on demand includes for ``all()`` and ``filter()`` operations - *Pro Edition:* Added support for RedmineUP Note ``create()``, ``update()``, ``delete()`` operations (see `docs `__ for details) - Added support for Project ``close()``, ``reopen()``, ``archive()``, ``unarchive()`` operations (see `docs `__ for details, requires Redmine >= 5.0.0) - Added support for updating and deleting issue journals (see `docs `__ for details, requires Redmine >= 5.0.0) **Changes**: - *Backwards Incompatible:* Switched to pytest instead of nose as nose project is dead (`Issue #312 `__) - *Backwards Incompatible:* Removed usage of distutils.LooseVersion internally since it became deprecated and caused warnings, because of that all version info internally is now being represented as tuples and not strings as before **Bugfixes**: - Stop raising `ResourceAttrError` for attributes that actually exist, but their value is `None` (`Issue #261 `__) - *Pro Edition:* RedmineUP Deal resource ``related_contacts`` attribute was returned as a list instead of being converted to ResourceSet object - Project resource ``default_assignee`` attribute was returned as a dict instead of being converted to Resource object - Project resource ``time_entry_activities`` attribute was returned as a list instead of being converted to ResourceSet object **Documentation**: - Document requirement of `project_id` param for `query_id` filter (`Issue #285 `__) (thanks to `Doezer `__) - Mentioned support for ``user_id`` in TimeEntry's resource create/update (`Issue #298 `__) - Mentioned support for additional scopes for Search API 2.3.0 (2020-05-21) ++++++++++++++++++ **Deprecations**: - Requests version required >= 2.23.0 - Removed Python 3.4 support as it's not supported by Requests anymore **Improvements**: - Support custom filename in ``redmine.upload()`` - Support for ``get()`` and ``update()`` operations for ``/my/account`` endpoint which doesn't require admin privileges by using ``me`` as an id, i.e. ``redmine.user.get('me')`` or ``redmine.user.update('me',firstname='John')`` (requires Redmine >= 4.1.0) - News ``create()``, ``update()``, ``delete()`` operations support (requires Redmine >= 4.1.0) - ResourceSet's ``export()`` method now supports ``columns`` keyword argument which can be either an iterable of column names, an "all" string which tells Python-Redmine to export all available columns, "all_gui" string for GUI like behaviour or iterable of elements with "all_gui" string and additional columns to export - Added support for special characters in WikiPage titles (`Issue #222 `__) (thanks to `Radek Czajka `__) - Added ``return_response`` and ``ignore_response`` parameters to engine which allow to skip response processing and speed up the create/update/delete operation in case response body isn't needed (see `docs `__ for details) **Bugfixes**: - User's ``send_information`` field wasn't sent correctly to Redmine so account information emails were never sent (`Issue #227 `__) (thanks to `wodny `__) - Project resource ``default_version`` attribute was returned as a dict instead of being converted to Resource object - Resource object was leaking memory during initialization (`Issue #257 `__) (thanks to `yihli `__) **Documentation**: - Introduced detailed parameter list for ``redmine.session`` - Mentioned support for ``admin`` in User's resource create/update 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) ++++++++++++++++++ **Deprecations**: - Removed vendored Requests package and make it an external dependency as Requests did the same with its own dependencies - Removed Python 2.6 and 3.3 support as they're not supported by Requests anymore **Improvements**: - ``PerformanceWarning`` will be issued when Python-Redmine does some unnecessary work under the hood to fix the clients code problems **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 its 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 - *Pro Edition:* RedmineUP CRM Contact and Deal resources export functionality didn't work - *Pro Edition:* 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**: - *Pro Edition:* 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) - *Pro Edition:* RedmineUP CRM Note resource now provides ``type`` attribute which shows text representation of ``type_id`` - *Pro Edition:* 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 its 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 its 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711891919.0 python-redmine-2.5.0/LICENSE0000644000076500000240000000105114602262717015565 0ustar00maxtepkeevstaffCopyright 2024 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.././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672081406.0 python-redmine-2.5.0/MANIFEST.in0000644000076500000240000000012214352367776016331 0ustar00maxtepkeevstaffrecursive-include tests *.py include .coveragerc README.rst CHANGELOG.rst LICENSE ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1711898121.8133745 python-redmine-2.5.0/PKG-INFO0000644000076500000240000013111014602277012015647 0ustar00maxtepkeevstaffMetadata-Version: 2.1 Name: python-redmine Version: 2.5.0 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 Keywords: redmine redmineup redminecrm redminelib easyredmine 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 :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Requires-Python: >=3.7, <4 Description-Content-Type: text/x-rst Requires-Dist: requests>=2.31.0 Python-Redmine ============== .. image:: https://badge.fury.io/py/python-redmine.svg :target: https://badge.fury.io/py/python-redmine .. image:: https://img.shields.io/github/actions/workflow/status/maxtepkeev/python-redmine/tests.yml :target: https://github.com/maxtepkeev/python-redmine/actions/workflows/tests.yml .. image:: https://img.shields.io/coverallsCoverage/github/maxtepkeev/python-redmine?branch=master :target: https://coveralls.io/github/maxtepkeev/python-redmine?branch=master Python-Redmine is a library for communicating with a `Redmine `__ project management application. Redmine exposes some of its 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 3.7 - 3.12 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.5.0 (2024-03-31) ++++++++++++++++++ **Deprecations**: - Requests version required >= 2.31.0 **New Features**: - *Pro Edition:* RedmineUP `Products plugin `__ support - Issue copying (see `docs `__ for details) (`Issue #203 `__) **Improvements**: - Migrated CI to GitHub Actions, also we now test not only on Linux, but on macOS and Windows as well - ``dir(resource)`` and ``list(resource)`` now also show properties of an object - Support for ``issues_assigned`` and ``issues_authored`` relations in User object (`Issue #317 `__) - Original filename will be used as a filename for all uploaded files if a path was provided and filename wasn't set - *Pro Edition:* Added support for RedmineUP Contact avatar add/update operations (see `docs `__ for details) - *Pro Edition:* Added support for RedmineUP DealCategory ``create()``, ``update()``, ``delete()`` operations (see `docs `__ for details) - *Pro Edition:* RedmineUP CrmQuery resource now supports ``invoices`` and ``expenses`` relation attributes - ``PerformanceWarning`` will be issued when Python-Redmine does some unnecessary redirects before the actual request is made **Changes**: - *Backwards Incompatible:* API key is now being sent in the X-Redmine-API-Key header instead of the key GET parameter which makes things more secure in case of a failed connection, but it might created issues for servers that don't do custom request header forwarding by default, so be sure to check your web server before upgrading (`Issue #328 `__ and `Issue #330 `__) (thanks to `Tom Misilo `__ and `Ricardo Branco `__) - *Backwards Incompatible:* User ``all`` operation now really returns all users, i.e. not only active, but locked, registered and anonymous as well instead of only returning just active users in previous versions due to the respect to Redmine's standard behaviour (`Issue #327 `__) **Bugfixes**: - Tests were failing on Windows OS - Tests were failing on Python 3.12 (`Issue #332 `__) (thanks to `Michał Górny `__) - Some closed Issues weren't converted to Resource objects using ``redmine.search()`` - *Pro Edition:* RedmineUP Invoice resource ``order`` attribute was returned as a dict instead of being converted to Resource object - *Pro Edition:* RedmineUP CrmQuery resource ``deals`` and ``contacts`` relation attributes didn't work - *Pro Edition:* RedmineUP DealStatus resource ``deals`` relation attribute didn't work **Documentation**: - Mentioned support for ``author_id`` in Issue's resource filter operation 2.4.0 (2023-01-18) ++++++++++++++++++ **Deprecations**: - Requests version required >= 2.28.2 - Removed Python 2.7, 3.5, 3.6 support as it's not supported by Requests anymore - Removed support for ``python setup.py test`` as it became deprecated by setuptools **New Features**: - *Pro Edition:* RedmineUP `Helpdesk plugin `__ support (`Issue #116 `__) - *Pro Edition:* RedmineUP `Invoices plugin `__ support (`Issue #301 `__) - Timezone support (see `docs `__ for details) (`Issue #271 `__) **Improvements**: - Added support for Python 3.10, 3.11 and latest PyPy - Added support for `allowed_statuses` to ``include`` param and on demand includes for Issue resource (requires Redmine >= 5.0.0) - Added support for `issue_custom_fields` to ``include`` param and on demand includes for Project resource (requires Redmine >= 4.2.0) - Added support for `comments` and `attachments` to ``include`` param and on demand includes for News resource (requires Redmine >= 4.1.0) - *Pro Edition:* Added support for RedmineUP Contact `projects` to ``include`` param and on demand includes for ``all()`` and ``filter()`` operations - *Pro Edition:* Added support for RedmineUP Note ``create()``, ``update()``, ``delete()`` operations (see `docs `__ for details) - Added support for Project ``close()``, ``reopen()``, ``archive()``, ``unarchive()`` operations (see `docs `__ for details, requires Redmine >= 5.0.0) - Added support for updating and deleting issue journals (see `docs `__ for details, requires Redmine >= 5.0.0) **Changes**: - *Backwards Incompatible:* Switched to pytest instead of nose as nose project is dead (`Issue #312 `__) - *Backwards Incompatible:* Removed usage of distutils.LooseVersion internally since it became deprecated and caused warnings, because of that all version info internally is now being represented as tuples and not strings as before **Bugfixes**: - Stop raising `ResourceAttrError` for attributes that actually exist, but their value is `None` (`Issue #261 `__) - *Pro Edition:* RedmineUP Deal resource ``related_contacts`` attribute was returned as a list instead of being converted to ResourceSet object - Project resource ``default_assignee`` attribute was returned as a dict instead of being converted to Resource object - Project resource ``time_entry_activities`` attribute was returned as a list instead of being converted to ResourceSet object **Documentation**: - Document requirement of `project_id` param for `query_id` filter (`Issue #285 `__) (thanks to `Doezer `__) - Mentioned support for ``user_id`` in TimeEntry's resource create/update (`Issue #298 `__) - Mentioned support for additional scopes for Search API 2.3.0 (2020-05-21) ++++++++++++++++++ **Deprecations**: - Requests version required >= 2.23.0 - Removed Python 3.4 support as it's not supported by Requests anymore **Improvements**: - Support custom filename in ``redmine.upload()`` - Support for ``get()`` and ``update()`` operations for ``/my/account`` endpoint which doesn't require admin privileges by using ``me`` as an id, i.e. ``redmine.user.get('me')`` or ``redmine.user.update('me',firstname='John')`` (requires Redmine >= 4.1.0) - News ``create()``, ``update()``, ``delete()`` operations support (requires Redmine >= 4.1.0) - ResourceSet's ``export()`` method now supports ``columns`` keyword argument which can be either an iterable of column names, an "all" string which tells Python-Redmine to export all available columns, "all_gui" string for GUI like behaviour or iterable of elements with "all_gui" string and additional columns to export - Added support for special characters in WikiPage titles (`Issue #222 `__) (thanks to `Radek Czajka `__) - Added ``return_response`` and ``ignore_response`` parameters to engine which allow to skip response processing and speed up the create/update/delete operation in case response body isn't needed (see `docs `__ for details) **Bugfixes**: - User's ``send_information`` field wasn't sent correctly to Redmine so account information emails were never sent (`Issue #227 `__) (thanks to `wodny `__) - Project resource ``default_version`` attribute was returned as a dict instead of being converted to Resource object - Resource object was leaking memory during initialization (`Issue #257 `__) (thanks to `yihli `__) **Documentation**: - Introduced detailed parameter list for ``redmine.session`` - Mentioned support for ``admin`` in User's resource create/update 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) ++++++++++++++++++ **Deprecations**: - Removed vendored Requests package and make it an external dependency as Requests did the same with its own dependencies - Removed Python 2.6 and 3.3 support as they're not supported by Requests anymore **Improvements**: - ``PerformanceWarning`` will be issued when Python-Redmine does some unnecessary work under the hood to fix the clients code problems **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 its 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 - *Pro Edition:* RedmineUP CRM Contact and Deal resources export functionality didn't work - *Pro Edition:* 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**: - *Pro Edition:* 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) - *Pro Edition:* RedmineUP CRM Note resource now provides ``type`` attribute which shows text representation of ``type_id`` - *Pro Edition:* 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 its 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 its 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709394916.0 python-redmine-2.5.0/README.rst0000644000076500000240000000650414570645744016270 0ustar00maxtepkeevstaffPython-Redmine ============== .. image:: https://badge.fury.io/py/python-redmine.svg :target: https://badge.fury.io/py/python-redmine .. image:: https://img.shields.io/github/actions/workflow/status/maxtepkeev/python-redmine/tests.yml :target: https://github.com/maxtepkeev/python-redmine/actions/workflows/tests.yml .. image:: https://img.shields.io/coverallsCoverage/github/maxtepkeev/python-redmine?branch=master :target: https://coveralls.io/github/maxtepkeev/python-redmine?branch=master Python-Redmine is a library for communicating with a `Redmine `__ project management application. Redmine exposes some of its 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 3.7 - 3.12 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. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1711898121.8125184 python-redmine-2.5.0/python_redmine.egg-info/0000755000076500000240000000000014602277012021273 5ustar00maxtepkeevstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711898121.0 python-redmine-2.5.0/python_redmine.egg-info/PKG-INFO0000644000076500000240000013111014602277011022364 0ustar00maxtepkeevstaffMetadata-Version: 2.1 Name: python-redmine Version: 2.5.0 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 Keywords: redmine redmineup redminecrm redminelib easyredmine 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 :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3 :: Only Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Requires-Python: >=3.7, <4 Description-Content-Type: text/x-rst Requires-Dist: requests>=2.31.0 Python-Redmine ============== .. image:: https://badge.fury.io/py/python-redmine.svg :target: https://badge.fury.io/py/python-redmine .. image:: https://img.shields.io/github/actions/workflow/status/maxtepkeev/python-redmine/tests.yml :target: https://github.com/maxtepkeev/python-redmine/actions/workflows/tests.yml .. image:: https://img.shields.io/coverallsCoverage/github/maxtepkeev/python-redmine?branch=master :target: https://coveralls.io/github/maxtepkeev/python-redmine?branch=master Python-Redmine is a library for communicating with a `Redmine `__ project management application. Redmine exposes some of its 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 3.7 - 3.12 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.5.0 (2024-03-31) ++++++++++++++++++ **Deprecations**: - Requests version required >= 2.31.0 **New Features**: - *Pro Edition:* RedmineUP `Products plugin `__ support - Issue copying (see `docs `__ for details) (`Issue #203 `__) **Improvements**: - Migrated CI to GitHub Actions, also we now test not only on Linux, but on macOS and Windows as well - ``dir(resource)`` and ``list(resource)`` now also show properties of an object - Support for ``issues_assigned`` and ``issues_authored`` relations in User object (`Issue #317 `__) - Original filename will be used as a filename for all uploaded files if a path was provided and filename wasn't set - *Pro Edition:* Added support for RedmineUP Contact avatar add/update operations (see `docs `__ for details) - *Pro Edition:* Added support for RedmineUP DealCategory ``create()``, ``update()``, ``delete()`` operations (see `docs `__ for details) - *Pro Edition:* RedmineUP CrmQuery resource now supports ``invoices`` and ``expenses`` relation attributes - ``PerformanceWarning`` will be issued when Python-Redmine does some unnecessary redirects before the actual request is made **Changes**: - *Backwards Incompatible:* API key is now being sent in the X-Redmine-API-Key header instead of the key GET parameter which makes things more secure in case of a failed connection, but it might created issues for servers that don't do custom request header forwarding by default, so be sure to check your web server before upgrading (`Issue #328 `__ and `Issue #330 `__) (thanks to `Tom Misilo `__ and `Ricardo Branco `__) - *Backwards Incompatible:* User ``all`` operation now really returns all users, i.e. not only active, but locked, registered and anonymous as well instead of only returning just active users in previous versions due to the respect to Redmine's standard behaviour (`Issue #327 `__) **Bugfixes**: - Tests were failing on Windows OS - Tests were failing on Python 3.12 (`Issue #332 `__) (thanks to `Michał Górny `__) - Some closed Issues weren't converted to Resource objects using ``redmine.search()`` - *Pro Edition:* RedmineUP Invoice resource ``order`` attribute was returned as a dict instead of being converted to Resource object - *Pro Edition:* RedmineUP CrmQuery resource ``deals`` and ``contacts`` relation attributes didn't work - *Pro Edition:* RedmineUP DealStatus resource ``deals`` relation attribute didn't work **Documentation**: - Mentioned support for ``author_id`` in Issue's resource filter operation 2.4.0 (2023-01-18) ++++++++++++++++++ **Deprecations**: - Requests version required >= 2.28.2 - Removed Python 2.7, 3.5, 3.6 support as it's not supported by Requests anymore - Removed support for ``python setup.py test`` as it became deprecated by setuptools **New Features**: - *Pro Edition:* RedmineUP `Helpdesk plugin `__ support (`Issue #116 `__) - *Pro Edition:* RedmineUP `Invoices plugin `__ support (`Issue #301 `__) - Timezone support (see `docs `__ for details) (`Issue #271 `__) **Improvements**: - Added support for Python 3.10, 3.11 and latest PyPy - Added support for `allowed_statuses` to ``include`` param and on demand includes for Issue resource (requires Redmine >= 5.0.0) - Added support for `issue_custom_fields` to ``include`` param and on demand includes for Project resource (requires Redmine >= 4.2.0) - Added support for `comments` and `attachments` to ``include`` param and on demand includes for News resource (requires Redmine >= 4.1.0) - *Pro Edition:* Added support for RedmineUP Contact `projects` to ``include`` param and on demand includes for ``all()`` and ``filter()`` operations - *Pro Edition:* Added support for RedmineUP Note ``create()``, ``update()``, ``delete()`` operations (see `docs `__ for details) - Added support for Project ``close()``, ``reopen()``, ``archive()``, ``unarchive()`` operations (see `docs `__ for details, requires Redmine >= 5.0.0) - Added support for updating and deleting issue journals (see `docs `__ for details, requires Redmine >= 5.0.0) **Changes**: - *Backwards Incompatible:* Switched to pytest instead of nose as nose project is dead (`Issue #312 `__) - *Backwards Incompatible:* Removed usage of distutils.LooseVersion internally since it became deprecated and caused warnings, because of that all version info internally is now being represented as tuples and not strings as before **Bugfixes**: - Stop raising `ResourceAttrError` for attributes that actually exist, but their value is `None` (`Issue #261 `__) - *Pro Edition:* RedmineUP Deal resource ``related_contacts`` attribute was returned as a list instead of being converted to ResourceSet object - Project resource ``default_assignee`` attribute was returned as a dict instead of being converted to Resource object - Project resource ``time_entry_activities`` attribute was returned as a list instead of being converted to ResourceSet object **Documentation**: - Document requirement of `project_id` param for `query_id` filter (`Issue #285 `__) (thanks to `Doezer `__) - Mentioned support for ``user_id`` in TimeEntry's resource create/update (`Issue #298 `__) - Mentioned support for additional scopes for Search API 2.3.0 (2020-05-21) ++++++++++++++++++ **Deprecations**: - Requests version required >= 2.23.0 - Removed Python 3.4 support as it's not supported by Requests anymore **Improvements**: - Support custom filename in ``redmine.upload()`` - Support for ``get()`` and ``update()`` operations for ``/my/account`` endpoint which doesn't require admin privileges by using ``me`` as an id, i.e. ``redmine.user.get('me')`` or ``redmine.user.update('me',firstname='John')`` (requires Redmine >= 4.1.0) - News ``create()``, ``update()``, ``delete()`` operations support (requires Redmine >= 4.1.0) - ResourceSet's ``export()`` method now supports ``columns`` keyword argument which can be either an iterable of column names, an "all" string which tells Python-Redmine to export all available columns, "all_gui" string for GUI like behaviour or iterable of elements with "all_gui" string and additional columns to export - Added support for special characters in WikiPage titles (`Issue #222 `__) (thanks to `Radek Czajka `__) - Added ``return_response`` and ``ignore_response`` parameters to engine which allow to skip response processing and speed up the create/update/delete operation in case response body isn't needed (see `docs `__ for details) **Bugfixes**: - User's ``send_information`` field wasn't sent correctly to Redmine so account information emails were never sent (`Issue #227 `__) (thanks to `wodny `__) - Project resource ``default_version`` attribute was returned as a dict instead of being converted to Resource object - Resource object was leaking memory during initialization (`Issue #257 `__) (thanks to `yihli `__) **Documentation**: - Introduced detailed parameter list for ``redmine.session`` - Mentioned support for ``admin`` in User's resource create/update 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) ++++++++++++++++++ **Deprecations**: - Removed vendored Requests package and make it an external dependency as Requests did the same with its own dependencies - Removed Python 2.6 and 3.3 support as they're not supported by Requests anymore **Improvements**: - ``PerformanceWarning`` will be issued when Python-Redmine does some unnecessary work under the hood to fix the clients code problems **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 its 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 - *Pro Edition:* RedmineUP CRM Contact and Deal resources export functionality didn't work - *Pro Edition:* 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**: - *Pro Edition:* 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) - *Pro Edition:* RedmineUP CRM Note resource now provides ``type`` attribute which shows text representation of ``type_id`` - *Pro Edition:* 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 its 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 its 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 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711898121.0 python-redmine-2.5.0/python_redmine.egg-info/SOURCES.txt0000644000076500000240000000162114602277011023156 0ustar00maxtepkeevstaff.coveragerc CHANGELOG.rst LICENSE MANIFEST.in 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.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711898121.0 python-redmine-2.5.0/python_redmine.egg-info/dependency_links.txt0000644000076500000240000000000114602277011025340 0ustar00maxtepkeevstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711898121.0 python-redmine-2.5.0/python_redmine.egg-info/not-zip-safe0000644000076500000240000000000114602277011023520 0ustar00maxtepkeevstaff ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711898121.0 python-redmine-2.5.0/python_redmine.egg-info/requires.txt0000644000076500000240000000002114602277011023663 0ustar00maxtepkeevstaffrequests>=2.31.0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711898121.0 python-redmine-2.5.0/python_redmine.egg-info/top_level.txt0000644000076500000240000000001314602277011024016 0ustar00maxtepkeevstaffredminelib ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1711898121.7997382 python-redmine-2.5.0/redminelib/0000755000076500000240000000000014602277012016667 5ustar00maxtepkeevstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711284467.0 python-redmine-2.5.0/redminelib/__init__.py0000644000076500000240000002244014600020363020773 0ustar00maxtepkeevstaff""" Provides public API. """ import os import io import inspect import warnings import datetime import contextlib from . import managers, exceptions, engines, utilities, resources from .version import __version__ class Redmine: """ 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 timezone: (optional). Whether to convert a naive datetime to a specific timezone aware one. :type timezone: str or cls :param cls engine: (optional). Engine that will be used to make requests to Redmine. """ self.url = url.rstrip('/') self.ver = kwargs.pop('version', None) if self.ver is not None: self.ver = utilities.versiontuple(self.ver) self.timezone = kwargs.pop('timezone', None) if self.timezone is not None and not isinstance(self.timezone, datetime.tzinfo): try: self.timezone = datetime.datetime.strptime(self.timezone, '%z').tzinfo except (TypeError, ValueError): raise exceptions.TimezoneError self.date_format = kwargs.pop('date_format', '%Y-%m-%d') self.datetime_format = kwargs.pop('datetime_format', '%Y-%m-%dT%H:%M:%SZ') self.raise_attr_exception = kwargs.pop('raise_attr_exception', True) engine = kwargs.pop('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 self.ver < 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, filename=None): """ 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 :param filename: (optional). Filename for the file that will be uploaded. """ if self.ver is not None and self.ver < (1, 4, 0): raise exceptions.VersionMismatchError('File uploading') # There are 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): warnings.warn('File-like object contains unicode, hence an additional step is performed to convert ' 'its 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 if not filename: filename = os.path.basename(f) stream = open(f, 'rb') close = True url = f'{self.url}/uploads.json' headers = {'Content-Type': 'application/octet-stream'} params = {'filename': filename or ''} response = self.engine.request('post', url, params=params, 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 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 self.ver < (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', f'{self.url}/search.json', '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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1711898121.8014333 python-redmine-2.5.0/redminelib/engines/0000755000076500000240000000000014602277012020317 5ustar00maxtepkeevstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711891919.0 python-redmine-2.5.0/redminelib/engines/__init__.py0000644000076500000240000000024214602262717022434 0ustar00maxtepkeevstaff""" Defines engines for processing requests/responses to/from Redmine. """ from .base import BaseEngine from .sync import SyncEngine DefaultEngine = SyncEngine ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709460889.0 python-redmine-2.5.0/redminelib/engines/base.py0000644000076500000240000002054214571046631021614 0ustar00maxtepkeevstaff""" Base engine that defines common behaviour and settings for all engines. """ import json import warnings from .. import exceptions class BaseEngine: 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 ignore_response (optional). If True no response processing will be done at all. :param bool return_response (optional). Whether to return response or None. :param bool return_raw_response (optional). Whether to return raw or json encoded responses. """ self.ignore_response = options.pop('ignore_response', False) self.return_response = options.pop('return_response', True) self.return_raw_response = options.pop('return_raw_response', False) self.requests = dict(dict(headers={}, params={}), **options.get('requests', {})) if self.ignore_response: self.requests['stream'] = True 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['headers']['X-Redmine-API-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 self.ignore_response: return None if response.history: r = response.history[0] if 300 <= r.status_code <= 399: url1, url2 = str(r.request.url), str(response.request.url) if (url1[:5] == 'http:' and url2[:6] == 'https:') or (url1[:6] == 'https:' and url2[:5] == 'http:'): raise exceptions.HTTPProtocolError else: warnings.warn('Redirect detected during request-response, normally there should be no redirects, ' 'so please check your Redmine URL for things like prepending www which redirects to ' 'a no www domain and vice versa or using an old domain which redirects to a new one', exceptions.PerformanceWarning) status_code = response.status_code if status_code in (200, 201, 204): if not self.return_response: return None elif 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1677854872.0 python-redmine-2.5.0/redminelib/engines/sync.py0000644000076500000240000000102714400404230021633 0ustar00maxtepkeevstaff""" 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]] ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709390063.0 python-redmine-2.5.0/redminelib/exceptions.py0000644000076500000240000002023314570634357021437 0ustar00maxtepkeevstaff""" Python-Redmine tries its best to provide human-readable errors in all situations. This is a list of all exceptions or warnings that Python-Redmine can throw/raise. """ class BaseRedmineWarning(Warning): """ Base warning class for Redmine warnings. """ class PerformanceWarning(BaseRedmineWarning): """ Warning raised when there's a possible performance impact. """ class BaseRedmineError(Exception): """ Base exception class for Redmine exceptions. """ class ResourceError(BaseRedmineError): """ Unsupported Redmine resource exception. """ def __init__(self): super().__init__('Unsupported Redmine resource') class NoFileError(BaseRedmineError): """ File doesn't exist or is empty exception. """ def __init__(self): super().__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().__init__("File-like object doesn't support the read(size) method") class ResourceNotFoundError(BaseRedmineError): """ Requested resource doesn't exist. """ def __init__(self): super().__init__("Requested resource doesn't exist") class ConflictError(BaseRedmineError): """ Resource version on the server is newer than on the client. """ def __init__(self): super().__init__('Resource version on the server is newer than on the client') class AuthError(BaseRedmineError): """ Invalid authentication details. """ def __init__(self): super().__init__('Invalid authentication details') class ImpersonateError(BaseRedmineError): """ Invalid impersonate login provided. """ def __init__(self): super().__init__("Impersonate login provided doesn't exist or isn't active") class ServerError(BaseRedmineError): """ Redmine internal error. """ def __init__(self): super().__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().__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().__init__(f'Redmine returned unknown error with the status code {status_code}') class ValidationError(BaseRedmineError): """ Redmine validation errors occurred on create/update resource. """ def __init__(self, error): super().__init__(error) class ResourceSetIndexError(BaseRedmineError): """ Index doesn't exist in the ResourceSet. """ def __init__(self): super().__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().__init__(f'Received an invalid lookup "{lookup}" in "{f}" filter') class ResourceBadMethodError(BaseRedmineError): """ Resource doesn't support the requested method. """ def __init__(self): super().__init__("Resource doesn't support the requested method") class ResourceFilterError(BaseRedmineError): """ Resource doesn't support requested filter(s). """ def __init__(self): super().__init__("Resource doesn't support requested filter(s)") class ResourceNoFiltersProvidedError(BaseRedmineError): """ No filter(s) provided. """ def __init__(self): super().__init__('Resource needs some filters to be filtered on') class ResourceNoFieldsProvidedError(BaseRedmineError): """ No field(s) provided. """ def __init__(self): super().__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().__init__("Resource doesn't have the requested attribute") class ReadonlyAttrError(BaseRedmineError): """ Resource can't set attribute that is read only. """ def __init__(self): super().__init__("Can't set read only attribute") class VersionFormatError(BaseRedmineError): """ Version format provided isn't supported. SemVer is the only format accepted. """ def __init__(self, version): super().__init__( f"Version in the {version} format isn't supported, please provide numeric version in the form of X.X.X") class VersionMismatchError(BaseRedmineError): """ Feature isn't supported on specified Redmine version. """ def __init__(self, feature): super().__init__(f"{feature} isn't supported on specified Redmine version") class ResourceVersionMismatchError(VersionMismatchError): """ Resource isn't supported on specified Redmine version. """ def __init__(self): super().__init__('Resource') class ResultSetTotalCountError(BaseRedmineError): """ ResultSet hasn't been yet evaluated and cannot yield a total_count. """ def __init__(self): super().__init__('Total count is unknown before evaluation') class CustomFieldValueError(BaseRedmineError): """ Custom fields should be passed as a list of dictionaries. """ def __init__(self): super().__init__( "Custom fields should be passed as a list of dictionaries in the form of [{'id': 1, 'value': 'foo'}]") class ResourceRequirementsError(BaseRedmineError): """ Resource requires specific Redmine plugin(s) to function. """ def __init__(self, requirements): reqs = [] for req in requirements: if isinstance(req, (list, tuple)): reqs.append(' >= '.join([req[0], '.'.join(map(str, req[1]))])) else: reqs.append(req) super().__init__(f"The following requirements must be installed for resource to function: {', '.join(reqs)}") class FileUrlError(BaseRedmineError): """ URL provided to download a file can't be parsed. """ def __init__(self): super().__init__("URL provided to download a file can't be parsed") class ForbiddenError(BaseRedmineError): """ Requested resource is forbidden. """ def __init__(self): super().__init__('Requested resource is forbidden') class JSONDecodeError(BaseRedmineError): """ Unable to decode received JSON. """ def __init__(self, response): self.response = response super().__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().__init__('Export functionality not supported by resource') class ExportFormatNotSupportedError(BaseRedmineError): """ The given format isn't supported by resource. """ def __init__(self): super().__init__("The given format isn't supported by resource") class HTTPProtocolError(BaseRedmineError): """ Wrong HTTP protocol usage. """ def __init__(self): super().__init__('Protocol redirect detected, Redmine URL expects HTTPS, but code uses HTTP or vice versa') class TimezoneError(BaseRedmineError): """ Timezone is neither a string, suitable for a strptime %z, nor is an instance of tzinfo class. """ def __init__(self): super().__init__( 'Timezone has to be either a ±HHMM string, e.g. -0800 or +0545, or an instance of datetime.tzinfo class') class EngineClassError(BaseRedmineError): """ Engine isn't a class or isn't a BaseEngine subclass. """ def __init__(self): super().__init__("Engine isn't a class or isn't a BaseEngine subclass") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672075144.0 python-redmine-2.5.0/redminelib/lookups.py0000644000076500000240000000121714352353610020737 0ustar00maxtepkeevstaff""" Defines lookup classes to be used in ResultSet's filter method. """ registry = {} class Lookup: lookup_name = None def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) registry[cls.lookup_name] = cls() 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 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1711898121.8032744 python-redmine-2.5.0/redminelib/managers/0000755000076500000240000000000014602277012020464 5ustar00maxtepkeevstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711891919.0 python-redmine-2.5.0/redminelib/managers/__init__.py0000644000076500000240000000025714602262717022607 0ustar00maxtepkeevstaff""" Defines manager classes. """ from .base import ResourceManager from .standard import ProjectManager, IssueManager, FileManager, WikiPageManager, UserManager, NewsManager ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711892001.0 python-redmine-2.5.0/redminelib/managers/base.py0000644000076500000240000002742714602263041021761 0ustar00maxtepkeevstaff""" Defines base Redmine resource manager class and its infrastructure. """ from .. import resultsets, exceptions class ResourceManager: """ 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 _construct_get_url(self, path): """ Constructs URL for get method. :param string path: absolute URL path. """ return self.redmine.url + path def _prepare_get_request(self, request): """ Makes the necessary preparations for get request data. :param dict request: Request data. """ return self.resource_class.bulk_decode(request, self) 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._construct_get_url(self.resource_class.query_one.format(resource_id, **params)) except KeyError as e: raise exceptions.ValidationError(f'{e} argument is required') self.params = self._prepare_get_request(params) self.container = self.resource_class.container_one try: response = self.redmine.engine.request('get', self.url, params=self.params) except exceptions.ResourceNotFoundError as e: if self.resource_class.requirements: raise exceptions.ResourceRequirementsError(self.resource_class.requirements) raise e return self._process_get_response(self.params, response) def _process_get_response(self, request, response): """ Processes get 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 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 try: url = self._construct_create_url(self.resource_class.query_create.format(**fields)) except KeyError as e: raise exceptions.ValidationError(f'{e} field is required') self.params = self.resource_class.query_create.formatter.used_kwargs self.container = self.resource_class.container_create request = self._prepare_create_request(self.resource_class.query_create.formatter.unused_kwargs) response = self.redmine.engine.request(self.resource_class.http_method_create, url, data=request) if response is None: return None resource = self._process_create_response(request, response) if self.resource_class.query_one is not None: 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.container: 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 try: query_update = self.resource_class.query_update.format(resource_id, **fields) except KeyError as e: param = e.args[0] if param in self.params: fields[param] = self.params[param] query_update = self.resource_class.query_update.format(resource_id, **fields) else: raise exceptions.ValidationError(f'{e} argument is required') self.params.update(self.resource_class.query_update.formatter.used_kwargs) self.container = self.resource_class.container_update url = self._construct_update_url(query_update) request = self._prepare_update_request(self.resource_class.query_update.formatter.unused_kwargs) response = self.redmine.engine.request(self.resource_class.http_method_update, url, data=request) if response is None: return None 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(f'{e} argument is required') request = self._prepare_delete_request(params) response = self.redmine.engine.request(self.resource_class.http_method_delete, url, params=request) if response is None: return None 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 f'' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709981416.0 python-redmine-2.5.0/redminelib/managers/standard.py0000644000076500000240000000704114573037350022646 0ustar00maxtepkeevstaff""" Defines standard Redmine resources managers. """ from . import ResourceManager from .. import exceptions class ProjectManager(ResourceManager): def __getattr__(self, attr): if attr in ('close', 'reopen', 'archive', 'unarchive'): if self.redmine.ver is not None and self.redmine.ver < (5, 0, 0): raise exceptions.VersionMismatchError(f'Project {attr}') return lambda resource_id: self.redmine.engine.request( 'put', f'{self.redmine.url}{self.resource_class.query_one.format(resource_id)[:-5]}/{attr}.json') raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{attr}'") class IssueManager(ResourceManager): def copy(self, issue_id, link_original=True, include=(), **fields): fields['_copy'] = {'copy_from': issue_id} if link_original: fields['_copy']['link_copy'] = '1' if include is not None: for i in include or ('subtasks', 'attachments'): fields['_copy'][f'copy_{i}'] = '1' return self.create(**fields) def _prepare_create_request(self, request): request = super()._prepare_create_request(request) request.update(request[self.container].pop('_copy', {})) return request 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()._process_create_response(request, response) class WikiPageManager(ResourceManager): def _process_create_response(self, request, response): if response is True: raise exceptions.ValidationError('Resource already exists') # issue #182 return super()._process_create_response(request, response) class UserManager(ResourceManager): @staticmethod def _check_custom_url(path): if path.endswith('/me.json'): path = '/my/account.json' return path def _construct_get_url(self, path): return super()._construct_get_url(self._check_custom_url(path)) def all(self, **params): resourceset = super().all(**params) if self.redmine.ver is not None: # https://www.redmine.org/issues/32090#note-6 if self.redmine.ver >= (5, 1, 2): resourceset.manager.url = f'{resourceset.manager.url}*' elif self.redmine.ver in ((5, 1, 0), (5, 1, 1)): resourceset.manager.url = (f'{resourceset.manager.url[:-7]}f[]=status_id&' f'op[status_id]==&v[status_id][]=1&v[status_id][]=2&v[status_id][]=3') return resourceset def _prepare_create_request(self, request): request = super()._prepare_create_request(request) request['send_information'] = request[self.container].pop('send_information', False) return request def _construct_update_url(self, path): return super()._construct_update_url(self._check_custom_url(path)) def _prepare_update_request(self, request): request = super()._prepare_update_request(request) request['send_information'] = request[self.resource_class.container_update].pop('send_information', False) return request class NewsManager(ResourceManager): def _process_create_response(self, request, response): if response is True: response = {self.container: self.redmine.news.filter(**self.params)[0].raw()} return super()._process_create_response(request, response) ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1711898121.805199 python-redmine-2.5.0/redminelib/resources/0000755000076500000240000000000014602277012020701 5ustar00maxtepkeevstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711891919.0 python-redmine-2.5.0/redminelib/resources/__init__.py0000644000076500000240000000054114602262717023020 0ustar00maxtepkeevstaff""" 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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709390352.0 python-redmine-2.5.0/redminelib/resources/base.py0000644000076500000240000005042014570635020022167 0ustar00maxtepkeevstaff""" Defines base Redmine resource class and its infrastructure. """ from datetime import date, datetime, timezone 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 its 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().__new__(mcs, name, bases, mcs.bulk_update_attrs(attrs)) mcs.bulk_update_cls_attrs(cls, 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', '_attach_includes_map'): class_attr_name = attr[7:] registry_attr_name = attr[1:] if registry_attr_name in registry[name]: if attr == '_attach_includes_map': mcs.update_cls_attr(cls, class_attr_name, dict(registry[name][registry_attr_name].keys())) else: 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]: if attr == '_attach_includes_map': mcs.update_cls_attr(registry[resource_name]['class'], class_attr_name, dict([value])) else: 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 bulk_update_attrs(attrs): """ Updates attrs with specific features and/or actualizes their content before a class is created. :param dict attrs: (required). Attributes to work with. """ for attr, value in attrs.items(): # `query_*` class attributes should use ResourceQueryFormatter by default if attr.startswith('query_') and value is not None: attrs[attr] = utilities.ResourceQueryStr(value) return attrs @classmethod def bulk_update_cls_attrs(mcs, cls, attrs): """ Updates attrs with specific features and/or actualizes their content after a class is created. :param any cls: (required). Resource class. :param dict attrs: (required). Attributes to work with. """ properties = [] for attr, value in attrs.items(): # `_members` class attribute should also contain all public properties if not attr.startswith('_') and isinstance(value, property): properties.append(attr) mcs.update_cls_attr(cls, '_members', properties) @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: (required). Attribute value. """ attr = getattr(cls, name, None) if isinstance(attr, list): value = list(set().union(attr, value)) elif isinstance(attr, dict): value = dict(attr, **value) else: return setattr(cls, name, value) class BaseResource(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 query_url = None search_hints = None extra_export_columns = [] http_method_create = 'post' http_method_update = 'put' http_method_delete = 'delete' manager_class = managers.ResourceManager _repr = [['id', 'name']] _includes = [] _includes_map = {} _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_includes_map = 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 = self._create_readonly[:] + relations_includes self._update_readonly = 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 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 if attr in self._encoded_attrs: return self._encoded_attrs[attr] # Else this is the first time access hence we need to encode the attribute if attr in self._relations: filters = {f'{self._relations_name}_id': self.internal_id} self._encoded_attrs[attr] = self.manager.new_manager(self._resource_set_map[attr]).filter(**filters) elif attr in self._includes: value = self._decoded_attrs[attr] = self._decoded_attrs.pop(self._includes_map.get(attr, attr), None) if value is None: value = self.refresh(itself=False, include=attr).raw().get(self._includes_map.get(attr, attr)) or [] self._encoded_attrs.update([self.encode(attr, value, self.manager)]) elif attr in self._decoded_attrs: self._encoded_attrs.update([self.encode(attr, self._decoded_attrs[attr], self.manager)]) # In case of successful encoding we put it to a cache and return if attr in self._encoded_attrs: return self._encoded_attrs[attr] # 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. """ custom_settable = [*self._single_attr_id_map, *self._multiple_attr_id_map] if attr.startswith('_') or attr in self._members and attr not in custom_settable: return super().__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 its 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: if manager.redmine.timezone is not None and value.tzinfo is not None: value = value.astimezone(timezone.utc) 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', ''), filename=attachment.get('filename'))['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: value = datetime.strptime(value, manager.redmine.datetime_format) if manager.redmine.timezone is not None: value = value.replace(tzinfo=timezone.utc).astimezone(manager.redmine.timezone) return attr, value 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.now(timezone.utc).strftime( self.manager.redmine.datetime_format) self.post_update() else: self.pre_create() resource = self.manager.create(**self._changes) if resource is not None: self._decoded_attrs = resource.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_url is not None: return self.manager.redmine.url + self.query_url.format(self.internal_id) elif 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 [*self._decoded_attrs, *self._members] def __iter__(self): """ Provides a way to iterate through Resource attributes and its values. """ return iter(dict(self._decoded_attrs, **{m: getattr(self, m, None) for m in self._members}).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, str(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 = f' 0: view += f" \"{' '.join(values)}\"" return view + '>' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709984286.0 python-redmine-2.5.0/redminelib/resources/standard.py0000644000076500000240000004712514573045036023072 0ustar00maxtepkeevstaff""" Defines standard Redmine resources and resource mappings. """ from . import BaseResource from .. import managers, exceptions class Project(BaseResource): redmine_version = (1, 0, 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/{}.json' query_create = '/projects.json' query_update = '/projects/{}.json' query_delete = '/projects/{}.json' search_hints = ['project'] manager_class = managers.ProjectManager _repr = [['id', 'name'], ['title']] _includes = ['trackers', 'issue_categories', 'enabled_modules', 'time_entry_activities', 'issue_custom_fields'] _relations = ['wiki_pages', 'memberships', 'issue_categories', 'time_entries', 'versions', 'news', 'issues', 'files'] _unconvertible = BaseResource._unconvertible + ['identifier', 'status'] _update_readonly = BaseResource._update_readonly + ['identifier'] _resource_map = {'default_version': 'Version', 'default_assignee': 'User'} _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', 'time_entry_activities': 'Enumeration', 'issue_custom_fields': 'CustomField', } _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().encode(attr, value, manager) def __getattr__(self, attr): if attr in ('close', 'reopen', 'archive', 'unarchive'): return lambda: getattr(self.manager, attr)(self.internal_id) return super().__getattr__(attr) class Issue(BaseResource): redmine_version = (1, 0, 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/{}.{format}' query_all = '/issues.json?status_id=*' query_one = '/issues/{}.json' query_filter = '/issues.json' query_create = '/projects/{project_id}/issues.json' query_update = '/issues/{}.json' query_delete = '/issues/{}.json' search_hints = ['issue', 'issue closed', 'issue-closed'] extra_export_columns = ['description', 'last_notes'] manager_class = managers.IssueManager _repr = [['id', 'subject'], ['title'], ['id']] _includes = ['children', 'attachments', 'relations', 'changesets', 'journals', 'watchers', 'allowed_statuses'] _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', 'allowed_statuses': 'IssueStatus', } _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 self._redmine.ver < (2, 3, 0): raise exceptions.ResourceVersionMismatchError def add(self, user_id): """ Adds user to issue watchers list. :param int user_id: (required). User id. """ url = f'{self._redmine.url}/issues/{self._issue_id}/watchers.json' 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 = f'{self._redmine.url}/issues/{self._issue_id}/watchers/{user_id}.json' 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().__getattr__(attr) def __setattr__(self, attr, value): if attr == 'version_id': attr = 'fixed_version_id' super().__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().decode(attr, value, manager) def copy(self, link_original=True, include=(), **fields): if 'project_id' not in fields and not self.is_new(): fields['project_id'] = self._decoded_attrs['project']['id'] return self.manager.copy(self.internal_id, link_original=link_original, include=include, **fields) class TimeEntry(BaseResource): redmine_version = (1, 1, 0) 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/{}.json' query_filter = '/time_entries.json' query_create = '/time_entries.json' query_update = '/time_entries/{}.json' query_delete = '/time_entries/{}.json' _repr = [['id']] _resource_map = {'project': 'Project', 'issue': 'Issue', 'user': 'User', 'activity': 'Enumeration'} _resource_set_map = {'custom_fields': 'CustomField'} _single_attr_id_map = {'project_id': 'project', '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().decode(attr, value, manager) class Enumeration(BaseResource): redmine_version = (2, 2, 0) container_filter = '{resource}' query_filter = '/enumerations/{resource}.json' query_url = '/enumerations/{}/edit' _resource_set_map = {'custom_fields': 'CustomField'} class Attachment(BaseResource): redmine_version = (1, 3, 0) container_one = 'attachment' container_update = 'attachment' query_one = '/attachments/{}.json' query_update = '/attachments/{}.json' query_delete = '/attachments/{}.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, 0) 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().decode(attr, value, manager) class IssueJournal(BaseResource): redmine_version = (1, 0, 0) container_update = 'journal' query_update = '/journals/{}.json' _repr = [['id']] _unconvertible = ['notes'] _resource_map = {'user': 'User'} class WikiPage(BaseResource): internal_id_key = 'title' redmine_version = (2, 2, 0) container_filter = 'wiki_pages' container_one = 'wiki_page' container_create = 'wiki_page' container_update = 'wiki_page' query_one_export = '/projects/{project_id}/wiki/{}.{format}' query_filter = '/projects/{project_id}/wiki/index.json' query_one = '/projects/{project_id}/wiki/{}.json' query_create = '/projects/{project_id}/wiki/{title}.json' query_update = '/projects/{project_id}/wiki/{}.json' query_delete = '/projects/{project_id}/wiki/{}.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().encode(attr, value, manager) def refresh(self, **params): return super().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().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().__getattr__(attr) def __int__(self): return self.version class ProjectMembership(BaseResource): redmine_version = (1, 4, 0) container_filter = 'memberships' container_one = 'membership' container_update = 'membership' container_create = 'membership' query_filter = '/projects/{project_id}/memberships.json' query_one = '/memberships/{}.json' query_create = '/projects/{project_id}/memberships.json' query_update = '/memberships/{}.json' query_delete = '/memberships/{}.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': 'user'} _multiple_attr_id_map = {'role_ids': 'roles'} class IssueCategory(BaseResource): redmine_version = (1, 3, 0) 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/{}.json' query_create = '/projects/{project_id}/issue_categories.json' query_update = '/issue_categories/{}.json' query_delete = '/issue_categories/{}.json' _resource_map = {'project': 'Project', 'assigned_to': 'User'} _single_attr_id_map = {'project_id': 'project', 'assigned_to_id': 'assigned_to'} class IssueRelation(BaseResource): redmine_version = (1, 3, 0) container_filter = 'relations' container_one = 'relation' container_create = 'relation' query_filter = '/issues/{issue_id}/relations.json' query_one = '/relations/{}.json' query_create = '/issues/{issue_id}/relations.json' query_delete = '/relations/{}.json' _repr = [['id']] _single_attr_id_map = {'issue_id': 'issue'} class Version(BaseResource): redmine_version = (1, 3, 0) container_filter = 'versions' container_one = 'version' container_create = 'version' container_update = 'version' query_filter = '/projects/{project_id}/versions.json' query_one = '/versions/{}.json' query_create = '/projects/{project_id}/versions.json' query_update = '/versions/{}.json' query_delete = '/versions/{}.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, 0) container_all = 'users' container_one = 'user' container_filter = 'users' container_create = 'user' container_update = 'user' query_all_export = '/users.{format}' query_all = '/users.json?status=' query_one = '/users/{}.json' query_filter = '/users.json' query_create = '/users.json' query_update = '/users/{}.json' query_delete = '/users/{}.json' manager_class = managers.UserManager _repr = [['id', 'firstname', 'lastname'], ['id', 'name']] _includes = ['memberships', 'groups'] _relations = ['issues', 'issues_assigned', 'issues_authored', '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', 'issues_assigned': 'Issue', 'issues_authored': 'Issue', 'time_entries': 'TimeEntry', } def __getattr__(self, attr): if attr in self._relations and attr not in self._encoded_attrs: if attr == 'issues_authored': self._relations_name = 'author' elif attr == 'time_entries': self._relations_name = 'user' value = super().__getattr__(attr) self._relations_name = 'assigned_to' return value return super().__getattr__(attr) class Group(BaseResource): redmine_version = (2, 1, 0) container_all = 'groups' container_one = 'group' container_create = 'group' container_update = 'group' query_all = '/groups.json' query_one = '/groups/{}.json' query_create = '/groups.json' query_update = '/groups/{}.json' query_delete = '/groups/{}.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 = f'{self._redmine.url}/groups/{self._group_id}/users.json' 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 = f'{self._redmine.url}/groups/{self._group_id}/users/{user_id}.json' return self._redmine.engine.request('delete', url) def __getattr__(self, attr): if attr == 'user': return Group.User(self) return super().__getattr__(attr) class Role(BaseResource): redmine_version = (1, 4, 0) container_all = 'roles' container_one = 'role' query_all = '/roles.json' query_one = '/roles/{}.json' class News(BaseResource): redmine_version = (1, 1, 0) container_all = 'news' container_one = 'news' container_filter = 'news' container_create = 'news' container_update = 'news' query_all_export = '/news.{format}' query_all = '/news.json' query_one = '/news/{}.json' query_filter = '/news.json' query_create = '/projects/{project_id}/news.json' query_update = '/news/{}.json' query_delete = '/news/{}.json' search_hints = ['news'] manager_class = managers.NewsManager _repr = [['id', 'title']] _includes = ['attachments', 'comments'] _resource_map = {'project': 'Project', 'author': 'User'} _resource_set_map = {'attachments': 'Attachment'} _single_attr_id_map = {'project_id': 'project'} class IssueStatus(BaseResource): redmine_version = (1, 3, 0) container_all = 'issue_statuses' query_all = '/issue_statuses.json' query_url = '/issue_statuses/{}/edit' _relations = ['issues'] _relations_name = 'status' _resource_set_map = {'issues': 'Issue'} class Tracker(BaseResource): redmine_version = (1, 3, 0) container_all = 'trackers' query_all = '/trackers.json' query_url = '/trackers/{}/edit' _relations = ['issues'] _resource_set_map = {'issues': 'Issue'} class Query(BaseResource): redmine_version = (1, 3, 0) container_all = 'queries' query_all = '/queries.json' query_url = '/projects/{}/issues?query_id={}' @property def url(self): return self.manager.redmine.url + self.query_url.format( self._decoded_attrs.get('project_id', 0), self.internal_id) class CustomField(BaseResource): redmine_version = (2, 4, 0) container_all = 'custom_fields' query_all = '/custom_fields.json' query_url = '/custom_fields/{}/edit' _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().__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().encode(attr, value, manager) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709983073.0 python-redmine-2.5.0/redminelib/resultsets.py0000644000076500000240000002605714573042541021474 0ustar00maxtepkeevstaff""" Defines ResourceSet objects that can be used to represent a set of resources. """ import operator import functools import itertools from . import lookups, exceptions class BaseResourceSet: """ 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, columns=None, encoding='UTF-8'): """ 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. :param columns: (optional). Iterable of column names, "all_gui" for GUI behaviour or "all" for all columns. :param encoding: (optional). Encoding that will be used by Redmine for the result file. :type columns: iterable or string """ if self.manager.resource_class.query_all_export is None: raise exceptions.ExportNotSupported url = self.manager.redmine.url + self.manager.resource_class.query_all_export.format( format=fmt, **self.manager.params) params = dict(self.manager.resource_class.query_all_export.formatter.unused_kwargs, encoding=encoding) if columns is not None: if columns == 'all': columns = ['all', 'all_inline'] + self.manager.resource_class.extra_export_columns if self.manager.redmine.ver is not None and self.manager.redmine.ver < (3, 4, 0): params.update(dict.fromkeys(self.manager.resource_class.extra_export_columns, 1), columns='all') elif 'all_gui' in columns: if columns == 'all_gui': columns = ['all', 'all_inline'] if self.manager.redmine.ver is not None and self.manager.redmine.ver < (3, 4, 0): params['columns'] = 'all' else: if self.manager.redmine.ver is not None and self.manager.redmine.ver < (3, 4, 0): params.update(dict.fromkeys(columns, 1), columns='all') columns = list(columns) + ['all', 'all_inline'] # Redmine >= 3.4.0 happily accepts c[] array with column names # for older versions the above hack with params is being used params['c[]'] = columns try: return self.manager.redmine.download(url, savepath, filename, params=params) 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 ( f'<{self.__class__.__module__}.{self.__class__.__name__} object ' f'with {self.manager.resource_class.__name__} resources>' ) 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().__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().__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().__iter__(): yield {field: resource[field] for field in fields if field in resource} else: for resource in super().__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().__iter__(): yield resource.get(fields[0]) else: for resource in super().__iter__(): yield tuple(resource[field] for field in fields if field in resource) else: for resource in super().__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().__iter__()) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1672147264.0 python-redmine-2.5.0/redminelib/utilities.py0000644000076500000240000000325314352570500021257 0ustar00maxtepkeevstaff""" Provides helper utilities. """ import copy import string import urllib.parse from . import exceptions def versiontuple(version): """ Converts numeric SemVer version string to tuple. :param string version: (required). Version string to convert. """ parts = str(version).split('.') if len(parts) != 3 or not all(part.isnumeric() for part in parts): raise exceptions.VersionFormatError(version) return tuple(int(part) for part in parts) 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 ResourceQueryFormatter(string.Formatter): """ Quotes query and 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 def format_field(self, value, format_spec): return urllib.parse.quote(super().format_field(value, format_spec).encode('utf-8')) class ResourceQueryStr(str): """ Extends default string with additional formatting capabilities. """ formatter = ResourceQueryFormatter() def format(self, *args, **kwargs): return self.formatter.format(self, *args, **kwargs) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711896404.0 python-redmine-2.5.0/redminelib/version.py0000644000076500000240000000002614602273524020730 0ustar00maxtepkeevstaff__version__ = '2.5.0' ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1711898121.8141184 python-redmine-2.5.0/setup.cfg0000644000076500000240000000026214602277012016376 0ustar00maxtepkeevstaff[tool:pytest] addopts = --cov-config=.coveragerc --cov=redminelib [metadata] license_files = .coveragerc README.rst CHANGELOG.rst LICENSE [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709410257.0 python-redmine-2.5.0/setup.py0000644000076500000240000000337614570703721016305 0ustar00maxtepkeevstafffrom setuptools import setup, find_packages 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_content_type='text/x-rst', long_description=open('README.rst').read() + '\n\n' + open('CHANGELOG.rst').read(), keywords='redmine redmineup redminecrm redminelib easyredmine', python_requires='>=3.7, <4', install_requires=['requests>=2.31.0'], 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 :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy' ], ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1711898121.8097017 python-redmine-2.5.0/tests/0000755000076500000240000000000014602277012015717 5ustar00maxtepkeevstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709390063.0 python-redmine-2.5.0/tests/__init__.py0000644000076500000240000000144114570634357020045 0ustar00maxtepkeevstafffrom unittest import mock, TestCase from redminelib import Redmine class BaseRedmineTestCase(TestCase): url = 'https://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': [], 'request.url': self.url}) for target, path in self.patch_targets.items(): setattr(self, f'{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, f'{self.patch_prefix}_{target}').side_effect = side_effect ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1711898121.8116143 python-redmine-2.5.0/tests/responses/0000755000076500000240000000000014602277012017740 5ustar00maxtepkeevstaff././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1711891919.0 python-redmine-2.5.0/tests/responses/__init__.py0000644000076500000240000000004014602262717022051 0ustar00maxtepkeevstafffrom .standard import responses ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1588945307.0 python-redmine-2.5.0/tests/responses/standard.py0000644000076500000240000000616613655260633022133 0ustar00maxtepkeevstaffresponses = { '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}}, 'get_special': {'wiki_page': {'title': 'Foo%Bar', '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': { 'get': {'news': {'title': 'Foo', 'id': 1}}, 'all': {'news': [{'title': 'Foo', 'id': 2}, {'title': 'Bar', 'id': 1}]}, 'filter': {'news': [{'title': 'Foo', 'id': 2}, {'title': 'Bar', 'id': 1}]}, }, '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}]}, }, } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709461107.0 python-redmine-2.5.0/tests/test_engines.py0000644000076500000240000001326014571047163020771 0ustar00maxtepkeevstaffimport warnings from . 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['headers']['X-Redmine-API-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_returns_none_with_ignore_response_true(self): with self.redmine.session(ignore_response=True): self.assertEqual(self.redmine.engine.ignore_response, True) self.assertEqual(self.redmine.engine.requests['stream'], True) self.assertEqual(self.redmine.engine.request('post', self.url), None) def test_returns_none_with_return_response_false(self): with self.redmine.session(return_response=False): self.assertEqual(self.redmine.engine.return_response, False) self.assertEqual(self.redmine.engine.request('post', self.url), None) 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(**{'status_code': 301, 'request.url': 'http://foo.bar'})] self.assertRaises(exceptions.HTTPProtocolError, lambda: self.redmine.engine.request('get', self.url)) def test_redirect_warning(self): self.response.history = [mock.Mock(**{'status_code': 301, 'request.url': 'https://www.foo.bar'})] with warnings.catch_warnings(record=True) as w: warnings.simplefilter('always') self.redmine.engine.request('get', self.url) self.assertEqual(len(w), 1) self.assertIs(w[0].category, exceptions.PerformanceWarning) 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') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709390063.0 python-redmine-2.5.0/tests/test_managers.py0000644000076500000240000003207114570634357021145 0ustar00maxtepkeevstaffimport 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.query), '') 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.assertEqual(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_create_resource_returns_none(self): with self.redmine.session(return_response=False): self.assertEqual(self.redmine.user.create(firstname='John', lastname='Smith'), None) 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.assertEqual(len(w), 1) self.assertIs(w[0].category, exceptions.PerformanceWarning) def test_update_resource_returns_none(self): with self.redmine.session(return_response=False): self.assertEqual(self.redmine.issue.update(1, subject='Bar'), None) 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_delete_resource_returns_none(self): with self.redmine.session(return_response=False): self.assertEqual(self.redmine.user.delete(1), None) 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')) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709461232.0 python-redmine-2.5.0/tests/test_redmine.py0000644000076500000240000002066114571047360020766 0ustar00maxtepkeevstaffimport 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.timezone, 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): from datetime import timezone FooEngine = type('FooEngine', (engines.BaseEngine,), {'create_session': lambda obj, **kwargs: None}) redmine = Redmine(self.url, version='1.0.0', date_format='format', datetime_format='format', timezone='+0000', raise_attr_exception=False, engine=FooEngine) self.assertEqual(redmine.url, self.url) self.assertEqual(redmine.ver, (1, 0, 0)) self.assertEqual(redmine.timezone, timezone.utc) 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_timezone_exception(self): self.assertRaises(exceptions.TimezoneError, lambda: Redmine(self.url, timezone='foobar')) 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['headers']['X-Redmine-API-Key'], 'opa') self.assertRaises(KeyError, lambda: self.redmine.engine.requests['headers']['X-Redmine-API-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['headers'], {'X-Redmine-API-Key': 'secret'}) self.assertEqual(self.redmine.engine.requests['params'], 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', filename='foo.jpg')['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.assertEqual(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(f'{self.url}/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(f'{self.url}/bar.txt').iter_content()), '01234') def test_file_url_exception(self): self.response.status_code = 200 self.assertRaises(exceptions.FileUrlError, lambda: self.redmine.download('https://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_unsupported_version_format_exception(self): self.assertRaises(exceptions.VersionFormatError, lambda: Redmine(self.url, version='4.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) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1709982459.0 python-redmine-2.5.0/tests/test_resources_standard.py0000644000076500000240000025112614573041373023237 0ustar00maxtepkeevstafffrom . import mock, BaseRedmineTestCase from .responses import responses from redminelib import resources, managers, 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, f'{self.url}/projects/foo') 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'), f'{self.url}/issues/1.pdf') 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, timezone 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)) self.redmine.timezone = timezone.utc issue.start_date = datetime(2014, 3, 9, 20, 2, 2, tzinfo=datetime.strptime('+0800', '%z').tzinfo) self.assertEqual(issue._decoded_attrs['start_date'], '2014-03-09T12:02:02Z') self.assertEqual(issue._changes['start_date'], '2014-03-09T12:02:02Z') self.assertEqual(issue.start_date, datetime( 2014, 3, 9, 20, 2, 2, tzinfo=datetime.strptime('+0800', '%z').tzinfo)) 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, timezone, timedelta 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)) self.redmine.timezone = datetime.strptime('+0800', '%z').tzinfo 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, 10, 4, 2, 2, tzinfo=timezone(timedelta(seconds=28800)))) 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) self.assertIn('children', attributes) self.assertIn('attachments', attributes) self.assertIn('manager', attributes) self.assertIn('url', attributes) self.assertIn('internal_id', attributes) def test_supports_iteration(self): self.response.json.return_value = responses['project']['get'] p = self.redmine.project.get(1) project = list(p) self.assertIn(('name', 'Foo'), project) self.assertIn(('id', 1), project) self.assertIn(('manager', p.manager), project) self.assertIn(('url', f'{self.url}/projects/foo'), project) self.assertIn(('internal_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_attach_attributes_through_registry(self): class BarResource(resources.BaseResource): _attach_includes = {'User': 'bars'} _attach_includes_map = {'User': ('bars', 'user_bars')} _attach_relations = {'Issue': 'bars', 'TimeEntry': 'bars'} self.assertEqual(resources.registry['BarResource']['class'], BarResource) self.assertIn(('bars', 'BarResource'), resources.registry['User']['attach_includes'].items()) self.assertIn((('bars', 'user_bars'), 'BarResource'), resources.registry['User']['attach_includes_map'].items()) self.assertIn(('bars', 'BarResource'), resources.registry['Issue']['attach_relations'].items()) self.assertIn(('bars', 'BarResource'), resources.registry['TimeEntry']['attach_relations'].items()) self.response.json.return_value = responses['user']['get'] user = self.redmine.user.get(1) self.assertIn('bars', user._includes) self.assertIn(('bars', 'BarResource'), user._resource_set_map.items()) self.assertIn(('bars', 'user_bars'), user._includes_map.items()) self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) self.assertIn('bars', issue._relations) self.assertIn(('bars', 'BarResource'), issue._resource_set_map.items()) self.response.json.return_value = responses['time_entry']['get'] time_entry = self.redmine.time_entry.get(1) self.assertIn('bars', time_entry._relations) self.assertIn(('bars', 'BarResource'), time_entry._resource_set_map.items()) def test_attach_attributes_through_registry_before_resource_was_added(self): resources.registry['BazResource'] = {'attach_relations': type('_', (), {'keys': lambda: set()})} class BazResource(resources.BaseResource): _relations = set() _attach_includes = {'QuxResource': 'bazs'} _attach_includes_map = {'QuxResource': ('bazs', 'qux_bazs')} self.assertEqual(resources.registry['BazResource']['class'], BazResource) self.assertEqual(resources.registry['QuxResource']['attach_includes'], {'bazs': 'BazResource'}) self.assertEqual( resources.registry['QuxResource']['attach_includes_map'], {('bazs', 'qux_bazs'): 'BazResource'}) def test_attach_includes(self): class QuxResource(resources.BaseResource): _attach_includes = {'User': 'quxs'} _attach_includes_map = {'User': ('quxs', 'user_quxs')} self.response.json.return_value = {'user': {'id': 1, 'user_quxs': [{'id': 1}]}} user = self.redmine.user.get(1) self.assertIsInstance(user.quxs, resultsets.ResourceSet) self.assertIsInstance(user.quxs[0], QuxResource) self.assertEqual(user.quxs[0].id, 1) def test_project_version(self): self.assertEqual(self.redmine.project.resource_class.redmine_version, (1, 0, 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 = self.url 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(responses['enumeration']['filter']) self.response.json.return_value = response_includes self.assertIsInstance(project.time_entry_activities, resultsets.ResourceSet) response_includes['project'].update(responses['custom_field']['all']) self.response.json.return_value = response_includes self.assertIsInstance(project.issue_custom_fields, resultsets.ResourceSet) 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, f'{self.url}/projects/foo') @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_project_resource_map_converts_to_resource(self): self.response.json.return_value = responses['project']['get'] project = self.redmine.project.get(1) project._decoded_attrs['default_version'] = {'id': 2, 'name': 'Release 4.0'} self.assertIsInstance(project.default_version, resources.Version) self.assertEqual(project.default_version.id, 2) project._decoded_attrs['default_assignee'] = {'id': 4, 'name': 'John Smith'} self.assertIsInstance(project.default_assignee, resources.User) self.assertEqual(project.default_assignee.id, 4) def test_project_sets_attrs_from_single_attr_id_map(self): self.response.json.return_value = responses['project']['get'] project = self.redmine.project.get(1) project.parent_id = 1 self.assertEqual(project._decoded_attrs['parent'], {'id': 1}) def test_project_sets_attrs_from_multiple_attr_id_map(self): self.response.json.return_value = responses['project']['get'] project = self.redmine.project.get(1) project.tracker_ids = [1, 2] self.assertEqual(project._decoded_attrs['trackers'], [{'id': 1}, {'id': 2}]) def test_project_supports_close_reopen_archive_unarchive(self): self.response.json.return_value = responses['project']['get'] project = self.redmine.project.get(1) self.response.content = '' self.assertEqual(project.close(), True) self.assertEqual(project.reopen(), True) self.assertEqual(project.archive(), True) self.assertEqual(project.unarchive(), True) self.assertEqual(self.redmine.project.close(1), True) self.assertEqual(self.redmine.project.reopen(1), True) self.assertEqual(self.redmine.project.archive(1), True) self.assertEqual(self.redmine.project.unarchive(1), True) self.redmine.ver = (4, 1, 0) self.assertRaises(exceptions.VersionMismatchError, lambda: project.close()) self.assertRaises(AttributeError, lambda: project.open()) def test_issue_version(self): self.assertEqual(self.redmine.issue.resource_class.redmine_version, (1, 0, 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_copy(self): import json self.response.status_code = 201 self.response.json.return_value = {'issue': {'subject': 'Foo', 'id': 1, 'project': {'id': 1}}} self.redmine.issue.get(1).copy() request = json.loads(self.patch_requests.call_args[1]['data']) self.assertEqual(request['copy_from'], 1) self.assertEqual(request['link_copy'], '1') self.assertEqual(request['copy_subtasks'], '1') self.assertEqual(request['copy_attachments'], '1') def test_issue_copy_via_manager(self): import json self.response.status_code = 201 self.response.json.return_value = responses['issue']['get'] self.redmine.issue.copy(1, project_id=1, link_original=False, include=None) request = json.loads(self.patch_requests.call_args[1]['data']) self.assertEqual(request['copy_from'], 1) self.assertNotIn('link_copy', request) self.assertNotIn('copy_subtasks', request) self.assertNotIn('copy_attachments', request) def test_issue_custom_manager(self): self.assertEqual(repr(self.redmine.issue), '') self.assertIsInstance(self.redmine.issue, managers.IssueManager) 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) response_includes['issue']['allowed_statuses'] = responses['issue_status']['all']['issue_statuses'] self.response.json.return_value = response_includes self.assertIsInstance(issue.allowed_statuses, 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_journals_update(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) issue._decoded_attrs['journals'] = [{'id': 1}] self.response.content = '' self.assertIsInstance(issue.journals[0].save(notes='new value'), resources.IssueJournal) self.assertEqual(self.redmine.issue_journal.update(1, notes='new value'), True) def test_issue_journals_resource_map_converts_to_resource(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) issue._decoded_attrs['journals'] = [{'id': 1, 'user': responses['user']['get']['user']}] self.assertIsInstance(issue.journals[0].user, resources.User) self.assertEqual(issue.journals[0].user.firstname, 'John') 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, f'{self.url}/issues/1') @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_issue_resource_map_converts_to_resource(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) issue._decoded_attrs['project'] = responses['project']['get']['project'] self.assertIsInstance(issue.project, resources.Project) self.assertEqual(issue.project.identifier, 'foo') issue._decoded_attrs['tracker'] = responses['tracker']['all']['trackers'][0] self.assertIsInstance(issue.tracker, resources.Tracker) self.assertEqual(issue.tracker.id, 1) issue._decoded_attrs['status'] = responses['issue_status']['all']['issue_statuses'][0] self.assertIsInstance(issue.status, resources.IssueStatus) self.assertEqual(issue.status.id, 1) issue._decoded_attrs['priority'] = responses['enumeration']['filter']['time_entry_activities'][0] self.assertIsInstance(issue.priority, resources.Enumeration) self.assertEqual(issue.priority.id, 1) issue._decoded_attrs['author'] = responses['user']['get']['user'] self.assertIsInstance(issue.author, resources.User) self.assertEqual(issue.author.firstname, 'John') issue._decoded_attrs['assigned_to'] = responses['user']['get']['user'] self.assertIsInstance(issue.assigned_to, resources.User) self.assertEqual(issue.assigned_to.lastname, 'Smith') issue._decoded_attrs['category'] = responses['issue_category']['get']['issue_category'] self.assertIsInstance(issue.category, resources.IssueCategory) self.assertEqual(issue.category.id, 1) issue._decoded_attrs['fixed_version'] = responses['version']['get']['version'] self.assertIsInstance(issue.fixed_version, resources.Version) self.assertEqual(issue.fixed_version.id, 1) def test_issue_sets_attrs_from_single_attr_id_map(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) issue.project_id = 1 issue.tracker_id = 1 issue.status_id = 1 issue.priority_id = 1 issue.category_id = 1 issue.fixed_version_id = 1 issue.assigned_to_id = 1 issue.parent_issue_id = 1 self.assertEqual(issue._decoded_attrs['project'], {'id': 1}) self.assertEqual(issue._decoded_attrs['tracker'], {'id': 1}) self.assertEqual(issue._decoded_attrs['status'], {'id': 1}) self.assertEqual(issue._decoded_attrs['priority'], {'id': 1}) self.assertEqual(issue._decoded_attrs['category'], {'id': 1}) self.assertEqual(issue._decoded_attrs['fixed_version'], {'id': 1}) self.assertEqual(issue._decoded_attrs['assigned_to'], {'id': 1}) self.assertEqual(issue._decoded_attrs['parent'], {'id': 1}) def test_issue_sets_attrs_from_multiple_attr_id_map(self): self.response.json.return_value = responses['issue']['get'] issue = self.redmine.issue.get(1) issue.watcher_user_ids = [1, 2] self.assertEqual(issue._decoded_attrs['watchers'], [{'id': 1}, {'id': 2}]) def test_time_entry_version(self): self.assertEqual(self.redmine.time_entry.resource_class.redmine_version, (1, 1, 0)) 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, f'{self.url}/time_entries/1') @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_time_entry_resource_map_converts_to_resource(self): self.response.json.return_value = responses['time_entry']['get'] time_entry = self.redmine.time_entry.get(1) time_entry._decoded_attrs['project'] = responses['project']['get']['project'] self.assertIsInstance(time_entry.project, resources.Project) self.assertEqual(time_entry.project.identifier, 'foo') time_entry._decoded_attrs['issue'] = responses['issue']['get']['issue'] self.assertIsInstance(time_entry.issue, resources.Issue) self.assertEqual(time_entry.issue.id, 1) time_entry._decoded_attrs['user'] = responses['user']['get']['user'] self.assertIsInstance(time_entry.user, resources.User) self.assertEqual(time_entry.user.firstname, 'John') time_entry._decoded_attrs['activity'] = responses['enumeration']['filter']['time_entry_activities'][0] self.assertIsInstance(time_entry.activity, resources.Enumeration) self.assertEqual(time_entry.activity.id, 1) def test_time_entry_sets_attrs_from_single_attr_id_map(self): self.response.json.return_value = responses['time_entry']['get'] time_entry = self.redmine.time_entry.get(1) time_entry.project_id = 1 time_entry.issue_id = 1 time_entry.activity_id = 1 self.assertEqual(time_entry._decoded_attrs['project'], {'id': 1}) self.assertEqual(time_entry._decoded_attrs['issue'], {'id': 1}) self.assertEqual(time_entry._decoded_attrs['activity'], {'id': 1}) def test_enumeration_version(self): self.assertEqual(self.redmine.enumeration.resource_class.redmine_version, (2, 2, 0)) 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, f'{self.url}/enumerations/1/edit' ) def test_attachment_version(self): self.assertEqual(self.redmine.attachment.resource_class.redmine_version, (1, 3, 0)) 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, f'{self.url}/attachments/1') @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_attachment_download(self): response = responses['attachment']['get'] response['attachment']['content_url'] = f'{self.url}/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_attachment_resource_map_converts_to_resource(self): self.response.json.return_value = responses['attachment']['get'] attachment = self.redmine.attachment.get(1) attachment._decoded_attrs['author'] = responses['user']['get']['user'] self.assertIsInstance(attachment.author, resources.User) self.assertEqual(attachment.author.firstname, 'John') def test_file_version(self): self.assertEqual(self.redmine.file.resource_class.redmine_version, (3, 4, 0)) 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, f'{self.url}/attachments/1') @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_file_download(self): response = responses['attachment']['get'] response['attachment']['content_url'] = f'{self.url}/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_file_resource_map_converts_to_resource(self): self.response.json.return_value = responses['attachment']['get'] f = self.redmine.file.get(1) f._decoded_attrs['author'] = responses['user']['get']['user'] self.assertIsInstance(f.author, resources.User) self.assertEqual(f.author.firstname, 'John') f._decoded_attrs['version'] = responses['version']['get']['version'] self.assertIsInstance(f.version, resources.Version) self.assertEqual(f.version.id, 1) def test_wiki_page_version(self): self.assertEqual(self.redmine.wiki_page.resource_class.redmine_version, (2, 2, 0)) 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_get_special(self): """Test getting a wiki page with special char in title.""" self.response.json.return_value = responses['wiki_page']['get_special'] wiki_page = self.redmine.wiki_page.get('Foo%Bar', project_id=1) self.assertEqual(self.patch_requests.call_args[0][1], f'{self.url}/projects/1/wiki/Foo%25Bar.json') self.assertEqual(wiki_page.title, 'Foo%Bar') self.assertEqual(wiki_page.url, f'{self.url}/projects/1/wiki/Foo%25Bar') 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_create_special(self): """Test creating a wiki page with special char in title.""" self.response.status_code = 201 self.response.json.return_value = responses['wiki_page']['get_special'] wiki_page = self.redmine.wiki_page.create(project_id='foo', title='Foo%Bar') self.assertEqual(self.patch_requests.call_args[0][1], f'{self.url}/projects/foo/wiki/Foo%25Bar.json') self.assertEqual(wiki_page.title, 'Foo%Bar') 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, f'{self.url}/projects/Foo/wiki/Foo') @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_wiki_page_resource_map_converts_to_resource(self): self.response.json.return_value = responses['wiki_page']['get'] wiki_page = self.redmine.wiki_page.get('Foo', project_id=1) wiki_page._decoded_attrs['author'] = responses['user']['get']['user'] self.assertIsInstance(wiki_page.author, resources.User) self.assertEqual(wiki_page.author.firstname, 'John') def test_wiki_page_sets_attrs_from_single_attr_id_map(self): self.response.json.return_value = responses['wiki_page']['get'] wiki_page = self.redmine.wiki_page.get(1, project_id=1) wiki_page.project_id = 1 self.assertEqual(wiki_page._decoded_attrs['project'], {'id': 1}) def test_project_membership_version(self): self.assertEqual(self.redmine.project_membership.resource_class.redmine_version, (1, 4, 0)) 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, f'{self.url}/memberships/1') def test_project_membership_resource_map_converts_to_resource(self): self.response.json.return_value = responses['project_membership']['get'] membership = self.redmine.project_membership.get(1) membership._decoded_attrs['project'] = responses['project']['get']['project'] self.assertIsInstance(membership.project, resources.Project) self.assertEqual(membership.project.identifier, 'foo') membership._decoded_attrs['user'] = responses['user']['get']['user'] self.assertIsInstance(membership.user, resources.User) self.assertEqual(membership.user.firstname, 'John') membership._decoded_attrs['group'] = responses['group']['get']['group'] self.assertIsInstance(membership.group, resources.Group) self.assertEqual(membership.group.id, 1) def test_project_membership_sets_attrs_from_single_attr_id_map(self): self.response.json.return_value = responses['project_membership']['get'] membership = self.redmine.project_membership.get(1) membership.project_id = 1 membership.user_id = 1 self.assertEqual(membership._decoded_attrs['project'], {'id': 1}) self.assertEqual(membership._decoded_attrs['user'], {'id': 1}) def test_project_membership_sets_attrs_from_multiple_attr_id_map(self): self.response.json.return_value = responses['project_membership']['get'] membership = self.redmine.project_membership.get(1) membership.role_ids = [1, 2] self.assertEqual(membership._decoded_attrs['roles'], [{'id': 1}, {'id': 2}]) def test_issue_category_version(self): self.assertEqual(self.redmine.issue_category.resource_class.redmine_version, (1, 3, 0)) 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, f'{self.url}/issue_categories/1') def test_issue_category_resource_map_converts_to_resource(self): self.response.json.return_value = responses['issue_category']['get'] category = self.redmine.issue_category.get(1) category._decoded_attrs['project'] = responses['project']['get']['project'] self.assertIsInstance(category.project, resources.Project) self.assertEqual(category.project.identifier, 'foo') category._decoded_attrs['assigned_to'] = responses['user']['get']['user'] self.assertIsInstance(category.assigned_to, resources.User) self.assertEqual(category.assigned_to.firstname, 'John') def test_issue_category_sets_attrs_from_single_attr_id_map(self): self.response.json.return_value = responses['issue_category']['get'] category = self.redmine.issue_category.get(1) category.project_id = 1 category.assigned_to_id = 1 self.assertEqual(category._decoded_attrs['project'], {'id': 1}) self.assertEqual(category._decoded_attrs['assigned_to'], {'id': 1}) def test_issue_relation_version(self): self.assertEqual(self.redmine.issue_relation.resource_class.redmine_version, (1, 3, 0)) 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, f'{self.url}/relations/1') def test_issue_relation_sets_attrs_from_single_attr_id_map(self): self.response.json.return_value = responses['issue_relation']['get'] relation = self.redmine.issue_relation.get(1) relation.issue_id = 1 self.assertEqual(relation._decoded_attrs['issue'], {'id': 1}) def test_version_version(self): self.assertEqual(self.redmine.version.resource_class.redmine_version, (1, 3, 0)) 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, f'{self.url}/versions/1') def test_version_resource_map_converts_to_resource(self): self.response.json.return_value = responses['version']['get'] version = self.redmine.version.get(1) version._decoded_attrs['project'] = responses['project']['get']['project'] self.assertIsInstance(version.project, resources.Project) self.assertEqual(version.project.identifier, 'foo') def test_version_relation_sets_attrs_from_single_attr_id_map(self): self.response.json.return_value = responses['version']['get'] version = self.redmine.version.get(1) version.project_id = 1 self.assertEqual(version._decoded_attrs['project'], {'id': 1}) def test_user_version(self): self.assertEqual(self.redmine.user.resource_class.redmine_version, (1, 1, 0)) 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_get_account(self): self.response.json.return_value = responses['user']['get'] user = self.redmine.user.get('me') self.assertEqual(user.firstname, 'John') self.assertTrue(self.patch_requests.call_args[0][1].endswith('/my/account.json')) 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_all_url_variations(self): self.redmine.ver = (5, 0, 0) self.assertEqual(self.redmine.user.all().manager.url, f'{self.url}/users.json?status=') self.redmine.ver = (5, 1, 0) self.assertEqual(self.redmine.user.all().manager.url, f'{self.url}/users.json?f[]=status_id&' f'op[status_id]==&v[status_id][]=1&v[status_id][]=2&v[status_id][]=3') self.redmine.ver = (6, 0, 0) self.assertEqual(self.redmine.user.all().manager.url, f'{self.url}/users.json?status=*') 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_create_with_send_information(self): import json self.response.status_code = 201 self.response.json.return_value = responses['user']['get'] self.redmine.user.create(firstname='John', lastname='Smith', send_information=True) self.assertEqual(json.loads(self.patch_requests.call_args[1]['data'])['send_information'], True) 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_update_account(self): self.redmine.user.update('me', lastname='Foo', firstname='Bar') self.assertTrue(self.patch_requests.call_args[0][1].endswith('/my/account.json')) def test_user_update_with_send_information(self): import json self.response.json.return_value = responses['user']['get'] self.redmine.user.update(1, firstname='John', lastname='Smith', send_information=True) self.assertEqual(json.loads(self.patch_requests.call_args[1]['data'])['send_information'], True) 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.issues_assigned, resultsets.ResourceSet) self.assertIsInstance(user.issues_authored, 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, f'{self.url}/users/1') @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_user_export(self): self.response.json.return_value = responses['user']['all'] self.response.iter_content = lambda chunk_size: (str(num) for num in range(0, 5)) self.assertEqual(self.redmine.user.all().export('txt', '/foo/bar/'), '/foo/bar/users.txt') def test_group_version(self): self.assertEqual(self.redmine.group.resource_class.redmine_version, (2, 1, 0)) 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, f'{self.url}/groups/1') def test_group_sets_attrs_from_multiple_attr_id_map(self): self.response.json.return_value = responses['group']['get'] group = self.redmine.group.get(1) group.user_ids = [1, 2] self.assertEqual(group._decoded_attrs['users'], [{'id': 1}, {'id': 2}]) def test_role_version(self): self.assertEqual(self.redmine.role.resource_class.redmine_version, (1, 4, 0)) 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, f'{self.url}/roles/1') def test_news_version(self): self.assertEqual(self.redmine.news.resource_class.redmine_version, (1, 1, 0)) def test_news_get(self): self.response.json.return_value = responses['news']['get'] 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, 2) self.assertEqual(news[0].title, 'Foo') self.assertEqual(news[1].id, 1) 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, 2) self.assertEqual(news[0].title, 'Foo') self.assertEqual(news[1].id, 1) self.assertEqual(news[1].title, 'Bar') def test_news_create(self): self.response.status_code = 201 self.response.json.return_value = responses['news']['get'] news = self.redmine.news.create(project_id=1, title='Foo') self.assertEqual(news.title, 'Foo') def test_news_create_empty_response(self): self.set_patch_side_effect([ mock.Mock(status_code=204, history=[], content=''), mock.Mock(status_code=201, history=[], **{'json.return_value': responses['news']['filter']}) ]) news = self.redmine.news.create(project_id=1, title='Foo') self.assertEqual(news.title, 'Foo') def test_news_delete(self): self.response.json.return_value = responses['news']['get'] news = self.redmine.news.get(1) self.response.content = '' self.assertEqual(news.delete(), True) self.assertEqual(self.redmine.news.delete(1), True) def test_news_update(self): self.response.json.return_value = responses['news']['get'] news = self.redmine.news.get(1) news.title = 'Bar' self.assertIsInstance(news.save(), resources.News) def test_news_url(self): self.response.json.return_value = responses['news']['filter'] self.assertEqual(self.redmine.news.filter(project_id=1)[0].url, f'{self.url}/news/2') @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_news_resource_map_converts_to_resource(self): self.response.json.return_value = responses['news']['get'] news = self.redmine.news.get(1) news._decoded_attrs['project'] = responses['project']['get']['project'] self.assertIsInstance(news.project, resources.Project) self.assertEqual(news.project.identifier, 'foo') news._decoded_attrs['author'] = responses['user']['get']['user'] self.assertIsInstance(news.author, resources.User) self.assertEqual(news.author.firstname, 'John') def test_news_includes(self): response_includes = responses['news']['get'] self.response.json.return_value = response_includes news = self.redmine.news.get(1) response_includes['news'].update({'attachments': [responses['attachment']['get']['attachment']]}) self.response.json.return_value = response_includes self.assertIsInstance(news.attachments, resultsets.ResourceSet) response_includes['news'].update({'comments': [{'id': 1, 'content': 'foobar'}]}) self.response.json.return_value = response_includes self.assertIsInstance(news.comments, list) def test_news_sets_attrs_from_single_attr_id_map(self): self.response.json.return_value = responses['news']['get'] news = self.redmine.news.get(1) news.project_id = 1 self.assertEqual(news._decoded_attrs['project'], {'id': 1}) def test_issue_status_version(self): self.assertEqual(self.redmine.issue_status.resource_class.redmine_version, (1, 3, 0)) 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, f'{self.url}/issue_statuses/1/edit') def test_tracker_version(self): self.assertEqual(self.redmine.tracker.resource_class.redmine_version, (1, 3, 0)) 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, f'{self.url}/trackers/1/edit') def test_query_version(self): self.assertEqual(self.redmine.query.resource_class.redmine_version, (1, 3, 0)) 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, f'{self.url}/projects/0/issues?query_id=1') def test_custom_field_version(self): self.assertEqual(self.redmine.custom_field.resource_class.redmine_version, (2, 4, 0)) 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, f'{self.url}/custom_fields/1/edit') ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1677854872.0 python-redmine-2.5.0/tests/test_resultsets.py0000644000076500000240000002440614400404230021541 0ustar00maxtepkeevstafffrom . 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().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') @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_export_with_all_columns(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/', columns='all'), '/foo/bar/issues.txt') self.redmine.ver = (3, 3, 0) self.assertEqual(self.redmine.issue.all().export('txt', '/foo/bar/', columns='all'), '/foo/bar/issues.txt') @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_export_with_all_gui_columns(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/', columns='all_gui'), '/foo/bar/issues.txt') self.redmine.ver = (3, 3, 0) self.assertEqual(self.redmine.issue.all().export('txt', '/foo/bar/', columns='all_gui'), '/foo/bar/issues.txt') @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_export_with_all_gui_extra_columns(self): columns = ['all_gui'] 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/', columns=columns), '/foo/bar/issues.txt') self.redmine.ver = (3, 3, 0) self.assertEqual(self.redmine.issue.all().export('txt', '/foo/bar/', columns=columns), '/foo/bar/issues.txt') @mock.patch('redminelib.open', mock.mock_open(), create=True) def test_export_with_custom_columns(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/', columns=['status']), '/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])