├── docs ├── readme.rst ├── changelog.rst ├── contributing.rst ├── sources │ ├── modules.rst │ ├── confluence.rst │ ├── confluence.exceptions.rst │ └── confluence.models.rst ├── index.rst ├── Makefile ├── make.bat └── conf.py ├── MANIFEST.in ├── confluence ├── exceptions │ ├── __init__.py │ ├── valuetoolong.py │ ├── permissionerror.py │ ├── resourcenotfound.py │ ├── generalerror.py │ ├── authenticationerror.py │ └── versionconflict.py ├── __init__.py ├── models │ ├── __init__.py │ ├── group.py │ ├── icon.py │ ├── label.py │ ├── version.py │ ├── user.py │ ├── contenthistory.py │ ├── longtask.py │ ├── contentbody.py │ ├── auditrecord.py │ ├── space.py │ └── content.py └── client.py ├── tests ├── __init__.py └── models │ ├── __init__.py │ ├── test_group.py │ ├── test_label.py │ ├── test_icon.py │ ├── test_version.py │ ├── test_comment.py │ ├── test_user.py │ ├── test_longtask.py │ ├── test_content_property.py │ ├── test_attachment.py │ ├── test_auditrecord.py │ ├── test_space.py │ └── test_page.py ├── integration_tests ├── __init__.py ├── test_authentication_error.py ├── config.py ├── test_context_managed_client.py ├── test_label_operations.py ├── test_content_history_operations.py ├── test_space_operations.py ├── test_content_property_operations.py ├── test_watch_operations.py ├── test_attachment_operations.py └── test_content_operations.py ├── .idea ├── encodings.xml └── dictionaries │ └── dat.xml ├── .github └── dependabot.yml ├── setup.cfg ├── CONTRIBUTING.rst ├── LICENSE ├── setup.py ├── .travis.yml ├── README.rst ├── .gitignore ├── CHANGELOG.rst └── endpoints.md /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CHANGELOG.rst -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGELOG.rst 2 | include LICENSE 3 | include README.rst 4 | -------------------------------------------------------------------------------- /confluence/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | """Package contains the exceptions which can be raised by the API.""" 2 | -------------------------------------------------------------------------------- /confluence/__init__.py: -------------------------------------------------------------------------------- 1 | """The top level package for the confluence library. See usage details in client.py.""" 2 | -------------------------------------------------------------------------------- /confluence/models/__init__.py: -------------------------------------------------------------------------------- 1 | """Package contains the various objects which are deserialised on the confluence API.""" 2 | -------------------------------------------------------------------------------- /docs/sources/modules.rst: -------------------------------------------------------------------------------- 1 | confluence 2 | ========== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | confluence 8 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger(__name__) 4 | logger.addHandler(logging.NullHandler()) 5 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger(__name__) 4 | logger.addHandler(logging.NullHandler()) 5 | -------------------------------------------------------------------------------- /integration_tests/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | logger = logging.getLogger(__name__) 4 | logger.addHandler(logging.NullHandler()) 5 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.idea/dictionaries/dat.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | tmpdir 5 | 6 | 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.rst 3 | 4 | [bdist_wheel] 5 | universal = 1 6 | 7 | [aliases] 8 | test = pytest 9 | 10 | [pycodestyle] 11 | max-line-length = 160 -------------------------------------------------------------------------------- /tests/models/test_group.py: -------------------------------------------------------------------------------- 1 | from confluence.models.group import Group 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | def test_create_group_with_valid_json(): 9 | g = Group({ 10 | 'type': 'Type', 11 | 'name': 'Name' 12 | }) 13 | assert str(g) == 'Name' 14 | -------------------------------------------------------------------------------- /tests/models/test_label.py: -------------------------------------------------------------------------------- 1 | from confluence.models.label import Label 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | def test_create_label(): 9 | label = Label({ 10 | "prefix": "global", 11 | "name": "branding", 12 | "id": "12345" 13 | }) 14 | 15 | assert str(label) == 'branding' 16 | -------------------------------------------------------------------------------- /tests/models/test_icon.py: -------------------------------------------------------------------------------- 1 | from confluence.models.icon import Icon 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | def test_create_with_valid_json(): 9 | i = Icon({ 10 | 'path': 'https://a.com', 11 | 'width': 200, 12 | 'height': 201, 13 | 'isDefault': False 14 | }) 15 | 16 | assert str(i) == 'https://a.com [200x201]' 17 | -------------------------------------------------------------------------------- /confluence/models/group.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | class Group: 9 | """Represents a group object in confluence.""" 10 | 11 | def __init__(self, json): # type: (Dict[str, Any]) -> None 12 | self.type = json['type'] 13 | self.name = json['name'] 14 | 15 | def __str__(self): 16 | return self.name 17 | -------------------------------------------------------------------------------- /integration_tests/test_authentication_error.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from confluence.client import Confluence 4 | from confluence.exceptions.authenticationerror import ConfluenceAuthenticationError 5 | from integration_tests.config import local_url 6 | 7 | 8 | def test_bad_username_password(): # type: () -> None 9 | c = Confluence(local_url, ('bad', 'bad')) 10 | with pytest.raises(ConfluenceAuthenticationError): 11 | c.create_space('OK', 'ok') 12 | -------------------------------------------------------------------------------- /integration_tests/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from confluence.client import Confluence 4 | 5 | local_url = 'http://localhost:1990/confluence' 6 | local_admin = ('admin', 'admin') 7 | 8 | 9 | def get_confluence_instance(): 10 | # type: () -> Confluence 11 | user = os.environ.get('ATLASSIAN_CLOUD_USER') 12 | password = os.environ.get('ATLASSIAN_CLOUD_PASSWORD') 13 | url = os.environ.get('ATLASSIAN_CLOUD_URL') 14 | 15 | return Confluence(url, (user, password)) if user and password and url else Confluence(local_url, local_admin) 16 | -------------------------------------------------------------------------------- /docs/sources/confluence.rst: -------------------------------------------------------------------------------- 1 | confluence package 2 | ================== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | 9 | confluence.exceptions 10 | confluence.models 11 | 12 | Submodules 13 | ---------- 14 | 15 | confluence.client module 16 | ------------------------ 17 | 18 | .. automodule:: confluence.client 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | 24 | Module contents 25 | --------------- 26 | 27 | .. automodule:: confluence 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | -------------------------------------------------------------------------------- /confluence/models/icon.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | class Icon: 9 | """Represents a single user or space icon in confluence.""" 10 | 11 | def __init__(self, json): # type: (Dict[str, Any]) -> None 12 | self.path = json['path'] 13 | self.width = json['width'] 14 | self.height = json['height'] 15 | self.is_default = json['isDefault'] 16 | 17 | def __str__(self): 18 | return '{} [{}x{}]'.format(self.path, self.width, self.height) 19 | -------------------------------------------------------------------------------- /integration_tests/test_context_managed_client.py: -------------------------------------------------------------------------------- 1 | from integration_tests.config import get_confluence_instance 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | c = get_confluence_instance() 8 | 9 | 10 | def test_context_managed_client(): 11 | """ 12 | This test just verifies that a context managed client can be used to 13 | perform requests. 14 | """ 15 | with c: 16 | c.create_space('TCMC', 'Test context managed space', 'Description') 17 | c.delete_space('TCMC') 18 | assert str(c) is not None 19 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Confluence REST API documentation master file, created by 2 | sphinx-quickstart on Sat Mar 10 14:50:42 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Confluence REST API's documentation! 7 | =============================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | readme 14 | contributing 15 | changelog 16 | 17 | Indices and tables 18 | ================== 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | -------------------------------------------------------------------------------- /tests/models/test_version.py: -------------------------------------------------------------------------------- 1 | from confluence.models.version import Version 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | def test_page_update_creation(): 9 | cv = Version({ 10 | 'by': { 11 | 'username': '1', 12 | 'displayName': '2', 13 | 'userKey': '3', 14 | 'type': '4' 15 | }, 16 | 'when': '2017-02-01', 17 | 'message': 'Hello', 18 | 'number': 1, 19 | 'minorEdit': False, 20 | 'hidden': False 21 | }) 22 | 23 | assert str(cv) == '1' 24 | -------------------------------------------------------------------------------- /confluence/exceptions/valuetoolong.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | from typing import Dict 4 | 5 | from confluence.exceptions.generalerror import ConfluenceError 6 | 7 | logger = logging.getLogger(__name__) 8 | logger.addHandler(logging.NullHandler()) 9 | 10 | 11 | class ConfluenceValueTooLong(ConfluenceError): 12 | """Corresponds to 413 errors on the REST API.""" 13 | 14 | def __init__(self, path, params, response): 15 | # type: (str, Dict[str, str], requests.Response) -> None 16 | msg = 'Resource post to path {} was too large'.format(path) 17 | super(ConfluenceValueTooLong, self).__init__(path, params, response, msg) 18 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = Confluence REST API 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /confluence/exceptions/permissionerror.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | from typing import Dict 4 | 5 | from confluence.exceptions.generalerror import ConfluenceError 6 | 7 | logger = logging.getLogger(__name__) 8 | logger.addHandler(logging.NullHandler()) 9 | 10 | 11 | class ConfluencePermissionError(ConfluenceError): 12 | """Corresponds to 403 errors on the REST API.""" 13 | 14 | def __init__(self, path, params, response): 15 | # type: (str, Dict[str, str], requests.Response) -> None 16 | msg = 'User has insufficient permissions to perform that operation on the path {}'.format(path) 17 | super(ConfluencePermissionError, self).__init__(path, params, response, msg) 18 | -------------------------------------------------------------------------------- /confluence/exceptions/resourcenotfound.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | from typing import Dict 4 | 5 | from confluence.exceptions.generalerror import ConfluenceError 6 | 7 | logger = logging.getLogger(__name__) 8 | logger.addHandler(logging.NullHandler()) 9 | 10 | 11 | class ConfluenceResourceNotFound(ConfluenceError): 12 | """Corresponds to 404 errors on the REST API.""" 13 | 14 | def __init__(self, path, params, response): 15 | # type: (str, Dict[str, str], requests.Response) -> None 16 | msg = 'Resource was not found at path {} or the user has insufficient permissions'.format(path) 17 | super(ConfluenceResourceNotFound, self).__init__(path, params, response, msg) 18 | -------------------------------------------------------------------------------- /tests/models/test_comment.py: -------------------------------------------------------------------------------- 1 | from confluence.models.content import Content 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | def test_create_group_with_valid_json(): 9 | c = Content({ 10 | "id": "39128239", 11 | "type": "comment", 12 | "status": "current", 13 | "title": "Re: Puppet: Architecture Overview", 14 | "extensions": { 15 | "location": "inline", 16 | "_expandable": { 17 | "inlineProperties": "", 18 | "resolution": "" 19 | } 20 | } 21 | }) 22 | assert str(c) == '39128239 - Re: Puppet: Architecture Overview' 23 | -------------------------------------------------------------------------------- /confluence/exceptions/generalerror.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, Optional 3 | 4 | import requests 5 | 6 | logger = logging.getLogger(__name__) 7 | logger.addHandler(logging.NullHandler()) 8 | 9 | 10 | class ConfluenceError(Exception): 11 | """Corresponds to 400 errors on the REST API.""" 12 | 13 | def __init__(self, path, params, response, msg=None): 14 | # type: (str, Dict[str, str], requests.Response, Optional[str]) -> None 15 | if not msg: 16 | msg = 'General resource error accessing path {}'.format(path) 17 | self.path = path 18 | self.params = params 19 | self.response = response 20 | super(ConfluenceError, self).__init__(msg) 21 | -------------------------------------------------------------------------------- /confluence/exceptions/authenticationerror.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | from typing import Dict 4 | 5 | from confluence.exceptions.generalerror import ConfluenceError 6 | 7 | logger = logging.getLogger(__name__) 8 | logger.addHandler(logging.NullHandler()) 9 | 10 | 11 | class ConfluenceAuthenticationError(ConfluenceError): 12 | """This exception corresponds to 401 errors on the REST API.""" 13 | 14 | def __init__(self, path, params, response): 15 | # type: (str, Dict[str, str], requests.Response) -> None 16 | msg = 'Authentication failure. This is most likely due to incorrect username/password'.format(path) 17 | super(ConfluenceAuthenticationError, self).__init__(path, params, response, msg) 18 | -------------------------------------------------------------------------------- /confluence/exceptions/versionconflict.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | from typing import Dict 4 | 5 | from confluence.exceptions.generalerror import ConfluenceError 6 | 7 | logger = logging.getLogger(__name__) 8 | logger.addHandler(logging.NullHandler()) 9 | 10 | 11 | class ConfluenceVersionConflict(ConfluenceError): 12 | """Corresponds to 409 errors on the REST API.""" 13 | 14 | def __init__(self, path, params, response): 15 | # type: (str, Dict[str, str], requests.Response) -> None 16 | msg = 'The given version does not match the expected next version. This is likely because someone else has ' \ 17 | 'made a change to the resource at path {}'.format(path) 18 | super(ConfluenceVersionConflict, self).__init__(path, params, response, msg) 19 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | How to contribute 2 | ================= 3 | 4 | You’re very welcome to make bug fixes or enhancements to this library. 5 | This document lays out the guidelines for how to get those changes into 6 | the main package repository. 7 | 8 | Getting Started 9 | --------------- 10 | 11 | 1. Fork the repository on github: 12 | https://github.com/DaveTCode/confluence-python-lib 13 | 2. Make changes 14 | 3. Send pull request 15 | 16 | Using your changes before they’re live 17 | -------------------------------------- 18 | 19 | You may want to use the changes you’ve made to this library before the 20 | merging/review process has been completed. To do this you can install it 21 | into the global python environment by running this command from the top 22 | level directory. 23 | 24 | :: 25 | 26 | pip install . --upgrade -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=Confluence REST API 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /tests/models/test_user.py: -------------------------------------------------------------------------------- 1 | from confluence.models.user import User 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | def test_creation_valid_json(): 9 | u = User({ 10 | 'username': '1', 11 | 'displayName': '2', 12 | 'userKey': '3', 13 | 'type': '4', 14 | 'profilePicture': { 15 | 'path': 'https://a.com', 16 | 'width': 200, 17 | 'height': 201, 18 | 'isDefault': False 19 | } 20 | }) 21 | assert u.username == '1' 22 | assert u.display_name == '2' 23 | assert u.user_key == '3' 24 | assert u.type == '4' 25 | 26 | assert str(u) == '1' 27 | 28 | 29 | def test_creation_minimal_json(): 30 | u = User({}) 31 | assert u.username is None 32 | assert not hasattr(u, 'display_name') 33 | assert not hasattr(u, 'user_key') 34 | assert not hasattr(u, 'type') 35 | 36 | assert str(u) == 'None' 37 | -------------------------------------------------------------------------------- /tests/models/test_longtask.py: -------------------------------------------------------------------------------- 1 | from confluence.models.longtask import LongTask 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | def test_create_full_json(): 9 | t = LongTask({ 10 | "id": "14365eab-f2df-4ecb-9458-75ad3af903a7", 11 | "name": { 12 | "key": "com.atlassian.confluence.extra.flyingpdf.exporttaskname", 13 | "args": []}, 14 | "elapsedTime": 101770, 15 | "percentageComplete": 100, 16 | "successful": True, 17 | "messages": [{ 18 | "translation": "Finished PDF space export. Download here.", 21 | "args": [] 22 | }] 23 | }) 24 | 25 | assert str(t) == 'com.atlassian.confluence.extra.flyingpdf.exporttaskname' 26 | assert len(t.messages) == 1 27 | assert t.successful 28 | -------------------------------------------------------------------------------- /confluence/models/label.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from enum import Enum 3 | from typing import Any, Dict 4 | 5 | logger = logging.getLogger(__name__) 6 | logger.addHandler(logging.NullHandler()) 7 | 8 | 9 | class LabelPrefix(Enum): 10 | """ 11 | Represents the valid prefix values for a Label. 12 | 13 | c.f. https://docs.atlassian.com/atlassian-confluence/6.6.0/com/atlassian/confluence/api/model/content/Label.Prefix.html 14 | """ 15 | 16 | GLOBAL = 'global' 17 | MY = 'my' 18 | SYSTEM = 'system' 19 | TEAM = 'team' 20 | 21 | 22 | class Label: 23 | """ 24 | Represents a label on a piece of confluence content. 25 | 26 | c.f. https://docs.atlassian.com/atlassian-confluence/6.6.0/com/atlassian/confluence/labels/Label.html 27 | """ 28 | 29 | def __init__(self, json): # type: (Dict[str, Any]) -> None 30 | self.id = json['id'] # type: str 31 | self.name = json['name'] # type: str 32 | self.prefix = json['prefix'] # type: str 33 | 34 | def __str__(self): 35 | return self.name 36 | -------------------------------------------------------------------------------- /tests/models/test_content_property.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from confluence.models.content import ContentProperty 4 | 5 | logger = logging.getLogger(__name__) 6 | logger.addHandler(logging.NullHandler()) 7 | 8 | 9 | def create_with_minimal_json(): 10 | p = ContentProperty({ 11 | 'key': 'KEY', 12 | 'value': {} 13 | }) 14 | 15 | assert str(p) == 'KEY' 16 | assert not hasattr(p, 'content') 17 | assert not hasattr(p, 'version') 18 | 19 | 20 | def create_with_full_json(): 21 | p = ContentProperty({ 22 | 'key': 'KEY', 23 | 'value': { 24 | 'anything': 1 25 | }, 26 | 'version': { 27 | 'number': 2, 28 | 'minorEdit': False, 29 | 'hidden': False 30 | }, 31 | 'content': { 32 | 'id': 1, 33 | 'title': 'Hello', 34 | 'status': 'current', 35 | 'type': 'page' 36 | } 37 | }) 38 | 39 | assert str(p) == 'KEY' 40 | assert p.version.number == 2 41 | assert p.content.id == 1 42 | -------------------------------------------------------------------------------- /confluence/models/version.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict 3 | 4 | from confluence.models.user import User 5 | 6 | logger = logging.getLogger(__name__) 7 | logger.addHandler(logging.NullHandler()) 8 | 9 | 10 | class Version: 11 | """ 12 | Represents a version of an object in Confluence. 13 | 14 | c.f. https://docs.atlassian.com/atlassian-confluence/6.6.0/com/atlassian/confluence/api/model/content/Version.html 15 | """ 16 | 17 | def __init__(self, json): # type: (Dict[str, Any]) -> None 18 | self.number = json['number'] # type: int 19 | self.minor_edit = json['minorEdit'] # type: bool 20 | 21 | if 'hidden' in json: 22 | self.hidden = json['hidden'] # type: bool 23 | 24 | if 'by' in json: 25 | self.by = User(json['by']) 26 | 27 | if 'when' in json: 28 | self.when = json['when'] # type: str 29 | 30 | if 'message' in json: 31 | self.message = json['message'] # type: str 32 | 33 | def __str__(self): 34 | return '{}'.format(self.number) 35 | -------------------------------------------------------------------------------- /tests/models/test_attachment.py: -------------------------------------------------------------------------------- 1 | from confluence.models.content import Content 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | def test_create_group_with_valid_json(): 9 | a = Content({ 10 | "id": "att35459744", 11 | "type": "attachment", 12 | "status": "current", 13 | "title": "Puppet Architecture.png", 14 | "metadata": { 15 | "comment": "Added by UWC, the Universal Wiki Converter", 16 | "mediaType": "image/png", 17 | "labels": { 18 | "results": [], 19 | "start": 0, 20 | "limit": 200, 21 | "size": 0, 22 | } 23 | }, 24 | "extensions": { 25 | "mediaType": "image/png", 26 | "fileSize": 61601, 27 | "comment": "Added by UWC, the Universal Wiki Converter" 28 | }, 29 | "_links": { 30 | "download": "/download/attachment/123454/Puppet%20Architecture.png" 31 | } 32 | }) 33 | assert str(a) == '35459744 - Puppet Architecture.png' 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 David Tyler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /integration_tests/test_label_operations.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from confluence.models.content import ContentType, ContentStatus 4 | from confluence.models.label import LabelPrefix 5 | from integration_tests.config import get_confluence_instance 6 | 7 | logger = logging.getLogger(__name__) 8 | logger.addHandler(logging.NullHandler()) 9 | 10 | c = get_confluence_instance() 11 | space_key = 'LO' 12 | 13 | 14 | def setup_module(): 15 | c.create_space(space_key, 'Label Operations Space') 16 | 17 | 18 | def teardown_module(): 19 | c.delete_space(space_key) 20 | 21 | 22 | def test_add_get_delete_label(): 23 | page = c.create_content(ContentType.PAGE, content='A', space_key=space_key, title='A') 24 | try: 25 | c.create_labels(page.id, [(LabelPrefix.GLOBAL, 'b'), (LabelPrefix.MY, 'd')]) 26 | 27 | labels = list(c.get_labels(page.id)) 28 | prefixed_labels = list(c.get_labels(page.id, LabelPrefix.GLOBAL)) 29 | assert len(labels) == 2 30 | assert len(prefixed_labels) == 1 31 | 32 | c.delete_label(page.id, labels[0].name) 33 | finally: 34 | c.delete_content(page.id, ContentStatus.CURRENT) 35 | -------------------------------------------------------------------------------- /confluence/models/user.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict 3 | 4 | from confluence.models.icon import Icon 5 | 6 | logger = logging.getLogger(__name__) 7 | logger.addHandler(logging.NullHandler()) 8 | 9 | 10 | class User: 11 | """ 12 | Represents a single user object in confluence either as attached to a page or as requested directly over the API. 13 | 14 | Note that all fields here are optional as the user attached to pages might 15 | not contain standard fields (e.g. when it's anonymous). 16 | """ 17 | 18 | def __init__(self, json): # type: (Dict[str, Any]) -> None 19 | # Fields are not always present when requesting a user. 20 | self.username = json['username'] if 'username' in json else None 21 | if 'displayName' in json: 22 | self.display_name = json['displayName'] 23 | if 'userKey' in json: 24 | self.user_key = json['userKey'] 25 | if 'type' in json: 26 | self.type = json['type'] 27 | if 'profilePicture' in json: 28 | self.profile_picture = Icon(json['profilePicture']) 29 | 30 | def __str__(self): 31 | return str(self.username) 32 | -------------------------------------------------------------------------------- /confluence/models/contenthistory.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict 3 | 4 | from confluence.models.user import User 5 | from confluence.models.version import Version 6 | 7 | logger = logging.getLogger(__name__) 8 | logger.addHandler(logging.NullHandler()) 9 | 10 | 11 | class ContentHistory: 12 | """Represents the history of a piece of content(blog|page|comment|attachment) in confluence.""" 13 | 14 | def __init__(self, json): # type: (Dict[str, Any]) -> None 15 | self.latest = json['latest'] 16 | self.author = User(json['createdBy']) 17 | self.created_date = json['createdDate'] 18 | 19 | # Fields only returned if the history.lastUpdated is expanded 20 | if 'lastUpdated' in json: 21 | self.last_updated = Version(json['lastUpdated']) 22 | 23 | if 'previousVersion' in json: 24 | self.previous_version = Version(json['previousVersion']) 25 | 26 | if 'nextVersion' in json: 27 | self.next_version = Version(json['nextVersion']) 28 | 29 | if 'contributors' in json: 30 | # Note: this is not properly implemented yet, we don't turn this 31 | # into objects with known properties. 32 | self.contributors = json['contributors'] 33 | -------------------------------------------------------------------------------- /tests/models/test_auditrecord.py: -------------------------------------------------------------------------------- 1 | from confluence.models.auditrecord import AuditRecord 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | def test_create_audit_record(): 9 | a = AuditRecord({ 10 | "author": { 11 | "type": "user", 12 | "displayName": "A Name", 13 | "username": "an", 14 | "userKey": "1" 15 | }, 16 | "remoteAddress": "1.1.1.1,2.2.2.2", 17 | "creationDate": 1517497826248, 18 | "summary": "Space Workflow States Initialized", 19 | "description": "", 20 | "category": "Comala Workflows", 21 | "sysAdmin": True, 22 | "affectedObject": { 23 | "name": "About", 24 | "objectType": "Space" 25 | }, 26 | "changedValues": [{ 27 | "name": "stateName", 28 | "oldValue": "", 29 | "newValue": "Up to date" 30 | }, { 31 | "name": "overrideCurrentState", 32 | "oldValue": "", 33 | "newValue": "false" 34 | }], 35 | "associatedObjects": [] 36 | }) 37 | 38 | assert str(a) == 'Space Workflow States Initialized' 39 | assert '1.1.1.1' in a.remote_address and '2.2.2.2' in a.remote_address 40 | assert len(a.changed_values) == 2 41 | assert a.creation_date.strftime('%Y-%m-%d') == '2018-02-01' 42 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from codecs import open 2 | from os import path 3 | from setuptools import setup, find_packages 4 | 5 | readme = path.join(path.abspath(path.dirname(__file__)), 'README.rst') 6 | 7 | with open(readme, encoding='utf-8') as f: 8 | long_description = f.read() 9 | 10 | setup( 11 | name='confluence-rest-library', 12 | packages=find_packages(exclude=['contrib', 'docs', 'tests', 'integration_tests']), 13 | version='2.0.0', 14 | description='A simple wrapper around the Confluence REST API.', 15 | long_description=long_description, 16 | author='David Tyler', 17 | author_email='davet.code@gmail.com', 18 | url='https://github.com/DaveTCode/confluence-python-lib', 19 | keywords=['confluence'], 20 | classifiers=[ 21 | 'Development Status :: 5 - Production/Stable', 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: MIT License', 24 | 'Programming Language :: Python :: 2.7', 25 | 'Programming Language :: Python :: 3.5', 26 | 'Programming Language :: Python :: 3.6', 27 | 'Programming Language :: Python :: 3.7' 28 | ], 29 | python_requires='>=2.7,!=3.0,!=3.1,!=3.2,!=3.3,!=3.4', 30 | setup_requires=['pytest-runner', 'typing', 'pycodestyle', 'bandit', 'mypy'], 31 | install_requires=['requests >= 2.19.1, < 3.0.0a0'], 32 | tests_require=['pytest >= 4.3.0, < 7.0.0', 'pytest-cov >= 2.5.0, < 4.0.0'] 33 | ) 34 | -------------------------------------------------------------------------------- /docs/sources/confluence.exceptions.rst: -------------------------------------------------------------------------------- 1 | confluence.exceptions package 2 | ============================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | confluence.exceptions.generalerror module 8 | ----------------------------------------- 9 | 10 | .. automodule:: confluence.exceptions.generalerror 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | confluence.exceptions.permissionerror module 16 | -------------------------------------------- 17 | 18 | .. automodule:: confluence.exceptions.permissionerror 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | confluence.exceptions.resourcenotfound module 24 | --------------------------------------------- 25 | 26 | .. automodule:: confluence.exceptions.resourcenotfound 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | confluence.exceptions.valuetoolong module 32 | ----------------------------------------- 33 | 34 | .. automodule:: confluence.exceptions.valuetoolong 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | confluence.exceptions.versionconflict module 40 | -------------------------------------------- 41 | 42 | .. automodule:: confluence.exceptions.versionconflict 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | 48 | Module contents 49 | --------------- 50 | 51 | .. automodule:: confluence.exceptions 52 | :members: 53 | :undoc-members: 54 | :show-inheritance: 55 | -------------------------------------------------------------------------------- /confluence/models/longtask.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict, List 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | class TaskName: 9 | """Represents the name of a long running task.""" 10 | 11 | def __init__(self, json): # type: (Dict[str, Any]) -> None 12 | self.key = json['key'] # type: str 13 | self.args = json['args'] # type: List[str] 14 | 15 | def __str__(self): 16 | return self.key 17 | 18 | 19 | class TaskMessage: 20 | """Represents a message about a long running task.""" 21 | 22 | def __init__(self, json): # type: (Dict[str, Any]) -> None 23 | self.translation = json['translation'] # type: str 24 | self.args = json['args'] # type: List[str] 25 | 26 | def __str__(self): 27 | return self.translation 28 | 29 | 30 | class LongTask: 31 | """Represents a single long running task in confluence (e.g. PDF space export).""" 32 | 33 | def __init__(self, json): # type: (Dict[str, Any]) -> None 34 | self.id = json['id'] # type: str 35 | self.name = TaskName(json['name']) # type: TaskName 36 | self.elapsed_time = json['elapsedTime'] # type: int 37 | self.percentage_complete = json['percentageComplete'] # type: int 38 | self.successful = json['successful'] # type: bool 39 | self.messages = [TaskMessage(m) for m in json['messages']] # type: List[TaskMessage] 40 | 41 | def __str__(self): 42 | return str(self.name) 43 | -------------------------------------------------------------------------------- /confluence/models/contentbody.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | class ContentBody: 9 | """ 10 | Represents the body of a content(blog|page|comment|attachment) in confluence. 11 | 12 | This has several different representations which may be present or not. 13 | """ 14 | 15 | def __init__(self, json): # type: (Dict[str, Any]) -> None 16 | if 'storage' in json: 17 | self.storage = json['storage']['value'] # type: str 18 | self.storage_representation = json['storage']['representation'] # type: str 19 | if 'editor' in json: 20 | self.editor = json['editor']['value'] # type: str 21 | self.editor_representation = json['editor']['representation'] # type: str 22 | if 'view' in json: 23 | self.view = json['view']['value'] # type: str 24 | self.view_representation = json['view']['representation'] # type: str 25 | if 'export_view' in json: 26 | self.export_view = json['export_view']['value'] # type: str 27 | self.export_view_representation = json['export_view']['representation'] # type: str 28 | if 'styled_view' in json: 29 | self.styled_view = json['styled_view']['value'] # type: str 30 | self.styled_view_representation = json['styled_view']['representation'] # type: str 31 | if 'anonymous_export_view' in json: 32 | self.anonymous_export_view = json['anonymous_export_view']['value'] # type: str 33 | self.anonymous_export_view_representation = json['anonymous_export_view']['representation'] # type: str 34 | -------------------------------------------------------------------------------- /integration_tests/test_content_history_operations.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from confluence.models.content import ContentType, ContentStatus 4 | 5 | from integration_tests.config import get_confluence_instance 6 | 7 | logger = logging.getLogger(__name__) 8 | logger.addHandler(logging.NullHandler()) 9 | 10 | c = get_confluence_instance() 11 | space_key = 'CH' 12 | 13 | 14 | def setup_module(): 15 | c.create_space(space_key, 'Content History Space') 16 | 17 | 18 | def teardown_module(): 19 | c.delete_space(space_key) 20 | 21 | 22 | def test_get_page_history_with_no_expansion(): 23 | page = c.create_content(ContentType.PAGE, content='A', space_key=space_key, title='A') 24 | try: 25 | c.update_content(page.id, ContentType.PAGE, new_version=page.version.number + 1, new_content='AA', 26 | new_title=page.title) 27 | history = c.get_content_history(page.id) 28 | assert history.author.username == 'admin' 29 | assert history.last_updated.number == 2 30 | assert history.previous_version.number == 1 31 | finally: 32 | c.delete_content(page.id, ContentStatus.CURRENT) 33 | 34 | 35 | def test_get_page_history_with_all_expansions(): 36 | page = c.create_content(ContentType.PAGE, content='A', space_key=space_key, title='A') 37 | try: 38 | c.update_content(page.id, ContentType.PAGE, new_version=page.version.number + 1, new_content='AA', 39 | new_title=page.title) 40 | history = c.get_content_history(page.id, expand=['previousVersion', 'nextVersion', 'lastUpdated', 41 | 'contributors.publishers']) 42 | assert history.author.username == 'admin' 43 | assert history.last_updated.number == 2 44 | assert history.previous_version.number == 1 45 | assert history.contributors is not None 46 | finally: 47 | c.delete_content(page.id, ContentStatus.CURRENT) 48 | -------------------------------------------------------------------------------- /integration_tests/test_space_operations.py: -------------------------------------------------------------------------------- 1 | from confluence.exceptions.generalerror import ConfluenceError 2 | from confluence.exceptions.resourcenotfound import ConfluenceResourceNotFound 3 | from integration_tests.config import get_confluence_instance 4 | import logging 5 | import pytest 6 | 7 | logger = logging.getLogger(__name__) 8 | logger.addHandler(logging.NullHandler()) 9 | 10 | c = get_confluence_instance() 11 | 12 | 13 | def test_create_space(): 14 | s = c.create_space('TEST', 'Test space', 'Test space description') 15 | assert s.key == 'TEST' 16 | assert s.name == 'Test space' 17 | 18 | 19 | def test_create_duplicate_space(): 20 | with pytest.raises(ConfluenceError): 21 | c.create_space('TEST', '') 22 | 23 | 24 | def test_get_all_spaces(): 25 | spaces = c.get_spaces(space_keys=['TEST']) 26 | assert 1 == len(list(spaces)) 27 | 28 | 29 | def test_get_space_no_expands(): 30 | s = c.get_space('TEST') 31 | assert s.name == 'Test space' 32 | assert s.key == 'TEST' 33 | 34 | 35 | def test_update_existing_space(): 36 | s = c.update_space('TEST', new_name='Test space updated', new_description='Test space description 2') 37 | assert s.key == 'TEST' 38 | assert s.name == 'Test space updated' 39 | 40 | 41 | def test_delete_space(): 42 | c.delete_space('TEST') 43 | 44 | 45 | def test_create_space_without_description(): 46 | try: 47 | s = c.create_space('TNOD', 'Test space without desc') 48 | assert s.key == 'TNOD' 49 | assert s.name == 'Test space without desc' 50 | finally: 51 | c.delete_space('TNOD') 52 | 53 | 54 | def test_update_nonexistent_space(): 55 | with pytest.raises(ConfluenceError): 56 | c.update_space('NONSENSE', '', '') 57 | 58 | 59 | def test_delete_nonexistent_space(): 60 | with pytest.raises(ConfluenceResourceNotFound): 61 | c.delete_space('NONSENSE') 62 | 63 | 64 | def test_create_private_space(): 65 | s = c.create_space('PRIVATE', 'Private space', is_private=True) 66 | assert s.key == 'PRIVATE' 67 | assert s.name == 'Private space' 68 | 69 | c.delete_space('PRIVATE') 70 | -------------------------------------------------------------------------------- /integration_tests/test_content_property_operations.py: -------------------------------------------------------------------------------- 1 | from confluence.exceptions.generalerror import ConfluenceError 2 | from confluence.exceptions.resourcenotfound import ConfluenceResourceNotFound 3 | from confluence.models.content import ContentType, ContentStatus 4 | from integration_tests.config import get_confluence_instance 5 | import logging 6 | import pytest 7 | 8 | logger = logging.getLogger(__name__) 9 | logger.addHandler(logging.NullHandler()) 10 | 11 | c = get_confluence_instance() 12 | space_key = 'CPS' 13 | page_id = 0 # type: int 14 | 15 | 16 | def setup_module(): 17 | c.create_space(space_key, 'Page Space') 18 | global page_id 19 | page = c.create_content(ContentType.PAGE, 'Test page', space_key, '

Test

') 20 | page_id = page.id 21 | 22 | 23 | def teardown_module(): 24 | c.delete_content(page_id, ContentStatus.CURRENT) 25 | c.delete_space(space_key) 26 | 27 | 28 | def test_crud_property(): 29 | prop = c.create_content_property(page_id, 'KEY', {'test': 'value'}) 30 | assert prop.key == 'KEY' 31 | assert prop.value['test'] == 'value' 32 | 33 | props = list(c.get_content_properties(page_id)) 34 | assert len(props) == 1 35 | 36 | prop = c.get_content_property(page_id, 'KEY') 37 | assert prop.value['test'] == 'value' 38 | 39 | prop = c.update_content_property(page_id, prop.key, {'test': 'new_value'}, 2) 40 | assert prop.value['test'] == 'new_value' 41 | assert prop.version.number == 2 42 | 43 | c.delete_content_property(page_id, prop.key) 44 | 45 | 46 | def test_duplicate_property_creation(): 47 | c.create_content_property(page_id, 'DK', {'test': 'value'}) 48 | with pytest.raises(ConfluenceError): 49 | c.create_content_property(page_id, 'DK', {'test': 'value'}) 50 | 51 | 52 | def test_update_non_existent_property(): 53 | with pytest.raises(ConfluenceResourceNotFound): 54 | c.update_content_property(page_id, 'NE', {}, 2) 55 | 56 | 57 | def test_update_with_wrong_version(): 58 | c.create_content_property(page_id, 'WV', {}) 59 | with pytest.raises(ConfluenceError): 60 | c.update_content_property(page_id, 'WV', {}, 10) 61 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3.6 3 | install: 4 | - pip uninstall numpy -y 5 | - pip install bandit codecov mypy pycodestyle safety typing 6 | - python setup.py install 7 | script: 8 | - pycodestyle --first --exclude venv,.eggs 9 | - safety check 10 | - bandit -r confluence 11 | - mypy --py2 . --ignore-missing-imports 12 | - sudo sh -c 'echo "deb https://packages.atlassian.com/debian/atlassian-sdk-deb/ stable contrib" >>/etc/apt/sources.list' 13 | - wget https://packages.atlassian.com/api/gpg/key/public 14 | - sudo apt-key add public 15 | - sudo apt-get update 16 | - sudo apt-get install atlassian-plugin-sdk 17 | - atlas-version 18 | - ATLAS_OPTS="-Dallow.google.tracking=false" atlas-run-standalone --product confluence --version 6.15.0 --server localhost &>/dev/null & 19 | - CONFLUENCE_PID=$! 20 | - > 21 | until $(curl --fail http://localhost:1990/confluence); do 22 | sleep 5 23 | done 24 | - curl --fail http://localhost:1990/confluence/rest/api/space 25 | - python setup.py test --addopts "tests integration_tests --cov confluence" 26 | - kill -9 $CONFLUENCE_PID 27 | - rm -rf amps-standalone-confluence-6.15.0 28 | after_success: 29 | - codecov 30 | deploy: 31 | provider: pypi 32 | distributions: sdist bdist_wheel 33 | user: davetcode 34 | password: 35 | secure: XgEbiQwqG5x5nhypM8/FPbZR3SIGhwJHzY5wZ5v+ThGpWdJbGl1ntiZy/gwQyUVbLV0AAZFp2hm741JbstO6UH2nD2EObtOK6N/DLwRbf/JBiilnpWbzVtOXZrPuMOeary/M71jFc3EI0f+vO1lxEe0NURjePbj8L2h8HOzc8J8yzZp3XcbvBjPekIfu4bN6qUGl/JQhpAmIjEioea4oWf1PuRgSGcuCtttPaU4ihm9f7y0+L1RQWfOtT+iLc9PzRAK61JGlzU7Om0dWbkGw7YABBQ7FxNWFAmHRdNmDoP0K6Q2iMWyCdUuDdmlEupwCY21iGYSNrDPQ9e/JAJ4+EdphwgQpTVEMh7tTTbh58KhxvL4K0o+I9mZjF3IYNUTwyX1B1To51BSFn+ueTg8bgooxOk5uBEMe4axuMMgHYs1ZQ7fJ3F3fuHm++GP+nAaZTwjt6ICrWU1mTgg2wN91cX3JaP2YjGI3NPQH02BhTiHjZ8VL5erjIX7gKyQvrfzIL+3DPuJlxf67GRXETRvxC3dGTR6i7D2YlhNIaxSqX+Bf6oRERaeRouQy+xuhO5HoEH3cGAwAuEKayhCIM0frWKtg6u3mwXj9eclFG66QtQnj3pRIxd6zC2F+w6GVOYAmazs+ZqO5u+53x7bsTcO3aHv/wNVcmdu0J2H1QAvE/x8= 36 | on: 37 | tags: true 38 | repo: DaveTCode/confluence-python-lib 39 | condition: "$TRAVIS_TAG =~ ^[0-9]+[.][0-9]+[.][0-9]+(rc[0-9]+|[.]dev[0-9]+)?$" 40 | -------------------------------------------------------------------------------- /tests/models/test_space.py: -------------------------------------------------------------------------------- 1 | from confluence.models.space import Space, SpaceProperty 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | def test_create_minimal_space(): 9 | s = Space({ 10 | 'id': 1, 11 | 'key': 'TEST', 12 | 'name': 'Test', 13 | 'type': 'personal' 14 | }) 15 | 16 | assert str(s) == '1 - TEST | Test' 17 | 18 | 19 | def test_create_space_all(): 20 | s = Space({ 21 | 'id': 1, 22 | 'key': 'TEST', 23 | 'name': 'Test', 24 | 'type': 'personal', 25 | 'description': '', 26 | 'homepage': { 27 | 'id': 1, 28 | 'title': 'Hello', 29 | 'status': 'current', 30 | 'type': 'page', 31 | 'space': { 32 | 'id': 1, 33 | 'key': 'TEST', 34 | 'name': 'Test', 35 | 'type': 'personal' 36 | } 37 | }, 38 | 'icon': { 39 | 'path': 'https://a.com', 40 | 'width': 200, 41 | 'height': 201, 42 | 'isDefault': False 43 | }, 44 | 'metadata': { 45 | 46 | } 47 | }) 48 | 49 | assert str(s) == '1 - TEST | Test' 50 | assert s.icon is not None 51 | assert s.homepage is not None 52 | 53 | 54 | def test_create_space_property_minimal(): 55 | s = SpaceProperty({ 56 | 'space': { 57 | 'id': 1, 58 | 'key': 'TST', 59 | 'name': 'Example space', 60 | 'type': 'personal', 61 | 'description': { 62 | 'plain': { 63 | 'value': 'This is an example space', 64 | 'representation': 'plain' 65 | } 66 | }, 67 | 'metadata': {}, 68 | '_links': { 69 | 'self': 'http://myhost:8080/confluence/rest/api/space/TST' 70 | } 71 | }, 72 | 'key': 'example-property-key', 73 | 'value': { 74 | 'anything': 'goes' 75 | }, 76 | 'version': { 77 | 'number': 2, 78 | 'minorEdit': False, 79 | 'hidden': False 80 | } 81 | }) 82 | 83 | assert str(s) == 'example-property-key' 84 | assert s.version.number == 2 85 | assert s.space.key == 'TST' 86 | -------------------------------------------------------------------------------- /tests/models/test_page.py: -------------------------------------------------------------------------------- 1 | from confluence.models.content import Content 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | logger.addHandler(logging.NullHandler()) 6 | 7 | 8 | def test_create_with_minimal_json(): 9 | p = Content({ 10 | 'id': 1, 11 | 'title': 'Hello', 12 | 'status': 'current', 13 | 'type': 'page' 14 | }) 15 | 16 | assert str(p) == '1 - Hello' 17 | 18 | 19 | def test_create_complete(): 20 | p = Content({ 21 | "id": "65577", 22 | "type": "page", 23 | "status": "current", 24 | "title": "SandBox", 25 | "space": { 26 | "id": 98306, 27 | "key": "SAN", 28 | "name": "SandBox", 29 | "type": "global" 30 | }, 31 | "history": { 32 | "latest": True, 33 | "createdBy": { 34 | "type": "anonymous", 35 | "profilePicture": { 36 | "path": "anonymous.png", 37 | "width": 48, 38 | "height": 48, 39 | "isDefault": True 40 | }, 41 | "displayName": "Anonymous" 42 | }, 43 | "createdDate": "2017-09-22T11:03:07.420+01:00" 44 | }, 45 | "version": { 46 | "by": { 47 | "type": "known", 48 | "username": "user", 49 | "userKey": "12345", 50 | "profilePicture": { 51 | "path": "default.png", 52 | "width": 48, 53 | "height": 48, 54 | "isDefault": True 55 | }, 56 | "displayName": "user" 57 | }, 58 | "when": "2017-10-28T17:05:56.026+01:00", 59 | "message": "", 60 | "number": 8, 61 | "minorEdit": False, 62 | "hidden": False 63 | }, 64 | "body": { 65 | "storage": { 66 | "value": "", 67 | "representation": "storage", 68 | "_expandable": { 69 | "content": "/rest/api/content/65577" 70 | } 71 | } 72 | }, 73 | "metadata": { 74 | }, 75 | "extensions": { 76 | "position": "none" 77 | } 78 | }) 79 | 80 | assert p.body.storage == '' 81 | assert p.body.storage_representation == 'storage' 82 | assert not hasattr(p.body, 'edit') 83 | 84 | assert p.history.latest 85 | 86 | assert p.space.id == 98306 87 | -------------------------------------------------------------------------------- /confluence/models/auditrecord.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from typing import Any, Dict, List 4 | 5 | from confluence.models.user import User 6 | 7 | logger = logging.getLogger(__name__) 8 | logger.addHandler(logging.NullHandler()) 9 | 10 | 11 | class AffectedObject: 12 | """ 13 | Represents the affected object of an audit record. c.f. 14 | 15 | https://docs.atlassian.com/atlassian-confluence/6.6.0/com/atlassian/confluence/api/model/audit/AffectedObject.html 16 | """ 17 | 18 | def __init__(self, json): # type: (Dict[str, Any]) -> None 19 | self.name = json['name'] # type: str 20 | self.object_type = json['objectType'] # type: str 21 | 22 | def __str__(self): 23 | return self.name 24 | 25 | 26 | class ChangedValue: 27 | """ 28 | Represents the change in value of an object in an audit record. c.f. 29 | 30 | https://docs.atlassian.com/atlassian-confluence/6.6.0/com/atlassian/confluence/api/model/audit/ChangedValue.html 31 | """ 32 | 33 | def __init__(self, json): # type: (Dict[str, Any]) -> None 34 | self.name = json['name'] # type: str 35 | self.new_value = json['newValue'] # type: str 36 | self.old_value = json['oldValue'] # type: str 37 | 38 | def __str__(self): 39 | return '{} change from {} to {}'.format(self.name, self.old_value, self.new_value) 40 | 41 | 42 | class AuditRecord: 43 | """ 44 | Represents a single audit record from Confluence. c.f. 45 | 46 | https://docs.atlassian.com/atlassian-confluence/6.6.0/com/atlassian/confluence/api/model/audit/AuditRecord.html 47 | """ 48 | 49 | def __init__(self, json): # type: (Dict[str, Any]) -> None 50 | self.affected_object = AffectedObject(json['affectedObject']) # type: AffectedObject 51 | self.associated_objects = [AffectedObject(a) for a in json['associatedObjects']] # type: List[AffectedObject] 52 | self.author = User(json['author']) # type: User 53 | self.category = json['category'] # type: str 54 | self.changed_values = [ChangedValue(v) for v in json['changedValues']] # type: List[ChangedValue] 55 | self.creation_date = datetime.utcfromtimestamp(json['creationDate'] / 1000) # type: datetime 56 | self.description = json['description'] # type: str 57 | self.remote_address = json['remoteAddress'].split(',') # type: List[str] 58 | self.summary = json['summary'] # type: str 59 | self.is_sys_admin = json['sysAdmin'] # type: bool 60 | 61 | def __str__(self): 62 | return self.summary 63 | -------------------------------------------------------------------------------- /docs/sources/confluence.models.rst: -------------------------------------------------------------------------------- 1 | confluence.models package 2 | ========================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | confluence.models.auditrecord module 8 | ------------------------------------ 9 | 10 | .. automodule:: confluence.models.auditrecord 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | confluence.models.content module 16 | -------------------------------- 17 | 18 | .. automodule:: confluence.models.content 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | confluence.models.contentbody module 24 | ------------------------------------ 25 | 26 | .. automodule:: confluence.models.contentbody 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | confluence.models.contenthistory module 32 | --------------------------------------- 33 | 34 | .. automodule:: confluence.models.contenthistory 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | confluence.models.group module 40 | ------------------------------ 41 | 42 | .. automodule:: confluence.models.group 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | confluence.models.icon module 48 | ----------------------------- 49 | 50 | .. automodule:: confluence.models.icon 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | confluence.models.label module 56 | ------------------------------ 57 | 58 | .. automodule:: confluence.models.label 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | confluence.models.longtask module 64 | --------------------------------- 65 | 66 | .. automodule:: confluence.models.longtask 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | 71 | confluence.models.space module 72 | ------------------------------ 73 | 74 | .. automodule:: confluence.models.space 75 | :members: 76 | :undoc-members: 77 | :show-inheritance: 78 | 79 | confluence.models.user module 80 | ----------------------------- 81 | 82 | .. automodule:: confluence.models.user 83 | :members: 84 | :undoc-members: 85 | :show-inheritance: 86 | 87 | confluence.models.version module 88 | -------------------------------- 89 | 90 | .. automodule:: confluence.models.version 91 | :members: 92 | :undoc-members: 93 | :show-inheritance: 94 | 95 | 96 | Module contents 97 | --------------- 98 | 99 | .. automodule:: confluence.models 100 | :members: 101 | :undoc-members: 102 | :show-inheritance: 103 | -------------------------------------------------------------------------------- /confluence/models/space.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | import logging 3 | from confluence.models.icon import Icon 4 | from typing import Any, Dict 5 | 6 | from confluence.models.version import Version 7 | 8 | logger = logging.getLogger(__name__) 9 | logger.addHandler(logging.NullHandler()) 10 | 11 | 12 | class SpaceType(Enum): 13 | """ 14 | By default only a specified set of types are valid. 15 | 16 | https://docs.atlassian.com/atlassian-confluence/6.6.0/com/atlassian/confluence/api/model/content/SpaceType.html 17 | """ 18 | 19 | GLOBAL = "global" 20 | PERSONAL = "personal" 21 | 22 | 23 | class SpaceStatus(Enum): 24 | """ 25 | By default only a specific set of statuses are valid. 26 | 27 | https://docs.atlassian.com/atlassian-confluence/6.6.0/com/atlassian/confluence/api/model/content/SpaceStatus.html 28 | """ 29 | 30 | CURRENT = "current" 31 | ARCHIVED = "archived" 32 | 33 | 34 | class Space: 35 | """Represents a single space in Confluence.""" 36 | 37 | def __init__(self, json): # type: (Dict[str, Any]) -> None 38 | # All fields always exist on the json object 39 | self.id = json['id'] # type: int 40 | self.key = json['key'] # type: str 41 | self.name = json['name'] # type: str 42 | self.type = SpaceType(json['type']) # type: SpaceType 43 | 44 | # Description is expandable 45 | if 'description' in json: 46 | pass # TODO - Description comes back with `view` & `plain` expandable, not clear whether that's a common object so not handling for now 47 | 48 | # Homepage is an expandable full page object 49 | if 'homepage' in json: 50 | from confluence.models.content import Content 51 | self.homepage = Content(json['homepage']) 52 | 53 | # icon is expandable 54 | if 'icon' in json: 55 | self.icon = Icon(json['icon']) 56 | 57 | # metadata (inc labels) is expandable 58 | if 'metadata' in json: 59 | self.metadata = json['metadata'] # type: Dict[str, Any] 60 | 61 | def __str__(self): 62 | return '{} - {} | {}'.format(self.id, self.key, self.name) 63 | 64 | 65 | class SpaceProperty: 66 | """ 67 | Represents a single property attached to a space. 68 | 69 | Corresponds to https://docs.atlassian.com/atlassian-confluence/6.6.0/com/atlassian/confluence/api/model/content/JsonSpaceProperty.html 70 | """ 71 | 72 | def __init__(self, json): # type: (Dict[str, Any]) -> None 73 | self.key = json['key'] # type: str 74 | self.value = json['value'] # type: Dict[str, Any] 75 | if 'version' in json: 76 | self.version = Version(json['version']) 77 | if 'space' in json: 78 | self.space = Space(json['space']) 79 | 80 | def __str__(self): 81 | return str(self.key) 82 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |Build Status| |PyPI version| |codecov| |Requirements Status| |Docs| 2 | 3 | Confluence Python Library 4 | ========================= 5 | 6 | This is a simple wrapper around the REST API which the Confluence 7 | provides. Consider the API to be stable as of v1.0.0 (now released) 8 | 9 | c.f. `endpoints.md`_ for a list of endpoints and whether this library 10 | supports them yet. Please do send pull requests if you want an endpoint 11 | that isn’t covered! 12 | 13 | Installation 14 | ------------ 15 | 16 | :: 17 | 18 | pip install confluence-rest-library 19 | 20 | Usage 21 | ----- 22 | 23 | .. code:: python 24 | 25 | from confluence.client import Confluence 26 | with Confluence('https://site:8080/confluence', ('user', 'pass')) as c: 27 | pages = c.search('ID=1') 28 | 29 | Development and Deployment 30 | -------------------------- 31 | 32 | See the `Contribution guidelines for this project`_ for details on how 33 | to make changes to this library. 34 | 35 | Testing Locally 36 | ~~~~~~~~~~~~~~~ 37 | 38 | There are two sets of tests included. A suite of unit tests that verify the 39 | models can be built correctly and a suite of integration tests that run against 40 | a local instance of confluence. The unit tests can be run by simply invoking: 41 | .. code:: 42 | 43 | python setup.py test --addopts "tests" 44 | 45 | The integration tests are more complex and require the developer to take the following steps: 46 | 47 | - Install the `Atlassian SDK `_ 48 | - Run ``atlas-run-standalone --product confluence --version 6.6.0 --server localhost`` 49 | - Wait for the server to complete starting up 50 | - Run integration tests using ``python setup.py test --addopts "integration_tests"`` 51 | 52 | .. _endpoints.md: endpoints.md 53 | .. _Contribution guidelines for this project: CONTRIBUTING.rst 54 | 55 | .. |Build Status| image:: https://travis-ci.org/DaveTCode/confluence-python-lib.svg?branch=master 56 | :target: https://travis-ci.org/DaveTCode/confluence-python-lib 57 | :alt: Build status 58 | .. |PyPI version| image:: https://badge.fury.io/py/confluence-rest-library.svg 59 | :target: https://badge.fury.io/py/confluence-rest-library 60 | :alt: PyPI version 61 | .. |codecov| image:: https://codecov.io/gh/DaveTCode/confluence-python-lib/branch/master/graph/badge.svg 62 | :target: https://codecov.io/gh/DaveTCode/confluence-python-lib 63 | :alt: Code coverage stats 64 | .. |Requirements Status| image:: https://requires.io/github/DaveTCode/confluence-python-lib/requirements.svg?branch=master 65 | :target: https://requires.io/github/DaveTCode/confluence-python-lib/requirements/?branch=master 66 | :alt: Requirements status 67 | .. |Docs| image:: https://readthedocs.org/projects/confluence-python-lib/badge/?version=latest 68 | :target: http://confluence-python-lib.readthedocs.io/en/latest/?badge=latest 69 | :alt: Documentation status 70 | -------------------------------------------------------------------------------- /confluence/models/content.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from enum import Enum 3 | from typing import Any, Dict 4 | 5 | from confluence.models.contentbody import ContentBody 6 | from confluence.models.contenthistory import ContentHistory 7 | from confluence.models.space import Space 8 | from confluence.models.version import Version 9 | 10 | logger = logging.getLogger(__name__) 11 | logger.addHandler(logging.NullHandler()) 12 | 13 | 14 | class ContentType(Enum): 15 | """ 16 | The set of valid content types in confluence along with their representation on the API. 17 | 18 | https://docs.atlassian.com/atlassian-confluence/6.6.0/com/atlassian/confluence/api/model/content/ContentType.html 19 | """ 20 | 21 | ATTACHMENT = "attachment" 22 | BLOG_POST = "blogpost" 23 | COMMENT = "comment" 24 | PAGE = "page" 25 | 26 | 27 | class ContentStatus(Enum): 28 | """ 29 | The set of valid content statuses in confluence along with their API representation. 30 | 31 | https://docs.atlassian.com/atlassian-confluence/6.6.0/com/atlassian/confluence/api/model/content/ContentStatus.html 32 | """ 33 | 34 | CURRENT = "current" 35 | DRAFT = "draft" 36 | HISTORICAL = "historical" 37 | TRASHED = "trashed" 38 | 39 | 40 | class CommentLocation(Enum): 41 | """The set of valid comment locations as per the Confluence API.""" 42 | 43 | INLINE = 'inline' 44 | FOOTER = 'footer' 45 | RESOLVED = 'resolved' 46 | 47 | 48 | class CommentDepth(Enum): 49 | """The set of depths at which comments can be retrieved over the API.""" 50 | 51 | ROOT = '' 52 | ALL = 'all' 53 | 54 | 55 | class Content: 56 | """ 57 | Main content class for all the different content types. This includes pages, blogs, comments and attachments. 58 | 59 | The type field will allow the end user to distinguish between the types from calling code. 60 | """ 61 | 62 | def __init__(self, json): # type: (Dict[str, Any]) -> None 63 | # attachment id get returned starting with att which can be stripped 64 | # this ensures ids are always of type int 65 | id = str(json['id']) 66 | if id.startswith('att'): 67 | id = id[3:] 68 | self.id = int(id) 69 | self.status = ContentStatus(json['status']) # type: ContentStatus 70 | self.type = ContentType(json['type']) # type: ContentType 71 | 72 | if 'title' in json: 73 | self.title = json['title'] # type: str 74 | 75 | if 'metadata' in json: 76 | self.metadata = json['metadata'] # type: Dict[str, Any] 77 | 78 | if 'extensions' in json: 79 | self.extensions = json['extensions'] # type: Dict[str, Any] 80 | 81 | if 'space' in json: 82 | self.space = Space(json['space']) 83 | 84 | if 'body' in json: 85 | self.body = ContentBody(json['body']) 86 | 87 | if 'history' in json: 88 | self.history = ContentHistory(json['history']) 89 | 90 | if 'version' in json: 91 | self.version = Version(json['version']) 92 | 93 | if self.type == ContentType.ATTACHMENT: 94 | self.links = json['_links'] # type: Dict[str, Any] 95 | 96 | def __str__(self): 97 | return '{} - {}'.format(self.id, self.title) 98 | 99 | 100 | class ContentProperty: 101 | """ 102 | Represents a single property attached to a piece of content. 103 | 104 | Corresponds to https://docs.atlassian.com/atlassian-confluence/6.6.0/com/atlassian/confluence/api/model/content/JsonContentProperty.html 105 | """ 106 | 107 | def __init__(self, json): # type: (Dict[str, Any]) -> None 108 | self.key = json['key'] # type: str 109 | self.value = json['value'] # type: Dict[str, Any] 110 | if 'version' in json: 111 | self.version = Version(json['version']) 112 | if 'content' in json: 113 | self.content = Content(json['content']) 114 | 115 | def __str__(self): 116 | return self.key 117 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python,pycharm,sublimetext 3 | 4 | ### PyCharm ### 5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 7 | 8 | # User-specific stuff: 9 | .idea/**/workspace.xml 10 | .idea/**/tasks.xml 11 | 12 | # Sensitive or high-churn files: 13 | .idea/**/dataSources/ 14 | .idea/**/dataSources.ids 15 | .idea/**/dataSources.xml 16 | .idea/**/dataSources.local.xml 17 | .idea/**/sqlDataSources.xml 18 | .idea/**/dynamic.xml 19 | .idea/**/uiDesigner.xml 20 | 21 | # Gradle: 22 | .idea/**/gradle.xml 23 | .idea/**/libraries 24 | 25 | # Mongo Explorer plugin: 26 | .idea/**/mongoSettings.xml 27 | 28 | ## File-based project format: 29 | *.iws 30 | 31 | ## Plugin-specific files: 32 | 33 | # IntelliJ 34 | /out/ 35 | 36 | # mpeltonen/sbt-idea plugin 37 | .idea_modules/ 38 | 39 | # JIRA plugin 40 | atlassian-ide-plugin.xml 41 | 42 | # Crashlytics plugin (for Android Studio and IntelliJ) 43 | com_crashlytics_export_strings.xml 44 | crashlytics.properties 45 | crashlytics-build.properties 46 | fabric.properties 47 | 48 | ### PyCharm Patch ### 49 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 50 | 51 | # *.iml 52 | # modules.xml 53 | # .idea/misc.xml 54 | # *.ipr 55 | 56 | ### Python ### 57 | # Byte-compiled / optimized / DLL files 58 | __pycache__/ 59 | *.py[cod] 60 | *$py.class 61 | 62 | # C extensions 63 | *.so 64 | 65 | # Distribution / packaging 66 | .Python 67 | env/ 68 | build/ 69 | develop-eggs/ 70 | dist/ 71 | downloads/ 72 | eggs/ 73 | .eggs/ 74 | lib/ 75 | lib64/ 76 | parts/ 77 | sdist/ 78 | var/ 79 | wheels/ 80 | *.egg-info/ 81 | .installed.cfg 82 | *.egg 83 | 84 | # PyInstaller 85 | # Usually these files are written by a python script from a template 86 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 87 | *.manifest 88 | *.spec 89 | 90 | # Installer logs 91 | pip-log.txt 92 | pip-delete-this-directory.txt 93 | 94 | # Unit test / coverage reports 95 | htmlcov/ 96 | .tox/ 97 | .coverage 98 | .coverage.* 99 | .cache 100 | nosetests.xml 101 | coverage.xml 102 | *,cover 103 | .hypothesis/ 104 | 105 | # Translations 106 | *.mo 107 | *.pot 108 | 109 | # Django stuff: 110 | *.log 111 | local_settings.py 112 | 113 | # Flask stuff: 114 | instance/ 115 | .webassets-cache 116 | 117 | # Scrapy stuff: 118 | .scrapy 119 | 120 | # Sphinx documentation 121 | docs/_build/ 122 | 123 | # PyBuilder 124 | target/ 125 | 126 | # Jupyter Notebook 127 | .ipynb_checkpoints 128 | 129 | # pyenv 130 | .python-version 131 | 132 | # celery beat schedule file 133 | celerybeat-schedule 134 | 135 | # dotenv 136 | .env 137 | 138 | # virtualenv 139 | .venv 140 | venv/ 141 | ENV/ 142 | 143 | # Spyder project settings 144 | .spyderproject 145 | 146 | # Rope project settings 147 | .ropeproject 148 | 149 | ### SublimeText ### 150 | # cache files for sublime text 151 | *.tmlanguage.cache 152 | *.tmPreferences.cache 153 | *.stTheme.cache 154 | 155 | # workspace files are user-specific 156 | *.sublime-workspace 157 | 158 | # project files should be checked into the repository, unless a significant 159 | # proportion of contributors will probably not be using SublimeText 160 | # *.sublime-project 161 | 162 | # sftp configuration file 163 | sftp-config.json 164 | 165 | # Package control specific files 166 | Package Control.last-run 167 | Package Control.ca-list 168 | Package Control.ca-bundle 169 | Package Control.system-ca-bundle 170 | Package Control.cache/ 171 | Package Control.ca-certs/ 172 | Package Control.merged-ca-bundle 173 | Package Control.user-ca-bundle 174 | oscrypto-ca-bundle.crt 175 | bh_unicode_properties.cache 176 | 177 | # Sublime-github package stores a github token in this file 178 | # https://packagecontrol.io/packages/sublime-github 179 | GitHub.sublime-settings 180 | 181 | # End of https://www.gitignore.io/api/python,pycharm,sublimetext 182 | 183 | .vscode 184 | .idea/misc.xml 185 | .idea/modules.xml 186 | .idea/vcs.xml 187 | .idea/*.iml 188 | 189 | # Mypy cache doesnt need including 190 | .mypy_cache 191 | 192 | .pytest_cache -------------------------------------------------------------------------------- /integration_tests/test_watch_operations.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | 5 | from confluence.exceptions.resourcenotfound import ConfluenceResourceNotFound 6 | from confluence.models.content import ContentType, ContentStatus 7 | from integration_tests.config import get_confluence_instance 8 | 9 | logger = logging.getLogger(__name__) 10 | logger.addHandler(logging.NullHandler()) 11 | 12 | c = get_confluence_instance() 13 | space_key = 'WOS' 14 | 15 | 16 | def setup_module(): 17 | c.create_space(space_key, 'Watch Space') 18 | 19 | 20 | def teardown_module(): 21 | c.delete_space(space_key) 22 | 23 | 24 | def test_user_not_watching_content(): 25 | page = c.create_content(ContentType.PAGE, space_key=space_key, title='Test', content='Test') 26 | try: 27 | c.remove_content_watch(page.id) # Remove the watch because we created the content so it's watched by default! 28 | assert not c.is_user_watching_content(page.id) 29 | finally: 30 | c.delete_content(page.id, ContentStatus.CURRENT) 31 | 32 | 33 | def test_user_watching_content(): 34 | page = c.create_content(ContentType.PAGE, space_key=space_key, title='Test', content='Test') 35 | try: 36 | c.add_content_watch(page.id) 37 | assert c.is_user_watching_content(page.id) 38 | c.remove_content_watch(page.id) 39 | finally: 40 | c.delete_content(page.id, ContentStatus.CURRENT) 41 | 42 | 43 | def test_user_is_watching_content_by_username(): 44 | page = c.create_content(ContentType.PAGE, space_key=space_key, title='Test', content='Test') 45 | try: 46 | c.add_content_watch(page.id, username='admin') 47 | assert c.is_user_watching_content(page.id, username='admin') 48 | c.remove_content_watch(page.id, username='admin') 49 | finally: 50 | c.delete_content(page.id, ContentStatus.CURRENT) 51 | 52 | 53 | def test_user_is_watching_content_by_key(): 54 | page = c.create_content(ContentType.PAGE, space_key=space_key, title='Test', content='Test') 55 | try: 56 | user = c.get_user(username='admin') 57 | c.add_content_watch(page.id, user_key=user.user_key) 58 | assert c.is_user_watching_content(page.id, user_key=user.user_key) 59 | c.remove_content_watch(page.id, user_key=user.user_key) 60 | finally: 61 | c.delete_content(page.id, ContentStatus.CURRENT) 62 | 63 | 64 | def test_bad_user_of_content_watch_functions(): 65 | with pytest.raises(ValueError): 66 | c.add_content_watch(1, "a", "a") 67 | with pytest.raises(ValueError): 68 | c.is_user_watching_content(1, "a", "a") 69 | with pytest.raises(ValueError): 70 | c.remove_content_watch(1, "a", "a") 71 | 72 | 73 | def test_user_not_watching_space(): 74 | assert not c.is_user_watching_space(space_key, username='admin') 75 | 76 | 77 | def test_user_is_watching_space(): 78 | c.add_space_watch(space_key, username='admin') 79 | assert c.is_user_watching_space(space_key, username='admin') 80 | c.remove_space_watch(space_key, username='admin') 81 | 82 | 83 | def test_user_is_watching_space_by_key(): 84 | user = c.get_user(username='admin') 85 | c.add_space_watch(space_key, user_key=user.user_key) 86 | assert c.is_user_watching_space(space_key, user_key=user.user_key) 87 | c.remove_space_watch(space_key, user_key=user.user_key) 88 | 89 | 90 | def test_remove_space_watch_without_one(): 91 | c.remove_space_watch(space_key, username='admin') 92 | assert True 93 | 94 | 95 | def test_current_user_watching_space(): 96 | c.add_space_watch(space_key) 97 | assert c.is_user_watching_space(space_key) 98 | c.remove_space_watch(space_key) 99 | 100 | 101 | def test_watching_non_existent_space(): 102 | with pytest.raises(ConfluenceResourceNotFound): 103 | c.add_space_watch("NONSENSE") 104 | with pytest.raises(ConfluenceResourceNotFound): 105 | c.is_user_watching_space("NONSENSE") 106 | with pytest.raises(ConfluenceResourceNotFound): 107 | c.remove_space_watch("NONSENSE") 108 | 109 | 110 | def test_bad_user_of_space_watch_functions(): 111 | with pytest.raises(ValueError): 112 | c.add_space_watch(space_key, "a", "a") 113 | with pytest.raises(ValueError): 114 | c.is_user_watching_space(space_key, "a", "a") 115 | with pytest.raises(ValueError): 116 | c.remove_space_watch(space_key, "a", "a") 117 | -------------------------------------------------------------------------------- /integration_tests/test_attachment_operations.py: -------------------------------------------------------------------------------- 1 | from confluence.exceptions.resourcenotfound import ConfluenceResourceNotFound 2 | from confluence.models.content import ContentType, ContentStatus 3 | from integration_tests.config import get_confluence_instance 4 | import logging 5 | import py 6 | import pytest 7 | 8 | logger = logging.getLogger(__name__) 9 | logger.addHandler(logging.NullHandler()) 10 | 11 | c = get_confluence_instance() 12 | space_key = 'ATTSP' 13 | 14 | 15 | def setup_module(): 16 | c.create_space(space_key, 'Page Space') 17 | 18 | 19 | def teardown_module(): 20 | c.delete_space(space_key) 21 | 22 | 23 | def test_add_attachment(tmpdir): # type: (py.path.local) -> None 24 | page_id = c.create_content(ContentType.PAGE, space_key=space_key, content='', title='Test Attachment Page').id 25 | 26 | try: 27 | p = tmpdir.mkdir("attachments").join("test.txt") 28 | p.write("test") 29 | c.add_attachment(page_id, p.realpath()) 30 | attachments = list(c.get_attachments(page_id, filename=None, media_type=None)) 31 | assert len(attachments) == 1 32 | finally: 33 | c.delete_content(page_id, ContentStatus.CURRENT) 34 | 35 | 36 | def test_get_attachment_by_filename(tmpdir): # type: (py.path.local) -> None 37 | page_id = c.create_content(ContentType.PAGE, space_key=space_key, content='', title='Test Filename Attachment Page').id 38 | 39 | try: 40 | p = tmpdir.mkdir("attachments").join("test.txt") 41 | p.write("test") 42 | c.add_attachment(page_id, p.realpath()) 43 | c.add_attachment(page_id, p.realpath(), file_name='other_file.txt') 44 | attachments = list(c.get_attachments(page_id, filename='test.txt', media_type=None)) 45 | assert len(attachments) == 1 46 | finally: 47 | c.delete_content(page_id, ContentStatus.CURRENT) 48 | 49 | 50 | def test_add_attachment_to_missing_page(tmpdir): # type: (py.path.local) -> None 51 | p = tmpdir.mkdir("attachments").join("bad.txt") 52 | p.write("bad") 53 | with pytest.raises(ConfluenceResourceNotFound): 54 | c.add_attachment(-1, file_path=p.realpath()) 55 | 56 | 57 | def test_download_attachment(tmpdir): # type: (py.path.local) -> None 58 | page_id = c.create_content(ContentType.PAGE, space_key=space_key, content='', title='Test Download Attachment Page').id 59 | 60 | try: 61 | p = tmpdir.mkdir("attachments").join("test.txt") 62 | p.write("test") 63 | attachments = list(c.add_attachment(page_id, p.realpath())) 64 | file_contents = c.download_attachment(attachments[0]) 65 | assert file_contents == b"test" 66 | finally: 67 | c.delete_content(page_id, ContentStatus.CURRENT) 68 | 69 | 70 | def test_update_attachment(tmpdir): # type: (py.path.local) -> None 71 | page_id = c.create_content(ContentType.PAGE, space_key=space_key, content='', title='Test Update Attachment Page').id 72 | 73 | try: 74 | p = tmpdir.mkdir("attachments").join("test.txt") 75 | p.write("test") 76 | attachments = list(c.add_attachment(page_id, p.realpath())) 77 | attachment = c.update_attachment(page_id, attachments[0].id, attachments[0].version.number, new_filename='test_update.txt', new_media_type='text/plain') 78 | assert attachment.title == 'test_update.txt' 79 | assert attachment.metadata['mediaType'] == 'text/plain' 80 | finally: 81 | c.delete_content(page_id, ContentStatus.CURRENT) 82 | 83 | 84 | def test_update_attachment_move(tmpdir): # type: (py.path.local) -> None 85 | page_id_old = c.create_content(ContentType.PAGE, space_key=space_key, content='', title='Test Update Attachment From Page').id 86 | page_id_new = c.create_content(ContentType.PAGE, space_key=space_key, content='', title='Test Update Attachment To Page').id 87 | 88 | try: 89 | p = tmpdir.mkdir("attachments").join("test.txt") 90 | p.write("test") 91 | attachments = list(c.add_attachment(page_id_old, p.realpath())) 92 | _ = c.update_attachment(page_id_old, attachments[0].id, attachments[0].version.number, new_page_id=page_id_new) 93 | attachments_old = list(c.get_attachments(page_id_old, filename='test.txt')) 94 | attachments_new = list(c.get_attachments(page_id_new, filename='test.txt')) 95 | assert len(attachments_old) == 0 96 | assert len(attachments_new) == 1 97 | 98 | finally: 99 | c.delete_content(page_id_old, ContentStatus.CURRENT) 100 | c.delete_content(page_id_new, ContentStatus.CURRENT) 101 | 102 | 103 | def test_update_attachment_data(tmpdir): # type: (py.path.local) -> None 104 | page_id = c.create_content(ContentType.PAGE, space_key=space_key, content='', title='Test Update Attachment Page').id 105 | 106 | try: 107 | p = tmpdir.mkdir("attachments").join("test.txt") 108 | p.write("test") 109 | attachments = list(c.add_attachment(page_id, p.realpath())) 110 | p2 = tmpdir.join("attachments", "test2.txt") 111 | p2.write("test2") 112 | attachment = c.update_attachment_data(page_id, attachments[0].id, p2.realpath()) 113 | assert attachment.title == 'test2.txt' 114 | assert attachment.id == attachments[0].id 115 | assert attachment.version.number == attachments[0].version.number + 1 116 | finally: 117 | c.delete_content(page_id, ContentStatus.CURRENT) 118 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Configuration file for the Sphinx documentation builder. 4 | # 5 | # This file does only contain a selection of the most common options. For a 6 | # full list see the documentation: 7 | # http://www.sphinx-doc.org/en/stable/config 8 | 9 | # -- Path setup -------------------------------------------------------------- 10 | 11 | # If extensions (or modules to document with autodoc) are in another directory, 12 | # add these directories to sys.path here. If the directory is relative to the 13 | # documentation root, use os.path.abspath to make it absolute, like shown here. 14 | # 15 | import os 16 | import sys 17 | sys.path.insert(0, os.path.abspath('../')) 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | project = 'Confluence REST API' 23 | copyright = '2018, David Tyler' 24 | author = 'David Tyler' 25 | 26 | # The short X.Y version 27 | version = '' 28 | # The full version, including alpha/beta/rc tags 29 | release = '0.10.0' 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | # If your documentation needs a minimal Sphinx version, state it here. 35 | # 36 | # needs_sphinx = '1.0' 37 | 38 | # Add any Sphinx extension module names here, as strings. They can be 39 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 40 | # ones. 41 | extensions = [ 42 | 'sphinx.ext.autodoc', 43 | ] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # The suffix(es) of source filenames. 49 | # You can specify multiple suffix as a list of string: 50 | # 51 | # source_suffix = ['.rst', '.md'] 52 | source_suffix = '.rst' 53 | 54 | # The master toctree document. 55 | master_doc = 'index' 56 | 57 | # The language for content autogenerated by Sphinx. Refer to documentation 58 | # for a list of supported languages. 59 | # 60 | # This is also used if you do content translation via gettext catalogs. 61 | # Usually you set "language" from the command line for these cases. 62 | language = None 63 | 64 | # List of patterns, relative to source directory, that match files and 65 | # directories to ignore when looking for source files. 66 | # This pattern also affects html_static_path and html_extra_path . 67 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 68 | 69 | # The name of the Pygments (syntax highlighting) style to use. 70 | pygments_style = 'sphinx' 71 | 72 | 73 | # -- Options for HTML output ------------------------------------------------- 74 | 75 | # The theme to use for HTML and HTML Help pages. See the documentation for 76 | # a list of builtin themes. 77 | # 78 | html_theme = 'sphinx_rtd_theme' 79 | 80 | # Theme options are theme-specific and customize the look and feel of a theme 81 | # further. For a list of options available for each theme, see the 82 | # documentation. 83 | # 84 | # html_theme_options = {} 85 | 86 | # Add any paths that contain custom static files (such as style sheets) here, 87 | # relative to this directory. They are copied after the builtin static files, 88 | # so a file named "default.css" will overwrite the builtin "default.css". 89 | html_static_path = ['_static'] 90 | 91 | # Custom sidebar templates, must be a dictionary that maps document names 92 | # to template names. 93 | # 94 | # The default sidebars (for documents that don't match any pattern) are 95 | # defined by theme itself. Builtin themes are using these templates by 96 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', 97 | # 'searchbox.html']``. 98 | # 99 | # html_sidebars = {} 100 | 101 | 102 | # -- Options for HTMLHelp output --------------------------------------------- 103 | 104 | # Output file base name for HTML help builder. 105 | htmlhelp_basename = 'Confluence REST API Doc' 106 | 107 | 108 | # -- Options for LaTeX output ------------------------------------------------ 109 | 110 | latex_elements = { 111 | # The paper size ('letterpaper' or 'a4paper'). 112 | # 113 | # 'papersize': 'letterpaper', 114 | 115 | # The font size ('10pt', '11pt' or '12pt'). 116 | # 117 | # 'pointsize': '10pt', 118 | 119 | # Additional stuff for the LaTeX preamble. 120 | # 121 | # 'preamble': '', 122 | 123 | # Latex figure (float) alignment 124 | # 125 | # 'figure_align': 'htbp', 126 | } 127 | 128 | # Grouping the document tree into LaTeX files. List of tuples 129 | # (source start file, target name, title, 130 | # author, documentclass [howto, manual, or own class]). 131 | latex_documents = [ 132 | (master_doc, 'Confluence REST API.tex', 'confluence\\_rest\\_api Documentation', 133 | 'David Tyler', 'manual'), 134 | ] 135 | 136 | 137 | # -- Options for manual page output ------------------------------------------ 138 | 139 | # One entry per manual page. List of tuples 140 | # (source start file, name, description, authors, manual section). 141 | man_pages = [ 142 | (master_doc, 'Confluence REST API', 'Confluence REST API Documentation', 143 | [author], 1) 144 | ] 145 | 146 | 147 | # -- Options for Texinfo output ---------------------------------------------- 148 | 149 | # Grouping the document tree into Texinfo files. List of tuples 150 | # (source start file, target name, title, author, 151 | # dir menu entry, description, category) 152 | texinfo_documents = [ 153 | (master_doc, 'Confluence REST API', 'Confluence REST API Documentation', 154 | author, 'Confluence REST API', 'One line description of project.', 155 | 'Miscellaneous'), 156 | ] 157 | 158 | 159 | # -- Extension configuration ------------------------------------------------- 160 | -------------------------------------------------------------------------------- /integration_tests/test_content_operations.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | 5 | from confluence.exceptions.generalerror import ConfluenceError 6 | from confluence.exceptions.valuetoolong import ConfluenceValueTooLong 7 | from confluence.models.content import ContentType, ContentStatus 8 | from integration_tests.config import get_confluence_instance 9 | 10 | logger = logging.getLogger(__name__) 11 | logger.addHandler(logging.NullHandler()) 12 | 13 | c = get_confluence_instance() 14 | space_key = 'PSA' 15 | 16 | 17 | def setup_module(): 18 | c.create_space(space_key, 'Page Space') 19 | 20 | 21 | def teardown_module(): 22 | c.delete_space(space_key) 23 | 24 | 25 | def test_create_orphaned_page(): 26 | page = c.create_content(ContentType.PAGE, 'Test orphaned page', space_key, '

Test

') 27 | assert page.title == 'Test orphaned page' 28 | 29 | c.delete_content(page.id, ContentStatus.CURRENT) 30 | 31 | 32 | def test_create_page_with_ancestor(): 33 | parent = c.create_content(ContentType.PAGE, 'Parent page', space_key, 'Parent') 34 | 35 | try: 36 | child = c.create_content(ContentType.PAGE, 'Child page', space_key, 'Child', parent_content_id=parent.id) 37 | assert child.title == 'Child page' 38 | children = c.get_child_pages(parent.id) 39 | assert len(list(children)) == 1 40 | 41 | c.delete_content(child.id, ContentStatus.CURRENT) 42 | finally: 43 | c.delete_content(parent.id, ContentStatus.CURRENT) 44 | 45 | 46 | def test_get_page_content(): 47 | title = 'Full page' 48 | content = 'This is a full piece of content' 49 | page = c.create_content(ContentType.PAGE, title, space_key, content=content) 50 | page = c.get_content_by_id(page.id, 51 | expand=['body.storage', 'body.editor', 'body.view', 'body.export_view', 52 | 'body.styled_view', 'body.anonymous_export_view']) 53 | assert page.body.anonymous_export_view == content 54 | assert hasattr(page.body, 'anonymous_export_view_representation') 55 | assert page.body.editor == content 56 | assert hasattr(page.body, 'editor_representation') 57 | assert page.body.export_view == content 58 | assert hasattr(page.body, 'export_view_representation') 59 | assert page.body.storage == content 60 | assert hasattr(page.body, 'storage_representation') 61 | assert content in page.body.styled_view 62 | assert hasattr(page.body, 'styled_view_representation') 63 | assert page.body.view == content 64 | assert hasattr(page.body, 'view_representation') 65 | 66 | c.delete_content(page.id, ContentStatus.CURRENT) 67 | 68 | 69 | def test_create_content_wrong_type(): 70 | with pytest.raises(ValueError): 71 | c.create_content(ContentType.ATTACHMENT, space_key=space_key, content='', title='') 72 | 73 | with pytest.raises(ValueError): 74 | c.create_content(ContentType.COMMENT, space_key=space_key, content='', title='') 75 | 76 | 77 | def test_create_too_large_page(): 78 | with pytest.raises(ConfluenceValueTooLong): 79 | c.create_content(ContentType.PAGE, space_key=space_key, content='a'*10000000, title='too long') 80 | 81 | 82 | def test_get_page_more_than_25_results(): 83 | c.create_space('LOTS', 'Lots') 84 | try: 85 | for i in range(50): 86 | c.create_content(ContentType.PAGE, str(i), 'LOTS', content=str(i), expand=['version']) 87 | 88 | pages = list(c.get_content(ContentType.PAGE, space_key='LOTS', expand=['version'])) 89 | assert len(pages) == 51 # NOTE: This relies on no other pages being added to the space and not deleted. 90 | finally: 91 | c.delete_space('LOTS') 92 | 93 | 94 | def test_update_page_content(): 95 | # Create test page 96 | title = 'Full page updated' 97 | content = 'This is a full piece of content' 98 | result = c.create_content(ContentType.PAGE, title, space_key, content=content, expand=['body.storage', 'version']) 99 | assert result.body.storage == content 100 | 101 | # Update test page 102 | new_content = 'This is updated content' 103 | new_title = 'Updated title' 104 | result = c.update_content(result.id, result.type, result.version.number + 1, new_content, new_title) 105 | assert result.title == new_title 106 | assert result.body.storage == new_content 107 | 108 | # Read updated page 109 | result = c.get_content_by_id(result.id, expand=['body.storage']) 110 | assert result.title == new_title 111 | assert result.body.storage == new_content 112 | 113 | c.delete_content(result.id, ContentStatus.CURRENT) 114 | 115 | 116 | def test_minor_update(): 117 | result = c.create_content(ContentType.PAGE, 'Minor Doc', space_key, content='Hello') 118 | update_result = c.update_content(result.id, result.type, result.version.number + 1, 'Hello minor', 'Minor Doc', 119 | minor_edit=True, edit_message='An edit message', expand=['version']) 120 | assert update_result.version.message == 'An edit message' 121 | # Note that the API says it wasn't a minor edit but that's a lie so commenting this check out. 122 | # assert update_result.version.minor_edit 123 | 124 | c.delete_content(update_result.id, ContentStatus.CURRENT) 125 | 126 | 127 | def test_get_content_no_results(): 128 | result = list(c.get_content(ContentType.PAGE, space_key=space_key, title='Nothing here')) 129 | assert len(result) == 0 130 | 131 | 132 | def test_create_duplicate_page(): 133 | page = c.create_content(ContentType.PAGE, 'Duplicate Page', space_key, '1') 134 | 135 | with pytest.raises(ConfluenceError): 136 | c.create_content(ContentType.PAGE, 'Duplicate Page', space_key, '1') 137 | 138 | c.delete_content(page.id, ContentStatus.CURRENT) 139 | 140 | 141 | def test_create_page_in_nonexistent_space(): 142 | with pytest.raises(ConfluenceError): 143 | c.create_content(ContentType.PAGE, 'Bad page', 'NONSENSE', 'Test') 144 | 145 | 146 | def test_get_content_with_bad_content_type(): 147 | with pytest.raises(ValueError): 148 | c.get_content(ContentType.ATTACHMENT) 149 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | 4 | All notable changes to this project will be documented in this file. 5 | 6 | The format is based on `Keep a Changelog`_ and this project adheres to 7 | `Semantic Versioning`_. 8 | 9 | `Unreleased`_ 10 | ------------- 11 | 12 | Added 13 | ~~~~~ 14 | 15 | - Nothing 16 | 17 | Changed 18 | ~~~~~~~ 19 | 20 | - Nothing 21 | 22 | `2.0.0`_ - 2019-09-19 23 | ---------------------- 24 | 25 | API Change 26 | ~~~~~ 27 | 28 | - The title of a content object can now be unset 29 | 30 | `1.2.2`_ - 2019-05-18 31 | ---------------------- 32 | 33 | Added 34 | ~~~~~ 35 | 36 | - Allow passing through a flag to not validate Confluence SSL certificate 37 | 38 | `1.2.1`_ - 2019-02-26 39 | ---------------------- 40 | 41 | Added 42 | ~~~~~ 43 | 44 | - Added support for updating attachment metadata 45 | - Added support for updating attachment contents 46 | 47 | `1.1.1`_ - 2019-02-24 48 | ---------------------- 49 | 50 | Added 51 | ~~~~~ 52 | 53 | - Added a function to get attachment data from an attachment object 54 | 55 | Changed 56 | ~~~~~~~ 57 | 58 | - Nothing 59 | 60 | `1.0.1`_ - 2018-08-17 61 | ---------------------- 62 | 63 | Added 64 | ~~~~~ 65 | 66 | - Added support for handling 401 errors and wrapping them in specific exceptions 67 | 68 | Changed 69 | ~~~~~~~ 70 | 71 | - Nothing 72 | 73 | `1.0.0`_ - 2018-06-02 74 | ---------------------- 75 | 76 | Added 77 | ~~~~~ 78 | 79 | - Additional functional tests to bring coverage up to ~90% 80 | 81 | Changed 82 | ~~~~~~~ 83 | 84 | - The API for getting labels has been changed so that the prefix is now an enum as the HTTP API expects. 85 | 86 | `0.15.0`_ - 2018-05-25 87 | ---------------------- 88 | 89 | Added 90 | ~~~~~ 91 | 92 | - Option to update content without sending notifications 93 | 94 | Changed 95 | ~~~~~~~ 96 | 97 | - Added lots of extra docstrings 98 | 99 | `0.14.0`_ - 2018-04-06 100 | ---------------------- 101 | 102 | Added 103 | ~~~~~ 104 | 105 | - Get content by identifier 106 | 107 | Changed 108 | ~~~~~~~ 109 | 110 | - Fixed update_content and added integration tests for it 111 | 112 | `0.13.2`_ - 2018-04-04 113 | ---------------------- 114 | 115 | Added 116 | ~~~~~ 117 | 118 | - Nothing 119 | 120 | Changed 121 | ~~~~~~~ 122 | 123 | - Fixed content body implementation to contain the correct data and added integration tests to verify it 124 | 125 | `0.13.1`_ - 2018-04-04 126 | ---------------------- 127 | 128 | Added 129 | ~~~~~ 130 | 131 | - Nothing 132 | 133 | Changed 134 | ~~~~~~~ 135 | 136 | - Fixed context managed client to return itself 137 | 138 | `0.13.0`_ - 2018-03-26 139 | ---------------------- 140 | 141 | Added 142 | ~~~~~ 143 | 144 | - Added functionality for handling content properties 145 | 146 | Changed 147 | ~~~~~~~ 148 | 149 | - Nothing 150 | 151 | `0.12.0`_ - 2018-03-18 152 | ---------------------- 153 | 154 | Added 155 | ~~~~~ 156 | 157 | - Added support for creating new content (blogs & pages) 158 | - Added functional tests for creating new content and various space functions 159 | 160 | Changed 161 | ~~~~~~~ 162 | 163 | - hidden is now optional when viewing a Version object 164 | 165 | `0.11.0`_ - 2018-03-11 166 | ---------------------- 167 | 168 | Added 169 | ~~~~~ 170 | 171 | - Added support for deleting content 172 | - Added support for creating, updating and deleting labels 173 | 174 | Changed 175 | ~~~~~~~ 176 | 177 | - Nothing 178 | 179 | `0.10.0`_ - 2018-03-10 180 | ---------------------- 181 | 182 | Added 183 | ~~~~~ 184 | 185 | - Added support for all endpoints relating to space properties 186 | 187 | Changed 188 | ~~~~~~~ 189 | 190 | - Complete overhaul of the way that failed responses are handled, all 191 | of them now raise custom exceptions. 192 | 193 | `0.9.0`_ - 2018-03-09 194 | --------------------- 195 | 196 | Added 197 | ~~~~~ 198 | 199 | - Added partial support for space properties 200 | 201 | Changed 202 | ~~~~~~~ 203 | 204 | - Nothing 205 | 206 | `0.8.0`_ - 2018-03-09 207 | --------------------- 208 | 209 | Added 210 | ~~~~~ 211 | 212 | - Added full support for manipulating watches on space and content 213 | 214 | Changed 215 | ~~~~~~~ 216 | 217 | - Nothing 218 | 219 | `0.7.0`_ - 2018-01-30 220 | --------------------- 221 | 222 | Added 223 | ~~~~~ 224 | 225 | - Added basic support for updating content 226 | - Many more of the fields on content objects are now stored when 227 | they’re expanded 228 | 229 | Changed 230 | ~~~~~~~ 231 | 232 | - Major overhaul of the content based objects to better match the API 233 | provided 234 | 235 | `0.6.0`_ - 2018-01-26 236 | --------------------- 237 | 238 | Added 239 | ~~~~~ 240 | 241 | - Added longtask endpoints 242 | - A markdown file containing all endpoints with their current state 243 | 244 | Changed 245 | ~~~~~~~ 246 | 247 | - client.spaces is renamed to client.get_spaces in keeping with other 248 | endpoints 249 | 250 | `0.5.0`_ - 2018-01-26 251 | --------------------- 252 | 253 | Added 254 | ~~~~~ 255 | 256 | - Added support for python 2.7 & 3.5 257 | - Added unit tests to verify the models are basically created how you’d 258 | expect 259 | 260 | Changed 261 | ~~~~~~~ 262 | 263 | - Nothing 264 | 265 | `0.3.0`_ - 2018-01-18 266 | --------------------- 267 | 268 | Added 269 | ~~~~~ 270 | 271 | - Can now be treated as a context manager holding a single session for 272 | the duration of the class. 273 | - README converted to RST for pypi 274 | 275 | Changed 276 | ~~~~~~~ 277 | 278 | - Nothing 279 | 280 | `0.2.2`_ - 2018-01-18 281 | --------------------- 282 | 283 | Added 284 | ~~~~~ 285 | 286 | - Nothing 287 | 288 | Changed 289 | ~~~~~~~ 290 | 291 | - requests.get isn’t a context manager… 292 | 293 | `0.2.1`_ - 2018-01-18 294 | --------------------- 295 | 296 | Added 297 | ~~~~~ 298 | 299 | - Nothing 300 | 301 | Changed 302 | ~~~~~~~ 303 | 304 | - Bug fix so we don’t hold a session for quite so long when running 305 | large queries 306 | 307 | `0.2.0`_ - 2018-01-15 308 | --------------------- 309 | 310 | Added 311 | ~~~~~ 312 | 313 | - API call /content/search 314 | - API call /content 315 | 316 | Changed 317 | ~~~~~~~ 318 | 319 | - Nothing 320 | 321 | .. _Keep a Changelog: http://keepachangelog.com/ 322 | .. _Semantic Versioning: http://semver.org/ 323 | .. _Unreleased: https://github.com/DaveTCode/confluence-python-lib/compare/2.0.0...HEAD 324 | .. _2.0.0: https://github.com/DaveTCode/confluence-python-lib/compare/1.2.2...2.0.0 325 | .. _1.2.2: https://github.com/DaveTCode/confluence-python-lib/compare/1.2.1...1.2.2 326 | .. _1.2.1: https://github.com/DaveTCode/confluence-python-lib/compare/1.1.1...1.2.1 327 | .. _1.1.1: https://github.com/DaveTCode/confluence-python-lib/compare/1.0.1...1.1.1 328 | .. _1.0.1: https://github.com/DaveTCode/confluence-python-lib/compare/1.0.0...1.0.1 329 | .. _1.0.0: https://github.com/DaveTCode/confluence-python-lib/compare/0.15.0...1.0.0 330 | .. _0.15.0: https://github.com/DaveTCode/confluence-python-lib/compare/0.14.0...0.15.0 331 | .. _0.14.0: https://github.com/DaveTCode/confluence-python-lib/compare/0.13.1...0.14.0 332 | .. _0.13.1: https://github.com/DaveTCode/confluence-python-lib/compare/0.13.0...0.13.1 333 | .. _0.13.0: https://github.com/DaveTCode/confluence-python-lib/compare/0.12.0...0.13.0 334 | .. _0.12.0: https://github.com/DaveTCode/confluence-python-lib/compare/0.11.1...0.12.0 335 | .. _0.11.0: https://github.com/DaveTCode/confluence-python-lib/compare/0.10.1...0.11.0 336 | .. _0.10.1: https://github.com/DaveTCode/confluence-python-lib/compare/0.9.0...0.10.1 337 | .. _0.10.0: https://github.com/DaveTCode/confluence-python-lib/compare/0.9.0...0.10.0 338 | .. _0.9.0: https://github.com/DaveTCode/confluence-python-lib/compare/0.8.0...0.9.0 339 | .. _0.8.0: https://github.com/DaveTCode/confluence-python-lib/compare/0.7.0...0.8.0 340 | .. _0.7.0: https://github.com/DaveTCode/confluence-python-lib/compare/0.6.0...0.7.0 341 | .. _0.6.0: https://github.com/DaveTCode/confluence-python-lib/compare/0.5.0...0.6.0 342 | .. _0.5.0: https://github.com/DaveTCode/confluence-python-lib/compare/0.3.0...0.5.0 343 | .. _0.3.0: https://github.com/DaveTCode/confluence-python-lib/compare/0.2.2...0.3.0 344 | .. _0.2.2: https://github.com/DaveTCode/confluence-python-lib/compare/0.2.1...0.2.2 345 | .. _0.2.1: https://github.com/DaveTCode/confluence-python-lib/compare/0.2.0...0.2.1 346 | .. _0.2.0: https://github.com/DaveTCode/confluence-python-lib/compare/0.0.1...0.2.0 347 | -------------------------------------------------------------------------------- /endpoints.md: -------------------------------------------------------------------------------- 1 | # Endpoints 2 | 3 | This document details the list of endpoints exposed by the REST API and their current 4 | state in this library. 5 | 6 | It was pulled from https://docs.atlassian.com/atlassian-confluence/REST/6.6.0/ so 7 | is current as of v6.6.0. 8 | 9 | State key: 10 | - empty => Not written 11 | - -1 => Not necessary (duplicate of another endpoint) 12 | - 1 => written but not tested 13 | - 2 => written and tested 14 | 15 | Note that there are also TODOs scattered through the code where particular parts of 16 | objects have not yet been expanded. 17 | 18 | ## audit 19 | 20 | | HTTP Type | Endpoint | State | 21 | |-----------|--------------------------------------------------------:|-------| 22 | |GET |/rest/audit | 1 | 23 | |POST |/rest/audit | | 24 | |GET |/rest/audit/export | | 25 | |GET |/rest/audit/retention | | 26 | |PUT |/rest/audit/retention | | 27 | |GET |/rest/audit/since | | 28 | 29 | ## content 30 | 31 | | HTTP Type | Endpoint | State | 32 | |-----------|--------------------------------------------------------:|-------| 33 | |POST |/rest/content | 2 | 34 | |GET |/rest/content | 2 | 35 | |PUT |/rest/content/{contentId} | 2 | 36 | |GET |/rest/content/{id} | 2 | 37 | |DELETE |/rest/content/{id} | 2 | 38 | |GET |/rest/content/{id}/history | 1 | 39 | |GET |/rest/content/{id}/history/{version}/macro/hash/{hash} | | 40 | |GET |/rest/content/{id}/history/{version}/macro/id/{macroId} | | 41 | |GET |/rest/content/search | 1 | 42 | 43 | ### content/{id}/child 44 | 45 | | HTTP Type | Endpoint | State | 46 | |-----------|--------------------------------------------------------:|-------| 47 | |GET |/rest/content/{id}/child | | 48 | |GET |/rest/content/{id}/child/{type} | 1 | 49 | |GET |/rest/content/{id}/child/comment | 1 | 50 | 51 | ### content/{id}/child/attachment 52 | 53 | | HTTP Type | Endpoint | State | 54 | |-----------|--------------------------------------------------------:|-------| 55 | |GET |/rest/content/{id}/child/attachment | 1 | 56 | |POST |/rest/content/{id}/child/attachment | 1 | 57 | |PUT |/rest/content/{id}/child/attachment/{attachmentId} | 1 | 58 | |POST |/rest/content/{id}/child/attachment/{attachmentId}/data | 1 | 59 | 60 | ### content/{id}/descendant 61 | 62 | | HTTP Type | Endpoint | State | 63 | |-----------|--------------------------------------------------------:|-------| 64 | |GET |/rest/content/{id}/descendant | | 65 | |GET |/rest/content/{id}/descendant/{type} | | 66 | 67 | ### content/{id}/label 68 | 69 | | HTTP Type | Endpoint | State | 70 | |-----------|--------------------------------------------------------:|-------| 71 | |GET |/rest/content/{id}/label | 1 | 72 | |POST |/rest/content/{id}/label | 1 | 73 | |DELETE |/rest/content/{id}/label | 1 | 74 | |DELETE |/rest/content/{id}/label/{label} | -1 | 75 | 76 | ### content/{id}/property 77 | 78 | | HTTP Type | Endpoint | State | 79 | |-----------|--------------------------------------------------------:|-------| 80 | |GET |/rest/content/{id}/property | 2 | 81 | |POST |/rest/content/{id}/property | 2 | 82 | |GET |/rest/content/{id}/property/{key} | 2 | 83 | |PUT |/rest/content/{id}/property/{key} | 2 | 84 | |DELETE |/rest/content/{id}/property/{key} | 2 | 85 | |POST |/rest/content/{id}/property/{key} | -1 | 86 | 87 | ### content/{id}/restriction 88 | 89 | | HTTP Type | Endpoint | State | 90 | |-----------|--------------------------------------------------------:|-------| 91 | |GET |/rest/content/{id}/restriction/byOperation | | 92 | |GET |/rest/content/{id}/restriction/byOperation/{operationKey}| | 93 | 94 | ### content/blueprint 95 | 96 | | HTTP Type | Endpoint | State | 97 | |-----------|--------------------------------------------------------:|-------| 98 | |POST |/rest/content/blueprint/instance/{draftId} | | 99 | |PUT |/rest/content/blueprint/instance/{draftId} | | 100 | 101 | ### contentbody/convert/{to} 102 | 103 | | HTTP Type | Endpoint | State | 104 | |-----------|--------------------------------------------------------:|-------| 105 | |POST |/rest/contentbody/convert/{to} | | 106 | 107 | ## group 108 | 109 | | HTTP Type | Endpoint | State | 110 | |-----------|--------------------------------------------------------:|-------| 111 | |GET |groups | 1 | 112 | |GET |group | 1 | 113 | |GET |members | 1 | 114 | 115 | ## longtask 116 | 117 | | HTTP Type | Endpoint | State | 118 | |-----------|--------------------------------------------------------:|-------| 119 | |GET |/rest/longtask | 1 | 120 | |GET |/rest/longtask/{id} | 1 | 121 | 122 | ## search 123 | 124 | | HTTP Type | Endpoint | State | 125 | |-----------|--------------------------------------------------------:|-------| 126 | |GET |/rest/search | | 127 | 128 | ## space 129 | 130 | | HTTP Type | Endpoint | State | 131 | |-----------|--------------------------------------------------------:|-------| 132 | |GET |/rest/space | 1 | 133 | |POST |/rest/space | 1 | 134 | |POST |/rest/space/_private | 1 | 135 | |PUT |/rest/space/{spaceKey} | 1 | 136 | |DELETE |/rest/space/{spaceKey} | 1 | 137 | |GET |/rest/space/{spaceKey} | 1 | 138 | |GET |/rest/space/{spaceKey}/content | 1 | 139 | |GET |/rest/space/{spaceKey}/content/{type} | 1 | 140 | 141 | ### space/{spaceKey}/property 142 | 143 | | HTTP Type | Endpoint | State | 144 | |-----------|--------------------------------------------------------:|-------| 145 | |GET |/rest/space/{spaceKey}/property | 1 | 146 | |POST |/rest/space/{spaceKey}/property | 1 | 147 | |GET |/rest/space/{spaceKey}/property/{key} | 1 | 148 | |PUT |/rest/space/{spaceKey}/property/{key} | 1 | 149 | |DELETE |/rest/space/{spaceKey}/property/{key} | 1 | 150 | |POST |/rest/space/{spaceKey}/property/{key} | -1 | 151 | 152 | ## user 153 | 154 | | HTTP Type | Endpoint | State | 155 | |-----------|--------------------------------------------------------:|-------| 156 | |GET |/rest/user | 1 | 157 | |GET |/rest/user/anonymous | 1 | 158 | |GET |/rest/user/current | 1 | 159 | |GET |/rest/user/memberof | 1 | 160 | 161 | ### user/watch 162 | 163 | | HTTP Type | Endpoint | State | 164 | |-----------|--------------------------------------------------------:|-------| 165 | |POST |/rest/user/watch/content/{contentId} | 1 | 166 | |DELETE |/rest/user/watch/content/{contentId} | 1 | 167 | |GET |/rest/user/watch/content/{contentId} | 1 | 168 | |POST |/rest/user/watch/space/{spaceKey} | 1 | 169 | |DELETE |/rest/user/watch/space/{spaceKey} | 1 | 170 | |GET |/rest/user/watch/space/{spaceKey} | 1 | 171 | -------------------------------------------------------------------------------- /confluence/client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import requests 4 | from datetime import date 5 | from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union 6 | 7 | from confluence.exceptions.authenticationerror import ConfluenceAuthenticationError 8 | from confluence.exceptions.generalerror import ConfluenceError 9 | from confluence.exceptions.permissionerror import ConfluencePermissionError 10 | from confluence.exceptions.resourcenotfound import ConfluenceResourceNotFound 11 | from confluence.exceptions.valuetoolong import ConfluenceValueTooLong 12 | from confluence.exceptions.versionconflict import ConfluenceVersionConflict 13 | from confluence.models.auditrecord import AuditRecord 14 | from confluence.models.content import CommentDepth, CommentLocation, Content, ContentStatus, ContentType, \ 15 | ContentProperty 16 | from confluence.models.contenthistory import ContentHistory 17 | from confluence.models.group import Group 18 | from confluence.models.label import Label, LabelPrefix 19 | from confluence.models.longtask import LongTask 20 | from confluence.models.space import Space, SpaceProperty, SpaceStatus, SpaceType 21 | from confluence.models.user import User 22 | 23 | logger = logging.getLogger(__name__) 24 | logger.addHandler(logging.NullHandler()) 25 | 26 | 27 | class Confluence: 28 | """ 29 | External interface into this library, all calls should be made through an instance of this class. 30 | 31 | Note: This class should be used in a context manager. e.g. 32 | ```with Confluence(...) as c:``` 33 | """ 34 | 35 | def __init__(self, base_url, basic_auth, verify_confluence_certificate=True): 36 | # type: (str, Tuple[str, str], Union[bool, str]) -> None 37 | """ 38 | :param base_url: The URL where the confluence web app is located. 39 | e.g. https://mysite.mydomain/confluence. 40 | :param basic_auth: A tuple containing a username/password pair that 41 | can log into confluence. 42 | :param verify_confluence_certificate: Maps to the requests library 43 | "verify" property. Defaults to True indicating that the SSL 44 | certificate on the confluence server must be valid but can be 45 | set to False to allow invalid certs or to a file path of a CA 46 | bundle file. 47 | c.f. https://2.python-requests.org/en/master/user/advanced/ for 48 | more details. 49 | """ 50 | self._base_url = base_url 51 | self._basic_auth = basic_auth 52 | self._client = None # type: Optional[requests.Session] 53 | self._verify_confluence_certificate = verify_confluence_certificate 54 | 55 | def __enter__(self): # type: () -> Confluence 56 | self._client = requests.session() 57 | self._client.auth = self._basic_auth 58 | return self 59 | 60 | def __exit__(self, exc_type, exc_val, exc_tb): 61 | if self._client: 62 | self._client.close() 63 | self._client = None 64 | 65 | @property 66 | def client(self): 67 | # type: () -> Union[requests.Session, Any] 68 | """ 69 | Provides access to an underlying requestsalike object so that the 70 | client can be used in or out of a with block. 71 | 72 | :return: An object which behaves like requests.Session 73 | """ 74 | # Allow the class to be used without being inside a with block if 75 | # required. 76 | return self._client if self._client else requests 77 | 78 | @staticmethod 79 | def _handle_response_errors(path, params, response): 80 | # type: (str, Dict[str, str], requests.Response) -> None 81 | if response.status_code == 400: 82 | raise ConfluenceError(path, params, response) 83 | elif response.status_code == 401: 84 | raise ConfluenceAuthenticationError(path, params, response) 85 | elif response.status_code == 403: 86 | raise ConfluencePermissionError(path, params, response) 87 | elif response.status_code == 404: 88 | raise ConfluenceResourceNotFound(path, params, response) 89 | elif response.status_code == 409: 90 | raise ConfluenceVersionConflict(path, params, response) 91 | elif response.status_code == 413: 92 | raise ConfluenceValueTooLong(path, params, response) 93 | 94 | def _make_url(self, path): 95 | # type: (str) -> str 96 | if path.startswith('/'): 97 | format_string = '{}{}' 98 | else: 99 | format_string = '{}/rest/api/{}' 100 | return format_string.format(self._base_url, path) 101 | 102 | def _get(self, path, params, expand): 103 | # type: (str, Dict[str, str], Optional[List[str]]) -> requests.Response 104 | url = self._make_url(path) 105 | 106 | if expand: 107 | params['expand'] = ','.join(expand) 108 | 109 | response = self.client.get(url, params=params, auth=self._basic_auth, 110 | verify=self._verify_confluence_certificate) 111 | 112 | Confluence._handle_response_errors(path, params, response) 113 | 114 | return response 115 | 116 | def _get_single_result(self, item_type, path, params, expand): 117 | # type: (Callable, str, Dict[str, str], Optional[List[str]]) -> Any 118 | return item_type(self._get(path, params, expand).json()) 119 | 120 | def _get_paged_results(self, item_type, path, params, expand): 121 | # type: (Callable, str, Dict[str, str], Optional[List[str]]) -> Iterable[Any] 122 | if expand: 123 | params['expand'] = ','.join(expand) 124 | 125 | while path != "": 126 | response = self._get(path, params, []) 127 | Confluence._handle_response_errors(path, params, response) 128 | search_results = response.json() 129 | 130 | if 'next' in search_results['_links']: 131 | # We have another page of results 132 | path = search_results['_links']['next'] 133 | params.clear() 134 | else: 135 | # No more pages of results 136 | path = "" 137 | 138 | for result in search_results['results']: 139 | yield item_type(result) 140 | 141 | def _post(self, path, params, data, files=None, expand=None): 142 | # type: (str, Dict[str, str], Any, Optional[Any], Optional[List[str]]) -> requests.Response 143 | url = self._make_url(path) 144 | headers = {"X-Atlassian-Token": "nocheck"} 145 | 146 | if expand: 147 | params['expand'] = ','.join(expand) 148 | 149 | response = self.client.post(url, params=params, json=data, headers=headers, files=files, auth=self._basic_auth, 150 | verify=self._verify_confluence_certificate) 151 | 152 | Confluence._handle_response_errors(path, params, response) 153 | 154 | return response 155 | 156 | def _post_return_single(self, item_type, path, params, data, files=None, expand=None): 157 | # type: (Callable, str, Dict[str, str], Any, Optional[Dict[str, Any]], Optional[List[str]]) -> Any 158 | return item_type(self._post(path, params, data, files=files, expand=expand).json()) 159 | 160 | def _post_return_multiple(self, item_type, path, params, data, files, expand=None): 161 | # type: (Callable, str, Dict[str, str], Any, Dict[str, Any], Optional[List[str]]) -> Any 162 | response = self._post(path, params, data, files=files, expand=expand) 163 | 164 | return [item_type(r) for r in response.json()['results']] 165 | 166 | def _put(self, path, params, data, expand): 167 | # type: (str, Dict[str, str], Any, Optional[List[str]]) -> requests.Response 168 | url = self._make_url(path) 169 | headers = {"X-Atlassian-Token": "nocheck"} 170 | 171 | if expand: 172 | params['expand'] = ','.join(expand) 173 | 174 | response = self.client.put(url, json=data, params=params, headers=headers, auth=self._basic_auth, 175 | verify=self._verify_confluence_certificate) 176 | 177 | Confluence._handle_response_errors(path, params, response) 178 | 179 | return response 180 | 181 | def _put_return_single(self, item_type, path, params, data, expand=None): 182 | # type: (Callable, str, Dict[str, str], Any, Optional[List[str]]) -> Any 183 | return item_type(self._put(path, params, data, expand).json()) 184 | 185 | def _delete(self, path, params): 186 | # type: (str, Dict[str, str]) -> requests.Response 187 | url = self._make_url(path) 188 | headers = {"X-Atlassian-Token": "nocheck"} 189 | 190 | response = self.client.delete(url, params=params, headers=headers, auth=self._basic_auth, 191 | verify=self._verify_confluence_certificate) 192 | 193 | Confluence._handle_response_errors(path, params, response) 194 | 195 | return response 196 | 197 | def create_content(self, content_type, title, space_key, content, parent_content_id=None, expand=None): 198 | # type: (ContentType, str, str, str, Optional[int], Optional[List[str]]) -> Content 199 | """ 200 | Create a new piece of content, used for creating blog entries & pages. 201 | 202 | :param content_type: Currently only works for ContentType.PAGE and 203 | BLOG_POST. Attachments and comments are handled through other 204 | routes. 205 | :param title: The title of the content. 206 | :param space_key: The space to put the content in. 207 | :param content: The storage format of the new piece of content. 208 | :param parent_content_id: An optional parent page id to put as the 209 | ancestor for this piece of content. 210 | :param expand: The confluence REST API utilised expansion to avoid 211 | returning all fields on all requests. This optional parameter 212 | allows the user to select which fields that they want to expand on 213 | the returning piece of content. 214 | 215 | :return: A fully populated content object. 216 | """ 217 | if content_type not in (ContentType.BLOG_POST, ContentType.PAGE): 218 | raise ValueError('Only blog posts and pages can be added through this function') 219 | 220 | data = { 221 | 'type': content_type.value, 222 | 'title': title, 223 | 'space': { 224 | 'key': space_key 225 | }, 226 | 'body': { 227 | 'storage': { 228 | 'value': content, 229 | 'representation': 'storage' 230 | } 231 | } 232 | } 233 | 234 | if parent_content_id: 235 | data['ancestors'] = [{ 236 | 'id': parent_content_id 237 | }] 238 | 239 | return self._post_return_single(Content, 'content', {}, data, expand=expand) 240 | 241 | def update_content(self, 242 | content_id, # type: int 243 | content_type, # type: ContentType 244 | new_version, # type: str 245 | new_content, # type: str 246 | new_title, # type: str 247 | status=None, # type: Optional[ContentStatus] 248 | new_parent=None, # type: Optional[int] 249 | new_status=None, # type: Optional[ContentStatus] 250 | minor_edit=False, # type: Optional[bool] 251 | edit_message=None, # type: Optional[str] 252 | expand=None, # type: Optional[List[str]] 253 | ): # type: (...) -> Content 254 | """ 255 | Replace a piece of content in confluence. This can be used to update 256 | title, content, parent or status. 257 | 258 | :param content_id: The confluence unique ID . 259 | :param content_type: The type of content to be updated. 260 | :param new_version: This should be the current version + 1. 261 | :param status: The current status of the object. 262 | :param new_content: The new content to store. 263 | :param new_title: The new title. 264 | :param new_parent: The new parent content id, optional. 265 | :param new_status: The new content status, optional. 266 | :param minor_edit: Defaults to False. Set to true to make this update 267 | a minor edit. 268 | :param edit_message: Edit message, optional. 269 | :param expand: An optional list of properties to be expanded on the resulting content object. 270 | 271 | :return: The updated content object. 272 | """ 273 | content = { 274 | 'title': new_title, 275 | 'version': { 276 | 'number': new_version, 277 | 'minorEdit': minor_edit 278 | }, 279 | 'type': content_type.value, 280 | 'body': { 281 | 'storage': { 282 | 'value': new_content, 283 | 'representation': 'storage' 284 | } 285 | } 286 | } 287 | 288 | if edit_message: 289 | content['version']['message'] = edit_message 290 | 291 | if new_parent: 292 | content['ancestors'] = [{ 293 | 'id': new_parent 294 | }] 295 | 296 | if new_status: 297 | content['status'] = new_status.value 298 | 299 | params = {} 300 | if status: 301 | params['status'] = status.value 302 | 303 | return self._put_return_single(Content, 'content/{}'.format(content_id), params=params, data=content, 304 | expand=expand) 305 | 306 | def get_content(self, content_type=ContentType.PAGE, space_key=None, 307 | title=None, status=None, posting_day=None, expand=None): 308 | # type: (ContentType, Optional[str], Optional[str], Optional[str], Optional[date], Optional[List[str]]) -> Iterable[Content] 309 | """ 310 | Matches the REST API call https://docs.atlassian.com/atlassian-confluence/REST/6.6.0/#content-getContent 311 | which returns an iterable of either pages or blogposts depending on 312 | the value of the content_type parameter. The default is to return documents. 313 | 314 | Note that this function handles pagination automatically and returns 315 | an iterable containing all content. Therefore any attempt to 316 | materialise the results will result in potentially large numbers of 317 | HTTP requests. 318 | 319 | :param content_type: Determines whether we want to return blog posts 320 | of pages, defaults to page. Valid values are page|blogpost. 321 | :param space_key: The string space key of a space on the confluence 322 | server. Defaults to None which results in this field being ignored. 323 | :param title: The title of the page we're looking for. Defaults to 324 | None which results in this field being ignored. 325 | :param status: Only return documents in a given status. 326 | Defaults to None which results in this field being ignored. 327 | :param posting_day: Only valid for blogpost content_type and returns 328 | blogs posted on the given day. 329 | :param expand: The confluence REST API utilised expansion to avoid 330 | returning all fields on all requests. This optional parameter allows 331 | the user to select which fields that they want to expand as a comma 332 | separated list. 333 | 334 | :return: An iterable of pages/blogposts which match the parameters. 335 | """ 336 | params = {} 337 | 338 | if content_type and content_type not in (ContentType.PAGE, ContentType.BLOG_POST): 339 | raise ValueError('Cannot GET comments/attachments, only blogposts available on this API call') 340 | elif content_type: 341 | params['type'] = content_type.value 342 | 343 | if space_key: 344 | params['spaceKey'] = space_key 345 | if title: 346 | params['title'] = title 347 | if status: 348 | params['status'] = status 349 | if posting_day and content_type == ContentType.BLOG_POST: 350 | params['postingDay'] = posting_day.strftime('%Y-%m-%d') 351 | 352 | return self._get_paged_results(Content, 'content', params, expand) 353 | 354 | def get_content_by_id(self, content_id, expand=None): 355 | # type: (int, Optional[List[str]]) -> Content 356 | """ 357 | Matches the REST API call https://docs.atlassian.com/atlassian-confluence/REST/6.6.0/#content-getContentById 358 | which returns the document based on the id. 359 | 360 | :param content_id: The unique identifier in confluence for the piece 361 | of content. 362 | 363 | :param expand: The confluence REST API utilised expansion to avoid 364 | returning all fields on all requests. This optional parameter allows 365 | the user to select which fields that they want to expand as a comma 366 | separated list. 367 | 368 | :return: An iterable of pages/blogposts which match the parameters. 369 | """ 370 | return self._get_single_result(Content, 'content/{}'.format(content_id), {}, expand) 371 | 372 | def delete_content(self, content_id, content_status): # type: (int, ContentStatus) -> None 373 | """ 374 | Deletes a piece of content according to a set of rules based on it's status. 375 | 376 | c.f. https://docs.atlassian.com/ConfluenceServer/rest/6.6.0/#content-delete 377 | for more details. 378 | 379 | :param content_id: The ID of the content in confluence. 380 | :param content_status: Required on this call to determine how to 381 | delete (whether to trash or permanently delete). 382 | """ 383 | self._delete('content/{}'.format(content_id), params={'status': content_status.value}) 384 | 385 | def get_content_history(self, content_id, expand=None): # type: (int, Optional[List[str]]) -> ContentHistory 386 | """ 387 | Get the full history of a confluence object. Note that in general you 388 | can retrieve this by using get_content with history expanded so this 389 | function is only useful when you don't need the content object as well. 390 | 391 | :param content_id: The ID of the content in confluence. 392 | :param expand: The confluence REST API utilised expansion to avoid 393 | returning all fields on all requests. This optional parameter allows 394 | the user to select which fields that they want to expand as a list. 395 | 396 | :return: A content history object. 397 | """ 398 | return self._get_single_result(ContentHistory, 'content/{}/history'.format(content_id), {}, expand) 399 | 400 | def get_child_pages(self, content_id, parent_version=None, expand=None): 401 | # type: (int, Optional[int], Optional[List[str]]) -> Iterable[Content] 402 | """ 403 | Get the child pages of a piece of content. Doesn't recurse through 404 | their children. 405 | 406 | :param content_id: Must be the confluence ID of a page. 407 | :param parent_version: Optionally pass the version of the page to look 408 | for children on. Defaults to 0. 409 | :param expand: The confluence REST API utilised expansion to avoid 410 | returning all fields on all requests. This optional parameter allows 411 | the user to select which fields that they want to expand as a list. 412 | 413 | :return: An iterable containing 0-n pages that are children of this page. 414 | """ 415 | params = {} 416 | if parent_version: 417 | params['parentVersion'] = str(parent_version) 418 | 419 | return self._get_paged_results(Content, 'content/{}/child/page'.format(content_id), params, expand) 420 | 421 | def get_comments(self, content_id, depth=None, parent_version=None, location=None, expand=None): 422 | # type: (int, Optional[CommentDepth], Optional[int], Optional[List[CommentLocation]], Optional[List[str]]) -> Iterable[Content] 423 | """ 424 | Retrieve comments on a piece of content. 425 | 426 | :param content_id: The ID of the content in confluence. 427 | :param depth: Either ROOT or ALL to indicate whether to see all 428 | comments at all depths. 429 | :param parent_version: The version of the content which we want 430 | comments on. Default is to use current version. 431 | :param location: List of inline, resolved and footer. 432 | :param expand: The confluence REST API utilised expansion to avoid 433 | returning all fields on all requests. This optional parameter allows 434 | the user to select which fields that they want to expand as a list. 435 | 436 | :return: A list of 0-n comments from the document. 437 | """ 438 | params = {} 439 | 440 | if depth: 441 | params['depth'] = depth.value 442 | 443 | if parent_version: 444 | params['parent_version'] = parent_version 445 | 446 | if location: 447 | # Note, this is really correct. The confluence API wants to have location=A&location=B not location=A,B 448 | params['location'] = [l.value for l in location] 449 | 450 | return self._get_paged_results(Content, 451 | 'content/{}/child/comment'.format(content_id), 452 | params=params, 453 | expand=expand) 454 | 455 | def get_attachments(self, content_id, filename=None, media_type=None, expand=None): 456 | # type: (int, Optional[str], Optional[str], Optional[List[str]]) -> Iterable[Content] 457 | """ 458 | Retrieve attachments on a piece of content. 459 | 460 | :param content_id: The ID of the content in confluence. 461 | :param filename: Optionally the filename to search by exact filename. 462 | :param media_type: Optionally the media type of attachments to search 463 | for. 464 | :param expand: The confluence REST API utilised expansion to avoid 465 | returning all fields on all requests. This optional parameter allows 466 | the user to select which fields that they want to expand as a list. 467 | 468 | :return: A list of 0-n attachments from the document. 469 | """ 470 | params = {} 471 | 472 | if filename: 473 | params['filename'] = filename 474 | 475 | if media_type: 476 | params['media_type'] = media_type 477 | 478 | return self._get_paged_results(Content, 479 | 'content/{}/child/attachment'.format(content_id), 480 | params=params, 481 | expand=expand) 482 | 483 | def download_attachment(self, attachment): 484 | # type: (Content) -> bytes 485 | """ 486 | Downloads an attachment. To be used in conjunction with get_attachments. 487 | 488 | :param attachment: A content record for the attachment to be downloaded 489 | 490 | :return: A byte array for the attachment. 491 | """ 492 | if not isinstance(attachment, Content) or attachment.type != ContentType.ATTACHMENT: 493 | raise ValueError('Parameter must be an Attachment Content object') 494 | 495 | path = attachment.links['download'] 496 | 497 | response = self._get(path, {}, []) 498 | 499 | Confluence._handle_response_errors(path, {}, response) 500 | return response.content 501 | 502 | def add_attachment(self, content_id, file_path, file_name=None, status=None): 503 | # type: (int, str, Optional[str], Optional[ContentStatus]) -> Iterable[Content] 504 | """ 505 | Add a single attachment to an existing piece of content. 506 | 507 | :param content_id: the confluence content to add the attachment to. 508 | :param file_path: The full location of the file on the local system. 509 | :param file_name: Optionally the name to give the attachment in 510 | confluence. 511 | :param status: Optionally the status of the attachment after upload. 512 | Must be one of current or draft, defaults to current. 513 | 514 | :return: A list containing 0-1 attachments depending on whether this 515 | succeeded or not. 516 | """ 517 | params = {} 518 | if status: 519 | if status in (ContentStatus.HISTORICAL, ContentStatus.TRASHED): 520 | raise ValueError('Only draft or current are valid states for a new attachment') 521 | params['status'] = status.value 522 | 523 | if not file_name: 524 | file_name = os.path.basename(file_path) 525 | 526 | with open(file_path, 'rb') as f: 527 | return self._post_return_multiple(Content, 528 | 'content/{}/child/attachment'.format(content_id), 529 | params=params, 530 | files={'file': (file_name, f)}, 531 | data={}) 532 | 533 | def update_attachment(self, 534 | page_id, # type: int 535 | attachment_id, # type: int 536 | version, # type: int 537 | new_filename=None, # type: Optional[str] 538 | new_comment=None, # type: Optional[str] 539 | new_media_type=None, # type: Optional[str] 540 | new_page_id=None, # type: Optional[int] 541 | new_status=None, # type: Optional[ContentStatus] 542 | expand=None, # type: Optional[List[str]] 543 | ): # type: (...) -> Content 544 | """ 545 | Update the information about an attachment in confluence. 546 | This can be used to update filename, media type, comment 547 | or status. 548 | 549 | :param page_id: The parent page of the attachment. 550 | :param attachment_id: Id of attachment to be updated 551 | :param version: This should be the current version. 552 | :param new_filename: The new filename to be used. 553 | :param new_comment: The new comment to be used, optional. 554 | :param new_media_type: The new comment to be used, optional. 555 | :param new_page_id: The new page id for this attachment, optional. 556 | :param new_status: The new content status, optional. 557 | :param expand: An optional list of properties to be expanded on the resulting attachment object. 558 | 559 | :return: The updated attachment object. 560 | """ 561 | content = { 562 | 'id': attachment_id, 563 | 'type': ContentType.ATTACHMENT.value, 564 | 'version': { 565 | 'number': version, 566 | }, 567 | 'metadata': {} 568 | } # type: Dict[str, Any] 569 | 570 | if new_filename: 571 | content['title'] = new_filename 572 | 573 | if new_comment: 574 | content['metadata']['comment'] = new_comment 575 | 576 | if new_media_type: 577 | content['metadata']['mediaType'] = new_media_type 578 | 579 | if new_page_id: 580 | content['container'] = {} 581 | content['container']['id'] = new_page_id 582 | content['container']['type'] = ContentType.ATTACHMENT.value 583 | 584 | if new_status: 585 | content['status'] = new_status.value 586 | 587 | path = 'content/{}/child/attachment/{}'.format(page_id, attachment_id) 588 | return self._put_return_single(Content, path, {}, data=content, expand=expand) 589 | 590 | def update_attachment_data(self, 591 | page_id, # type; int 592 | attachment_id, # type: int 593 | file_path, # type: str 594 | file_name=None, # type: Optional[str] 595 | minor_edit=False, # type: Optional[bool] 596 | expand=None # type: Optional[List[str]] 597 | ): # type: (...) -> Content 598 | """ 599 | Updates an attachments contents to a new file. 600 | 601 | :param page_id: The parent page of the attachment. 602 | :param attachment_id: the confluence content to add the attachment to. 603 | :param file_path: The full location of the file on the local system. 604 | :param file_name: Optionally the name to give the attachment in confluence. 605 | :param minor_edit: Defaults to False. Set to true to make this update 606 | a minor edit. 607 | :param expand: An optional list of properties to be expanded on the resulting attachment object. 608 | 609 | :return: A Content object containing details of the attachment. 610 | """ 611 | if not file_name: 612 | file_name = os.path.basename(file_path) 613 | 614 | files = {} # type: Dict[str, Any] 615 | 616 | if minor_edit: 617 | files['minorEdit'] = 'true' 618 | 619 | with open(file_path, 'rb') as f: 620 | files['file'] = (file_name, f) 621 | 622 | return self._post_return_single(Content, 623 | 'content/{}/child/attachment/{}/data'.format(page_id, attachment_id), 624 | params={}, 625 | data={}, 626 | files=files, 627 | expand=expand) 628 | 629 | def get_labels(self, content_id, prefix=None): # type: (int, Optional[LabelPrefix]) -> Iterable[Label] 630 | """ 631 | Retrieve the set of labels on a piece of content. 632 | 633 | :param content_id: The confluence unique id for this content. 634 | :param prefix: Optionally specify the label prefix. 635 | 636 | :return: A list of the labels on that document. 637 | """ 638 | params = {} 639 | 640 | if prefix: 641 | params['prefix'] = prefix.value 642 | 643 | return self._get_paged_results(Label, 'content/{}/label'.format(content_id), params, None) 644 | 645 | def create_labels(self, content_id, new_labels): 646 | # type: (int, Iterable[Tuple[LabelPrefix, str]]) -> Iterable[Label] 647 | """ 648 | Create 1-n labels on a piece of content. 649 | 650 | :param content_id: The unique identifier for the content object. 651 | :param new_labels: An array of tuples where item 1 is the label prefix 652 | and item 2 is the label name. 653 | 654 | :return: The set of labels as Label objects. 655 | """ 656 | data = [{ 657 | 'prefix': label[0].value, 658 | 'name': label[1] 659 | } for label in new_labels] 660 | 661 | return self._post_return_multiple(Label, 'content/{}/label'.format(content_id), 662 | files={}, data=data, params={}) 663 | 664 | def delete_label(self, content_id, label_name): # type: (int, str) -> None 665 | """ 666 | Remove a label from a piece of content by label name. 667 | 668 | Note that we use the query parameter form of the delete to allow for 669 | deleting labels with a / in the name. 670 | 671 | :param content_id: The unique identifier for the content object. 672 | :param label_name: The name of the label to remove. 673 | """ 674 | self._delete('content/{}/label'.format(content_id), params={'name': label_name}) 675 | 676 | def get_content_properties(self, content_id, expand=None): 677 | # type: (int, Optional[List[str]]) -> Iterable[ContentProperty] 678 | """ 679 | 680 | :param content_id: Required to identify which piece of content we want 681 | to get properties from. 682 | :param expand: The confluence REST API utilised expansion to avoid 683 | returning all fields on all requests. This optional parameter allows 684 | the user to select which fields that they want to expand as a comma 685 | separated list. 686 | 687 | :return: The full list of properties defined on that piece of content. 688 | """ 689 | return self._get_paged_results(ContentProperty, 'content/{}/property'.format(content_id), {}, expand) 690 | 691 | def create_content_property(self, content_id, property_key, property_value): 692 | # type: (int, str, Dict[str, Any]) -> ContentProperty 693 | """ 694 | Create a property on a specific piece of content. 695 | 696 | :param content_id: Required to identify which piece of content we want 697 | to store the property on. 698 | :param property_key: The new key, will raise a GeneralError if this 699 | clashes with an existing property. 700 | :param property_value: An arbitrary piece of json serializable data. 701 | 702 | :return: The fully populated ContentProperty object. 703 | """ 704 | data = { 705 | 'key': property_key, 706 | 'value': property_value 707 | } 708 | 709 | return self._post_return_single(ContentProperty, 'content/{}/property'.format(content_id), {}, data) 710 | 711 | def get_content_property(self, content_id, property_key, expand=None): 712 | # type: (int, str, Optional[List[str]]) -> ContentProperty 713 | """ 714 | Retrieve a single property by key from a particular piece of content. 715 | 716 | :param content_id: Required to identify the content we're looking for a 717 | property on. 718 | :param property_key: The specific property to search for. If this 719 | doesn't exist then we raise a ResourceNotFound error. 720 | :param expand: The confluence REST API utilised expansion to avoid 721 | returning all fields on all requests. This optional parameter allows 722 | the user to select which fields that they want to expand as a comma 723 | separated list. 724 | 725 | :return: The fully populated ContentProperty object. 726 | """ 727 | return self._get_single_result(ContentProperty, 'content/{}/property/{}'.format(content_id, property_key), {}, 728 | expand) 729 | 730 | def update_content_property(self, content_id, property_key, new_value, new_version, 731 | is_minor_edit=False, is_hidden_edit=False): 732 | # type: (int, str, Dict[str, Any], int, bool, bool) -> ContentProperty 733 | """ 734 | Create a new version of a property on a piece of content. 735 | 736 | :param content_id: Required to identify the piece of content. 737 | :param property_key: The specific property to update. If this doesn't 738 | exist then we raise a ResourceNotFound error. 739 | :param new_value: Any arbitrary JSON serializable value. 740 | :param new_version: If this is 1 and the key doesn't already exist then 741 | create a new property. Otherwise it must be 1 more than the 742 | previous version number for this property. 743 | :param is_minor_edit: Whether this should count as a minor update. 744 | Defaults to False. 745 | :param is_hidden_edit: Whether this should count as a hidden edit. 746 | Defaults to False. 747 | 748 | :return: The new ContentProperty object. 749 | """ 750 | data = { 751 | 'key': property_key, 752 | 'value': new_value, 753 | 'version': { 754 | 'number': new_version, 755 | 'minorEdit': is_minor_edit, 756 | 'hidden': is_hidden_edit 757 | } 758 | } 759 | 760 | return self._put_return_single(ContentProperty, 'content/{}/property/{}'.format(content_id, property_key), 761 | {}, data) 762 | 763 | def delete_content_property(self, content_id, property_key): 764 | # type: (int, str) -> None 765 | """ 766 | Remove a property from a piece of content. 767 | 768 | :param content_id: Required to identify the piece of content. 769 | :param property_key: Required to identify the property uniquely. 770 | """ 771 | self._delete('content/{}/property/{}'.format(content_id, property_key), {}) 772 | 773 | def search(self, cql, cql_context=None, expand=None): 774 | # type: (str, Optional[str], Optional[List[str]]) -> Iterable[Content] 775 | """ 776 | Perform a CQL search on the confluence instance and return an iterable 777 | of the pages which match the query. 778 | 779 | :param cql: A CQL query. See https://developer.atlassian.com/server/confluence/advanced-searching-using-cql/ 780 | for reference. 781 | :param cql_context: "the context to execute a cql search in, this is 782 | the json serialized form of SearchContext". 783 | :param expand: The confluence REST API utilised expansion to avoid 784 | returning all fields on all requests. This optional parameter allows 785 | the user to select which fields that they want to expand as a comma 786 | separated list. 787 | 788 | :return: An iterable of pages which match the parameters. 789 | """ 790 | params = {'cql': cql} 791 | if cql_context: 792 | params['cqlcontext'] = cql_context 793 | 794 | return self._get_paged_results(Content, 'content/search', params, expand) 795 | 796 | def get_spaces(self, space_keys=None, space_type=None, status=None, label=None, favourite=None, expand=None): 797 | # type: (Optional[List[str]], Optional[SpaceType], Optional[SpaceStatus], Optional[str], Optional[bool], Optional[List[str]]) -> Iterable[Space] 798 | """ 799 | Queries the list of spaces, providing several ways to further filter 800 | that query. 801 | 802 | :param space_keys: A list of space keys, only these spaces will be 803 | returned and invalid values will be ignored. 804 | :param space_type: Filter on the type of space, all space types 805 | returned by default. 806 | :param status: Filter on the status of space, all statuses returned by 807 | default. 808 | :param label: Filter on space label, no filter by default. 809 | :param favourite: Filter on whether the space is favourited by the 810 | user running the query. Ignored by default. 811 | :param expand: Optional list of things to expand. Some of icon, 812 | description, metadata & homepage. 813 | :return: 814 | """ 815 | params = {} 816 | if space_keys: 817 | params['spaceKey'] = ','.join(space_keys) 818 | if space_type: 819 | params['type'] = space_type.value 820 | if status: 821 | params['status'] = status.value 822 | if label: 823 | params['label'] = label 824 | if favourite: 825 | # TODO - Can't figure out if this really works. The REST API docs don't explain it and no 826 | # queries re: favourite seem to make any difference 827 | params['favourite'] = str(favourite) 828 | 829 | return self._get_paged_results(Space, 'space', params, expand) 830 | 831 | def create_space(self, space_key, space_name, space_description=None, is_private=False): 832 | # type: (str, str, Optional[str], bool) -> Space 833 | """ 834 | Create a space with the specified key, name and (optional) description. 835 | 836 | :param space_key: The new space key. Causes exception if this is not 837 | unique. 838 | :param space_name: The new name for the space. 839 | :param space_description: Optional. A description of the space. 840 | :param is_private: Set to true to make this space only visible by the 841 | creator. 842 | 843 | :return: The full space object including it's id. 844 | """ 845 | path = 'space/_private' if is_private else 'space' 846 | 847 | data = { 848 | 'key': space_key, 849 | 'name': space_name 850 | } # type: Dict[str, Any] 851 | 852 | if space_description: 853 | data['description'] = { 854 | 'plain': { 855 | 'value': space_description, 856 | 'representation': 'plain' 857 | } 858 | } 859 | 860 | return self._post_return_single(Space, path, data=data, params={}) 861 | 862 | def get_space(self, space_key, expand=None): # type: (str, Optional[List[str]]) -> Space 863 | """ 864 | Retrieve information on a single space. 865 | 866 | :param space_key: Required parameter which identifies the space. 867 | :param expand: Optional list of things to expand. Some of icon, 868 | description, metadata & homepage. 869 | 870 | :return: The space matching the given key. 871 | """ 872 | return self._get_single_result(Space, 'space/{}'.format(space_key), {}, expand) 873 | 874 | def update_space(self, space_key, new_name, new_description): 875 | # type: (str, Optional[str], Optional[str]) -> Space 876 | """ 877 | Update the name, description or both for a given space. 878 | 879 | :param space_key: The unique key for the space. 880 | :param new_name: The new name, if None then don't update. 881 | :param new_description: The new description, if None then don't update. 882 | 883 | :return: The full new space object including id. 884 | """ 885 | data = {} # type: Dict[str, Any] 886 | 887 | if new_name: 888 | data['name'] = new_name 889 | 890 | if new_description: 891 | data['description'] = { 892 | 'plain': { 893 | 'value': new_description, 894 | 'representation': 'plain' 895 | } 896 | } 897 | 898 | return self._put_return_single(Space, 'space/{}'.format(space_key), data=data, params={}) 899 | 900 | def delete_space(self, space_key): # type: (str) -> None 901 | """ 902 | Delete a space inside of a long running task. 903 | 904 | # TODO - This should really return the longtask that can be used to poll for when it completes. 905 | :param space_key: The spaces unique identifier. 906 | """ 907 | self._delete('space/{}'.format(space_key), params={}) 908 | 909 | def get_space_content(self, space_key, just_root=False, expand=None): 910 | # type: (str, bool, Optional[List[str]]) -> Iterable[Content] 911 | """ 912 | Get all of the content underneath a particular space. 913 | 914 | :param space_key: The unique identifier for the space. 915 | :param just_root: Set to true if you only want the top level pages. 916 | :param expand: A list of page properties which can be expanded. 917 | 918 | :return: A generator containing all pages matching the search criteria. 919 | """ 920 | params = {} 921 | 922 | if just_root: 923 | params['depth'] = 'root' 924 | 925 | return self._get_paged_results(Content, 'space/{}/content'.format(space_key), params, expand) 926 | 927 | def get_space_content_with_type(self, space_key, content_type, just_root=False, expand=None): 928 | # type: (str, ContentType, bool, Optional[List[str]]) -> Iterable[Content] 929 | """ 930 | Get all of the content underneath a particular space of a given type 931 | 932 | :param space_key: The unique identifier for the space. 933 | :param content_type: What sort of content to return. Blogs or pages. 934 | :param just_root: Set to true if you only want the top level pages. 935 | :param expand: A list of page properties which can be expanded. 936 | 937 | :return: A generator containing all pages matching the search criteria. 938 | """ 939 | path = 'space/{}/content/{}'.format(space_key, content_type.value) 940 | params = {} 941 | 942 | if just_root: 943 | params['depth'] = 'root' 944 | 945 | return self._get_paged_results(Content, path, params, expand) 946 | 947 | def get_space_properties(self, space_key, expand=None): 948 | # type: (str, Optional[List[str]]) -> Iterable[SpaceProperty] 949 | """ 950 | Get all of the properties attached to a given space. 951 | 952 | :param space_key: The key of the space. 953 | :param expand: A list of properties which can be expanded. 954 | 955 | :return: A generator containing all of the properties attached to the 956 | space. 957 | """ 958 | return self._get_paged_results(SpaceProperty, 'space/{}/property'.format(space_key), {}, expand) 959 | 960 | def create_space_property(self, space_key, property_key, property_value): 961 | # type: (str, str, Dict[str, Any]) -> SpaceProperty 962 | """ 963 | Create a property attached to a space. 964 | 965 | :param space_key: The space to which we're adding a property. 966 | :param property_key: The key for the new property. 967 | :param property_value: An arbitrary JSON serializable object which 968 | will become the property value. 969 | 970 | :return: The space property that was created. 971 | """ 972 | data = { 973 | 'key': property_key, 974 | 'value': property_value 975 | } 976 | return self._post_return_single(SpaceProperty, 'space/{}/property'.format(space_key), params={}, data=data) 977 | 978 | def get_space_property(self, space_key, property_key, expand=None): 979 | # type: (str, str, Optional[List[str]]) -> Iterable[SpaceProperty] 980 | """ 981 | Get all of the properties attached to a given space which match a 982 | property key. 983 | 984 | :param space_key: The key of the space. 985 | :param property_key: The key of the property. 986 | :param expand: A list of properties which can be expanded. 987 | 988 | :return: A generator containing all of the properties attached to the 989 | space. 990 | """ 991 | path = 'space/{}/property/{}'.format(space_key, property_key) 992 | 993 | return self._get_paged_results(SpaceProperty, path, {}, expand) 994 | 995 | def update_space_property(self, space_key, property_key, property_value, new_version, 996 | minor_edit=False, hidden_version=False): 997 | # type: (str, str, Dict[str, Any], int, Optional[bool], Optional[bool]) -> SpaceProperty 998 | """ 999 | Create a new version of a space property. 1000 | 1001 | :param space_key: The space to create the property in. 1002 | :param property_key: The key of the property to create. 1003 | :param property_value: The new value of the property. 1004 | :param new_version: The version number of the property. If this is 1 1005 | then a new property is created, otherwise it must be current_version+1 1006 | or this function will raise an exception. 1007 | :param minor_edit: Defaults to False. Set to true to make this update 1008 | a minor edit. 1009 | :param hidden_version: Defaults to False. Set to true to make this 1010 | version hidden. 1011 | 1012 | :return: The created property (including version). 1013 | """ 1014 | path = 'space/{}/property/{}'.format(space_key, property_key) 1015 | data = { 1016 | 'key': property_key, 1017 | 'value': property_value, 1018 | 'version': { 1019 | 'number': new_version, 1020 | 'minorEdit': minor_edit, 1021 | 'hidden': hidden_version 1022 | } 1023 | } 1024 | return self._put_return_single(SpaceProperty, path, params={}, data=data) 1025 | 1026 | def delete_space_property(self, space_key, property_key): 1027 | # type: (str, str) -> None 1028 | """ 1029 | 1030 | :param space_key: The space in which we're removing a property. 1031 | :param property_key: The property to remove. 1032 | """ 1033 | self._delete('space/{}/property/{}'.format(space_key, property_key), {}) 1034 | 1035 | def get_user(self, username=None, user_key=None, expand=None): 1036 | # type: (Optional[str], Optional[str], Optional[List[str]]) -> User 1037 | """ 1038 | Return a single user object matching either the username of the key 1039 | passed in. 1040 | 1041 | Note: You must pass exactly one of username or user_key to this 1042 | function. 1043 | 1044 | :param username: The username as seen in Confluence. 1045 | :param user_key: The unique user id. 1046 | :param expand: A list of sections of the user object to expand. 1047 | 1048 | :return: A full user object. 1049 | """ 1050 | if (not username and not user_key) or (username and user_key): 1051 | raise ValueError('Exactly one of username or user_key must be set') 1052 | 1053 | params = {} 1054 | if username: 1055 | params['username'] = username 1056 | if user_key: 1057 | params['key'] = user_key 1058 | 1059 | return self._get_single_result(User, 'user', params, expand) 1060 | 1061 | def get_anonymous_user(self): # type: () -> User 1062 | """ 1063 | Returns the user object which represents anonymous users on Confluence. 1064 | 1065 | :return: A full user object. 1066 | """ 1067 | return self._get_single_result(User, 'user/anonymous', {}, None) 1068 | 1069 | def get_current_user(self): # type: () -> User 1070 | """ 1071 | Returns the user object for the current logged in user. 1072 | 1073 | :return: A full user object. 1074 | """ 1075 | return self._get_single_result(User, 'user/current', {}, None) 1076 | 1077 | def get_user_groups(self, username=None, user_key=None, expand=None): 1078 | # type: (Optional[str], Optional[str], Optional[List[str]]) -> Iterable[Group] 1079 | """ 1080 | Get a list of the groups that a user is a member of. Either the 1081 | username or key must be set and not both. 1082 | 1083 | :param username: The username as seen in confluence. 1084 | :param user_key: The users unique key in confluence. 1085 | :param expand: An optional list of fields to expand on the returned 1086 | group objects. None currently known. 1087 | 1088 | :return: The list of groups as an iterator. 1089 | """ 1090 | if (not username and not user_key) or (username and user_key): 1091 | raise ValueError('Exactly one of username or user_key must be set') 1092 | 1093 | params = {} 1094 | 1095 | if username: 1096 | params['username'] = username 1097 | if user_key: 1098 | params['key'] = user_key 1099 | 1100 | return self._get_paged_results(Group, 'user/memberof', params, expand) 1101 | 1102 | def get_groups(self, expand): 1103 | # type: (Optional[List[str]]) -> Iterable[Group] 1104 | """ 1105 | Get the entire collection of groups on this instance. 1106 | 1107 | :param expand: An optional list of fields to expand on the returned 1108 | group objects. None currently known. 1109 | 1110 | :return: The list of groups as an iterator. 1111 | """ 1112 | return self._get_paged_results(Group, 'group', {}, expand) 1113 | 1114 | def get_group(self, name, expand): 1115 | # type: (str, Optional[List[str]]) -> Group 1116 | """ 1117 | Get a single group instance. 1118 | 1119 | :param name: The name of the group to search for. 1120 | :param expand: An optional list of fields to expand on the returned 1121 | group objects. None currently known. 1122 | 1123 | :return: The group object. 1124 | """ 1125 | return self._get_single_result(Group, 'group/{}'.format(name), {}, expand) 1126 | 1127 | def get_group_members(self, name, expand): 1128 | # type: (str, Optional[List[str]]) -> Iterable[User] 1129 | """ 1130 | Get the entire collection of users in this group. 1131 | 1132 | :param name: The name of the group to search for. 1133 | :param expand: An optional list of fields to expand on the returned 1134 | user objects. None currently known. 1135 | 1136 | :return: The list of groups as an iterator. 1137 | """ 1138 | return self._get_paged_results(User, 'group/{}/member'.format(name), {}, expand) 1139 | 1140 | def get_long_tasks(self, expand): 1141 | # type: (Optional[List[str]]) -> Iterable[LongTask] 1142 | """ 1143 | Get the full list of long running tasks from the confluence instance. 1144 | 1145 | :param expand: An optional list of fields to expand on the returned 1146 | user objects. None currently known. 1147 | 1148 | :return: The list of long running tasks including recently completed 1149 | ones. 1150 | """ 1151 | return self._get_paged_results(LongTask, 'longtask', {}, expand) 1152 | 1153 | def get_long_task(self, task_id, expand): 1154 | # type: (str, Optional[List[str]]) -> Iterable[LongTask] 1155 | """ 1156 | Get the details about a single long running task. 1157 | 1158 | :param task_id: The task id as a GUID. 1159 | :param expand: An optional list of fields to expand on the returned 1160 | user objects. None currently known. 1161 | 1162 | :return: The full task information. 1163 | """ 1164 | return self._get_paged_results(LongTask, 'longtask/{}'.format(task_id), {}, expand) 1165 | 1166 | def get_audit_records(self, start_date, end_date, search_string): 1167 | # type: (Optional[date], Optional[date], Optional[str]) -> Iterable[AuditRecord] 1168 | """ 1169 | Retrieve audit records between two dates with the given search parameters. 1170 | 1171 | :param start_date: Optional date to start searching. 1172 | :param end_date: Optional date to end searching. 1173 | :param search_string: Optional string which will be included in all 1174 | returned audit records. 1175 | 1176 | :return: A list of all audit records matching the given criteria. 1177 | """ 1178 | params = {} 1179 | if start_date: 1180 | params['startDate'] = start_date.strftime('%Y-%m-%d') 1181 | 1182 | if end_date: 1183 | params['endDate'] = end_date.strftime('%Y-%m-%d') 1184 | 1185 | if search_string: 1186 | params['searchString'] = search_string 1187 | 1188 | return self._get_paged_results(AuditRecord, 'audit', params, None) 1189 | 1190 | def add_content_watch(self, content_id, user_key=None, username=None): 1191 | # type: (int, Optional[str], Optional[str]) -> None 1192 | """ 1193 | Add a watch for a given user & piece of content. 1194 | 1195 | User is optional. If not specified, currently logged-in user will be 1196 | used. Otherwise, it can be specified by either user key or username. 1197 | When a user is specified and is different from the logged-in user, 1198 | the logged-in user needs to be a Confluence administrator. 1199 | 1200 | :param content_id: The unique content id. 1201 | :param user_key: The users unique key. If this is set then username 1202 | must not be. 1203 | :param username: The username to check for watches. If this is set 1204 | then user_key must not be. 1205 | """ 1206 | if username and user_key: 1207 | raise ValueError('Only one of username or user_key may be set') 1208 | 1209 | params = {} 1210 | 1211 | if username: 1212 | params['username'] = username 1213 | if user_key: 1214 | params['key'] = user_key 1215 | 1216 | self._post('user/watch/content/{}'.format(content_id), params=params, data={}) 1217 | 1218 | def remove_content_watch(self, content_id, user_key=None, username=None): 1219 | # type: (int, Optional[str], Optional[str]) -> None 1220 | """ 1221 | Stop a user watching a piece of content. 1222 | 1223 | User is optional. If not specified, currently logged-in user will be 1224 | used. Otherwise, it can be specified by either user key or username. 1225 | When a user is specified and is different from the logged-in user, 1226 | the logged-in user needs to be a Confluence administrator. 1227 | 1228 | :param content_id: The unique content id. 1229 | :param user_key: The users unique key. If this is set then username 1230 | must not be. 1231 | :param username: The username to check for watches. If this is set 1232 | then user_key must not be. 1233 | """ 1234 | if username and user_key: 1235 | raise ValueError('Only one of username or user_key may be set') 1236 | 1237 | params = {} 1238 | 1239 | if username: 1240 | params['username'] = username 1241 | if user_key: 1242 | params['key'] = user_key 1243 | 1244 | self._delete('user/watch/content/{}'.format(content_id), params) 1245 | 1246 | def is_user_watching_content(self, content_id, user_key=None, username=None): 1247 | # type: (int, Optional[str], Optional[str]) -> bool 1248 | """ 1249 | Get information about whether a user is watching specific content. 1250 | 1251 | User is optional. If not specified, currently logged-in user will be 1252 | used. Otherwise, it can be specified by either user key or username. 1253 | When a user is specified and is different from the logged-in user, 1254 | the logged-in user needs to be a Confluence administrator. 1255 | 1256 | :param content_id: The content id to check 1257 | :param user_key: The users unique key. If this is set then username 1258 | must not be. 1259 | :param username: The username to check for watches. If this is set 1260 | then user_key must not be. 1261 | 1262 | :return: True/False depending on whether the user is watching the 1263 | specified content. 1264 | """ 1265 | if username and user_key: 1266 | raise ValueError('Only one of username or user_key may be set') 1267 | 1268 | params = {} 1269 | 1270 | if username: 1271 | params['username'] = username 1272 | if user_key: 1273 | params['key'] = user_key 1274 | 1275 | return self._get('user/watch/content/{}'.format(content_id), params, None).json()['watching'] 1276 | 1277 | def add_space_watch(self, space_key, user_key=None, username=None): 1278 | # type: (str, Optional[str], Optional[str]) -> None 1279 | """ 1280 | Add a watch for a given user & space. 1281 | 1282 | User is optional. If not specified, currently logged-in user will be 1283 | used. Otherwise, it can be specified by either user key or username. 1284 | When a user is specified and is different from the logged-in user, 1285 | the logged-in user needs to be a Confluence administrator. 1286 | 1287 | :param space_key: The key of the space to add the watch on. 1288 | :param user_key: The users unique key. If this is set then username 1289 | must not be. 1290 | :param username: The username to check for watches. If this is set 1291 | then user_key must not be. 1292 | """ 1293 | if username and user_key: 1294 | raise ValueError('Only one of username or user_key may be set') 1295 | 1296 | params = {} 1297 | 1298 | if username: 1299 | params['username'] = username 1300 | if user_key: 1301 | params['key'] = user_key 1302 | 1303 | self._post('user/watch/space/{}'.format(space_key), params, data={}) 1304 | 1305 | def remove_space_watch(self, space_key, user_key=None, username=None): 1306 | # type: (str, Optional[str], Optional[str]) -> None 1307 | """ 1308 | Stop a user watching a space. 1309 | 1310 | User is optional. If not specified, currently logged-in user will be 1311 | used. Otherwise, it can be specified by either user key or username. 1312 | When a user is specified and is different from the logged-in user, 1313 | the logged-in user needs to be a Confluence administrator. 1314 | 1315 | :param space_key: The key of the space to remove the watch on. 1316 | :param user_key: The users unique key. If this is set then username 1317 | must not be. 1318 | :param username: The username to check for watches. If this is set 1319 | then user_key must not be. 1320 | """ 1321 | if username and user_key: 1322 | raise ValueError('Only one of username or user_key may be set') 1323 | 1324 | params = {} 1325 | 1326 | if username: 1327 | params['username'] = username 1328 | if user_key: 1329 | params['key'] = user_key 1330 | 1331 | self._delete('user/watch/space/{}'.format(space_key), params) 1332 | 1333 | def is_user_watching_space(self, space_key, user_key=None, username=None): 1334 | # type: (str, Optional[str], Optional[str]) -> bool 1335 | """ 1336 | Get information about whether a user is watching a specific space. 1337 | 1338 | User is optional. If not specified, currently logged-in user will be 1339 | used. Otherwise, it can be specified by either user key or username. 1340 | When a user is specified and is different from the logged-in user, 1341 | the logged-in user needs to be a Confluence administrator. 1342 | 1343 | :param space_key: The key of the space to check. 1344 | :param user_key: The users unique key. If this is set then username 1345 | must not be. 1346 | :param username: The username to check for watches. If this is set 1347 | then user_key must not be. 1348 | 1349 | :return: True/False depending on whether the user is watching the 1350 | specified space. 1351 | """ 1352 | if username and user_key: 1353 | raise ValueError('Only one of username or user_key may be set') 1354 | 1355 | params = {} 1356 | 1357 | if username: 1358 | params['username'] = username 1359 | if user_key: 1360 | params['key'] = user_key 1361 | 1362 | return self._get('user/watch/space/{}'.format(space_key), params, None).json()['watching'] 1363 | 1364 | def __str__(self): 1365 | return self._base_url 1366 | --------------------------------------------------------------------------------