├── .github └── workflows │ ├── gh-pages.yml │ ├── publish.yml │ └── test.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── centraldogma ├── __init__.py ├── base_client.py ├── content_service.py ├── data │ ├── __init__.py │ ├── change.py │ ├── commit.py │ ├── constants.py │ ├── content.py │ ├── creator.py │ ├── entry.py │ ├── merge_source.py │ ├── merged_entry.py │ ├── project.py │ ├── push_result.py │ ├── repository.py │ └── revision.py ├── dogma.py ├── exceptions.py ├── project_service.py ├── query.py ├── repository_service.py ├── repository_watcher.py ├── util.py └── watcher.py ├── docker-compose.yml ├── docs ├── Makefile ├── centraldogma.data.rst ├── centraldogma.rst ├── conf.py ├── index.rst ├── make.bat └── modules.rst ├── examples └── hierarchical_get.py ├── pyproject.toml ├── pytest.ini ├── tests ├── __init__.py ├── integration │ ├── __init__.py │ ├── test_content_service.py │ ├── test_project_service.py │ ├── test_repository_service.py │ └── test_watcher.py ├── test_base_client.py ├── test_dogma.py ├── test_exceptions.py ├── test_push_result.py └── test_repository_watcher.py └── uv.lock /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Sphinx documentation to Github pages 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | env: 8 | UV_SYSTEM_PYTHON: 1 9 | UV_PYTHON_PREFERENCE: system 10 | 11 | jobs: 12 | docs: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: 3.13 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v5 24 | 25 | - name: Install dependencies 26 | run: uv pip install -e '.[docs]' 27 | 28 | - name: Build docs 29 | run: sphinx-build docs ./docs/_build/html/ 30 | 31 | - uses: actions/upload-artifact@v4 32 | with: 33 | name: html-docs 34 | path: docs/_build/html/ 35 | 36 | - uses: peaceiris/actions-gh-pages@v4 37 | with: 38 | github_token: ${{ secrets.GITHUB_TOKEN }} 39 | publish_dir: docs/_build/html 40 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPi 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.x" 17 | cache: "pip" 18 | cache-dependency-path: "**/pyproject.toml" 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install build twine 24 | 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: "__token__" # https://github.com/pypa/twine/blob/3.x/tests/test_integration.py#L53-L64 28 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN_CENTRALDOGMA }} 29 | run: | 30 | python -m build 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Testing Central Dogma Python 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | env: 10 | UV_SYSTEM_PYTHON: 1 11 | UV_PYTHON_PREFERENCE: system 12 | 13 | jobs: 14 | docs: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Install uv 19 | uses: astral-sh/setup-uv@v3 20 | with: 21 | version: "0.5.1" 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: 3.13 26 | - name: Install dependencies 27 | run: uv pip install -e '.[docs]' 28 | - name: Build docs 29 | run: sphinx-build docs ./docs/_build/html/ 30 | 31 | linter: 32 | name: black --check 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: rickstaa/action-black@v1 37 | id: action_black 38 | with: 39 | black_args: ". --check" 40 | 41 | integration-test: 42 | runs-on: ${{ matrix.os }} 43 | strategy: 44 | matrix: 45 | os: [ubuntu-latest] 46 | python-version: 47 | ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.9", "pypy3.10"] 48 | 49 | steps: 50 | - uses: actions/checkout@v4 51 | - name: Start Central Dogma 52 | run: docker compose -f "docker-compose.yml" up -d --build 53 | 54 | - name: Set up Python ${{ matrix.python-version }} 55 | uses: actions/setup-python@v5 56 | with: 57 | python-version: ${{ matrix.python-version }} 58 | 59 | - name: Install uv 60 | uses: astral-sh/setup-uv@v3 61 | with: 62 | version: "0.5.1" 63 | enable-cache: true 64 | 65 | - name: Install dependencies 66 | run: | 67 | uv pip install -r pyproject.toml --extra dev 68 | 69 | - name: Test with pytest 70 | run: | 71 | INTEGRATION_TEST=true pytest --cov=centraldogma ./tests 72 | 73 | - name: Upload to codecov 74 | run: codecov 75 | 76 | - name: Stop containers 77 | if: always() 78 | run: docker compose -f "docker-compose.yml" down 79 | 80 | test: 81 | runs-on: ${{ matrix.os }} 82 | strategy: 83 | matrix: 84 | os: [macos-latest, windows-latest] 85 | python-version: 86 | ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.9", "pypy3.10"] 87 | 88 | steps: 89 | - uses: actions/checkout@v4 90 | - name: Install uv 91 | uses: astral-sh/setup-uv@v3 92 | with: 93 | version: "0.5.1" 94 | enable-cache: true 95 | 96 | - name: Set up Python ${{ matrix.python-version }} 97 | uses: actions/setup-python@v5 98 | with: 99 | python-version: ${{ matrix.python-version }} 100 | 101 | - name: Install dependencies 102 | run: | 103 | uv pip install -r pyproject.toml --extra dev 104 | 105 | - name: Test with pytest 106 | run: | 107 | pytest --cov=centraldogma ./tests 108 | 109 | - name: Upload to codecov 110 | run: codecov 111 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | public/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | # IntelliJ 133 | .idea/ 134 | 135 | # macOS folder metadata 136 | .DS_Store 137 | 138 | # asdf 139 | .tool-versions 140 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | https://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | https://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Central Dogma client in Python 2 | 3 | [![PyPI version](https://badge.fury.io/py/centraldogma-python.svg)](https://badge.fury.io/py/centraldogma-python) 4 | [![PyPI Supported Python Versions](https://img.shields.io/pypi/pyversions/centraldogma-python.svg)](https://pypi.python.org/pypi/centraldogma-python/) 5 | [![check](https://github.com/line/centraldogma-python/actions/workflows/test.yml/badge.svg)](https://github.com/line/centraldogma-python/actions/workflows/test.yml) 6 | [![Downloads](https://static.pepy.tech/badge/centraldogma-python/month)](https://pepy.tech/project/centraldogma-python) 7 | 8 | Python client library for [Central Dogma](https://line.github.io/centraldogma/). 9 | 10 | ## Install 11 | ``` 12 | $ pip install centraldogma-python 13 | ``` 14 | 15 | ## Getting started 16 | Only URL indicating CentralDogma server and access token are required. 17 | ```pycon 18 | >>> from centraldogma.dogma import Dogma 19 | >>> dogma = Dogma("https://dogma.yourdomain.com", "token") 20 | >>> dogma.list_projects() 21 | [] 22 | ``` 23 | 24 | It supports client configurations. 25 | ```pycon 26 | >>> retries, max_connections = 5, 10 27 | >>> dogma = Dogma("https://dogma.yourdomain.com", "token", retries=retries, max_connections=max_connections) 28 | ``` 29 | 30 | Please see [`examples` folder](https://github.com/line/centraldogma-python/tree/main/examples) for more detail. 31 | 32 | --- 33 | 34 | ## Development 35 | ### Tests 36 | #### Unit test 37 | ``` 38 | $ pytest 39 | ``` 40 | 41 | #### Integration test 42 | 1. Run local Central Dogma server with docker-compose 43 | ``` 44 | $ docker-compose up -d 45 | ``` 46 | 47 | 2. Run integration tests 48 | ``` 49 | $ INTEGRATION_TEST=true pytest 50 | ``` 51 | 52 | 3. Stop the server 53 | ``` 54 | $ docker-compose down 55 | ``` 56 | 57 | ### Lint 58 | - [PEP 8](https://www.python.org/dev/peps/pep-0008) 59 | ``` 60 | $ black . 61 | ``` 62 | 63 | ### Documentation 64 | - [PEP 257](https://www.python.org/dev/peps/pep-0257) 65 | 66 | #### To build sphinx at local 67 | ``` 68 | $ pip install sphinx sphinx_rtd_theme 69 | $ cd docs && make html 70 | ``` 71 | -------------------------------------------------------------------------------- /centraldogma/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.4.0" 2 | -------------------------------------------------------------------------------- /centraldogma/base_client.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from typing import Dict, Union, Callable, TypeVar, Optional 15 | 16 | from httpx import Client, HTTPTransport, Limits, Response 17 | from tenacity import stop_after_attempt, wait_exponential, Retrying 18 | 19 | from centraldogma.exceptions import to_exception 20 | 21 | T = TypeVar("T") 22 | 23 | 24 | class BaseClient: 25 | def __init__( 26 | self, 27 | base_url: str, 28 | token: str, 29 | http2: bool = True, 30 | retries: int = 1, 31 | max_connections: int = 10, 32 | max_keepalive_connections: int = 2, 33 | **configs, 34 | ): 35 | assert retries >= 0, "retries must be greater than or equal to zero" 36 | assert max_connections > 0, "max_connections must be greater than zero" 37 | assert ( 38 | max_keepalive_connections > 0 39 | ), "max_keepalive_connections must be greater than zero" 40 | 41 | base_url = base_url[:-1] if base_url[-1] == "/" else base_url 42 | 43 | for key in ["transport", "limits"]: 44 | if key in configs: 45 | del configs[key] 46 | 47 | self.retries = retries 48 | self.client = Client( 49 | base_url=f"{base_url}/api/v1", 50 | http2=http2, 51 | transport=HTTPTransport(retries=retries), 52 | limits=Limits( 53 | max_connections=max_connections, 54 | max_keepalive_connections=max_keepalive_connections, 55 | ), 56 | **configs, 57 | ) 58 | self.token = token 59 | self.headers = self._get_headers(token) 60 | self.patch_headers = self._get_patch_headers(token) 61 | 62 | def request( 63 | self, 64 | method: str, 65 | path: str, 66 | handler: Optional[Dict[int, Callable[[Response], T]]] = None, 67 | **kwargs, 68 | ) -> Union[Response, T]: 69 | kwargs = self._set_request_headers(method, **kwargs) 70 | retryer = Retrying( 71 | stop=stop_after_attempt(self.retries + 1), 72 | wait=wait_exponential(max=60), 73 | reraise=True, 74 | ) 75 | return retryer(self._request, method, path, handler, **kwargs) 76 | 77 | def _set_request_headers(self, method: str, **kwargs) -> Dict: 78 | default_headers = self.patch_headers if method == "patch" else self.headers 79 | kwargs["headers"] = {**default_headers, **(kwargs.get("headers") or {})} 80 | return kwargs 81 | 82 | def _request( 83 | self, 84 | method: str, 85 | path: str, 86 | handler: Optional[Dict[int, Callable[[Response], T]]] = None, 87 | **kwargs, 88 | ): 89 | resp = self.client.request(method, path, **kwargs) 90 | if handler: 91 | converter = handler.get(resp.status_code) 92 | if converter: 93 | return converter(resp) 94 | else: # Unexpected response status 95 | raise to_exception(resp) 96 | return resp 97 | 98 | @staticmethod 99 | def _get_headers(token: str) -> Dict: 100 | return { 101 | "Authorization": f"bearer {token}", 102 | "Content-Type": "application/json", 103 | } 104 | 105 | @staticmethod 106 | def _get_patch_headers(token: str) -> Dict: 107 | return { 108 | "Authorization": f"bearer {token}", 109 | "Content-Type": "application/json-patch+json", 110 | } 111 | -------------------------------------------------------------------------------- /centraldogma/content_service.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from dataclasses import asdict 15 | from enum import Enum 16 | from http import HTTPStatus 17 | from typing import List, Optional, TypeVar, Any, Callable, Dict 18 | from urllib.parse import quote 19 | 20 | from httpx import Response 21 | 22 | from centraldogma.base_client import BaseClient 23 | from centraldogma.data import Content 24 | from centraldogma.data.change import Change 25 | from centraldogma.data.commit import Commit 26 | from centraldogma.data.entry import Entry, EntryType 27 | from centraldogma.data.merge_source import MergeSource 28 | from centraldogma.data.merged_entry import MergedEntry 29 | from centraldogma.data.push_result import PushResult 30 | from centraldogma.data.revision import Revision 31 | from centraldogma.exceptions import CentralDogmaException 32 | from centraldogma.query import Query, QueryType 33 | 34 | T = TypeVar("T") 35 | 36 | 37 | class ContentService: 38 | def __init__(self, client: BaseClient): 39 | self.client = client 40 | 41 | def get_files( 42 | self, 43 | project_name: str, 44 | repo_name: str, 45 | path_pattern: Optional[str], 46 | revision: Optional[int], 47 | include_content: bool = False, 48 | ) -> List[Content]: 49 | params = {"revision": revision} if revision else None 50 | path = f"/projects/{project_name}/repos/{repo_name}/" 51 | path += "contents" if include_content else "list" 52 | if path_pattern: 53 | if path_pattern.startswith("/"): 54 | path += path_pattern 55 | else: 56 | path += "/" + path_pattern 57 | 58 | handler = { 59 | HTTPStatus.OK: lambda resp: ( 60 | lambda data: ( 61 | [Content.from_dict(content) for content in data] 62 | if isinstance(data, list) 63 | else [Content.from_dict(data)] 64 | ) 65 | )(resp.json()), 66 | HTTPStatus.NO_CONTENT: lambda resp: [], 67 | } 68 | return self.client.request("get", path, params=params, handler=handler) 69 | 70 | def get_file( 71 | self, 72 | project_name: str, 73 | repo_name: str, 74 | file_path: str, 75 | revision: Optional[int], 76 | json_path: Optional[str], 77 | ) -> Content: 78 | params = {} 79 | if revision: 80 | params["revision"] = revision 81 | if json_path: 82 | params["jsonpath"] = json_path 83 | if not file_path.startswith("/"): 84 | file_path = "/" + file_path 85 | path = f"/projects/{project_name}/repos/{repo_name}/contents{file_path}" 86 | 87 | handler = {HTTPStatus.OK: lambda resp: Content.from_dict(resp.json())} 88 | return self.client.request("get", path, params=params, handler=handler) 89 | 90 | def push( 91 | self, 92 | project_name: str, 93 | repo_name: str, 94 | commit: Commit, 95 | # TODO(ikhoon): Make changes accept varargs? 96 | changes: List[Change], 97 | ) -> PushResult: 98 | params = { 99 | "commitMessage": asdict(commit), 100 | "changes": [ 101 | asdict(change, dict_factory=self._change_dict) for change in changes 102 | ], 103 | } 104 | path = f"/projects/{project_name}/repos/{repo_name}/contents" 105 | handler = {HTTPStatus.OK: lambda resp: PushResult.from_dict(resp.json())} 106 | return self.client.request("post", path, json=params, handler=handler) 107 | 108 | def watch_repository( 109 | self, 110 | project_name: str, 111 | repo_name: str, 112 | last_known_revision: Revision, 113 | path_pattern: str, 114 | timeout_millis: int, 115 | ) -> Optional[Revision]: 116 | path = f"/projects/{project_name}/repos/{repo_name}/contents" 117 | if path_pattern[0] != "/": 118 | path += "/**/" 119 | 120 | path += quote(path_pattern) 121 | 122 | handler = { 123 | HTTPStatus.OK: lambda resp: Revision(resp.json()["revision"]), 124 | HTTPStatus.NOT_MODIFIED: lambda resp: None, 125 | } 126 | return self._watch(last_known_revision, timeout_millis, path, handler) 127 | 128 | def watch_file( 129 | self, 130 | project_name: str, 131 | repo_name: str, 132 | last_known_revision: Revision, 133 | query: Query[T], 134 | timeout_millis: int, 135 | ) -> Optional[Entry[T]]: 136 | path = f"/projects/{project_name}/repos/{repo_name}/contents/{query.path}" 137 | if query.query_type == QueryType.JSON_PATH: 138 | queries = [f"jsonpath={quote(expr)}" for expr in query.expressions] 139 | path = f"{path}?{'&'.join(queries)}" 140 | 141 | def on_ok(response: Response) -> Entry: 142 | json = response.json() 143 | revision = Revision(json["revision"]) 144 | return self._to_entry(revision, json["entry"], query.query_type) 145 | 146 | handler = {HTTPStatus.OK: on_ok, HTTPStatus.NOT_MODIFIED: lambda resp: None} 147 | return self._watch(last_known_revision, timeout_millis, path, handler) 148 | 149 | def merge_files( 150 | self, 151 | project_name: str, 152 | repo_name: str, 153 | merge_sources: List[MergeSource], 154 | json_paths: Optional[List[str]], 155 | revision: Optional[int], 156 | ) -> MergedEntry: 157 | if not merge_sources: 158 | raise ValueError("at least one MergeSource is required") 159 | path = f"/projects/{project_name}/repos/{repo_name}/merge" 160 | queries = [] 161 | if revision: 162 | queries.append(f"revision={revision}") 163 | for merge_source in merge_sources: 164 | query = ( 165 | f"optional_path={merge_source.path}" 166 | if merge_source.optional 167 | else f"path={merge_source.path}" 168 | ) 169 | queries.append(query) 170 | for json_path in json_paths: 171 | queries.append(f"jsonpath={json_path}") 172 | path = f"{path}?{'&'.join(queries)}" 173 | handler = {HTTPStatus.OK: lambda resp: MergedEntry.from_dict(resp.json())} 174 | return self.client.request("get", path, handler=handler) 175 | 176 | def _watch( 177 | self, 178 | last_known_revision: Revision, 179 | timeout_millis: int, 180 | path: str, 181 | handler: Dict[int, Callable[[Response], T]], 182 | ) -> T: 183 | normalized_timeout = (timeout_millis + 999) // 1000 184 | headers = { 185 | "if-none-match": f"{last_known_revision.major}", 186 | "prefer": f"wait={normalized_timeout}", 187 | } 188 | return self.client.request( 189 | "get", path, handler=handler, headers=headers, timeout=normalized_timeout 190 | ) 191 | 192 | @staticmethod 193 | def _to_entry(revision: Revision, json: Any, query_type: QueryType) -> Entry: 194 | entry_path = json["path"] 195 | received_entry_type = EntryType[json["type"]] 196 | content = json["content"] 197 | if query_type == QueryType.IDENTITY_TEXT: 198 | return Entry.text(revision, entry_path, content) 199 | elif query_type == QueryType.IDENTITY_JSON or query_type == QueryType.JSON_PATH: 200 | if received_entry_type != EntryType.JSON: 201 | raise CentralDogmaException( 202 | f"invalid entry type. entry type: {received_entry_type} (expected: {query_type})" 203 | ) 204 | 205 | return Entry.json(revision, entry_path, content) 206 | elif query_type == QueryType.IDENTITY: 207 | if received_entry_type == EntryType.JSON: 208 | return Entry.json(revision, entry_path, content) 209 | elif received_entry_type == EntryType.TEXT: 210 | return Entry.text(revision, entry_path, content) 211 | elif received_entry_type == EntryType.DIRECTORY: 212 | return Entry.directory(revision, entry_path) 213 | 214 | @staticmethod 215 | def _change_dict(data): 216 | return { 217 | field: value.value if isinstance(value, Enum) else value 218 | for field, value in data 219 | } 220 | -------------------------------------------------------------------------------- /centraldogma/data/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from .change import Change, ChangeType 15 | from .commit import Commit 16 | from .constants import DATE_FORMAT_ISO8601, DATE_FORMAT_ISO8601_MS 17 | from .content import Content 18 | from .creator import Creator 19 | from .project import Project 20 | from .push_result import PushResult 21 | from .repository import Repository 22 | -------------------------------------------------------------------------------- /centraldogma/data/change.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from enum import Enum 15 | from typing import Optional, Any 16 | 17 | from dataclasses_json import dataclass_json 18 | from pydantic.dataclasses import dataclass 19 | 20 | 21 | class ChangeType(Enum): 22 | UPSERT_JSON = "UPSERT_JSON" 23 | UPSERT_TEXT = "UPSERT_TEXT" 24 | REMOVE = "REMOVE" 25 | RENAME = "RENAME" 26 | APPLY_JSON_PATCH = "APPLY_JSON_PATCH" 27 | APPLY_TEXT_PATCH = "APPLY_TEXT_PATCH" 28 | 29 | 30 | @dataclass_json 31 | @dataclass 32 | class Change: 33 | path: str 34 | type: ChangeType 35 | content: Optional[Any] = None 36 | -------------------------------------------------------------------------------- /centraldogma/data/commit.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from typing import Optional 15 | 16 | from dataclasses_json import dataclass_json 17 | from pydantic.dataclasses import dataclass 18 | 19 | 20 | @dataclass_json 21 | @dataclass 22 | class Commit: 23 | summary: str 24 | detail: Optional[str] = None 25 | markup: Optional[str] = None 26 | -------------------------------------------------------------------------------- /centraldogma/data/constants.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | DATE_FORMAT_ISO8601 = "%Y-%m-%dT%H:%M:%S%z" 15 | DATE_FORMAT_ISO8601_MS = "%Y-%m-%dT%H:%M:%S.%f%z" 16 | -------------------------------------------------------------------------------- /centraldogma/data/content.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from typing import Any, Optional 15 | 16 | from dataclasses_json import dataclass_json 17 | from pydantic.dataclasses import dataclass 18 | 19 | 20 | @dataclass_json 21 | @dataclass 22 | class Content: 23 | path: str 24 | type: str 25 | url: str 26 | revision: int 27 | content: Optional[Any] = None 28 | -------------------------------------------------------------------------------- /centraldogma/data/creator.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from dataclasses_json import dataclass_json 15 | from pydantic.dataclasses import dataclass 16 | 17 | 18 | @dataclass_json 19 | @dataclass 20 | class Creator: 21 | name: str 22 | email: str 23 | -------------------------------------------------------------------------------- /centraldogma/data/entry.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from __future__ import annotations 15 | 16 | import json 17 | from enum import Enum 18 | from typing import TypeVar, Generic, Any 19 | 20 | from centraldogma import util 21 | from centraldogma.data.revision import Revision 22 | from centraldogma.exceptions import EntryNoContentException 23 | 24 | 25 | class EntryType(Enum): 26 | JSON = "JSON" 27 | TEXT = "TEXT" 28 | DIRECTORY = "DIRECTORY" 29 | 30 | 31 | T = TypeVar("T") 32 | 33 | 34 | class Entry(Generic[T]): 35 | """A file or a directory in a repository.""" 36 | 37 | @staticmethod 38 | def text(revision: Revision, path: str, content: str) -> Entry[str]: 39 | """Returns a newly-created ``Entry`` of a text file. 40 | 41 | :param revision: the revision of the text file 42 | :param path: the path of the text file 43 | :param content: the content of the text file 44 | """ 45 | return Entry(revision, path, EntryType.TEXT, content) 46 | 47 | @staticmethod 48 | def json(revision: Revision, path: str, content: Any) -> Entry[Any]: 49 | """Returns a newly-created ``Entry`` of a JSON file. 50 | 51 | :param revision: the revision of the JSON file 52 | :param path: the path of the JSON file 53 | :param content: the content of the JSON file 54 | """ 55 | if type(content) is str: 56 | content = json.loads(content) 57 | return Entry(revision, path, EntryType.JSON, content) 58 | 59 | @staticmethod 60 | def directory(revision: Revision, path: str) -> Entry[None]: 61 | """Returns a newly-created ``Entry`` of a directory. 62 | 63 | :param revision: the revision of the directory 64 | :param path: the path of the directory 65 | """ 66 | return Entry(revision, path, EntryType.DIRECTORY, None) 67 | 68 | def __init__( 69 | self, revision: Revision, path: str, entry_type: EntryType, content: T 70 | ): 71 | self.revision = revision 72 | self.path = path 73 | self.entry_type = entry_type 74 | self._content = content 75 | self._content_as_text = None 76 | 77 | def has_content(self) -> bool: 78 | """Returns if this ``Entry`` has content, which is always ``True`` if it's not a directory.""" 79 | return self.content is not None 80 | 81 | @property 82 | def content(self) -> T: 83 | """Returns the content. 84 | 85 | :raises EntryNoContentException: it occurs if the content is ``None`` 86 | """ 87 | if not self._content: 88 | raise EntryNoContentException( 89 | f"{self.path} (type: {self.entry_type}, revision: {self.revision.major})" 90 | ) 91 | 92 | return self._content 93 | 94 | def content_as_text(self) -> str: 95 | """Returns the textual representation of the specified content. 96 | 97 | :raises EntryNoContentException: it occurs if the content is ``None`` 98 | """ 99 | if self._content_as_text: 100 | return self._content_as_text 101 | 102 | content = self.content 103 | if self.entry_type == EntryType.TEXT: 104 | self._content_as_text = content 105 | else: 106 | self._content_as_text = json.dumps(self.content) 107 | 108 | return self._content_as_text 109 | 110 | def __str__(self) -> str: 111 | return util.to_string(self) 112 | 113 | def __repr__(self) -> str: 114 | return self.__str__() 115 | -------------------------------------------------------------------------------- /centraldogma/data/merge_source.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License 14 | from pydantic.dataclasses import dataclass 15 | 16 | 17 | @dataclass 18 | class MergeSource: 19 | path: str 20 | optional: bool = True 21 | -------------------------------------------------------------------------------- /centraldogma/data/merged_entry.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from typing import List, Union 15 | 16 | from dataclasses_json.core import Json 17 | 18 | from centraldogma import util 19 | from centraldogma.data.entry import EntryType 20 | from centraldogma.data.revision import Revision 21 | from centraldogma.exceptions import EntryNoContentException 22 | 23 | 24 | class MergedEntry: 25 | @staticmethod 26 | def from_dict(json: Json): 27 | paths: List[str] = json["paths"] 28 | revision = Revision(json["revision"]) 29 | entry_type = EntryType[json["type"]] 30 | content = json["content"] 31 | return MergedEntry(revision, paths, entry_type, content) 32 | 33 | def __init__( 34 | self, revision: Revision, paths: List[str], entry_type: EntryType, content: Json 35 | ): 36 | self.revision = revision 37 | self.paths = paths 38 | self.entry_type = entry_type 39 | self._content = content 40 | 41 | @property 42 | def content(self) -> Union[str, dict]: 43 | """Returns the content. 44 | 45 | :raises EntryNoContentException: it occurs if the content is ``None`` 46 | """ 47 | if not self._content: 48 | raise EntryNoContentException( 49 | f"{self.paths} (type: {self.entry_type}, revision: {self.revision.major})" 50 | ) 51 | 52 | return self._content 53 | 54 | def __str__(self) -> str: 55 | return util.to_string(self) 56 | -------------------------------------------------------------------------------- /centraldogma/data/project.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from dataclasses import field 15 | from datetime import datetime 16 | from typing import Optional 17 | 18 | from dataclasses_json import LetterCase, config, dataclass_json 19 | from dateutil import parser 20 | from marshmallow import fields 21 | from pydantic.dataclasses import dataclass 22 | 23 | from centraldogma.data.creator import Creator 24 | 25 | 26 | @dataclass_json(letter_case=LetterCase.CAMEL) 27 | @dataclass 28 | class Project: 29 | name: str 30 | creator: Optional[Creator] = None 31 | created_at: Optional[datetime] = field( 32 | default=None, 33 | metadata=config( 34 | decoder=lambda x: parser.parse(x) if x else None, 35 | mm_field=fields.DateTime(format="iso"), 36 | ), 37 | ) 38 | url: Optional[str] = None 39 | -------------------------------------------------------------------------------- /centraldogma/data/push_result.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from dataclasses import field 15 | from datetime import datetime 16 | 17 | from dataclasses_json import LetterCase, config, dataclass_json 18 | from marshmallow import fields 19 | from dateutil import parser 20 | from pydantic.dataclasses import dataclass 21 | 22 | 23 | @dataclass_json(letter_case=LetterCase.CAMEL) 24 | @dataclass 25 | class PushResult: 26 | revision: int 27 | pushed_at: datetime = field( 28 | metadata=config( 29 | decoder=lambda x: parser.parse(x), 30 | mm_field=fields.DateTime(format="iso"), 31 | ), 32 | ) 33 | -------------------------------------------------------------------------------- /centraldogma/data/repository.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from dataclasses import field 15 | from datetime import datetime 16 | from typing import Optional 17 | 18 | from dataclasses_json import LetterCase, config, dataclass_json 19 | from dateutil import parser 20 | from marshmallow import fields 21 | from pydantic.dataclasses import dataclass 22 | 23 | from centraldogma.data.creator import Creator 24 | 25 | 26 | @dataclass_json(letter_case=LetterCase.CAMEL) 27 | @dataclass 28 | class Repository: 29 | name: str 30 | creator: Optional[Creator] = None 31 | created_at: Optional[datetime] = field( 32 | default=None, 33 | metadata=config( 34 | decoder=lambda x: parser.parse(x) if x else None, 35 | mm_field=fields.DateTime(format="iso"), 36 | ), 37 | ) 38 | head_revision: Optional[int] = -1 39 | url: Optional[str] = None 40 | -------------------------------------------------------------------------------- /centraldogma/data/revision.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from __future__ import annotations 15 | 16 | from pydantic.dataclasses import dataclass 17 | 18 | 19 | @dataclass 20 | class Revision: 21 | """A revision number of a ``Commit``.""" 22 | 23 | major: int 24 | 25 | @staticmethod 26 | def init() -> Revision: 27 | """Revision ``1``, also known as 'INIT'.""" 28 | return _INIT 29 | 30 | @staticmethod 31 | def head() -> Revision: 32 | """Revision ``-1``, also known as 'HEAD'.""" 33 | return _HEAD 34 | 35 | 36 | _INIT = Revision(1) 37 | _HEAD = Revision(-1) 38 | -------------------------------------------------------------------------------- /centraldogma/dogma.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | import os 15 | from typing import List, Optional, TypeVar, Callable 16 | 17 | from centraldogma.base_client import BaseClient 18 | from centraldogma.content_service import ContentService 19 | 20 | # noinspection PyUnresolvedReferences 21 | from centraldogma.data import ( 22 | Change, 23 | Commit, 24 | Content, 25 | ChangeType, 26 | Project, 27 | PushResult, 28 | Repository, 29 | ) 30 | from centraldogma.data.entry import Entry 31 | from centraldogma.data.merge_source import MergeSource 32 | from centraldogma.data.merged_entry import MergedEntry 33 | from centraldogma.data.revision import Revision 34 | from centraldogma.project_service import ProjectService 35 | from centraldogma.query import Query 36 | from centraldogma.repository_service import RepositoryService 37 | from centraldogma.repository_watcher import RepositoryWatcher, FileWatcher 38 | from centraldogma.watcher import Watcher 39 | 40 | T = TypeVar("T") 41 | U = TypeVar("U") 42 | 43 | _DEFAULT_WATCH_TIMEOUT_MILLIS = 1 * 60 * 1000 # 1 minute 44 | 45 | 46 | class Dogma: 47 | DEFAULT_BASE_URL = "http://localhost:36462" 48 | DEFAULT_TOKEN = "anonymous" 49 | 50 | def __init__(self, base_url: str = None, token: str = None, **configs): 51 | """A Central Dogma API client using requests. 52 | 53 | :param base_url: a base URL indicating Central Dogma server such as domain. 54 | :param token: a token for authorization. 55 | :param configs: configurations for an HTTP client. For example, cert and timeout can be applied by using it. 56 | :type configs: dict, optional 57 | """ 58 | if base_url is None: 59 | env_host = os.getenv("CENTRAL_DOGMA_HOST") 60 | base_url = env_host if env_host else self.DEFAULT_BASE_URL 61 | if token is None: 62 | env_token = os.getenv("CENTRAL_DOGMA_TOKEN") 63 | token = env_token if env_token else self.DEFAULT_TOKEN 64 | self.base_client = BaseClient(base_url, token, **configs) 65 | self.project_service = ProjectService(self.base_client) 66 | self.repository_service = RepositoryService(self.base_client) 67 | self.content_service = ContentService(self.base_client) 68 | 69 | def list_projects(self, removed: bool = False) -> List[Project]: 70 | """Lists all projects, in the order that they were created on the Central Dogma server.""" 71 | return self.project_service.list(removed) 72 | 73 | def create_project(self, name: str) -> Project: 74 | """Creates a project. The creator of the project will become the owner of the project.""" 75 | return self.project_service.create(name) 76 | 77 | def remove_project(self, name: str) -> None: 78 | """Removes a project. Only the owner and an admin can remove the project.""" 79 | return self.project_service.remove(name) 80 | 81 | def unremove_project(self, name: str) -> Project: 82 | """Unremoves a project which is removed before. Only an admin can unremove the project.""" 83 | return self.project_service.unremove(name) 84 | 85 | def purge_project(self, name: str) -> None: 86 | """Purges a project. Only the owner and an admin can purge the project removed before.""" 87 | return self.project_service.purge(name) 88 | 89 | def list_repositories( 90 | self, project_name: str, removed: bool = False 91 | ) -> List[Repository]: 92 | """Lists all repositories, in the order that they were created on the Central Dogma server.""" 93 | return self.repository_service.list(project_name, removed) 94 | 95 | def create_repository(self, project_name: str, name: str) -> Repository: 96 | """Creates a repository. Only the owner and an admin can create.""" 97 | return self.repository_service.create(project_name, name) 98 | 99 | def remove_repository(self, project_name: str, name: str) -> None: 100 | """Removes a repository. Only the owner and an admin can remove.""" 101 | return self.repository_service.remove(project_name, name) 102 | 103 | def unremove_repository(self, project_name: str, name: str) -> Repository: 104 | """Unremoves a repository. Only the owner and an admin can unremove.""" 105 | return self.repository_service.unremove(project_name, name) 106 | 107 | def purge_repository(self, project_name: str, name: str) -> None: 108 | """Purges a repository. Only the owner and an admin can purge a repository removed before.""" 109 | return self.repository_service.purge(project_name, name) 110 | 111 | # TODO(ikhoon): Use `Revision` class instead of int 112 | def normalize_repository_revision( 113 | self, project_name: str, name: str, revision: int 114 | ) -> int: 115 | """Normalizes the revision into an absolute revision.""" 116 | return self.repository_service.normalize_revision(project_name, name, revision) 117 | 118 | def list_files( 119 | self, 120 | project_name: str, 121 | repo_name: str, 122 | path_pattern: Optional[str] = None, 123 | revision: Optional[int] = None, 124 | ) -> List[Content]: 125 | """Lists files. The user should have read permission at least. 126 | 127 | :param path_pattern: A path pattern is a variant of glob as follows. |br| 128 | "/\\*\\*" - find all files recursively |br| 129 | "\\*.json" - find all JSON files recursively |br| 130 | "/foo/\\*.json" - find all JSON files under the directory ``/foo`` |br| 131 | "/\\*/foo.txt" - find all files named foo.txt at the second depth level |br| 132 | "\\*.json,/bar/\\*.txt" - use comma to match any patterns |br| 133 | This will bring all of the files in the repository, if unspecified. 134 | :param revision: The revision of the list to get. If not specified, gets the list of 135 | the latest revision. 136 | """ 137 | return self.content_service.get_files( 138 | project_name, repo_name, path_pattern, revision, include_content=False 139 | ) 140 | 141 | def get_files( 142 | self, 143 | project_name: str, 144 | repo_name: str, 145 | path_pattern: Optional[str] = None, 146 | revision: Optional[int] = None, 147 | ) -> List[Content]: 148 | """Gets files. The user should have read permission at least. The difference from 149 | the API List files is that this includes the content of the files. 150 | 151 | :param path_pattern: A path pattern is a variant of glob as follows. |br| 152 | "/\\*\\*" - find all files recursively |br| 153 | "\\*.json" - find all JSON files recursively |br| 154 | "/foo/\\*.json" - find all JSON files under the directory ``/foo`` |br| 155 | "/\\*/foo.txt" - find all files named foo.txt at the second depth level |br| 156 | "\\*.json,/bar/\\*.txt" - use comma to match any patterns |br| 157 | This will bring all of the files in the repository, if unspecified. 158 | :param revision: The revision of the list to get. If not specified, gets the list of 159 | the latest revision. 160 | """ 161 | return self.content_service.get_files( 162 | project_name, repo_name, path_pattern, revision, include_content=True 163 | ) 164 | 165 | def get_file( 166 | self, 167 | project_name: str, 168 | repo_name: str, 169 | file_path: str, 170 | revision: Optional[int] = None, 171 | json_path: Optional[str] = None, 172 | ) -> Content: 173 | """Gets a file. The user should have read permission at least. 174 | 175 | :param revision: The revision of the file to get. If not specified, gets the file of 176 | the latest revision. 177 | :param json_path: The JSON path expressions. 178 | """ 179 | return self.content_service.get_file( 180 | project_name, repo_name, file_path, revision, json_path 181 | ) 182 | 183 | def push( 184 | self, 185 | project_name: str, 186 | repo_name: str, 187 | commit: Commit, 188 | changes: List[Change], 189 | ) -> PushResult: 190 | """Creates, replaces, renames or deletes files. The user should have a permission to write. 191 | 192 | :param commit: A commit message for changes. 193 | :param changes: Detailed changes including path, type and content. 194 | If the type is REMOVE, the content should be empty. If the type is RENAME, 195 | the content is supposed to be the new name. 196 | """ 197 | return self.content_service.push(project_name, repo_name, commit, changes) 198 | 199 | def watch_repository( 200 | self, 201 | project_name: str, 202 | repo_name: str, 203 | last_known_revision: Revision, 204 | path_pattern: str, 205 | timeout_millis: int = _DEFAULT_WATCH_TIMEOUT_MILLIS, 206 | ) -> Optional[Revision]: 207 | """Waits for the files matched by the specified ``path_pattern`` to be changed since the specified 208 | ``last_known_revision``. If no changes were made within the specified ``timeout_millis``, 209 | ``None`` will be returned. It is recommended to specify the largest ``timeout_millis`` allowed by the server. 210 | If unsure, use the default watch timeout. 211 | 212 | :return: the latest known ``Revision`` which contains the changes for the matched files. 213 | ``None`` if the files were not changed for ``timeout_millis`` milliseconds 214 | since the invocation of this method. 215 | """ 216 | return self.content_service.watch_repository( 217 | project_name, repo_name, last_known_revision, path_pattern, timeout_millis 218 | ) 219 | 220 | def watch_file( 221 | self, 222 | project_name: str, 223 | repo_name: str, 224 | last_known_revision: Revision, 225 | query: Query[T], 226 | timeout_millis: int = _DEFAULT_WATCH_TIMEOUT_MILLIS, 227 | ) -> Optional[Entry[T]]: 228 | """Waits for the file matched by the specified ``Query`` to be changed since the specified 229 | ``last_known_revision``. If no changes were made within the specified ``timeout_millis``, 230 | ``None`` will be returned. It is recommended to specify the largest ``timeout_millis`` allowed by the server. 231 | If unsure, use the default watch timeout. 232 | 233 | :return: the ``Entry`` which contains the latest known ``Query`` result. 234 | ``None`` if the file was not changed for ``timeout_millis`` milliseconds 235 | since the invocation of this method. 236 | """ 237 | return self.content_service.watch_file( 238 | project_name, repo_name, last_known_revision, query, timeout_millis 239 | ) 240 | 241 | def repository_watcher( 242 | self, 243 | project_name: str, 244 | repo_name: str, 245 | path_pattern: str, 246 | function: Callable[[Revision], T] = lambda x: x, 247 | timeout_millis: int = _DEFAULT_WATCH_TIMEOUT_MILLIS, 248 | ) -> Watcher[T]: 249 | """Returns a ``Watcher`` which notifies its listeners when the specified repository has a new commit 250 | that contains the changes for the files matched by the given ``path_pattern``. e.g:: 251 | 252 | def get_files(revision: Revision) -> List[Content]: 253 | return dogma.get_files("foo_project", "bar_repo", revision, "/*.json") 254 | 255 | with dogma.repository_watcher("foo_project", "bar_repo", "/*.json", get_files) as watcher: 256 | def listener(revision: Revision, contents: List[Content]) -> None: 257 | ... 258 | 259 | watcher.watch(listener) 260 | 261 | Note that you may get ``RevisionNotFoundException`` during the ``get_files()`` call and 262 | may have to retry in the above example due to `a known issue`_. 263 | 264 | :param path_pattern: the path pattern to match files in the repository. 265 | :param function: the function to convert the given `Revision` into another. 266 | :param timeout_millis: the timeout millis for the watching request. 267 | 268 | .. _a known issue: 269 | https://github.com/line/centraldogma/issues/40 270 | """ 271 | watcher = RepositoryWatcher( 272 | self.content_service, 273 | project_name, 274 | repo_name, 275 | path_pattern, 276 | timeout_millis, 277 | function, 278 | ) 279 | watcher.start() 280 | return watcher 281 | 282 | def file_watcher( 283 | self, 284 | project_name: str, 285 | repo_name: str, 286 | query: Query[T], 287 | function: Callable[[T], U] = lambda x: x, 288 | ) -> Watcher[U]: 289 | """Returns a ``Watcher`` which notifies its listeners after applying the specified ``function`` when the result 290 | of the given ``Query`` becomes available or changes. e.g:: 291 | 292 | with dogma.file_watcher("foo_project", "bar_repo", Query.json("/baz.json"), 293 | lambda content: MyType.from_dict(content)) as watcher: 294 | def listener(revision: Revision, value: MyType) -> None: 295 | ... 296 | 297 | watcher.watch(listener) 298 | 299 | :param query: the query to watch a file or a content in the repository. 300 | :param function: the function to convert the given content into another. 301 | """ 302 | watcher = FileWatcher( 303 | self.content_service, 304 | project_name, 305 | repo_name, 306 | query, 307 | _DEFAULT_WATCH_TIMEOUT_MILLIS, 308 | function, 309 | ) 310 | watcher.start() 311 | return watcher 312 | 313 | def merge_files( 314 | self, 315 | project_name: str, 316 | repo_name: str, 317 | merge_sources: List[MergeSource], 318 | json_paths: Optional[List[str]] = None, 319 | revision: Optional[int] = None, 320 | ) -> MergedEntry: 321 | """Returns the merged result of files represented by ``MergeSource``. Each ``MergeSource`` 322 | can be optional, indicating that no error should be thrown even if the path doesn't exist. 323 | If ``json_paths`` is specified, each ``json_path`` is applied recursively on the merged 324 | result. If any of the ``json_path`` s is invalid, a ``QueryExecutionException`` is thrown. 325 | 326 | :raises ValueError: If the provided ``merge_sources`` is empty. 327 | :return: the ``MergedEntry`` which contains the merged content for the given query. 328 | """ 329 | return self.content_service.merge_files( 330 | project_name, repo_name, merge_sources, json_paths or [], revision 331 | ) 332 | -------------------------------------------------------------------------------- /centraldogma/exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from http import HTTPStatus 15 | from json import JSONDecodeError 16 | from typing import Callable, Dict 17 | 18 | from httpx import Response 19 | 20 | 21 | class CentralDogmaException(Exception): 22 | """An exception that is raised when failed to access Central Dogma.""" 23 | 24 | pass 25 | 26 | 27 | class BadRequestException(CentralDogmaException): 28 | """An exception indicating a 400 Bad Client Request.""" 29 | 30 | pass 31 | 32 | 33 | class NotFoundException(CentralDogmaException): 34 | """An exception indicating a 404 Not Found.""" 35 | 36 | pass 37 | 38 | 39 | class UnauthorizedException(CentralDogmaException): 40 | """An exception indicating a 401 Unauthorized.""" 41 | 42 | pass 43 | 44 | 45 | class ForbiddenException(CentralDogmaException): 46 | """An exception indicating that an access to a resource requested by a client has been forbidden 47 | by the Central Dogma. 48 | """ 49 | 50 | pass 51 | 52 | 53 | class UnknownException(CentralDogmaException): 54 | """An exception used for reporting unknown exceptions.""" 55 | 56 | pass 57 | 58 | 59 | class InvalidResponseException(CentralDogmaException): 60 | """A ``CentralDogmaException`` that is raised when a client received an invalid response.""" 61 | 62 | pass 63 | 64 | 65 | # The exceptions defined in upstream. The following types will be populated from an error response with 66 | # _EXCEPTION_FACTORIES. 67 | # https://github.com/line/centraldogma/blob/b167d594af5abc06af30d7d6d7d8b68b320861d8/client/java-armeria/src/main/java/com/linecorp/centraldogma/client/armeria/ArmeriaCentralDogma.java#L119-L132 68 | class ProjectExistsException(CentralDogmaException): 69 | """A ``CentralDogmaException`` that is raised when attempted to create a project with an existing project name.""" 70 | 71 | pass 72 | 73 | 74 | class ProjectNotFoundException(CentralDogmaException): 75 | """A ``CentralDogmaException`` that is raised when attempted to access a non-existent project.""" 76 | 77 | pass 78 | 79 | 80 | class QueryExecutionException(CentralDogmaException): 81 | """A ``CentralDogmaException`` that is raised when the evaluation of a `Query` has failed.""" 82 | 83 | pass 84 | 85 | 86 | class RedundantChangeException(CentralDogmaException): 87 | """A ``CentralDogmaException`` that is raised when attempted to push a commit without effective changes.""" 88 | 89 | pass 90 | 91 | 92 | class RevisionNotFoundException(CentralDogmaException): 93 | """A ``CentralDogmaException`` that is raised when attempted to access a non-existent revision.""" 94 | 95 | pass 96 | 97 | 98 | class EntryNotFoundException(CentralDogmaException): 99 | """A ``CentralDogmaException`` that is raised when attempted to access a non-existent entry in a repository.""" 100 | 101 | pass 102 | 103 | 104 | class ChangeConflictException(CentralDogmaException): 105 | """A ``CentralDogmaException`` that is raised when attempted to push a commit which cannot be applied 106 | without a conflict. 107 | """ 108 | 109 | pass 110 | 111 | 112 | class RepositoryNotFoundException(CentralDogmaException): 113 | """A ``CentralDogmaException`` that is raised when attempted to access a non-existent repository.""" 114 | 115 | pass 116 | 117 | 118 | class AuthorizationException(CentralDogmaException): 119 | """A ``CentralDogmaException`` that is raised when a client failed to authenticate or attempted to 120 | perform an unauthorized operation. 121 | """ 122 | 123 | pass 124 | 125 | 126 | class ShuttingDownException(CentralDogmaException): 127 | """A ``CentralDogmaException`` that is raised when Central Dogma cannot handle a request 128 | because it's shutting down. 129 | """ 130 | 131 | pass 132 | 133 | 134 | class RepositoryExistsException(CentralDogmaException): 135 | """A ``CentralDogmaException`` that is raised when attempted to create a repository 136 | with an existing repository name. 137 | """ 138 | 139 | pass 140 | 141 | 142 | class EntryNoContentException(CentralDogmaException): 143 | """A ``CentralDogmaException`` that is raised when attempted to retrieve the content from a directory entry.""" 144 | 145 | pass 146 | 147 | 148 | _EXCEPTION_FACTORIES: Dict[str, Callable[[str], CentralDogmaException]] = { 149 | "com.linecorp.centraldogma.common." + exception.__name__: exception 150 | for exception in [ 151 | ProjectExistsException, 152 | ProjectNotFoundException, 153 | QueryExecutionException, 154 | RedundantChangeException, 155 | RevisionNotFoundException, 156 | EntryNotFoundException, 157 | ChangeConflictException, 158 | RepositoryNotFoundException, 159 | AuthorizationException, 160 | ShuttingDownException, 161 | RepositoryExistsException, 162 | ] 163 | } 164 | 165 | 166 | def to_exception(response: Response) -> CentralDogmaException: 167 | if not response.text: 168 | return _to_status_exception(response.status_code, "") 169 | 170 | try: 171 | body = response.json() 172 | except JSONDecodeError: 173 | return InvalidResponseException(response.text) 174 | 175 | exception = body.get("exception") 176 | message = body.get("message") or response.text 177 | if exception: 178 | exception_type = _EXCEPTION_FACTORIES.get(exception) 179 | if exception_type: 180 | return exception_type(message) 181 | 182 | return _to_status_exception(response.status_code, message) 183 | 184 | 185 | def _to_status_exception(status: int, message: str) -> CentralDogmaException: 186 | if status == HTTPStatus.UNAUTHORIZED: 187 | return UnauthorizedException(message) 188 | elif status == HTTPStatus.BAD_REQUEST: 189 | return BadRequestException(message) 190 | elif status == HTTPStatus.NOT_FOUND: 191 | return NotFoundException(message) 192 | else: 193 | return UnknownException(message) 194 | -------------------------------------------------------------------------------- /centraldogma/project_service.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from http import HTTPStatus 15 | from typing import List 16 | 17 | from centraldogma.base_client import BaseClient 18 | from centraldogma.data import Project 19 | 20 | 21 | class ProjectService: 22 | def __init__(self, client: BaseClient): 23 | self.client = client 24 | 25 | def list(self, removed: bool) -> List[Project]: 26 | params = {"status": "removed"} if removed else None 27 | handler = { 28 | HTTPStatus.OK: lambda resp: [ 29 | Project.from_dict(project) for project in resp.json() 30 | ], 31 | HTTPStatus.NO_CONTENT: lambda resp: [], 32 | } 33 | return self.client.request("get", "/projects", params=params, handler=handler) 34 | 35 | def create(self, name: str) -> Project: 36 | handler = {HTTPStatus.CREATED: lambda resp: Project.from_dict(resp.json())} 37 | return self.client.request( 38 | "post", "/projects", json={"name": name}, handler=handler 39 | ) 40 | 41 | def remove(self, name: str) -> None: 42 | handler = {HTTPStatus.NO_CONTENT: lambda resp: None} 43 | return self.client.request("delete", f"/projects/{name}", handler=handler) 44 | 45 | def unremove(self, name: str) -> Project: 46 | body = [{"op": "replace", "path": "/status", "value": "active"}] 47 | handler = {HTTPStatus.OK: lambda resp: Project.from_dict(resp.json())} 48 | return self.client.request( 49 | "patch", f"/projects/{name}", json=body, handler=handler 50 | ) 51 | 52 | def purge(self, name: str) -> None: 53 | handler = {HTTPStatus.NO_CONTENT: lambda resp: None} 54 | return self.client.request( 55 | "delete", f"/projects/{name}/removed", handler=handler 56 | ) 57 | -------------------------------------------------------------------------------- /centraldogma/query.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from __future__ import annotations 15 | 16 | from dataclasses import dataclass, field 17 | from enum import Enum 18 | from typing import TypeVar, Generic, Any, List 19 | 20 | 21 | class QueryType(Enum): 22 | IDENTITY = "IDENTITY" 23 | IDENTITY_TEXT = "IDENTITY_TEXT" 24 | IDENTITY_JSON = "IDENTITY_JSON" 25 | JSON_PATH = "JSON_PATH" 26 | 27 | 28 | T = TypeVar("T") 29 | 30 | 31 | @dataclass 32 | class Query(Generic[T]): 33 | """A query on a file.""" 34 | 35 | path: str 36 | query_type: QueryType 37 | expressions: List[str] = field(default_factory=list) 38 | 39 | @staticmethod 40 | def identity(path: str) -> Query[str]: 41 | """Returns a newly-created ``Query`` that retrieves the content as it is. 42 | 43 | :param path: the path of a file being queried on 44 | """ 45 | return Query(path=path, query_type=QueryType.IDENTITY) 46 | 47 | @staticmethod 48 | def text(path: str) -> Query[str]: 49 | """Returns a newly-created ``Query`` that retrieves the textual content as it is. 50 | 51 | :param path: the path of a file being queried on 52 | """ 53 | return Query(path=path, query_type=QueryType.IDENTITY_TEXT) 54 | 55 | @staticmethod 56 | def json(path: str) -> Query[Any]: 57 | """Returns a newly-created ``Query`` that retrieves the JSON content as it is. 58 | 59 | :param path: the path of a file being queried on 60 | """ 61 | return Query(path=path, query_type=QueryType.IDENTITY_JSON) 62 | 63 | @staticmethod 64 | def json_path(path: str, json_paths: List[str]) -> Query[Any]: 65 | """Returns a newly-created ``Query`` that applies a series of 66 | `JSON path expressions `_ to the content. 67 | 68 | :param path: the path of a file being queried on 69 | :param json_paths: the JSON path expressions to apply 70 | """ 71 | return Query(path=path, query_type=QueryType.JSON_PATH, expressions=json_paths) 72 | -------------------------------------------------------------------------------- /centraldogma/repository_service.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from http import HTTPStatus 15 | from typing import List 16 | 17 | from centraldogma.base_client import BaseClient 18 | from centraldogma.data import Repository 19 | 20 | 21 | class RepositoryService: 22 | def __init__(self, client: BaseClient): 23 | self.client = client 24 | 25 | def list(self, project_name: str, removed: bool) -> List[Repository]: 26 | params = {"status": "removed"} if removed else None 27 | handler = { 28 | HTTPStatus.OK: lambda resp: [ 29 | Repository.from_dict(repo) for repo in resp.json() 30 | ], 31 | HTTPStatus.NO_CONTENT: lambda resp: [], 32 | } 33 | return self.client.request( 34 | "get", f"/projects/{project_name}/repos", params=params, handler=handler 35 | ) 36 | 37 | def create(self, project_name: str, name: str) -> Repository: 38 | handler = {HTTPStatus.CREATED: lambda resp: Repository.from_dict(resp.json())} 39 | return self.client.request( 40 | "post", 41 | f"/projects/{project_name}/repos", 42 | json={"name": name}, 43 | handler=handler, 44 | ) 45 | 46 | def remove(self, project_name: str, name: str) -> None: 47 | handler = {HTTPStatus.NO_CONTENT: lambda resp: None} 48 | return self.client.request( 49 | "delete", f"/projects/{project_name}/repos/{name}", handler=handler 50 | ) 51 | 52 | def unremove(self, project_name: str, name: str) -> Repository: 53 | body = [{"op": "replace", "path": "/status", "value": "active"}] 54 | handler = {HTTPStatus.OK: lambda resp: Repository.from_dict(resp.json())} 55 | return self.client.request( 56 | "patch", 57 | f"/projects/{project_name}/repos/{name}", 58 | json=body, 59 | handler=handler, 60 | ) 61 | 62 | def purge(self, project_name: str, name: str) -> None: 63 | handler = {HTTPStatus.NO_CONTENT: lambda resp: None} 64 | return self.client.request( 65 | "delete", f"/projects/{project_name}/repos/{name}/removed", handler=handler 66 | ) 67 | 68 | def normalize_revision(self, project_name: str, name: str, revision: int) -> int: 69 | handler = {HTTPStatus.OK: lambda resp: resp.json()["revision"]} 70 | return self.client.request( 71 | "get", 72 | f"/projects/{project_name}/repos/{name}/revision/{revision}", 73 | handler=handler, 74 | ) 75 | -------------------------------------------------------------------------------- /centraldogma/repository_watcher.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | import itertools 15 | import logging 16 | import math 17 | import threading 18 | import time 19 | from concurrent.futures import Future 20 | from enum import Enum 21 | from random import randrange 22 | from threading import Thread, Lock 23 | from typing import TypeVar, Callable, Optional, List 24 | 25 | from centraldogma.content_service import ContentService 26 | from centraldogma.data.revision import Revision 27 | from centraldogma.exceptions import ( 28 | EntryNotFoundException, 29 | RepositoryNotFoundException, 30 | ShuttingDownException, 31 | ) 32 | from centraldogma.query import Query 33 | from centraldogma.watcher import Watcher, Latest 34 | 35 | S = TypeVar("S") 36 | T = TypeVar("T") 37 | 38 | 39 | class WatchState(Enum): 40 | INIT = "INIT" 41 | STARTED = "STARTED" 42 | STOPPED = "STOPPED" 43 | 44 | 45 | _DELAY_ON_SUCCESS_MILLIS = 1000 # 1 second 46 | _MIN_INTERVAL_MILLIS = _DELAY_ON_SUCCESS_MILLIS * 2 # 2 seconds 47 | _MAX_INTERVAL_MILLIS = 1 * 60 * 1000 # 1 minute 48 | _JITTER_RATE = 0.2 49 | _THREAD_ID = itertools.count() 50 | 51 | 52 | class AbstractWatcher(Watcher[T]): 53 | def __init__( 54 | self, 55 | content_service: ContentService, 56 | project_name: str, 57 | repo_name: str, 58 | path_pattern: str, 59 | timeout_millis: int, 60 | function: Callable[[S], T], 61 | ): 62 | self.content_service = content_service 63 | self.project_name = project_name 64 | self.repo_name = repo_name 65 | self.path_pattern = path_pattern 66 | self.timeout_millis = timeout_millis 67 | self.function = function 68 | 69 | # states 70 | self._latest: Optional[Latest[T]] = None 71 | self._state: WatchState = WatchState.INIT 72 | # The actual type of `_initial_value_future` is `Future[Latest[T]]` that is unavailable under Python 3.9. 73 | self._initial_value_future: Future = Future() 74 | self._update_listeners: List[Callable[[Revision, T], None]] = [] 75 | self._thread: Optional[threading.Thread] = None 76 | self._lock: Lock = threading.Lock() 77 | 78 | def start(self): 79 | with self._lock: 80 | if self._state != WatchState.INIT: 81 | return 82 | else: 83 | self._state = WatchState.STARTED 84 | 85 | # FIXME(ikhoon): Replace Thread with Coroutine of asyncio once AsyncClient is implemented. 86 | self._thread = Thread( 87 | target=self._schedule_watch, 88 | args=(0,), 89 | name=f"centraldogma-watcher-{next(_THREAD_ID)}", 90 | daemon=True, 91 | ) 92 | self._thread.start() 93 | 94 | def close(self) -> None: 95 | self._state = WatchState.STOPPED 96 | 97 | def __enter__(self): 98 | return self 99 | 100 | def __exit__(self, exc_type, exc_val, exc_tb): 101 | self.close() 102 | 103 | def latest(self) -> Latest[T]: 104 | return self._latest 105 | 106 | def initial_value_future(self) -> Future: 107 | return self._initial_value_future 108 | 109 | def watch(self, listener: Callable[[Revision, T], None]) -> None: 110 | self._update_listeners.append(listener) 111 | 112 | if self._latest: 113 | listener(self._latest.revision, self._latest.value) 114 | 115 | def _schedule_watch(self, num_attempts_so_far: int) -> None: 116 | num_attempts = num_attempts_so_far 117 | while num_attempts >= 0: 118 | if self._is_stopped(): 119 | break 120 | 121 | if num_attempts == 0: 122 | delay = _DELAY_ON_SUCCESS_MILLIS if self._latest is None else 0 123 | else: 124 | delay = self._next_delay_millis(num_attempts) 125 | 126 | # FIXME(ikhoon): Replace asyncio.sleep() after AsyncClient is implemented. 127 | time.sleep(delay / 1000) 128 | num_attempts = self._watch(num_attempts) 129 | return None 130 | 131 | def _watch(self, num_attempts_so_far: int) -> int: 132 | if self._is_stopped(): 133 | return -1 134 | 135 | last_known_revision = self._latest.revision if self._latest else Revision.init() 136 | try: 137 | new_latest = self._do_watch(last_known_revision) 138 | if new_latest: 139 | old_latest = self._latest 140 | self._latest = new_latest 141 | logging.debug( 142 | "watcher noticed updated file %s/%s%s: rev=%s", 143 | self.project_name, 144 | self.repo_name, 145 | self.path_pattern, 146 | new_latest.revision, 147 | ) 148 | self.notify_listeners() 149 | if not old_latest: 150 | self._initial_value_future.set_result(new_latest) 151 | # Watch again for the next change. 152 | return 0 153 | except Exception as ex: 154 | if isinstance(ex, EntryNotFoundException): 155 | logging.info( 156 | "%s/%s%s does not exist yet; trying again", 157 | self.project_name, 158 | self.repo_name, 159 | self.path_pattern, 160 | ) 161 | elif isinstance(ex, RepositoryNotFoundException): 162 | logging.info( 163 | "%s/%s does not exist yet; trying again", 164 | self.project_name, 165 | self.repo_name, 166 | ) 167 | elif isinstance(ex, ShuttingDownException): 168 | logging.info("Central Dogma is shutting down; trying again") 169 | else: 170 | logging.warning( 171 | "Failed to watch a file (%s/%s%s) at Central Dogma; trying again.\n%s", 172 | self.project_name, 173 | self.repo_name, 174 | self.path_pattern, 175 | ex, 176 | ) 177 | return num_attempts_so_far + 1 178 | 179 | def _do_watch(self, last_known_revision: Revision) -> Optional[Latest[T]]: 180 | pass 181 | 182 | def notify_listeners(self): 183 | if self._is_stopped(): 184 | # Do not notify after stopped. 185 | return 186 | 187 | latest = self._latest 188 | for listener in self._update_listeners: 189 | # noinspection PyBroadException 190 | try: 191 | listener(latest.revision, latest.value) 192 | except Exception as ex: 193 | logging.exception( 194 | "Exception thrown for watcher (%s/%s%s): rev=%s.", 195 | self.project_name, 196 | self.repo_name, 197 | self.path_pattern, 198 | latest.revision, 199 | ) 200 | 201 | def _is_stopped(self) -> bool: 202 | return self._state == WatchState.STOPPED 203 | 204 | @staticmethod 205 | def _next_delay_millis(num_attempts_so_far: int) -> int: 206 | if num_attempts_so_far == 1: 207 | next_delay_millis = _MIN_INTERVAL_MILLIS 208 | else: 209 | delay = _MIN_INTERVAL_MILLIS * math.pow(2.0, num_attempts_so_far - 1) 210 | next_delay_millis = min(delay, _MAX_INTERVAL_MILLIS) 211 | 212 | min_jitter = int(next_delay_millis * (1 - _JITTER_RATE)) 213 | max_jitter = int(next_delay_millis * (1 + _JITTER_RATE)) 214 | bound = max_jitter - min_jitter + 1 215 | jitter = randrange(bound) 216 | return max(0, min_jitter + jitter) 217 | 218 | 219 | class FileWatcher(AbstractWatcher[T]): 220 | def __init__( 221 | self, 222 | content_service: ContentService, 223 | project_name: str, 224 | repo_name: str, 225 | query: Query[T], 226 | timeout_millis: int, 227 | function: Callable[[S], T], 228 | ): 229 | super().__init__( 230 | content_service, 231 | project_name, 232 | repo_name, 233 | query.path, 234 | timeout_millis, 235 | function, 236 | ) 237 | self.query = query 238 | 239 | def _do_watch(self, last_known_revision: Revision) -> Optional[Latest[T]]: 240 | result = self.content_service.watch_file( 241 | self.project_name, 242 | self.repo_name, 243 | last_known_revision, 244 | self.query, 245 | self.timeout_millis, 246 | ) 247 | if not result: 248 | return None 249 | return Latest(result.revision, self.function(result.content)) 250 | 251 | 252 | class RepositoryWatcher(AbstractWatcher[T]): 253 | def __init__( 254 | self, 255 | content_service: ContentService, 256 | project_name: str, 257 | repo_name: str, 258 | path_pattern: str, 259 | timeout_millis: int, 260 | function: Callable[[Revision], T], 261 | ): 262 | super().__init__( 263 | content_service, 264 | project_name, 265 | repo_name, 266 | path_pattern, 267 | timeout_millis, 268 | function, 269 | ) 270 | 271 | def _do_watch(self, last_known_revision) -> Optional[Latest[T]]: 272 | revision = self.content_service.watch_repository( 273 | self.project_name, 274 | self.repo_name, 275 | last_known_revision, 276 | self.path_pattern, 277 | self.timeout_millis, 278 | ) 279 | if not revision: 280 | return None 281 | return Latest(revision, self.function(revision)) 282 | -------------------------------------------------------------------------------- /centraldogma/util.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | 15 | 16 | def to_string(obj) -> str: 17 | items = vars(obj).items() 18 | values = [f"{k}={v}" for k, v in items] 19 | return f"{obj.__class__.__name__}({','.join(values)})" 20 | -------------------------------------------------------------------------------- /centraldogma/watcher.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from concurrent.futures import Future 15 | from dataclasses import dataclass 16 | from typing import TypeVar, Generic, Callable 17 | 18 | from centraldogma.data.revision import Revision 19 | 20 | T = TypeVar("T") 21 | 22 | 23 | @dataclass 24 | class Latest(Generic[T]): 25 | """A holder of the latest known value and its `Revision` retrieved by `Watcher`.""" 26 | 27 | revision: Revision 28 | value: T 29 | 30 | 31 | class Watcher(Generic[T]): 32 | """Watches the changes of a repository or a file.""" 33 | 34 | def latest(self) -> Latest[T]: 35 | """Returns the latest ``Revision`` and value of ``watch_file()`` result.""" 36 | pass 37 | 38 | # TODO(ikhoon): Use Generic `Future[Latest[T]]` when the Python 3.9 becomes the baseline. 39 | # https://github.com/python/typing/issues/446#issuecomment-623251451 40 | def initial_value_future(self) -> Future: 41 | """Returns the ``Future`` which completes a ``Latest[T]`` when the initial value retrieval is done 42 | successfully. 43 | """ 44 | pass 45 | 46 | def await_initial_value(self) -> Latest[T]: 47 | """Waits for the initial value to be available.""" 48 | return self.initial_value_future().result() 49 | 50 | def watch(self, listener: Callable[[Revision, T], None]) -> None: 51 | """Registers a listener that will be invoked when the value of the watched entry becomes 52 | available or changes. 53 | """ 54 | pass 55 | 56 | def close(self) -> None: 57 | """Stops watching the file specified in the ``Query`` or the path pattern in the repository.""" 58 | pass 59 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | centraldogma: 3 | image: line/centraldogma:latest 4 | ports: 5 | - "36462:36462" 6 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/centraldogma.data.rst: -------------------------------------------------------------------------------- 1 | centraldogma.data package 2 | ========================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | centraldogma.data.change module 8 | ------------------------------- 9 | 10 | .. automodule:: centraldogma.data.change 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | centraldogma.data.commit module 16 | ------------------------------- 17 | 18 | .. automodule:: centraldogma.data.commit 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | centraldogma.data.content module 24 | -------------------------------- 25 | 26 | .. automodule:: centraldogma.data.content 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | centraldogma.data.creator module 32 | -------------------------------- 33 | 34 | .. automodule:: centraldogma.data.creator 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | centraldogma.data.entry module 40 | ---------------------------------- 41 | 42 | .. automodule:: centraldogma.data.entry 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | centraldogma.data.merge\_source module 48 | --------------------------------------- 49 | 50 | .. automodule:: centraldogma.data.merge_source 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | centraldogma.data.merged\_entry module 56 | --------------------------------------- 57 | 58 | .. automodule:: centraldogma.data.merged_entry 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | centraldogma.data.project module 64 | -------------------------------- 65 | 66 | .. automodule:: centraldogma.data.project 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | 71 | centraldogma.data.push\_result module 72 | ------------------------------------- 73 | 74 | .. automodule:: centraldogma.data.push_result 75 | :members: 76 | :undoc-members: 77 | :show-inheritance: 78 | 79 | centraldogma.data.repository module 80 | ----------------------------------- 81 | 82 | .. automodule:: centraldogma.data.repository 83 | :members: 84 | :undoc-members: 85 | :show-inheritance: 86 | 87 | centraldogma.data.revision module 88 | ----------------------------------- 89 | 90 | .. automodule:: centraldogma.data.revision 91 | :members: 92 | :undoc-members: 93 | :show-inheritance: 94 | -------------------------------------------------------------------------------- /docs/centraldogma.rst: -------------------------------------------------------------------------------- 1 | centraldogma package 2 | ==================== 3 | 4 | centraldogma.dogma module 5 | ------------------------- 6 | 7 | .. automodule:: centraldogma.dogma 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | centraldogma.project\_service module 13 | ------------------------------------ 14 | 15 | .. automodule:: centraldogma.project_service 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | centraldogma.repository\_service module 21 | --------------------------------------- 22 | 23 | .. automodule:: centraldogma.repository_service 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | centraldogma.content\_service module 29 | ------------------------------------ 30 | 31 | .. automodule:: centraldogma.content_service 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: 35 | 36 | centraldogma.watcher module 37 | -------------------------------- 38 | 39 | .. automodule:: centraldogma.watcher 40 | :members: 41 | :undoc-members: 42 | :show-inheritance: 43 | 44 | centraldogma.query module 45 | -------------------------------- 46 | 47 | .. automodule:: centraldogma.query 48 | :members: 49 | :undoc-members: 50 | :show-inheritance: 51 | 52 | centraldogma.exceptions module 53 | ------------------------------ 54 | 55 | .. automodule:: centraldogma.exceptions 56 | :members: 57 | :undoc-members: 58 | :show-inheritance: 59 | 60 | Subpackages 61 | ----------- 62 | 63 | .. toctree:: 64 | :maxdepth: 4 65 | 66 | centraldogma.data 67 | 68 | .. |br| raw:: html 69 | 70 |
-------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | 16 | sys.path.insert(0, os.path.abspath("..")) 17 | 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = "centraldogma-python" 22 | copyright = "2017-2025, LINE Corporation" 23 | author = "Central Dogma Team" 24 | 25 | 26 | # -- General configuration --------------------------------------------------- 27 | 28 | # Add any Sphinx extension module names here, as strings. They can be 29 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 30 | # ones. 31 | extensions = [ 32 | "sphinx.ext.autodoc", 33 | "sphinx_rtd_theme", 34 | ] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ["_templates"] 38 | 39 | # List of patterns, relative to source directory, that match files and 40 | # directories to ignore when looking for source files. 41 | # This pattern also affects html_static_path and html_extra_path. 42 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 43 | 44 | 45 | # -- Options for HTML output ------------------------------------------------- 46 | 47 | # The theme to use for HTML and HTML Help pages. See the documentation for 48 | # a list of builtin themes. 49 | # 50 | html_theme = "sphinx_rtd_theme" 51 | 52 | # Add any paths that contain custom static files (such as style sheets) here, 53 | # relative to this directory. They are copied after the builtin static files, 54 | # so a file named "default.css" will overwrite the builtin "default.css". 55 | html_static_path = ["_static"] 56 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. centraldogma-python documentation master file, created by 2 | sphinx-quickstart on Fri Jan 7 16:27:06 2022. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to centraldogma-python's documentation! 7 | =============================================== 8 | 9 | .. toctree:: 10 | :maxdepth: 2 11 | :caption: Contents: 12 | 13 | modules 14 | 15 | Indices and tables 16 | ================== 17 | 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.https://www.sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | centraldogma 2 | ============ 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | centraldogma 8 | -------------------------------------------------------------------------------- /examples/hierarchical_get.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from centraldogma.dogma import Dogma 15 | 16 | dogma = Dogma("https://dogma.yourdomain.com", "token") 17 | 18 | # List projects 19 | projects = dogma.list_projects() 20 | print("List projects----------------------") 21 | if len(projects) < 1: 22 | print("No content") 23 | exit() 24 | for project in projects: 25 | print(project) 26 | 27 | # List repos 28 | project_name = projects[0].name 29 | repos = dogma.list_repositories(project_name) 30 | print("\nList repositories------------------") 31 | if len(repos) < 1: 32 | print("No content") 33 | exit() 34 | for repo in repos: 35 | print(repo) 36 | 37 | # List files 38 | repo_name = repos[0].name 39 | files = dogma.list_files(project_name, repo_name) 40 | print("\nList files-------------------------") 41 | if len(files) < 1: 42 | print("No content") 43 | exit() 44 | for file in files: 45 | print(file) 46 | 47 | # Get files 48 | repo_name = repos[0].name 49 | files = dogma.get_files(project_name, repo_name) 50 | print("\nGet files-------------------------") 51 | if len(files) < 1: 52 | print("No content") 53 | exit() 54 | for file in files: 55 | print(file) 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Copyright 2024 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | [build-system] 15 | requires = ["setuptools >= 61"] 16 | build-backend = "setuptools.build_meta" 17 | 18 | [project] 19 | name = "centraldogma-python" 20 | dynamic = ["version"] 21 | description = "Python client library for Central Dogma" 22 | readme = "README.md" 23 | authors= [{name = "Central Dogma Team", email = "dl_centraldogma@linecorp.com"}] 24 | license = {file = "LICENSE"} 25 | classifiers = [ 26 | "Development Status :: 4 - Beta", 27 | "Intended Audience :: Developers", 28 | "License :: OSI Approved :: Apache Software License", 29 | "Operating System :: OS Independent", 30 | "Programming Language :: Python", 31 | "Programming Language :: Python :: 3", 32 | "Programming Language :: Python :: 3.9", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "Programming Language :: Python :: 3.12", 36 | "Programming Language :: Python :: 3.13", 37 | "Topic :: Software Development :: Libraries :: Python Modules", 38 | ] 39 | requires-python = ">=3.9" 40 | dependencies = [ 41 | "dataclasses-json == 0.6.7", 42 | "httpx[http2] == 0.27.2", 43 | "marshmallow == 3.23.0", 44 | "pydantic == 2.9.2", 45 | "python-dateutil == 2.9.0.post0", 46 | "tenacity == 9.0.0", 47 | ] 48 | 49 | [project.urls] 50 | Homepage = "https://github.com/line/centraldogma-python" 51 | Documentation = "https://line.github.io/centraldogma-python" 52 | Repository = "https://github.com/line/centraldogma-python.git" 53 | Issues = "https://github.com/line/centraldogma-python/issues" 54 | 55 | [project.optional-dependencies] 56 | dev = [ 57 | "black", 58 | "codecov", 59 | "pytest", 60 | "pytest-cov", 61 | "pytest-mock", 62 | "respx" 63 | ] 64 | docs = [ 65 | "sphinx_rtd_theme" 66 | ] 67 | 68 | [tool.setuptools] 69 | packages = ["centraldogma", "centraldogma.data"] 70 | 71 | [tool.setuptools.dynamic] 72 | version = {attr = "centraldogma.__version__"} 73 | 74 | [tool.distutils.bdist_wheel] 75 | universal = true 76 | 77 | [tool.pep8] 78 | ignore = "E501" 79 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | minversion = 6.0 3 | addopts = -ra -q 4 | testpaths = 5 | tests 6 | markers = 7 | integration: mark as integration tests (deselect with '-m "not integration"') 8 | 9 | log_format = %(asctime)s %(levelname)s %(message)s 10 | log_date_format = %Y-%m-%d %H:%M:%S 11 | log_cli=true 12 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/line/centraldogma-python/a2c2b98507c6905374ae13cfdc61ace3672e38b4/tests/__init__.py -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/line/centraldogma-python/a2c2b98507c6905374ae13cfdc61ace3672e38b4/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_content_service.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | import os 15 | import time 16 | from concurrent.futures import ThreadPoolExecutor 17 | from datetime import datetime 18 | from typing import Optional, Any 19 | 20 | import pytest 21 | 22 | from centraldogma.data.entry import Entry, EntryType 23 | from centraldogma.data.merge_source import MergeSource 24 | from centraldogma.data.revision import Revision 25 | from centraldogma.dogma import Change, ChangeType, Commit, Dogma 26 | from centraldogma.exceptions import ( 27 | BadRequestException, 28 | RedundantChangeException, 29 | ChangeConflictException, 30 | CentralDogmaException, 31 | EntryNotFoundException, 32 | QueryExecutionException, 33 | ) 34 | from centraldogma.query import Query 35 | 36 | dogma = Dogma(retries=3) 37 | project_name = "TestProject" 38 | repo_name = "TestRepository" 39 | 40 | 41 | @pytest.fixture(scope="module") 42 | def run_around_test(): 43 | dogma.create_project(project_name) 44 | dogma.create_repository(project_name, repo_name) 45 | yield 46 | dogma.remove_repository(project_name, repo_name) 47 | dogma.purge_repository(project_name, repo_name) 48 | dogma.remove_project(project_name) 49 | dogma.purge_project(project_name) 50 | 51 | 52 | @pytest.mark.skipif( 53 | os.getenv("INTEGRATION_TEST", "false").lower() != "true", 54 | reason="Integration tests are disabled. Use `INTEGRATION_TEST=true pytest` to enable them.", 55 | ) 56 | @pytest.mark.integration 57 | class TestContentService: 58 | def test_content(self, run_around_test): 59 | files = dogma.list_files(project_name, repo_name) 60 | assert len(files) == 0 61 | 62 | commit = Commit("Upsert test.json") 63 | upsert_json = Change("/test .json", ChangeType.UPSERT_JSON, {"foo": "bar"}) 64 | with pytest.raises(BadRequestException): 65 | dogma.push(project_name, repo_name, commit, [upsert_json]) 66 | upsert_json = Change("/test.json", ChangeType.UPSERT_JSON, {"foo": "bar"}) 67 | ret = dogma.push(project_name, repo_name, commit, [upsert_json]) 68 | assert ret.revision == 2 69 | 70 | with pytest.raises(RedundantChangeException): 71 | dogma.push(project_name, repo_name, commit, [upsert_json]) 72 | 73 | commit = Commit("Upsert test.txt") 74 | upsert_text = Change("/path/test.txt", ChangeType.UPSERT_TEXT, "foo") 75 | ret = dogma.push(project_name, repo_name, commit, [upsert_text]) 76 | assert ret.revision == 3 77 | 78 | commit = Commit("Upsert both json and txt") 79 | upsert_json = Change("/test.json", ChangeType.UPSERT_JSON, {"foo": "bar2"}) 80 | upsert_text = Change("/path/test.txt", ChangeType.UPSERT_TEXT, "foo2") 81 | ret = dogma.push(project_name, repo_name, commit, [upsert_json, upsert_text]) 82 | assert ret.revision == 4 83 | 84 | commit = Commit("Rename the json") 85 | rename_json = Change("/test2.json", ChangeType.RENAME, "/test3.json") 86 | with pytest.raises(ChangeConflictException): 87 | dogma.push(project_name, repo_name, commit, [rename_json]) 88 | rename_json = Change("/test.json", ChangeType.RENAME, "") 89 | 90 | with pytest.raises(BadRequestException): 91 | dogma.push(project_name, repo_name, commit, [rename_json]) 92 | rename_json = Change("/test.json", ChangeType.RENAME, "/test2.json") 93 | ret = dogma.push(project_name, repo_name, commit, [rename_json]) 94 | assert ret.revision == 5 95 | 96 | files = dogma.list_files(project_name, repo_name) 97 | assert len(files) == 2 98 | assert set(map(lambda x: x.path, files)) == {"/path", "/test2.json"} 99 | assert set(map(lambda x: x.type, files)) == {"DIRECTORY", "JSON"} 100 | 101 | commit = Commit("Remove the json") 102 | remove_json = Change("/test.json", ChangeType.REMOVE) 103 | with pytest.raises(ChangeConflictException): 104 | dogma.push(project_name, repo_name, commit, [remove_json]) 105 | remove_json = Change("/test2.json", ChangeType.REMOVE) 106 | ret = dogma.push(project_name, repo_name, commit, [remove_json]) 107 | assert ret.revision == 6 108 | 109 | with pytest.raises(ChangeConflictException): 110 | dogma.push(project_name, repo_name, commit, [remove_json]) 111 | 112 | files = dogma.list_files(project_name, repo_name) 113 | assert len(files) == 1 114 | 115 | commit = Commit("Remove the folder") 116 | remove_folder = Change("/path", ChangeType.REMOVE) 117 | ret = dogma.push(project_name, repo_name, commit, [remove_folder]) 118 | assert ret.revision == 7 119 | 120 | files = dogma.list_files(project_name, repo_name) 121 | assert len(files) == 0 122 | 123 | def test_watch_repository(self, run_around_test): 124 | commit = Commit("Upsert test.json") 125 | upsert_json = Change("/test.json", ChangeType.UPSERT_JSON, {"foo": "bar"}) 126 | ret = dogma.push(project_name, repo_name, commit, [upsert_json]) 127 | 128 | start = datetime.now() 129 | revision = dogma.watch_repository( 130 | project_name, repo_name, Revision(ret.revision), "/**", 2000 131 | ) 132 | end = datetime.now() 133 | assert not revision # Not modified 134 | assert (end - start).seconds >= 1 135 | 136 | with ThreadPoolExecutor(max_workers=1) as e: 137 | e.submit(self.push_later) 138 | start = datetime.now() 139 | revision = dogma.watch_repository( 140 | project_name, repo_name, Revision(ret.revision), "/**", 4000 141 | ) 142 | end = datetime.now() 143 | assert revision.major == ret.revision + 1 144 | assert (end - start).seconds < 3 145 | 146 | def test_watch_file(self, run_around_test): 147 | commit = Commit("Upsert test.json") 148 | upsert_json = Change("/test.json", ChangeType.UPSERT_JSON, {"foo": "bar"}) 149 | ret = dogma.push(project_name, repo_name, commit, [upsert_json]) 150 | 151 | start = datetime.now() 152 | entry: Optional[Entry[Any]] = dogma.watch_file( 153 | project_name, 154 | repo_name, 155 | Revision(ret.revision), 156 | Query.json("/test.json"), 157 | 2000, 158 | ) 159 | end = datetime.now() 160 | assert not entry # Not modified 161 | assert (end - start).seconds >= 1 162 | 163 | with ThreadPoolExecutor(max_workers=1) as e: 164 | e.submit(self.push_later) 165 | start = datetime.now() 166 | entry = dogma.watch_file( 167 | project_name, 168 | repo_name, 169 | Revision(ret.revision), 170 | Query.json("/test.json"), 171 | 4000, 172 | ) 173 | end = datetime.now() 174 | assert entry.revision.major == ret.revision + 1 175 | assert entry.content == {"foo": "qux"} 176 | assert (end - start).seconds < 3 177 | 178 | def test_invalid_entry_type(self, run_around_test): 179 | commit = Commit("Upsert test.txt") 180 | upsert_text = Change("/test.txt", ChangeType.UPSERT_TEXT, "foo") 181 | ret = dogma.push(project_name, repo_name, commit, [upsert_text]) 182 | 183 | upsert_text = Change("/test.txt", ChangeType.UPSERT_TEXT, "bar") 184 | dogma.push(project_name, repo_name, commit, [upsert_text]) 185 | 186 | with pytest.raises(CentralDogmaException) as ex: 187 | dogma.watch_file( 188 | project_name, 189 | repo_name, 190 | Revision(ret.revision), 191 | Query.json("/test.txt"), # A wrong JSON query for a text 192 | 2000, 193 | ) 194 | assert ( 195 | "invalid entry type. entry type: EntryType.TEXT (expected: QueryType.IDENTITY_JSON)" 196 | in str(ex.value) 197 | ) 198 | 199 | def test_merge_files(self, run_around_test): 200 | commit = Commit("Upsert test.json") 201 | upsert_json = Change("/test.json", ChangeType.UPSERT_JSON, {"foo": "bar"}) 202 | dogma.push(project_name, repo_name, commit, [upsert_json]) 203 | upsert_json = Change("/test2.json", ChangeType.UPSERT_JSON, {"foo2": "bar2"}) 204 | dogma.push(project_name, repo_name, commit, [upsert_json]) 205 | upsert_json = Change( 206 | "/test3.json", 207 | ChangeType.UPSERT_JSON, 208 | {"inner": {"inner2": {"foo3": "bar3"}}}, 209 | ) 210 | dogma.push(project_name, repo_name, commit, [upsert_json]) 211 | 212 | merge_sources = [ 213 | MergeSource("/nonexisting.json", False), 214 | ] 215 | with pytest.raises(EntryNotFoundException): 216 | dogma.merge_files(project_name, repo_name, merge_sources) 217 | 218 | merge_sources = [ 219 | MergeSource("/test.json", True), 220 | MergeSource("/test2.json", True), 221 | MergeSource("/test3.json", True), 222 | ] 223 | ret = dogma.merge_files(project_name, repo_name, merge_sources) 224 | assert ret.entry_type == EntryType.JSON 225 | assert ret.content == { 226 | "foo": "bar", 227 | "foo2": "bar2", 228 | "inner": {"inner2": {"foo3": "bar3"}}, 229 | } 230 | 231 | with pytest.raises(QueryExecutionException): 232 | dogma.merge_files(project_name, repo_name, merge_sources, ["$.inner2"]) 233 | 234 | ret = dogma.merge_files(project_name, repo_name, merge_sources, ["$.inner"]) 235 | assert ret.entry_type == EntryType.JSON 236 | assert ret.content == {"inner2": {"foo3": "bar3"}} 237 | 238 | ret = dogma.merge_files( 239 | project_name, repo_name, merge_sources, ["$.inner", "$.inner2"] 240 | ) 241 | assert ret.entry_type == EntryType.JSON 242 | assert ret.content == {"foo3": "bar3"} 243 | 244 | def test_get_files_for_single_file(self, run_around_test): 245 | commit = Commit("Upsert dummy1-test.json") 246 | upsert_json = Change( 247 | "/test/dummy1-test.json", ChangeType.UPSERT_JSON, {"foo": "bar"} 248 | ) 249 | dogma.push(project_name, repo_name, commit, [upsert_json]) 250 | 251 | ret = dogma.get_files(project_name, repo_name, "/test/dummy1-test.json") 252 | assert len(ret) == 1 253 | 254 | def test_get_files_for_multiple_file(self, run_around_test): 255 | commit = Commit("Upsert dummy1-test.json") 256 | upsert_json = Change( 257 | "/dummy1-test.json", ChangeType.UPSERT_JSON, {"foo": "bar"} 258 | ) 259 | dogma.push(project_name, repo_name, commit, [upsert_json]) 260 | 261 | commit = Commit("Upsert dummy2-test.json") 262 | upsert_json = Change( 263 | "/dummy2-test.json", ChangeType.UPSERT_JSON, {"foo": "bar"} 264 | ) 265 | dogma.push(project_name, repo_name, commit, [upsert_json]) 266 | 267 | ret = dogma.get_files(project_name, repo_name, "/dummy*-test.json") 268 | assert len(ret) == 2 269 | 270 | @staticmethod 271 | def push_later(): 272 | time.sleep(1) 273 | commit = Commit("Upsert test.json") 274 | upsert_json = Change("/test.json", ChangeType.UPSERT_JSON, {"foo": "qux"}) 275 | dogma.push(project_name, repo_name, commit, [upsert_json]) 276 | -------------------------------------------------------------------------------- /tests/integration/test_project_service.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | import os 15 | 16 | import pytest 17 | 18 | from centraldogma.dogma import Dogma 19 | from centraldogma.exceptions import ( 20 | BadRequestException, 21 | ProjectNotFoundException, 22 | ) 23 | 24 | dogma = Dogma(retries=3) 25 | project_name = "TestProject" 26 | 27 | 28 | @pytest.fixture(scope="module") 29 | def run_around_test(): 30 | projects = dogma.list_projects() 31 | removed_projects = dogma.list_projects(removed=True) 32 | 33 | yield 34 | 35 | for project in dogma.list_projects(): 36 | if project not in projects: 37 | dogma.remove_project(project.name) 38 | for removed in dogma.list_projects(removed=True): 39 | if removed not in removed_projects: 40 | dogma.purge_project(removed.name) 41 | 42 | 43 | @pytest.mark.skipif( 44 | os.getenv("INTEGRATION_TEST", "false").lower() != "true", 45 | reason="Integration tests are disabled. Use `INTEGRATION_TEST=true pytest` to enable them.", 46 | ) 47 | @pytest.mark.integration 48 | def test_project(run_around_test): 49 | with pytest.raises(BadRequestException): 50 | dogma.create_project("Test project") 51 | 52 | len_project = len(dogma.list_projects()) 53 | len_removed_project = len(dogma.list_projects(removed=True)) 54 | 55 | new_project = dogma.create_project(project_name) 56 | assert new_project.name == project_name 57 | validate_len(len_project + 1, len_removed_project) 58 | 59 | with pytest.raises(ProjectNotFoundException): 60 | dogma.remove_project("Non-existent") 61 | 62 | dogma.remove_project(project_name) 63 | validate_len(len_project, len_removed_project + 1) 64 | 65 | with pytest.raises(ProjectNotFoundException): 66 | dogma.unremove_project("Non-existent") 67 | 68 | unremoved = dogma.unremove_project(project_name) 69 | assert unremoved.name == project_name 70 | validate_len(len_project + 1, len_removed_project) 71 | 72 | dogma.remove_project(project_name) 73 | dogma.purge_project(project_name) 74 | validate_len(len_project, len_removed_project) 75 | 76 | 77 | def validate_len(expected_len, expected_removed_len): 78 | projects = dogma.list_projects() 79 | removed_projects = dogma.list_projects(removed=True) 80 | assert len(projects) == expected_len 81 | assert len(removed_projects) == expected_removed_len 82 | -------------------------------------------------------------------------------- /tests/integration/test_repository_service.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from centraldogma.dogma import Dogma 15 | from centraldogma.exceptions import ( 16 | BadRequestException, 17 | NotFoundException, 18 | RepositoryNotFoundException, 19 | ) 20 | import pytest 21 | import os 22 | 23 | dogma = Dogma(retries=3) 24 | project_name = "TestProject" 25 | repo_name = "TestRepository" 26 | 27 | 28 | @pytest.fixture(scope="module") 29 | def run_around_test(): 30 | projects = dogma.list_projects() 31 | removed_projects = dogma.list_projects(removed=True) 32 | dogma.create_project(project_name) 33 | 34 | yield 35 | 36 | for project in dogma.list_projects(): 37 | if project not in projects: 38 | dogma.remove_project(project.name) 39 | for removed in dogma.list_projects(removed=True): 40 | if removed not in removed_projects: 41 | dogma.purge_project(removed.name) 42 | 43 | 44 | @pytest.mark.skipif( 45 | os.getenv("INTEGRATION_TEST", "false").lower() != "true", 46 | reason="Integration tests are disabled. Use `INTEGRATION_TEST=true pytest` to enable them.", 47 | ) 48 | @pytest.mark.integration 49 | def test_repository(run_around_test): 50 | with pytest.raises(BadRequestException): 51 | dogma.create_repository(project_name, "Test repo") 52 | 53 | len_repo = len(dogma.list_repositories(project_name)) 54 | len_removed_repo = len(dogma.list_repositories(project_name, removed=True)) 55 | repo = dogma.create_repository(project_name, repo_name) 56 | assert repo.name == repo_name 57 | validate_len(len_repo + 1, len_removed_repo) 58 | 59 | with pytest.raises(RepositoryNotFoundException): 60 | dogma.remove_repository(project_name, "Non-existent") 61 | 62 | dogma.remove_repository(project_name, repo_name) 63 | validate_len(len_repo, len_removed_repo + 1) 64 | 65 | with pytest.raises(RepositoryNotFoundException): 66 | dogma.unremove_repository(project_name, "Non-existent") 67 | 68 | unremoved = dogma.unremove_repository(project_name, repo_name) 69 | assert unremoved.name == repo_name 70 | validate_len(len_repo + 1, len_removed_repo) 71 | 72 | 73 | def validate_len(expected_len, expected_removed_len): 74 | repos = dogma.list_repositories(project_name) 75 | removed_repos = dogma.list_repositories(project_name, removed=True) 76 | assert len(repos) == expected_len 77 | assert len(removed_repos) == expected_removed_len 78 | -------------------------------------------------------------------------------- /tests/integration/test_watcher.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | import json 15 | import os 16 | import time 17 | from concurrent.futures import Future 18 | from concurrent.futures import TimeoutError 19 | 20 | import pytest 21 | 22 | from centraldogma.data import Commit, Change, ChangeType 23 | from centraldogma.data.revision import Revision 24 | from centraldogma.dogma import Dogma 25 | from centraldogma.query import Query 26 | from centraldogma.watcher import Watcher, Latest 27 | 28 | dogma = Dogma(retries=3) 29 | project_name = "TestProject" 30 | repo_name = "TestRepository" 31 | 32 | 33 | @pytest.fixture(scope="module") 34 | def run_around_test(): 35 | dogma.create_project(project_name) 36 | dogma.create_repository(project_name, repo_name) 37 | yield 38 | dogma.remove_repository(project_name, repo_name) 39 | dogma.purge_repository(project_name, repo_name) 40 | dogma.remove_project(project_name) 41 | dogma.purge_project(project_name) 42 | 43 | 44 | @pytest.mark.skipif( 45 | os.getenv("INTEGRATION_TEST", "false").lower() != "true", 46 | reason="Integration tests are disabled. Use `INTEGRATION_TEST=true pytest` to enable them.", 47 | ) 48 | @pytest.mark.integration 49 | class TestWatcher: 50 | def test_repository_watcher(self, run_around_test): 51 | watcher: Watcher[Revision] = dogma.repository_watcher( 52 | project_name, repo_name, "/**" 53 | ) 54 | with watcher: 55 | future: Future[Revision] = Future() 56 | 57 | def listener(revision1: Revision, _: Revision) -> None: 58 | future.set_result(revision1) 59 | 60 | watcher.watch(listener) 61 | commit = Commit("Upsert1 test.txt") 62 | upsert_text = Change("/path/test.txt", ChangeType.UPSERT_TEXT, "foo") 63 | result = dogma.push(project_name, repo_name, commit, [upsert_text]) 64 | watched_revision = future.result() 65 | assert result.revision == watched_revision.major 66 | 67 | future = Future() 68 | commit = Commit("Upsert2 test.txt") 69 | upsert_text = Change("/path/test.txt", ChangeType.UPSERT_TEXT, "bar") 70 | result = dogma.push(project_name, repo_name, commit, [upsert_text]) 71 | watched_revision = future.result() 72 | assert result.revision == watched_revision.major 73 | 74 | future = Future() 75 | commit = Commit("Upsert3 test.txt") 76 | upsert_text = Change("/path/test.txt", ChangeType.UPSERT_TEXT, "qux") 77 | result = dogma.push(project_name, repo_name, commit, [upsert_text]) 78 | watched_revision = future.result() 79 | assert result.revision == watched_revision.major 80 | 81 | def test_file_watcher(self, run_around_test): 82 | commit = Commit("Upsert1 test.json") 83 | upsert_text = Change( 84 | "/test.json", ChangeType.UPSERT_JSON, {"a": 1, "b": 2, "c": 3} 85 | ) 86 | dogma.push(project_name, repo_name, commit, [upsert_text]) 87 | 88 | with dogma.file_watcher( 89 | project_name, 90 | repo_name, 91 | Query.json_path("/test.json", ["$.a"]), 92 | lambda j: json.dumps(j), 93 | ) as watcher: 94 | future: Future[Revision] = Future() 95 | 96 | def listener(revision1: Revision, _: str) -> None: 97 | future.set_result(revision1) 98 | 99 | watcher.watch(listener) 100 | commit = Commit("Upsert1 test.json") 101 | upsert_text = Change( 102 | "/test.json", ChangeType.UPSERT_JSON, {"b": 12, "c": 3} 103 | ) 104 | dogma.push(project_name, repo_name, commit, [upsert_text]) 105 | 106 | with pytest.raises(TimeoutError): 107 | future.result(timeout=1) 108 | 109 | upsert_text = Change( 110 | "/test.json", ChangeType.UPSERT_JSON, {"a": 11, "b": 12, "c": 33} 111 | ) 112 | result = dogma.push(project_name, repo_name, commit, [upsert_text]) 113 | watched_revision = future.result() 114 | assert result.revision == watched_revision.major 115 | 116 | future = Future() 117 | commit = Commit("Upsert2 test.json") 118 | upsert_text = Change( 119 | "/test.json", ChangeType.UPSERT_JSON, {"a": 21, "b": 12, "c": 33} 120 | ) 121 | result = dogma.push(project_name, repo_name, commit, [upsert_text]) 122 | watched_revision = future.result() 123 | assert result.revision == watched_revision.major 124 | 125 | # As the content of 'a' has not been changed, the push event should not trigger 'listener'. 126 | future = Future() 127 | commit = Commit("Upsert3 test.json") 128 | upsert_text = Change( 129 | "/test.json", ChangeType.UPSERT_JSON, {"a": 21, "b": 22, "c": 33} 130 | ) 131 | dogma.push(project_name, repo_name, commit, [upsert_text]) 132 | with pytest.raises(TimeoutError): 133 | future.result(timeout=1) 134 | 135 | def test_file_watcher_multiple_json_path(self): 136 | commit = Commit("Upsert test.json") 137 | upsert_json = Change("/test.json", ChangeType.UPSERT_JSON, {"b": 2}) 138 | dogma.push(project_name, repo_name, commit, [upsert_json]) 139 | 140 | with dogma.file_watcher( 141 | project_name, 142 | repo_name, 143 | # Applies a series of JSON patches that will be translated to '$.a.c' 144 | Query.json_path("/test.json", ["$.a", "$.c"]), 145 | lambda j: json.dumps(j), 146 | ) as watcher: 147 | future: Future[Revision] = Future() 148 | 149 | def listener(revision1: Revision, _: str) -> None: 150 | future.set_result(revision1) 151 | 152 | watcher.watch(listener) 153 | # Entity not found. 154 | with pytest.raises(TimeoutError): 155 | future.result(timeout=2) 156 | 157 | future: Future[Revision] = Future() 158 | json_patch = Change( 159 | "/test.json", 160 | ChangeType.APPLY_JSON_PATCH, 161 | [{"op": "replace", "path": "/b", "value": 12}], 162 | ) 163 | dogma.push(project_name, repo_name, commit, [json_patch]) 164 | 165 | # $.a.c has not changed. 166 | with pytest.raises(TimeoutError): 167 | future.result(timeout=2) 168 | 169 | future: Future[Revision] = Future() 170 | json_patch = Change( 171 | "/test.json", 172 | ChangeType.APPLY_JSON_PATCH, 173 | [{"op": "add", "path": "/a", "value": {"a": 1}}], 174 | ) 175 | dogma.push(project_name, repo_name, commit, [json_patch]) 176 | 177 | # $.a.c has not changed. 178 | with pytest.raises(TimeoutError): 179 | future.result(timeout=2) 180 | 181 | future: Future[Revision] = Future() 182 | json_patch = Change( 183 | "/test.json", 184 | ChangeType.APPLY_JSON_PATCH, 185 | [{"op": "add", "path": "/a", "value": {"c": 3}}], 186 | ) 187 | result = dogma.push(project_name, repo_name, commit, [json_patch]) 188 | watched_revision = future.result() 189 | assert result.revision == watched_revision.major 190 | 191 | def test_await_init_value(self, run_around_test): 192 | with dogma.file_watcher( 193 | project_name, 194 | repo_name, 195 | Query.json("/foo.json"), 196 | lambda j: json.dumps(j), 197 | ) as watcher: 198 | future: Future[Latest[str]] = watcher.initial_value_future() 199 | with pytest.raises(TimeoutError): 200 | future.result(timeout=1) 201 | assert not watcher.latest() 202 | 203 | commit = Commit("Upsert foo.json") 204 | upsert_text = Change("/foo.json", ChangeType.UPSERT_JSON, {"a": 1}) 205 | dogma.push(project_name, repo_name, commit, [upsert_text]) 206 | latest: Latest[str] = future.result() 207 | assert latest.value == '{"a": 1}' 208 | assert watcher.latest() == latest 209 | 210 | def test_not_modified_repository_watcher(self, run_around_test): 211 | """It verifies that a watcher keep watching well even after `NOT_MODIFIED`.""" 212 | timeout_millis = 1000 213 | timeout_second = timeout_millis / 1000 214 | 215 | # pass short timeout millis for testing purpose. 216 | watcher: Watcher[Revision] = dogma.repository_watcher( 217 | project_name, repo_name, "/**", timeout_millis=timeout_millis 218 | ) 219 | 220 | # wait until watcher get `NOT_MODIFIED` at least once. 221 | time.sleep(4 * timeout_second) 222 | 223 | commit = Commit("Upsert modify.txt") 224 | upsert_text = Change("/path/modify.txt", ChangeType.UPSERT_TEXT, "modified") 225 | result = dogma.push(project_name, repo_name, commit, [upsert_text]) 226 | 227 | # wait until watcher watch latest. 228 | time.sleep(4 * timeout_second) 229 | 230 | assert result.revision == watcher.latest().revision.major 231 | -------------------------------------------------------------------------------- /tests/test_base_client.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from http import HTTPStatus 15 | 16 | from centraldogma.exceptions import UnauthorizedException, NotFoundException 17 | from centraldogma.base_client import BaseClient 18 | from httpx import ConnectError, NetworkError, Response 19 | import pytest 20 | 21 | base_url = "http://baseurl" 22 | client = BaseClient(base_url, "token", retries=0) 23 | 24 | configs = { 25 | "auth": None, 26 | "cookies": None, 27 | "verify": None, 28 | "cert": None, 29 | "proxies": None, 30 | "mounts": None, 31 | "timeout": 5, 32 | "retries": 10, 33 | "max_connections": 50, 34 | "max_keepalive_connections": 10, 35 | "max_redirects": 1, 36 | "event_hooks": None, 37 | "transport": None, 38 | "app": None, 39 | "trust_env": True, 40 | } 41 | client_with_configs = BaseClient(base_url, "token", **configs) 42 | 43 | ok_handler = {HTTPStatus.OK: lambda resp: resp} 44 | 45 | 46 | def test_set_request_headers(): 47 | for method in ["get", "post", "delete", "patch"]: 48 | kwargs = client_with_configs._set_request_headers( 49 | method, params={"a": "b"}, follow_redirects=True 50 | ) 51 | content_type = ( 52 | "application/json-patch+json" if method == "patch" else "application/json" 53 | ) 54 | assert kwargs["headers"] == { 55 | "Authorization": "bearer token", 56 | "Content-Type": content_type, 57 | } 58 | assert kwargs["params"] == {"a": "b"} 59 | assert kwargs["follow_redirects"] 60 | assert "limits" not in kwargs 61 | assert "event_hooks" not in kwargs 62 | assert "transport" not in kwargs 63 | assert "app" not in kwargs 64 | assert "trust_env" not in kwargs 65 | 66 | 67 | def test_request_with_configs(respx_mock): 68 | methods = ["get", "post", "put", "delete", "patch", "options"] 69 | for method in methods: 70 | getattr(respx_mock, method)(f"{base_url}/api/v1/path").mock( 71 | return_value=Response(200, text="success") 72 | ) 73 | client.request( 74 | method, 75 | "/path", 76 | timeout=5, 77 | cookies=None, 78 | auth=None, 79 | ) 80 | client.request(method, "/path", timeout=(3.05, 27)) 81 | client_with_configs.request(method, "/path") 82 | assert respx_mock.calls.call_count == len(methods) * 3 83 | 84 | 85 | def test_delete(respx_mock): 86 | route = respx_mock.delete(f"{base_url}/api/v1/path").mock( 87 | return_value=Response(200, text="success") 88 | ) 89 | resp = client.request("delete", "/path", params={"a": "b"}) 90 | 91 | assert route.called 92 | assert resp.request.headers["Authorization"] == "bearer token" 93 | assert resp.request.headers["Content-Type"] == "application/json" 94 | assert resp.request.url.params.multi_items() == [("a", "b")] 95 | 96 | 97 | def test_delete_exception_authorization(respx_mock): 98 | with pytest.raises(UnauthorizedException): 99 | respx_mock.delete(f"{base_url}/api/v1/path").mock(return_value=Response(401)) 100 | client.request("delete", "/path", handler=ok_handler) 101 | 102 | 103 | def test_get(respx_mock): 104 | route = respx_mock.get(f"{base_url}/api/v1/path").mock( 105 | return_value=Response(200, text="success") 106 | ) 107 | resp = client.request("get", "/path", params={"a": "b"}, handler=ok_handler) 108 | 109 | assert route.called 110 | assert route.call_count == 1 111 | assert resp.request.headers["Authorization"] == "bearer token" 112 | assert resp.request.headers["Content-Type"] == "application/json" 113 | assert resp.request.url.params.multi_items() == [("a", "b")] 114 | 115 | 116 | def test_get_exception_authorization(respx_mock): 117 | with pytest.raises(UnauthorizedException): 118 | respx_mock.get(f"{base_url}/api/v1/path").mock(return_value=Response(401)) 119 | client.request("get", "/path", handler=ok_handler) 120 | 121 | 122 | def test_get_exception_not_found(respx_mock): 123 | with pytest.raises(NotFoundException): 124 | respx_mock.get(f"{base_url}/api/v1/path").mock(return_value=Response(404)) 125 | client.request("get", "/path", handler=ok_handler) 126 | 127 | 128 | def test_get_with_retry_by_response(respx_mock): 129 | route = respx_mock.get(f"{base_url}/api/v1/path").mock( 130 | side_effect=[Response(503), Response(404), Response(200)], 131 | ) 132 | 133 | retry_client = BaseClient(base_url, "token", retries=2) 134 | retry_client.request("get", "/path", handler=ok_handler) 135 | 136 | assert route.called 137 | assert route.call_count == 3 138 | 139 | 140 | def test_get_with_retry_by_client(respx_mock): 141 | route = respx_mock.get(f"{base_url}/api/v1/path").mock( 142 | side_effect=[ConnectError, ConnectError, NetworkError, Response(200)], 143 | ) 144 | 145 | retry_client = BaseClient(base_url, "token", retries=10) 146 | retry_client.request("get", "/path", handler=ok_handler) 147 | 148 | assert route.called 149 | assert route.call_count == 4 150 | 151 | 152 | def test_patch(respx_mock): 153 | route = respx_mock.patch(f"{base_url}/api/v1/path").mock( 154 | return_value=Response(200, text="success") 155 | ) 156 | resp = client.request("patch", "/path", json={"a": "b"}, handler=ok_handler) 157 | 158 | assert route.called 159 | assert resp.request.headers["Authorization"] == "bearer token" 160 | assert resp.request.headers["Content-Type"] == "application/json-patch+json" 161 | assert resp.request._content == b'{"a": "b"}' 162 | 163 | 164 | def test_patch_exception_authorization(respx_mock): 165 | with pytest.raises(UnauthorizedException): 166 | respx_mock.patch(f"{base_url}/api/v1/path").mock(return_value=Response(401)) 167 | client.request("patch", "/path", json={"a": "b"}, handler=ok_handler) 168 | 169 | 170 | def test_post(respx_mock): 171 | route = respx_mock.post(f"{base_url}/api/v1/path").mock( 172 | return_value=Response(200, text="success") 173 | ) 174 | resp = client.request("post", "/path", json={"a": "b"}, handler=ok_handler) 175 | 176 | assert route.called 177 | assert resp.request.headers["Authorization"] == "bearer token" 178 | assert resp.request.headers["Content-Type"] == "application/json" 179 | assert resp.request._content == b'{"a": "b"}' 180 | 181 | 182 | def test_post_exception_authorization(respx_mock): 183 | with pytest.raises(UnauthorizedException): 184 | respx_mock.post(f"{base_url}/api/v1/path").mock(return_value=Response(401)) 185 | client.request("post", "/path", handler=ok_handler) 186 | -------------------------------------------------------------------------------- /tests/test_dogma.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from datetime import datetime 15 | from http import HTTPStatus 16 | 17 | import pytest 18 | from httpx import Response 19 | 20 | from centraldogma.data import ( 21 | DATE_FORMAT_ISO8601, 22 | DATE_FORMAT_ISO8601_MS, 23 | Change, 24 | ChangeType, 25 | Commit, 26 | Content, 27 | Creator, 28 | Project, 29 | Repository, 30 | ) 31 | from centraldogma.data.merge_source import MergeSource 32 | from centraldogma.dogma import Dogma 33 | from centraldogma.exceptions import ( 34 | BadRequestException, 35 | UnknownException, 36 | ProjectExistsException, 37 | RepositoryExistsException, 38 | ) 39 | 40 | base_url = "http://baseurl" 41 | client = Dogma(base_url, "token", retries=0) 42 | 43 | mock_project = { 44 | "name": "project1", 45 | "creator": {"name": "admin", "email": "admin@centraldogma.com"}, 46 | "url": "/api/v1/projects/myproject", 47 | "createdAt": "2017-09-28T15:33:35Z", 48 | } 49 | mock_repository = { 50 | "name": "repository1", 51 | "creator": {"name": "admin", "email": "admin@centraldogma.com"}, 52 | "headRevision": 3, 53 | "url": "/api/v1/projects/myproject/repos/myrepo", 54 | "createdAt": "2017-09-28T15:33:35Z", 55 | } 56 | mock_content_text = { 57 | "path": "/fooDir/foo.txt", 58 | "type": "TEXT", 59 | "content": "foofoofoofoobarbar", 60 | "revision": 3, 61 | "url": "/projects/myPro/repos/myRepo/contents/fooDir/foo.txt", 62 | } 63 | mock_content_json = { 64 | "path": "/fooDir/foo.txt", 65 | "type": "JSON", 66 | "content": {"foo": "bar", "bar": ["foo", "foo"]}, 67 | "revision": 3, 68 | "url": "/projects/myPro/repos/myRepo/contents/fooDir/foo.txt", 69 | } 70 | mock_push_result = { 71 | "revision": 2, 72 | "pushedAt": "2021-10-28T15:33:35.123Z", 73 | } 74 | mock_merge_result = { 75 | "revision": 4, 76 | "type": "JSON", 77 | "content": {"foo": "bar"}, 78 | "paths": ["/test.json", "/test2.json", "/test3.json"], 79 | } 80 | 81 | 82 | def test_list_projects(respx_mock): 83 | url = f"{base_url}/api/v1/projects" 84 | route = respx_mock.get(url).mock( 85 | return_value=Response(200, json=[mock_project, mock_project]) 86 | ) 87 | projects = client.list_projects() 88 | 89 | assert route.called 90 | assert len(projects) == 2 91 | for project in projects: 92 | assert project.name == mock_project["name"] 93 | assert project.creator == Creator.from_dict(mock_project["creator"]) 94 | assert project.url == mock_project["url"] 95 | assert project.created_at == datetime.strptime( 96 | mock_project["createdAt"], DATE_FORMAT_ISO8601 97 | ) 98 | 99 | 100 | def test_list_projects_removed(respx_mock): 101 | url = f"{base_url}/api/v1/projects" 102 | route = respx_mock.get(url).mock(return_value=Response(HTTPStatus.NO_CONTENT)) 103 | projects = client.list_projects(removed=True) 104 | 105 | assert route.called 106 | assert respx_mock.calls.last.request.url == (url + "?status=removed") 107 | assert projects == [] 108 | 109 | 110 | def test_create_project(respx_mock): 111 | url = f"{base_url}/api/v1/projects" 112 | route = respx_mock.post(url).mock( 113 | return_value=Response(HTTPStatus.CREATED, json=mock_project) 114 | ) 115 | project = client.create_project("newProject") 116 | 117 | assert route.called 118 | request = respx_mock.calls.last.request 119 | assert request.url == url 120 | assert request._content == b'{"name": "newProject"}' 121 | assert project == Project.from_dict(mock_project) 122 | 123 | 124 | def test_create_project_failed(respx_mock): 125 | url = f"{base_url}/api/v1/projects" 126 | response_body = { 127 | "exception": "com.linecorp.centraldogma.common.ProjectExistsException", 128 | "message": "Project 'newProject' exists already.", 129 | } 130 | route = respx_mock.post(url).mock( 131 | return_value=Response(HTTPStatus.CONFLICT, json=response_body) 132 | ) 133 | with pytest.raises(ProjectExistsException) as cause: 134 | client.create_project("newProject") 135 | assert response_body["message"] == str(cause.value) 136 | 137 | assert route.called 138 | request = respx_mock.calls.last.request 139 | assert request.url == url 140 | assert request._content == b'{"name": "newProject"}' 141 | 142 | 143 | def test_remove_project(respx_mock): 144 | url = f"{base_url}/api/v1/projects/project1" 145 | route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.NO_CONTENT)) 146 | client.remove_project("project1") 147 | 148 | assert route.called 149 | assert respx_mock.calls.last.request.url == url 150 | 151 | 152 | def test_remove_project_failed(respx_mock): 153 | url = f"{base_url}/api/v1/projects/project1" 154 | route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.BAD_REQUEST)) 155 | with pytest.raises(BadRequestException): 156 | client.remove_project("project1") 157 | 158 | assert route.called 159 | assert respx_mock.calls.last.request.url == url 160 | 161 | 162 | def test_unremove_project(respx_mock): 163 | url = f"{base_url}/api/v1/projects/project1" 164 | route = respx_mock.patch(url).mock( 165 | return_value=Response(HTTPStatus.OK, json=mock_project) 166 | ) 167 | project = client.unremove_project("project1") 168 | 169 | assert route.called 170 | request = respx_mock.calls.last.request 171 | assert request.url == url 172 | assert ( 173 | request._content == b'[{"op": "replace", "path": "/status", "value": "active"}]' 174 | ) 175 | assert project == Project.from_dict(mock_project) 176 | 177 | 178 | def test_unremove_project_failed(respx_mock): 179 | url = f"{base_url}/api/v1/projects/project1" 180 | route = respx_mock.patch(url).mock( 181 | return_value=Response(HTTPStatus.SERVICE_UNAVAILABLE) 182 | ) 183 | with pytest.raises(UnknownException): 184 | client.unremove_project("project1") 185 | 186 | assert route.called 187 | request = respx_mock.calls.last.request 188 | assert request.url == url 189 | assert ( 190 | request._content == b'[{"op": "replace", "path": "/status", "value": "active"}]' 191 | ) 192 | 193 | 194 | def test_purge_project(respx_mock): 195 | url = f"{base_url}/api/v1/projects/project1/removed" 196 | route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.NO_CONTENT)) 197 | client.purge_project("project1") 198 | 199 | assert route.called 200 | assert respx_mock.calls.last.request.url == url 201 | 202 | 203 | def test_purge_project_failed(respx_mock): 204 | url = f"{base_url}/api/v1/projects/project1/removed" 205 | route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.FORBIDDEN)) 206 | with pytest.raises(UnknownException): 207 | client.purge_project("project1") 208 | 209 | assert route.called 210 | assert respx_mock.calls.last.request.url == url 211 | 212 | 213 | def test_list_repositories(respx_mock): 214 | url = f"{base_url}/api/v1/projects/myproject/repos" 215 | route = respx_mock.get(url).mock( 216 | return_value=Response(HTTPStatus.OK, json=[mock_repository, mock_repository]) 217 | ) 218 | repos = client.list_repositories("myproject") 219 | 220 | assert route.called 221 | assert respx_mock.calls.last.request.url == url 222 | assert len(repos) == 2 223 | for repo in repos: 224 | assert repo.name == mock_repository["name"] 225 | assert repo.creator == Creator.from_dict(mock_repository["creator"]) 226 | assert repo.head_revision == mock_repository["headRevision"] 227 | assert repo.url == mock_repository["url"] 228 | assert repo.created_at == datetime.strptime( 229 | mock_repository["createdAt"], DATE_FORMAT_ISO8601 230 | ) 231 | 232 | 233 | def test_list_repositories_removed(respx_mock): 234 | url = f"{base_url}/api/v1/projects/myproject/repos" 235 | route = respx_mock.get(url).mock(return_value=Response(HTTPStatus.NO_CONTENT)) 236 | repos = client.list_repositories("myproject", removed=True) 237 | 238 | assert route.called 239 | assert respx_mock.calls.last.request.url == (url + "?status=removed") 240 | assert repos == [] 241 | 242 | 243 | def test_create_repository(respx_mock): 244 | url = f"{base_url}/api/v1/projects/myproject/repos" 245 | route = respx_mock.post(url).mock( 246 | return_value=Response(HTTPStatus.CREATED, json=mock_repository) 247 | ) 248 | repo = client.create_repository("myproject", "newRepo") 249 | 250 | assert route.called 251 | request = respx_mock.calls.last.request 252 | assert request.url == url 253 | assert request._content == b'{"name": "newRepo"}' 254 | assert repo == Repository.from_dict(mock_repository) 255 | 256 | 257 | def test_create_repository_failed(respx_mock): 258 | url = f"{base_url}/api/v1/projects/myproject/repos" 259 | response_body = { 260 | "exception": "com.linecorp.centraldogma.common.RepositoryExistsException", 261 | "message": "Respository 'myproject/newRepo' exists already.", 262 | } 263 | route = respx_mock.post(url).mock( 264 | return_value=Response(HTTPStatus.CONFLICT, json=response_body) 265 | ) 266 | with pytest.raises(RepositoryExistsException): 267 | client.create_repository("myproject", "newRepo") 268 | 269 | assert route.called 270 | request = respx_mock.calls.last.request 271 | assert request.url == url 272 | assert request._content == b'{"name": "newRepo"}' 273 | 274 | 275 | def test_remove_repository(respx_mock): 276 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo" 277 | route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.NO_CONTENT)) 278 | client.remove_repository("myproject", "myrepo") 279 | 280 | assert route.called 281 | assert respx_mock.calls.last.request.url == url 282 | 283 | 284 | def test_remove_repository_failed(respx_mock): 285 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo" 286 | route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.FORBIDDEN)) 287 | with pytest.raises(UnknownException): 288 | client.remove_repository("myproject", "myrepo") 289 | 290 | assert route.called 291 | assert respx_mock.calls.last.request.url == url 292 | 293 | 294 | def test_unremove_repository(respx_mock): 295 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo" 296 | route = respx_mock.patch(url).mock( 297 | return_value=Response(HTTPStatus.OK, json=mock_repository) 298 | ) 299 | repo = client.unremove_repository("myproject", "myrepo") 300 | 301 | assert route.called 302 | request = respx_mock.calls.last.request 303 | assert request.url == url 304 | assert ( 305 | request._content == b'[{"op": "replace", "path": "/status", "value": "active"}]' 306 | ) 307 | assert repo == Repository.from_dict(mock_repository) 308 | 309 | 310 | def test_unremove_repository_failed(respx_mock): 311 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo" 312 | route = respx_mock.patch(url).mock(return_value=Response(HTTPStatus.BAD_REQUEST)) 313 | with pytest.raises(BadRequestException): 314 | client.unremove_repository("myproject", "myrepo") 315 | 316 | assert route.called 317 | request = respx_mock.calls.last.request 318 | assert request.url == url 319 | assert ( 320 | request._content == b'[{"op": "replace", "path": "/status", "value": "active"}]' 321 | ) 322 | 323 | 324 | def test_purge_repository(respx_mock): 325 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/removed" 326 | route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.NO_CONTENT)) 327 | client.purge_repository("myproject", "myrepo") 328 | 329 | assert route.called 330 | assert respx_mock.calls.last.request.url == url 331 | 332 | 333 | def test_purge_repository_failed(respx_mock): 334 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/removed" 335 | route = respx_mock.delete(url).mock(return_value=Response(HTTPStatus.FORBIDDEN)) 336 | with pytest.raises(UnknownException): 337 | client.purge_repository("myproject", "myrepo") 338 | 339 | assert route.called 340 | assert respx_mock.calls.last.request.url == url 341 | 342 | 343 | def test_normalize_repository_revision(respx_mock): 344 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/revision/3" 345 | route = respx_mock.get(url).mock( 346 | return_value=Response(HTTPStatus.OK, json={"revision": 3}) 347 | ) 348 | revision = client.normalize_repository_revision("myproject", "myrepo", 3) 349 | 350 | assert route.called 351 | assert respx_mock.calls.last.request.url == url 352 | assert revision == 3 353 | 354 | 355 | def test_normalize_repository_revision_failed(respx_mock): 356 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/revision/3" 357 | route = respx_mock.get(url).mock(return_value=Response(HTTPStatus.BAD_REQUEST)) 358 | with pytest.raises(BadRequestException): 359 | client.normalize_repository_revision("myproject", "myrepo", 3) 360 | 361 | assert route.called 362 | assert respx_mock.calls.last.request.url == url 363 | 364 | 365 | def test_list_files(respx_mock): 366 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/list" 367 | route = respx_mock.get(url).mock( 368 | return_value=Response( 369 | HTTPStatus.OK, json=[mock_content_text, mock_content_text] 370 | ) 371 | ) 372 | files = client.list_files("myproject", "myrepo") 373 | 374 | assert route.called 375 | assert respx_mock.calls.last.request.url == url 376 | assert len(files) == 2 377 | for file in files: 378 | assert file.path == mock_content_text["path"] 379 | assert file.type == mock_content_text["type"] 380 | assert file.url == mock_content_text["url"] 381 | 382 | 383 | def test_list_files_pattern(respx_mock): 384 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/list/foo/*.json" 385 | route = respx_mock.get(url).mock(return_value=Response(HTTPStatus.NO_CONTENT)) 386 | files = client.list_files("myproject", "myrepo", "/foo/*.json") 387 | 388 | assert route.called 389 | assert respx_mock.calls.last.request.url == url 390 | assert files == [] 391 | 392 | 393 | def test_list_files_revision(respx_mock): 394 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/list/*.json" 395 | route = respx_mock.get(url).mock(return_value=Response(HTTPStatus.NO_CONTENT)) 396 | files = client.list_files("myproject", "myrepo", "*.json", 3) 397 | 398 | assert route.called 399 | assert respx_mock.calls.last.request.url == (url + "?revision=3") 400 | assert files == [] 401 | 402 | 403 | def test_get_files(respx_mock): 404 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/contents" 405 | route = respx_mock.get(url).mock( 406 | return_value=Response( 407 | HTTPStatus.OK, json=[mock_content_text, mock_content_text] 408 | ) 409 | ) 410 | files = client.get_files("myproject", "myrepo") 411 | 412 | assert route.called 413 | assert respx_mock.calls.last.request.url == url 414 | assert len(files) == 2 415 | for file in files: 416 | assert file == Content.from_dict(mock_content_text) 417 | 418 | 419 | def test_get_files_pattern(respx_mock): 420 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/contents/foo/*.json" 421 | route = respx_mock.get(url).mock(return_value=Response(HTTPStatus.NO_CONTENT)) 422 | files = client.get_files("myproject", "myrepo", "/foo/*.json") 423 | 424 | assert route.called 425 | assert respx_mock.calls.last.request.url == url 426 | assert files == [] 427 | 428 | 429 | def test_get_files_revision(respx_mock): 430 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/contents/*.json" 431 | route = respx_mock.get(url).mock(return_value=Response(HTTPStatus.NO_CONTENT)) 432 | files = client.get_files("myproject", "myrepo", "*.json", 3) 433 | 434 | assert route.called 435 | assert respx_mock.calls.last.request.url == (url + "?revision=3") 436 | assert files == [] 437 | 438 | 439 | def test_get_file_text(respx_mock): 440 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/contents/foo.text" 441 | route = respx_mock.get(url).mock( 442 | return_value=Response(HTTPStatus.OK, json=mock_content_text) 443 | ) 444 | file = client.get_file("myproject", "myrepo", "foo.text") 445 | 446 | assert route.called 447 | assert respx_mock.calls.last.request.url == url 448 | assert file.path == mock_content_text["path"] 449 | assert file.type == mock_content_text["type"] 450 | assert isinstance(file.content, str) 451 | assert file.content == mock_content_text["content"] 452 | assert file.revision == mock_content_text["revision"] 453 | assert file.url == mock_content_text["url"] 454 | 455 | 456 | def test_get_file_json(respx_mock): 457 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/contents/foo.json" 458 | route = respx_mock.get(url).mock( 459 | return_value=Response(HTTPStatus.OK, json=mock_content_json) 460 | ) 461 | file = client.get_file("myproject", "myrepo", "foo.json", 3) 462 | 463 | assert route.called 464 | assert respx_mock.calls.last.request.url == (url + "?revision=3") 465 | assert file.path == mock_content_json["path"] 466 | assert file.type == mock_content_json["type"] 467 | assert isinstance(file.content, dict) or isinstance(file.content, list) 468 | assert file.content == mock_content_json["content"] 469 | assert file.revision == mock_content_json["revision"] 470 | assert file.url == mock_content_json["url"] 471 | 472 | 473 | def test_get_file_json_path(respx_mock): 474 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/contents/foo.json" 475 | route = respx_mock.get(url).mock( 476 | return_value=Response(HTTPStatus.OK, json=mock_content_json) 477 | ) 478 | file = client.get_file("myproject", "myrepo", "foo.json", 3, "$.a") 479 | 480 | assert route.called 481 | assert respx_mock.calls.last.request.url == (url + "?revision=3&jsonpath=%24.a") 482 | assert file.path == mock_content_json["path"] 483 | assert file.type == mock_content_json["type"] 484 | assert isinstance(file.content, dict) or isinstance(file.content, list) 485 | assert file.content == mock_content_json["content"] 486 | assert file.revision == mock_content_json["revision"] 487 | assert file.url == mock_content_json["url"] 488 | 489 | 490 | def test_push(respx_mock): 491 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/contents" 492 | route = respx_mock.post(url).mock( 493 | return_value=Response(HTTPStatus.OK, json=mock_push_result) 494 | ) 495 | commit = Commit("Upsert test.json") 496 | upsert_json = Change("/test.json", ChangeType.UPSERT_JSON, {"foo": "bar"}) 497 | ret = client.push("myproject", "myrepo", commit, [upsert_json]) 498 | 499 | assert route.called 500 | request = respx_mock.calls.last.request 501 | assert request.url == url 502 | payload = ( 503 | '{"commitMessage": {"summary": "Upsert test.json", "detail": null, "markup": null}, ' 504 | '"changes": [{"path": "/test.json", "type": "UPSERT_JSON", "content": {"foo": "bar"}}]}' 505 | ) 506 | assert request._content == bytes(str(payload), "utf-8") 507 | assert ret.pushed_at == datetime.strptime( 508 | mock_push_result["pushedAt"], DATE_FORMAT_ISO8601_MS 509 | ) 510 | 511 | 512 | def test_merge(respx_mock): 513 | url = f"{base_url}/api/v1/projects/myproject/repos/myrepo/merge?optional_path=test.json&optional_path=test2.json&path=test3.json" 514 | route = respx_mock.get(url).mock( 515 | return_value=Response(HTTPStatus.OK, json=mock_merge_result) 516 | ) 517 | 518 | # merge_sources cannot be empty 519 | with pytest.raises(ValueError): 520 | client.merge_files("myproject", "myrepo", []) 521 | 522 | merge_sources = [ 523 | MergeSource("test.json"), 524 | MergeSource("test2.json"), 525 | MergeSource("test3.json", False), 526 | ] 527 | ret = client.merge_files("myproject", "myrepo", merge_sources) 528 | 529 | assert route.called 530 | request = respx_mock.calls.last.request 531 | assert request.url == url 532 | assert ret.content == {"foo": "bar"} 533 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2021 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | import json 15 | from http import HTTPStatus 16 | from typing import Any 17 | 18 | import pytest 19 | 20 | from centraldogma.exceptions import ( 21 | to_exception, 22 | ProjectExistsException, 23 | InvalidResponseException, 24 | ProjectNotFoundException, 25 | QueryExecutionException, 26 | RedundantChangeException, 27 | RevisionNotFoundException, 28 | EntryNotFoundException, 29 | ChangeConflictException, 30 | RepositoryNotFoundException, 31 | AuthorizationException, 32 | ShuttingDownException, 33 | RepositoryExistsException, 34 | BadRequestException, 35 | UnauthorizedException, 36 | NotFoundException, 37 | UnknownException, 38 | ) 39 | 40 | 41 | @pytest.mark.parametrize( 42 | "exception,expected_type", 43 | [ 44 | ("ProjectExistsException", ProjectExistsException), 45 | ("ProjectNotFoundException", ProjectNotFoundException), 46 | ("QueryExecutionException", QueryExecutionException), 47 | ("RedundantChangeException", RedundantChangeException), 48 | ("RevisionNotFoundException", RevisionNotFoundException), 49 | ("EntryNotFoundException", EntryNotFoundException), 50 | ("ChangeConflictException", ChangeConflictException), 51 | ("RepositoryNotFoundException", RepositoryNotFoundException), 52 | ("AuthorizationException", AuthorizationException), 53 | ("ShuttingDownException", ShuttingDownException), 54 | ("RepositoryExistsException", RepositoryExistsException), 55 | ], 56 | ) 57 | def test_repository_exists_exception(exception, expected_type): 58 | class MockResponse: 59 | def json(self) -> Any: 60 | return { 61 | "exception": f"com.linecorp.centraldogma.common.{exception}", 62 | "message": "foobar", 63 | } 64 | 65 | def text(self) -> str: 66 | return json.dumps(self.json()) 67 | 68 | # noinspection PyTypeChecker 69 | exception = to_exception(MockResponse()) 70 | assert isinstance(exception, expected_type) 71 | assert str(exception) == "foobar" 72 | 73 | 74 | @pytest.mark.parametrize( 75 | "status,expected_type", 76 | [ 77 | (HTTPStatus.UNAUTHORIZED, UnauthorizedException), 78 | (HTTPStatus.BAD_REQUEST, BadRequestException), 79 | (HTTPStatus.NOT_FOUND, NotFoundException), 80 | (HTTPStatus.GATEWAY_TIMEOUT, UnknownException), 81 | ], 82 | ) 83 | def test_http_status_exception(status, expected_type): 84 | class MockResponse: 85 | status_code = status 86 | 87 | def json(self) -> Any: 88 | return { 89 | "exception": "com.linecorp.centraldogma.common.unknown", 90 | "message": "foobar", 91 | } 92 | 93 | def text(self) -> str: 94 | return json.dumps(self.json()) 95 | 96 | # noinspection PyTypeChecker 97 | exception = to_exception(MockResponse()) 98 | assert isinstance(exception, expected_type) 99 | assert str(exception) == "foobar" 100 | 101 | 102 | def test_invalid_response_exception(): 103 | class MockResponse: 104 | def json(self) -> Any: 105 | return json.loads(self.text()) 106 | 107 | def text(self): 108 | return '{"foo":' 109 | 110 | # noinspection PyTypeChecker 111 | exception: InvalidResponseException = to_exception(MockResponse()) 112 | assert isinstance(exception, InvalidResponseException) 113 | -------------------------------------------------------------------------------- /tests/test_push_result.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from dateutil import parser 15 | 16 | from centraldogma.data import PushResult 17 | 18 | 19 | def test_decode_push_result_with_iso8601(): 20 | push_result_with_millis = { 21 | "revision": 2, 22 | "pushedAt": "2021-10-28T15:33:35.123Z", 23 | } 24 | 25 | push_result: PushResult = PushResult.from_dict(push_result_with_millis) 26 | assert push_result.revision == 2 27 | assert push_result.pushed_at == parser.parse(push_result_with_millis["pushedAt"]) 28 | 29 | push_result_without_millis = { 30 | "revision": 3, 31 | "pushedAt": "2021-10-28T15:33:35Z", 32 | } 33 | 34 | push_result: PushResult = PushResult.from_dict(push_result_without_millis) 35 | assert push_result.revision == 3 36 | assert push_result.pushed_at == parser.parse(push_result_without_millis["pushedAt"]) 37 | 38 | push_result_with_date = { 39 | "revision": 3, 40 | "pushedAt": "2021-10-28", 41 | } 42 | push_result: PushResult = PushResult.from_dict(push_result_with_date) 43 | assert push_result.revision == 3 44 | assert push_result.pushed_at == parser.parse(push_result_with_date["pushedAt"]) 45 | -------------------------------------------------------------------------------- /tests/test_repository_watcher.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 LINE Corporation 2 | # 3 | # LINE Corporation licenses this file to you under the Apache License, 4 | # version 2.0 (the "License"); you may not use this file except in compliance 5 | # with the License. You may obtain a copy of the License at: 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12 | # License for the specific language governing permissions and limitations 13 | # under the License. 14 | from centraldogma.data.revision import Revision 15 | from centraldogma.query import Query 16 | from centraldogma.repository_watcher import RepositoryWatcher, FileWatcher 17 | from centraldogma.watcher import Latest 18 | 19 | import pytest 20 | 21 | 22 | @pytest.fixture() 23 | def repo_watcher(mocker): 24 | return RepositoryWatcher( 25 | content_service=mocker.MagicMock(), 26 | project_name="project", 27 | repo_name="repo", 28 | path_pattern="/test", 29 | timeout_millis=1 * 60 * 1000, 30 | function=lambda x: x, 31 | ) 32 | 33 | 34 | @pytest.fixture() 35 | def file_watcher(mocker): 36 | return FileWatcher( 37 | content_service=mocker.MagicMock(), 38 | project_name="project", 39 | repo_name="repo", 40 | query=Query.text("test.txt"), 41 | timeout_millis=5000, 42 | function=lambda x: x, 43 | ) 44 | 45 | 46 | def test_repository_watch(repo_watcher, mocker): 47 | revision = Revision.init() 48 | latest = Latest(revision, repo_watcher.function(revision)) 49 | mocker.patch.object(repo_watcher, "_do_watch", return_value=latest) 50 | 51 | response = repo_watcher._watch(0) 52 | assert response == 0 53 | assert repo_watcher.latest() is latest 54 | 55 | 56 | def test_repository_watch_with_none_revision(repo_watcher, mocker): 57 | mocker.patch.object(repo_watcher, "_do_watch", return_value=None) 58 | 59 | response = repo_watcher._watch(0) 60 | assert response == 0 61 | assert repo_watcher.latest() is None 62 | 63 | 64 | def test_repository_watch_with_exception(repo_watcher, mocker): 65 | mocker.patch.object( 66 | repo_watcher, "_do_watch", side_effect=Exception("test exception") 67 | ) 68 | 69 | response = repo_watcher._watch(0) 70 | assert response == 1 71 | assert repo_watcher.latest() is None 72 | 73 | 74 | def test_file_watch(file_watcher, mocker): 75 | revision = Revision.init() 76 | latest = Latest(revision, file_watcher.function(revision)) 77 | mocker.patch.object(file_watcher, "_do_watch", return_value=latest) 78 | 79 | response = file_watcher._watch(0) 80 | assert response == 0 81 | assert file_watcher.latest() is latest 82 | 83 | 84 | def test_file_watch_with_none_revision(file_watcher, mocker): 85 | mocker.patch.object(file_watcher, "_do_watch", return_value=None) 86 | 87 | response = file_watcher._watch(0) 88 | assert response == 0 89 | assert file_watcher.latest() is None 90 | 91 | 92 | def test_file_watch_with_exception(file_watcher, mocker): 93 | mocker.patch.object( 94 | file_watcher, "_do_watch", side_effect=Exception("test exception") 95 | ) 96 | 97 | response = file_watcher._watch(0) 98 | assert response == 1 99 | assert file_watcher.latest() is None 100 | --------------------------------------------------------------------------------