├── docs ├── _static │ ├── img │ │ ├── logo.ico │ │ └── logo.png │ └── css │ │ └── custom.css ├── api │ ├── modules.rst │ ├── confluencer.api.rst │ ├── confluencer.util.rst │ ├── confluencer.tools.rst │ ├── confluencer.rst │ └── confluencer.commands.rst ├── requirements.txt ├── api-reference.rst ├── cli-reference.rst ├── index.rst ├── usage.rst ├── CONTRIBUTING.rst ├── conf.py └── LICENSE.rst ├── test-requirements.txt ├── MANIFEST.in ├── dev-requirements.txt ├── requirements.txt ├── project.d ├── README.md ├── classifiers.txt ├── cookiecutter.json ├── skeleton_module.py ├── coverage.cfg ├── skeleton_testmodule.py └── pylint.cfg ├── setup.cfg ├── .travis.yml ├── tox.ini ├── src ├── confluencer │ ├── tools │ │ ├── __init__.py │ │ └── content.py │ ├── commands │ │ ├── __init__.py │ │ ├── pretty.py │ │ ├── tidy.py │ │ ├── remove.py │ │ ├── help.py │ │ └── stats.py │ ├── util │ │ └── __init__.py │ ├── __init__.py │ ├── config.py │ ├── __main__.py │ ├── _compat.py │ └── api │ │ └── __init__.py └── tests │ ├── conftest.py │ ├── test_package.py │ ├── markers.py │ ├── test_tools_content.py │ ├── test_api.py │ └── test_cli.py ├── .gitignore ├── .github └── workflows │ └── main.yml ├── tasks.py ├── CONTRIBUTING.md ├── README.md ├── setup.py ├── .env └── LICENSE /docs/_static/img/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mam-dev/confluencer/HEAD/docs/_static/img/logo.ico -------------------------------------------------------------------------------- /docs/_static/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mam-dev/confluencer/HEAD/docs/_static/img/logo.png -------------------------------------------------------------------------------- /docs/api/modules.rst: -------------------------------------------------------------------------------- 1 | confluencer 2 | =========== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | confluencer 8 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # "Read The Docs" requirements 3 | # 4 | 5 | sphinx-click==2.3.1 6 | 7 | -r ../requirements.txt 8 | -------------------------------------------------------------------------------- /docs/api/confluencer.api.rst: -------------------------------------------------------------------------------- 1 | confluencer.api package 2 | ======================= 3 | 4 | .. automodule:: confluencer.api 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /docs/api/confluencer.util.rst: -------------------------------------------------------------------------------- 1 | confluencer.util package 2 | ======================== 3 | 4 | .. automodule:: confluencer.util 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # Test requirements 3 | # 4 | 5 | pytest==5.3.1 6 | pytest-reqs==0.2.1 7 | https://github.com/jhermann/pytest-spec/archive/fix-hook-config.zip#egg=pytest-spec 8 | #pytest-spec==1.1.0 9 | pytest-cov==2.8.1 10 | py>=1.4.29 11 | coveralls==1.8.2 12 | sh==1.12.14 13 | -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | /* Custom styles */ 2 | 3 | @import url("theme.css"); 4 | 5 | code, .rst-content tt, .rst-content code { 6 | font-size: 95%; 7 | } 8 | .note p code, .note div div pre { 9 | background-color: #E7F2FC; 10 | } 11 | .warning p code, .warning div div pre { 12 | background-color: #FFF8DD; 13 | } 14 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # 2 | # Source Distribution Manifest 3 | # 4 | # More at https://docs.python.org/2/distutils/sourcedist.html 5 | 6 | include README.md LICENSE requirements.txt *-requirements.txt 7 | recursive-include project.d *.py *.md *.txt *.cfg 8 | #recursive-include debian changelog control compat copyright rules *.links *.postinst *.triggers 9 | #recursive-include debian/source format options 10 | -------------------------------------------------------------------------------- /docs/api-reference.rst: -------------------------------------------------------------------------------- 1 | .. full API docs 2 | 3 | Copyright © 2015 1&1 Group 4 | 5 | ## LICENSE_SHORT ## 6 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 7 | 8 | 9 | Complete API Reference 10 | ====================== 11 | 12 | The following is a complete API reference generated from source. 13 | 14 | .. toctree:: 15 | :maxdepth: 4 16 | 17 | api/confluencer 18 | -------------------------------------------------------------------------------- /docs/api/confluencer.tools.rst: -------------------------------------------------------------------------------- 1 | confluencer.tools package 2 | ========================= 3 | 4 | .. automodule:: confluencer.tools 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Submodules 10 | ---------- 11 | 12 | confluencer.tools.content module 13 | -------------------------------- 14 | 15 | .. automodule:: confluencer.tools.content 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # Development requirements 3 | # 4 | 5 | pip>=19 6 | invoke==1.3.0 7 | #rituals==0.3.0 8 | https://github.com/jhermann/rituals/archive/master.zip#egg=rituals 9 | 10 | Sphinx==2.2.1 11 | sphinx-autobuild==0.7.1 12 | sphinx-rtd-theme==0.2.4 13 | 14 | pylint==2.4.4 15 | bpython==0.18 16 | yolk3k==0.9 17 | pip-upgrader==1.4.15 18 | 19 | tox==3.14.1 20 | twine==1.15.0 21 | 22 | -r test-requirements.txt 23 | -r docs/requirements.txt 24 | -e . 25 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # Install requirements 3 | # 4 | 5 | click==7.0 6 | colorama==0.4.1 7 | 8 | munch==2.5.0 9 | addict==2.2.1 10 | configobj>=5.0,<6 11 | #rudiments>=0.2.1 12 | https://github.com/jhermann/rudiments/archive/master.zip#egg=rudiments 13 | 14 | appdirs==1.4.3 15 | requests==2.22.0 16 | requests-cache==0.5.2 17 | pyOpenSSL>=0.15 ; python_version < '2.7.9' 18 | pyasn1>=0.1.9 ; python_version < '2.7.9' 19 | ndg-httpsclient>=0.4 ; python_version < '2.7.9' 20 | 21 | lxml==4.4.2 22 | arrow==0.15.4 23 | tqdm==4.39.0 24 | -------------------------------------------------------------------------------- /docs/api/confluencer.rst: -------------------------------------------------------------------------------- 1 | confluencer package 2 | =================== 3 | 4 | .. automodule:: confluencer 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Subpackages 10 | ----------- 11 | 12 | .. toctree:: 13 | 14 | confluencer.api 15 | confluencer.commands 16 | confluencer.tools 17 | confluencer.util 18 | 19 | Submodules 20 | ---------- 21 | 22 | confluencer.config module 23 | ------------------------- 24 | 25 | .. automodule:: confluencer.config 26 | :members: 27 | :undoc-members: 28 | :show-inheritance: 29 | 30 | -------------------------------------------------------------------------------- /project.d/README.md: -------------------------------------------------------------------------------- 1 | # Project Resources 2 | 3 | This directory exists to keep the project base directory clean and 4 | holds any files that do not *have to* live in the project root. 5 | It contains the folowing files: 6 | 7 | Filename | Description 8 | :---- | :---- 9 | ``classifiers.txt`` | The Trove classifiers for this project (loaded into ``setup.py``). 10 | ``pylint.cfg`` | Configuration for Pylint during ``invoke check``. 11 | ``skeleton_module.py`` | A skeleton file to quickly create new package modules. 12 | ``skeleton_testmodule.py`` | A skeleton file to quickly create new test modules. 13 | -------------------------------------------------------------------------------- /project.d/classifiers.txt: -------------------------------------------------------------------------------- 1 | # Details at http://pypi.python.org/pypi?:action=list_classifiers 2 | Development Status :: 4 - Beta 3 | #Development Status :: 5 - Production/Stable 4 | Intended Audience :: Developers 5 | Intended Audience :: End Users/Desktop 6 | Intended Audience :: System Administrators 7 | License :: OSI Approved :: Apache Software License 8 | Operating System :: OS Independent 9 | Programming Language :: Python :: 3 10 | Programming Language :: Python :: 3.4 11 | Environment :: Console 12 | Topic :: Documentation 13 | Topic :: Internet :: WWW/HTTP :: Site Management 14 | Topic :: Office/Business :: Groupware 15 | Topic :: Utilities 16 | -------------------------------------------------------------------------------- /project.d/cookiecutter.json: -------------------------------------------------------------------------------- 1 | { 2 | "email": "git@1and1.com", 3 | "features": "travis flake8 cli", 4 | "full_name": "1&1 Group", 5 | "github_url": "https://github.com/1and1/confluencer", 6 | "github_username": "1and1", 7 | "keywords": "atlassian,confluence,wiki,cli,tool", 8 | "license": "Apache 2.0", 9 | "pkg_name": "confluencer", 10 | "project_name": "Confluencer", 11 | "repo_name": "confluencer", 12 | "short_description": "A CLI tool to automate common Confluence maintenance tasks and content publishing", 13 | "url": "https://github.com/1and1/confluencer", 14 | "version": "0.1.0", 15 | "year": "2015" 16 | } 17 | -------------------------------------------------------------------------------- /docs/cli-reference.rst: -------------------------------------------------------------------------------- 1 | .. documentation: cli-reference 2 | 3 | Copyright © 2020 1&1 Group 4 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 5 | 6 | ============================================================================= 7 | Complete CLI Reference 8 | ============================================================================= 9 | 10 | This is a full reference of the :command:`cfr` command, 11 | with the same information as you get from using :option:`--help`. 12 | It is generated from source code and thus always up to date. 13 | See :doc:`usage` for a more detailed description. 14 | 15 | .. contents:: Available Commands 16 | :local: 17 | 18 | .. click:: confluencer.__main__:cli 19 | :prog: cfr 20 | :show-nested: 21 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # 2 | # Configuration for setuptools 3 | # 4 | 5 | [egg_info] 6 | tag_build = .dev148+0.g90233e1 7 | tag_date = false 8 | 9 | 10 | [sdist] 11 | formats = zip 12 | 13 | 14 | [bdist_wheel] 15 | # If you set this to 1, make sure you have a proper Travis CI build matrix, 16 | # and that your Trove classifiers state you support Python 2 and 3 17 | universal = 1 18 | 19 | 20 | [tool:pytest] 21 | norecursedirs = .* *.egg *.egg-info bin debian dist include lib local share static docs 22 | python_files = src/tests/test_*.py 23 | addopts = --spec 24 | 25 | markers = 26 | cli: command line interface integration tests. 27 | integration: integration tests. 28 | online: tests that need an Internet connection. 29 | 30 | 31 | [flake8] 32 | #ignore = E226,… 33 | max-line-length = 132 34 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # 2 | # Travis Project Descriptor 3 | # 4 | # See http://docs.travis-ci.com/user/build-configuration/ 5 | # 6 | 7 | # build matrix 8 | language: python 9 | python: 10 | - "3.6" 11 | - "3.8" 12 | # - "pypy" 13 | #matrix: 14 | # # Do not allow failures for Python 3 when you create "universal" wheels (see 'setup.cfg') 15 | # allow_failures: 16 | # - python: "3.6" 17 | 18 | env: 19 | - CONFLUENCE_BASE_URL=http://example.com/ 20 | # - CONFLUENCE_BASE_URL=https://confluence.atlassian.com/ 21 | 22 | # command to install dependencies 23 | install: 24 | - "pip install -r dev-requirements.txt" 25 | 26 | sudo: false 27 | 28 | cache: 29 | directories: 30 | - $HOME/.cache/pip 31 | 32 | # command to run tests 33 | script: invoke --echo --pty ci 34 | 35 | # report to coveralls.io 36 | after_success: 37 | - coveralls --rcfile project.d/coverage.cfg 38 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox configuration, for details see 2 | # 3 | # http://tox.testrun.org/ 4 | # 5 | # $ . .env "--yes" "--develop" 6 | # $ tox 7 | 8 | [tox] 9 | envlist = py35, flake8 10 | 11 | 12 | [testenv] 13 | deps = 14 | -r./test-requirements.txt 15 | -r./requirements.txt 16 | passenv = 17 | CONFLUENCE_BASE_URL 18 | commands = 19 | py.test -c {toxinidir}/setup.cfg --color=yes --cov=confluencer \ 20 | --cov-config=project.d/coverage.cfg --cov-report=term --cov-report=html --cov-report=xml \ 21 | {posargs} 22 | 23 | [testenv:py3-check] 24 | basepython = python3 25 | deps = 26 | pip>=6.0.8 27 | -r./test-requirements.txt 28 | -r./requirements.txt 29 | commands = 30 | invoke check 31 | 32 | 33 | [testenv:flake8] 34 | deps = 35 | flake8==2.3.0 36 | pep8==1.6.2 37 | 38 | ; for now just informational 39 | commands = 40 | flake8 --count --statistics --exit-zero src/confluencer 41 | -------------------------------------------------------------------------------- /project.d/skeleton_module.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=bad-continuation 3 | """ Short description. 4 | """ 5 | # Copyright © 2015 1&1 Group 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | from __future__ import absolute_import, unicode_literals, print_function 19 | 20 | import os 21 | import sys 22 | 23 | # from . import … 24 | 25 | 26 | __all__ = [] 27 | -------------------------------------------------------------------------------- /project.d/coverage.cfg: -------------------------------------------------------------------------------- 1 | # 2 | # Coveragerc configuration, for details see 3 | # 4 | # http://nedbatchelder.com/code/coverage/config.html 5 | # 6 | 7 | [run] 8 | branch = True 9 | ; data_file = build/coverage.db 10 | 11 | omit = 12 | */_compat.py 13 | */tests/*.py 14 | 15 | 16 | [report] 17 | ignore_errors = True 18 | 19 | # Regex 'find' matches for lines to exclude from consideration 20 | exclude_lines = 21 | # Have to re-enable the standard pragma 22 | pragma: no cover 23 | 24 | # Don't complain about missing debug-only code 25 | def __repr__ 26 | if self\.debug 27 | 28 | # Don't complain if tests don't hit defensive assertion code 29 | raise AssertionError 30 | raise NotImplementedError 31 | 32 | # Don't complain if non-runnable code isn't run 33 | if 0: 34 | if False: 35 | if __name__ == .__main__.: 36 | 37 | 38 | [xml] 39 | output = build/coverage.xml 40 | 41 | 42 | [html] 43 | directory = build/coverage_html_report 44 | -------------------------------------------------------------------------------- /src/confluencer/tools/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=bad-continuation 3 | """ Confluence supporting tools. 4 | 5 | Classes and functions in this package help you to work 6 | with Confluence entities, and provide higher level abstractions 7 | to ease API usage. 8 | """ 9 | # Copyright © 2015 1&1 Group 10 | # 11 | # Licensed under the Apache License, Version 2.0 (the "License"); 12 | # you may not use this file except in compliance with the License. 13 | # You may obtain a copy of the License at 14 | # 15 | # http://www.apache.org/licenses/LICENSE-2.0 16 | # 17 | # Unless required by applicable law or agreed to in writing, software 18 | # distributed under the License is distributed on an "AS IS" BASIS, 19 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | # See the License for the specific language governing permissions and 21 | # limitations under the License. 22 | from __future__ import absolute_import, unicode_literals, print_function 23 | -------------------------------------------------------------------------------- /src/confluencer/commands/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=bad-continuation, unused-import 3 | """ CLI commands. 4 | """ 5 | # Copyright © 2015 1&1 Group 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | from __future__ import absolute_import, unicode_literals, print_function 19 | 20 | # Load the command modules for registration 21 | from . import help # noqa pylint: disable=redefined-builtin 22 | from . import tidy 23 | from . import stats 24 | from . import pretty 25 | from . import remove 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | src/*.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # virtualenv 27 | bin/ 28 | include/ 29 | lib/ 30 | share/ 31 | local/ 32 | pyvenv.cfg 33 | .venv/ 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | pip-selfcheck.json 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .coverage 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | 61 | # Sphinx documentation 62 | docs/_build/ 63 | docs/README.rst 64 | 65 | # PyBuilder 66 | target/ 67 | 68 | # Jekyll 69 | _site/ 70 | -------------------------------------------------------------------------------- /project.d/skeleton_testmodule.py: -------------------------------------------------------------------------------- 1 | # *- coding: utf-8 -*- 2 | # pylint: disable=wildcard-import, missing-docstring, no-self-use, bad-continuation 3 | # pylint: disable=invalid-name, redefined-outer-name, too-few-public-methods 4 | """ Test :py:mod:`«some_module»`. 5 | """ 6 | # Copyright © 2015 1&1 Group 7 | # 8 | # Licensed under the Apache License, Version 2.0 (the "License"); 9 | # you may not use this file except in compliance with the License. 10 | # You may obtain a copy of the License at 11 | # 12 | # http://www.apache.org/licenses/LICENSE-2.0 13 | # 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | # See the License for the specific language governing permissions and 18 | # limitations under the License. 19 | from __future__ import absolute_import, unicode_literals, print_function 20 | 21 | from confluencer import some_module 22 | 23 | 24 | def test_fails(): 25 | assert False, "This test needs to get some sensible logic" 26 | -------------------------------------------------------------------------------- /src/confluencer/util/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=bad-continuation 3 | """ Helpers. 4 | """ 5 | # Copyright © 2015 1&1 Group 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | from __future__ import absolute_import, unicode_literals, print_function 19 | 20 | from tqdm import tqdm 21 | 22 | 23 | CLEARLINE = "\r\033[2K" 24 | 25 | 26 | def progress(*args, **kwargs): 27 | """Wrapper for progress bars providing consistent customizaion.""" 28 | kwargs.setdefault('dynamic_ncols', True) 29 | kwargs.setdefault('position', 1) 30 | return tqdm(*args, **kwargs) 31 | -------------------------------------------------------------------------------- /docs/api/confluencer.commands.rst: -------------------------------------------------------------------------------- 1 | confluencer.commands package 2 | ============================ 3 | 4 | .. automodule:: confluencer.commands 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Submodules 10 | ---------- 11 | 12 | confluencer.commands.help module 13 | -------------------------------- 14 | 15 | .. automodule:: confluencer.commands.help 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | confluencer.commands.pretty module 21 | ---------------------------------- 22 | 23 | .. automodule:: confluencer.commands.pretty 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | confluencer.commands.remove module 29 | ---------------------------------- 30 | 31 | .. automodule:: confluencer.commands.remove 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: 35 | 36 | confluencer.commands.stats module 37 | --------------------------------- 38 | 39 | .. automodule:: confluencer.commands.stats 40 | :members: 41 | :undoc-members: 42 | :show-inheritance: 43 | 44 | confluencer.commands.tidy module 45 | -------------------------------- 46 | 47 | .. automodule:: confluencer.commands.tidy 48 | :members: 49 | :undoc-members: 50 | :show-inheritance: 51 | 52 | -------------------------------------------------------------------------------- /src/tests/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable= 3 | """ py.test dynamic configuration. 4 | 5 | For details needed to understand these tests, refer to: 6 | https://pytest.org/ 7 | http://pythontesting.net/start-here/ 8 | """ 9 | # Copyright © 2015 1&1 Group 10 | # 11 | # Licensed under the Apache License, Version 2.0 (the "License"); 12 | # you may not use this file except in compliance with the License. 13 | # You may obtain a copy of the License at 14 | # 15 | # http://www.apache.org/licenses/LICENSE-2.0 16 | # 17 | # Unless required by applicable law or agreed to in writing, software 18 | # distributed under the License is distributed on an "AS IS" BASIS, 19 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | # See the License for the specific language governing permissions and 21 | # limitations under the License. 22 | from __future__ import absolute_import, unicode_literals, print_function 23 | 24 | import logging 25 | 26 | import pytest 27 | 28 | 29 | # Globally available fixtures 30 | @pytest.fixture(scope='session') 31 | def logger(): 32 | """Test logger instance as a fixture.""" 33 | logging.basicConfig(level=logging.DEBUG) 34 | return logging.getLogger('tests') 35 | -------------------------------------------------------------------------------- /src/confluencer/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=bad-whitespace 3 | # flake8: noqa 4 | """ 5 | Confluencer – A CLI tool to automate common Confluence maintenance tasks and content publishing. 6 | 7 | Copyright © 2015 1&1 Group 8 | 9 | Licensed under the Apache License, Version 2.0 (the "License"); 10 | you may not use this file except in compliance with the License. 11 | You may obtain a copy of the License at 12 | 13 | http://www.apache.org/licenses/LICENSE-2.0 14 | 15 | Unless required by applicable law or agreed to in writing, software 16 | distributed under the License is distributed on an "AS IS" BASIS, 17 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | See the License for the specific language governing permissions and 19 | limitations under the License. 20 | """ 21 | from __future__ import absolute_import, unicode_literals, print_function 22 | 23 | __url__ = "https://github.com/1and1/confluencer" 24 | __version__ = "0.1.0" 25 | __license__ = "Apache 2.0" 26 | __author__ = "1&1 Group" 27 | __author_email__ = "git@1and1.com" 28 | __keywords__ = "atlassian,confluence,wiki,cli,tool" 29 | 30 | __all__ = [] 31 | -------------------------------------------------------------------------------- /src/tests/test_package.py: -------------------------------------------------------------------------------- 1 | # *- coding: utf-8 -*- 2 | # pylint: disable=wildcard-import, missing-docstring, no-self-use, bad-continuation 3 | """ Test the package metadata. 4 | """ 5 | # Copyright © 2015 1&1 Group 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | from __future__ import absolute_import, unicode_literals, print_function 19 | 20 | from confluencer import __version__ as version 21 | 22 | 23 | def test_semver(): 24 | """Test a proper semantic version is used.""" 25 | # TODO Test rules according to PEP440 - Version Identification and Dependency Specification 26 | assert len(version.split('.')) == 3, "Semantic version M.m.µ OK" 27 | assert all(i.isdigit for i in version.split('.')), "Semantic version parts are numeric" 28 | -------------------------------------------------------------------------------- /src/tests/markers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=invalid-name 3 | """ py.test markers. 4 | 5 | For details needed to understand these tests, refer to: 6 | https://pytest.org/ 7 | http://pythontesting.net/start-here/ 8 | """ 9 | # Copyright © 2015 1&1 Group 10 | # 11 | # Licensed under the Apache License, Version 2.0 (the "License"); 12 | # you may not use this file except in compliance with the License. 13 | # You may obtain a copy of the License at 14 | # 15 | # http://www.apache.org/licenses/LICENSE-2.0 16 | # 17 | # Unless required by applicable law or agreed to in writing, software 18 | # distributed under the License is distributed on an "AS IS" BASIS, 19 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 20 | # See the License for the specific language governing permissions and 21 | # limitations under the License. 22 | from __future__ import absolute_import, unicode_literals, print_function 23 | 24 | import pytest 25 | 26 | 27 | # See also setup.cfg » [pytest] » markers 28 | cli = pytest.mark.cli 29 | integration = pytest.mark.integration 30 | online = pytest.mark.online 31 | 32 | 33 | # Export all markers 34 | __all__ = [_k 35 | for _k, _v in globals().items() 36 | if _v.__class__ is cli.__class__] 37 | -------------------------------------------------------------------------------- /src/tests/test_tools_content.py: -------------------------------------------------------------------------------- 1 | # *- coding: utf-8 -*- 2 | # pylint: disable=wildcard-import, missing-docstring, no-self-use, bad-continuation 3 | # pylint: disable=invalid-name, redefined-outer-name, too-few-public-methods 4 | """ Test :py:mod:`confluencer.tools.page`. 5 | """ 6 | # Copyright © 2015 1&1 Group 7 | # 8 | # Licensed under the Apache License, Version 2.0 (the "License"); 9 | # you may not use this file except in compliance with the License. 10 | # You may obtain a copy of the License at 11 | # 12 | # http://www.apache.org/licenses/LICENSE-2.0 13 | # 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | # See the License for the specific language governing permissions and 18 | # limitations under the License. 19 | from __future__ import absolute_import, unicode_literals, print_function 20 | 21 | from munch import Munch as Bunch 22 | 23 | from confluencer.tools import content 24 | 25 | 26 | class APIMock(object): 27 | def get(self, _, **_dummy): 28 | return Bunch(body={'storage': Bunch(value='foo')}) 29 | 30 | 31 | def test_page_object_creation(): 32 | page = content.ConfluencePage(APIMock(), '/SOME/URL') 33 | assert page.body == 'foo' 34 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # GitHub CI Action for Python Project (with tox) 2 | # 3 | # DOCS 4 | # 5 | # https://help.github.com/en/actions/language-and-framework-guides/github-actions-for-python 6 | # https://help.github.com/en/actions/reference/workflow-syntax-for-github-actions 7 | # https://help.github.com/en/actions/building-actions/metadata-syntax-for-github-actions 8 | # https://github.com/actions/virtual-environments/blob/master/images/linux/Ubuntu1804-README.md#readme 9 | # https://github.com/ymyzk/tox-gh-actions#readme 10 | --- 11 | name: CI 12 | 13 | on: 14 | push: 15 | branches: ["master"] 16 | pull_request: 17 | branches: ["master"] 18 | 19 | env: 20 | CONFLUENCE_BASE_URL: http://example.com/ 21 | # CONFLUENCE_BASE_URL: https://confluence.atlassian.com/ 22 | 23 | jobs: 24 | tests: 25 | runs-on: "ubuntu-latest" 26 | 27 | strategy: 28 | matrix: 29 | python-version: ["3.6", "3.8"] 30 | name: "Python ${{ matrix.python-version }}" 31 | 32 | steps: 33 | - uses: "actions/checkout@v2" 34 | - uses: "actions/setup-python@v1" 35 | with: 36 | python-version: "${{ matrix.python-version }}" 37 | - name: "Install dependencies" 38 | run: | 39 | set -xe 40 | python -VV 41 | python -m site 42 | echo 43 | python -m pip install --upgrade pip setuptools wheel 44 | python -m pip install --upgrade tox tox-gh-actions 45 | python -m pip install -r dev-requirements.txt 46 | - name: "Run CI task for Python ${{ matrix.python-version }}" 47 | run: "invoke --echo --pty ci" 48 | -------------------------------------------------------------------------------- /src/confluencer/commands/pretty.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=bad-continuation, too-few-public-methods 3 | """ 'pretty' command. 4 | """ 5 | # Copyright © 2015 1&1 Group 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | from __future__ import absolute_import, unicode_literals, print_function 19 | 20 | import os 21 | import sys 22 | import json as jsonlib 23 | 24 | from rudiments.reamed import click 25 | 26 | from .. import config, api 27 | from ..tools import content 28 | 29 | 30 | @config.cli.command() 31 | @click.option('-R', '--recursive', is_flag=True, default=False, help='Handle all descendants.') 32 | @click.option('-J', '--json', is_flag=True, default=False, help='Print raw API response (JSON).') 33 | @click.option('-f', '--format', 'markup', default='view', type=click.Choice(content.CLI_CONTENT_FORMATS.keys()), 34 | help="Markup format.", 35 | ) 36 | @click.argument('pages', metavar='‹page-url›…', nargs=-1) 37 | @click.pass_context 38 | def pretty(ctx, pages, markup, recursive=False, json=False): 39 | """Pretty-print page content markup.""" 40 | content_format = content.CLI_CONTENT_FORMATS[markup] 41 | with api.context() as cf: 42 | for page_url in pages: 43 | try: 44 | page = content.ConfluencePage(cf, page_url, markup=content_format, 45 | expand='metadata.labels,metadata.properties') 46 | except api.ERRORS as cause: 47 | # Just log and otherwise ignore any errors 48 | api.diagnostics(cause) 49 | else: 50 | if json: 51 | jsonlib.dump(page.json, sys.stdout, indent=' ', sort_keys=True) 52 | else: 53 | root = page.etree() 54 | with os.fdopen(sys.stdout.fileno(), "wb", closefd=False) as stdout: 55 | root.getroottree().write(stdout, encoding='utf8', pretty_print=True, xml_declaration=False) 56 | -------------------------------------------------------------------------------- /src/tests/test_api.py: -------------------------------------------------------------------------------- 1 | # *- coding: utf-8 -*- 2 | # pylint: disable=wildcard-import, missing-docstring, no-self-use, bad-continuation 3 | # pylint: disable=invalid-name, redefined-outer-name, too-few-public-methods 4 | """ Test :py:mod:`«some_module»`. 5 | """ 6 | # Copyright © 2015 1&1 Group 7 | # 8 | # Licensed under the Apache License, Version 2.0 (the "License"); 9 | # you may not use this file except in compliance with the License. 10 | # You may obtain a copy of the License at 11 | # 12 | # http://www.apache.org/licenses/LICENSE-2.0 13 | # 14 | # Unless required by applicable law or agreed to in writing, software 15 | # distributed under the License is distributed on an "AS IS" BASIS, 16 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | # See the License for the specific language governing permissions and 18 | # limitations under the License. 19 | from __future__ import absolute_import, unicode_literals, print_function 20 | 21 | import pytest 22 | 23 | from confluencer import api 24 | 25 | 26 | def test_tiny_link_is_parsed(): 27 | url = 'https://confluence.example.com/x/ZqQ8' 28 | page_id = api.page_id_from_tiny_link(url) 29 | assert page_id == 3974246 30 | 31 | 32 | def test_error_for_malformed_tiny_link(): 33 | with pytest.raises(ValueError): 34 | url = 'https://confluence.example.com/x/#' 35 | api.page_id_from_tiny_link(url) 36 | 37 | 38 | @pytest.mark.parametrize('page_id, tiny_id', [ 39 | (3974246, 'ZqQ8'), 40 | ('3974246', 'ZqQ8'), 41 | (5063416, '_EJN'), 42 | ]) 43 | def test_tiny_id_from_page_id(page_id, tiny_id): 44 | assert tiny_id == api.tiny_id(page_id) 45 | 46 | 47 | def test_api_with_explicit_endpoint(): 48 | url = 'https://confluence.example.com/' 49 | cf = api.ConfluenceAPI(endpoint=url) 50 | assert not cf.base_url.endswith('/') 51 | assert cf.url('space') == url + 'rest/api/space' 52 | 53 | 54 | @pytest.mark.parametrize('expected, link', [ 55 | ('/rest/api/content/3974246', '/pages/viewpage.action?pageId=3974246'), 56 | ('/rest/api/content/3974246', '/x/ZqQ8'), 57 | ('/rest/api/content/5063416', '/x/_EJN'), 58 | ]) 59 | def test_api_url_from_page_link(expected, link): 60 | if link.startswith('/x/'): 61 | page_id = api.page_id_from_tiny_link(link) 62 | assert page_id == int(expected.split('/')[-1]) 63 | cf = api.ConfluenceAPI(endpoint='https://confluence.example.com/') 64 | api_url = cf.url(cf.base_url + link) 65 | assert api_url == cf.base_url + expected 66 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=wildcard-import, unused-wildcard-import, bad-continuation 3 | """ Project automation for Invoke. 4 | """ 5 | # Copyright © 2015 1&1 Group 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | from __future__ import absolute_import, unicode_literals 19 | 20 | import os 21 | import shutil 22 | import tempfile 23 | 24 | from rituals.easy import * # pylint: disable=redefined-builtin 25 | 26 | 27 | @task(name='fresh-cookies', 28 | help={ 29 | 'mold': "git URL or directory to use for the refresh", 30 | }, 31 | ) 32 | def fresh_cookies(ctx, mold=''): 33 | """Refresh the project from the original cookiecutter template.""" 34 | mold = mold or "https://github.com/Springerle/py-generic-project.git" # TODO: URL from config 35 | tmpdir = os.path.join(tempfile.gettempdir(), "cc-upgrade-confluencer") 36 | 37 | if os.path.isdir('.git'): 38 | # TODO: Ensure there are no local unstashed changes 39 | pass 40 | 41 | # Make a copy of the new mold version 42 | if os.path.isdir(tmpdir): 43 | shutil.rmtree(tmpdir) 44 | if os.path.exists(mold): 45 | shutil.copytree(mold, tmpdir, ignore=shutil.ignore_patterns( 46 | ".git", ".svn", "*~", 47 | )) 48 | else: 49 | ctx.run("git clone {} {}".format(mold, tmpdir)) 50 | 51 | # Copy recorded "cookiecutter.json" into mold 52 | shutil.copy2("project.d/cookiecutter.json", tmpdir) 53 | 54 | with pushd('..'): 55 | ctx.run("cookiecutter --no-input {}".format(tmpdir)) 56 | if os.path.exists('.git'): 57 | ctx.run("git status") 58 | 59 | namespace.add_task(fresh_cookies) 60 | 61 | 62 | @task(help={ 63 | 'pty': "Whether to run commands under a pseudo-tty", 64 | }) # pylint: disable=invalid-name 65 | def ci(ctx): 66 | """Perform continuous integration tasks.""" 67 | opts = [''] 68 | 69 | # 'tox' makes no sense in Travis 70 | if os.environ.get('TRAVIS', '').lower() == 'true': 71 | opts += ['test.pytest'] 72 | else: 73 | opts += ['test.tox'] 74 | 75 | ctx.run("invoke --echo --pty clean --all build --docs check --reports{}".format(' '.join(opts))) 76 | 77 | namespace.add_task(ci) 78 | -------------------------------------------------------------------------------- /src/confluencer/commands/tidy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=bad-continuation, too-few-public-methods 3 | """ 'tidy' command. 4 | """ 5 | # Copyright © 2015 1&1 Group 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | from __future__ import absolute_import, unicode_literals, print_function 19 | 20 | from rudiments.reamed import click 21 | 22 | from .. import config, api 23 | from ..tools import content 24 | 25 | 26 | @config.cli.command() 27 | @click.option('--diff', is_flag=True, default=False, help='Show differences after tidying.') 28 | @click.option('-n', '--no-save', '--dry-run', count=True, 29 | help="Only show differences after tidying, don't apply them (use twice for no diff).") 30 | @click.option('-R', '--recursive', is_flag=True, default=False, help='Handle all descendants.') 31 | @click.argument('pages', metavar='‹page-url›…', nargs=-1) 32 | @click.pass_context 33 | def tidy(ctx, pages, diff=False, dry_run=0, recursive=False): 34 | """Tidy pages after cut&paste migration from other wikis.""" 35 | with api.context() as cf: 36 | for page_url in pages: 37 | try: 38 | page = content.ConfluencePage(cf, page_url) 39 | except api.ERRORS as cause: 40 | # Just log and otherwise ignore any errors 41 | api.diagnostics(cause) 42 | else: 43 | ##print(page._data); xxx 44 | body = page.tidy(log=ctx.obj.log) 45 | if body == page.body: 46 | ctx.obj.log.info('No changes for "%s"', page.title) 47 | else: 48 | if diff or dry_run == 1: 49 | page.dump_diff(body) 50 | if dry_run: 51 | ctx.obj.log.info('WOULD save page#{0} "{1}" as v. {2}'.format(page.page_id, page.title, page.version + 1)) 52 | else: 53 | result = page.update(body) 54 | if result: 55 | ctx.obj.log.info('Updated page#{id} "{title}" to v. {version.number}'.format(**result)) 56 | else: 57 | ctx.obj.log.info('Changes not saved for "%s"', page.title) 58 | -------------------------------------------------------------------------------- /src/confluencer/commands/remove.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=bad-continuation, too-few-public-methods 3 | """ 'rm' command. 4 | """ 5 | # Copyright © 2015 – 2017 1&1 Group 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | from __future__ import absolute_import, unicode_literals, print_function 19 | 20 | import sys 21 | 22 | from munch import Munch as Bunch 23 | from rudiments.reamed import click 24 | 25 | from .. import config, api 26 | from ..util import progress, CLEARLINE 27 | from ..tools import content 28 | 29 | 30 | @config.cli.group(name='rm') 31 | @click.pass_context 32 | def remove(ctx): 33 | """Remove contents.""" 34 | 35 | 36 | @remove.command() 37 | ## TODO: -n, --no-act, --dry-run, --simulate 38 | ## TODO: --with[out]-root (or tree vs children command?) 39 | ## TODO: include / exclude filters (title glob) 40 | @click.argument('pages', metavar='‹page-url›…', nargs=-1) 41 | @click.pass_context 42 | def tree(ctx, pages): 43 | """Remove page(s) including their descendants.""" 44 | with api.context() as cf: 45 | for page_ref in pages: 46 | root_page = cf.get(page_ref) 47 | root_children = cf.get(root_page._expandable.children, expand='page', limit=200) 48 | 49 | # Get confirmation 50 | answer = None 51 | while answer not in {'yes', 'no', 'n'}: 52 | answer = input('REALLY remove {} children of »{}« and all their descendants? [yes|No|N] ' 53 | .format(len(root_children.page.results), root_page.title)) 54 | answer = answer.lower() or 'n' 55 | 56 | # Delete data on positive confirmation 57 | if answer != 'yes': 58 | click.echo('No confirmation, did not delete anything!') 59 | else: 60 | counter = 0 61 | try: 62 | iter_pages = progress(sorted(root_children.page.results, key=lambda x: x.title.lower())) 63 | for page in iter_pages: 64 | print(CLEARLINE + "DEL", page.title, end='\r') 65 | iter_pages.set_postfix_str('') 66 | while True: 67 | children = cf.get(page._expandable.children, expand='page', limit=200).page.results 68 | if not children: 69 | break 70 | for child in children: 71 | cf.delete_page(child) 72 | counter += 1 73 | cf.delete_page(page) 74 | counter += 1 75 | finally: 76 | print(CLEARLINE + "Deleted {} pages.\n".format(counter)) 77 | -------------------------------------------------------------------------------- /src/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | # *- coding: utf-8 -*- 2 | # pylint: disable=wildcard-import, unused-wildcard-import, missing-docstring 3 | # pylint: disable=redefined-outer-name, no-self-use, bad-continuation, unused-import 4 | """ Test '__main__' CLI stub. 5 | 6 | See http://click.pocoo.org/3/testing/ 7 | """ 8 | # Copyright © 2015 1&1 Group 9 | # 10 | # Licensed under the Apache License, Version 2.0 (the "License"); 11 | # you may not use this file except in compliance with the License. 12 | # You may obtain a copy of the License at 13 | # 14 | # http://www.apache.org/licenses/LICENSE-2.0 15 | # 16 | # Unless required by applicable law or agreed to in writing, software 17 | # distributed under the License is distributed on an "AS IS" BASIS, 18 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 19 | # See the License for the specific language governing permissions and 20 | # limitations under the License. 21 | from __future__ import absolute_import, unicode_literals, print_function 22 | 23 | import os 24 | import sys 25 | 26 | import sh 27 | import pytest 28 | from click.testing import CliRunner 29 | 30 | from markers import * 31 | from confluencer import __version__ as version 32 | from confluencer import __main__ as main 33 | from confluencer import commands 34 | 35 | 36 | UsageError = sh.ErrorReturnCode_2 # pylint: disable=no-member 37 | 38 | 39 | @pytest.fixture 40 | def cmd(): 41 | """Command fixture.""" 42 | return sh.Command(main.__app_name__) 43 | 44 | 45 | @cli 46 | @integration 47 | def test_cli_help(cmd): 48 | result = cmd('--help') 49 | lines = result.stdout.decode('ascii').splitlines() 50 | 51 | assert main.__app_name__ in lines[0].split(), "Command name is reported" 52 | 53 | 54 | @cli 55 | @integration 56 | def test_cli_version(cmd): 57 | result = cmd('--version') 58 | stdout = result.stdout.decode('ascii') 59 | reported_version = stdout.split()[1] 60 | py_version = sys.version.split()[0] 61 | 62 | assert version in stdout, "Version string contains version" 63 | assert reported_version[:len(version)] == version, "Version is 2nd field" 64 | assert py_version in stdout, "Python version is reported" 65 | 66 | 67 | @cli 68 | @integration 69 | def test_cli_invalid_option(cmd): 70 | with pytest.raises(UsageError): 71 | cmd('--this-is-certainly-not-a-supported-option') 72 | 73 | 74 | @cli 75 | @integration 76 | def test_cli_invalid_sub_command(cmd): 77 | with pytest.raises(UsageError): 78 | cmd.sub_command_that_does_not_exist() 79 | 80 | 81 | @cli 82 | def test_cmd_missing(): 83 | runner = CliRunner() 84 | result = runner.invoke(main.cli) 85 | 86 | assert result.exit_code == 0 87 | 88 | 89 | @cli 90 | def test_cmd_help(): 91 | runner = CliRunner() 92 | result = runner.invoke(main.cli, args=('help',)) 93 | if result.exit_code: 94 | print(vars(result)) 95 | print('~' * 78) 96 | print(result.stdout_bytes) 97 | print('~' * 78) 98 | print(result.stderr_bytes) 99 | print('~' * 78) 100 | words = result.output.split() 101 | 102 | assert result.exit_code == 0 103 | assert 'configuration' in words 104 | assert any(i.endswith(os.sep + 'cli.conf') for i in words), \ 105 | "Some '.conf' files listed in " + repr(words) 106 | -------------------------------------------------------------------------------- /src/confluencer/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=bad-continuation 3 | """ Configuration utilities. 4 | """ 5 | # Copyright © 2015 1&1 Group 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | from __future__ import absolute_import, unicode_literals, print_function 19 | 20 | import os 21 | import re 22 | import sys 23 | import shutil 24 | 25 | from rudiments.reamed.click import Configuration # noqa pylint: disable=unused-import 26 | 27 | from ._compat import iteritems 28 | 29 | # Determine path this command is located in (installed to) 30 | try: 31 | CLI_PATH = sys.modules['__main__'].__file__ 32 | except (KeyError, AttributeError): 33 | CLI_PATH = __file__ 34 | CLI_PATH = os.path.dirname(CLI_PATH) 35 | if CLI_PATH.endswith('/bin'): 36 | CLI_PATH = CLI_PATH[:-4] 37 | CLI_PATH = re.sub('^' + os.path.expanduser('~'), '~', CLI_PATH) 38 | 39 | # Extended version info for use by `click.version_option` 40 | VERSION_INFO = '%(prog)s %(version)s from {} [Python {}]'.format(CLI_PATH, ' '.join(sys.version.split()[:1]),) 41 | 42 | # These will be filled by `__main__` 43 | APP_NAME = None 44 | cli = None # pylint: disable=invalid-name 45 | cfg = None # pylint: disable=invalid-name 46 | 47 | 48 | def version_info(ctx=None): 49 | """Return version information just like --version does.""" 50 | from . import __version__ 51 | 52 | prog = ctx.find_root().info_name if ctx else APP_NAME 53 | version = __version__ 54 | try: 55 | import pkg_resources 56 | except ImportError: 57 | pass 58 | else: 59 | for dist in iter(pkg_resources.working_set): 60 | scripts = dist.get_entry_map().get('console_scripts') or {} 61 | for _, entry_point in iteritems(scripts): 62 | if entry_point.module_name == (__package__ + '.__main__'): 63 | version = dist.version 64 | break 65 | 66 | return VERSION_INFO % dict(prog=prog, version=version) 67 | 68 | 69 | def envvar(name, default=None): 70 | """Return an environment variable specific for this application (using a prefix).""" 71 | varname = (APP_NAME + '-' + name).upper().replace('-', '_') 72 | return os.environ.get(varname, default) 73 | 74 | 75 | def cache_file(name): 76 | """Return absolute path to any app-specific caching file.""" 77 | import appdirs 78 | 79 | conf_dir = appdirs.user_cache_dir() or os.path.expanduser('~/.cache') 80 | if APP_NAME: 81 | conf_dir = os.path.join(conf_dir, APP_NAME) 82 | if not os.path.exists(conf_dir): 83 | os.makedirs(conf_dir) 84 | 85 | return os.path.join(conf_dir, name) 86 | 87 | 88 | def cache_empty_dir(name): 89 | """Return absolute path to a clean staging directory.""" 90 | path = cache_file(name) 91 | 92 | if os.path.exists(path): 93 | shutil.rmtree(path) 94 | os.mkdir(path) 95 | 96 | return path 97 | -------------------------------------------------------------------------------- /src/confluencer/commands/help.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=bad-continuation, too-few-public-methods 3 | """ 'help' command. 4 | """ 5 | # Copyright © 2015 1&1 Group 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | from __future__ import absolute_import, unicode_literals, print_function 19 | 20 | import os 21 | import sys 22 | import itertools 23 | 24 | from rudiments.reamed import click 25 | 26 | from .. import config, api 27 | 28 | 29 | @config.cli.command(name='help') 30 | @click.option('-c', '--config-dump', is_flag=True, default=False, help='Dump the merged configuration to stdout.') 31 | @click.pass_context 32 | def help_command(ctx, config_dump=False): 33 | """Print some information on the system environment.""" 34 | def banner(title): 35 | "Helper" 36 | click.echo('') 37 | click.secho('~~~ {} ~~~'.format(title), fg='green', bg='black', bold=True) 38 | 39 | if config_dump: 40 | ctx.obj.cfg.dump() 41 | sys.exit(0) 42 | 43 | app_name = ctx.find_root().info_name 44 | click.secho('*** "{}" Help & Information ***'.format(app_name), fg='white', bg='blue', bold=True) 45 | 46 | banner('Version Information') 47 | click.echo(config.version_info(ctx)) 48 | 49 | banner('Configuration') 50 | locations = ctx.obj.cfg.locations(exists=False) 51 | locations = [(u'✔' if os.path.exists(i) else u'✘', click.pretty_path(i)) for i in locations] 52 | click.echo(u'The following configuration files are merged in order, if they exist:\n {0}'.format( 53 | u'\n '.join(u'{} {}'.format(*i) for i in locations), 54 | )) 55 | 56 | banner('Confluence Stats') 57 | with api.context() as cf: 58 | try: 59 | user = cf.user() 60 | spaces = list(cf.getall('space')) 61 | except api.ERRORS as cause: 62 | # Just log and otherwise ignore any errors 63 | api.diagnostics(cause) 64 | else: 65 | click.echo(u'Confluence API [{u._info.server} / {u._info.sen}]' 66 | u' accessed as {u.displayName} [{u.username}].'.format(u=user)) 67 | click.echo(u'{} spaces found.'.format(len(spaces))) 68 | click.echo(u'\nMost recently created:') 69 | for space in itertools.islice(sorted(spaces, key=lambda i: i.id, reverse=True), 5): 70 | click.echo(u' {:10} {:>15} {}'.format(space.type, space.key, space.name)) 71 | 72 | banner('More Help') 73 | click.echo("Call '{} --help' to get a list of available commands & options.".format(app_name)) 74 | click.echo("Call '{} «command» --help' to get help on a specific command.".format(app_name)) 75 | click.echo("Call '{} --version' to get the above version information separately.".format(app_name)) 76 | click.echo("Call '{} --license' to get licensing informatioon.".format(app_name)) 77 | 78 | # click.echo('\ncontext = {}'.format(repr(vars(ctx)))) 79 | # click.echo('\nparent = {}'.format(repr(vars(ctx.parent)))) 80 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | ## Overview 4 | 5 | Contributing to this project is easy, and reporting an issue or adding to the documentation 6 | also improves things for every user. You don't need to be a developer to contribute. 7 | 8 | 9 | ### Reporting issues 10 | 11 | Please use the *GitHub issue tracker*, and describe your problem so that it can be easily 12 | reproduced. Providing relevant version information on the project itself and your environment helps with that. 13 | 14 | 15 | ### Improving documentation 16 | 17 | The easiest way to provide examples or related documentation that helps other users 18 | is the *GitHub wiki*. 19 | 20 | If you are comfortable with the Sphinx documentation tool, you can also prepare a 21 | pull request with changes to the core documentation. 22 | GitHub's built-in text editor makes this especially easy, when you choose the 23 | _“Create a new branch for this commit and start a pull request”_ option on saving. 24 | Small fixes for typos and the like are a matter of minutes when using that tool. 25 | 26 | 27 | ### Code contributions 28 | 29 | Here's a quick guide to improve the code: 30 | 31 | 1. Fork the repo, and clone the fork to your machine. 32 | 1. Add your improvements, the technical details are further below. 33 | 1. Run the tests and make sure they're passing (`invoke test`). 34 | 1. Check for violations of code conventions (`invoke check`). 35 | 1. Make sure the documentation builds without errors (`invoke build --docs`). 36 | 1. Push to your fork and submit a [pull request](https://help.github.com/articles/using-pull-requests/). 37 | 38 | Please be patient while waiting for a review. Life & work tend to interfere. 39 | 40 | 41 | ## Details on contributing code 42 | 43 | This project is written in [Python](http://www.python.org/), 44 | and the documentation is generated using [Sphinx](https://pypi.python.org/pypi/Sphinx). 45 | [setuptools](https://packaging.python.org/en/latest/projects.html#setuptools) 46 | and [Invoke](http://www.pyinvoke.org/) are used to build and manage the project. 47 | Tests are written and executed using [pytest](http://pytest.org/) and 48 | [tox](https://testrun.org/tox/ ). 49 | 50 | 51 | ### Set up a working development environment 52 | 53 | To set up a working directory from your own fork, 54 | follow [these steps](https://github.com/1and1/confluencer#contributing), 55 | but replace the repository `https` URLs with SSH ones that point to your fork. 56 | 57 | For that to work on Debian type systems, you need the 58 | `git`, `python`, and `python-virtualenv` 59 | packages installed. Other distributions are similar. 60 | 61 | 62 | ### Add your changes to a feature branch 63 | 64 | For any cohesive set of changes, create a *new* branch based on the current upstream `master`, 65 | with a name reflecting the essence of your improvement. 66 | 67 | ```sh 68 | git branch "name-for-my-fixes" origin/master 69 | git checkout "name-for-my-fixes" 70 | … make changes… 71 | invoke ci # check output for broken tests, or PEP8 violations and the like 72 | … commit changes… 73 | git push origin "name-for-my-fixes" 74 | ``` 75 | 76 | Please don't create large lumps of unrelated changes in a single pull request. 77 | Also take extra care to avoid spurious changes, like mass whitespace diffs. 78 | All Python sources use spaces to indent, not TABs. 79 | 80 | 81 | ### Make sure your changes work 82 | 83 | Some things that will increase the chance that your pull request is accepted: 84 | 85 | * Follow style conventions you see used in the source already (and read [PEP8](http://www.python.org/dev/peps/pep-0008/)). 86 | * Include tests that fail *without* your code, and pass *with* it. Only minor refactoring and documentation changes require no new tests. If you are adding functionality or fixing a bug, please also add a test for it! 87 | * Update any documentation or examples impacted by your change. 88 | * Styling conventions and code quality are checked with `invoke check`, tests are run using `invoke test`, and the docs can be built locally using `invoke build --docs`. 89 | 90 | Following these hints also expedites the whole procedure, since it avoids unnecessary feedback cycles. 91 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. documentation master file 2 | 3 | Copyright © 2015 1&1 Group 4 | 5 | ## LICENSE_SHORT ## 6 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 7 | 8 | 9 | ============================================================================= 10 | Welcome to the “Confluencer” manual! 11 | ============================================================================= 12 | 13 | .. image:: _static/img/logo.png 14 | 15 | A CLI tool to automate common Confluence maintenance tasks and content publishing. 16 | 17 | .. image:: https://img.shields.io/pypi/v/confluencer.svg# 18 | :alt: Latest Version 19 | :target: https://pypi.python.org/pypi/confluencer/ 20 | 21 | .. image:: https://api.travis-ci.org/1and1/confluencer.svg# 22 | :alt: Travis CI 23 | :target: https://travis-ci.org/1and1/confluencer 24 | 25 | .. image:: https://img.shields.io/coveralls/1and1/confluencer.svg# 26 | :alt: Coveralls 27 | :target: https://coveralls.io/r/1and1/confluencer 28 | 29 | .. image:: https://img.shields.io/github/issues/1and1/confluencer.svg# 30 | :alt: GitHub Issues 31 | :target: https://github.com/1and1/confluencer/issues 32 | 33 | 34 | Installing 35 | ---------- 36 | 37 | *Confluencer* can be installed from PyPI 38 | via ``pip install confluencer`` as usual, 39 | see `releases `_ 40 | on GitHub for an overview of available versions – the project uses 41 | `semantic versioning `_ and follows 42 | `PEP 440 `_ conventions. 43 | 44 | To get a bleeding-edge version from source, use these commands: 45 | 46 | .. code-block:: shell 47 | 48 | repo="1and1/confluencer" 49 | pip install -r "https://raw.githubusercontent.com/$repo/master/requirements.txt" 50 | pip install -UI -e "git+https://github.com/$repo.git#egg=${repo#*/}" 51 | 52 | See the following section on how to create a full development environment. 53 | 54 | To add bash completion, read the 55 | `Click docs `_ 56 | about it, or just follow these instructions: 57 | 58 | .. code-block:: shell 59 | 60 | cmdname=confluencer 61 | mkdir -p ~/.bash_completion.d 62 | ( export _$(tr a-z- A-Z_ <<<"$cmdname")_COMPLETE=source ; \ 63 | $cmdname >~/.bash_completion.d/$cmdname.sh ) 64 | grep /.bash_completion.d/$cmdname.sh ~/.bash_completion >/dev/null \ 65 | || echo >>~/.bash_completion ". ~/.bash_completion.d/$cmdname.sh" 66 | . "/etc/bash_completion" 67 | 68 | 69 | Contributing 70 | ------------ 71 | 72 | To create a working directory for this project, call these commands: 73 | 74 | .. code-block:: shell 75 | 76 | git clone "https://github.com/1and1/confluencer.git" 77 | cd "confluencer" 78 | . .env --yes --develop 79 | invoke build --docs test check 80 | 81 | Contributing to this project is easy, and reporting an issue or 82 | adding to the documentation also improves things for every user. 83 | You don’t need to be a developer to contribute. 84 | See :doc:`CONTRIBUTING` for more. 85 | 86 | 87 | Documentation Contents 88 | ---------------------- 89 | 90 | .. toctree:: 91 | :maxdepth: 4 92 | 93 | usage 94 | cli-reference 95 | api-reference 96 | CONTRIBUTING 97 | LICENSE 98 | 99 | 100 | References 101 | ---------- 102 | 103 | Tools 104 | ^^^^^ 105 | 106 | - `Cookiecutter `_ 107 | - `PyInvoke `_ 108 | - `pytest `_ 109 | - `tox `_ 110 | - `Pylint `_ 111 | - `twine `_ 112 | - `bpython `_ 113 | - `yolk3k `_ 114 | 115 | Packages 116 | ^^^^^^^^ 117 | 118 | - `Rituals `_ 119 | - `Click `_ 120 | 121 | 122 | Indices and Tables 123 | ------------------ 124 | 125 | * :ref:`genindex` 126 | * :ref:`modindex` 127 | * :ref:`search` 128 | -------------------------------------------------------------------------------- /src/confluencer/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=bad-continuation 3 | """ Command line interface. 4 | """ 5 | # Copyright © 2015 1&1 Group 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | from __future__ import absolute_import, unicode_literals, print_function 19 | 20 | import re 21 | import logging 22 | 23 | import click 24 | from munch import Munch as Bunch 25 | 26 | from . import config 27 | 28 | 29 | # Default name of the app, and its app directory 30 | __app_name__ = 'confluencer' 31 | config.APP_NAME = __app_name__ 32 | 33 | # The `click` custom context settings 34 | CONTEXT_SETTINGS = dict( 35 | obj=Bunch(cfg=None, quiet=False, verbose=False), # namespace for custom stuff 36 | help_option_names=['-h', '--help'], 37 | auto_envvar_prefix=config.APP_NAME.upper().replace('-', '_'), 38 | ) 39 | 40 | 41 | # `--license` option decorator 42 | def license_option(*param_decls, **attrs): 43 | """``--license`` option that prints license information and then exits.""" 44 | def decorator(func): 45 | "decorator inner wrapper" 46 | def callback(ctx, _dummy, value): 47 | "click option callback" 48 | if not value or ctx.resilient_parsing: 49 | return 50 | 51 | from . import __doc__ as license_text 52 | license_text = re.sub(r"``([^`]+?)``", lambda m: click.style(m.group(1), bold=True), license_text) 53 | click.echo(license_text) 54 | ctx.exit() 55 | 56 | attrs.setdefault('is_flag', True) 57 | attrs.setdefault('expose_value', False) 58 | attrs.setdefault('is_eager', True) 59 | attrs.setdefault('help', 'Show the license and exit.') 60 | attrs['callback'] = callback 61 | return click.option(*(param_decls or ('--license',)), **attrs)(func) 62 | 63 | return decorator 64 | 65 | 66 | # Main command (root) 67 | @click.group(context_settings=CONTEXT_SETTINGS) 68 | @click.version_option(message=config.VERSION_INFO) 69 | @license_option() 70 | @click.option('-q', '--quiet', is_flag=True, default=False, help='Be quiet (show only errors).') 71 | @click.option('-v', '--verbose', is_flag=True, default=False, help='Create extra verbose output.') 72 | @click.option('-c', '--config', "config_paths", metavar='FILE', 73 | multiple=True, type=click.Path(), help='Load given configuration file(s).') 74 | @click.pass_context 75 | def cli(ctx, quiet=False, verbose=False, config_paths=None): # pylint: disable=unused-argument 76 | """'confluencer' command line tool.""" 77 | config.cfg = config.Configuration.from_context(ctx, config_paths) 78 | ctx.obj.quiet = quiet 79 | ctx.obj.verbose = verbose 80 | 81 | log_level = logging.INFO 82 | if ctx.obj.quiet: 83 | log_level = logging.WARNING 84 | if ctx.obj.verbose: 85 | log_level = logging.DEBUG 86 | logging.basicConfig(level=log_level) 87 | if ctx.obj.verbose: 88 | logging.getLogger("requests").setLevel(logging.DEBUG) 89 | else: 90 | logging.getLogger("requests").setLevel(logging.WARNING) 91 | ctx.obj.log = logging.getLogger(ctx.info_name) 92 | 93 | 94 | def run(): 95 | """Call main command.""" 96 | cli.main(prog_name=config.APP_NAME) 97 | 98 | 99 | # Import sub-commands to define them AFTER `cli` is defined 100 | config.cli = cli 101 | from . import commands as _ # noqa pylint: disable=unused-import, wrong-import-position 102 | 103 | if __name__ == "__main__": # imported via "python -m"? 104 | __package__ = 'confluencer' # pylint: disable=redefined-builtin 105 | run() 106 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | .. documentation: usage 2 | 3 | Copyright © 2015 1&1 Group 4 | 5 | ## LICENSE_SHORT ## 6 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 7 | 8 | ============================================================================= 9 | Using Confluencer 10 | ============================================================================= 11 | 12 | Providing Login Credentials 13 | --------------------------- 14 | 15 | Before you can use *Confluencer*, you have to provide some minimal 16 | configuration, most importantly credentials for API access. 17 | Select one of the ways outlined below to store your API credentials. 18 | 19 | Using the ~/.netrc File 20 | ^^^^^^^^^^^^^^^^^^^^^^^ 21 | 22 | 1. Create the file ``~/.netrc`` with the following contents (or add that 23 | to the existing file): 24 | 25 | .. code-block:: aconf 26 | 27 | machine confluence.example.org 28 | login «your username» 29 | password «your password» 30 | 31 | 2. Call ``chmod 600 ~/.netrc`` to protect your sensitive data. 32 | 33 | 3. Check that everything is OK by calling ``python3 -m netrc | less``. 34 | 35 | This way, the sensitive authentication information is separate from the 36 | rest of the configuration. Use the ``cfr help`` command to check whether 37 | your credentials actually work – if they do, the “Confluence Stats” 38 | section in the output will show some basic info about your wiki, 39 | otherwise you'll see an error indicator. 40 | 41 | 42 | Using a Keyring Query Command 43 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 44 | **TODO** 45 | 46 | Tools that can be used on Linux are ``gnome-keyring-query`` 47 | and `gkeyring `_. 48 | 49 | 50 | 51 | Cleaning Up Pages After Content Migration 52 | ----------------------------------------- 53 | 54 | The *Confluence* rich text editor allows you to migrate content 55 | from rendered HTML in other systems by simple copy&paste. 56 | However, certain artifacts of the source system are carried over, 57 | or active content is only copied with its *current (static) state*. 58 | 59 | The ``cfr tidy`` sub-command relieves you from manually fixing all those tiny 60 | defects, based on built-in patterns and replacement rules. 61 | These rules currently target *FosWiki* as a source, and for example 62 | a copied table of contents is replaced by the related *Confluence* macro. 63 | 64 | Pass it the URL of the page you want to clean up – adding the ``--recursive`` 65 | option includes all descendants of that page. Normally, the output 66 | shows which and how often rules are applied to the content, the ``--diff`` option 67 | adds a detailed record of the applied changes. 68 | 69 | If you want to just show the changes without applying them, use the 70 | ``--no-save`` option (or the shorter ``-n``). This automatically includes 71 | diff output, to just show the applied rules repeat the option (``-nn``). 72 | 73 | .. code:: 74 | 75 | $ cfr tidy -nn "http://confluence.local/display/~jhe/Sandbox" 76 | INFO:confluencer:Replaced 2 matche(s) of "FosWiki: Empty anchor in headers" (16 chars removed) 77 | INFO:confluencer:Replaced 3 matche(s) of "FosWiki: 'tok' spans in front of headers" (94 chars removed) 78 | INFO:confluencer:Replaced 3 matche(s) of "FosWiki: Section edit icons at the end of headers" (664 chars removed) 79 | INFO:confluencer:Replaced 1 matche(s) of "FosWiki: Replace TOC div with macro" (127 chars removed) 80 | INFO:confluencer:WOULD save page#2393332 "Sandbox" as v. 11 81 | 82 | 83 | Exporting Metadata for a Page Tree 84 | ---------------------------------- 85 | 86 | :command:`cfr stats tree` generates a JSON list of a page tree given its root page 87 | (other output formats will follow). You can then select more specific information 88 | from that using ``jq`` or other JSON tools. 89 | 90 | Consider this example creating a CSV file: 91 | 92 | .. code-block:: console 93 | 94 | $ cfr stats tree "https://confluence.local/x/_EJN" \ 95 | | jq '.[] | .depth, .title, .version.when, .version.by.displayName' \ 96 | | paste -sd ';;;\n' 97 | INFO:confluencer:Got 21 results. 98 | 0;"Root Page";"2016-10-24T17:20:04.000+02:00";"Jürgen Hermann" 99 | 1;"First Immediate Child";"2020-01-22T14:24:45.111+01:00";"Jürgen Hermann" 100 | … 101 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contribution Guidelines 2 | ======================= 3 | 4 | Overview 5 | -------- 6 | 7 | Contributing to this project is easy, and reporting an issue or adding 8 | to the documentation also improves things for every user. You don't need 9 | to be a developer to contribute. 10 | 11 | Reporting issues 12 | ~~~~~~~~~~~~~~~~ 13 | 14 | Please use the *GitHub issue tracker*, and describe your problem so that 15 | it can be easily reproduced. Providing relevant version information on 16 | the project itself and your environment helps with that. 17 | 18 | Improving documentation 19 | ~~~~~~~~~~~~~~~~~~~~~~~ 20 | 21 | The easiest way to provide examples or related documentation that helps 22 | other users is the *GitHub wiki*. 23 | 24 | If you are comfortable with the Sphinx documentation tool, you can also 25 | prepare a pull request with changes to the core documentation. GitHub's 26 | built-in text editor makes this especially easy, when you choose the 27 | *“Create a new branch for this commit and start a pull request”* option 28 | on saving. Small fixes for typos and the like are a matter of minutes 29 | when using that tool. 30 | 31 | Code contributions 32 | ~~~~~~~~~~~~~~~~~~ 33 | 34 | Here's a quick guide to improve the code: 35 | 36 | 1. Fork the repo, and clone the fork to your machine. 37 | 2. Add your improvements, the technical details are further below. 38 | 3. Run the tests and make sure they're passing (``invoke test``). 39 | 4. Check for violations of code conventions (``invoke check``). 40 | 5. Make sure the documentation builds without errors 41 | (``invoke build --docs``). 42 | 6. Push to your fork and submit a `pull 43 | request `__. 44 | 45 | Please be patient while waiting for a review. Life & work tend to 46 | interfere. 47 | 48 | Details on contributing code 49 | ---------------------------- 50 | 51 | This project is written in `Python `__, and the 52 | documentation is generated using 53 | `Sphinx `__. 54 | `setuptools `__ 55 | and `Invoke `__ are used to build and manage 56 | the project. Tests are written and executed using 57 | `pytest `__ and `tox `__. 58 | 59 | Set up a working development environment 60 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 61 | 62 | To set up a working directory from your own fork, follow `these 63 | steps `__, but 64 | replace the repository ``https`` URLs with SSH ones that point to your 65 | fork. 66 | 67 | For that to work on Debian type systems, you need the ``git``, 68 | ``python``, and ``python-virtualenv`` packages installed. Other 69 | distributions are similar. 70 | 71 | Add your changes to a feature branch 72 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 73 | 74 | For any cohesive set of changes, create a *new* branch based on the 75 | current upstream ``master``, with a name reflecting the essence of your 76 | improvement. 77 | 78 | .. code:: sh 79 | 80 | git branch "name-for-my-fixes" origin/master 81 | git checkout "name-for-my-fixes" 82 | … make changes… 83 | invoke ci # check output for broken tests, or PEP8 violations and the like 84 | … commit changes… 85 | git push origin "name-for-my-fixes" 86 | 87 | Please don't create large lumps of unrelated changes in a single pull 88 | request. Also take extra care to avoid spurious changes, like mass 89 | whitespace diffs. All Python sources use spaces to indent, not TABs. 90 | 91 | Make sure your changes work 92 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 93 | 94 | Some things that will increase the chance that your pull request is 95 | accepted: 96 | 97 | - Follow style conventions you see used in the source already (and read 98 | `PEP8 `__). 99 | - Include tests that fail *without* your code, and pass *with* it. Only 100 | minor refactoring and documentation changes require no new tests. If 101 | you are adding functionality or fixing a bug, please also add a test 102 | for it! 103 | - Update any documentation or examples impacted by your change. 104 | - Styling conventions and code quality are checked with 105 | ``invoke check``, tests are run using ``invoke test``, and the docs 106 | can be built locally using ``invoke build --docs``. 107 | 108 | Following these hints also expedites the whole procedure, since it 109 | avoids unnecessary feedback cycles. 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # confluencer 2 | 3 | A CLI tool to automate common Confluence maintenance tasks and content publishing. 4 | 5 |  [![Travis CI](https://api.travis-ci.org/1and1/confluencer.svg)](https://travis-ci.org/1and1/confluencer) 6 |  [![Coveralls](https://img.shields.io/coveralls/1and1/confluencer.svg)](https://coveralls.io/r/1and1/confluencer) 7 |  [![GitHub Issues](https://img.shields.io/github/issues/1and1/confluencer.svg)](https://github.com/1and1/confluencer/issues) 8 |  [![License](https://img.shields.io/pypi/l/confluencer.svg)](https://github.com/1and1/confluencer/blob/master/LICENSE) 9 |  [![Latest Version](https://img.shields.io/pypi/v/confluencer.svg)](https://pypi.python.org/pypi/confluencer/) 10 |  [![Downloads](https://img.shields.io/pypi/dw/confluencer.svg)](https://pypi.python.org/pypi/confluencer/) 11 | 12 | 13 | ## Overview 14 | 15 | The ``confluencer`` command line tool (or ``cfr`` for short) allows you to 16 | control your Confluence installation from a shell prompt, and thus automate 17 | (mass) content changes and reports. 18 | 19 | For example, ``cfr tidy`` applies rules to remove artifacts from a page's body 20 | left behind if you cut© content from other sources (especially *FosWiki* pages). 21 | 22 | The ``confluencer.api`` Python package can be used in your own scripts to implement 23 | more complex custom use-cases that are not covered by the provided commands. 24 | It provides helpers like converting an URL as found in the web interface (i.e. 25 | your browser's location bar) into an API one, and abstractions for typical 26 | *Confluence* objects like pages and spaces. 27 | 28 | 29 | ## Installation 30 | 31 | *Confluencer* can be installed via ``pip install confluencer`` as usual, 32 | see [releases](https://github.com/1and1/confluencer/releases) for an overview of available versions. 33 | To get a bleeding-edge version from source, use these commands: 34 | 35 | ```sh 36 | repo="1and1/confluencer" 37 | pip install -r "https://raw.githubusercontent.com/$repo/master/requirements.txt" 38 | pip install -U -e "git+https://github.com/$repo.git#egg=${repo#*/}" 39 | ``` 40 | 41 | See [Contributing](#contributing) on how to create a full development environment. 42 | 43 | To add bash completion, read the [Click docs](http://click.pocoo.org/4/bashcomplete/#activation) about it, 44 | or just follow these instructions: 45 | 46 | ```sh 47 | cmdname=confluencer 48 | mkdir -p ~/.bash_completion.d 49 | ( export _$(tr a-z- A-Z_ <<<"$cmdname")_COMPLETE=source ; \ 50 | $cmdname >~/.bash_completion.d/$cmdname.sh ) 51 | grep /.bash_completion.d/$cmdname.sh ~/.bash_completion >/dev/null \ 52 | || echo >>~/.bash_completion ". ~/.bash_completion.d/$cmdname.sh" 53 | . "/etc/bash_completion" 54 | ``` 55 | 56 | 57 | ## Usage 58 | 59 | See the [main documentation](http://confluencer.readthedocs.io/) at *Read the Docs*. 60 | 61 | 62 | ## Contributing 63 | 64 | Contributing to this project is easy, and reporting an issue or 65 | adding to the documentation also improves things for every user. 66 | You don’t need to be a developer to contribute. 67 | See [CONTRIBUTING](https://github.com/1and1/confluencer/blob/master/CONTRIBUTING.md) for more. 68 | 69 | As a documentation author or developer, 70 | to create a working directory for this project, 71 | call these commands: 72 | 73 | ```sh 74 | git clone "https://github.com/1and1/confluencer.git" 75 | cd "confluencer" 76 | . .env --yes --develop 77 | invoke build --docs test check 78 | ``` 79 | 80 | You might also need to follow some 81 | [setup procedures](https://py-generic-project.readthedocs.io/en/latest/installing.html#quick-setup) 82 | to make the necessary basic commands available on *Linux*, *Mac OS X*, and *Windows*. 83 | 84 | 85 | ## References 86 | 87 | **Tools** 88 | 89 | * [Cookiecutter](http://cookiecutter.readthedocs.io/en/latest/) 90 | * [PyInvoke](http://www.pyinvoke.org/) 91 | * [pytest](http://pytest.org/latest/contents.html) 92 | * [tox](https://tox.readthedocs.io/en/latest/) 93 | * [Pylint](http://docs.pylint.org/) 94 | * [twine](https://github.com/pypa/twine#twine) 95 | * [bpython](http://docs.bpython-interpreter.org/) 96 | * [yolk3k](https://github.com/myint/yolk#yolk) 97 | 98 | **Packages** 99 | 100 | * [Rituals](https://jhermann.github.io/rituals) 101 | * [Click](http://click.pocoo.org/) 102 | 103 | 104 | ## Related Projects 105 | 106 | * [Conflence.py](https://github.com/RaymiiOrg/confluence-python-cli) – A 1:1 mapping of the REST API to a command line tool. 107 | * [PythonConfluenceAPI](https://github.com/pushrodtechnology/PythonConfluenceAPI) - A Pythonic API wrapper over the Confluence REST API. 108 | * [pyconfluence](https://github.com/FulcrumIT/pyconfluence) – PyConfluence is designed for automated documentation, with convenience in mind and easy interaction with the numerous services Confluence offers. 109 | * [rst2confluence](https://github.com/netresearch/rst2confluence) – Convert reStructuredText to Confluence style wiki markup. 110 | 111 | 112 | ## Acknowledgements 113 | 114 | … 115 | -------------------------------------------------------------------------------- /src/confluencer/commands/stats.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=bad-continuation, too-few-public-methods 3 | """ 'stats' command group. 4 | """ 5 | # Copyright © 2015 – 2017 1&1 Group 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | from __future__ import absolute_import, unicode_literals, print_function 19 | 20 | import sys 21 | import json 22 | from pprint import pformat 23 | 24 | from munch import Munch as Bunch 25 | from rudiments.reamed import click 26 | 27 | from .. import config, api 28 | from ..tools import content 29 | from .._compat import text_type, string_types 30 | 31 | 32 | SERIALIZERS_NEED_NL = ('dict', 'json', 'html') 33 | SERIALIZERS_TEXT = SERIALIZERS_NEED_NL + ('yaml', 'csv', 'tsv') 34 | SERIALIZERS_BINARY = ('ods', 'xls') # this just doesn't work right (Unicode issues): , 'xlsx') 35 | SERIALIZERS = SERIALIZERS_TEXT + SERIALIZERS_BINARY 36 | 37 | ENTITIES = ('macro',) # 'label', 'page', 'title', 'attachment', 'blog', … 38 | 39 | 40 | def print_result(ctx, obj): 41 | """ Dump a result to the console or an output file.""" 42 | text = obj 43 | if isinstance(text, dict): 44 | if isinstance(text, Bunch): 45 | # Debunchify for ``pformat`` 46 | text = dict(text.copy()) 47 | for key, val in text.items(): 48 | if isinstance(val, Bunch): 49 | text[key] = dict(val) 50 | #text = pformat(text) 51 | text = json.dumps(text, indent=2, sort_keys=True) 52 | elif isinstance(text, list): 53 | text = json.dumps(text, indent=2, sort_keys=True) 54 | 55 | if not isinstance(text, string_types): 56 | text = repr(text) 57 | if ctx.obj.serializer in SERIALIZERS_NEED_NL: 58 | text += '\n' 59 | #if isinstance(text, text_type): 60 | # text = text.encode('utf-8') 61 | try: 62 | (ctx.obj.outfile or sys.stdout).write(text) 63 | except EnvironmentError as cause: 64 | raise click.LoggedFailure('Error while writing "{}" ({})'.format( 65 | getattr(ctx.obj.outfile or object(), 'name', ''), cause)) 66 | 67 | 68 | @config.cli.group() 69 | @click.option('-f', '--format', 'serializer', default=None, type=click.Choice(SERIALIZERS), 70 | help="Output format (defaults to extension of OUTFILE).", 71 | ) 72 | @click.option('-s', '--space', multiple=True) 73 | @click.option('-e', '--entity', default=None, type=click.Choice(ENTITIES), 74 | help="Entity to handle / search for.", 75 | ) 76 | @click.option('-o', '--outfile', default=None, type=click.File('wb')) 77 | @click.pass_context 78 | def stats(ctx, space, entity, outfile, serializer): 79 | """Create status reports (or data exports).""" 80 | ctx.obj.spaces = space 81 | ctx.obj.entity = entity 82 | ctx.obj.outfile = outfile 83 | ctx.obj.serializer = serializer 84 | 85 | ctx.obj.cql = [] 86 | if ctx.obj.spaces: 87 | ctx.obj.cql.append('({})'.format( 88 | ' OR '.join(['space="{}"'.format(x) for x in ctx.obj.spaces]), 89 | )) 90 | 91 | 92 | @stats.command() 93 | @click.option('--top', metavar='N', default=0, type=int, 94 | help="Show top ‹N› ranked entities.") 95 | @click.argument('query') 96 | @click.pass_context 97 | def usage(ctx, query, top=0): 98 | """Create report on usage of different entities (macros, labels, …).""" 99 | if not ctx.obj.entity: 100 | click.serror("No --entity selected!") 101 | return 102 | if top: 103 | click.echo("TOP {:d}".format(top)) 104 | 105 | outname = getattr(ctx.obj.outfile, 'name', None) 106 | 107 | with api.context() as cf: 108 | ctx.obj.cql.append('type=page AND macro != "{}"'.format(query)) 109 | try: 110 | response = cf.get("content/search", cql=' AND '.join(ctx.obj.cql)) 111 | except api.ERRORS as cause: 112 | # Just log and otherwise ignore any errors 113 | api.diagnostics(cause) 114 | else: 115 | print('Got {} results.'.format(len(response.results))) 116 | if response.results: 117 | print_result(ctx, response.results[0]) 118 | 119 | 120 | @stats.command() 121 | @click.argument('rootpage') 122 | @click.pass_context 123 | def tree(ctx, rootpage): 124 | """Export metadata of a page tree.""" 125 | if not rootpage: 126 | click.serror("No root page selected via --entity!") 127 | return 1 128 | 129 | outname = getattr(ctx.obj.outfile, 'name', None) 130 | 131 | with api.context() as cf: 132 | results = [] 133 | try: 134 | #page = content.ConfluencePage(cf, rootpage, expand='metadata.labels,metadata.properties') 135 | #results.append(page.json) 136 | pagetree = cf.walk(rootpage, depth_1st=True, 137 | expand='metadata.labels,metadata.properties,version') 138 | for depth, data in pagetree: 139 | data.update(dict(depth=depth)) 140 | results.append(data) 141 | except api.ERRORS as cause: 142 | # Just log and otherwise ignore any errors 143 | api.diagnostics(cause) 144 | else: 145 | ctx.obj.log.info('Got {} results.'.format(len(results))) 146 | if results: 147 | print_result(ctx, results) 148 | -------------------------------------------------------------------------------- /src/confluencer/_compat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=invalid-name, unused-import, missing-docstring, exec-used 3 | # pylint: disable=unused-argument, too-few-public-methods, redefined-builtin 4 | # pylint: disable=no-name-in-module, no-member, undefined-variable 5 | # pylint: disable=import-error, wrong-import-position, wrong-import-order 6 | # pylint: disable=ungrouped-imports 7 | # flake8: noqa 8 | """ 9 | Some py2/py3 compatibility support based on a stripped down 10 | version of six so there is no dependency on a specific version 11 | of it. 12 | 13 | Copied from Jinja2 (2015-03-22, #9bf5fcb). See also 14 | 15 | http://lucumr.pocoo.org/2013/5/21/porting-to-python-3-redux/ 16 | 17 | :copyright: Copyright (c) 2013 by the Jinja team, see their AUTHORS. 18 | :license: BSD, see the module's source for details. 19 | """ 20 | # Some rights reserved. 21 | # 22 | # Redistribution and use in source and binary forms, with or without 23 | # modification, are permitted provided that the following conditions are 24 | # met: 25 | # 26 | # * Redistributions of source code must retain the above copyright 27 | # notice, this list of conditions and the following disclaimer. 28 | # 29 | # * Redistributions in binary form must reproduce the above 30 | # copyright notice, this list of conditions and the following 31 | # disclaimer in the documentation and/or other materials provided 32 | # with the distribution. 33 | # 34 | # * The names of the contributors may not be used to endorse or 35 | # promote products derived from this software without specific 36 | # prior written permission. 37 | # 38 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 39 | # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 40 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 41 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 42 | # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 43 | # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 44 | # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 45 | # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 46 | # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 47 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 48 | # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 49 | 50 | import sys 51 | 52 | PY2 = sys.version_info[0] == 2 53 | PYPY = hasattr(sys, 'pypy_translation_info') 54 | _identity = lambda x: x 55 | 56 | 57 | if not PY2: 58 | unichr = chr 59 | range_type = range 60 | text_type = str 61 | string_types = (str,) 62 | integer_types = (int,) 63 | 64 | iterkeys = lambda d: iter(d.keys()) 65 | itervalues = lambda d: iter(d.values()) 66 | iteritems = lambda d: iter(d.items()) 67 | 68 | import pickle 69 | from io import BytesIO, StringIO 70 | NativeStringIO = StringIO 71 | 72 | def reraise(tp, value, tb=None): 73 | if value.__traceback__ is not tb: 74 | raise value.with_traceback(tb) 75 | raise value 76 | 77 | ifilter = filter 78 | imap = map 79 | izip = zip 80 | intern = sys.intern 81 | 82 | implements_iterator = _identity 83 | implements_to_string = _identity 84 | encode_filename = _identity 85 | get_next = lambda x: x.__next__ 86 | 87 | else: 88 | unichr = unichr 89 | text_type = unicode 90 | range_type = xrange 91 | string_types = (str, unicode) 92 | integer_types = (int, long) 93 | 94 | iterkeys = lambda d: d.iterkeys() 95 | itervalues = lambda d: d.itervalues() 96 | iteritems = lambda d: d.iteritems() 97 | 98 | import cPickle as pickle 99 | from cStringIO import StringIO as BytesIO, StringIO # pylint: disable=reimported 100 | NativeStringIO = BytesIO 101 | 102 | exec('def reraise(tp, value, tb=None):\n raise tp, value, tb') 103 | 104 | from itertools import imap, izip, ifilter 105 | intern = intern 106 | 107 | def implements_iterator(cls): 108 | cls.next = cls.__next__ 109 | del cls.__next__ 110 | return cls 111 | 112 | def implements_to_string(cls): 113 | cls.__unicode__ = cls.__str__ 114 | cls.__str__ = lambda x: x.__unicode__().encode('utf-8') 115 | return cls 116 | 117 | get_next = lambda x: x.next 118 | 119 | def encode_filename(filename): 120 | if isinstance(filename, unicode): 121 | return filename.encode('utf-8') 122 | return filename 123 | 124 | 125 | def with_metaclass(meta, *bases): 126 | # This requires a bit of explanation: the basic idea is to make a 127 | # dummy metaclass for one level of class instanciation that replaces 128 | # itself with the actual metaclass. Because of internal type checks 129 | # we also need to make sure that we downgrade the custom metaclass 130 | # for one level to something closer to type (that's why __call__ and 131 | # __init__ comes back from type etc.). 132 | # 133 | # This has the advantage over six.with_metaclass in that it does not 134 | # introduce dummy classes into the final MRO. 135 | class metaclass(meta): 136 | __call__ = type.__call__ 137 | __init__ = type.__init__ 138 | def __new__(cls, name, this_bases, d): 139 | if this_bases is None: 140 | return type.__new__(cls, name, (), d) 141 | return meta(name, bases, d) 142 | return metaclass('temporary_class', None, {}) 143 | 144 | 145 | try: 146 | from html import unescape as html_unescape 147 | except ImportError: 148 | from HTMLParser import HTMLParser as _HTMLParser 149 | html_unescape = _HTMLParser().unescape 150 | 151 | try: 152 | from urllib.parse import quote_from_bytes as url_quote 153 | from urllib.parse import unquote, unquote_plus 154 | except ImportError: 155 | from urllib import quote as url_quote, unquote, unquote_plus 156 | 157 | try: 158 | from urllib.parse import urlparse, urlunparse, parse_qs, parse_qsl, urlencode 159 | except ImportError: 160 | from urlparse import urlparse, urlunparse, parse_qs, parse_qsl 161 | from urllib import urlencode 162 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # pylint: disable=bad-whitespace, attribute-defined-outside-init, invalid-name 4 | # pylint: disable=too-many-statements, wrong-import-position 5 | """ 6 | Confluencer – A CLI tool to automate common Confluence maintenance tasks and content publishing. 7 | 8 | This setuptools script follows the DRY principle and tries to 9 | minimize repetition of project metadata by loading it from other 10 | places (like the package's `__init__.py`). Incidently, this makes 11 | the script almost identical between different projects. 12 | 13 | It is also importable (by using the usual `if __name__ == '__main__'` 14 | idiom), and exposes the project's setup data in a `project` dict. 15 | This allows other tools to exploit the data assembling code contained 16 | in here, and again supports the DRY principle. The `rituals` package 17 | uses that to provide Invoke tasks that work for any project, based on 18 | its project metadata. 19 | 20 | Copyright © 2015 1&1 Group 21 | 22 | Licensed under the Apache License, Version 2.0 (the "License"); 23 | you may not use this file except in compliance with the License. 24 | You may obtain a copy of the License at 25 | 26 | http://www.apache.org/licenses/LICENSE-2.0 27 | 28 | Unless required by applicable law or agreed to in writing, software 29 | distributed under the License is distributed on an "AS IS" BASIS, 30 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 31 | See the License for the specific language governing permissions and 32 | limitations under the License. 33 | """ 34 | 35 | # Project data (the rest is parsed from __init__.py and other project files) 36 | name = 'confluencer' 37 | package_name = 'confluencer' 38 | entry_points = { 39 | 'console_scripts': ['cfr = confluencer.__main__:run'], 40 | } 41 | 42 | 43 | # ~~~ BEGIN springerle/py-generic-project ~~~ 44 | # Stdlib imports 45 | import io 46 | import os 47 | import re 48 | import sys 49 | import json 50 | import textwrap 51 | from collections import defaultdict 52 | 53 | # Import setuptools 54 | try: 55 | from setuptools import setup, find_packages 56 | from setuptools.command.test import test as TestCommand 57 | except ImportError as exc: 58 | raise RuntimeError("Cannot install '{0}', setuptools is missing ({1})".format(name, exc)) 59 | 60 | # Helpers 61 | project_root = os.path.abspath(os.path.dirname(__file__)) 62 | 63 | def srcfile(*args): 64 | "Helper for path building." 65 | return os.path.join(*((project_root,) + args)) 66 | 67 | class PyTest(TestCommand): 68 | """pytest integration into setuptool's `test` command.""" 69 | user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")] 70 | 71 | def initialize_options(self): 72 | TestCommand.initialize_options(self) 73 | self.pytest_args = [] 74 | 75 | def finalize_options(self): 76 | TestCommand.finalize_options(self) 77 | self.test_args = [] 78 | self.test_suite = True 79 | 80 | def run_tests(self): 81 | if 0 and os.environ.get('DH_VIRTUALENV_INSTALL_ROOT', None): 82 | return # disable tests during dh-virtualenv build 83 | 84 | # import locally, cause outside the eggs aren't loaded 85 | import pytest 86 | errno = pytest.main(self.pytest_args) 87 | if errno: 88 | sys.exit(errno) 89 | 90 | def _build_metadata(): # pylint: disable=too-many-locals, too-many-branches 91 | "Return project's metadata as a dict." 92 | # Handle metadata in package source 93 | expected_keys = ('url', 'version', 'license', 'author', 'author_email', 'long_description', 'keywords') 94 | metadata = {} 95 | with io.open(srcfile('src', package_name, '__init__.py'), encoding='utf-8') as handle: 96 | pkg_init = handle.read() 97 | # Get default long description from docstring 98 | metadata['long_description'] = re.search(r'^"""(.+?)^"""$', pkg_init, re.DOTALL|re.MULTILINE).group(1) 99 | for line in pkg_init.splitlines(): 100 | match = re.match(r"""^__({0})__ += (?P['"])(.+?)(?P=q)$""".format('|'.join(expected_keys)), line) 101 | if match: 102 | metadata[match.group(1)] = match.group(3) 103 | 104 | if not all(i in metadata for i in expected_keys): 105 | raise RuntimeError("Missing or bad metadata in '{0}' package: {1}" 106 | .format(name, ', '.join(sorted(set(expected_keys) - set(metadata.keys()))),)) 107 | 108 | text = metadata['long_description'].strip() 109 | if text: 110 | metadata['description'], text = text.split('.', 1) 111 | metadata['description'] = ' '.join(metadata['description'].split()).strip() + '.' # normalize whitespace 112 | metadata['long_description'] = textwrap.dedent(text).strip() 113 | metadata['keywords'] = metadata['keywords'].replace(',', ' ').strip().split() 114 | 115 | # Load requirements files 116 | requirements_files = dict( 117 | install = 'requirements.txt', 118 | setup = 'setup-requirements.txt', 119 | test = 'test-requirements.txt', 120 | ) 121 | requires = {} 122 | for key, filename in requirements_files.items(): 123 | requires[key] = [] 124 | if os.path.exists(srcfile(filename)): 125 | with io.open(srcfile(filename), encoding='utf-8') as handle: 126 | for line in handle: 127 | line = line.strip() 128 | if line and not line.startswith('#') and ';' not in line: 129 | if any(line.startswith(i) for i in ('-e', 'http://', 'https://')): 130 | line = line.split('#egg=')[1] 131 | requires[key].append(line) 132 | if not any('pytest' == re.split('[\t ,<=>]', i.lower())[0] for i in requires['test']): 133 | requires['test'].append('pytest') # add missing requirement 134 | 135 | # CLI entry points 136 | console_scripts = [] 137 | for path, dirs, files in os.walk(srcfile('src', package_name)): 138 | dirs = [i for i in dirs if not i.startswith('.')] 139 | if '__main__.py' in files: 140 | path = path[len(srcfile('src') + os.sep):] 141 | appname = path.split(os.sep)[-1] 142 | with io.open(srcfile('src', path, '__main__.py'), encoding='utf-8') as handle: 143 | for line in handle.readlines(): 144 | match = re.match(r"""^__app_name__ += (?P['"])(.+?)(?P=q)$""", line) 145 | if match: 146 | appname = match.group(2) 147 | console_scripts.append('{0} = {1}.__main__:cli'.format(appname, path.replace(os.sep, '.'))) 148 | 149 | # Add some common files to EGG-INFO 150 | candidate_files = [ 151 | 'LICENSE', 'NOTICE', 152 | 'README', 'README.md', 'README.rst', 'README.txt', 153 | 'CHANGES', 'CHANGELOG', 'debian/changelog', 154 | ] 155 | data_files = defaultdict(list) 156 | for filename in candidate_files: 157 | if os.path.exists(srcfile(filename)): 158 | data_files['EGG-INFO'].append(filename) 159 | 160 | # Complete project metadata 161 | classifiers = [] 162 | for classifiers_txt in ('classifiers.txt', 'project.d/classifiers.txt'): 163 | classifiers_txt = srcfile(classifiers_txt) 164 | if os.path.exists(classifiers_txt): 165 | with io.open(classifiers_txt, encoding='utf-8') as handle: 166 | classifiers = [i.strip() for i in handle if i.strip() and not i.startswith('#')] 167 | break 168 | entry_points.setdefault('console_scripts', []).extend(console_scripts) 169 | 170 | metadata.update(dict( 171 | name = name, 172 | package_dir = {'': 'src'}, 173 | packages = find_packages(srcfile('src'), exclude=['tests']), 174 | data_files = data_files.items(), 175 | zip_safe = False, 176 | include_package_data = True, 177 | install_requires = requires['install'], 178 | setup_requires = requires['setup'], 179 | tests_require = requires['test'], 180 | classifiers = classifiers, 181 | cmdclass = dict( 182 | test = PyTest, 183 | ), 184 | entry_points = entry_points, 185 | )) 186 | return metadata 187 | 188 | # Ensure "setup.py" is importable by other tools, to access the project's metadata 189 | project = _build_metadata() 190 | __all__ = ['project', 'project_root', 'package_name', 'srcfile'] 191 | if __name__ == '__main__': 192 | if '--metadata' in sys.argv[:2]: 193 | json.dump(project, sys.stdout, default=repr, indent=4, sort_keys=True) 194 | sys.stdout.write('\n') 195 | else: 196 | setup(**project) 197 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # 2 | # Sphinx documentation build configuration file. 3 | # 4 | # This file is execfile()d with the current directory set to its containing dir. 5 | # 6 | # Note that not all possible configuration values are present in this 7 | # autogenerated file. 8 | # 9 | # All configuration values have a default; values that are commented out 10 | # serve to show the default. 11 | 12 | import os 13 | import re 14 | import sys 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | sys.path.insert(0, os.path.abspath("..")) 20 | sys.path.insert(0, os.path.abspath("../src")) 21 | from setup import project as meta 22 | from confluencer import __main__ 23 | 24 | on_rtd = os.environ.get('READTHEDOCS', None) == 'True' 25 | if not on_rtd: 26 | import sphinx_rtd_theme 27 | 28 | # -- General configuration ----------------------------------------------------- 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | needs_sphinx = '1.3' 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be extensions 34 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 35 | extensions = [ 36 | 'sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.coverage', 37 | 'sphinx.ext.ifconfig', 'sphinx.ext.viewcode', 'sphinx.ext.napoleon', 38 | 'sphinx.ext.intersphinx', 'sphinx_click.ext', 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ['_templates'] 43 | 44 | # The suffix of source filenames. 45 | source_suffix = '.rst' 46 | 47 | # The encoding of source files. 48 | #source_encoding = 'utf-8-sig' 49 | 50 | # The master toctree document. 51 | master_doc = 'index' 52 | 53 | # General information about the project. 54 | project = meta["name"] 55 | copyright = ' '.join([i for i in meta["long_description"].splitlines() if "Copyright" in i][0].split()[2:]) 56 | 57 | # The version info for the project you're documenting, acts as replacement for 58 | # |version| and |release|, also used in various other places throughout the 59 | # built documents. 60 | # 61 | # The full version, including alpha/beta/rc tags. 62 | release = meta["version"] 63 | # The short X.Y version. 64 | version = '.'.join(re.split("[^\d]+", release)[:2]) 65 | 66 | # The language for content autogenerated by Sphinx. Refer to documentation 67 | # for a list of supported languages. 68 | #language = None 69 | 70 | # There are two options for replacing |today|: either, you set today to some 71 | # non-false value, then it is used: 72 | #today = '' 73 | # Else, today_fmt is used as the format for a strftime call. 74 | today_fmt = '%Y-%m-%d' 75 | 76 | # List of patterns, relative to source directory, that match files and 77 | # directories to ignore when looking for source files. 78 | exclude_patterns = [ 79 | "*~", 80 | ] 81 | 82 | # The reST default role (used for this markup: `text`) to use for all documents. 83 | #default_role = None 84 | 85 | # If true, '()' will be appended to :func: etc. cross-reference text. 86 | #add_function_parentheses = True 87 | 88 | # If true, the current module name will be prepended to all description 89 | # unit titles (such as .. function::). 90 | #add_module_names = True 91 | 92 | # If true, sectionauthor and moduleauthor directives will be shown in the 93 | # output. They are ignored by default. 94 | #show_authors = False 95 | 96 | # The name of the Pygments (syntax highlighting) style to use. 97 | pygments_style = 'sphinx' 98 | 99 | # A list of ignored prefixes for module index sorting. 100 | #modindex_common_prefix = [] 101 | 102 | # Napoleon settings 103 | napoleon_numpy_docstring = False 104 | 105 | 106 | # -- Options for HTML output --------------------------------------------------- 107 | if not on_rtd: 108 | # The theme to use for HTML and HTML Help pages. See the documentation for 109 | # a list of builtin themes. 110 | html_theme = 'sphinx_rtd_theme' 111 | 112 | # Add any paths that contain custom themes here, relative to this directory. 113 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 114 | 115 | html_style = 'css/custom.css' 116 | 117 | # Theme options are theme-specific and customize the look and feel of a theme 118 | # further. For a list of options available for each theme, see the 119 | # documentation. 120 | html_theme_options = dict( 121 | ) 122 | 123 | # The name for this set of Sphinx documents. If None, it defaults to 124 | # " v documentation". 125 | #html_title = None 126 | 127 | # A shorter title for the navigation bar. Default is the same as html_title. 128 | #html_short_title = None 129 | 130 | # The name of an image file (relative to this directory) to place at the top 131 | # of the sidebar. 132 | html_logo = "_static/img/logo.png" 133 | 134 | # The name of an image file (within the static path) to use as favicon of the 135 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 136 | # pixels large. 137 | html_favicon = "_static/img/logo.ico" 138 | 139 | # Add any paths that contain custom static files (such as style sheets) here, 140 | # relative to this directory. They are copied after the builtin static files, 141 | # so a file named "default.css" will overwrite the builtin "default.css". 142 | html_static_path = ['_static'] 143 | 144 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 145 | # using the given strftime format. 146 | html_last_updated_fmt = '%Y-%m-%d' 147 | 148 | # If true, SmartyPants will be used to convert quotes and dashes to 149 | # typographically correct entities. 150 | html_use_smartypants = True 151 | 152 | # Custom sidebar templates, maps document names to template names. 153 | #html_sidebars = {} 154 | 155 | # Additional templates that should be rendered to pages, maps page names to 156 | # template names. 157 | #html_additional_pages = {} 158 | 159 | # If false, no module index is generated. 160 | html_domain_indices = True 161 | 162 | # If false, no index is generated. 163 | html_use_index = True 164 | 165 | # If true, the index is split into individual pages for each letter. 166 | #html_split_index = False 167 | 168 | # If true, links to the reST sources are added to the pages. 169 | #html_show_sourcelink = True 170 | 171 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 172 | html_show_sphinx = False 173 | 174 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 175 | #html_show_copyright = True 176 | 177 | # If true, an OpenSearch description file will be output, and all pages will 178 | # contain a tag referring to it. The value of this option must be the 179 | # base URL from which the finished HTML is served. 180 | #html_use_opensearch = '' 181 | 182 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 183 | #html_file_suffix = None 184 | 185 | # Output file base name for HTML help builder. 186 | htmlhelp_basename = project + 'doc' 187 | 188 | 189 | # -- Options for LaTeX output -------------------------------------------------- 190 | 191 | latex_elements = { 192 | # The paper size ('letterpaper' or 'a4paper'). 193 | #'papersize': 'letterpaper', 194 | 195 | # The font size ('10pt', '11pt' or '12pt'). 196 | #'pointsize': '10pt', 197 | 198 | # Additional stuff for the LaTeX preamble. 199 | #'preamble': '', 200 | } 201 | 202 | # Grouping the document tree into LaTeX files. List of tuples 203 | # (source start file, target name, title, author, documentclass [howto/manual]). 204 | latex_documents = [ 205 | ('index', project + '.tex', project + u' Documentation', meta["author"], 'manual'), 206 | ] 207 | 208 | # The name of an image file (relative to this directory) to place at the top of 209 | # the title page. 210 | #latex_logo = None 211 | 212 | # For "manual" documents, if this is true, then toplevel headings are parts, 213 | # not chapters. 214 | #latex_use_parts = False 215 | 216 | # If true, show page references after internal links. 217 | #latex_show_pagerefs = False 218 | 219 | # If true, show URL addresses after external links. 220 | #latex_show_urls = False 221 | 222 | # Documents to append as an appendix to all manuals. 223 | #latex_appendices = [] 224 | 225 | # If false, no module index is generated. 226 | #latex_domain_indices = True 227 | 228 | 229 | # -- Options for manual page output -------------------------------------------- 230 | 231 | # One entry per manual page. List of tuples 232 | # (source start file, name, description, authors, manual section). 233 | man_pages = [ 234 | ('index', project, project + u' Documentation', [meta["author"]], 1) 235 | ] 236 | 237 | # If true, show URL addresses after external links. 238 | #man_show_urls = False 239 | 240 | 241 | # -- Options for Texinfo output ------------------------------------------------ 242 | 243 | # Grouping the document tree into Texinfo files. List of tuples 244 | # (source start file, target name, title, author, 245 | # dir menu entry, description, category) 246 | texinfo_documents = [ 247 | ('index', project, project + u' Documentation', meta["author"], project, meta["description"], 'Miscellaneous'), 248 | ] 249 | 250 | # Documents to append as an appendix to all manuals. 251 | #texinfo_appendices = [] 252 | 253 | # If false, no module index is generated. 254 | #texinfo_domain_indices = True 255 | 256 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 257 | #texinfo_show_urls = 'footnote' 258 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # /bin/bash - help gedit syntax highlight do the right thing 2 | # autoenv script (https://github.com/kennethreitz/autoenv) 3 | # Use ". .env -h" for help. 4 | # 5 | # This script is an autoenev script that either activates or creates 6 | # a local virtualenv in '.venv' for (almost) any project. Typical 7 | # project assets like 'setup.py' and requirements files are used to 8 | # populate the virtualenv. 9 | # 10 | # This script is tested with bash, report problems with other shells to 11 | # 12 | # https://github.com/Springerle/py-generic-project/issues 13 | # 14 | # It should work on any Linux, and Mac OSX. 15 | # 16 | _venv_virtualenv="/usr/bin/python3 -m venv" 17 | _venv_py3=true 18 | _venv_pip_req='pip>20' 19 | _venv_bin=bin 20 | _venv_master_url="https://raw.githubusercontent.com/Springerle/py-generic-project/master/.env" 21 | : ${USER:=$USERNAME} 22 | 23 | #_venv_temp=/usr/local/bin/virtualenv 24 | #if test -x $_venv_temp; then 25 | # _venv_virtualenv=$_venv_temp 26 | #fi 27 | test "${_venv_py3:0:2}" != '{''{' || _venv_py3=true 28 | if test -n "$SYSTEMROOT" -a -n "$WINDIR"; then 29 | _venv_virtualenv=$(cygpath -au "$(py -3 -c 'import sys; print(sys.executable)')")" -m venv" 30 | _venv_bin=Scripts 31 | elif $_venv_py3; then # Python 3 mode? 32 | # /opt/pyenv, see https://github.com/jhermann/priscilla/tree/master/pyenv 33 | _venv_temp=/opt/pyenv/bin/python3 34 | if test -x $_venv_temp; then 35 | _venv_virtualenv="$_venv_temp -m venv" 36 | fi 37 | # python3 venv in recent Debian / Ubuntu 38 | # prefer a version that has Tkinter installed, if available 39 | _venv_py_tk=$(dpkg-query -W -f '${Version}\n' 'python3*-tk' 2>/dev/null | sort -rV | head -n1 | cut -f-2 -d.) 40 | for _venv_temp in $_venv_py_tk 3.9 3.8 3.7 3.6 3.5 3; do 41 | _venv_temp=/usr/bin/python$_venv_temp 42 | if test -x $_venv_temp; then 43 | _venv_virtualenv="$_venv_temp -m venv" 44 | break 45 | fi 46 | done 47 | unset _venv_py_tk 48 | fi 49 | 50 | _venv_readlink=readlink 51 | case "$(uname -s)" in 52 | # See http://en.wikipedia.org/wiki/Uname#Examples 53 | Darwin) 54 | _venv_temp="/usr/local/opt/coreutils/libexec/gnubin/readlink" 55 | if test -x $_venv_temp; then 56 | _venv_readlink=$_venv_temp 57 | else 58 | echo "*** No readlink command, do a 'brew install coreutils'..." 59 | return 1 60 | fi 61 | ;; 62 | CYGWIN*) 63 | ;; 64 | Linux|*) 65 | ;; 66 | esac 67 | 68 | test -z "$JENKINS_URL" || echo '***' "\$0=$0" "\$BASH_SOURCE=${BASH_SOURCE[0]}" 69 | if test "$0" = "-bash" -o "$(basename -- "$0")" = "bash" -o "$(basename -- "${BASH_SOURCE[0]}")" = ".env"; then 70 | _venv_script=$($_venv_readlink -f ${BASH_SOURCE[0]}) 71 | elif test "$(basename -- "$0")" = ".env"; then 72 | _venv_script=$($_venv_readlink -f "$0") 73 | fi 74 | _venv_script="${_venv_script:-/dont-know-really/.env}" 75 | _venv_xtrace=$(set +o | grep xtrace) 76 | set +x 77 | _venv_name="$(basename $(pwd))" 78 | 79 | _venv_pip_log() { 80 | if $_venv_verbose; then 81 | cat 82 | else 83 | egrep "Found.existing.installation|Collecting|Installing.collected.packages|Searching.for|Installed|Finished" 84 | fi 85 | } 86 | 87 | # command line flags, mostly for CI server usage 88 | _venv_create=false 89 | _venv_develop=false 90 | _venv_verbose=false 91 | while test "${1:0:1}" = "-"; do 92 | case "$1" in 93 | --yes) _venv_create=true ;; 94 | --develop) _venv_develop=true ;; 95 | --verbose | -v) _venv_verbose=true; set -x ;; 96 | --virtualenv) shift; _venv_virtualenv="$1" ;; 97 | --help | -h) 98 | # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 99 | echo "usage: . $_venv_script " 100 | echo 101 | echo "Create and/or activate a Python virtual environment. Updates pip and other" 102 | echo "system packages, and recognizes 'dev-requirements.txt' and 'setup.py' or" 103 | echo "'requirements.txt'." 104 | echo 105 | echo "This is an autoenv script (see https://github.com/kennethreitz/autoenv)." 106 | echo 107 | echo "Options:" 108 | echo " --yes create missing venv without prompting" 109 | echo " --develop call 'develop -U' on activation" 110 | echo " -v | --verbose don't filter install logs on terminal" 111 | echo " (full logs in '.venv/install-*.log')" 112 | echo " -h | --help this help message" 113 | echo " --virtualenv PATH set explicit path of virtualenv binary to use" 114 | echo " --update update to the newest master copy" 115 | return 1 116 | ;; 117 | --update) 118 | ( cd /tmp && $(command which python3 py 2>/dev/null | head -n1) </dev/null || return 1 128 | sed -r -e "s/^_venv_py3=.*/_venv_py3=$_venv_py3/" <"/tmp/$USER-py-env-update.py" >.env 129 | git diff .env || : 130 | echo ".env script update OK" 131 | return 0 132 | ;; 133 | *) echo "WARNING: Ignored unknown option '$1'" ;; 134 | esac 135 | shift 136 | done 137 | 138 | # Outside the tree of the .env script? 139 | if pwd -P | egrep -v '^'$(dirname "$_venv_script")'($|/)' >/dev/null; then 140 | : echo Outside "[$0 $1 ; $_venv_script]" 141 | 142 | # Inside the tree of the .env script, but have another local '.env'? 143 | elif test \! -f .env -o "$_venv_script" != "$(pwd -P)/.env"; then 144 | : echo Inside "[$0 $1 ; $_venv_script]" 145 | 146 | # Only try virtualenv creation outside of template dirs; the egrep pattern is escaped for hiding it from Jinja2 147 | elif pwd -P | egrep -v '/{''{''.*''}''}(/|$)' >/dev/null || $_venv_create; then 148 | test -f ".env" && _venv_ask=true || _venv_ask=false 149 | 150 | # Look for existing venv at common locations 151 | for _venv_base in .venv/$_venv_name .venv venv/$_venv_name venv . ..; do 152 | if test -f "$_venv_base/$_venv_bin/activate"; then 153 | deactivate 2>/dev/null || : 154 | . "$_venv_base/$_venv_bin/activate" 155 | if test -f setup.py; then 156 | $_venv_develop && $_venv_base/$_venv_bin/python setup.py -q develop -U || : 157 | $_venv_base/$_venv_bin/python setup.py --name --version --url | tr -d \\r \ 158 | | xargs -d$'\n' printf "*** Activated %s %s @ %s\\n" || : 159 | else 160 | echo "*** Activated $_venv_base" 161 | fi 162 | _venv_ask=false 163 | break 164 | fi 165 | done 166 | 167 | if $_venv_ask && test \! -d .venv; then 168 | $_venv_create || { read -p "No virtualenv found, shall I create one for you? [Y/n] " -n 1 -r || REPLY='n'; echo; } 169 | if $_venv_create || echo "$REPLY" | egrep '^[Yy]?$' >/dev/null; then 170 | # Create, activate, and update virtualenv 171 | echo "Calling: $_venv_virtualenv --prompt $_venv_name .venv/" 172 | eval $_venv_virtualenv "--prompt $_venv_name .venv/" 173 | . ".venv/$_venv_bin/activate" 174 | ".venv/$_venv_bin/python" -m pip --log ".venv/install-pip.log" install -U "$_venv_pip_req" 2>&1 | _venv_pip_log \ 175 | || echo >&2 "!!! WARN: pip -U pip failed (RC=$?), see .venv/install-pip.log for details" 176 | ".venv/$_venv_bin/python" -m pip --log ".venv/install-tools.log" install -U "setuptools>40" "wheel>0.30" 2>&1 | _venv_pip_log \ 177 | || echo >&2 "!!! WARN: pip -U setuptools wheel failed (RC=$?), see .venv/install-tools.log for details" 178 | 179 | # Get rid of cruft some older systems produce 180 | ".venv/$_venv_bin/python" -m pip --log ".venv/uninstall-distribute.log" uninstall --yes distribute >/dev/null 2>&1 || : 181 | 182 | # pypandoc fails when the base package is missing, so we install it here, if possible 183 | if which pandoc >/dev/null 2>&1 ; then 184 | ".venv/$_venv_bin/python" -m pip --log ".venv/install-pandoc.log" install pypandoc 2>&1 | _venv_pip_log \ 185 | || echo >&2 "!!! WARN: pip install pypandoc failed (RC=$?), see .venv/install-pandoc.log for details" 186 | fi 187 | 188 | # Install development + project dependencies 189 | if test -f dev-requirements.txt; then 190 | ".venv/$_venv_bin/python" -m pip --log ".venv/install-dev.log" install -r dev-requirements.txt 2>&1 | _venv_pip_log \ 191 | || echo >&2 "!!! WARN: pip install dev-requirements failed (RC=$?), see .venv/pip-install-dev.log for details" 192 | fi 193 | if test -f setup.py; then 194 | if grep "^# mkvenv: no-deps" "setup.py" >/dev/null; then 195 | echo "*** Not installing setup.py packages as requested" 196 | else 197 | ".venv/$_venv_bin/python" setup.py develop -U 2>&1 | _venv_pip_log 198 | fi 199 | echo 200 | ".venv/$_venv_bin/python" setup.py --name --version --author --author-email --license --description --url \ 201 | | tr -d \\r | xargs -d$'\n' printf "%s %s by %s <%s> [%s]\\n%s\\n%s\\n" || : 202 | else 203 | echo 204 | if test -f requirements.txt; then 205 | echo "*** No 'setup.py' found, installing requirements..." 206 | ".venv/$_venv_bin/python" -m pip --log ".venv/pip-install-req.log" install -r requirements.txt 2>&1 | _venv_pip_log 207 | else 208 | echo "*** No 'setup.py' or 'requirements.txt' found, all done." 209 | fi 210 | fi 211 | else 212 | # prevent constant prompting 213 | mkdir -p .venv 214 | fi 215 | fi 216 | if ! grep .venv/Scripts/activate .venv/Scripts/activate >/dev/null && test -n "$SYSTEMROOT" -a -n "$WINDIR"; then 217 | echo >>.venv/Scripts/activate \ 218 | 'export PATH=$(echo $PATH | sed -r -e '\''s~[\\]~/~g'\'' -e s~C:~/c~); hash -r' 219 | . .venv/Scripts/activate 220 | fi 221 | fi 222 | 223 | unset _venv_script _venv_bin _venv_name _venv_ask _venv_create _venv_develop _venv_pip_log _venv_base _venv_py_tk 224 | unset _venv_readlink _venv_temp _venv_virtualenv _venv_verbose _venv_py3 _venv_pip_req _venv_master_url 225 | eval $_venv_xtrace # restore xtrace state 226 | unset _venv_xtrace 227 | -------------------------------------------------------------------------------- /src/confluencer/tools/content.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=bad-continuation 3 | """ Tools to discover and modify content. 4 | """ 5 | # Copyright © 2015 1&1 Group 6 | # 7 | # Licensed under the Apache License, Version 2.0 (the "License"); 8 | # you may not use this file except in compliance with the License. 9 | # You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, software 14 | # distributed under the License is distributed on an "AS IS" BASIS, 15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | # See the License for the specific language governing permissions and 17 | # limitations under the License. 18 | from __future__ import absolute_import, unicode_literals, print_function 19 | 20 | import re 21 | import difflib 22 | try: 23 | import html.entities as htmlentitydefs 24 | except ImportError: # Python 2 25 | import htmlentitydefs # pylint: disable=import-error,wrong-import-order 26 | from xml.sax.saxutils import quoteattr # pylint: disable=wrong-import-order 27 | 28 | import arrow 29 | from munch import munchify as bunchify 30 | from lxml.etree import fromstring, HTMLParser, XMLParser, XMLSyntaxError # pylint: disable=no-name-in-module 31 | from rudiments.reamed import click 32 | 33 | from .._compat import BytesIO 34 | 35 | 36 | # Mapping of CLI content format names to Confluence API names 37 | CLI_CONTENT_FORMATS = dict(view='view', editor='editor', storage='storage', export='export_view', anon='anonymous_export_view') 38 | 39 | # Simple replacement rules, order is important! 40 | TIDY_REGEX_RULES = ((_name, re.compile(_rule), _subst) for _name, _rule, _subst in [ 41 | ("FosWiki: Remove CSS class from section title", 42 | r'<(h[1-5]) class="[^"]*">', r'<\1>'), 43 | ("FosWiki: Remove static section numbering", 44 | r'(?<=)(|)[0-9.]+?\s*(?= )', r'\1'), 45 | ("FosWiki: Empty anchor in headers", 46 | r'(?<=)\s* +', ''), 47 | ("FosWiki: 'tok' spans in front of headers", 48 | r'(?<=)(|)\s* ', r'\1'), 49 | ("FosWiki: Section edit icons at the end of headers", 50 | r'\s*' 51 | r']+>' 52 | r'(?=)(|)\s*' 55 | r'\s*([^<]+)(?:)\s*(?=)', r'\1\2'), 56 | ("FosWiki: 'Edit Chapter Plugin' spans (new)", 57 | r'(?<=)(|)\s*' 58 | r'\s*([^<]+)(?:)\s*(?=)', r'\1\2'), 59 | ("FosWiki: Residual leading whitespace in headers", 60 | r'(?<=)(|)\s* +', r'\1'), 61 | ("FosWiki: Replace TOC div with macro", 62 | r'()?
.*?
', ''' 63 | 64 | Contents 65 | 66 |

67 | 68 |

69 |
70 |
'''), 71 | ("FosWiki: Replace TOC in a Twisty with Expand+TOC macro", 72 | r'
', ''' 73 | 74 | Table of Contents 75 | 76 |

77 | 78 |

79 |
80 |
'''), 81 | ("FosWiki: Named anchors (#WikiWords)", 82 | r'(]+>[^<]+)', 83 | r'\1\2; float: right;\3'), 84 | ("FosWiki: Wrap HTML '
' into 'panel' macro",
 85 |      r'(?)()',
 86 |      r''
 87 |      r'#eeeeee'
 88 |      r''
 89 |      r'\1'),
 90 |     ("FosWiki: Wrap HTML '
' into 'panel' macro", 91 | r'(?!)', ''), 92 | ("FosWiki: Embedded CSS - custom list indent", 93 | r'
    ', '
      '), 94 | ("FosWiki: Empty paragraphs", 95 | r'

       

      ', r''), 96 | ("FosWiki: Obsolete CSS classes", 97 | r'(<(?:div|p|span|h[1-5])) class="(foswikiTopic)"', r'\1'), 98 | ]) 99 | 100 | 101 | def _apply_tidy_regex_rules(body, log=None): 102 | """Return tidied body after applying regex rules.""" 103 | body = body.replace(u'\u00A0', ' ') 104 | for name, rule, subst in TIDY_REGEX_RULES: 105 | length = len(body) 106 | try: 107 | body, count = rule.subn(subst, body) 108 | except re.error as cause: 109 | raise click.LoggedFailure('Error "{}" in "{}" replacement: {} => {}'.format( 110 | cause, name, rule.pattern, subst, 111 | )) 112 | if count and log: 113 | length -= len(body) 114 | log.info('Replaced %d matche(s) of "%s" (%d chars %s)', 115 | count, name, abs(length), "added" if length < 0 else "removed") 116 | return body 117 | 118 | 119 | def _make_etree(body, content_format='storage', attrs=None): 120 | """Create an ElementTree from a page's body.""" 121 | attrs = (attrs or {}).copy() 122 | attrs.update({ 123 | 'xmlns:ac': 'http://www.atlassian.com/schema/confluence/4/ac/', 124 | 'xmlns:ri': 'http://www.atlassian.com/schema/confluence/4/ri/', 125 | }) 126 | xml_body = re.sub(r'&(?!(amp|lt|gt|quot|apos))([a-zA-Z0-9]+);', 127 | lambda cref: '&#{};'.format(htmlentitydefs.name2codepoint[cref.group(2)]), body) 128 | #print(body.encode('utf8')) 129 | xmldoc = u'<{root} {attrs}>{body}'.format( 130 | root=content_format, 131 | attrs=' '.join('{}={}'.format(k, quoteattr(v)) for k, v in sorted(attrs.items())), 132 | body=xml_body) 133 | 134 | parser = (XMLParser if content_format == 'storage' else HTMLParser)(remove_blank_text=True) 135 | try: 136 | return fromstring(xmldoc, parser) 137 | except XMLSyntaxError as cause: 138 | raise click.LoggedFailure('{}\n{}'.format( 139 | cause, '\n'.join(['{:7d} {}'.format(i+1, k) for i, k in enumerate(xmldoc.splitlines())]) 140 | )) 141 | 142 | 143 | def _pretty_xml(body, content_format='storage', attrs=None): 144 | """Pretty-print the given page body and return a list of lines.""" 145 | root = _make_etree(body, content_format=content_format, attrs=attrs) 146 | prettyfied = BytesIO() 147 | root.getroottree().write(prettyfied, encoding='utf8', pretty_print=True, xml_declaration=False) 148 | return prettyfied.getvalue().decode('utf8').splitlines() 149 | 150 | 151 | class ConfluencePage(object): 152 | """A page that holds enough state so it can be modified.""" 153 | 154 | DIFF_COLS = { 155 | '+': 'green', 156 | '-': 'red', 157 | '@': 'yellow', 158 | } 159 | 160 | def __init__(self, cf, url, markup='storage', expand=None): 161 | """ Load the given page. 162 | """ 163 | if expand and isinstance(expand, str): 164 | expand = expand.split(',') 165 | expand = set(expand or []) | {'space', 'version', 'body.' + markup} 166 | 167 | self.cf = cf 168 | self.url = url 169 | self.markup = markup 170 | self._data = cf.get(self.url, expand=','.join(expand)) 171 | self.body = self._data.body[self.markup].value 172 | 173 | @property 174 | def page_id(self): 175 | """The numeric page ID.""" 176 | return self._data.id 177 | 178 | @property 179 | def space_key(self): 180 | """The space this page belongs to.""" 181 | return self._data.space.key 182 | 183 | @property 184 | def title(self): 185 | """The page's title.""" 186 | return self._data.title 187 | 188 | @property 189 | def json(self): 190 | """The full JSON response data.""" 191 | return self._data 192 | 193 | @property 194 | def version(self): 195 | """The page's version number in history.""" 196 | return self._data.version.number 197 | 198 | def etree(self): 199 | """Parse the page's body into an ElementTree.""" 200 | attrs = { 201 | 'id': 'page-' + self._data.id, 202 | 'href': self._data._links.base + (self._data._links.tinyui or ''), 203 | 'status': self._data.status, 204 | 'title': self._data.title, 205 | } 206 | return _make_etree(self.body, content_format=self.markup, attrs=attrs) 207 | 208 | def tidy(self, log=None): 209 | """Return a tidy copy of this page's body.""" 210 | assert self.markup == 'storage', "Can only clean up pages in storage format!" 211 | return _apply_tidy_regex_rules(self.body, log=log) 212 | 213 | def update(self, body=None, minor=True): 214 | """Update a page's content.""" 215 | assert self.markup == 'storage', "Cannot update non-storage page markup!" 216 | if body is None: 217 | body = self.body 218 | if body == self._data.body[self.markup].value: 219 | return # No changes 220 | 221 | data = { 222 | #'id': self._data.id, 223 | 'type': 'page', 224 | 'space': {'key': self.space_key}, 225 | 'title': self.title, 226 | 'version': dict(number=self.version + 1, minorEdit=minor), 227 | 'body': { 228 | 'storage': { 229 | 'value': body, 230 | 'representation': self.markup, 231 | } 232 | }, 233 | 'expand': 'version', 234 | } 235 | response = self.cf.session.put(self._data._links.self, json=data) 236 | response.raise_for_status() 237 | ##page = response.json(); print(page) 238 | result = bunchify(response.json()) 239 | self._data.body[self.markup].value = body 240 | self._data.version = result.version 241 | return result 242 | 243 | 244 | def dump_diff(self, changed): 245 | """Dump a diff to terminal between changed and stored body.""" 246 | if self.body == changed: 247 | click.secho('=== No changes to "{0}"'.format(self.title), fg='green') 248 | return 249 | 250 | diff = difflib.unified_diff( 251 | _pretty_xml(self.body, self.markup), 252 | _pretty_xml(changed, self.markup), 253 | u'v. {0} of "{1}"'.format(self.version, self.title), 254 | u'v. {0} of "{1}"'.format(self.version + 1, self.title), 255 | arrow.get(self._data.version.when).replace(microsecond=0).isoformat(sep=' '), 256 | arrow.now().replace(microsecond=0).isoformat(sep=' '), 257 | lineterm='', n=2) 258 | for line in diff: 259 | click.secho(line, fg=self.DIFF_COLS.get(line and line[0], None)) 260 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /project.d/pylint.cfg: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore=CVS 13 | 14 | # Pickle collected data for later comparisons. 15 | persistent=yes 16 | 17 | # List of plugins (as comma separated values of python modules names) to load, 18 | # usually to register additional checkers. 19 | load-plugins= 20 | 21 | # Use multiple processes to speed up Pylint. 22 | jobs=1 23 | 24 | # Allow loading of arbitrary C extensions. Extensions are imported into the 25 | # active Python interpreter and may run arbitrary code. 26 | unsafe-load-any-extension=no 27 | 28 | # A comma-separated list of package or module names from where C extensions may 29 | # be loaded. Extensions are loading into the active Python interpreter and may 30 | # run arbitrary code 31 | extension-pkg-whitelist= 32 | 33 | 34 | [MESSAGES CONTROL] 35 | 36 | # Only show warnings with the listed confidence levels. Leave empty to show 37 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 38 | confidence= 39 | 40 | # Enable the message, report, category or checker with the given id(s). You can 41 | # either give multiple identifier separated by comma (,) or put this option 42 | # multiple time. See also the "--disable" option for examples. 43 | #enable= 44 | 45 | # Disable the message, report, category or checker with the given id(s). You 46 | # can either give multiple identifiers separated by comma (,) or put this 47 | # option multiple times (only on the command line, not in the configuration 48 | # file where it should appear only once).You can also use "--disable=all" to 49 | # disable everything first and then reenable specific checks. For example, if 50 | # you want to run only the similarities checker, you can use "--disable=all 51 | # --enable=similarities". If you want to run only the classes checker, but have 52 | # no Warning level messages displayed, use"--disable=all --enable=classes 53 | # --disable=W" 54 | disable=locally-disabled, star-args, misplaced-comparison-constant, useless-object-inheritance, 55 | inconsistent-return-statements 56 | 57 | [REPORTS] 58 | 59 | # Set the output format. Available formats are text, parseable, colorized, msvs 60 | # (visual studio) and html. You can also give a reporter class, eg 61 | # mypackage.mymodule.MyReporterClass. 62 | output-format=text 63 | 64 | # Put messages in a separate file for each module / package specified on the 65 | # command line instead of printing them on stdout. Reports (if any) will be 66 | # written in a file name "pylint_global.[txt|html]". 67 | files-output=no 68 | 69 | # Tells whether to display a full report or only the messages 70 | reports=no 71 | 72 | # Python expression which should return a note less than 10 (10 is the highest 73 | # note). You have access to the variables errors warning, statement which 74 | # respectively contain the number of errors / warnings messages and the total 75 | # number of statements analyzed. This is used by the global evaluation report 76 | # (RP0004). 77 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 78 | 79 | # Template used to display messages. This is a python new-style format string 80 | # used to format the message information. See doc for all details 81 | #msg-template= 82 | 83 | 84 | [TYPECHECK] 85 | 86 | # Tells whether missing members accessed in mixin class should be ignored. A 87 | # mixin class is detected if its name ends with "mixin" (case insensitive). 88 | ignore-mixin-members=yes 89 | 90 | # List of module names for which member attributes should not be checked 91 | # (useful for modules/projects where namespaces are manipulated during runtime 92 | # and thus existing member attributes cannot be deduced by static analysis 93 | ignored-modules= 94 | 95 | # List of classes names for which member attributes should not be checked 96 | # (useful for classes with attributes dynamically set). 97 | ignored-classes=SQLObject 98 | 99 | # List of members which are set dynamically and missed by pylint inference 100 | # system, and so shouldn't trigger E0201 when accessed. Python regular 101 | # expressions are accepted. 102 | generated-members=REQUEST,acl_users,aq_parent 103 | 104 | 105 | [LOGGING] 106 | 107 | # Logging modules to check that the string format arguments are in logging 108 | # function parameter format 109 | logging-modules=logging 110 | 111 | 112 | [SIMILARITIES] 113 | 114 | # Minimum lines number of a similarity. 115 | min-similarity-lines=4 116 | 117 | # Ignore comments when computing similarities. 118 | ignore-comments=yes 119 | 120 | # Ignore docstrings when computing similarities. 121 | ignore-docstrings=yes 122 | 123 | # Ignore imports when computing similarities. 124 | ignore-imports=no 125 | 126 | 127 | [BASIC] 128 | 129 | # List of builtins function names that should not be used, separated by a comma 130 | bad-functions=map,filter,input,apply 131 | 132 | # Good variable names which should always be accepted, separated by a comma 133 | good-names=i,j,k,ex,Run,_,kw,v,cf 134 | 135 | # Bad variable names which should always be refused, separated by a comma 136 | bad-names=foo,bar,baz,toto,tutu,tata 137 | 138 | # Colon-delimited sets of names that determine each other's naming style when 139 | # the name regexes allow several styles. 140 | name-group= 141 | 142 | # Include a hint for the correct naming format with invalid-name 143 | include-naming-hint=no 144 | 145 | # Regular expression matching correct function names 146 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 147 | 148 | # Naming hint for function names 149 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 150 | 151 | # Regular expression matching correct variable names 152 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 153 | 154 | # Naming hint for variable names 155 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 156 | 157 | # Regular expression matching correct constant names 158 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))|log|app|manager$ 159 | 160 | # Naming hint for constant names 161 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 162 | 163 | # Regular expression matching correct attribute names 164 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 165 | 166 | # Naming hint for attribute names 167 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 168 | 169 | # Regular expression matching correct argument names 170 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 171 | 172 | # Naming hint for argument names 173 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 174 | 175 | # Regular expression matching correct class attribute names 176 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 177 | 178 | # Naming hint for class attribute names 179 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 180 | 181 | # Regular expression matching correct inline iteration names 182 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 183 | 184 | # Naming hint for inline iteration names 185 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 186 | 187 | # Regular expression matching correct class names 188 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 189 | 190 | # Naming hint for class names 191 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 192 | 193 | # Regular expression matching correct module names 194 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 195 | 196 | # Naming hint for module names 197 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 198 | 199 | # Regular expression matching correct method names 200 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 201 | 202 | # Naming hint for method names 203 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 204 | 205 | # Regular expression which should only match function or class names that do 206 | # not require a docstring. 207 | no-docstring-rgx=__.*__ 208 | 209 | # Minimum line length for functions/classes that require docstrings, shorter 210 | # ones are exempt. 211 | docstring-min-length=-1 212 | 213 | 214 | [FORMAT] 215 | 216 | # Maximum number of characters on a single line. 217 | max-line-length=132 218 | 219 | # Regexp for a line that is allowed to be longer than the limit. 220 | ignore-long-lines=^\s*(# )??$ 221 | 222 | # Allow the body of an if to be on the same line as the test if there is no 223 | # else. 224 | single-line-if-stmt=no 225 | 226 | # List of optional constructs for which whitespace checking is disabled 227 | no-space-check=trailing-comma,dict-separator 228 | 229 | # Maximum number of lines in a module 230 | max-module-lines=1500 231 | 232 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 233 | # tab). 234 | indent-string=' ' 235 | 236 | # Number of spaces of indent required inside a hanging or continued line. 237 | indent-after-paren=4 238 | 239 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 240 | expected-line-ending-format= 241 | 242 | 243 | [SPELLING] 244 | 245 | # Spelling dictionary name. Available dictionaries: none. To make it working 246 | # install python-enchant package. 247 | spelling-dict= 248 | 249 | # List of comma separated words that should not be checked. 250 | spelling-ignore-words= 251 | 252 | # A path to a file that contains private dictionary; one word per line. 253 | spelling-private-dict-file= 254 | 255 | # Tells whether to store unknown words to indicated private dictionary in 256 | # --spelling-private-dict-file option instead of raising a message. 257 | spelling-store-unknown-words=no 258 | 259 | 260 | [VARIABLES] 261 | 262 | # Tells whether we should check for unused import in __init__ files. 263 | init-import=no 264 | 265 | # A regular expression matching the name of dummy variables (i.e. expectedly 266 | # not used). 267 | dummy-variables-rgx=_$|^_?dummy|^_dummy_.* 268 | 269 | # List of additional names supposed to be defined in builtins. Remember that 270 | # you should avoid to define new builtins when possible. 271 | additional-builtins= 272 | 273 | # List of strings which can identify a callback function by name. A callback 274 | # name must start or end with one of those strings. 275 | callbacks=cb_,_cb 276 | 277 | 278 | [MISCELLANEOUS] 279 | 280 | # List of note tags to take in consideration, separated by a comma. 281 | notes=FIXME,XXX,TODO 282 | 283 | 284 | [DESIGN] 285 | 286 | # Maximum number of arguments for function / method 287 | max-args=8 288 | 289 | # Argument names that match this expression will be ignored. Default to name 290 | # with leading underscore 291 | ignored-argument-names=_.* 292 | 293 | # Maximum number of locals for function / method body 294 | max-locals=15 295 | 296 | # Maximum number of return / yield for function / method body 297 | max-returns=6 298 | 299 | # Maximum number of branch for function / method body 300 | max-branches=12 301 | 302 | # Maximum number of statements in function / method body 303 | max-statements=50 304 | 305 | # Maximum number of parents for a class (see R0901). 306 | max-parents=7 307 | 308 | # Maximum number of attributes for a class (see R0902). 309 | max-attributes=7 310 | 311 | # Minimum number of public methods for a class (see R0903). 312 | min-public-methods=2 313 | 314 | # Maximum number of public methods for a class (see R0904). 315 | max-public-methods=20 316 | 317 | 318 | [CLASSES] 319 | 320 | # List of method names used to declare (i.e. assign) instance attributes. 321 | defining-attr-methods=__init__,__new__,setUp,init 322 | 323 | # List of valid names for the first argument in a class method. 324 | valid-classmethod-first-arg=cls 325 | 326 | # List of valid names for the first argument in a metaclass class method. 327 | valid-metaclass-classmethod-first-arg=mcs 328 | 329 | # List of member names, which should be excluded from the protected access 330 | # warning. 331 | exclude-protected=_asdict,_fields,_replace,_source,_make,_links 332 | 333 | 334 | [IMPORTS] 335 | 336 | # Deprecated modules which should not be used, separated by a comma 337 | deprecated-modules=regsub,TERMIOS,Bastion,rexec,string 338 | 339 | # Create a graph of every (i.e. internal and external) dependencies in the 340 | # given file (report RP0402 must not be disabled) 341 | import-graph= 342 | 343 | # Create a graph of external dependencies in the given file (report RP0402 must 344 | # not be disabled) 345 | ext-import-graph= 346 | 347 | # Create a graph of internal dependencies in the given file (report RP0402 must 348 | # not be disabled) 349 | int-import-graph= 350 | 351 | 352 | [EXCEPTIONS] 353 | 354 | # Exceptions that will emit a warning when being caught. Defaults to 355 | # "Exception" 356 | overgeneral-exceptions=Exception 357 | -------------------------------------------------------------------------------- /docs/LICENSE.rst: -------------------------------------------------------------------------------- 1 | Software License 2 | ================ 3 | 4 | Copyright © 2015 1&1 Group 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | 18 | Full License Text 19 | ----------------- 20 | 21 | :: 22 | 23 | Apache License 24 | Version 2.0, January 2004 25 | http://www.apache.org/licenses/ 26 | 27 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 28 | 29 | 1. Definitions. 30 | 31 | "License" shall mean the terms and conditions for use, reproduction, 32 | and distribution as defined by Sections 1 through 9 of this document. 33 | 34 | "Licensor" shall mean the copyright owner or entity authorized by 35 | the copyright owner that is granting the License. 36 | 37 | "Legal Entity" shall mean the union of the acting entity and all 38 | other entities that control, are controlled by, or are under common 39 | control with that entity. For the purposes of this definition, 40 | "control" means (i) the power, direct or indirect, to cause the 41 | direction or management of such entity, whether by contract or 42 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 43 | outstanding shares, or (iii) beneficial ownership of such entity. 44 | 45 | "You" (or "Your") shall mean an individual or Legal Entity 46 | exercising permissions granted by this License. 47 | 48 | "Source" form shall mean the preferred form for making modifications, 49 | including but not limited to software source code, documentation 50 | source, and configuration files. 51 | 52 | "Object" form shall mean any form resulting from mechanical 53 | transformation or translation of a Source form, including but 54 | not limited to compiled object code, generated documentation, 55 | and conversions to other media types. 56 | 57 | "Work" shall mean the work of authorship, whether in Source or 58 | Object form, made available under the License, as indicated by a 59 | copyright notice that is included in or attached to the work 60 | (an example is provided in the Appendix below). 61 | 62 | "Derivative Works" shall mean any work, whether in Source or Object 63 | form, that is based on (or derived from) the Work and for which the 64 | editorial revisions, annotations, elaborations, or other modifications 65 | represent, as a whole, an original work of authorship. For the purposes 66 | of this License, Derivative Works shall not include works that remain 67 | separable from, or merely link (or bind by name) to the interfaces of, 68 | the Work and Derivative Works thereof. 69 | 70 | "Contribution" shall mean any work of authorship, including 71 | the original version of the Work and any modifications or additions 72 | to that Work or Derivative Works thereof, that is intentionally 73 | submitted to Licensor for inclusion in the Work by the copyright owner 74 | or by an individual or Legal Entity authorized to submit on behalf of 75 | the copyright owner. For the purposes of this definition, "submitted" 76 | means any form of electronic, verbal, or written communication sent 77 | to the Licensor or its representatives, including but not limited to 78 | communication on electronic mailing lists, source code control systems, 79 | and issue tracking systems that are managed by, or on behalf of, the 80 | Licensor for the purpose of discussing and improving the Work, but 81 | excluding communication that is conspicuously marked or otherwise 82 | designated in writing by the copyright owner as "Not a Contribution." 83 | 84 | "Contributor" shall mean Licensor and any individual or Legal Entity 85 | on behalf of whom a Contribution has been received by Licensor and 86 | subsequently incorporated within the Work. 87 | 88 | 2. Grant of Copyright License. Subject to the terms and conditions of 89 | this License, each Contributor hereby grants to You a perpetual, 90 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 91 | copyright license to reproduce, prepare Derivative Works of, 92 | publicly display, publicly perform, sublicense, and distribute the 93 | Work and such Derivative Works in Source or Object form. 94 | 95 | 3. Grant of Patent License. Subject to the terms and conditions of 96 | this License, each Contributor hereby grants to You a perpetual, 97 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 98 | (except as stated in this section) patent license to make, have made, 99 | use, offer to sell, sell, import, and otherwise transfer the Work, 100 | where such license applies only to those patent claims licensable 101 | by such Contributor that are necessarily infringed by their 102 | Contribution(s) alone or by combination of their Contribution(s) 103 | with the Work to which such Contribution(s) was submitted. If You 104 | institute patent litigation against any entity (including a 105 | cross-claim or counterclaim in a lawsuit) alleging that the Work 106 | or a Contribution incorporated within the Work constitutes direct 107 | or contributory patent infringement, then any patent licenses 108 | granted to You under this License for that Work shall terminate 109 | as of the date such litigation is filed. 110 | 111 | 4. Redistribution. You may reproduce and distribute copies of the 112 | Work or Derivative Works thereof in any medium, with or without 113 | modifications, and in Source or Object form, provided that You 114 | meet the following conditions: 115 | 116 | (a) You must give any other recipients of the Work or 117 | Derivative Works a copy of this License; and 118 | 119 | (b) You must cause any modified files to carry prominent notices 120 | stating that You changed the files; and 121 | 122 | (c) You must retain, in the Source form of any Derivative Works 123 | that You distribute, all copyright, patent, trademark, and 124 | attribution notices from the Source form of the Work, 125 | excluding those notices that do not pertain to any part of 126 | the Derivative Works; and 127 | 128 | (d) If the Work includes a "NOTICE" text file as part of its 129 | distribution, then any Derivative Works that You distribute must 130 | include a readable copy of the attribution notices contained 131 | within such NOTICE file, excluding those notices that do not 132 | pertain to any part of the Derivative Works, in at least one 133 | of the following places: within a NOTICE text file distributed 134 | as part of the Derivative Works; within the Source form or 135 | documentation, if provided along with the Derivative Works; or, 136 | within a display generated by the Derivative Works, if and 137 | wherever such third-party notices normally appear. The contents 138 | of the NOTICE file are for informational purposes only and 139 | do not modify the License. You may add Your own attribution 140 | notices within Derivative Works that You distribute, alongside 141 | or as an addendum to the NOTICE text from the Work, provided 142 | that such additional attribution notices cannot be construed 143 | as modifying the License. 144 | 145 | You may add Your own copyright statement to Your modifications and 146 | may provide additional or different license terms and conditions 147 | for use, reproduction, or distribution of Your modifications, or 148 | for any such Derivative Works as a whole, provided Your use, 149 | reproduction, and distribution of the Work otherwise complies with 150 | the conditions stated in this License. 151 | 152 | 5. Submission of Contributions. Unless You explicitly state otherwise, 153 | any Contribution intentionally submitted for inclusion in the Work 154 | by You to the Licensor shall be under the terms and conditions of 155 | this License, without any additional terms or conditions. 156 | Notwithstanding the above, nothing herein shall supersede or modify 157 | the terms of any separate license agreement you may have executed 158 | with Licensor regarding such Contributions. 159 | 160 | 6. Trademarks. This License does not grant permission to use the trade 161 | names, trademarks, service marks, or product names of the Licensor, 162 | except as required for reasonable and customary use in describing the 163 | origin of the Work and reproducing the content of the NOTICE file. 164 | 165 | 7. Disclaimer of Warranty. Unless required by applicable law or 166 | agreed to in writing, Licensor provides the Work (and each 167 | Contributor provides its Contributions) on an "AS IS" BASIS, 168 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 169 | implied, including, without limitation, any warranties or conditions 170 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 171 | PARTICULAR PURPOSE. You are solely responsible for determining the 172 | appropriateness of using or redistributing the Work and assume any 173 | risks associated with Your exercise of permissions under this License. 174 | 175 | 8. Limitation of Liability. In no event and under no legal theory, 176 | whether in tort (including negligence), contract, or otherwise, 177 | unless required by applicable law (such as deliberate and grossly 178 | negligent acts) or agreed to in writing, shall any Contributor be 179 | liable to You for damages, including any direct, indirect, special, 180 | incidental, or consequential damages of any character arising as a 181 | result of this License or out of the use or inability to use the 182 | Work (including but not limited to damages for loss of goodwill, 183 | work stoppage, computer failure or malfunction, or any and all 184 | other commercial damages or losses), even if such Contributor 185 | has been advised of the possibility of such damages. 186 | 187 | 9. Accepting Warranty or Additional Liability. While redistributing 188 | the Work or Derivative Works thereof, You may choose to offer, 189 | and charge a fee for, acceptance of support, warranty, indemnity, 190 | or other liability obligations and/or rights consistent with this 191 | License. However, in accepting such obligations, You may act only 192 | on Your own behalf and on Your sole responsibility, not on behalf 193 | of any other Contributor, and only if You agree to indemnify, 194 | defend, and hold each Contributor harmless for any liability 195 | incurred by, or claims asserted against, such Contributor by reason 196 | of your accepting any such warranty or additional liability. 197 | 198 | END OF TERMS AND CONDITIONS 199 | 200 | APPENDIX: How to apply the Apache License to your work. 201 | 202 | To apply the Apache License to your work, attach the following 203 | boilerplate notice, with the fields enclosed by brackets "{}" 204 | replaced with your own identifying information. (Don't include 205 | the brackets!) The text should be enclosed in the appropriate 206 | comment syntax for the file format. We also recommend that a 207 | file or class name and description of purpose be included on the 208 | same "printed page" as the copyright notice for easier 209 | identification within third-party archives. 210 | 211 | Copyright {yyyy} {name of copyright owner} 212 | 213 | Licensed under the Apache License, Version 2.0 (the "License"); 214 | you may not use this file except in compliance with the License. 215 | You may obtain a copy of the License at 216 | 217 | http://www.apache.org/licenses/LICENSE-2.0 218 | 219 | Unless required by applicable law or agreed to in writing, software 220 | distributed under the License is distributed on an "AS IS" BASIS, 221 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 222 | See the License for the specific language governing permissions and 223 | limitations under the License. 224 | -------------------------------------------------------------------------------- /src/confluencer/api/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # pylint: disable=bad-continuation, protected-access, no-else-return 3 | """ Confluence API support. 4 | 5 | https://developer.atlassian.com/cloud/confluence/rest/ 6 | """ 7 | # Copyright © 2015-2018 1&1 Group 8 | # 9 | # Licensed under the Apache License, Version 2.0 (the "License"); 10 | # you may not use this file except in compliance with the License. 11 | # You may obtain a copy of the License at 12 | # 13 | # http://www.apache.org/licenses/LICENSE-2.0 14 | # 15 | # Unless required by applicable law or agreed to in writing, software 16 | # distributed under the License is distributed on an "AS IS" BASIS, 17 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | # See the License for the specific language governing permissions and 19 | # limitations under the License. 20 | from __future__ import absolute_import, unicode_literals, print_function 21 | 22 | import os 23 | import re 24 | import sys 25 | import json 26 | import base64 27 | import struct 28 | import logging 29 | import collections 30 | from contextlib import contextmanager 31 | 32 | import requests 33 | import requests_cache 34 | from addict import Dict as AttrDict 35 | from rudiments.reamed import click 36 | 37 | from .. import config 38 | from .. import __version__ as version 39 | from .._compat import text_type, urlparse, urlunparse, parse_qs, urlencode, unquote_plus 40 | 41 | 42 | # Exceptions that API calls typically emit 43 | ERRORS = ( 44 | requests.RequestException, 45 | ) 46 | MAX_ERROR_LINES = 15 47 | 48 | 49 | def page_id_from_tiny_link(uri, _re=re.compile(r'/x/([-_A-Za-z0-9]+)')): 50 | """ Extract the page ID from a so-called *tiny link*. 51 | 52 | See `this answer ` 53 | for details. 54 | """ 55 | matched = _re.search(uri) 56 | if matched: 57 | tiny_url_id = matched.group(1) 58 | if isinstance(tiny_url_id, text_type): 59 | tiny_url_id = tiny_url_id.encode('ascii') 60 | #tiny_url_id += b'=' * (len(tiny_url_id) % 4) 61 | page_id_bytes = (base64.b64decode(tiny_url_id, altchars=b'_-') + b'\0\0\0\0')[:4] 62 | return struct.unpack(' MAX_ERROR_LINES: 99 | data = data[:MAX_ERROR_LINES] + ['...'] 100 | data = '| RESPONSE BODY:\n' + '\n'.join(['| ' + x for x in data]) 101 | 102 | click.serror("{} ERROR: {}".format(method, cause)) 103 | if data: 104 | click.secho(data) 105 | 106 | 107 | @contextmanager 108 | def context(*args, **kwargs): 109 | """Context manager providing an API object with standard error logging.""" 110 | api = ConfluenceAPI(*args, **kwargs) 111 | try: 112 | yield api 113 | except ERRORS as cause: 114 | api.log.error("API ERROR: %s", cause) 115 | raise 116 | 117 | 118 | class ConfluenceAPI(object): 119 | """ Support for using the Confluence API. 120 | 121 | Since the Confluence API has excellent support for discovery by 122 | e.g. the ``_links`` attribute in results, this just adds a thin 123 | convenience layer above plain ``requests`` HTTP calls. 124 | """ 125 | 126 | CACHE_EXPIRATION = 10 * 60 * 60 # seconds 127 | UA_NAME = 'Confluencer' 128 | 129 | def __init__(self, endpoint=None, session=None): 130 | self.log = logging.getLogger('cfapi') 131 | self.base_url = endpoint or os.environ.get('CONFLUENCE_BASE_URL') 132 | assert self.base_url, "You MUST set the CONFLUENCE_BASE_URL environment variable!" 133 | self.base_url = self.base_url.rstrip('/') 134 | 135 | # Enable HTTP logging when 'requests' logger is on DEBUG level 136 | if logging.getLogger("requests").getEffectiveLevel() <= logging.DEBUG: 137 | try: 138 | import http.client as http_client 139 | except ImportError: # Python 2 140 | import httplib as http_client # pylint: disable=import-error 141 | http_client.HTTPConnection.debuglevel = 1 142 | 143 | self.session = session or requests.Session() 144 | self.session.headers['User-Agent'] = '{}/{} [{}]'.format( 145 | self.UA_NAME, version, requests.utils.default_user_agent()) 146 | 147 | self.cached_session = requests_cache.CachedSession( 148 | cache_name=config.cache_file(type(self).__name__), 149 | expire_after=self.CACHE_EXPIRATION) 150 | self.cached_session.headers['User-Agent'] = self.session.headers['User-Agent'] 151 | 152 | def url(self, path): 153 | """ Build an API URL from partial paths. 154 | 155 | Parameters: 156 | path (str): Page URL / URI in various formats (tiny, title, id). 157 | 158 | Returns: 159 | str: The fully qualified API URL for the page. 160 | 161 | Raises: 162 | ValueError: A ``path`` was passed that isn't understood, or malformed. 163 | """ 164 | url = path 165 | 166 | # Fully qualify partial URLs 167 | if not url.startswith('/rest/api/') and '://' not in url: 168 | url = '/rest/api/' + url.lstrip('/') 169 | if not url.startswith('http'): 170 | url = self.base_url + url 171 | 172 | if '/rest/api/' not in url: 173 | # Parse and rewrite URLs of the following forms: 174 | # https://confluence.example.com/pages/viewpage.action?pageId=####### 175 | # https://confluence.example.com/display/SPACEKEY/Page+Title 176 | # https://confluence.example.com/x/TTTTT 177 | scheme, netloc, url_path, params, query, fragment = urlparse(url) 178 | query = parse_qs(query or '') 179 | #print((scheme, netloc, url_path, params, query, fragment)) 180 | 181 | if url_path.endswith('/pages/viewpage.action'): 182 | # Page link with ID 183 | page_id = int(query.pop('pageId', [0])[0]) 184 | if page_id: 185 | url_path = '{}/rest/api/content/{}'.format(url_path.split('/pages/')[0], page_id) 186 | else: 187 | raise ValueError("Missing 'pageId' in malformed URL '{}'".format(path)) 188 | elif 'display' in url_path.lstrip('/').split('/')[:2]: 189 | # Page link with title 190 | matched = re.search(r'/display/([^/]+)/([^/]+)', url_path) 191 | if matched: 192 | url_path = '{}/rest/api/content/search'.format(url_path.split('/display/')[0]) 193 | title = unquote_plus(matched.group(2)) 194 | search_query = dict( 195 | # CF 3.5.x ignores cqlcontext? 196 | cql='title="{}" AND space="{}"'.format( 197 | title.replace('"', '?'), matched.group(1) 198 | ), 199 | cqlcontext=json.dumps(dict(spaceKey=matched.group(1))), 200 | ) 201 | search_url = urlunparse((scheme, netloc, url_path, params, urlencode(search_query), fragment)) 202 | found = self.get(search_url) 203 | if found.size == 1: 204 | url_path, url = None, found.results[0]._links.self 205 | else: 206 | raise ValueError("{} results while searching for page with URL '{}'{}, query was:\n{}" 207 | .format('Multiple' if found.size else 'No', 208 | path, 209 | '' if found.size else ' (maybe indexing is lagging)', 210 | search_url)) 211 | else: 212 | raise ValueError("Missing '.../display/SPACE/TITLE' in malformed URL '{}'".format(path)) 213 | elif 'x' in url_path.lstrip('/').split('/')[:2]: 214 | # Tiny link 215 | page_id = page_id_from_tiny_link(url_path) 216 | url_path = '{}/rest/api/content/{}'.format(url_path.split('/x/')[0], page_id) 217 | else: 218 | raise ValueError("Cannot create API endpoint from malformed URL '{}'".format(path)) 219 | 220 | if url_path: 221 | url = urlunparse((scheme, netloc, url_path, params, urlencode(query), fragment)) 222 | 223 | return url 224 | 225 | def get(self, path, **params): 226 | """ GET an API path and return result. 227 | 228 | If ``_cached=True`` is provided, the cached session is used. 229 | """ 230 | params = params.copy() 231 | cached = params.pop('_cached', False) 232 | url = self.url(path) 233 | self.log.debug("GET from %r", url) 234 | response = (self.cached_session if cached else self.session).get(url, params=params) 235 | response.raise_for_status() 236 | result = AttrDict(response.json()) 237 | result._info.server = response.headers.get('Server', '') 238 | result._info.sen = response.headers.get('X-ASEN', '') 239 | return result 240 | 241 | def getall(self, path, **params): 242 | """ Yield all results of a paginated GET. 243 | 244 | If the ``limit`` keyword argument is set, it is used to stop the 245 | generator after the given number of result items. 246 | 247 | :param path: Confluence API URI. 248 | :param params: Request parameters. 249 | """ 250 | params = params.copy() 251 | pos, outer_limit = 0, params.pop('limit', sys.maxsize) 252 | while path: 253 | response = self.get(path, **params) 254 | #import pprint; print('\nGETALL RESPONSE'); pprint.pprint(response); print('') 255 | if 'page' in params.get('expand', '').split(','): 256 | response = response['page'] 257 | items = response.get('results', []) 258 | for item in items: 259 | pos += 1 260 | if pos > outer_limit: 261 | return 262 | yield item 263 | 264 | path = response.get('_links', {}).get('next', None) 265 | params.clear() 266 | 267 | def add_page(self, space_key, title, body, parent_id=None, labels=None): 268 | """ Create a new page. 269 | 270 | The body must be in 'storage' representation. 271 | """ 272 | data = { 273 | "type": "page", 274 | "title": title, 275 | "space": { 276 | "key": space_key, 277 | }, 278 | "body": { 279 | "storage": { 280 | "value": body, 281 | "representation": "storage", 282 | } 283 | } 284 | } 285 | if parent_id: 286 | data.update(dict(ancestors=[dict(type='page', id=parent_id)])) 287 | 288 | url = self.url('/content') 289 | self.log.debug("POST (add page) to %r", url) 290 | response = self.session.post(url, json=data) 291 | response.raise_for_status() 292 | page = AttrDict(response.json()) 293 | self.log.debug("Create '%s': %r", title, response) 294 | 295 | # Add any provided labels 296 | if labels: 297 | data = [dict(prefix='global', name=label) for label in labels] 298 | response = self.session.post(page._links.self + '/label', json=data) 299 | response.raise_for_status() 300 | self.log.debug("Labels for #'%s': %r %r", 301 | page.id, response, [i['name'] for i in response.json()['results']]) 302 | 303 | return page 304 | 305 | def update_page(self, page, body, minor_edit=True): 306 | """ Update an existing page. 307 | 308 | The page **MUST** have been retrieved using ``expand='body.storage,version,ancestors'``. 309 | """ 310 | if page.body.storage.value == body: 311 | self.log.debug("Update: Unchanged page '%s', doing nothing", page.title) 312 | else: 313 | data = { 314 | "id": page.id, 315 | "type": page.type, 316 | "title": page.title, 317 | "space": { 318 | "key": page._expandable.space.split('/')[-1], 319 | }, 320 | "body": { 321 | "storage": { 322 | "value": body, 323 | "representation": "storage", 324 | } 325 | }, 326 | "version": {"number": page.version.number + 1, "minorEdit": minor_edit}, 327 | "ancestors": [{'type': page.ancestors[-1].type, 'id': page.ancestors[-1].id}], 328 | } 329 | 330 | url = self.url('/content/{}'.format(page.id)) 331 | self.log.debug("PUT (update page) to %r", url) 332 | #import pprint; print('\nPAGE UPDATE'); pprint.pprint(data); print('') 333 | response = self.session.put(url, json=data) 334 | response.raise_for_status() 335 | page = AttrDict(response.json()) 336 | self.log.debug("Create '%s': %r", page.title, response) 337 | 338 | return page 339 | 340 | def delete_page(self, page, status=None): 341 | """ Delete an existing page. 342 | 343 | To permanently purge trashed content, pass ``status='trashed'``. 344 | """ 345 | url = self.url('/content/{}'.format(page.id)) 346 | self.log.debug("DELETE %r (status=%r)", url, status) 347 | data = {} 348 | if status: 349 | data['status'] = status 350 | response = self.session.delete(url, json=data) 351 | response.raise_for_status() 352 | 353 | def user(self, username=None, key=None): 354 | """ Return user details. 355 | 356 | Passing neither user name nor key retrieves the current user. 357 | """ 358 | if key: 359 | user = self.get('user', key=key, _cached=True) 360 | elif username: 361 | user = self.get('user', username=username, _cached=True) 362 | else: 363 | user = self.get('user/current') 364 | return user 365 | 366 | def walk(self, path, **params): 367 | """ Walk a page tree recursively, and yield the root and all its children. 368 | """ 369 | params = params.copy() 370 | depth_1st = params.pop('depth_1st', False) 371 | root_url = self.url(path) 372 | self.log.debug("Walking %r %s", root_url, 'depth 1st' if depth_1st else 'breadth 1st') 373 | 374 | stack = collections.deque([(0, [self.get(root_url, **params)])]) 375 | while stack: 376 | depth, pages = stack.pop() 377 | for page in pages: 378 | ##import pprint; print('~ {:3d} {} '.format(depth, page.title).ljust(78, '~')); pprint.pprint(dict(page)) 379 | yield depth, page 380 | children = self.getall(page._links.self + '/child/page', **params) 381 | if depth_1st: 382 | for child in children: 383 | stack.append((depth+1, [child])) 384 | else: 385 | stack.appendleft((depth+1, children)) 386 | --------------------------------------------------------------------------------