├── 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 |
--------------------------------------------------------------------------------