├── .github └── workflows │ ├── danger.yml │ ├── python-publish-release.yml │ └── python-publish.yml ├── .gitignore ├── Dangerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── WIKI.md ├── example ├── config.yaml ├── docker_example.py └── example.py ├── image ├── code_start.gif ├── docker_log.gif └── file.gif ├── pyproject.toml ├── requirements.txt ├── src └── easierdocker │ ├── __init__.py │ ├── __main__.py │ ├── config.py │ ├── constants.py │ ├── docker_utils.py │ ├── easier_docker.py │ ├── easier_docker.pyi │ ├── exceptions.py │ ├── log_re.py │ └── reload_process.py └── tests └── test_easier_docker.py /.github/workflows/danger.yml: -------------------------------------------------------------------------------- 1 | name: PR Summary 2 | on: 3 | pull_request: 4 | types: [opened, synchronize] 5 | 6 | permissions: 7 | pull-requests: write 8 | contents: read 9 | 10 | jobs: 11 | danger: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout repository 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: '3.0' 21 | 22 | - name: Install Danger 23 | run: gem install danger 24 | 25 | - name: Setup Python 26 | uses: actions/setup-python@v4 27 | with: 28 | python-version: '3.10' 29 | 30 | - name: Install Python dependencies 31 | run: pip install flake8 pylint 32 | 33 | - name: Run Pylint 34 | run: pylint $(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} -- '*.py') > pylint_report.txt || true 35 | 36 | - name: Run Danger 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | run: danger 40 | -------------------------------------------------------------------------------- /.github/workflows/python-publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Upload Package To PyPI And Create Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | deploy: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v3 20 | with: 21 | python-version: '3.x' 22 | 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install build 27 | 28 | - name: Build package 29 | run: python -m build 30 | 31 | - name: Get previous and current tag 32 | id: get_tags 33 | run: | 34 | CURRENT_TAG=${GITHUB_REF##*/} 35 | echo "Current tag: $CURRENT_TAG" 36 | 37 | PREVIOUS_TAG=$(git tag --sort=-creatordate | grep -B 1 "$CURRENT_TAG" | head -n 1) 38 | echo "Previous tag: $PREVIOUS_TAG" 39 | 40 | echo "CURRENT_TAG=$CURRENT_TAG" >> $GITHUB_ENV 41 | echo "PREVIOUS_TAG=$PREVIOUS_TAG" >> $GITHUB_ENV 42 | 43 | - name: Get git log between tags 44 | id: get_git_log 45 | run: | 46 | git log ${{ env.PREVIOUS_TAG }}..${{ env.CURRENT_TAG }} --oneline | \ 47 | sed 's/^/- /' > release_notes.md 48 | 49 | cat release_notes.md 50 | 51 | - name: Create GitHub Release 52 | id: create_release 53 | uses: actions/create-release@v1 54 | with: 55 | tag_name: ${{ github.ref }} 56 | release_name: Release ${{ github.ref }} 57 | body: | 58 | $(cat release_notes.md) 59 | draft: false 60 | prerelease: false 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.MY_GITHUB_TOKEN }} 63 | 64 | - name: Upload Wheel File to Release 65 | uses: actions/upload-release-asset@v1 66 | with: 67 | upload_url: ${{ steps.create_release.outputs.upload_url }} 68 | asset_path: ./dist/*.whl 69 | asset_name: easier_docker-${{ github.ref }}.whl 70 | asset_content_type: application/zip 71 | 72 | - name: Upload Source Tarball to Release 73 | uses: actions/upload-release-asset@v1 74 | with: 75 | upload_url: ${{ steps.create_release.outputs.upload_url }} 76 | asset_path: ./dist/*.tar.gz 77 | asset_name: easier_docker-${{ github.ref }}.tar.gz 78 | asset_content_type: application/gzip 79 | 80 | - name: Publish package to PyPI 81 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 82 | with: 83 | user: __token__ 84 | password: ${{ secrets.PYPI_API_TOKEN }} 85 | continue-on-error: true 86 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Package To PyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python 16 | uses: actions/setup-python@v3 17 | with: 18 | python-version: '3.x' 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install build 23 | - name: Build package 24 | run: python -m build 25 | - name: Publish package 26 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 27 | with: 28 | user: __token__ 29 | password: ${{ secrets.PYPI_API_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .idea/ 3 | __pycache__/ 4 | dist/ 5 | easier_docker.egg-info/ 6 | example.egg-info/ 7 | easierdocker/easier_docker.egg-info/ 8 | venv/ 9 | .venv/ 10 | build/ 11 | .coverage 12 | htmlcov 13 | -------------------------------------------------------------------------------- /Dangerfile: -------------------------------------------------------------------------------- 1 | modified_files = git.modified_files + git.added_files 2 | deleted_files = git.deleted_files 3 | 4 | total_lines_changed = git.lines_of_code 5 | 6 | summary = "### 🤖 PR Auto Summary\n" 7 | summary += "🚀 **Total affected files**: #{modified_files.count + deleted_files.count}\n" 8 | summary += "🆕 **New files**: #{git.added_files.count}\n" 9 | summary += "✏️ **Modified files**: #{git.modified_files.count}\n" 10 | summary += "🗑️ **Deleted files**: #{git.deleted_files.count}\n" 11 | summary += "📊 **Total lines changed**: #{total_lines_changed}\n" 12 | summary += "📂 **Key modified files**:\n" 13 | 14 | modified_files.first(5).each do |file| 15 | summary += " - `#{file}`\n" 16 | end 17 | 18 | unless deleted_files.empty? 19 | summary += "🗂️ **Key deleted files**:\n" 20 | deleted_files.first(5).each do |file| 21 | summary += " - `#{file}`\n" 22 | end 23 | end 24 | 25 | warn("PR description is empty. Please provide a detailed explanation of the changes.") if github.pr_body.nil? || github.pr_body.strip.empty? 26 | 27 | source_branch = github.branch_for_head 28 | target_branch = github.branch_for_base 29 | 30 | warn("PR target branch is `#{target_branch}`. Ensure this PR follows the merge strategy!") if (target_branch == "main" || target_branch == "master") && !(source_branch == "dev" || source_branch == "develop") 31 | 32 | warn("PR is marked as Work in Progress (WIP).") if github.pr_title.include? "WIP" 33 | 34 | warn("Please add labels to this PR.") if github.pr_labels.empty? 35 | 36 | markdown(summary) 37 | 38 | python_files = (git.modified_files + git.added_files).select { |file| file.end_with?(".py") } 39 | 40 | unless python_files.empty? 41 | flake8_result = `flake8 #{python_files.join(" ")}` 42 | flake8_exit_status = $?.exitstatus 43 | 44 | if flake8_result.include?("E501") 45 | message("📣 Flake8 code issues found (lines > 79 characters):\n```\n#{flake8_result}\n```") 46 | elif flake8_exit_status != 0 47 | fail("❌ Flake8 code issues found:\n```\n#{flake8_result}\n```") 48 | else 49 | message("✅ No Flake8 issues found!") 50 | end 51 | 52 | pylint_result = `pylint --output-format=parseable #{python_files.join(" ")}` 53 | pylint_exit_status = $?.exitstatus 54 | 55 | if pylint_exit_status != 0 56 | fail("❌ Pylint issues found:\n```\n#{pylint_result}\n```") 57 | else 58 | message("✅ No Pylint issues found!") 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://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 | Copyright 2016 Docker, Inc. 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | http://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | limitations under the License. 192 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | exclude *.log 2 | exclude __pycache__ 3 | exclude .idea 4 | exclude .git 5 | exclude venv 6 | exclude dist 7 | exclude build 8 | exclude *.egg-info 9 | exclude tests 10 | exclude .coverage 11 | exclude htmlcov 12 | exclude .venv 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test clean install uninstall build upload all 2 | 3 | TWINE_UPLOAD := twine upload --repository pypi --username __token__ --password $(TWINE_API_TOKEN) 4 | 5 | all: clean build 6 | 7 | test: 8 | coverage run -m unittest discover 9 | coverage report 10 | coverage html 11 | google-chrome htmlcov/index.html 12 | 13 | 14 | clean: 15 | find . -name '__pycache__' -type d -exec rm -rf {} + 16 | find . -name 'easier_docker.egg-info' -type d -exec rm -rf {} + 17 | find . -name 'example.egg-info' -type d -exec rm -rf {} + 18 | rm -rf htmlcov 19 | rm -rf build 20 | rm -rf dist 21 | rm -rf .coverage 22 | rm -rf htmlcov 23 | 24 | install: 25 | pip install -e . 26 | 27 | uninstall: 28 | pip uninstall -y easier_docker 29 | 30 | build: 31 | python -m build 32 | 33 | upload: 34 | @echo "Uploading the package..." 35 | @if [ -z "$(TWINE_API_TOKEN)" ]; then \ 36 | echo "Error: TWINE_API_TOKEN is not set. Please export it as an environment variable."; \ 37 | exit 1; \ 38 | fi 39 | $(TWINE_UPLOAD) dist/* 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # easier-docker 2 | 3 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/easier-docker) 4 | ![Static Badge](https://img.shields.io/badge/-docker-grey?logo=docker) 5 | ![GitHub License](https://img.shields.io/github/license/touero/easier-docker) 6 | ![PyPI - License](https://img.shields.io/pypi/l/easier-docker) 7 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/easier-docker) 8 | ![GitHub last commit](https://img.shields.io/github/last-commit/touero/easier-docker) 9 | [![Upload Package To PyPI](https://github.com/touero/easier-docker/actions/workflows/python-publish.yml/badge.svg?branch=master)](https://github.com/touero/easier-docker/actions/workflows/python-publish.yml) 10 | 11 | 12 | ## Repository Introduction 13 | This is based on [docker-py](https://github.com/docker/docker-py?tab=readme-ov-file) which makes it easier to run your program in docker. 14 | Configure your container image information more easily in python, allowing the container in docker to execute the configured program you want to execute. 15 | 16 | 17 | ## Install 18 | ```bash 19 | pip install easier-docker 20 | ``` 21 | 22 | ## Usage 23 | Please check [wiki](https://github.com/touero/easier-docker/wiki). 24 | 25 | *Learn how to design: [DeepWiki](https://deepwiki.com/touero/easier-docker)* 26 | 27 | ## Related 28 | ### Repository 29 | [docker-py](https://github.com/docker/docker-py) — A Python library for the Docker Engine API. 30 | 31 | ### Materials 32 | [Docker SDK for Python](https://docker-py.readthedocs.io/en/stable/) 33 | 34 | ### Repository Used Example 35 | [opsariichthys-bidens](https://github.com/weiensong/opsariichthys-bidens) — About 36 | Building a Basic Information API for Chinese National Universities in the Handheld College Entrance Examination Based on Fastapi. 37 | 38 | 39 | ## Maintainers 40 | [@touero](https://github.com/touero) 41 | 42 | 43 | ## Contributing 44 | [Open an issue](https://github.com/weiensong/easier_docker/issues) or submit PRs to git branch `develop`. 45 | 46 | Standard Python follows the [Python PEP-8](https://peps.python.org/pep-0008/) Code of Conduct. 47 | 48 | 49 | ### Contributors 50 | This project exists thanks to all the people who contribute. 51 | 52 | 53 | 54 | 55 | 56 | 57 | ## License 58 | [Apache License 2.0](https://github.com/weiensong/easier-docker/blob/master/LICENSE) 59 | 60 | -------------------------------------------------------------------------------- /WIKI.md: -------------------------------------------------------------------------------- 1 | # Welcome to the eaiser-docker wiki! 2 | 3 | ## Install 4 | It should be a package for your real environment. 5 | ```shell 6 | pip install easier-docker 7 | ``` 8 | 9 | ## Explain 10 | Two params it need now, and `network_config` and `extra_config` are kwargs: 11 | > [!Note] 12 | > - __container_config__: Necessary, run and manage containers on the server. Run a container. By default, it will wait for the container to finish and return its logs, similar to `docker run`. 13 | > - __network_config__: Unnecessary, create and manage networks on the server. For more information about networks, see the [Engine documentation](https://docs.docker.com/network/). Create a network. Similar to the `docker network create`. 14 | > - __extra_config__: Unnecessary, add extra configurations to the container.Currently used to control whether existing containers will be automatically removed. 15 | 16 | Two params config please check: 17 | > [!Important] 18 | > 1. __container_config__: [Docker SDK for Python with Container](https://docker-py.readthedocs.io/en/7.1.0/containers.html) 19 | > 2. __network_config__: [Docker SDK for Python with Network](https://docker-py.readthedocs.io/en/7.1.0/networks.html) 20 | > 3. __extra_config__: Include and default value: `is_remove`, `days_ago_remove`, `remove_now`, 21 | >> `is_remove`: default value is `0`, if it is `1`, enable function that will remove the existing container with the same name. 22 | >> `days_ago_remove`: default value is `3`, it will remove the existing container with the same name if it is older than the specified number of days. 23 | >> `remove_now`: default value is `0`, if it is `1`, `days_ago_remove`will be ineffective, it will remove the existing container with the same name immediately. 24 | 25 | 26 | ## Usage 27 | ### Use examples in code 28 | [example.py](https://github.com/touero/easier-docker/blob/master/example/example.py) 29 | ```bash 30 | python example.py 31 | ``` 32 | ![code_start](/image/code_start.gif) 33 | 34 | and the docker container logs will be shown in the console. 35 | [docker_example.py](https://github.com/touero/easier-docker/blob/master/example/docker_example.py) 36 | ![docker_logs](/image/docker_log.gif) 37 | 38 | ### Run directly from configuration file 39 | > [!Note] 40 | > Currently supports type of file: _yml_, _yaml_, _json_ 41 | 42 | ```bash 43 | easier-docker -c config.yaml 44 | ``` 45 | [config.yaml](https://github.com/touero/easier-docker/blob/master/example/config.yaml) 46 | ![file](/image/file.gif) 47 | -------------------------------------------------------------------------------- /example/config.yaml: -------------------------------------------------------------------------------- 1 | # config.yaml 2 | 3 | container: 4 | image: python:3.9 5 | name: python_test 6 | volumes: 7 | D:\code-project\EasierDocker\example: 8 | bind: /path/to/container 9 | mode: rw 10 | detach: true 11 | command: 12 | - sh 13 | - -c 14 | - cd /path/to/container && python docker_example.py 15 | 16 | network: 17 | name: bridge 18 | driver: bridge 19 | 20 | extra: 21 | 'is_remove': 1 22 | 'days_ago_remove': 7 23 | 'remove_now': 0 24 | -------------------------------------------------------------------------------- /example/docker_example.py: -------------------------------------------------------------------------------- 1 | 2 | def main(): 3 | import logging 4 | import time 5 | for i in range(1, 101): 6 | logger = logging.getLogger("easier-docker") 7 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(name)s ==> %(message)s') 8 | logger.info(f'sleep 30s, times:{i}') 9 | time.sleep(30) 10 | 11 | 12 | if __name__ == '__main__': 13 | main() 14 | -------------------------------------------------------------------------------- /example/example.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from easierdocker import EasierDocker 4 | 5 | if __name__ == '__main__': 6 | parent_dir = os.getcwd() 7 | host_script = os.path.join(parent_dir, 'example') 8 | container_script = '/path/to/container' 9 | container_config = { 10 | 'image': 'python:3.9', 11 | 'name': 'python_test', 12 | 'volumes': { 13 | f'{host_script}': {'bind': container_script, 'mode': 'rw'} 14 | }, 15 | 'detach': True, 16 | 'command': ["sh", "-c", f'cd {container_script} &&' 17 | 'python docker_example.py'], 18 | } 19 | network_config = { 20 | 'name': 'bridge', 21 | 'driver': 'bridge', 22 | } 23 | extra_config = { 24 | 'is_remove': 1, 25 | 'days_ago_remove': 3, 26 | 'remove_now': 1 27 | } 28 | easier_docker = EasierDocker(container_config, network_config=network_config, extra_config=extra_config) 29 | easier_docker.start() 30 | -------------------------------------------------------------------------------- /image/code_start.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touero/easier-docker/85da40d089c6b8d74f29af2010471c306f39a512/image/code_start.gif -------------------------------------------------------------------------------- /image/docker_log.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touero/easier-docker/85da40d089c6b8d74f29af2010471c306f39a512/image/docker_log.gif -------------------------------------------------------------------------------- /image/file.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/touero/easier-docker/85da40d089c6b8d74f29af2010471c306f39a512/image/file.gif -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=68.2.0", "wheel>=0.42.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "easier-docker" 7 | version = "2.2.7" 8 | description = "Configure your container image information more easily in python, allowing the container in docker to execute the configured program you want to execute." 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | license = {text = "Apache License 2.0"} 12 | authors = [ 13 | {name = "EnSong Wei", email = "touer0018@gmail.com"} 14 | ] 15 | keywords = ["easy","docker","python","docker sdk","python sdk"] 16 | classifiers = [ 17 | "License :: OSI Approved :: Apache Software License", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13" 24 | ] 25 | dependencies = [ 26 | "docker~=7.1.0", 27 | "setuptools~=68.2.0", 28 | "PyYAML~=6.0.1", 29 | "wheel~=0.42.0", 30 | "twine~=4.0.2", 31 | "coverage==7.4.4" 32 | ] 33 | 34 | [project.urls] 35 | Homepage = "https://github.com/touero/easier-docker" 36 | "Bug Reports" = "https://github.com/touero/easier-docker/issues" 37 | Source = "https://github.com/touero/easier-docker" 38 | 39 | [project.scripts] 40 | easier-docker = "easierdocker.__main__:main" 41 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | docker~=7.1.0 2 | setuptools~=68.2.0 3 | PyYAML~=6.0.1 4 | wheel~=0.42.0 5 | twine~=4.0.2 6 | coverage==7.4.4 7 | build 8 | 9 | -------------------------------------------------------------------------------- /src/easierdocker/__init__.py: -------------------------------------------------------------------------------- 1 | from .easier_docker import EasierDocker 2 | from .log_re import log 3 | from .config import Config 4 | 5 | __version__ = '2.2.6' 6 | -------------------------------------------------------------------------------- /src/easierdocker/__main__.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from argparse import ArgumentParser 4 | 5 | from . import EasierDocker, log, Config 6 | 7 | 8 | def main(): 9 | parser = ArgumentParser() 10 | parser.add_argument('--config', '-c', help='configuration file path: yaml, yml and json', required=True) 11 | args = parser.parse_args() 12 | config_path = os.path.abspath(args.config) if args.config else None 13 | config = Config(config_path).load_file() 14 | log(f"config =\n {json.dumps(config, sort_keys=False, indent=4, separators=(',', ': '))}") 15 | container_config = config['container'] 16 | network_config = config.get('network', {}) 17 | extra_config = config.get('extra', {}) 18 | easier_docker = EasierDocker(container_config, network_config=network_config, extra_config=extra_config) 19 | easier_docker.start() 20 | 21 | 22 | if __name__ == "__main__": 23 | main() 24 | -------------------------------------------------------------------------------- /src/easierdocker/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import yaml 3 | 4 | from . import log 5 | 6 | 7 | class Config: 8 | def __init__(self, file_path: str): 9 | self.file_path = file_path 10 | 11 | def load_file(self) -> dict: 12 | config = {} 13 | with open(self.file_path, encoding='utf8') as file: 14 | if self.file_path.endswith(('.yaml', '.yml')): 15 | config: dict = yaml.safe_load(file) 16 | elif self.file_path.endswith('.json'): 17 | config: dict = json.load(file) 18 | else: 19 | log(f'Currently unsupported file types: {self.file_path}') 20 | return config 21 | -------------------------------------------------------------------------------- /src/easierdocker/constants.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum, unique 2 | from dataclasses import dataclass, fields 3 | from typing import Dict 4 | 5 | 6 | @unique 7 | class ContainerStatus(IntEnum): 8 | RUNNING = 1 # running 9 | EXITED = 2 # exited 10 | CREATED = 3 # created 11 | 12 | 13 | @dataclass 14 | class ExtraConfigModel: 15 | is_remove: int 16 | days_ago_remove: int 17 | remove_now: int 18 | 19 | @staticmethod 20 | def validate_dict(config_dict: Dict): 21 | allowed_fields = {field.name: field.type for field in fields(ExtraConfigModel)} 22 | for key, value in config_dict.items(): 23 | if key not in allowed_fields: 24 | raise ValueError(f"Unexpected field: '{key}' in extra_config") 25 | 26 | expected_type = allowed_fields[key] 27 | if not isinstance(value, expected_type): 28 | raise TypeError( 29 | f"Field '{key}' expects type '{expected_type.__name__}', but got '{type(value).__name__}'") 30 | -------------------------------------------------------------------------------- /src/easierdocker/docker_utils.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from datetime import datetime 4 | 5 | from docker.models.containers import Container 6 | from .log_re import log 7 | from .constants import ContainerStatus 8 | 9 | 10 | def check_container_status(container: Container) -> ContainerStatus: 11 | for index in range(60): 12 | time.sleep(1) 13 | if container.status == ContainerStatus.CREATED.name.lower(): 14 | continue 15 | if container.status != ContainerStatus.RUNNING.name.lower() and index == 0: 16 | container.reload() 17 | continue 18 | elif container.status == ContainerStatus.RUNNING.name.lower(): 19 | return ContainerStatus.RUNNING 20 | elif container.status == ContainerStatus.EXITED.name.lower(): 21 | log(f'Container name: [{container.name}] is exited') 22 | return ContainerStatus.EXITED 23 | 24 | 25 | def check_time(target_time_str, days_ago_remove): 26 | """ 27 | Check if the target_time_str is within the last days_ago_remove days 28 | :param target_time_str: timestamp in ISO 8601 format, accurate to nanoseconds. 29 | :param days_ago_remove: how many days old will the container be forcibly removed 30 | :return: 31 | """ 32 | target_time_str = target_time_str[:26] + 'Z' 33 | target_time = datetime.strptime(target_time_str, "%Y-%m-%dT%H:%M:%S.%fZ") 34 | current_time = datetime.utcnow() 35 | time_diff = current_time - target_time 36 | if time_diff.days >= days_ago_remove: 37 | return True 38 | return False 39 | -------------------------------------------------------------------------------- /src/easierdocker/easier_docker.py: -------------------------------------------------------------------------------- 1 | import docker 2 | import json 3 | import time 4 | 5 | from typing import Union 6 | from docker.errors import ImageNotFound, APIError, DockerException, NotFound 7 | from docker.models.containers import Container 8 | from .exceptions import DockerConnectionError, NotFoundImageInDockerHub 9 | from .log_re import log 10 | from .constants import ContainerStatus, ExtraConfigModel 11 | from .docker_utils import check_container_status, check_time 12 | 13 | 14 | class EasierDocker: 15 | def __init__(self, container_config: dict, **kwargs): 16 | self._container_config: dict = container_config 17 | self._network_config: dict = kwargs.get('network_config', {}) 18 | self._extra_config: dict = kwargs.get('extra_config', {}) 19 | ExtraConfigModel.validate_dict(self._extra_config) 20 | 21 | try: 22 | self._client = docker.from_env() 23 | except DockerException: 24 | raise DockerConnectionError 25 | 26 | @property 27 | def container_config(self): 28 | return self._container_config 29 | 30 | @property 31 | def network_config(self): 32 | return self._network_config 33 | 34 | @property 35 | def extra_config(self): 36 | return self._extra_config 37 | 38 | @property 39 | def client(self): 40 | return self._client 41 | 42 | @property 43 | def get_container_status(self): 44 | return self.client.containers.get(self.container_name).status 45 | 46 | @property 47 | def image_name(self): 48 | return self._container_config['image'] 49 | 50 | @property 51 | def container_name(self): 52 | return self._container_config['name'] 53 | 54 | def __get_image(self): 55 | log(f'Find docker image: [{self.image_name}] locally...') 56 | try: 57 | self._client.images.get(self.image_name) 58 | log(f'Image: [{self.image_name}] is found locally') 59 | except Exception as e: 60 | if isinstance(e, ImageNotFound): 61 | log(f'ImageNotFound: {str(e)}, it will be pulled') 62 | log(f'Waiting docker pull {self.image_name}...') 63 | try: 64 | for event in self._client.api.pull(self.image_name, stream=True): 65 | event_info = json.loads(event.decode('utf-8')) 66 | if 'status' in event_info: 67 | status = event_info['status'] 68 | progress = event_info.get('progress', '') 69 | log(f'Status: {status}, Progress: {progress}') 70 | except NotFound: 71 | raise NotFoundImageInDockerHub(self.image_name) 72 | log(f'Docker pull {self.image_name} finish') 73 | else: 74 | log(str(e)) 75 | 76 | def __get_container(self) -> Union[Container, None]: 77 | log(f'Find docker container: [{self.container_name}] locally...') 78 | containers = self._client.containers.list(all=True) 79 | for container in containers: 80 | if self.container_name == container.name: 81 | created_time = container.attrs['Created'] 82 | log(f'Container name: [{container.name}] is found locally') 83 | if self.extra_config.get('is_remove', 0): 84 | if (check_time(created_time, self.extra_config.get('days_ago_remove', 3)) or 85 | self.extra_config.get('remove_now', 0)): 86 | log(f'Container: [{container.name}] is created {self.extra_config.get("days_ago_remove", 3)} ' 87 | f'days ago or remove_now is {self.extra_config.get("remove_now", 0)}, ' 88 | f'it will be removed...') 89 | if container.status == ContainerStatus.RUNNING.name.lower(): 90 | log(f'Stopping container: [{container.name}]') 91 | container.stop() 92 | if self.__wait_container_status(ContainerStatus.EXITED): 93 | log(f'Removing container: [{container.name}]') 94 | container.remove() 95 | return None 96 | container.start() 97 | ip_address = container.attrs['NetworkSettings']['IPAddress'] 98 | if check_container_status(container) is ContainerStatus.EXITED: 99 | return container 100 | log(f'Container name: [{container.name}] is found locally, id: [{container.short_id}], ' 101 | f'ip address: [{ip_address}], created time: [{created_time}]') 102 | return container 103 | log(f'ContainerNotFound: [{self.container_name}], it will be created') 104 | return None 105 | 106 | def __wait_container_status(self, status: ContainerStatus) -> bool: 107 | container_status = self.get_container_status 108 | for _ in range(60): 109 | container_status = self.get_container_status 110 | log(f'Waiting for container [{container_status}] to be [{status.name.lower()}]') 111 | if container_status != status.name.lower(): 112 | time.sleep(1) 113 | continue 114 | break 115 | 116 | if container_status == status.name.lower(): 117 | return True 118 | else: 119 | return False 120 | 121 | def __run_container(self): 122 | try: 123 | container: Container = self._client.containers.run(**self.container_config) 124 | if check_container_status(container) is ContainerStatus.EXITED: 125 | return 126 | ip_address = container.attrs['NetworkSettings']['IPAddress'] 127 | created_time = container.attrs['Created'] 128 | log(f'Successfully container name: [{container.name}] is running, id: [{container.short_id}], ' 129 | f'ip address: [{ip_address}], created time: [{created_time}]') 130 | except Exception as e: 131 | if isinstance(e, APIError): 132 | log(f'Error starting container: {str(e)}') 133 | else: 134 | log(f'An error occurred: {str(e)}') 135 | raise e 136 | 137 | def __get_all_networks(self) -> list: 138 | networks = self._client.networks.list() 139 | for network in networks: 140 | log(f'Network id: [{network.short_id}], name: [{network.name}]') 141 | return networks 142 | 143 | def __create_network(self) -> None: 144 | if not self._network_config.get('name'): 145 | return 146 | network_name = self._network_config['name'] 147 | networks = self.__get_all_networks() 148 | for network in networks: 149 | if network.name == network_name: 150 | log(f'Network: [{network_name}] is found locally...') 151 | self._container_config['network'] = network_name 152 | return 153 | log(f'Network: [{network_name}] is not found locally, it will be created') 154 | self._client.networks.create(**self.network_config) 155 | log(f'Network: [{network_name}] is created') 156 | self._container_config['network'] = network_name 157 | 158 | def container_execute_command(self, container_name_or_id: str, command: str) -> str: 159 | """ 160 | :param container_name_or_id: container name or id 161 | :param command: command of shell type 162 | :return: 163 | """ 164 | if container_name_or_id: 165 | container_find = container_name_or_id 166 | else: 167 | container_find = self.container_name 168 | log(f'Executing command: [{command}] in container: [{container_find}]') 169 | container = self.client.containers.get(container_find) 170 | command_result = container.exec_run(command) 171 | exit_code = command_result.exit_code 172 | standard_output = command_result.output.decode('utf-8') 173 | log(f'Executing command result: exit_code: [{exit_code}], standard output: [{standard_output}]') 174 | return f'exit_code: {exit_code}, standard output: {standard_output}' 175 | 176 | def start(self): 177 | self.__get_image() 178 | self.__create_network() 179 | container = self.__get_container() 180 | if container is None: 181 | self.__run_container() 182 | -------------------------------------------------------------------------------- /src/easierdocker/easier_docker.pyi: -------------------------------------------------------------------------------- 1 | import docker 2 | from typing import Union 3 | from docker.models.containers import Container 4 | from docker import DockerClient 5 | 6 | 7 | class EasierDocker: 8 | def __init__(self, container_config: dict, **kwargs) -> None: 9 | self._container_config: dict = container_config 10 | self._network_config: dict = kwargs.get('network_config', {}) 11 | self._extra_config: dict = kwargs.get('extra_config', {}) 12 | 13 | self._client: DockerClient() = docker.from_env() 14 | """ 15 | Initialize client, config, image name, container name 16 | """ 17 | 18 | @property 19 | def container_config(self) -> dict: 20 | """ 21 | Return the container config. 22 | """ 23 | return self._container_config 24 | 25 | @property 26 | def network_config(self): 27 | """ 28 | Return the network config. 29 | """ 30 | return self._network_config 31 | 32 | @property 33 | def extra_config(self): 34 | """ 35 | Return the extra config. 36 | """ 37 | return self._extra_config 38 | 39 | @property 40 | def client(self) -> DockerClient: 41 | """ 42 | Return the client. 43 | """ 44 | return self._client 45 | 46 | @property 47 | def get_container_status(self) -> str: 48 | """ 49 | Return the status of the container. 50 | """ 51 | ... 52 | 53 | @property 54 | def image_name(self) -> str: 55 | """ 56 | Return the image name. 57 | """ 58 | return self._container_config['image'] 59 | 60 | @property 61 | def container_name(self) -> str: 62 | """ 63 | Return the container name. 64 | """ 65 | return self._container_config['name'] 66 | 67 | 68 | def __get_image(self) -> None: 69 | """ 70 | Search for the image that exists locally. If it does not exist, the image will be pulled. 71 | :return: 72 | """ 73 | ... 74 | 75 | def __get_container(self) -> Union[Container, None]: 76 | """ 77 | Find and return the locally existing container. 78 | :return: obj of the container or None 79 | """ 80 | ... 81 | 82 | def __wait_container_status(self, status: str) -> bool: 83 | """ 84 | Wait for the container to reach a certain status. 85 | :param status: str, current status of the container 86 | :return: boolean. 87 | """ 88 | ... 89 | 90 | 91 | def __run_container(self) -> None: 92 | """ 93 | Start the found container. If the container does not exist, it will create one according to the image. 94 | :return: 95 | """ 96 | ... 97 | 98 | def __get_all_networks(self) -> list: 99 | """ 100 | :return: list, all already exits networks 101 | """ 102 | ... 103 | 104 | def __create_network(self) -> None: 105 | """ 106 | create and manage networks on the server. 107 | :return: 108 | """ 109 | ... 110 | 111 | def start(self) -> None: 112 | """ 113 | where to start. 114 | :return: 115 | """ 116 | ... 117 | -------------------------------------------------------------------------------- /src/easierdocker/exceptions.py: -------------------------------------------------------------------------------- 1 | class DockerConnectionError(Exception): 2 | def __init__(self, message="Unable to connect to Docker server, please make sure dockers is running."): 3 | self.message = message 4 | super().__init__(self.message) 5 | 6 | 7 | class NotFoundImageInDockerHub(Exception): 8 | def __init__(self, image_name: str): 9 | self.message = f'Unable to pull the image named [{image_name}], please confirm whether it exists' 10 | super().__init__(self.message) 11 | -------------------------------------------------------------------------------- /src/easierdocker/log_re.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | class LogRe: 5 | def __init__(self, name: str = "easier-docker", level: int = logging.INFO): 6 | self.logger = logging.getLogger(name) 7 | logging.basicConfig(level=level, format='%(asctime)s - %(levelname)s - %(name)s ==> %(message)s') 8 | 9 | def log_info(self, msg: str = ''): 10 | if msg == '': 11 | return 12 | self.logger.info(msg) 13 | 14 | 15 | _logger = LogRe() 16 | log = _logger.log_info 17 | 18 | -------------------------------------------------------------------------------- /src/easierdocker/reload_process.py: -------------------------------------------------------------------------------- 1 | def reload_process(executable, new_program, file_path): 2 | # executable: sys.executable 3 | # new_program: 'python' 4 | # file_path: __file__ 5 | import os 6 | import sys 7 | from .log_re import log 8 | 9 | log(f"Reloading process with {executable} {new_program} {file_path} {sys.argv[1:]}") 10 | os.execl(executable, new_program, file_path, *sys.argv[1:]) 11 | -------------------------------------------------------------------------------- /tests/test_easier_docker.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch, MagicMock 3 | 4 | from src.easierdocker import EasierDocker 5 | from docker.errors import ImageNotFound, APIError, NotFound, DockerException 6 | from src.easierdocker.exceptions import DockerConnectionError, NotFoundImageInDockerHub 7 | 8 | 9 | class TestEasierDocker(unittest.TestCase): 10 | def setUp(self): 11 | self.container_config = { 12 | "image": "test_image", 13 | "name": "test_container", 14 | "detach": True 15 | } 16 | self.network_config = {"name": "test_network"} 17 | self.easier_docker = EasierDocker(self.container_config, self.network_config) 18 | 19 | @patch("docker.from_env") 20 | def test_docker_connection_error(self, mock_from_env): 21 | mock_from_env.side_effect = DockerException("Docker connection failed") 22 | with self.assertRaises(DockerConnectionError): 23 | EasierDocker(container_config={}, network_config={}) 24 | 25 | @patch("docker.from_env") 26 | def test_init_success(self, mock_from_env): 27 | client_mock = MagicMock() 28 | mock_from_env.return_value = client_mock 29 | docker_instance = EasierDocker(self.container_config) 30 | self.assertIsNotNone(docker_instance.client) 31 | 32 | @patch("docker.from_env", side_effect=DockerConnectionError("Docker connection failed")) 33 | def test_init_failure(self, mock_from_env): 34 | with self.assertRaises(DockerConnectionError): 35 | EasierDocker(self.container_config) 36 | 37 | @patch("docker.models.images.ImageCollection.get") 38 | def test_get_image_found_locally(self, mock_image_get): 39 | self.easier_docker._EasierDocker__get_image() 40 | mock_image_get.assert_called_once_with("test_image") 41 | 42 | @patch("docker.models.images.ImageCollection.get", side_effect=ImageNotFound("Image not found")) 43 | @patch("docker.api.APIClient.pull", return_value=[ 44 | '{"status": "Pulling", "progress": "50%"}'.encode("utf-8"), 45 | '{"status": "Complete"}'.encode("utf-8") 46 | ]) 47 | def test_get_image_pull_success(self, mock_pull, mock_image_get): 48 | self.easier_docker._EasierDocker__get_image() 49 | mock_pull.assert_called_once_with("test_image", stream=True) 50 | 51 | @patch("docker.models.images.ImageCollection.get", side_effect=ImageNotFound("Image not found")) 52 | @patch("docker.api.APIClient.pull", side_effect=NotFound("Image not in Docker Hub")) 53 | def test_get_image_pull_failure(self, mock_pull, mock_image_get): 54 | with self.assertRaises(NotFoundImageInDockerHub): 55 | self.easier_docker._EasierDocker__get_image() 56 | 57 | @patch("docker.models.containers.ContainerCollection.list", return_value=[]) 58 | @patch("docker.models.containers.ContainerCollection.run") 59 | def test_run_container_success(self, mock_run, mock_list): 60 | container_mock = MagicMock() 61 | container_mock.attrs = {"NetworkSettings": {"IPAddress": "127.0.0.1"}, "Created": "now"} 62 | container_mock.name = "test_container" 63 | container_mock.short_id = "12345" 64 | mock_run.return_value = container_mock 65 | 66 | self.easier_docker._EasierDocker__run_container() 67 | mock_run.assert_called_once_with(**self.container_config) 68 | 69 | @patch("docker.models.containers.ContainerCollection.list", return_value=[]) 70 | @patch("docker.models.containers.ContainerCollection.run", side_effect=APIError("API Error")) 71 | def test_run_container_failure(self, mock_run, mock_list): 72 | with self.assertRaises(APIError): 73 | self.easier_docker._EasierDocker__run_container() 74 | 75 | @patch("docker.models.containers.ContainerCollection.list", return_value=[MagicMock()]) 76 | def test_get_container_found(self, mock_list): 77 | container_mock = mock_list.return_value[0] 78 | container_mock.name = "test_container" 79 | container_mock.attrs = {"NetworkSettings": {"IPAddress": "127.0.0.1"}, "Created": "now"} 80 | container_mock.start = MagicMock() 81 | 82 | container = self.easier_docker._EasierDocker__get_container() 83 | self.assertEqual(container_mock, container) 84 | container_mock.start.assert_called_once() 85 | 86 | @patch("docker.models.containers.ContainerCollection.list", return_value=[]) 87 | def test_get_container_not_found(self, mock_list): 88 | container = self.easier_docker._EasierDocker__get_container() 89 | self.assertIsNone(container) 90 | 91 | @patch("docker.models.networks.NetworkCollection.list", return_value=[]) 92 | @patch("docker.models.networks.NetworkCollection.create") 93 | def test_create_network(self, mock_create, mock_list): 94 | self.easier_docker._EasierDocker__create_network() 95 | mock_create.assert_called_once_with(**self.network_config) 96 | 97 | @patch("docker.client.DockerClient.networks", new_callable=MagicMock) 98 | def test_create_network_exists(self, mock_networks): 99 | mock_network = MagicMock(name="test_network", short_id="short_id_1") 100 | mock_networks.list.return_value = [mock_network] 101 | self.easier_docker._EasierDocker__create_network() 102 | mock_networks.list.assert_called_once() 103 | --------------------------------------------------------------------------------