├── .flake8 ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── ---bug-report.md │ └── ---feature-request.md ├── pull_request_template.md └── workflows │ ├── check.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .readthedocs.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Pipfile ├── Pipfile.lock ├── README.md ├── docs ├── Makefile ├── make.bat ├── quickstart.gif ├── requirements.txt └── source │ ├── _static │ └── .githold │ ├── _templates │ └── .githold │ ├── api.rst │ ├── commands.rst │ ├── conf.py │ ├── development.rst │ ├── images.rst │ └── index.rst ├── run.py ├── setup.cfg ├── setup.py ├── tests ├── test_basic.py └── test_server.py ├── tox.ini └── wilfred ├── __init__.py ├── api ├── __init__.py ├── config_parser.py ├── images.py ├── parser │ ├── __init__.py │ ├── json.py │ ├── properties.py │ └── yaml.py ├── server_config.py └── servers.py ├── container_variables.py ├── core.py ├── database.py ├── decorators.py ├── docker_conn.py ├── errors.py ├── keyboard.py ├── message_handler.py ├── migrate.py ├── version.py └── wilfred.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 127 3 | extend-ignore = E203 4 | count=True 5 | statistics=True 6 | show_source = True 7 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: vilhelmprytz 2 | custom: "https://paypal.me/vilhelmprytz" 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F41B Bug Report" 3 | about: Create a report for a reproducible bug 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | ### Environment 10 | 11 | - Installation type: 12 | - Python version: 13 | - Wilfred version: 14 | 15 | ### Steps to Reproduce 16 | 17 | 18 | 19 | ### Expected Behavior 20 | 21 | 22 | 23 | ### Observed Behavior 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/---feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "\U0001F680 Feature Request" 3 | about: Propose new features or enhancements 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | Fixes #?? 4 | 5 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | name: Check code style 7 | steps: 8 | - uses: actions/checkout@v2 9 | - name: Setup python 10 | uses: actions/setup-python@v2 11 | with: 12 | python-version: "3.8" 13 | architecture: x64 14 | - name: Install dependencies 15 | env: 16 | PYTHON_VERSION: "3.8" 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install pipenv --upgrade 20 | pipenv --python "$PYTHON_VERSION" 21 | pipenv install --dev 22 | - name: Run tests 23 | env: 24 | TOXENV: "style" 25 | run: pipenv run tox 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [published] 4 | 5 | jobs: 6 | publish: 7 | name: Build and publish Wilfred to PyPI 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Setup python 12 | uses: actions/setup-python@v2 13 | with: 14 | python-version: "3.8" 15 | architecture: x64 16 | - name: Set release info 17 | run: | 18 | sed -i "s/development/$GITHUB_SHA/g" wilfred/version.py 19 | sed -i "s/YYYY-MM-DD/`git log -1 --format="%at" | xargs -I{} date -d @{} +%Y-%m-%d`/g" wilfred/version.py 20 | - name: Install pypa/build 21 | run: >- 22 | python -m 23 | pip install 24 | build 25 | --user 26 | - name: Build a binary wheel and a source tarball 27 | run: >- 28 | python -m 29 | build 30 | --sdist 31 | --wheel 32 | --outdir dist/ 33 | . 34 | - name: Publish distribution 📦 to PyPI 35 | if: startsWith(github.ref, 'refs/tags') 36 | uses: pypa/gh-action-pypi-publish@master 37 | with: 38 | password: ${{ secrets.PYPI_API_TOKEN }} 39 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] 9 | name: Python ${{ matrix.python-version }} tests 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Setup python 13 | uses: actions/setup-python@v2 14 | with: 15 | python-version: ${{ matrix.python-version }} 16 | architecture: x64 17 | - name: Install dependencies 18 | env: 19 | PYTHON_VERSION: "${{ matrix.python-version }}" 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install pipenv --upgrade 23 | pipenv --python "$PYTHON_VERSION" 24 | pipenv install --dev 25 | - name: Run tests 26 | env: 27 | TOXENV: "py${{ matrix.python-version }}" 28 | run: pipenv run tox 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | wilfred.egg-info 4 | build 5 | dist 6 | .eggs 7 | 8 | # Tests 9 | .tox 10 | .coveralls.yml 11 | .coverage 12 | 13 | # IDE 14 | .vscode 15 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | 3 | version: 2 4 | 5 | formats: all 6 | 7 | python: 8 | version: 3.8 9 | install: 10 | - requirements: docs/requirements.txt 11 | 12 | sphinx: 13 | builder: html 14 | configuration: docs/source/conf.py 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | Please refer to the [official documentation](https://docs.wilfredproject.org/en/latest/development.html) for more information about the CHANGELOG and releases. 4 | 5 | ## v0.10.1 (released on 2022-05-08) 6 | 7 | - **Changed** [c057239](https://github.com/wilfred-dev/wilfred/commit/c0572392c49031652e4b1607de40a1dbddcdcc6f) Removed snap package and all references to it (no longer supported, please use homebrew or pip). 8 | - **Changed** [#124](https://github.com/wilfred-dev/wilfred/issues/124) Changed so that Wilfred now binds to both TCP and UDP (applies to server port and any extra ports). 9 | - **Fixed** [df4fd00](https://github.com/wilfred-dev/wilfred/commit/df4fd00be10d24f558e268ec69ba59f09bb5ddd7) Fixed a bug that would cause `wilfred top` to crash when the Docker API does not provide enough statistics. 10 | 11 | ## v0.10.0 (released on 2022-04-24) 12 | 13 | - **Added** [157186c](https://github.com/wilfred-dev/wilfred/commit/157186cc588ac19272e2448f5c9dae0a398098e8) Added so that the running Python version is displayed when running `wilfred --version`. 14 | - **Changed** [1ff0a30](https://github.com/wilfred-dev/wilfred/commit/1ff0a30743f51d3fb25edbae283fedb7222f69d5) Added memory usage in perecent alongside in MB to `wilfred top`. 15 | - **Changed** [358b1dc](https://github.com/wilfred-dev/wilfred/commit/358b1dc6b57dd7fdc4190b823c5e7467f14b337a) Wilfred now requires Python 3.7 or newer to run. 16 | - **Fixed** [8d20053](https://github.com/wilfred-dev/wilfred/commit/8d200533157fa42e55c616f794d96e8b4d50d69d) Fixed so that associated server ports are deleted upon server removal. 17 | - **Fixed** [#120](https://github.com/wilfred-dev/wilfred/issues/120) Fixed broken CPU load calculation used by `wilfred top` (command no longer completely broken). 18 | 19 | ## v0.9.0 (released on 2021-11-09) 20 | 21 | - **Added** [#112](https://github.com/wilfred-dev/wilfred/issues/112) Added the ability to add additional ports to any server using `wilfred port `. 22 | - **Fixed** [#118](https://github.com/wilfred-dev/wilfred/issues/118) Fixed a minor spelling mistake during server creation. 23 | 24 | ## v0.8.0 (released on 2020-12-19) 25 | 26 | - **Added** [#92](https://github.com/wilfred-dev/wilfred/issues/92) Added new commit check on `--version`. If running the HEAD version of the brew package or the edge channel of the snap package, `wilfred --version` will now check for new commits. 27 | - **Added** [#74](https://github.com/wilfred-dev/wilfred/issues/74) Added the ability for Wilfred to automatically refresh the default images periodically. Wilfred will currently initiate refresh if images on file are older than 1 week or if the running version of Wilfred changes. 28 | - **Added** [#86](https://github.com/wilfred-dev/wilfred/issues/86) Added the ability to specify repo and branch as image source when running `wilfred images --refresh`. The new options are `--repo` (which by default has the value `wilfred-dev/images` and `--branch` (which by default has the value `master`). 29 | - **Fixed** [#87](https://github.com/wilfred-dev/wilfred/issues/87) Fixed so that `wilfred delete` no longer gracefully stops the container before deletion and instead kills the container (`container.kill()`). The use of `container.stop()` was not intended. This change will lead to faster server deletion. 30 | - **Fixed** [#84](https://github.com/wilfred-dev/wilfred/issues/84) Fixed a bug that would cause Wilfred to display a long traceback when `docker_client()` function raised exception (such as `DockerException` which is raised when Docker is not installed/broken) 31 | 32 | ## v0.7.1 (released on 2020-06-19) 33 | 34 | - **Fixed** [#57](https://github.com/wilfred-dev/wilfred/issues/57) Fixed a bug that caused `wilfred top` to crash when the installation finishes and the server starts (refactored underlying API). 35 | - **Fixed** [#58](https://github.com/wilfred-dev/wilfred/issues/58) Fixed a bug that caused all server statuses to show up as `stoppped`. `running`, `installing` and `stopped` are now properly displayed and detected (refactored underlying API, related to [#57](<(https://github.com/wilfred-dev/wilfred/issues/57)>)). 36 | 37 | ## v0.7.0 (released on 2020-06-17) 38 | 39 | - **Changed** [#56](https://github.com/wilfred-dev/wilfred/issues/56)/[#43](https://github.com/wilfred-dev/wilfred/issues/43) Name of server folders now include both the unique ID and the name of the server (easier to find the server folder). 40 | 41 | ## v0.6.1 (released on 2020-05-03) 42 | 43 | - **Fixed** [#54](https://github.com/wilfred-dev/wilfred/issues/54) Hopefully fixed broken PyPI deployment with Travis CI. 44 | - **Fixed** [#55](https://github.com/wilfred-dev/wilfred/issues/55) Fixed so that Docker exceptions reveal more info when installing by raising the Docker exceptions directly to the CLI. 45 | - **Changed** Replaced mkdocs documentation with Sphinx (and initial API autodoc). 46 | 47 | ## v0.6.0 (released on 2020-04-10) 48 | 49 | - **Added** [#47](https://github.com/wilfred-dev/wilfred/issues/47) Added `--force`/`-f` flags to `wilfred kill` and `wilfred delete` (forces actions without confirmation). 50 | - **Added** [#50](https://github.com/wilfred-dev/wilfred/issues/50) Added ability to reset back to default startup command (remove custom startup command). 51 | - **Changed** Enforce 20 character length limit on server names. 52 | - **Changed** [#42](https://github.com/wilfred-dev/wilfred/issues/42) Major refactor, separate the CLI from the core API and rewrite some of the core methods to be more consistent. With this, the Wilfred API now has it's own set of exceptions that it raise. The exceptions are no longer caught within the methods themselves and instead within the UI (a lot more predictable and makes a lot more sense). 53 | - **Fixed** [#49](https://github.com/wilfred-dev/wilfred/issues/49) Fixed a bug that caused Wilfred to crash if a container stopped running between the statement that checks if the server is running and the statement that actually retrieves the log in `wilfred console`. 54 | 55 | ## v0.5.1 (released on 2020-03-21) 56 | 57 | - **Added** Added project URLs to `setup.py`. 58 | - **Changed** Disabled terminal emojis on Windows (PowerShell and cmd have poor support for emojis). 59 | - **Fixed** [#46](https://github.com/wilfred-dev/wilfred/issues/46) Fixed a bug that caused Wilfred to crash if attaching to the server console during installation. 60 | 61 | ## v0.5.0 (released on 2020-03-20) 62 | 63 | - **Added** [#12](https://github.com/wilfred-dev/wilfred/issues/12) Added support for Windows. 64 | - **Added** Added new unit tests. 65 | - **Added** Print snap revision if Wilfred is installed via snap. 66 | - **Changed** Replaced [yaspin](https://pypi.org/project/yaspin/) with [halo](https://pypi.org/project/halo/) for terminal spinners (mostly because yaspin does not support Windows). 67 | - **Changed** Updated copyright headers. 68 | 69 | ## v0.4.2 (released on 2020-02-16) 70 | 71 | - **Fixed** [#41](https://github.com/wilfred-dev/wilfred/issues/41) Fixed a critical bug that caused servers with config settings linked to environment variables not to start. 72 | 73 | ## v0.4.1 (released on 2020-02-10) 74 | 75 | - **Fixed** [#37](https://github.com/wilfred-dev/wilfred/issues/37) Fixed so that the commit hash and build date are correctly displayed on `wilfred --version` for pip installations (error in Travis CI config). 76 | 77 | ## v0.4.0 (released on 2020-02-10) 78 | 79 | - **Added** [#21](https://github.com/wilfred-dev/wilfred/issues/21) Added `wilfred config`, ability to edit server configuration files. Exposes the server configuration to Wilfred. Currently supporting `.properties` and read-only `.yml` and `.json`. 80 | - **Added** Added image API version 2, introduces configuration files. 81 | - **Added** [#23](https://github.com/wilfred-dev/wilfred/issues/23) Added `wilfred top`, server statistics that fill the screen and updates in real-time (like `top`). 82 | - **Changed** [#30](https://github.com/wilfred-dev/wilfred/issues/30) Releases are now built with the git commit hash saved as a variable (including versions pushed to the Snap edge channel). `wilfred --version` displays the commit hash accordingly. 83 | - **Changed** Removed unnecessary `python3-distutils` and `build-essential` from `stage-packages` within the Snapcraft configuration (see [this comment](https://github.com/wilfred-dev/wilfred/issues/30#issuecomment-581396779)). 53 MB decrease in package size. 84 | - **Fixed** [#17](https://github.com/wilfred-dev/wilfred/issues/17) Changing port using `wilfred edit` should be able to trigger configuration update on supported filetypes (this is closely related to image API v2 and [#21](https://github.com/wilfred-dev/wilfred/issues/21)). 85 | - **Fixed** [#28](https://github.com/wilfred-dev/wilfred/issues/28) SQLAlchemy exceptions no longer occur when trying to delete a server that has no environment variables. 86 | - **Fixed** [#31](https://github.com/wilfred-dev/wilfred/issues/31) Config settings that are linked to an environment variable are no longer editable using `wilfred config`. 87 | 88 | ## v0.3.0 (released on 2020-01-25) 89 | 90 | - **Added** Added check for new releases against GitHub when running `wilfred --version`. 91 | - **Added** Added check so that it is now required for all images UID's to be lowercase. 92 | - **Changed** Refactor: more clean way of searching for images internally (improved the `image.get_image` function using `next`). 93 | 94 | ## v0.2.0 (released on 2020-01-18) 95 | 96 | - **Added** [#14](https://github.com/wilfred-dev/wilfred/issues/14) Added ability to restart servers using `wilfred restart `. 97 | - **Added** [#15](https://github.com/wilfred-dev/wilfred/issues/15) Added ability to send a single command to STDIN of the server without attaching to the server console. 98 | - **Changed** [#16](https://github.com/wilfred-dev/wilfred/issues/16) Changed database management (major rewrite), use SQLAlchemy instead of raw SQL queries everywhere 99 | - **Fixed** Empty environment variables in startup commands are now correctly replaced (bug). 100 | 101 | ## v0.1.1 (released on 2020-01-11) 102 | 103 | - **Changed** [#11](https://github.com/wilfred-dev/wilfred/issues/11) Build `wheel` package along with standard `sdist` on Travis CI PyPI deployment. 104 | - **Changed** Update help texts. 105 | - **Fixed** Only perform Travis CI PyPI deployment once, an error in the config caused the CI to deploy twice during the `v0.1.0` release. 106 | - **Fixed** [#10](https://github.com/wilfred-dev/wilfred/issues/10) Truncate custom startup commands, too long commands no longer break table styling. 107 | 108 | ## v0.1.0 (released on 2020-01-11) 109 | 110 | - Initial release 111 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at vilhelm@prytznet.se. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2022 Vilhelm Prytz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | # flake8 = "*" 8 | black = "*" 9 | tox = "*" 10 | sphinx = "*" 11 | sphinx-autobuild = "*" 12 | 13 | [packages] 14 | docker = "*" 15 | click = "*" 16 | colorama = "*" 17 | appdirs = "*" 18 | requests = "*" 19 | tabulate = "*" 20 | sqlalchemy = "*" 21 | pyyaml = "*" 22 | halo = "*" 23 | 24 | [requires] 25 | python_version = "3.8" 26 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "a5887a2a86d659e4627b396bbbdbb443eb377c161ad39e270385ec20c89ca969" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "appdirs": { 20 | "hashes": [ 21 | "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", 22 | "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" 23 | ], 24 | "index": "pypi", 25 | "version": "==1.4.4" 26 | }, 27 | "certifi": { 28 | "hashes": [ 29 | "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1", 30 | "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474" 31 | ], 32 | "markers": "python_version >= '3.6'", 33 | "version": "==2023.11.17" 34 | }, 35 | "charset-normalizer": { 36 | "hashes": [ 37 | "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", 38 | "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", 39 | "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", 40 | "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", 41 | "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", 42 | "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", 43 | "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", 44 | "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", 45 | "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", 46 | "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", 47 | "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", 48 | "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", 49 | "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", 50 | "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", 51 | "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", 52 | "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", 53 | "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", 54 | "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", 55 | "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", 56 | "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", 57 | "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", 58 | "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", 59 | "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", 60 | "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", 61 | "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", 62 | "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", 63 | "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", 64 | "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", 65 | "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", 66 | "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", 67 | "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", 68 | "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", 69 | "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", 70 | "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", 71 | "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", 72 | "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", 73 | "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", 74 | "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", 75 | "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", 76 | "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", 77 | "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", 78 | "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", 79 | "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", 80 | "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", 81 | "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", 82 | "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", 83 | "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", 84 | "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", 85 | "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", 86 | "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", 87 | "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", 88 | "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", 89 | "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", 90 | "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", 91 | "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", 92 | "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", 93 | "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", 94 | "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", 95 | "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", 96 | "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", 97 | "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", 98 | "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", 99 | "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", 100 | "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", 101 | "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", 102 | "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", 103 | "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", 104 | "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", 105 | "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", 106 | "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", 107 | "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", 108 | "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", 109 | "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", 110 | "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", 111 | "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", 112 | "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", 113 | "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", 114 | "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", 115 | "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", 116 | "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", 117 | "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", 118 | "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", 119 | "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", 120 | "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", 121 | "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", 122 | "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", 123 | "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", 124 | "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", 125 | "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", 126 | "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" 127 | ], 128 | "markers": "python_full_version >= '3.7.0'", 129 | "version": "==3.3.2" 130 | }, 131 | "click": { 132 | "hashes": [ 133 | "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", 134 | "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" 135 | ], 136 | "index": "pypi", 137 | "markers": "python_version >= '3.7'", 138 | "version": "==8.1.7" 139 | }, 140 | "colorama": { 141 | "hashes": [ 142 | "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", 143 | "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" 144 | ], 145 | "index": "pypi", 146 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", 147 | "version": "==0.4.6" 148 | }, 149 | "docker": { 150 | "hashes": [ 151 | "sha256:12ba681f2777a0ad28ffbcc846a69c31b4dfd9752b47eb425a274ee269c5e14b", 152 | "sha256:323736fb92cd9418fc5e7133bc953e11a9da04f4483f828b527db553f1e7e5a3" 153 | ], 154 | "index": "pypi", 155 | "markers": "python_version >= '3.8'", 156 | "version": "==7.0.0" 157 | }, 158 | "halo": { 159 | "hashes": [ 160 | "sha256:5350488fb7d2aa7c31a1344120cee67a872901ce8858f60da7946cef96c208ab", 161 | "sha256:7b67a3521ee91d53b7152d4ee3452811e1d2a6321975137762eb3d70063cc9d6" 162 | ], 163 | "index": "pypi", 164 | "markers": "python_version >= '3.4'", 165 | "version": "==0.0.31" 166 | }, 167 | "idna": { 168 | "hashes": [ 169 | "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", 170 | "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" 171 | ], 172 | "markers": "python_version >= '3.5'", 173 | "version": "==3.6" 174 | }, 175 | "log-symbols": { 176 | "hashes": [ 177 | "sha256:4952106ff8b605ab7d5081dd2c7e6ca7374584eff7086f499c06edd1ce56dcca", 178 | "sha256:cf0bbc6fe1a8e53f0d174a716bc625c4f87043cc21eb55dd8a740cfe22680556" 179 | ], 180 | "version": "==0.0.14" 181 | }, 182 | "packaging": { 183 | "hashes": [ 184 | "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", 185 | "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" 186 | ], 187 | "markers": "python_version >= '3.7'", 188 | "version": "==23.2" 189 | }, 190 | "pyyaml": { 191 | "hashes": [ 192 | "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5", 193 | "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc", 194 | "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df", 195 | "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741", 196 | "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206", 197 | "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27", 198 | "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595", 199 | "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62", 200 | "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98", 201 | "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696", 202 | "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290", 203 | "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9", 204 | "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d", 205 | "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6", 206 | "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867", 207 | "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47", 208 | "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486", 209 | "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6", 210 | "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3", 211 | "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007", 212 | "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938", 213 | "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0", 214 | "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c", 215 | "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735", 216 | "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d", 217 | "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28", 218 | "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4", 219 | "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba", 220 | "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8", 221 | "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5", 222 | "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd", 223 | "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3", 224 | "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0", 225 | "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515", 226 | "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c", 227 | "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c", 228 | "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924", 229 | "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34", 230 | "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43", 231 | "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859", 232 | "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673", 233 | "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54", 234 | "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a", 235 | "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b", 236 | "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab", 237 | "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa", 238 | "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c", 239 | "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585", 240 | "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", 241 | "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" 242 | ], 243 | "index": "pypi", 244 | "markers": "python_version >= '3.6'", 245 | "version": "==6.0.1" 246 | }, 247 | "requests": { 248 | "hashes": [ 249 | "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", 250 | "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" 251 | ], 252 | "index": "pypi", 253 | "markers": "python_version >= '3.7'", 254 | "version": "==2.31.0" 255 | }, 256 | "six": { 257 | "hashes": [ 258 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 259 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 260 | ], 261 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 262 | "version": "==1.16.0" 263 | }, 264 | "spinners": { 265 | "hashes": [ 266 | "sha256:1eb6aeb4781d72ab42ed8a01dcf20f3002bf50740d7154d12fb8c9769bf9e27f", 267 | "sha256:2fa30d0b72c9650ad12bbe031c9943b8d441e41b4f5602b0ec977a19f3290e98" 268 | ], 269 | "version": "==0.0.24" 270 | }, 271 | "sqlalchemy": { 272 | "hashes": [ 273 | "sha256:0d3cab3076af2e4aa5693f89622bef7fa770c6fec967143e4da7508b3dceb9b9", 274 | "sha256:0dacf67aee53b16f365c589ce72e766efaabd2b145f9de7c917777b575e3659d", 275 | "sha256:10331f129982a19df4284ceac6fe87353ca3ca6b4ca77ff7d697209ae0a5915e", 276 | "sha256:14a6f68e8fc96e5e8f5647ef6cda6250c780612a573d99e4d881581432ef1669", 277 | "sha256:1b1180cda6df7af84fe72e4530f192231b1f29a7496951db4ff38dac1687202d", 278 | "sha256:29049e2c299b5ace92cbed0c1610a7a236f3baf4c6b66eb9547c01179f638ec5", 279 | "sha256:342d365988ba88ada8af320d43df4e0b13a694dbd75951f537b2d5e4cb5cd002", 280 | "sha256:420362338681eec03f53467804541a854617faed7272fe71a1bfdb07336a381e", 281 | "sha256:4344d059265cc8b1b1be351bfb88749294b87a8b2bbe21dfbe066c4199541ebd", 282 | "sha256:4f7a7d7fcc675d3d85fbf3b3828ecd5990b8d61bd6de3f1b260080b3beccf215", 283 | "sha256:555651adbb503ac7f4cb35834c5e4ae0819aab2cd24857a123370764dc7d7e24", 284 | "sha256:59a21853f5daeb50412d459cfb13cb82c089ad4c04ec208cd14dddd99fc23b39", 285 | "sha256:5fdd402169aa00df3142149940b3bf9ce7dde075928c1886d9a1df63d4b8de62", 286 | "sha256:605b6b059f4b57b277f75ace81cc5bc6335efcbcc4ccb9066695e515dbdb3900", 287 | "sha256:665f0a3954635b5b777a55111ababf44b4fc12b1f3ba0a435b602b6387ffd7cf", 288 | "sha256:6f9e2e59cbcc6ba1488404aad43de005d05ca56e069477b33ff74e91b6319735", 289 | "sha256:736ea78cd06de6c21ecba7416499e7236a22374561493b456a1f7ffbe3f6cdb4", 290 | "sha256:74b080c897563f81062b74e44f5a72fa44c2b373741a9ade701d5f789a10ba23", 291 | "sha256:75432b5b14dc2fff43c50435e248b45c7cdadef73388e5610852b95280ffd0e9", 292 | "sha256:75f99202324383d613ddd1f7455ac908dca9c2dd729ec8584c9541dd41822a2c", 293 | "sha256:790f533fa5c8901a62b6fef5811d48980adeb2f51f1290ade8b5e7ba990ba3de", 294 | "sha256:798f717ae7c806d67145f6ae94dc7c342d3222d3b9a311a784f371a4333212c7", 295 | "sha256:7c88f0c7dcc5f99bdb34b4fd9b69b93c89f893f454f40219fe923a3a2fd11625", 296 | "sha256:7d505815ac340568fd03f719446a589162d55c52f08abd77ba8964fbb7eb5b5f", 297 | "sha256:84daa0a2055df9ca0f148a64fdde12ac635e30edbca80e87df9b3aaf419e144a", 298 | "sha256:87d91043ea0dc65ee583026cb18e1b458d8ec5fc0a93637126b5fc0bc3ea68c4", 299 | "sha256:87f6e732bccd7dcf1741c00f1ecf33797383128bd1c90144ac8adc02cbb98643", 300 | "sha256:884272dcd3ad97f47702965a0e902b540541890f468d24bd1d98bcfe41c3f018", 301 | "sha256:8b8cb63d3ea63b29074dcd29da4dc6a97ad1349151f2d2949495418fd6e48db9", 302 | "sha256:91f7d9d1c4dd1f4f6e092874c128c11165eafcf7c963128f79e28f8445de82d5", 303 | "sha256:a2c69a7664fb2d54b8682dd774c3b54f67f84fa123cf84dda2a5f40dcaa04e08", 304 | "sha256:a3be4987e3ee9d9a380b66393b77a4cd6d742480c951a1c56a23c335caca4ce3", 305 | "sha256:a86b4240e67d4753dc3092d9511886795b3c2852abe599cffe108952f7af7ac3", 306 | "sha256:aa9373708763ef46782d10e950b49d0235bfe58facebd76917d3f5cbf5971aed", 307 | "sha256:b64b183d610b424a160b0d4d880995e935208fc043d0302dd29fee32d1ee3f95", 308 | "sha256:b801154027107461ee992ff4b5c09aa7cc6ec91ddfe50d02bca344918c3265c6", 309 | "sha256:bb209a73b8307f8fe4fe46f6ad5979649be01607f11af1eb94aa9e8a3aaf77f0", 310 | "sha256:bc8b7dabe8e67c4832891a5d322cec6d44ef02f432b4588390017f5cec186a84", 311 | "sha256:c51db269513917394faec5e5c00d6f83829742ba62e2ac4fa5c98d58be91662f", 312 | "sha256:c55731c116806836a5d678a70c84cb13f2cedba920212ba7dcad53260997666d", 313 | "sha256:cf18ff7fc9941b8fc23437cc3e68ed4ebeff3599eec6ef5eebf305f3d2e9a7c2", 314 | "sha256:d24f571990c05f6b36a396218f251f3e0dda916e0c687ef6fdca5072743208f5", 315 | "sha256:db854730a25db7c956423bb9fb4bdd1216c839a689bf9cc15fada0a7fb2f4570", 316 | "sha256:dc55990143cbd853a5d038c05e79284baedf3e299661389654551bd02a6a68d7", 317 | "sha256:e607cdd99cbf9bb80391f54446b86e16eea6ad309361942bf88318bcd452363c", 318 | "sha256:ecf6d4cda1f9f6cb0b45803a01ea7f034e2f1aed9475e883410812d9f9e3cfcf", 319 | "sha256:f2a159111a0f58fb034c93eeba211b4141137ec4b0a6e75789ab7a3ef3c7e7e3", 320 | "sha256:f37c0caf14b9e9b9e8f6dbc81bc56db06acb4363eba5a633167781a48ef036ed", 321 | "sha256:f5693145220517b5f42393e07a6898acdfe820e136c98663b971906120549da5" 322 | ], 323 | "index": "pypi", 324 | "markers": "python_version >= '3.7'", 325 | "version": "==2.0.25" 326 | }, 327 | "tabulate": { 328 | "hashes": [ 329 | "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", 330 | "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f" 331 | ], 332 | "index": "pypi", 333 | "markers": "python_version >= '3.7'", 334 | "version": "==0.9.0" 335 | }, 336 | "termcolor": { 337 | "hashes": [ 338 | "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63", 339 | "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a" 340 | ], 341 | "markers": "python_version >= '3.8'", 342 | "version": "==2.4.0" 343 | }, 344 | "typing-extensions": { 345 | "hashes": [ 346 | "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", 347 | "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" 348 | ], 349 | "markers": "python_version >= '3.8'", 350 | "version": "==4.9.0" 351 | }, 352 | "urllib3": { 353 | "hashes": [ 354 | "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3", 355 | "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54" 356 | ], 357 | "markers": "python_version >= '3.8'", 358 | "version": "==2.1.0" 359 | } 360 | }, 361 | "develop": { 362 | "alabaster": { 363 | "hashes": [ 364 | "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3", 365 | "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2" 366 | ], 367 | "markers": "python_version >= '3.6'", 368 | "version": "==0.7.13" 369 | }, 370 | "babel": { 371 | "hashes": [ 372 | "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363", 373 | "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287" 374 | ], 375 | "markers": "python_version >= '3.7'", 376 | "version": "==2.14.0" 377 | }, 378 | "black": { 379 | "hashes": [ 380 | "sha256:0808494f2b2df923ffc5723ed3c7b096bd76341f6213989759287611e9837d50", 381 | "sha256:1fa88a0f74e50e4487477bc0bb900c6781dbddfdfa32691e780bf854c3b4a47f", 382 | "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e", 383 | "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec", 384 | "sha256:3e1b38b3135fd4c025c28c55ddfc236b05af657828a8a6abe5deec419a0b7055", 385 | "sha256:3fa4be75ef2a6b96ea8d92b1587dd8cb3a35c7e3d51f0738ced0781c3aa3a5a3", 386 | "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5", 387 | "sha256:4f0031eaa7b921db76decd73636ef3a12c942ed367d8c3841a0739412b260a54", 388 | "sha256:602cfb1196dc692424c70b6507593a2b29aac0547c1be9a1d1365f0d964c353b", 389 | "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e", 390 | "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e", 391 | "sha256:8d4df77958a622f9b5a4c96edb4b8c0034f8434032ab11077ec6c56ae9f384ba", 392 | "sha256:97e56155c6b737854e60a9ab1c598ff2533d57e7506d97af5481141671abf3ea", 393 | "sha256:9c4352800f14be5b4864016882cdba10755bd50805c95f728011bcb47a4afd59", 394 | "sha256:a4d6a9668e45ad99d2f8ec70d5c8c04ef4f32f648ef39048d010b0689832ec6d", 395 | "sha256:a920b569dc6b3472513ba6ddea21f440d4b4c699494d2e972a1753cdc25df7b0", 396 | "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9", 397 | "sha256:b18fb2ae6c4bb63eebe5be6bd869ba2f14fd0259bda7d18a46b764d8fb86298a", 398 | "sha256:c04b6d9d20e9c13f43eee8ea87d44156b8505ca8a3c878773f68b4e4812a421e", 399 | "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba", 400 | "sha256:dd15245c8b68fe2b6bd0f32c1556509d11bb33aec9b5d0866dd8e2ed3dba09c2", 401 | "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2" 402 | ], 403 | "index": "pypi", 404 | "markers": "python_version >= '3.8'", 405 | "version": "==23.12.1" 406 | }, 407 | "cachetools": { 408 | "hashes": [ 409 | "sha256:086ee420196f7b2ab9ca2db2520aca326318b68fe5ba8bc4d49cca91add450f2", 410 | "sha256:861f35a13a451f94e301ce2bec7cac63e881232ccce7ed67fab9b5df4d3beaa1" 411 | ], 412 | "markers": "python_version >= '3.7'", 413 | "version": "==5.3.2" 414 | }, 415 | "certifi": { 416 | "hashes": [ 417 | "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1", 418 | "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474" 419 | ], 420 | "markers": "python_version >= '3.6'", 421 | "version": "==2023.11.17" 422 | }, 423 | "chardet": { 424 | "hashes": [ 425 | "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", 426 | "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970" 427 | ], 428 | "markers": "python_version >= '3.7'", 429 | "version": "==5.2.0" 430 | }, 431 | "charset-normalizer": { 432 | "hashes": [ 433 | "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027", 434 | "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087", 435 | "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786", 436 | "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8", 437 | "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09", 438 | "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185", 439 | "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574", 440 | "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e", 441 | "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519", 442 | "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898", 443 | "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269", 444 | "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3", 445 | "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f", 446 | "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6", 447 | "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8", 448 | "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a", 449 | "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73", 450 | "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc", 451 | "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714", 452 | "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2", 453 | "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc", 454 | "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce", 455 | "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d", 456 | "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e", 457 | "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6", 458 | "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269", 459 | "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96", 460 | "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d", 461 | "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a", 462 | "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4", 463 | "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77", 464 | "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d", 465 | "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0", 466 | "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed", 467 | "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068", 468 | "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac", 469 | "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25", 470 | "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8", 471 | "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab", 472 | "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26", 473 | "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2", 474 | "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db", 475 | "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f", 476 | "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5", 477 | "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99", 478 | "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c", 479 | "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d", 480 | "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811", 481 | "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa", 482 | "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a", 483 | "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03", 484 | "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b", 485 | "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04", 486 | "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c", 487 | "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001", 488 | "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458", 489 | "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389", 490 | "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99", 491 | "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985", 492 | "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537", 493 | "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238", 494 | "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f", 495 | "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d", 496 | "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796", 497 | "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a", 498 | "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143", 499 | "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8", 500 | "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c", 501 | "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5", 502 | "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5", 503 | "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711", 504 | "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4", 505 | "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6", 506 | "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c", 507 | "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7", 508 | "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4", 509 | "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b", 510 | "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae", 511 | "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12", 512 | "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c", 513 | "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae", 514 | "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8", 515 | "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887", 516 | "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b", 517 | "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4", 518 | "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f", 519 | "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5", 520 | "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33", 521 | "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519", 522 | "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561" 523 | ], 524 | "markers": "python_full_version >= '3.7.0'", 525 | "version": "==3.3.2" 526 | }, 527 | "click": { 528 | "hashes": [ 529 | "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", 530 | "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" 531 | ], 532 | "index": "pypi", 533 | "markers": "python_version >= '3.7'", 534 | "version": "==8.1.7" 535 | }, 536 | "colorama": { 537 | "hashes": [ 538 | "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", 539 | "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" 540 | ], 541 | "index": "pypi", 542 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", 543 | "version": "==0.4.6" 544 | }, 545 | "distlib": { 546 | "hashes": [ 547 | "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", 548 | "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64" 549 | ], 550 | "version": "==0.3.8" 551 | }, 552 | "docutils": { 553 | "hashes": [ 554 | "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", 555 | "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b" 556 | ], 557 | "markers": "python_version >= '3.7'", 558 | "version": "==0.20.1" 559 | }, 560 | "filelock": { 561 | "hashes": [ 562 | "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e", 563 | "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c" 564 | ], 565 | "markers": "python_version >= '3.8'", 566 | "version": "==3.13.1" 567 | }, 568 | "idna": { 569 | "hashes": [ 570 | "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", 571 | "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" 572 | ], 573 | "markers": "python_version >= '3.5'", 574 | "version": "==3.6" 575 | }, 576 | "imagesize": { 577 | "hashes": [ 578 | "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", 579 | "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a" 580 | ], 581 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 582 | "version": "==1.4.1" 583 | }, 584 | "importlib-metadata": { 585 | "hashes": [ 586 | "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e", 587 | "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc" 588 | ], 589 | "markers": "python_version < '3.10'", 590 | "version": "==7.0.1" 591 | }, 592 | "jinja2": { 593 | "hashes": [ 594 | "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", 595 | "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" 596 | ], 597 | "markers": "python_version >= '3.7'", 598 | "version": "==3.1.2" 599 | }, 600 | "livereload": { 601 | "hashes": [ 602 | "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869", 603 | "sha256:ad4ac6f53b2d62bb6ce1a5e6e96f1f00976a32348afedcb4b6d68df2a1d346e4" 604 | ], 605 | "version": "==2.6.3" 606 | }, 607 | "markupsafe": { 608 | "hashes": [ 609 | "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e", 610 | "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e", 611 | "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431", 612 | "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686", 613 | "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c", 614 | "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559", 615 | "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc", 616 | "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb", 617 | "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939", 618 | "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c", 619 | "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0", 620 | "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4", 621 | "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9", 622 | "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575", 623 | "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba", 624 | "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d", 625 | "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd", 626 | "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3", 627 | "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00", 628 | "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155", 629 | "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac", 630 | "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52", 631 | "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f", 632 | "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8", 633 | "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b", 634 | "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007", 635 | "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24", 636 | "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea", 637 | "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198", 638 | "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0", 639 | "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee", 640 | "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be", 641 | "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2", 642 | "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1", 643 | "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707", 644 | "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6", 645 | "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c", 646 | "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58", 647 | "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823", 648 | "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779", 649 | "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636", 650 | "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c", 651 | "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad", 652 | "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee", 653 | "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc", 654 | "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2", 655 | "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48", 656 | "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7", 657 | "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e", 658 | "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b", 659 | "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa", 660 | "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5", 661 | "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e", 662 | "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb", 663 | "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9", 664 | "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57", 665 | "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc", 666 | "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc", 667 | "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2", 668 | "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11" 669 | ], 670 | "markers": "python_version >= '3.7'", 671 | "version": "==2.1.3" 672 | }, 673 | "mypy-extensions": { 674 | "hashes": [ 675 | "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", 676 | "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" 677 | ], 678 | "markers": "python_version >= '3.5'", 679 | "version": "==1.0.0" 680 | }, 681 | "packaging": { 682 | "hashes": [ 683 | "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", 684 | "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" 685 | ], 686 | "markers": "python_version >= '3.7'", 687 | "version": "==23.2" 688 | }, 689 | "pathspec": { 690 | "hashes": [ 691 | "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", 692 | "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" 693 | ], 694 | "markers": "python_version >= '3.8'", 695 | "version": "==0.12.1" 696 | }, 697 | "platformdirs": { 698 | "hashes": [ 699 | "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380", 700 | "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420" 701 | ], 702 | "markers": "python_version >= '3.8'", 703 | "version": "==4.1.0" 704 | }, 705 | "pluggy": { 706 | "hashes": [ 707 | "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12", 708 | "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7" 709 | ], 710 | "markers": "python_version >= '3.8'", 711 | "version": "==1.3.0" 712 | }, 713 | "pygments": { 714 | "hashes": [ 715 | "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c", 716 | "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367" 717 | ], 718 | "markers": "python_version >= '3.7'", 719 | "version": "==2.17.2" 720 | }, 721 | "pyproject-api": { 722 | "hashes": [ 723 | "sha256:1817dc018adc0d1ff9ca1ed8c60e1623d5aaca40814b953af14a9cf9a5cae538", 724 | "sha256:4c0116d60476b0786c88692cf4e325a9814965e2469c5998b830bba16b183675" 725 | ], 726 | "markers": "python_version >= '3.8'", 727 | "version": "==1.6.1" 728 | }, 729 | "pytz": { 730 | "hashes": [ 731 | "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b", 732 | "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7" 733 | ], 734 | "markers": "python_version < '3.9'", 735 | "version": "==2023.3.post1" 736 | }, 737 | "requests": { 738 | "hashes": [ 739 | "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", 740 | "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" 741 | ], 742 | "index": "pypi", 743 | "markers": "python_version >= '3.7'", 744 | "version": "==2.31.0" 745 | }, 746 | "six": { 747 | "hashes": [ 748 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 749 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 750 | ], 751 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 752 | "version": "==1.16.0" 753 | }, 754 | "snowballstemmer": { 755 | "hashes": [ 756 | "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", 757 | "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" 758 | ], 759 | "version": "==2.2.0" 760 | }, 761 | "sphinx": { 762 | "hashes": [ 763 | "sha256:780f4d32f1d7d1126576e0e5ecc19dc32ab76cd24e950228dcf7b1f6d3d9e22f", 764 | "sha256:d170a81825b2fcacb6dfd5a0d7f578a053e45d3f2b153fecc948c37344eb4cbe" 765 | ], 766 | "index": "pypi", 767 | "markers": "python_version >= '3.8'", 768 | "version": "==7.1.2" 769 | }, 770 | "sphinx-autobuild": { 771 | "hashes": [ 772 | "sha256:8fe8cbfdb75db04475232f05187c776f46f6e9e04cacf1e49ce81bdac649ccac", 773 | "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05" 774 | ], 775 | "index": "pypi", 776 | "markers": "python_version >= '3.6'", 777 | "version": "==2021.3.14" 778 | }, 779 | "sphinxcontrib-applehelp": { 780 | "hashes": [ 781 | "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228", 782 | "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e" 783 | ], 784 | "markers": "python_version >= '3.8'", 785 | "version": "==1.0.4" 786 | }, 787 | "sphinxcontrib-devhelp": { 788 | "hashes": [ 789 | "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", 790 | "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" 791 | ], 792 | "markers": "python_version >= '3.5'", 793 | "version": "==1.0.2" 794 | }, 795 | "sphinxcontrib-htmlhelp": { 796 | "hashes": [ 797 | "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff", 798 | "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903" 799 | ], 800 | "markers": "python_version >= '3.8'", 801 | "version": "==2.0.1" 802 | }, 803 | "sphinxcontrib-jsmath": { 804 | "hashes": [ 805 | "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", 806 | "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" 807 | ], 808 | "markers": "python_version >= '3.5'", 809 | "version": "==1.0.1" 810 | }, 811 | "sphinxcontrib-qthelp": { 812 | "hashes": [ 813 | "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", 814 | "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" 815 | ], 816 | "markers": "python_version >= '3.5'", 817 | "version": "==1.0.3" 818 | }, 819 | "sphinxcontrib-serializinghtml": { 820 | "hashes": [ 821 | "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd", 822 | "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952" 823 | ], 824 | "markers": "python_version >= '3.5'", 825 | "version": "==1.1.5" 826 | }, 827 | "tomli": { 828 | "hashes": [ 829 | "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", 830 | "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" 831 | ], 832 | "markers": "python_version < '3.11'", 833 | "version": "==2.0.1" 834 | }, 835 | "tornado": { 836 | "hashes": [ 837 | "sha256:02ccefc7d8211e5a7f9e8bc3f9e5b0ad6262ba2fbb683a6443ecc804e5224ce0", 838 | "sha256:10aeaa8006333433da48dec9fe417877f8bcc21f48dda8d661ae79da357b2a63", 839 | "sha256:27787de946a9cffd63ce5814c33f734c627a87072ec7eed71f7fc4417bb16263", 840 | "sha256:6f8a6c77900f5ae93d8b4ae1196472d0ccc2775cc1dfdc9e7727889145c45052", 841 | "sha256:71ddfc23a0e03ef2df1c1397d859868d158c8276a0603b96cf86892bff58149f", 842 | "sha256:72291fa6e6bc84e626589f1c29d90a5a6d593ef5ae68052ee2ef000dfd273dee", 843 | "sha256:88b84956273fbd73420e6d4b8d5ccbe913c65d31351b4c004ae362eba06e1f78", 844 | "sha256:e43bc2e5370a6a8e413e1e1cd0c91bedc5bd62a74a532371042a18ef19e10579", 845 | "sha256:f0251554cdd50b4b44362f73ad5ba7126fc5b2c2895cc62b14a1c2d7ea32f212", 846 | "sha256:f7894c581ecdcf91666a0912f18ce5e757213999e183ebfc2c3fdbf4d5bd764e", 847 | "sha256:fd03192e287fbd0899dd8f81c6fb9cbbc69194d2074b38f384cb6fa72b80e9c2" 848 | ], 849 | "markers": "python_version > '2.7'", 850 | "version": "==6.4" 851 | }, 852 | "tox": { 853 | "hashes": [ 854 | "sha256:2adb83d68f27116812b69aa36676a8d6a52249cb0d173649de0e7d0c2e3e7229", 855 | "sha256:73a7240778fabf305aeb05ab8ea26e575e042ab5a18d71d0ed13e343a51d6ce1" 856 | ], 857 | "index": "pypi", 858 | "markers": "python_version >= '3.8'", 859 | "version": "==4.11.4" 860 | }, 861 | "typing-extensions": { 862 | "hashes": [ 863 | "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", 864 | "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" 865 | ], 866 | "markers": "python_version >= '3.8'", 867 | "version": "==4.9.0" 868 | }, 869 | "urllib3": { 870 | "hashes": [ 871 | "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3", 872 | "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54" 873 | ], 874 | "markers": "python_version >= '3.8'", 875 | "version": "==2.1.0" 876 | }, 877 | "virtualenv": { 878 | "hashes": [ 879 | "sha256:4238949c5ffe6876362d9c0180fc6c3a824a7b12b80604eeb8085f2ed7460de3", 880 | "sha256:bf51c0d9c7dd63ea8e44086fa1e4fb1093a31e963b86959257378aef020e1f1b" 881 | ], 882 | "markers": "python_version >= '3.7'", 883 | "version": "==20.25.0" 884 | }, 885 | "zipp": { 886 | "hashes": [ 887 | "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31", 888 | "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0" 889 | ], 890 | "markers": "python_version >= '3.8'", 891 | "version": "==3.17.0" 892 | } 893 | } 894 | } 895 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wilfred 2 | 3 | [![.github/workflows/check.yml](https://github.com/wilfred-dev/wilfred/actions/workflows/check.yml/badge.svg)](https://github.com/wilfred-dev/wilfred/actions/workflows/check.yml) 4 | [![Python Versions](https://img.shields.io/pypi/pyversions/wilfred)](https://pypi.org/project/wilfred) 5 | [![pypi](https://img.shields.io/pypi/v/wilfred)](https://pypi.org/project/wilfred) 6 | [![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/wilfred-dev/wilfred.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/wilfred-dev/wilfred/context:python) 7 | [![Total alerts](https://img.shields.io/lgtm/alerts/g/wilfred-dev/wilfred.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/wilfred-dev/wilfred/alerts/) 8 | [![Downloads](https://pepy.tech/badge/wilfred)](https://pepy.tech/project/wilfred) 9 | [![Discord](https://img.shields.io/discord/666366973072113698?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://wilfredproject.org/discord) 10 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 11 | 12 | Wilfred is a command-line interface for running and managing game servers locally. It uses Docker to run game servers in containers, which means they are completely separated. Wilfred can run any game that can run in Docker. 13 | 14 | ⚠️ Wilfred is currently under development and should not be considered stable. Features may break or may not be implemented yet. Use with caution. 15 | 16 | ## Documentation 17 | 18 | The official documentation is available [here](https://docs.wilfredproject.org/en/latest/). For support, use our [Discord Chat](https://wilfredproject.org/discord). For bugs, you can open an issue [here](https://github.com/wilfred-dev/wilfred/issues). 19 | 20 | ## Supported games 21 | 22 | As long as your server can run in Docker, it can probably run using Wilfred (after some tinkering). These are the games supported by default. You can submit new games to [wilfred-dev/images](https://github.com/wilfred-dev/images). 23 | 24 | - Minecraft 25 | - Vanilla Minecraft 26 | - BungeeCord 27 | - Paper 28 | - Spigot 29 | - SpongeVanilla 30 | - Waterfall 31 | - Bedrock 32 | - Mindustry 33 | - Multi Theft Auto 34 | - Voice Servers 35 | - TeamSpeak 3 36 | - Mumble 37 | 38 | ## Installation 39 | 40 | Please refer to the [official documentation](https://docs.wilfredproject.org/en/latest/#installation) for further installation instructions and documentation. 41 | 42 | ### Quickstart 43 | 44 | Make sure you have Docker installed (see the official documentation for more info). The recommended way of installing Wilfred is via [Homebrew](https://brew.sh). Once brew is installed, Wilfred can easily be installed from the official tap. 45 | 46 | ```bash 47 | brew tap wilfred-dev/wilfred 48 | brew install wilfred 49 | ``` 50 | 51 | Want the bleeding edge? You can install the latest commit using `--HEAD` (bugs are to be expected, don't use in production environments!). 52 | 53 | ```bash 54 | brew tap wilfred-dev/wilfred 55 | brew install --HEAD wilfred 56 | ``` 57 | 58 | Wilfred can also be installed using `pip`. You need to use **Python 3.8** or newer to run Wilfred. 59 | 60 | ```bash 61 | pip install wilfred --upgrade 62 | ``` 63 | 64 | Once you got Wilfred installed, run `wilfred setup` to set a path for Wilfred to use to store server files. 65 | 66 | ![Creating a server in Wilfred](https://raw.githubusercontent.com/wilfred-dev/wilfred/master/docs/quickstart.gif) 67 | 68 | To create your first server, use `wilfred create`. Most values have a default value, where you can just press return to use them. 69 | 70 | Wilfred will ask you which "image" to use. An image is a set of configuration files that defines a specific game within Wilfred. These images are not to be confused with Docker images, Wilfred images sort of wrap around the Docker images. A couple of games are already built into Wilfred, but you can also create your own. 71 | 72 | Then, Wilfred will ask you to set any environment variables (if available for that image). The environment variables differ from game to game and most of them have a default value. 73 | 74 | Once the server is created, you can use `wilfred servers` to list available servers. To start it, use `wilfred start `. To attach to the server console, you can use `wilfred console `. If you want to start the server and attach to the server console in a single command, you can use `wilfred start --console` (it will start the server and then immediately attach to the server console). 75 | 76 | ## Helping 77 | 78 | The best places to contribute are through the issue tracker and the official Discord server. For code contributions, pull requests and patches are always welcome! 79 | 80 | ## Contributors ✨ 81 | 82 | Created, written, and maintained by [Vilhelm Prytz](https://github.com/vilhelmprytz). 83 | 84 | Copyright (C) 2020-2022, Vilhelm Prytz, 85 | -------------------------------------------------------------------------------- /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 = source 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/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=source 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.http://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/quickstart.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilfred-dev/wilfred/d8253df731b92c10193e1e067389efd920616056/docs/quickstart.gif -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx==7.2.6 2 | sphinx-click==5.1.0 3 | 4 | # From Wilfred dependencies (used for autodoc) 5 | docker==7.0.7 6 | click==8.1.7 7 | colorama==0.4.6 8 | appdirs==1.4.4 9 | requests==2.31.0 10 | tabulate==0.9.0 11 | sqlalchemy==2.0.25 12 | pyyaml==6.0.1 13 | halo==0.0.31 14 | -------------------------------------------------------------------------------- /docs/source/_static/.githold: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilfred-dev/wilfred/d8253df731b92c10193e1e067389efd920616056/docs/source/_static/.githold -------------------------------------------------------------------------------- /docs/source/_templates/.githold: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilfred-dev/wilfred/d8253df731b92c10193e1e067389efd920616056/docs/source/_templates/.githold -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | Wilfred API 2 | =========== 3 | 4 | .. py:module:: wilfred.api.servers 5 | 6 | .. warning:: 7 | 8 | The API is currently not stable and should not be used. 9 | 10 | ``wilfred.api.servers`` 11 | ----------------------- 12 | 13 | .. py:class:: Servers 14 | 15 | .. automethod:: __init__ 16 | .. automethod:: all 17 | .. automethod:: remove 18 | .. automethod:: console 19 | .. automethod:: install 20 | .. automethod:: kill 21 | .. automethod:: command 22 | .. automethod:: sync 23 | .. automethod:: rename 24 | 25 | .. py:module:: wilfred.api.images 26 | 27 | ``wilfred.api.images`` 28 | ---------------------- 29 | 30 | .. py:class:: Images 31 | 32 | .. automethod:: download 33 | .. automethod:: data_strip_non_ui 34 | .. automethod:: get_image 35 | .. automethod:: read_images -------------------------------------------------------------------------------- /docs/source/commands.rst: -------------------------------------------------------------------------------- 1 | Commands and Syntax 2 | =================================== 3 | 4 | Commands and syntax available for Wilfred. Documentation is automatically generated from source code using `sphinx-click `__ 5 | 6 | .. click:: wilfred.wilfred:cli 7 | :prog: wilfred 8 | :nested: full 9 | -------------------------------------------------------------------------------- /docs/source/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 | os.environ["WILFRED_SKIP_DOCKER"] = "true" 18 | 19 | 20 | # -- Project information ----------------------------------------------------- 21 | 22 | master_doc = "index" 23 | project = "Wilfred" 24 | copyright = "2020-2022, Vilhelm Prytz" 25 | author = "Vilhelm Prytz" 26 | 27 | # The full version, including alpha/beta/rc tags 28 | release = "v0.10.1" 29 | 30 | 31 | # -- General configuration --------------------------------------------------- 32 | 33 | # Add any Sphinx extension module names here, as strings. They can be 34 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 35 | # ones. 36 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon", "sphinx_click"] 37 | 38 | # Add any paths that contain templates here, relative to this directory. 39 | templates_path = ["_templates"] 40 | 41 | # List of patterns, relative to source directory, that match files and 42 | # directories to ignore when looking for source files. 43 | # This pattern also affects html_static_path and html_extra_path. 44 | exclude_patterns = [] 45 | 46 | 47 | # -- Options for HTML output ------------------------------------------------- 48 | 49 | # The theme to use for HTML and HTML Help pages. See the documentation for 50 | # a list of builtin themes. 51 | # 52 | html_theme = "alabaster" 53 | 54 | # Add any paths that contain custom static files (such as style sheets) here, 55 | # relative to this directory. They are copied after the builtin static files, 56 | # so a file named "default.css" will overwrite the builtin "default.css". 57 | html_static_path = ["_static"] 58 | -------------------------------------------------------------------------------- /docs/source/development.rst: -------------------------------------------------------------------------------- 1 | Development & Ops 2 | ================= 3 | 4 | .. note:: 5 | This is mostly used as a reference for the core project team. 6 | 7 | 8 | Working with Wilfred locally 9 | ---------------------------- 10 | 11 | Make sure you have `pipenv `__ installed and that you have cloned the repository to your computer (perhaps created a fork). 12 | 13 | At the root of the project, enter the `shell` for the development environment. 14 | 15 | .. code-block:: bash 16 | 17 | pipenv shell 18 | 19 | This will create the environment. Then you need to install the dependencies. 20 | 21 | .. code-block:: bash 22 | 23 | pipenv install 24 | pipenv install --dev 25 | 26 | Instead of using `wilfred ` to run Wilfred, you can use the built-in run script. 27 | 28 | .. code-block:: bash 29 | 30 | ./run.py 31 | 32 | In this way, you can develop and see your changes instantly. 33 | 34 | Publishing a release 35 | -------------------- 36 | 37 | Update the version in `wilfred/version.py` and `docs/source/conf.py` accordingly. 38 | 39 | Set the version name and release status of this release in `CHANGELOG.md` to `released on YYYY-MM-DD`. 40 | 41 | Commit the changes. 42 | 43 | Tag the release on GitHub. Include all the changes from `CHANGELOG.md` in the release notes (you can use a previous release as reference). 44 | 45 | GitHub Actions should automatically build and release the `PyPI package `__. 46 | 47 | Revert the changes in `wilfred/version.py` and commit the changes (should therefore not be included in the release). 48 | 49 | Windows 50 | ------- 51 | 52 | You have to install `pypiwin32` to develop on Windows. 53 | 54 | .. code-block:: bash 55 | 56 | pipenv shell 57 | pip install pypiwin32 58 | 59 | This is not ideal, but due to a bug in Pipenv we cannot put the `pypiwin32` package as a platform specific dependency in the `Pipfile`. 60 | 61 | Versioning convention 62 | --------------------- 63 | 64 | Wilfred releases should use the `semantic versioning convention `__ (i.e. `MAJOR.MINOR.PATCH`). 65 | -------------------------------------------------------------------------------- /docs/source/images.rst: -------------------------------------------------------------------------------- 1 | Images 2 | ====== 3 | 4 | Not to be confused with actual Docker images, Wilfred images are configuration files used by Wilfred to create game servers. It tells Wilfred which Docker container to run the server in, with which command the server is started with and how to initially install libraries etc. 5 | 6 | Wilfred images are formatted in JSON. 7 | 8 | This is the configuration file for Vanilla Minecraft. 9 | 10 | .. code-block:: json 11 | 12 | { 13 | "meta": { 14 | "api_version": 2 15 | }, 16 | "uid": "minecraft-vanilla", 17 | "name": "Vanilla Minecraft", 18 | "author": "info@wilfredproject.org", 19 | "docker_image": "wilfreddev/java:latest", 20 | "command": "java -Xms128M -Xmx{{SERVER_MEMORY}}M -jar server.jar", 21 | "default_port": "25565", 22 | "user": "container", 23 | "stop_command": "stop", 24 | "default_image": true, 25 | "config": { 26 | "files": [ 27 | { 28 | "filename": "server.properties", 29 | "parser": "properties", 30 | "environment": [ 31 | { 32 | "config_variable": "server-port", 33 | "environment_variable": "SERVER_PORT", 34 | "value_format": null 35 | } 36 | ], 37 | "action": { 38 | "difficulty": "difficulty {}", 39 | "white-list": "whitelist {}" 40 | } 41 | } 42 | ] 43 | }, 44 | "installation": { 45 | "docker_image": "wilfreddev/alpine:latest", 46 | "shell": "/bin/ash", 47 | "script": [ 48 | "apk --no-cache --update add curl jq", 49 | "if [ \"$MINECRAFT_VERSION\" == \"latest\" ]; then", 50 | " VERSION=`curl https://launchermeta.mojang.com/mc/game/version_manifest.json | jq -r '.latest.release'`", 51 | "else", 52 | " VERSION=\"$MINECRAFT_VERSION\"", 53 | "fi", 54 | "MANIFEST_URL=$(curl -sSL https://launchermeta.mojang.com/mc/game/version_manifest.json | jq --arg VERSION $VERSION -r '.versions | .[] | select(.id== $VERSION )|.url')", 55 | "DOWNLOAD_URL=$(curl ${MANIFEST_URL} | jq .downloads.server | jq -r '. | .url')", 56 | "curl -o server.jar $DOWNLOAD_URL", 57 | "if [ \"$EULA_ACCEPTANCE\" == \"true\" ]; then", 58 | " echo \"eula=true\" > eula.txt", 59 | "fi", 60 | "curl -o server.properties https://raw.githubusercontent.com/wilfred-dev/images/master/configs/minecraft/standard/server.properties", 61 | "sed -i \"s/{{SERVER_PORT}}/$SERVER_PORT/g\" server.properties", 62 | "chown -R container:container /server" 63 | ] 64 | }, 65 | "variables": [ 66 | { 67 | "prompt": "Which Minecraft version to use during install?", 68 | "variable": "MINECRAFT_VERSION", 69 | "install_only": true, 70 | "default": "latest", 71 | "hidden": false 72 | }, 73 | { 74 | "prompt": "Do you agree to the Minecraft EULA (https://account.mojang.com/documents/minecraft_eula)?", 75 | "variable": "EULA_ACCEPTANCE", 76 | "install_only": true, 77 | "default": "true", 78 | "hidden": false 79 | } 80 | ] 81 | } 82 | 83 | Image syntax 84 | ------------ 85 | 86 | **All** variables are required for image configurations. 87 | 88 | - `meta` 89 | - `api_version` - Version of configuration. 90 | - `uid` - A unique ID for this config, do not uses spaces. **Must be lowercase.** 91 | - `name` - Name of image to be displayed to user. 92 | - `author` - Email of author. 93 | - `docker_image` - Docker image to run server in. 94 | - `command` - Command to be executed on start. 95 | - `default_port` - Default port to run server on (will be suggested by Wilfred). 96 | - `user` - User to run command as, leave empty for default `root`. 97 | - `stop_command` - Command to send to STDIN in order to stop the container. 98 | - `default_image` - Indicates to Wilfred that the image is an official image from the Wilfred project. 99 | - `config` - Configuration files and how Wilfred should parse them, used within the `wilfred config` command (such as `server.properties` for Minecraft or `config.yml` for BungeeCord). 100 | - `files` - List of files to parse. 101 | - `filename` - The filename to read and write to. 102 | - `parser` - What parser Wilfred should use (file-type). Currently, only `properties`, `yaml` and `json` are supported parsers. 103 | - `environment` - List of environment variables to link to specific config settings. 104 | - `config_variable` - The setting (variable name) as it's named within the configuration file (e.g. `server-port` as that's the name of the setting in `server.properties`). 105 | - `environment_variable` - A valid environment variable to link with the specified setting. Apart from the variables specified in the image config, `SERVER_PORT` and `SERVER_MEMORY` are valid values. 106 | - `value_format` - Can be used to append a prefix to the value. Specifying `null` just replaces the value of the setting with the value of the environment variable, without prefixes and suffixes. Otherwise, use `{}` to indicate where the actual value should be set (e.g. `0.0.0.0:{}` is valid syntax). 107 | - `action` - Dictionary, sends a command to the STDIN of the container when the setting updates. 108 | - `{SETTING_NAME}` - The value should contain the command that should be sent to the container when the specified setting changes. Use `{}` to indicate where the actual value should be set (e.g. `whitelist {}` would send `whitelist on` if the user runs `wilfred config my-server white-list "on"`). 109 | - `installation` 110 | - `docker_image` - Docker image to use during installation. 111 | - `shell` - Shell to use (usually `/bin/ash` for Alpine or `/bin/bash` for Ubuntu/Debian). 112 | - `script` - List (array) of commands to execute during installation. 113 | - `variables` - List of environment variables. 114 | - `prompt` - Prompt during server creation/modification. 115 | - `variable` - Name of environment variable. 116 | - `install_only` - boolean, variable will only be accessible during installation if `true`. 117 | - `default` - Default value for prompt, use boolean `true` in order to make variable required but not set a default value and use `""` to make it optional, without default value. 118 | - `hidden` - Boolean, decides whether this value should be hidden from the user (i.e. static variables). 119 | 120 | Environment Variables 121 | --------------------- 122 | 123 | Environment variables can be defined in the image configuration. The user will be prompted to enter values for these variables when creating a new server. 124 | 125 | The variables are accessible from the installation script and the startup command. But referring to them is slightly different. 126 | 127 | To access an environment variable named `MINECRAFT_VERSION` from the installation script, one can use `$MINECRAFT_VERSION` (just as you'd expect it to work). 128 | 129 | And to access an environment variable from the startup command, refer to it as `{{image.env.KEY}}` (e.g. `{{image.env.MINECRAFT_VERSION}}` in this case). 130 | 131 | Default Variables 132 | ----------------- 133 | 134 | The variable `SERVER_MEMORY` and `SERVER_PORT` (so `{{SERVER_MEMORY}}` from the startup command and `$SERVER_MEMORY` from the installation script) are always defined and can be accessed in both the installation script and the startup command. 135 | 136 | Default Images 137 | -------------- 138 | 139 | You can find the default images `here `__. 140 | 141 | 142 | Creating a custom image 143 | ----------------------- 144 | 145 | When creating a custom image, make sure to **not** put it in the same folder as the default ones. The `default` folder is deleted when Wilfred updates the images from GitHub. 146 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Wilfred documentation master file, created by 2 | sphinx-quickstart on Sat May 2 21:42:47 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Wilfred 7 | =================================== 8 | 9 | Wilfred is a command-line interface for running and managing game servers locally. It uses Docker to run game servers in containers, which means they are completely separated. Wilfred can run any game that can run in Docker. 10 | 11 | 12 | .. warning:: 13 | 14 | The documentation has just been migrated to Sphinx. It's currently being updated and things may change at any time. We're also preparing for the Wilfred API to be documented here. 15 | 16 | Installation 17 | ------------ 18 | 19 | Prerequisites 20 | ^^^^^^^^^^^^^ 21 | 22 | Wilfred currently supports Linux (should be everywhere where Python and Docker is supported), MacOS and now Windows. 23 | 24 | Before installing, make sure you have Docker already installed and configured. You can install it from the links below. 25 | 26 | * `Docker on Linux `__ 27 | * `Docker Desktop on MacOS `__ 28 | * `Docker Desktop on Windows `__ 29 | 30 | You can verify that Docker is installed using ``docker --version`` or ``docker info`` (``info`` shows more information). 31 | 32 | .. code-block:: bash 33 | 34 | user@host:~$ docker --version 35 | Docker version XX.XX.X, build XXXXXXXXXX 36 | 37 | If you're having trouble accessing the Docker CLI as a non-root user, you can `add yourself to the Docker group `__. 38 | 39 | Homebrew 40 | ^^^^^^^^ 41 | 42 | The recommended way of installing Wilfred is via `Homebrew `__ (works on macOS and Linux). Make sure you have it installed on your system. Once Homebrew is installed, use the two commands below to install Wilfred via the offical tap. 43 | 44 | .. code-block:: bash 45 | 46 | brew tap wilfred-dev/wilfred 47 | brew install wilfred 48 | 49 | Want the bleeding edge? You can install the latest commit using ``--HEAD`` (bugs are to be expected, don't use in production environments!). 50 | 51 | .. code-block:: bash 52 | 53 | brew tap wilfred-dev/wilfred 54 | brew install --HEAD wilfred 55 | 56 | Pip 57 | ^^^ 58 | 59 | Wilfred can be installed using ``pip``. You need to use **Python 3.8** or newer to run Wilfred (if you also have ``pip2`` on your system, run with ``pip3``). 60 | 61 | .. code-block:: bash 62 | 63 | pip install wilfred --upgrade 64 | 65 | You can install using a specific python version, e.g. `3.8`. 66 | 67 | .. code-block:: bash 68 | 69 | python3.8 -m pip install wilfred --upgrade 70 | 71 | Basic Configuration 72 | ------------------- 73 | 74 | Once you got Wilfred installed, you can run the setup command to create the basic configuration. 75 | 76 | .. code-block:: bash 77 | 78 | wilfred setup 79 | 80 | Currently, the only config option required is the path for soring data. 81 | 82 | .. code-block:: text 83 | 84 | Path for storing server data [/home/{{ username }}/wilfred-data/servers]: 85 | 86 | By default, this is ``/home/{{ username }}/wilfred-data/servers``. You can use any path as long as you have permissions to access it as the current user. 87 | 88 | To create a new server, you can run ``wilfred create`` and follow the instructions. 89 | 90 | Upgrading 91 | --------- 92 | 93 | To check if you're running the latest version, run ``wilfred --version``. If a new version is available, Wilfred will print a message. 94 | 95 | If you installed Wilfred using ``pip``, then you can upgrade by running the same command as for installing (note the ``--upgrade`` flag). 96 | 97 | .. code-block:: bash 98 | 99 | pip install wilfred --upgrade 100 | 101 | If you installed Wilfred using ``brew``, you can use Homebrew to upgrade Wilfred as you would do with any formula. 102 | 103 | .. code-block:: bash 104 | 105 | brew update 106 | brew upgrade 107 | 108 | .. toctree:: 109 | :maxdepth: 3 110 | :hidden: 111 | 112 | commands 113 | images 114 | api 115 | development 116 | 117 | Indices and tables 118 | ================== 119 | 120 | * :ref:`genindex` 121 | * :ref:`modindex` 122 | * :ref:`search` 123 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ################################################################# 4 | # # 5 | # Wilfred # 6 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 7 | # # 8 | # Licensed under the terms of the MIT license, see LICENSE. # 9 | # https://github.com/wilfred-dev/wilfred # 10 | # # 11 | ################################################################# 12 | 13 | from wilfred.wilfred import main 14 | 15 | if __name__ == "__main__": 16 | main() 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | python-tag = py37 3 | 4 | [options] 5 | setup_requires = setuptools_scm==6.4.2 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | import sys 12 | from setuptools import setup, find_packages 13 | from wilfred.version import version 14 | 15 | assert sys.version_info >= (3, 7, 0), "Wilfred requires Python 3.8+" 16 | 17 | with open("README.md", "r", encoding="utf-8") as f: 18 | long_description = f.read() 19 | 20 | setup( 21 | name="wilfred", 22 | version=version, 23 | author="Vilhelm Prytz", 24 | author_email="vlhelm@prytznet.se", 25 | description="A CLI for managing game servers using Docker.", 26 | long_description=long_description, 27 | long_description_content_type="text/markdown", 28 | url="https://wilfredproject.org", 29 | project_urls={ 30 | "Documentation": "https://docs.wilfredproject.org", 31 | "Code": "https://github.com/wilfred-dev/wilfred", 32 | "Issue tracker": "https://github.com/wilfred-dev/wilfred/issues", 33 | }, 34 | packages=find_packages(), 35 | license="MIT", 36 | classifiers=[ 37 | "Programming Language :: Python", 38 | "Programming Language :: Python :: 3.8", 39 | "Programming Language :: Python :: 3.9", 40 | "Programming Language :: Python :: 3.10", 41 | "Programming Language :: Python :: 3.11", 42 | "Programming Language :: Python :: 3.12", 43 | "Programming Language :: Python :: 3 :: Only", 44 | "Environment :: Console", 45 | "License :: OSI Approved :: MIT License", 46 | "Operating System :: MacOS :: MacOS X", 47 | "Operating System :: POSIX :: Linux", 48 | "Operating System :: Unix", 49 | "Operating System :: Microsoft :: Windows", 50 | "Natural Language :: English", 51 | ], 52 | python_requires=">=3.8", 53 | install_requires=[ 54 | "docker", 55 | "click", 56 | "colorama", 57 | "appdirs", 58 | "requests", 59 | "tabulate", 60 | "halo", 61 | "sqlalchemy", 62 | "pyyaml", 63 | "pypiwin32 ; platform_system=='Windows'", 64 | ], 65 | entry_points={"console_scripts": ["wilfred=wilfred.wilfred:main"]}, 66 | ) 67 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | from click.testing import CliRunner 12 | from wilfred.wilfred import cli 13 | 14 | 15 | def test_basic(): 16 | runner = CliRunner() 17 | result = runner.invoke(cli) 18 | 19 | assert result.exit_code == 0 20 | 21 | 22 | def test_version(): 23 | runner = CliRunner() 24 | result = runner.invoke(cli, "--version") 25 | 26 | assert result.exit_code == 0 27 | 28 | 29 | def test_path(): 30 | runner = CliRunner() 31 | result = runner.invoke(cli, "--path") 32 | 33 | assert result.exit_code == 0 34 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | from pathlib import Path 12 | 13 | from wilfred.api.images import Images 14 | from wilfred.api.servers import Servers 15 | from wilfred.docker_conn import docker_client 16 | from wilfred.api.server_config import ServerConfig 17 | from wilfred.database import Server, EnvironmentVariable, session 18 | from wilfred.api.config_parser import Config 19 | 20 | config = Config() 21 | config.write(f"{str(Path.home())}/wilfred-data/servers") 22 | config.read() 23 | 24 | images = Images() 25 | 26 | if not images.check_if_present(): 27 | images.download() 28 | 29 | images.read_images() 30 | 31 | servers = Servers(docker_client(), config.configuration, images) 32 | 33 | 34 | def test_create_server(): 35 | # create 36 | server = Server( 37 | id="test", 38 | name="test", 39 | image_uid="minecraft-paper", 40 | memory="1024", 41 | port="25565", 42 | custom_startup=None, 43 | status="installing", 44 | ) 45 | session.add(server) 46 | session.commit() 47 | 48 | minecraft_version = EnvironmentVariable( 49 | server_id=server.id, variable="MINECRAFT_VERSION", value="latest" 50 | ) 51 | 52 | eula_acceptance = EnvironmentVariable( 53 | server_id=server.id, variable="EULA_ACCEPTANCE", value="true" 54 | ) 55 | 56 | session.add(minecraft_version) 57 | session.add(eula_acceptance) 58 | session.commit() 59 | 60 | servers.install(server, skip_wait=False) 61 | servers.sync() 62 | 63 | 64 | def test_start_server(): 65 | server = session.query(Server).filter_by(id="test").first() 66 | 67 | if server.status == "installing": 68 | raise Exception("server is installing") 69 | 70 | servers.set_status(server, "running") 71 | servers.sync() 72 | 73 | 74 | def test_pseudo_config_write(): 75 | server = session.query(Server).filter_by(id="test").first() 76 | 77 | image = images.get_image(server.image_uid) 78 | 79 | Path(f"{str(Path.home())}/temp/test_test").mkdir(parents=True, exist_ok=True) 80 | 81 | with open(f"{str(Path.home())}/temp/test_test/server.properties", "w") as f: 82 | f.write("\n".join(("query.port=25564", "server-port=25564"))) 83 | 84 | ServerConfig( 85 | {"data_path": f"{str(Path.home())}/temp"}, servers, server, image 86 | ).write_environment_variables() 87 | 88 | 89 | def test_delete_server(): 90 | server = session.query(Server).filter_by(id="test").first() 91 | servers.remove(server) 92 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 3.8.0 3 | envlist=py3.8,py3.9,py3.10,py3.11,py3.12 4 | 5 | [testenv] 6 | deps = 7 | pytest 8 | #coveralls 9 | #pytest-cov 10 | commands = 11 | pytest 12 | #pytest --cov=wilfred/ 13 | #coveralls 14 | 15 | [testenv:style] 16 | deps = 17 | flake8 18 | black 19 | basepython = python3.8 20 | commands = 21 | flake8 . 22 | flake8 . --exit-zero --max-complexity 10 23 | black --check . 24 | -------------------------------------------------------------------------------- /wilfred/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilfred-dev/wilfred/d8253df731b92c10193e1e067389efd920616056/wilfred/__init__.py -------------------------------------------------------------------------------- /wilfred/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilfred-dev/wilfred/d8253df731b92c10193e1e067389efd920616056/wilfred/api/__init__.py -------------------------------------------------------------------------------- /wilfred/api/config_parser.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | import json 12 | 13 | from appdirs import user_config_dir 14 | from os.path import isfile, isdir 15 | from pathlib import Path 16 | 17 | from wilfred.errors import WilfredException, ParseError, WriteError 18 | 19 | API_VERSION = 1 20 | 21 | 22 | class NoConfiguration(WilfredException): 23 | """Configuration files does not exist""" 24 | 25 | 26 | class ConfigurationAPIMismatch(WilfredException): 27 | """API level of config does not match with API level present in config""" 28 | 29 | 30 | class Config(object): 31 | def __init__(self): 32 | self.data_dir = f"{user_config_dir()}/wilfred" 33 | self.config_path = f"{self.data_dir}/config.json" 34 | self.configuration = None 35 | 36 | if not isdir(self.data_dir): 37 | Path(self.data_dir).mkdir(parents=True, exist_ok=True) 38 | 39 | def read(self): 40 | if not isfile(self.config_path): 41 | raise NoConfiguration 42 | 43 | with open(self.config_path) as f: 44 | try: 45 | self.configuration = json.loads(f.read()) 46 | except Exception as e: 47 | raise ParseError(e) 48 | 49 | if self.configuration["meta"]["version"] != API_VERSION: 50 | raise ConfigurationAPIMismatch( 51 | f"Wilfred has version {API_VERSION}, file has version {self.configuration['meta']['version']}" 52 | ) 53 | 54 | def write(self, data_path): 55 | try: 56 | Path(data_path).mkdir(parents=True, exist_ok=True) 57 | except Exception as e: 58 | raise WriteError(e) 59 | 60 | with open(self.config_path, "w") as f: 61 | f.write( 62 | json.dumps( 63 | {"meta": {"version": API_VERSION}, "data_path": data_path}, indent=4 64 | ) 65 | ) 66 | -------------------------------------------------------------------------------- /wilfred/api/images.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | import json 12 | 13 | from appdirs import user_config_dir 14 | from pathlib import Path 15 | from os.path import isdir, join, isfile 16 | from os import walk, remove 17 | from requests import get 18 | from zipfile import ZipFile 19 | from shutil import move, rmtree 20 | from copy import deepcopy 21 | from datetime import datetime, timedelta 22 | 23 | from wilfred.errors import WilfredException, ReadError, ParseError 24 | from wilfred.version import version 25 | 26 | API_VERSION = 2 27 | 28 | 29 | class ImagesNotPresent(WilfredException): 30 | """Default images not present on host""" 31 | 32 | 33 | class ImagesNotRead(WilfredException): 34 | """Images are not read yet""" 35 | 36 | 37 | class ImageAPIMismatch(WilfredException): 38 | """API level of image and API level of Wilfred mismatch""" 39 | 40 | 41 | class ImagesOutdated(WilfredException): 42 | """Wilfred images are outdated and require refresh""" 43 | 44 | 45 | class Images(object): 46 | """Manage Wilfred images""" 47 | 48 | def __init__(self): 49 | self.config_dir = f"{user_config_dir()}/wilfred" 50 | self.image_dir = f"{self.config_dir}/images" 51 | self.images = [] 52 | 53 | if not isdir(self.image_dir): 54 | Path(self.image_dir).mkdir(parents=True, exist_ok=True) 55 | 56 | def download(self, branch="master", repo="wilfred-dev/images"): 57 | """ 58 | Downloads default Wilfred Images from GitHub 59 | """ 60 | 61 | rmtree(f"{self.image_dir}/default", ignore_errors=True) 62 | 63 | with open(f"{self.config_dir}/img.zip", "wb") as f: 64 | response = get( 65 | f"https://github.com/{repo}/archive/{branch}.zip", 66 | stream=True, 67 | ) 68 | f.write(response.content) 69 | 70 | with ZipFile(f"{self.config_dir}/img.zip", "r") as obj: 71 | obj.extractall(f"{self.config_dir}/temp_images") 72 | 73 | move( 74 | f"{self.config_dir}/temp_images/images-{branch}/images", 75 | f"{self.image_dir}/default", 76 | ) 77 | 78 | remove(f"{self.config_dir}/img.zip") 79 | rmtree(f"{self.config_dir}/temp_images") 80 | 81 | # write to cache info that images have been updated 82 | data = {"time": str(datetime.now()), "version": version} 83 | 84 | with open(f"{self.config_dir}/image_cache.json", "w") as f: 85 | json.dump(data, f) 86 | 87 | def data_strip_non_ui(self): 88 | """ 89 | Returns a list of all images with only the variables important to the user shown 90 | 91 | Returns: 92 | Returns ``list`` of images. 93 | 94 | Raises: 95 | :py:class:`wilfred.api.images.ImagesNotRead` 96 | """ 97 | 98 | if not self._check_if_read(): 99 | raise ImagesNotRead("Read images before trying to get images") 100 | 101 | _images = deepcopy(self.images) 102 | 103 | for d in _images: 104 | for key in ( 105 | "meta", 106 | "installation", 107 | "docker_image", 108 | "command", 109 | "stop_command", 110 | "variables", 111 | "user", 112 | "config", 113 | ): 114 | try: 115 | del d[key] 116 | except Exception: 117 | pass 118 | 119 | return _images 120 | 121 | def get_image(self, uid: str): 122 | """ 123 | Retrieves image configuration for specific image 124 | 125 | Returns: 126 | Returns ``dict`` of image configuration. 127 | 128 | Raises: 129 | :py:class:`wilfred.api.images.ImagesNotRead` 130 | """ 131 | 132 | if not self._check_if_read(): 133 | raise ImagesNotRead("Read images before trying to get image") 134 | 135 | return next(filter(lambda img: img["uid"] == uid, self.images), None) 136 | 137 | def read_images(self): 138 | """ 139 | Reads and parses all images on system 140 | 141 | Returns: 142 | Returns ``True`` if success. 143 | 144 | Raises: 145 | :py:class:`wilfred.api.images.ImagesNotPresent` 146 | :py:class:`wilfred.api.errors.ReadError` 147 | :py:class:`wilfred.api.images.ImageAPIMismatch` 148 | """ 149 | 150 | if not self.check_if_present(): 151 | raise ImagesNotPresent("Default images not present") 152 | 153 | if self.is_outdated(): 154 | raise ImagesOutdated("Images are outdated, refresh required") 155 | 156 | self.image_fetch_date = "N/A" 157 | self.image_fetch_version = "N/A" 158 | self.image_time_to_refresh = "N/A" 159 | 160 | try: 161 | with open(f"{self.config_dir}/image_cache.json") as f: 162 | data = json.load(f) 163 | 164 | self.image_fetch_date = datetime.strptime( 165 | data["time"], "%Y-%m-%d %H:%M:%S.%f" 166 | ) 167 | self.image_fetch_version = data["version"] 168 | self.image_time_to_refresh = timedelta(days=7) - ( 169 | datetime.now() - self.image_fetch_date 170 | ) 171 | except Exception: 172 | pass 173 | 174 | self.images = [] 175 | 176 | for root, dirs, files in walk(self.image_dir): 177 | for file in files: 178 | if file.endswith(".json"): 179 | with open(join(root, file)) as f: 180 | try: 181 | _image = json.loads(f.read()) 182 | except Exception as e: 183 | raise ReadError(f"{file} failed with exception {str(e)}") 184 | 185 | try: 186 | if _image["meta"]["api_version"] != API_VERSION: 187 | raise ImageAPIMismatch( 188 | " ".join( 189 | ( 190 | f"{file} API level {_image['meta']['api_version']},", 191 | f"Wilfred API level {API_VERSION}", 192 | ) 193 | ) 194 | ) 195 | except ImageAPIMismatch as e: 196 | raise ImageAPIMismatch(str(e)) 197 | except Exception as e: 198 | raise ReadError(f"{file} with err {str(e)}") 199 | 200 | self._verify(_image, file) 201 | self.images.append(_image) 202 | 203 | return True 204 | 205 | def check_if_present(self): 206 | """Checks if default images are present""" 207 | 208 | if not isdir(f"{self.image_dir}/default"): 209 | return False 210 | 211 | return True 212 | 213 | def is_outdated(self): 214 | """Checks if default images are outdated""" 215 | 216 | if not isfile(f"{self.config_dir}/image_cache.json"): 217 | return True 218 | 219 | try: 220 | with open(f"{self.config_dir}/image_cache.json") as f: 221 | data = json.load(f) 222 | 223 | if ( 224 | datetime.now() 225 | - datetime.strptime(data["time"], "%Y-%m-%d %H:%M:%S.%f") 226 | ) > timedelta(days=7): 227 | return True 228 | 229 | if data["version"] != version: 230 | return True 231 | 232 | return False 233 | except Exception: 234 | return True 235 | 236 | def _verify(self, image: dict, file: str): 237 | def _exception(key): 238 | raise ParseError(f"image {file} is missing key {str(key)}") 239 | 240 | for key in ( 241 | "meta", 242 | "uid", 243 | "name", 244 | "author", 245 | "docker_image", 246 | "command", 247 | "default_port", 248 | "user", 249 | "stop_command", 250 | "default_image", 251 | "variables", 252 | "installation", 253 | "config", 254 | ): 255 | try: 256 | image[key] 257 | except Exception: 258 | return _exception(key) 259 | 260 | if image["uid"] != image["uid"].lower(): 261 | raise ParseError(f"image {file} uid must be lowercase") 262 | 263 | for key in ["api_version"]: 264 | try: 265 | image["meta"][key] 266 | except Exception: 267 | return _exception(key) 268 | 269 | for key in ("docker_image", "shell", "script"): 270 | try: 271 | image["installation"][key] 272 | except Exception: 273 | return _exception(key) 274 | 275 | try: 276 | image["config"]["files"] 277 | except Exception: 278 | return _exception(key) 279 | 280 | if len(image["config"]["files"]) > 0: 281 | for i in range(len(image["config"]["files"])): 282 | for key in ("filename", "parser", "environment", "action"): 283 | try: 284 | image["config"]["files"][i][key] 285 | except Exception: 286 | return _exception(key) 287 | 288 | # check for valid syntax in environment variables 289 | for x in range(len(image["config"]["files"][i]["environment"])): 290 | for key in ( 291 | "config_variable", 292 | "environment_variable", 293 | "value_format", 294 | ): 295 | try: 296 | image["config"]["files"][i]["environment"][x][key] 297 | except Exception: 298 | return _exception( 299 | f"{image['config']['files'][i]['filename']} environment key {key}" 300 | ) 301 | 302 | # should also check for valid syntax in environment variable linking 303 | 304 | if len(image["variables"]) > 0: 305 | for i in range(len(image["variables"])): 306 | for key in ("prompt", "variable", "install_only", "default", "hidden"): 307 | try: 308 | image["variables"][i][key] 309 | except Exception: 310 | return _exception(key) 311 | 312 | return True 313 | 314 | def _check_if_read(self): 315 | if len(self.images) == 0: 316 | return False 317 | 318 | return True 319 | -------------------------------------------------------------------------------- /wilfred/api/parser/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wilfred-dev/wilfred/d8253df731b92c10193e1e067389efd920616056/wilfred/api/parser/__init__.py -------------------------------------------------------------------------------- /wilfred/api/parser/json.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | import json 12 | 13 | 14 | def json_read(path): 15 | with open(path) as f: 16 | _raw = json.loads(f.read()) 17 | 18 | _reformatted = {} 19 | 20 | def _iterate_dict(data, _name): 21 | """ 22 | iterates a dictionary and continues to iterate the value of the dict if the 23 | type is dictionary, list or tuple (recursion). Adds the dictionary key and value 24 | to the _reformatted dict if it's a string, integer or boolean 25 | """ 26 | 27 | _def = _name 28 | for k, v in data.items(): 29 | _name = f"{_def}/{k}" 30 | if type(v) in [dict]: 31 | _iterate_dict(v, _name) 32 | if type(v) in [list, tuple]: 33 | _iterate_list(v, _name) 34 | 35 | if type(v) in [str, int, bool]: 36 | _reformatted[f"{_def}/{k}"] = v 37 | 38 | def _iterate_list(data, _name): 39 | """ 40 | iterates a list and continues to iterate the values of the list if the 41 | type is dictionary, list or tuple (recursion). Adds the list value 42 | to the _reformatted dict if it's a string, integer or boolean 43 | """ 44 | 45 | _def = _name 46 | 47 | i = 0 48 | for x in data: 49 | _name = f"{_def}/{i}" 50 | if type(x) in [dict]: 51 | _iterate_dict(x, _name) 52 | if type(x) in [list, tuple]: 53 | _iterate_list(x, _name) 54 | 55 | if type(x) in [str, int, bool]: 56 | _reformatted[f"{_def}/{i}"] = x 57 | 58 | i = i + 1 59 | 60 | for k, v in _raw.items(): 61 | _name = k 62 | 63 | if type(v) in [dict]: 64 | _iterate_dict(v, _name) 65 | if type(v) in [list, tuple]: 66 | _iterate_list(v, _name) 67 | 68 | if type(v) in [str, int, bool]: 69 | _reformatted[k] = v 70 | 71 | return _reformatted 72 | 73 | 74 | def json_write(path, key, value): 75 | raise Exception("Modifying JSON variables is currently not supported") 76 | 77 | # return True 78 | -------------------------------------------------------------------------------- /wilfred/api/parser/properties.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | from configparser import RawConfigParser 12 | 13 | 14 | def _config_parser(path): 15 | """appends a dummy section to the top of the raw file so that the built-in 16 | python function, ConfigParser, can read and parse the file""" 17 | 18 | with open(path) as f: 19 | file_content = "[dummy_section]\n" + f.read() 20 | 21 | config_parser = RawConfigParser() 22 | config_parser.read_string(file_content) 23 | 24 | return config_parser 25 | 26 | 27 | def properties_read(path): 28 | config_parser = _config_parser(path) 29 | 30 | settings = {} 31 | 32 | for item in config_parser.items("dummy_section"): 33 | settings[item[0]] = item[1] 34 | 35 | return settings 36 | 37 | 38 | def properties_write(path, key, value): 39 | with open(path) as f: 40 | raw = f.read().split("\n") 41 | 42 | _write = [] 43 | 44 | for line in raw: 45 | if key in line: 46 | if line.split("=")[0] == key: 47 | _write.append(f"{key}={value}") 48 | continue 49 | 50 | _write.append(line) 51 | 52 | with open(path, "w") as f: 53 | f.write("\n".join(_write)) 54 | -------------------------------------------------------------------------------- /wilfred/api/parser/yaml.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | import yaml 12 | 13 | # from wilfred.core import is_integer 14 | 15 | 16 | def yaml_read(path): # this function should be refactored later on!!! 17 | with open(path) as f: 18 | _raw = yaml.load(f.read(), Loader=yaml.FullLoader) 19 | 20 | _reformatted = {} 21 | 22 | def _iterate_dict(data, _name): 23 | """ 24 | iterates a dictionary and continues to iterate the value of the dict if the 25 | type is dictionary, list or tuple (recursion). Adds the dictionary key and value 26 | to the _reformatted dict if it's a string, integer or boolean 27 | """ 28 | 29 | _def = _name 30 | for k, v in data.items(): 31 | _name = f"{_def}/{k}" 32 | if type(v) in [dict]: 33 | _iterate_dict(v, _name) 34 | if type(v) in [list, tuple]: 35 | _iterate_list(v, _name) 36 | 37 | if type(v) in [str, int, bool]: 38 | _reformatted[f"{_def}/{k}"] = v 39 | 40 | def _iterate_list(data, _name): 41 | """ 42 | iterates a list and continues to iterate the values of the list if the 43 | type is dictionary, list or tuple (recursion). Adds the list value 44 | to the _reformatted dict if it's a string, integer or boolean 45 | """ 46 | 47 | _def = _name 48 | 49 | i = 0 50 | for x in data: 51 | _name = f"{_def}/{i}" 52 | if type(x) in [dict]: 53 | _iterate_dict(x, _name) 54 | if type(x) in [list, tuple]: 55 | _iterate_list(x, _name) 56 | 57 | if type(x) in [str, int, bool]: 58 | _reformatted[f"{_def}/{i}"] = x 59 | 60 | i = i + 1 61 | 62 | for k, v in _raw.items(): 63 | _name = k 64 | 65 | if type(v) in [dict]: 66 | _iterate_dict(v, _name) 67 | if type(v) in [list, tuple]: 68 | _iterate_list(v, _name) 69 | 70 | if type(v) in [str, int, bool]: 71 | _reformatted[k] = v 72 | 73 | return _reformatted 74 | 75 | 76 | # def yaml_write(path, key, value): # does not work yet!! 77 | # with open(path) as f: 78 | # _raw = yaml.load(f.read(), Loader=yaml.FullLoader) 79 | 80 | # def _traverse(obj, path=None, callback=None): 81 | # if path is None: 82 | # path = [] 83 | 84 | # if type(obj) in [dict]: 85 | # value = {k: _traverse(v, path + [k], callback) for k, v in obj.items()} 86 | # elif type(obj) in [list, tuple]: 87 | # value = [_traverse(elem, path + [[]], callback) for elem in obj] 88 | # else: 89 | # value = obj 90 | 91 | # if callback is None: 92 | # return value 93 | # else: 94 | # return callback(path, value) 95 | 96 | # def _traverse_modify(obj, target_path, action): 97 | # def transformer(path, value): 98 | # if path == target_path: 99 | # return action(value) 100 | # else: 101 | # return value 102 | 103 | # return _traverse(obj, callback=transformer) 104 | 105 | # def _callback(v): 106 | # return value 107 | 108 | # _reformatted_path = [[] if is_integer(x) else x for x in key.split("/")] 109 | # _traverse_modify(_raw, _reformatted_path, _callback) 110 | 111 | # # with open(path, "w") as f: 112 | # # yaml.dump(_raw, f) 113 | 114 | 115 | def yaml_write(path, key, value): # does not work yet!! 116 | raise Exception("Modifying YAML variables is currently not supported") 117 | 118 | # with open(path) as f: 119 | # _raw = yaml.load(f.read(), Loader=yaml.FullLoader) 120 | 121 | # def _entrypoint(search, current, value, parent): 122 | # if type(value) in [dict]: 123 | # current = current+1 124 | # parent = value 125 | # _iterate_dict(search, current, value, parent) 126 | # if type(value) in [list, tuple]: 127 | # current = current+1 128 | # parent = value 129 | # _iterate_list(search, current, value, parent) 130 | # if type(value) in [str, int, bool]: 131 | # print(f"{search[current]} with value {value} in {parent}") 132 | # exit(1) 133 | 134 | # def _iterate_dict(search, current, value, parent): 135 | # for k, v in value.items(): 136 | # if k == search[current]: 137 | # _entrypoint(search, current, v, parent) 138 | 139 | # def _iterate_list(search, current, value, parent): 140 | # _entrypoint(search, current, value[search[current]], parent) 141 | 142 | # _search = [int(x) if is_integer(x) else x for x in key.split("/")] 143 | # _current = 0 144 | 145 | # for i in range(len(_search)): 146 | # for k, v in _raw.items(): 147 | # if _search[_current] == k: 148 | # _entrypoint(_search, _current, v, _raw) 149 | 150 | # with open(path, "w") as f: 151 | # yaml.dump(_raw) 152 | 153 | # return True 154 | -------------------------------------------------------------------------------- /wilfred/api/server_config.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | import click 12 | 13 | from tabulate import tabulate 14 | 15 | from wilfred.container_variables import ContainerVariables 16 | from wilfred.errors import WilfredException, ParseError, WriteError 17 | 18 | from wilfred.api.parser.properties import properties_read, properties_write 19 | from wilfred.api.parser.yaml import yaml_read, yaml_write 20 | from wilfred.api.parser.json import json_read, json_write 21 | 22 | 23 | class UnsupportedFiletype(WilfredException): 24 | """File type is not supported by parser""" 25 | 26 | 27 | class ServerConfig: 28 | def __init__(self, configuration, servers, server, image): 29 | """ 30 | Read, edit and update configurations for servers. Exposes server specific configs to Wilfred. 31 | 32 | :param configuration: JSON dict for Wilfred config 33 | :param object servers: wilfred.api.servers object 34 | :param str server: SQLAlchemy server object 35 | :param dictionary image: Image dict object 36 | """ 37 | 38 | self._configuration = configuration 39 | self._servers = servers 40 | self._server = server 41 | self._image = image 42 | 43 | self.raw = [] 44 | self._variables = [] # list of dicts 45 | 46 | self._parse() 47 | 48 | def _parse(self): 49 | """iterates configuration files for the specific server and parses the files""" 50 | 51 | def _err(e): 52 | raise ParseError(f"failed to parse {file['filename']}, err {str(e)}") 53 | 54 | for file in self._image["config"]["files"]: 55 | path = f"{self._configuration['data_path']}/{self._server.name}_{self._server.id}/{file['filename']}" 56 | 57 | if file["parser"] == "properties": 58 | try: 59 | _raw = properties_read(path) 60 | except Exception as e: 61 | _err(e) 62 | 63 | _raw["_wilfred_config_filename"] = file["filename"] 64 | self.raw.append(_raw) 65 | 66 | continue 67 | 68 | if file["parser"] == "yaml": 69 | try: 70 | _raw = yaml_read(path) 71 | except Exception as e: 72 | _err(e) 73 | 74 | _raw["_wilfred_config_filename"] = file["filename"] 75 | self.raw.append(_raw) 76 | 77 | continue 78 | 79 | if file["parser"] == "json": 80 | try: 81 | _raw = json_read(path) 82 | except Exception as e: 83 | _err(e) 84 | 85 | _raw["_wilfred_config_filename"] = file["filename"] 86 | self.raw.append(_raw) 87 | 88 | continue 89 | 90 | raise UnsupportedFiletype(file["parser"]) 91 | return True 92 | 93 | def pretty(self): 94 | """returns parsed configuration variables in a print-friendly format""" 95 | 96 | headers = { 97 | "file": click.style("Config File", bold=True), 98 | "setting": click.style("Setting", bold=True), 99 | "value": click.style("Value", bold=True), 100 | } 101 | 102 | data = [] 103 | 104 | for file in self.raw: 105 | for k, v in file.items(): 106 | for _image_config_file in self._image["config"]["files"]: 107 | for x in _image_config_file["environment"]: 108 | k = ( 109 | f"{k} ({click.style('not editable', fg='red', bold=True)})" 110 | if x["config_variable"] == k 111 | else k 112 | ) 113 | 114 | data.append( 115 | {"file": file["_wilfred_config_filename"], "setting": k, "value": v} 116 | ) if k != "_wilfred_config_filename" else None 117 | 118 | return tabulate( 119 | data, 120 | headers=headers, 121 | tablefmt="fancy_grid", 122 | ) 123 | 124 | def edit(self, filename, variable, value, override_linking_check=False): 125 | """modifies value of specified variable""" 126 | 127 | try: 128 | value = int(value) 129 | except Exception: 130 | pass 131 | 132 | try: 133 | value = True if value.lower() == "true" else value 134 | value = False if value.lower() == "false" else value 135 | except Exception: 136 | pass 137 | 138 | for file in self._image["config"]["files"]: 139 | if file["filename"] == filename: 140 | path = f"{self._configuration['data_path']}/{self._server.name}_{self._server.id}" 141 | 142 | for _image_config_file in self._image["config"]["files"]: 143 | for x in _image_config_file["environment"]: 144 | if ( 145 | x["config_variable"] == variable 146 | and not override_linking_check 147 | ): 148 | raise WriteError( 149 | "This setting is linked to an environment variable and is therefore not editable directly" 150 | ) 151 | 152 | if file["parser"] == "properties": 153 | properties_write(f"{path}/{file['filename']}", variable, value) 154 | 155 | if file["parser"] == "yaml": 156 | yaml_write(f"{path}/{file['filename']}", variable, value) 157 | 158 | if file["parser"] == "json": 159 | json_write(f"{path}/{file['filename']}", variable, value) 160 | 161 | if variable in file["action"]: 162 | self._servers.command( 163 | self._server, file["action"][variable].format(value) 164 | ) 165 | 166 | def write_environment_variables(self): 167 | """writes environment variable to config file(s)""" 168 | 169 | for file in self._image["config"]["files"]: 170 | for _env in file["environment"]: 171 | env_vars = ContainerVariables(self._server, self._image).get_env_vars() 172 | 173 | if _env["environment_variable"] in env_vars: 174 | self.edit( 175 | file["filename"], 176 | _env["config_variable"], 177 | _env["value_format"].format( 178 | env_vars[_env["environment_variable"]] 179 | ) 180 | if _env["value_format"] 181 | else env_vars[_env["environment_variable"]], 182 | override_linking_check=True, 183 | ) 184 | -------------------------------------------------------------------------------- /wilfred/api/servers.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | import click 12 | import docker 13 | 14 | from pathlib import Path 15 | from shutil import rmtree 16 | from os import remove as remove_file 17 | from os import rename 18 | from time import sleep 19 | from sys import platform 20 | from subprocess import call 21 | from sqlalchemy import inspect 22 | 23 | from wilfred.database import session, Server, EnvironmentVariable, Port 24 | from wilfred.keyboard import KeyboardThread 25 | from wilfred.container_variables import ContainerVariables 26 | from wilfred.api.images import Images 27 | from wilfred.errors import WilfredException, WriteError 28 | 29 | 30 | class ServerNotRunning(WilfredException): 31 | """Server is not running""" 32 | 33 | 34 | class Servers(object): 35 | def __init__( 36 | self, docker_client: docker.DockerClient, configuration: dict, images: Images 37 | ): 38 | """ 39 | Initiates wilfred.api.Servers, method for controlling servers 40 | 41 | Args: 42 | docker_client (docker.DockerClient): DockerClient object from Docker module 43 | configuration (dict): Dictionary of Wilfred config 44 | images (Images): wilfred.api.Images object 45 | """ 46 | 47 | self._images = images 48 | self._configuration = configuration 49 | self._docker_client = docker_client 50 | 51 | def all(self, cpu_load=False, memory_usage=False): 52 | """ 53 | Returns data of all servers 54 | 55 | Args: 56 | cpu_load (bool): Include the CPU load of the container. Defaults to `None` if server is not running. 57 | memory_usage (bool): Include memory usage of the container. Defaults to `None` if server is not running. 58 | """ 59 | 60 | servers = [ 61 | {c.key: getattr(u, c.key) for c in inspect(u).mapper.column_attrs} 62 | for u in session.query(Server).all() 63 | ] 64 | 65 | for server in servers: 66 | if cpu_load or memory_usage: 67 | for server in servers: 68 | _running = True 69 | _stats_avail = True 70 | 71 | try: 72 | container = self._docker_client.containers.get( 73 | f"wilfred_{server['id']}" 74 | ) 75 | d = container.stats(stream=False) 76 | except docker.errors.NotFound: 77 | server.update({"cpu_load": "-"}) 78 | server.update({"memory_usage": "-"}) 79 | _running = False 80 | except Exception: 81 | server.update({"cpu_load": "error"}) 82 | server.update({"memory_usage": "error"}) 83 | _running = False 84 | 85 | if cpu_load and _running: 86 | # on some systems, statisics are not available 87 | if ( 88 | "system_cpu_usage" not in d["cpu_stats"] 89 | or "system_cpu_usage" not in d["precpu_stats"] 90 | ): 91 | server.update({"cpu_load": "-"}) 92 | _stats_avail = False 93 | 94 | if cpu_load and _running and _stats_avail: 95 | # calculate the change in CPU usage between current and previous reading 96 | cpu_delta = float( 97 | d["cpu_stats"]["cpu_usage"]["total_usage"] 98 | ) - float(d["precpu_stats"]["cpu_usage"]["total_usage"]) 99 | 100 | # calculate the change in system CPU usage between current and previous reading 101 | system_delta = float( 102 | d["cpu_stats"]["system_cpu_usage"] 103 | ) - float(d["precpu_stats"]["system_cpu_usage"]) 104 | 105 | # Calculate number of CPU cores 106 | cpu_count = float(d["cpu_stats"]["online_cpus"]) 107 | if cpu_count == 0.0: 108 | cpu_count = len( 109 | d["precpu_stats"]["cpu_usage"]["percpu_usage"] 110 | ) 111 | 112 | if system_delta > 0.0: 113 | cpu_percent = f"{round(cpu_delta / system_delta * 100.0 * cpu_count, 2)}%" 114 | 115 | server.update({"cpu_load": cpu_percent if cpu_percent else "-"}) 116 | 117 | if memory_usage and _running: 118 | mem_used = d["memory_stats"]["usage"] / 1024 / 1024 119 | mem_percent = ( 120 | d["memory_stats"]["usage"] 121 | / d["memory_stats"]["limit"] 122 | * 100 123 | ) 124 | server.update( 125 | { 126 | "memory_usage": f"{round(mem_used, 1)} MB / {round(mem_percent, 2)}%" 127 | } 128 | ) 129 | 130 | return servers 131 | 132 | def set_status(self, server, status): 133 | server.status = status 134 | session.commit() 135 | 136 | def sync(self): 137 | """ 138 | Performs sync, checks for state of containers 139 | """ 140 | 141 | for server in session.query(Server).all(): 142 | if server.status == "installing": 143 | try: 144 | self._docker_client.containers.get(f"wilfred_{server.id}") 145 | except docker.errors.NotFound: 146 | self.set_status(server, "stopped") 147 | 148 | # stopped 149 | if server.status == "stopped": 150 | self._stop(server) 151 | 152 | # start 153 | if server.status == "running": 154 | try: 155 | self._docker_client.containers.get(f"wilfred_{server.id}") 156 | except docker.errors.NotFound: 157 | self._start(server) 158 | 159 | def remove(self, server: Server): 160 | """ 161 | Removes specified server 162 | 163 | Args: 164 | server (wilfred.database.Server): Server database object 165 | """ 166 | 167 | path = f"{self._configuration['data_path']}/{server.name}_{server.id}" 168 | 169 | # delete all environment variables associated to this server 170 | for x in ( 171 | session.query(EnvironmentVariable).filter_by(server_id=server.id).all() 172 | ): 173 | session.delete(x) 174 | 175 | # delete all additional ports associated to this server 176 | for x in session.query(Port).filter_by(server_id=server.id).all(): 177 | session.delete(x) 178 | 179 | session.delete(server) 180 | session.commit() 181 | 182 | try: 183 | container = self._docker_client.containers.get(f"wilfred_{server.id}") 184 | container.kill() 185 | except docker.errors.NotFound: 186 | pass 187 | 188 | rmtree(path, ignore_errors=True) 189 | 190 | def console(self, server: Server, disable_user_input=False): 191 | """ 192 | Enters server console 193 | 194 | Args: 195 | server (wilfred.database.Server): Server database object 196 | disable_user_input (bool): Blocks user input if `True`. By default this is `False`. 197 | 198 | Raises: 199 | :py:class:`ServerNotRunning` 200 | If server is not running 201 | """ 202 | 203 | try: 204 | container = self._docker_client.containers.get(f"wilfred_{server.id}") 205 | except docker.errors.NotFound: 206 | raise ServerNotRunning(f"server {server.id} is not running") 207 | 208 | if platform.startswith("win"): 209 | click.echo(container.logs()) 210 | call(["docker", "attach", container.id]) 211 | else: 212 | if not disable_user_input: 213 | KeyboardThread(self._console_input_callback, params=server) 214 | 215 | try: 216 | for line in container.logs(stream=True, tail=200): 217 | click.echo(line.strip()) 218 | except docker.errors.NotFound: 219 | raise ServerNotRunning(f"server {server.id} is not running") 220 | 221 | def install(self, server: Server, skip_wait=False, spinner=None): 222 | """ 223 | Performs installation 224 | 225 | Args: 226 | server (wilfred.database.Server): Server database object 227 | skip_wait (bool): Doesn't stall while waiting for server installation to complete if `True`. 228 | spinner (Halo): If `Halo` spinner object is defined, will then write and perform actions to it. 229 | 230 | Raises: 231 | :py:class:`WriteError` 232 | If not able to create directory or write to it 233 | """ 234 | 235 | path = f"{self._configuration['data_path']}/{server.name}_{server.id}" 236 | image = self._images.get_image(server.image_uid) 237 | 238 | if platform.startswith("win"): 239 | path = path.replace("/", "\\") 240 | 241 | try: 242 | Path(path).mkdir(parents=True, exist_ok=True) 243 | except Exception as e: 244 | raise WriteError(f"could not create server data directory, {str(e)}") 245 | 246 | with open(f"{path}/install.sh", "w", newline="\n") as f: 247 | f.write("cd /server\n" + "\n".join(image["installation"]["script"])) 248 | 249 | if spinner: 250 | spinner.info( 251 | "Pulling Docker image and creating installation container, do not exit" 252 | ) 253 | spinner.start() 254 | 255 | self._docker_client.containers.run( 256 | image["installation"]["docker_image"], 257 | f"{image['installation']['shell']} /server/install.sh", 258 | volumes={path: {"bind": "/server", "mode": "rw"}}, 259 | name=f"wilfred_{server.id}", 260 | environment=ContainerVariables(server, image, install=True).get_env_vars(), 261 | remove=True, 262 | detach=True, 263 | ) 264 | 265 | if skip_wait and spinner: 266 | spinner.info( 267 | "Installation will continue in background, use `wilfred servers` to see if process has finished." 268 | ) 269 | spinner.start() 270 | 271 | if not skip_wait: 272 | if spinner: 273 | spinner.info( 274 | "You can safely press CTRL+C, the installation will continue in the background." 275 | ) 276 | spinner.info( 277 | "Run `wilfred servers` to see when the status changes from `installing` to `stopped`." 278 | ) 279 | spinner.info( 280 | f"You can also follow the installation log using `wilfred console {server.name}`" 281 | ) 282 | spinner.start() 283 | while self._container_alive(server): 284 | sleep(1) 285 | 286 | def kill(self, server): 287 | """ 288 | Kills server container 289 | 290 | Args: 291 | server (wilfred.database.Server): Server database object 292 | 293 | Raises: 294 | :py:class:`ServerNotRunning` 295 | If server is not running 296 | """ 297 | 298 | try: 299 | container = self._docker_client.containers.get(f"wilfred_{server.id}") 300 | except docker.errors.NotFound: 301 | raise ServerNotRunning(f"server {server.id} is not running") 302 | 303 | container.kill() 304 | 305 | def rename(self, server, name): 306 | """ 307 | Renames server and moves server folder 308 | 309 | Args: 310 | server (wilfred.database.Server): Server database object 311 | name (str): New name of the server 312 | 313 | Raises: 314 | :py:class:`WilfredException` 315 | If server is running 316 | :py:class:`WriteError` 317 | If not able to move folder 318 | """ 319 | 320 | if self._container_alive(server): 321 | raise WilfredException("You cannot rename the server while it is running") 322 | 323 | try: 324 | rename( 325 | f"{self._configuration['data_path']}/{server.name}_{server.id}", 326 | f"{self._configuration['data_path']}/{name}_{server.id}", 327 | ) 328 | except Exception as e: 329 | raise WriteError(f"could not rename folder, {str(e)}") 330 | 331 | server.name = name 332 | session.commit() 333 | 334 | def _console_input_callback(self, payload, server): 335 | self.command(server, payload) 336 | 337 | def command(self, server, command): 338 | """ 339 | Sends command to server console 340 | 341 | Args: 342 | server (wilfred.database.Server): Server database object 343 | command (str): The command to send to the stdin of the server 344 | 345 | Raises: 346 | :py:class:`ServerNotRunning` 347 | If server is not running 348 | """ 349 | 350 | _cmd = f"{command}\n".encode("utf-8") 351 | 352 | try: 353 | container = self._docker_client.containers.get(f"wilfred_{server.id}") 354 | except docker.errors.NotFound: 355 | raise ServerNotRunning(f"server {server.id} is not running") 356 | 357 | s = container.attach_socket(params={"stdin": 1, "stream": 1}) 358 | s.send(_cmd) if platform.startswith("win") else s._sock.send(_cmd) 359 | s.close() 360 | 361 | def _running_docker_sync(self): 362 | for server in session.query(Server).all(): 363 | try: 364 | self._docker_client.containers.get(f"wilfred_{server.id}") 365 | except docker.errors.NotFound: 366 | self.set_status(server, "stopped") 367 | 368 | def _parse_startup_command(self, cmd, server, image): 369 | return ContainerVariables(server, image).parse_startup_command( 370 | cmd.replace("{{SERVER_MEMORY}}", str(server.memory)).replace( 371 | "{{SERVER_PORT}}", str(server.port) 372 | ) 373 | ) 374 | 375 | def _container_alive(self, server): 376 | try: 377 | self._docker_client.containers.get(f"wilfred_{server.id}") 378 | except docker.errors.NotFound: 379 | return False 380 | 381 | return True 382 | 383 | def _start(self, server): 384 | path = f"{self._configuration['data_path']}/{server.name}_{server.id}" 385 | image = self._images.get_image(server.image_uid) 386 | 387 | try: 388 | remove_file(f"{path}/install.sh") 389 | except Exception: 390 | pass 391 | 392 | # get additional ports 393 | ports = session.query(Port).filter_by(server_id=server.id).all() 394 | 395 | self._docker_client.containers.run( 396 | image["docker_image"], 397 | self._parse_startup_command(server.custom_startup, server, image) 398 | if server.custom_startup is not None 399 | else f"{self._parse_startup_command(image['command'], server, image)}", 400 | volumes={path: {"bind": "/server", "mode": "rw"}}, 401 | name=f"wilfred_{server.id}", 402 | remove=True, 403 | ports={ 404 | **{ 405 | f"{server.port}/tcp": server.port, 406 | f"{server.port}/udp": server.port, 407 | }, 408 | **{ 409 | f"{additional_port.port}/tcp": additional_port.port 410 | for additional_port in ports 411 | }, 412 | **{ 413 | f"{additional_port.port}/udp": additional_port.port 414 | for additional_port in ports 415 | }, 416 | }, 417 | detach=True, 418 | working_dir="/server", 419 | mem_limit=f"{server.memory}m", 420 | oom_kill_disable=True, 421 | stdin_open=True, 422 | environment=ContainerVariables(server, image).get_env_vars(), 423 | user=image["user"] if image["user"] else "root", 424 | ) 425 | 426 | def _stop(self, server): 427 | image = self._images.get_image(server.image_uid) 428 | 429 | try: 430 | container = self._docker_client.containers.get(f"wilfred_{server.id}") 431 | except docker.errors.NotFound: 432 | return 433 | 434 | if not image["stop_command"]: 435 | container.stop() 436 | 437 | return 438 | 439 | self.command(server, image["stop_command"]) 440 | 441 | stopped = False 442 | 443 | while not stopped: 444 | try: 445 | self._docker_client.containers.get(f"wilfred_{server.id}") 446 | except docker.errors.NotFound: 447 | stopped = True 448 | -------------------------------------------------------------------------------- /wilfred/container_variables.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | from wilfred.database import session, EnvironmentVariable 12 | 13 | 14 | class ContainerVariables(object): 15 | def __init__(self, server, image, install=False): 16 | self._server = server 17 | self._image = image 18 | self._install = install 19 | 20 | def parse_startup_command(self, cmd): 21 | for k, v in self.get_env_vars().items(): 22 | cmd = cmd.replace("{{image.env." + str(k) + "}}", str(v if v else "")) 23 | 24 | return cmd 25 | 26 | def get_env_vars(self): 27 | environment = {} 28 | 29 | for var in self._image["variables"]: 30 | value = ( 31 | session.query(EnvironmentVariable) 32 | .filter_by(server_id=self._server.id) 33 | .filter_by(variable=var["variable"]) 34 | .first() 35 | .value 36 | ) 37 | 38 | if var["install_only"] and not self._install: 39 | continue 40 | 41 | environment[var["variable"]] = value 42 | 43 | environment["SERVER_MEMORY"] = self._server.memory 44 | environment["SERVER_PORT"] = self._server.port 45 | 46 | return environment 47 | -------------------------------------------------------------------------------- /wilfred/core.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | import requests 12 | import click 13 | 14 | from random import choice 15 | from string import ascii_lowercase, digits 16 | 17 | from wilfred.version import version, commit_hash 18 | from wilfred.message_handler import warning 19 | 20 | 21 | def random_string(length=8): 22 | """ 23 | Generate a random string of fixed length 24 | 25 | :param int length: length of string to generate 26 | """ 27 | 28 | return "".join(choice(ascii_lowercase + digits) for i in range(length)) 29 | 30 | 31 | def check_for_new_releases(enable_emojis=True): 32 | """ 33 | Checks if a new version is available on GitHub 34 | """ 35 | 36 | url = "https://api.github.com/repos/wilfred-dev/wilfred/tags" 37 | key = "name" 38 | version_type = "version" 39 | 40 | if version == "0.0.0.dev0": 41 | url = "https://api.github.com/repos/wilfred-dev/wilfred/commits" 42 | key = "sha" 43 | version_type = "commit" 44 | 45 | r = requests.get(url) 46 | 47 | if r.status_code != requests.codes.ok: 48 | warning("unable to retrieve latest version") 49 | 50 | return 51 | 52 | try: 53 | latest = r.json()[0][key] 54 | except Exception: 55 | warning("unable to parse release data") 56 | 57 | return 58 | 59 | compare = commit_hash if version == "0.0.0.dev0" else f"v{version}" 60 | 61 | if latest != compare: 62 | click.echo( 63 | "".join( 64 | ( 65 | f"{'🎉 ' if enable_emojis else click.style('! ', fg='green')}", 66 | f"A new {version_type} of Wilfred is available! {latest}", 67 | ) 68 | ) 69 | ) 70 | 71 | return 72 | 73 | 74 | def is_integer(variable): 75 | try: 76 | int(variable) 77 | except Exception: 78 | return False 79 | 80 | return True 81 | 82 | 83 | def set_in_dict(dic, keys, value): 84 | for key in keys[:-1]: 85 | dic = dic.setdefault(key, {}) 86 | dic[keys[-1]] = value 87 | 88 | return dic 89 | -------------------------------------------------------------------------------- /wilfred/database.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | from sqlalchemy import create_engine, Column, Integer, String, ForeignKey 12 | from sqlalchemy.orm import relationship, sessionmaker, validates 13 | from sqlalchemy.ext.declarative import declarative_base 14 | 15 | from appdirs import user_data_dir 16 | from os.path import isdir 17 | from pathlib import Path 18 | 19 | 20 | if not isdir(f"{user_data_dir()}/wilfred"): 21 | Path(f"{user_data_dir()}/wilfred").mkdir(parents=True, exist_ok=True) 22 | 23 | database_path = f"{user_data_dir()}/wilfred/wilfred.sqlite" 24 | engine = create_engine(f"sqlite:///{database_path}") 25 | Base = declarative_base() 26 | 27 | 28 | class Server(Base): 29 | __tablename__ = "servers" 30 | 31 | id = Column(String, primary_key=True, unique=True) 32 | name = Column(String(20), unique=True) 33 | image_uid = Column(String) 34 | memory = Column(Integer) 35 | port = Column(Integer, unique=True) 36 | custom_startup = Column(String) 37 | status = Column(String) 38 | 39 | environment_variables = relationship("EnvironmentVariable") 40 | 41 | # SQLite does not enforce string maximums 42 | @validates("name") 43 | def validate_name(self, key, name) -> str: 44 | if len(name) > 20: 45 | raise ValueError("name is too long (max 20 characters)") 46 | return name 47 | 48 | def __repr__(self): 49 | return f"" 50 | 51 | 52 | class EnvironmentVariable(Base): 53 | __tablename__ = "environment_variables" 54 | 55 | id = Column(Integer, primary_key=True) 56 | server_id = Column(String, ForeignKey("servers.id"), unique=False) 57 | variable = Column(String) 58 | value = Column(String) 59 | 60 | 61 | class Port(Base): 62 | __tablename__ = "ports" 63 | 64 | id = Column(Integer, primary_key=True) 65 | server_id = Column(String, ForeignKey("servers.id"), unique=False) 66 | port = Column(Integer, unique=True) 67 | 68 | 69 | Base.metadata.create_all(engine) 70 | 71 | Session = sessionmaker() 72 | Session.configure(bind=engine) 73 | session = Session() 74 | -------------------------------------------------------------------------------- /wilfred/decorators.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | from functools import wraps 12 | 13 | from wilfred.message_handler import error 14 | from wilfred.api.config_parser import Config, NoConfiguration 15 | 16 | config = Config() 17 | 18 | try: 19 | config.read() 20 | except NoConfiguration: 21 | pass 22 | 23 | 24 | def configuration_present(f): 25 | @wraps(f) 26 | def decorated_function(*args, **kwargs): 27 | if not config.configuration: 28 | error("Wilfred has not been configured", exit_code=1) 29 | return f(*args, **kwargs) 30 | 31 | return decorated_function 32 | -------------------------------------------------------------------------------- /wilfred/docker_conn.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | import docker 12 | 13 | 14 | def docker_client(base_url=None): 15 | """returns client object used for Docker socket communication 16 | 17 | :param str base_url: Base URL for Docker socket. Uses env if None.""" 18 | 19 | client = docker.DockerClient(base_url=base_url) if base_url else docker.from_env() 20 | 21 | return client 22 | -------------------------------------------------------------------------------- /wilfred/errors.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | 12 | class WilfredException(Exception): 13 | """ 14 | A base class from which all other exceptions inherit. 15 | 16 | If you want to catch all errors that the Wilfred API might raise, 17 | catch this base exception. Though, dependencies of Wilfred might 18 | raise other exceptions which this base exception does not cover. 19 | """ 20 | 21 | 22 | class ReadError(WilfredException): 23 | """ 24 | Exception occured while reading file 25 | """ 26 | 27 | 28 | class ParseError(WilfredException): 29 | """ 30 | Exception occured while parsing file (malformed or missing variables) 31 | """ 32 | 33 | 34 | class WriteError(WilfredException): 35 | """ 36 | Exception occured while wiring to file (permission denied or invalid path) 37 | """ 38 | -------------------------------------------------------------------------------- /wilfred/keyboard.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | import threading 12 | 13 | 14 | class KeyboardThread(threading.Thread): 15 | def __init__(self, input_callback, params): 16 | self._running = True 17 | self.input_callback = input_callback 18 | self.params = params 19 | 20 | super(KeyboardThread, self).__init__(name="wilfred-console-input", daemon=True) 21 | 22 | self.start() 23 | 24 | def run(self): 25 | while self._running: 26 | try: 27 | self.input_callback(input(), self.params) 28 | except Exception: 29 | self._running = False 30 | -------------------------------------------------------------------------------- /wilfred/message_handler.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | import click 12 | import sys 13 | 14 | 15 | def _message(prefix, msg): 16 | click.echo(f"{prefix} {msg}") 17 | 18 | 19 | def ui_exception(e): 20 | _exception_name = click.style(f"{type(e).__name__}", bold=True) 21 | error(f"{_exception_name} {str(e)}", exit_code=1) 22 | 23 | 24 | def error(message, exit_code=None): 25 | _message(prefix=click.style("💥 Error", fg="red"), msg=message) 26 | 27 | if exit_code is not None: 28 | sys.exit(exit_code) 29 | 30 | 31 | def warning(message, exit_code=None): 32 | _message(prefix=click.style("⚠️ Warning ", fg="yellow"), msg=message) 33 | 34 | if exit_code is not None: 35 | sys.exit(exit_code) 36 | 37 | 38 | def info(message, exit_code=None): 39 | _message(prefix=click.style("🔵 Info", fg="blue"), msg=message) 40 | 41 | if exit_code is not None: 42 | sys.exit(exit_code) 43 | -------------------------------------------------------------------------------- /wilfred/migrate.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | import sqlite3 12 | import click 13 | 14 | from appdirs import user_data_dir 15 | from os.path import isfile 16 | from os import remove 17 | 18 | from wilfred.message_handler import error 19 | from wilfred.database import session, Server, EnvironmentVariable 20 | 21 | 22 | class Migrate: 23 | def __init__(self): 24 | self._legacy_sqlite_path = f"{user_data_dir()}/wilfred/wilfred.db" 25 | 26 | # perform checks 27 | self._legacy_sqlite_db_check() 28 | 29 | def _legacy_sqlite_query(self, query): 30 | result = [] 31 | 32 | try: 33 | conn = sqlite3.connect(self._legacy_sqlite_path) 34 | conn.row_factory = sqlite3.Row 35 | 36 | cur = conn.cursor() 37 | cur.execute(query) 38 | 39 | for r in cur.fetchall(): 40 | result.append(dict(r)) 41 | 42 | conn.commit() 43 | conn.close() 44 | except sqlite3.OperationalError as e: 45 | error( 46 | "could not communicate with database " + click.style(str(e), bold=True), 47 | exit_code=1, 48 | ) 49 | except sqlite3.IntegrityError as e: 50 | error("invalid input " + click.style(str(e), bold=True), exit_code=1) 51 | 52 | return result 53 | 54 | def _legacy_sqlite_db_check(self): 55 | if isfile(self._legacy_sqlite_path): 56 | for server in self._legacy_sqlite_query("SELECT * FROM servers"): 57 | session.add( 58 | Server( 59 | id=server["id"], 60 | name=server["name"], 61 | image_uid=server["image_uid"], 62 | memory=server["memory"], 63 | port=server["port"], 64 | custom_startup=server["custom_startup"], 65 | status=server["status"], 66 | ) 67 | ) 68 | 69 | try: 70 | session.commit() 71 | except Exception as e: 72 | error(str(e), exit_code=1) 73 | 74 | for variable in self._legacy_sqlite_query("SELECT * FROM variables"): 75 | session.add( 76 | EnvironmentVariable( 77 | server_id=variable["server_id"], 78 | variable=variable["variable"], 79 | value=variable["value"], 80 | ) 81 | ) 82 | 83 | try: 84 | session.commit() 85 | except Exception as e: 86 | error(str(e), exit_code=1) 87 | 88 | try: 89 | remove(f"{self._legacy_sqlite_path}") 90 | except Exception as e: 91 | error(str(e), exit_code=1) 92 | -------------------------------------------------------------------------------- /wilfred/version.py: -------------------------------------------------------------------------------- 1 | ################################################################# 2 | # # 3 | # Wilfred # 4 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 5 | # # 6 | # Licensed under the terms of the MIT license, see LICENSE. # 7 | # https://github.com/wilfred-dev/wilfred # 8 | # # 9 | ################################################################# 10 | 11 | # use "0.0.0.dev0" to indicate incremental commit build 12 | # specify version "0.3.0" to indicate standard release 13 | # "commit_date" should be replaced upon building 14 | version = "0.0.0.dev0" 15 | commit_hash = "development" 16 | commit_date = "YYYY-MM-DD" 17 | -------------------------------------------------------------------------------- /wilfred/wilfred.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | ################################################################# 4 | # # 5 | # Wilfred # 6 | # Copyright (C) 2020-2022, Vilhelm Prytz, # 7 | # # 8 | # Licensed under the terms of the MIT license, see LICENSE. # 9 | # https://github.com/wilfred-dev/wilfred # 10 | # # 11 | ################################################################# 12 | 13 | import click 14 | import codecs 15 | import locale 16 | import os 17 | import sys 18 | 19 | from halo import Halo 20 | from pathlib import Path 21 | from sqlalchemy.exc import IntegrityError 22 | from sqlalchemy import inspect 23 | from time import sleep 24 | from tabulate import tabulate 25 | from shutil import get_terminal_size 26 | 27 | from wilfred.docker_conn import docker_client 28 | from wilfred.version import version, commit_hash, commit_date 29 | from wilfred.api.config_parser import Config, NoConfiguration 30 | from wilfred.database import session, database_path, Server, EnvironmentVariable, Port 31 | from wilfred.api.servers import Servers 32 | from wilfred.api.images import Images, ImageAPIMismatch, ImagesOutdated 33 | from wilfred.message_handler import warning, error, ui_exception 34 | from wilfred.core import is_integer, random_string, check_for_new_releases 35 | from wilfred.migrate import Migrate 36 | from wilfred.api.server_config import ServerConfig 37 | from wilfred.decorators import configuration_present 38 | 39 | 40 | config = Config() 41 | 42 | try: 43 | config.read() 44 | except NoConfiguration: 45 | warning("Wilfred is not yet configured. Run `wilfred setup` to configure Wilfred.") 46 | except Exception as e: 47 | ui_exception(e) 48 | 49 | 50 | images = Images() 51 | 52 | if not os.environ.get("WILFRED_SKIP_DOCKER", False): 53 | try: 54 | servers = Servers(docker_client(), config.configuration, images) 55 | except Exception as e: 56 | ui_exception(e) 57 | 58 | if not images.check_if_present(): 59 | with Halo( 60 | text="Downloading default images", color="yellow", spinner="dots" 61 | ) as spinner: 62 | images.download() 63 | spinner.succeed("Images downloaded") 64 | 65 | try: 66 | images.read_images() 67 | except ImageAPIMismatch: 68 | with Halo( 69 | text="Downloading default images", color="yellow", spinner="dots" 70 | ) as spinner: 71 | images.download() 72 | spinner.succeed("Images downloaded") 73 | try: 74 | images.read_images() 75 | except Exception as e: 76 | ui_exception(e) 77 | except ImagesOutdated: 78 | with Halo( 79 | text="Images outdated, refreshing default images", 80 | color="yellow", 81 | spinner="dots", 82 | ) as spinner: 83 | images.download() 84 | spinner.succeed("Images refreshed") 85 | try: 86 | images.read_images() 87 | except Exception as e: 88 | ui_exception(e) 89 | except Exception as e: 90 | ui_exception(e) 91 | 92 | # check 93 | Migrate() 94 | 95 | ENABLE_EMOJIS = False if sys.platform.startswith("win") else True 96 | 97 | 98 | def print_version(ctx, param, value): 99 | """ 100 | print version and exit 101 | """ 102 | 103 | if not value or ctx.resilient_parsing: 104 | return 105 | 106 | _commit_hash = commit_hash[0:7] 107 | _snap = ( 108 | f" via snap (revision {os.environ['SNAP_REVISION']})" 109 | if "SNAP" in os.environ and "SNAP_REVISION" in os.environ 110 | else "" 111 | ) 112 | _python_version = ( 113 | f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" 114 | ) 115 | 116 | check_for_new_releases(enable_emojis=ENABLE_EMOJIS) 117 | if str(version) == "0.0.0.dev0": 118 | click.echo( 119 | "".join( 120 | ( 121 | f"{'✨ ' if ENABLE_EMOJIS else ''}wilfred version ", 122 | f"{_commit_hash}/edge (development build) built {commit_date}{_snap} (python {_python_version})", 123 | ) 124 | ) 125 | ) 126 | else: 127 | click.echo( 128 | "".join( 129 | ( 130 | f"{'✨ ' if ENABLE_EMOJIS else ''}wilfred version ", 131 | f"v{version}/stable (commit {_commit_hash}) built {commit_date}{_snap} (python {_python_version})", 132 | ) 133 | ) 134 | ) 135 | 136 | ctx.exit() 137 | 138 | 139 | def print_path(ctx, param, value): 140 | """ 141 | print config/data paths 142 | """ 143 | 144 | if not value or ctx.resilient_parsing: 145 | return 146 | 147 | click.echo(f"Configuration file: {click.format_filename(config.config_path)}") 148 | click.echo(f"Image config file: {click.format_filename(images.image_dir)}") 149 | click.echo(f"Database file: {click.format_filename(database_path)}") 150 | 151 | if config.configuration: 152 | _path = f"{click.format_filename(config.configuration['data_path'])}" 153 | 154 | if sys.platform.startswith("win"): 155 | _path = _path.replace("/", "\\") 156 | 157 | click.echo(f"Server data: {_path}") 158 | 159 | ctx.exit() 160 | 161 | 162 | def pretty_list(data, tablefmt): 163 | for server in data: 164 | server.update( 165 | ( 166 | k, 167 | str(v) 168 | .replace("running", click.style("running", fg="green")) 169 | .replace("stopped", click.style("stopped", fg="red")) 170 | .replace("installing", click.style("installing", fg="yellow")), 171 | ) 172 | for k, v in server.items() 173 | ) 174 | 175 | headers = { 176 | "id": click.style("ID", bold=True), 177 | "name": click.style("Name", bold=True), 178 | "image_uid": click.style("Image UID", bold=True), 179 | "memory": click.style("RAM", bold=True), 180 | "port": click.style("Port", bold=True), 181 | "status": click.style("Status", bold=True), 182 | "custom_startup": click.style("Custom startup", bold=True), 183 | } 184 | 185 | return tabulate( 186 | data, 187 | headers=headers, 188 | tablefmt=tablefmt, 189 | ) 190 | 191 | 192 | def main(): 193 | # snap packages raise some weird ASCII codec errors, so we just force C.UTF-8 194 | if ( 195 | codecs.lookup(locale.getpreferredencoding()).name == "ascii" 196 | and os.name == "posix" 197 | ): 198 | os.environ["LC_ALL"] = "C.UTF-8" 199 | os.environ["LANG"] = "C.UTF-8" 200 | 201 | cli() 202 | 203 | 204 | @click.group() 205 | @click.option( 206 | "--version", 207 | is_flag=True, 208 | callback=print_version, 209 | expose_value=False, 210 | is_eager=True, 211 | help="Print version and exit", 212 | ) 213 | @click.option( 214 | "--path", 215 | is_flag=True, 216 | callback=print_path, 217 | expose_value=False, 218 | is_eager=True, 219 | help="Print paths for configurations and server data", 220 | ) 221 | def cli(): 222 | """ 223 | Wilfred - A CLI for managing game servers using Docker. 224 | 225 | Website - https://wilfredproject.org 226 | 227 | Official documentation - https://docs.wilfredproject.org 228 | 229 | Source code - https://github.com/wilfred-dev/wilfred 230 | 231 | Discord server for support - https://wilfredproject.org/discord 232 | """ 233 | 234 | pass 235 | 236 | 237 | @cli.command() 238 | def setup(): 239 | """Setup wilfred, create configuration.""" 240 | 241 | if config.configuration: 242 | warning("A configuration file for Wilfred already exists.") 243 | click.confirm("Are you sure you wan't to continue?", abort=True) 244 | 245 | data_path = click.prompt( 246 | "Path for storing server data", 247 | default=f"{str(Path.home())}/wilfred-data/servers", 248 | ) 249 | 250 | config.write(data_path) 251 | 252 | 253 | @cli.command("servers") 254 | def servers_list(): 255 | """List all existing servers.""" 256 | 257 | # run sync to refresh server state 258 | servers.sync() 259 | 260 | data = servers.all() 261 | 262 | click.echo( 263 | pretty_list( 264 | data, 265 | tablefmt="plain" if get_terminal_size((80, 20))[0] < 96 else "fancy_grid", 266 | ) 267 | ) 268 | 269 | 270 | @cli.command("images") 271 | @click.option("--refresh", help="Download the default images from GitHub", is_flag=True) 272 | @click.option( 273 | "--repo", 274 | help="Specify repo to fetch images from during image refresh", 275 | default="wilfred-dev/images", 276 | show_default=True, 277 | ) 278 | @click.option( 279 | "--branch", 280 | help="Specify branch to fetch images from during image refresh", 281 | default="master", 282 | show_default=True, 283 | ) 284 | def list_images(refresh, repo, branch): 285 | """List images available on file.""" 286 | 287 | if refresh: 288 | with Halo( 289 | text=f"Refreshing images [{repo}/{branch}]", color="yellow", spinner="dots" 290 | ) as spinner: 291 | try: 292 | images.download(repo=repo, branch=branch) 293 | images.read_images() 294 | except Exception as e: 295 | spinner.fail() 296 | ui_exception(e) 297 | 298 | spinner.succeed(f"Images refreshed [{repo}/{branch}]") 299 | 300 | click.echo( 301 | tabulate( 302 | images.data_strip_non_ui(), 303 | headers={ 304 | "uid": click.style("UID", bold=True), 305 | "name": click.style("Image Name", bold=True), 306 | "author": click.style("Author", bold=True), 307 | "default_image": click.style("Default Image", bold=True), 308 | }, 309 | tablefmt="fancy_grid", 310 | ) 311 | ) 312 | 313 | updated_at = click.style( 314 | images.image_fetch_date.strftime("%Y-%m-%d %H:%M:%S"), bold=True 315 | ) 316 | will_refresh_in = click.style( 317 | str(images.image_time_to_refresh).split(".")[0], bold=True 318 | ) 319 | 320 | click.echo( 321 | f"Default images last updated at {updated_at}, will refresh in {will_refresh_in}" 322 | ) 323 | 324 | 325 | @cli.command() 326 | @click.option( 327 | "--console", 328 | help="Attach to server console immediately after creation.", 329 | is_flag=True, 330 | ) 331 | @click.option( 332 | "--detach", 333 | help="Immediately detach during install.", 334 | is_flag=True, 335 | ) 336 | @click.pass_context 337 | @configuration_present 338 | def create(ctx, console, detach): 339 | """Create a new server.""" 340 | 341 | name = click.prompt("Server Name").lower() 342 | 343 | if " " in name: 344 | error("space not allowed in name", exit_code=1) 345 | 346 | click.secho("Available Images", bold=True) 347 | click.echo( 348 | tabulate( 349 | images.data_strip_non_ui(), 350 | headers={ 351 | "uid": click.style("UID", bold=True), 352 | "name": click.style("Image Name", bold=True), 353 | "author": click.style("Author", bold=True), 354 | "default_image": click.style("Default Image", bold=True), 355 | }, 356 | tablefmt="fancy_grid", 357 | ) 358 | ) 359 | 360 | image_uid = click.prompt("Image UID", default="minecraft-vanilla") 361 | 362 | if " " in image_uid: 363 | error("space not allowed in image_uid", exit_code=1) 364 | 365 | if not images.get_image(image_uid): 366 | error("image does not exist", exit_code=1) 367 | 368 | port = click.prompt("Port", default=images.get_image(image_uid)["default_port"]) 369 | memory = click.prompt("Memory", default=1024) 370 | 371 | # create 372 | server = Server(id=random_string()) 373 | 374 | try: 375 | server.name = name 376 | except ValueError as e: 377 | error(str(e), exit_code=1) 378 | 379 | server.image_uid = image_uid 380 | server.memory = memory 381 | server.port = port 382 | server.custom_startup = None 383 | server.status = "installing" 384 | 385 | session.add(server) 386 | 387 | try: 388 | session.commit() 389 | except IntegrityError as e: 390 | error(f"unable to create server {click.style(str(e), bold=True)}", exit_code=1) 391 | 392 | click.secho("Environment Variables", bold=True) 393 | 394 | # environment variables available for the container 395 | for v in images.get_image(image_uid)["variables"]: 396 | if not v["hidden"]: 397 | value = click.prompt( 398 | v["prompt"], default=v["default"] if v["default"] is not True else None 399 | ) 400 | 401 | if v["hidden"]: 402 | value = v["default"] 403 | 404 | variable = EnvironmentVariable( 405 | server_id=server.id, variable=v["variable"], value=value 406 | ) 407 | session.add(variable) 408 | 409 | try: 410 | session.commit() 411 | except IntegrityError as e: 412 | error( 413 | f"unable to create variables {click.style(str(e), bold=True)}", exit_code=1 414 | ) 415 | 416 | # custom startup command 417 | if click.confirm("Would you like to set a custom startup command (optional)?"): 418 | custom_startup = click.prompt( 419 | "Custom startup command", default=images.get_image(image_uid)["command"] 420 | ) 421 | 422 | server.custom_startup = custom_startup 423 | try: 424 | session.commit() 425 | except IntegrityError as e: 426 | error( 427 | f"unable to set startup command {click.style(str(e), bold=True)}", 428 | exit_code=1, 429 | ) 430 | 431 | with Halo(text="Creating server", color="yellow", spinner="dots") as spinner: 432 | try: 433 | servers.install( 434 | server, skip_wait=True if detach else False, spinner=spinner 435 | ) 436 | except Exception as e: 437 | spinner.fail() 438 | ui_exception(e) 439 | spinner.succeed("Server created") 440 | 441 | if console: 442 | ctx.invoke(start, name=name) 443 | ctx.invoke(server_console, name=name) 444 | 445 | 446 | @cli.command("sync") 447 | @configuration_present 448 | def sync_cmd(): 449 | """ 450 | Sync all servers on file with Docker (start/stop/kill). 451 | """ 452 | 453 | with Halo(text="Docker sync", color="yellow", spinner="dots") as spinner: 454 | try: 455 | servers.sync() 456 | except Exception as e: 457 | spinner.fail() 458 | ui_exception(e) 459 | spinner.succeed("Servers synced") 460 | 461 | 462 | @cli.command(short_help="Start server") 463 | @click.argument("name") 464 | @click.option( 465 | "--console", help="Attach to server console immediately after start.", is_flag=True 466 | ) 467 | @click.pass_context 468 | @configuration_present 469 | def start(ctx, name, console): 470 | """ 471 | Start server 472 | 473 | NAME is the name of the server 474 | """ 475 | 476 | try: 477 | servers.sync() 478 | except Exception as e: 479 | ui_exception(e) 480 | 481 | with Halo(text="Starting server", color="yellow", spinner="dots") as spinner: 482 | server = session.query(Server).filter_by(name=name.lower()).first() 483 | 484 | if not server: 485 | spinner.fail("Server does not exit") 486 | sys.exit(1) 487 | 488 | if server.status == "installing": 489 | spinner.fail("Server is installing, start blocked.") 490 | sys.exit(1) 491 | 492 | image = images.get_image(server.image_uid) 493 | 494 | if not image: 495 | error("Image UID does not exit", exit_code=1) 496 | 497 | try: 498 | ServerConfig( 499 | config.configuration, servers, server, image 500 | ).write_environment_variables() 501 | except Exception as e: 502 | ui_exception(e) 503 | 504 | try: 505 | servers.set_status(server, "running") 506 | servers.sync() 507 | except Exception as e: 508 | spinner.fail() 509 | ui_exception(e) 510 | 511 | spinner.succeed("Server started") 512 | 513 | if console: 514 | ctx.invoke(server_console, name=name) 515 | 516 | 517 | @cli.command(short_help="Forcefully kill running server") 518 | @click.argument("name") 519 | @click.option("-f", "--force", is_flag=True, help="Force action without confirmation") 520 | @configuration_present 521 | def kill(name, force): 522 | """ 523 | Forcefully kill running server 524 | 525 | NAME is the name of the server 526 | """ 527 | 528 | if force or click.confirm( 529 | "Are you sure you want to do this? This will kill the running container without saving data." 530 | ): 531 | with Halo(text="Killing server", color="yellow", spinner="dots") as spinner: 532 | server = session.query(Server).filter_by(name=name.lower()).first() 533 | 534 | if not server: 535 | spinner.fail("Server does not exit") 536 | sys.exit(1) 537 | 538 | try: 539 | servers.kill(server) 540 | servers.set_status(server, "stopped") 541 | servers.sync() 542 | except Exception as e: 543 | spinner.fail() 544 | ui_exception(e) 545 | 546 | spinner.succeed("Server killed") 547 | 548 | 549 | @cli.command(short_help="Stop server gracefully") 550 | @click.argument("name") 551 | @configuration_present 552 | def stop(name): 553 | """ 554 | Stop server gracefully. 555 | 556 | NAME is the name of the server 557 | """ 558 | 559 | try: 560 | servers.sync() 561 | except Exception as e: 562 | ui_exception(e) 563 | 564 | with Halo(text="Stopping server", color="yellow", spinner="dots") as spinner: 565 | server = session.query(Server).filter_by(name=name.lower()).first() 566 | 567 | if not server: 568 | spinner.fail("Server does not exit") 569 | sys.exit(1) 570 | 571 | if server.status == "installing": 572 | spinner.fail( 573 | " ".join( 574 | ( 575 | "Server is installing, you cannot gracefully stop it.", 576 | "Use `wilfred kill` if the installation process has hanged.", 577 | ) 578 | ) 579 | ) 580 | sys.exit(1) 581 | 582 | try: 583 | servers.set_status(server, "stopped") 584 | servers.sync() 585 | except Exception as e: 586 | spinner.fail() 587 | ui_exception(e) 588 | 589 | spinner.succeed("Server stopped") 590 | 591 | 592 | @cli.command(short_help="Restart server") 593 | @click.argument("name") 594 | @click.option( 595 | "--console", help="Attach to server console immediately after start.", is_flag=True 596 | ) 597 | @click.pass_context 598 | @configuration_present 599 | def restart(ctx, name, console): 600 | """ 601 | Restart server 602 | 603 | NAME is the name of the server 604 | """ 605 | 606 | ctx.invoke(stop, name=name) 607 | ctx.invoke(start, name=name) 608 | 609 | if console: 610 | ctx.invoke(server_console, name=name) 611 | 612 | 613 | @cli.command() 614 | @click.argument("name") 615 | @click.option("-f", "--force", is_flag=True, help="Force action without confirmation") 616 | @configuration_present 617 | def delete(name, force): 618 | """ 619 | Delete existing server. 620 | 621 | NAME is the name of the server 622 | """ 623 | 624 | if force or click.confirm( 625 | "Are you sure you want to do this? All data will be permanently deleted." 626 | ): 627 | with Halo(text="Deleting server", color="yellow", spinner="dots") as spinner: 628 | server = session.query(Server).filter_by(name=name.lower()).first() 629 | 630 | if not server: 631 | spinner.fail("Server does not exit") 632 | sys.exit(1) 633 | 634 | try: 635 | servers.remove(server) 636 | spinner.succeed("Server removed") 637 | except Exception as e: 638 | spinner.fail() 639 | ui_exception(e) 640 | 641 | 642 | @cli.command("command", short_help="Send command to STDIN of server") 643 | @click.argument("name") 644 | @click.argument("command") 645 | @configuration_present 646 | def run_command(name, command): 647 | """ 648 | Send command to STDIN of server 649 | 650 | \b 651 | NAME is the name of the server 652 | COMMAND is the command to send, can be put in \" for commands with whitespaces 653 | """ 654 | 655 | server = session.query(Server).filter_by(name=name.lower()).first() 656 | 657 | if not server: 658 | error("Server does not exit", exit_code=1) 659 | 660 | try: 661 | servers.command(server, command) 662 | except Exception as e: 663 | ui_exception(e) 664 | 665 | 666 | @cli.command( 667 | "console", 668 | short_help="\n\n".join( 669 | ( 670 | " ".join( 671 | ( 672 | "Attach current terminal to the console of a specific server (STDIN of running process).", 673 | "This allows you to view the current log and send commands.", 674 | ) 675 | ), 676 | "Use CTRL+C to exit the console, the server will continue to run in the background.", 677 | ) 678 | ), 679 | ) 680 | @click.argument("name") 681 | @configuration_present 682 | def server_console(name): 683 | """ 684 | Attach to server console, view log and run commands 685 | 686 | NAME is the name of the server 687 | """ 688 | 689 | server = session.query(Server).filter_by(name=name.lower()).first() 690 | 691 | if not server: 692 | error("Server does not exit", exit_code=1) 693 | 694 | click.secho( 695 | " ".join( 696 | ( 697 | f"Viewing server console of {name} (id {server.id})", 698 | f"{'- input disabled, installing' if server.status == 'installing' else ''}", 699 | ) 700 | ), 701 | bold=True, 702 | ) 703 | 704 | try: 705 | servers.console( 706 | server, disable_user_input=True if server.status == "installing" else False 707 | ) 708 | except Exception as e: 709 | ui_exception(e) 710 | 711 | 712 | @cli.command( 713 | short_help=" ".join( 714 | ( 715 | "Edit existing server (name, memory, port, environment variables and the custom startup command).", 716 | "Restart server after update for changes to take effect.", 717 | ) 718 | ) 719 | ) 720 | @click.argument("name") 721 | @configuration_present 722 | def edit(name): 723 | """ 724 | Edit server (name, memory, port, environment variables) 725 | 726 | NAME is the name of the server 727 | """ 728 | 729 | server = session.query(Server).filter_by(name=name.lower()).first() 730 | 731 | if not server: 732 | error("Server does not exist", exit_code=1) 733 | 734 | click.echo( 735 | pretty_list( 736 | [ 737 | { 738 | c.key: getattr(server, c.key) 739 | for c in inspect(server).mapper.column_attrs 740 | } 741 | ], 742 | tablefmt="plain" if get_terminal_size((80, 20))[0] < 96 else "fancy_grid", 743 | ) 744 | ) 745 | click.echo("Leave values empty to use existing value") 746 | 747 | name = click.prompt("Name", default=server.name).lower() 748 | 749 | if " " in name: 750 | error("space not allowed in name", exit_code=1) 751 | 752 | port = click.prompt("Port", default=server.port) 753 | memory = click.prompt("Memory", default=server.memory) 754 | 755 | if not is_integer(port) or not is_integer(memory): 756 | error("port/memory must be integer", exit_code=1) 757 | 758 | click.secho("Environment Variables", bold=True) 759 | 760 | for v in images.get_image(server.image_uid)["variables"]: 761 | if v["install_only"]: 762 | continue 763 | 764 | current_variable = ( 765 | session.query(EnvironmentVariable) 766 | .filter_by(server_id=server.id) 767 | .filter_by(variable=v["variable"]) 768 | .first() 769 | ) 770 | 771 | if not current_variable: 772 | continue 773 | 774 | if not v["hidden"]: 775 | value = click.prompt(v["prompt"], default=current_variable.value) 776 | 777 | if v["hidden"]: 778 | value = v["default"] 779 | 780 | current_variable.value = value 781 | 782 | try: 783 | session.commit() 784 | except IntegrityError as e: 785 | error( 786 | f"unable to edit variables {click.style(str(e), bold=True)}", 787 | exit_code=1, 788 | ) 789 | 790 | server.custom_startup = ( 791 | None if server.custom_startup == "None" else server.custom_startup 792 | ) 793 | custom_startup = None 794 | 795 | if server.custom_startup is not None: 796 | custom_startup = click.prompt( 797 | "Custom startup command (use 'None' to reset to default)", 798 | default=server.custom_startup, 799 | ) 800 | 801 | if server.custom_startup is None: 802 | if click.confirm("Would you like to set a custom startup command?"): 803 | custom_startup = click.prompt( 804 | "Custom startup command (use 'None' to reset to default)", 805 | default=images.get_image(server.image_uid)["command"], 806 | ) 807 | 808 | custom_startup = None if custom_startup == "None" else custom_startup 809 | 810 | if name != server.name: 811 | try: 812 | servers.rename(server, name) 813 | except Exception as e: 814 | ui_exception(e) 815 | 816 | server.port = port 817 | server.memory = memory 818 | 819 | if custom_startup: 820 | server.custom_startup = custom_startup 821 | 822 | try: 823 | session.commit() 824 | except IntegrityError as e: 825 | error(f"unable to edit server {click.style(str(e), bold=True)}", exit_code=1) 826 | 827 | click.echo( 828 | "✅ Server information updated, restart server for changes to take effect" 829 | ) 830 | 831 | 832 | @cli.command( 833 | short_help=" ".join(("Show server statistics, CPU load, memory load etc.",)) 834 | ) 835 | def top(): 836 | while True: 837 | # retrieve data (this can take a split moment) 838 | data = servers.all(cpu_load=True, memory_usage=True) 839 | 840 | for server in data: 841 | server.update( 842 | ( 843 | k, 844 | str(v) 845 | .replace("running", click.style("running", fg="green")) 846 | .replace("stopped", click.style("stopped", fg="red")) 847 | .replace("installing", click.style("installing", fg="yellow")), 848 | ) 849 | for k, v in server.items() 850 | ) 851 | 852 | # clear the screen 853 | click.clear() 854 | 855 | headers = { 856 | "id": click.style("ID", bold=True), 857 | "name": click.style("Name", bold=True), 858 | "image_uid": click.style("Image UID", bold=True), 859 | "memory": click.style("RAM", bold=True), 860 | "port": click.style("Port", bold=True), 861 | "status": click.style("Status", bold=True), 862 | "custom_startup": click.style("Custom startup", bold=True), 863 | "cpu_load": click.style("CPU", bold=True), 864 | "memory_usage": click.style("MEM usage / MEM %", bold=True), 865 | } 866 | 867 | # display table 868 | click.echo( 869 | tabulate( 870 | data, 871 | headers=headers, 872 | tablefmt="plain", 873 | ) 874 | ) 875 | 876 | # cooldown before repeating 877 | sleep(1) 878 | 879 | 880 | @cli.command( 881 | "config", 882 | short_help="".join( 883 | ( 884 | "View and edit configuration variables (e.g. the settings in `server.properties` for Minecraft).", 885 | ) 886 | ), 887 | ) 888 | @click.argument("name") 889 | @click.argument("variable", required=False) 890 | @click.argument("value", required=False) 891 | def config_command(name, variable, value): 892 | """ 893 | Manage server configuration (for supported filetypes) 894 | 895 | \b 896 | NAME is the name of the server 897 | VARIABLE is the name of an available setting 898 | VALUE is the new value for the variable setting 899 | """ 900 | 901 | def _get(): 902 | return ServerConfig(config.configuration, servers, server, image) 903 | 904 | def _print_all_values(variable, config_list): 905 | for var in config_list: 906 | click.echo( 907 | f"{click.style(var['_wilfred_config_filename'], bold=True)} {variable}: '{var[variable]}'" 908 | ) 909 | 910 | def _get_variable_occurrences(variable, raw): 911 | _variable_occurrences = [] 912 | for x in raw: 913 | if variable in x: 914 | _variable_occurrences.append(x) 915 | 916 | return _variable_occurrences 917 | 918 | server = session.query(Server).filter_by(name=name.lower()).first() 919 | 920 | if not server: 921 | error("Server does not exist", exit_code=1) 922 | 923 | image = images.get_image(server.image_uid) 924 | 925 | if not image: 926 | error("Image UID does not exit", exit_code=1) 927 | 928 | server_conf = _get() 929 | 930 | _variable_occurrences = _get_variable_occurrences(variable, server_conf.raw) 931 | 932 | if variable and len(_variable_occurrences) == 0: 933 | error("variable does not exist", exit_code=1) 934 | 935 | if variable and len(_variable_occurrences) > 1: 936 | click.echo("This variable exists in multiple configuration files.") 937 | 938 | if variable and not value: 939 | _print_all_values(variable, _variable_occurrences) 940 | exit(0) 941 | 942 | if variable and value: 943 | user_selection = None 944 | if len(_variable_occurrences) > 1: 945 | for i in range(len(_variable_occurrences)): 946 | click.echo( 947 | f"[{i}] - {_variable_occurrences[i]['_wilfred_config_filename']}" 948 | ) 949 | 950 | user_selection = click.prompt( 951 | "In which file would like to modify this setting?", default=0 952 | ) 953 | 954 | if ( 955 | int(user_selection) >= len(_variable_occurrences) 956 | or int(user_selection) < 0 957 | ): 958 | error( 959 | "integer is not valid, please pick one from the list", exit_code=1 960 | ) 961 | 962 | filename = _variable_occurrences[user_selection if user_selection else 0][ 963 | "_wilfred_config_filename" 964 | ] 965 | 966 | try: 967 | server_conf.edit(filename, variable, value) 968 | server_conf = _get() 969 | _print_all_values( 970 | variable, _get_variable_occurrences(variable, server_conf.raw) 971 | ) 972 | except Exception as e: 973 | ui_exception(e) 974 | 975 | exit(0) 976 | 977 | click.echo(server_conf.pretty()) 978 | 979 | 980 | @cli.command( 981 | "port", 982 | short_help="".join(("Manage additional ports for a server.",)), 983 | ) 984 | @click.argument("name") 985 | @click.argument("action", required=False) 986 | @click.argument("port", required=False) 987 | def port_command(name, action, port): 988 | """ 989 | Manage additional ports for a server. 990 | 991 | \b 992 | NAME is the name of the server 993 | ACTION is the action, either "remove" or "add" 994 | PORT is the port to add or remove 995 | """ 996 | 997 | server = session.query(Server).filter_by(name=name.lower()).first() 998 | 999 | if not server: 1000 | error("Server does not exist", exit_code=1) 1001 | 1002 | if action == "add": 1003 | if not is_integer(port): 1004 | error("Port must be integer", exit_code=1) 1005 | 1006 | if len(session.query(Server).filter_by(port=port).all()) != 0: 1007 | error("Port is already occupied by a server", exit_code=1) 1008 | 1009 | # create 1010 | additional_port = Port(server_id=server.id, port=port) 1011 | session.add(additional_port) 1012 | 1013 | try: 1014 | session.commit() 1015 | except IntegrityError as e: 1016 | error( 1017 | f"unable to create port {click.style(str(e), bold=True)}", exit_code=1 1018 | ) 1019 | 1020 | if action == "remove": 1021 | additional_port = ( 1022 | session.query(Port).filter_by(port=port, server_id=server.id).first() 1023 | ) 1024 | 1025 | if not additional_port: 1026 | error("Port not found", exit_code=1) 1027 | 1028 | session.delete(additional_port) 1029 | session.commit() 1030 | 1031 | # display ports 1032 | ports = [ 1033 | {"port": u.port} 1034 | for u in session.query(Port).filter_by(server_id=server.id).all() 1035 | ] 1036 | click.echo(f"Additional ports for server {server.name}") 1037 | click.echo( 1038 | tabulate( 1039 | ports, 1040 | headers={ 1041 | "port": click.style("Port", bold=True), 1042 | }, 1043 | tablefmt="fancy_grid", 1044 | ) 1045 | ) 1046 | 1047 | 1048 | if __name__ == "__main__": 1049 | main() 1050 | --------------------------------------------------------------------------------