├── .coveragerc ├── .flake8 ├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── Makefile ├── make.bat ├── requirements.txt └── source │ ├── _static │ └── avatar.jpeg │ ├── conf.py │ ├── index.md │ └── user_guide.md ├── mypy.ini ├── renovate.json ├── setup.py ├── src └── pyscript │ ├── __init__.py │ ├── __main__.py │ ├── _generator.py │ ├── cli.py │ ├── plugins │ ├── __init__.py │ ├── create.py │ ├── hookspecs.py │ └── run.py │ ├── templates │ └── basic.html │ └── version └── tests ├── conftest.py ├── test_cli.py ├── test_generator.py ├── test_run_cli_cmd.py └── utils.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | __main__.py 4 | exclude_lines = 5 | pragma: no cover 6 | # This covers both typing.TYPE_CHECKING and plain TYPE_CHECKING, with any amount of whitespace 7 | if\s+(typing\.)?TYPE_CHECKING: 8 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | ignore = E203, W503 4 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release to PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | environment: 12 | name: pypi 13 | url: https://pypi.org/project/pyscript-cli 14 | permissions: 15 | id-token: write 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v2 19 | 20 | - name: Set up Python 21 | uses: actions/setup-python@v2 22 | with: 23 | python-version: 3.11 24 | 25 | - name: Install build tools 26 | run: | 27 | pip install --upgrade build 28 | 29 | - name: Build and package 30 | env: 31 | CHECK_VERSION: 'true' 32 | run: | 33 | python -m build 34 | 35 | - name: Upload to PyPI 36 | uses: pypa/gh-action-pypi-publish@release/v1 37 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | os: [ubuntu-latest, macos-latest] 17 | python-version: ['3.9', '3.10', '3.11'] 18 | runs-on: ${{ matrix.os }} 19 | env: 20 | OS: ${{ matrix.os }} 21 | PYTHON: ${{ matrix.python-version }} 22 | steps: 23 | - name: Check out repository 24 | uses: actions/checkout@v3 25 | - name: Set up python ${{ matrix.python-version }} 26 | uses: actions/setup-python@bd6b4b6205c4dbad673328db7b31b7fab9e241c0 # v4 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | - name: Install dependencies 30 | run: | 31 | pip install -U pip 32 | pip install '.[dev]' 33 | - name: Type check with mypy 34 | run: mypy . 35 | - name: Test with pytest 36 | run: | 37 | coverage run -m pytest tests 38 | coverage xml 39 | - uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # v3 40 | with: 41 | files: ./coverage.xml 42 | env_vars: OS,PYTHON 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # Distribution / packaging 6 | dist/ 7 | 8 | # Sphinx documentation 9 | docs/_build/ 10 | 11 | # Environments 12 | .venv 13 | env/ 14 | venv/ 15 | 16 | # Poetry lock file 17 | poetry.lock 18 | 19 | # Unit test / coverage reports 20 | htmlcov/ 21 | .coverage 22 | .coverage.* 23 | .cache 24 | coverage.xml 25 | .pytest_cache/ 26 | 27 | # mypy 28 | .mypy_cache/ 29 | 30 | # Generated documentation 31 | /docs/build/ 32 | /docs/source/api/ 33 | 34 | pyscript.egg-info/ 35 | build/ 36 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # This is the configuration for pre-commit, a local framework for managing pre-commit hooks 2 | # Check out the docs at: https://pre-commit.com/ 3 | ci: 4 | #skip: [eslint] 5 | autoupdate_schedule: monthly 6 | 7 | default_stages: [pre-commit] 8 | repos: 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v5.0.0 11 | hooks: 12 | - id: end-of-file-fixer 13 | - id: trailing-whitespace 14 | - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks 15 | rev: v2.14.0 16 | hooks: 17 | - id: pretty-format-yaml 18 | args: [--autofix, --indent, '4'] 19 | - id: pretty-format-toml 20 | args: [--autofix] 21 | - repo: https://github.com/asottile/pyupgrade 22 | rev: v3.19.1 23 | hooks: 24 | - id: pyupgrade 25 | - repo: https://github.com/pycqa/isort 26 | rev: 6.0.1 27 | hooks: 28 | - id: isort 29 | name: isort (python) 30 | args: [--profile, black] 31 | - repo: https://github.com/psf/black 32 | rev: 25.1.0 33 | hooks: 34 | - id: black 35 | - repo: https://github.com/pycqa/flake8 36 | rev: 7.1.2 37 | hooks: 38 | - id: flake8 39 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to the PyScript CLI will be documented in this file, with the latest release at the top. 4 | 5 | ## [0.3.0] - 2023-03-06 6 | 7 | *A lot has changed between 0.2.5 and 0.3.6. This is a summary of the most important changes.* 8 | 9 | ### Features 10 | 11 | - New command `run` to run a local server which serves the current directory. 12 | - Added the right CORS headers to the server running locally 13 | - Added no-cache headers to the server running locally to avoid caching issues while developing 14 | 15 | 16 | ## Improvements 17 | 18 | - Allow users to specify an empty author name, email and app description when running `pyscript create` 19 | - Merged `wrap` command into `create` command, so now `pyscript create` can create a new project or wrap an existing python file into a new pyscript project. 20 | - The `pyscript create` command now prompts for a name if the name is not provided as an argument. 21 | - You can now pass a single Python file to `pyscript create` and it will create a new project with the file's contents in the new `main.py` file - similar to using the `--wrap` option. 22 | 23 | ### Documentation 24 | 25 | - Various improvements to the `README.md` files 26 | - Added `CONTRIBUTING.md` file with information on how to contribute to the project and installing the development environment. 27 | - Added `CHANGELOG.md` file to keep track of the changes in the project. 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guide for developers 2 | 3 | ## Developer setup 4 | 5 | Git clone the repository: 6 | 7 | ```shell 8 | git clone https://github.com/pyscript/pyscript.git 9 | ``` 10 | 11 | (Recommended) Upgrade local pip: 12 | 13 | ```shell 14 | pip install --upgrade pip 15 | ``` 16 | 17 | Create a local environment with your environment manager of choice. 18 | 19 | ### Virtualenv 20 | 21 | In case you choose Virtualenv, make a virtualenv and activate it using the following commands: 22 | 23 | ```shell 24 | python -m venv .venv 25 | source .venv/bin/activate 26 | ``` 27 | 28 | ### Conda 29 | 30 | In case you choose to use conda, use the following commands: 31 | 32 | ```shell 33 | conda create -n pyscript-cli python=3.13 34 | conda activate pyscript-cli 35 | ``` 36 | 37 | ### Installation 38 | 39 | Now that you have your environment set up and activated, install your local environment dependencies 40 | 41 | ```shell 42 | pip install -e ".[dev]" 43 | ``` 44 | 45 | ## Use the CLI 46 | 47 | It is now possible to normally use the CLI. For more information on how to use it and it's commands, see the [Use the CLI section of the README](README.md) 48 | 49 | ## Run the tests 50 | 51 | After setting up your developer environment, you can run the tests with the following command from the root directory: 52 | 53 | ```shell 54 | pytest . 55 | ``` 56 | 57 | # Running CLI Commands 58 | 59 | Once the installation process is done, the `pyscript` CLI is available to be used once the environment has been 60 | activated. Simply run `pyscript` with the appropriate command. For instance, to see the list of commands: 61 | 62 | ```shell 63 | >> pyscript --help 64 | 65 | Usage: pyscript [OPTIONS] COMMAND [ARGS]... 66 | 67 | Command Line Interface for PyScript. 68 | 69 | ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ 70 | │ --version Show project version and exit. │ 71 | │ --help Show this message and exit. │ 72 | ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ 73 | ╭─ Commands ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ 74 | │ create Create a new pyscript project with the passed in name, creating a new directory in the current directory. Alternatively, use `--wrap` so as to embed a │ 75 | │ python file instead. │ 76 | │ run Creates a local server to run the app on the path and port specified. │ 77 | ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ 78 | ``` 79 | 80 | or, to run a pyscript app: 81 | 82 | ```shell 83 | >> pyscript run 84 | Serving from /pyscript-example at port 8000. To stop, press Ctrl+C. 85 | 127.0.0.1 - - [30/Apr/2025 17:01:03] "GET / HTTP/1.1" 200 - 86 | ``` 87 | 88 | ## Documentation 89 | 90 | ### Install the documentation dependencies 91 | 92 | To get started, you will need to install the documentation dependencies from the project root: 93 | 94 | ```shell 95 | pip install -e ".[docs]" 96 | ``` 97 | 98 | ### Generate the docs in live mode 99 | 100 | The live mode will allow you to generate the documentation with live reload. 101 | 102 | From the project root, run the following command : 103 | 104 | ```shell 105 | make -C docs live 106 | ``` 107 | 108 | Or, alternately, navigate to the `docs` directory and run: 109 | 110 | ```shell 111 | make live 112 | ``` 113 | 114 | Either of the above commands should launch a live dev server and you will be able to view the 115 | docs in your browser. 116 | As the files are updated, the docs should be refreshed. 117 | 118 | ### Generate static docs 119 | 120 | If you don't want to use the live reload mode, simply replace either command above with `html`, 121 | e.g.: 122 | 123 | ```shell 124 | make -C docs html 125 | ``` 126 | 127 | 128 | ## Creating a New Release 129 | 130 | To create a new release of pyscript-cli, follow these steps: 131 | 132 | 1. Update the version number in `src/pyscript/version` 133 | 134 | 2. Update CHANGELOG.md with the changes since the last release 135 | 136 | 3. Create a new git tag matching the version number: 137 | ```shell 138 | git tag X.Y.Z 139 | ``` 140 | 141 | 4. Push the tag to GitHub: 142 | ```shell 143 | git push origin X.Y.Z 144 | ``` 145 | 146 | 5. The GitHub Actions workflow will automatically: 147 | - Verify the tag matches the version in `src/pyscript/version` 148 | - Run tests 149 | - Build and publish the package to PyPI 150 | - Create a GitHub release 151 | 152 | 6. Verify the new version is available on PyPI: https://pypi.org/project/pyscript-cli/ 153 | 154 | Note: Make sure all tests pass locally before creating a new release. The release workflow will fail if there are any test failures or version mismatches. 155 | 156 | Note 2: The version number in `src/pyscript/version` and the tag pushed to git (`X.Y.Z` in the example above) MUST MATCH! If they don't match the, the 157 | action to create and publish the release won't start. 158 | 159 | 160 | ### How the Release Process Works 161 | 162 | The release process is automated through GitHub Actions workflows. Here's what happens behind the scenes: 163 | 164 | 1. When a new tag is pushed, it triggers the release workflow 165 | 2. The workflow first checks that: 166 | - The tag name matches the version in `src/pyscript/version` 167 | - All tests pass successfully 168 | 169 | 3. If checks pass, the workflow: 170 | - Builds the Python package using setuptools 171 | - Creates source and wheel distributions 172 | - Uploads the distributions to PyPI using twine 173 | - Creates a GitHub release with the tag name 174 | 175 | 4. The version check is performed by the `check_tag_version()` function in setup.py, which: 176 | - Reads the version from `src/pyscript/version` 177 | - Compares it to the git tag that triggered the workflow 178 | - Fails if they don't match exactly 179 | 180 | 5. The PyPI upload uses credentials stored as GitHub repository secrets 181 | 182 | This automated process ensures consistent and reliable releases while preventing common issues like version mismatches or failed tests from being published. 183 | 184 | NOTE: If you wanna build locally, run `CHECK_VERSION=False python -m build`. This will skip the check tag version conditions defined in `setup.py`, allowing 185 | to create the wheel locally, without having a tag with a version matching the `src/pyscript/version` file. 186 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include src/pyscript/version 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PyScript CLI 2 | 3 | A command-line interface for [PyScript](https://pyscript.net). 4 | 5 | 6 | [![Version](https://img.shields.io/pypi/v/pyscript.svg)](https://pypi.org/project/pyscript/) 7 | [![Test](https://github.com/pyscript/pyscript-cli/actions/workflows/test.yml/badge.svg)](https://github.com/pyscript/pyscript-cli/actions/workflows/test.yml) 8 | [![codecov](https://codecov.io/gh/pyscript/pyscript-cli/branch/main/graph/badge.svg?token=dCxt9oBQPL)](https://codecov.io/gh/pyscript/pyscript-cli) 9 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/pyscript/pyscript-cli/main.svg)](https://results.pre-commit.ci/latest/github/pyscript/pyscript-cli/main) 10 | [![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/) 11 | 12 | Quickly wrap Python scripts into a HTML template, pre-configured with [PyScript](https://pyscript.net). 13 | 14 | ```bash 15 | ❯ pyscript 16 | 17 | Usage: pyscript [OPTIONS] COMMAND [ARGS]... 18 | 19 | Command Line Interface for PyScript. 20 | 21 | ╭─ Options ──────────────────────────────────────────────────────────────────────────────────────╮ 22 | │ --version Show project version and exit. │ 23 | │ --help Show this message and exit. │ 24 | ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ 25 | ╭─ Commands ─────────────────────────────────────────────────────────────────────────────────────╮ 26 | │ create Create a new pyscript project with the passed in name, creating a new directory in the │ 27 | │ current directory. Alternatively, use `--wrap` so as to embed a python file instead. │ 28 | │ run Creates a local server to run the app on the path and port specified. │ 29 | ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ 30 | 31 | ``` 32 | 33 | ## Installation 34 | 35 | ### Using Pip 36 | 37 | ```shell 38 | $ pip install pyscript 39 | ``` 40 | 41 | ### Installing the developer setup from the a repository clone 42 | 43 | 44 | [see the Developer setup section on CONTRIBUTING page](CONTRIBUTING.md) 45 | 46 | ## Usage 47 | 48 | ### run 49 | 50 | #### Spin up a local server to run on the path and specified port 51 | 52 | ```shell 53 | $ pyscript run 54 | ``` 55 | 56 | This will serve the folder `path_of_folder` at `localhost:8000` by default 57 | and will open the URL in a browser window. Default is current directory if 58 | `path_of_folder` is not supplied. 59 | 60 | To use a different port, use `--port` option. 61 | 62 | ```shell 63 | $ pyscript run --port 9000 64 | ``` 65 | 66 | To avoid opening a browser window, use `--no-view` option. 67 | 68 | ```shell 69 | $ pyscript run --no-view 70 | ``` 71 | 72 | ### create 73 | 74 | #### Create a new pyscript project with the passed in name, creating a new directory 75 | 76 | ```shell 77 | $ pyscript create 78 | ``` 79 | 80 | This will create a new directory named `name_of_app` under the current directory. 81 | 82 | The interactive prompts will further ask for information such as `description of the app`, 83 | `name of the author`, `email of the author`, etc. These of course can be provided via 84 | options such as `--author-name` etc. Use `pyscript create --help` for more information. 85 | 86 | The following files will be created: 87 | 88 | - `index.html`: start page for the project 89 | - `pyscript.toml`: project metadata and config file 90 | - `main.py`: a "Hello world" python starter module 91 | 92 | #### Use --wrap to embed a python file OR a command string 93 | 94 | - ##### Embed a Python script into a PyScript HTML file 95 | 96 | ```shell 97 | $ pyscript create --wrap 98 | ``` 99 | 100 | This will generate a project i.e. a new directory named `filename` under the current directory. 101 | 102 | Similar to the above, interactive prompts will further ask for metadata information. 103 | 104 | The following files will be created: 105 | 106 | - `index.html`: start page for the project 107 | - `pyscript.toml`: project metadata and config file 108 | - `main.py`: contains code of `filename.py` 109 | 110 | This can be overridden with the `-o` or `--output` option: 111 | 112 | ```shell 113 | $ pyscript create --wrap -o 114 | ``` 115 | 116 | i.e. the HTML file created in the above directory will now be named `another_filename.html` 117 | 118 | - ##### Very simple command examples with `--command` option 119 | 120 | The `-c` or `--command` option can be used to demo very simple cases. 121 | 122 | By default, the name of the project folder created will be `pyscript-command-app` with the HTML file named `index.html`. 123 | 124 | `-o/--output` option can be used with the `-c/--command` option to configure name of the project folder as well 125 | as the name of the resulting HTML file. 126 | 127 | ```shell 128 | $ pyscript create --wrap -c 'print("Hello World!")' -o 129 | ``` 130 | 131 | This will generate a project i.e. a new directory named `output_filename` under the current directory. 132 | 133 | Similar to the above, interactive prompts will further ask for metadata information. 134 | 135 | The following files will be created: 136 | 137 | - `output_filename.html`: start page for the project 138 | - `pyscript.toml`: project metadata and config file 139 | - `main.py`: contains code of the command string passed via `-c/--command` 140 | -------------------------------------------------------------------------------- /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 | # Internal variables. 12 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(SPHINXOPTS) source 13 | 14 | # Put it first so that "make" without argument is like "make help". 15 | help: 16 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 17 | 18 | .PHONY: help Makefile 19 | 20 | .PHONY: api-docs 21 | api-docs: 22 | sphinx-apidoc -f -o source/api ../src/pyscript -H "API Docs" 23 | 24 | .PHONY: html 25 | html: api-docs 26 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 27 | @echo 28 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 29 | 30 | .PHONY: live 31 | live: api-docs 32 | sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) --watch ../src --open-browser 33 | 34 | # Catch-all target: route all unknown targets to Sphinx using the new 35 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 36 | %: Makefile 37 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 38 | -------------------------------------------------------------------------------- /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 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 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/requirements.txt: -------------------------------------------------------------------------------- 1 | # Install the documentation dependencies 2 | # This is required because readthedocs needs requirements.txt 3 | ..[docs] 4 | -------------------------------------------------------------------------------- /docs/source/_static/avatar.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyscript/pyscript-cli/c088568091b487c152a05942ff77f08a630dafd6/docs/source/_static/avatar.jpeg -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | """Documentation configuration for `pyscript-cli`.""" 2 | 3 | from __future__ import annotations 4 | 5 | import sys 6 | from datetime import datetime 7 | from pathlib import Path 8 | 9 | import toml 10 | 11 | # Ensure the package is in the path 12 | project_root = Path(__file__).parents[2] 13 | sys.path.insert(0, (project_root / "src").as_posix()) 14 | 15 | # General information about the project. 16 | project = "pyscript-cli" 17 | author = "Anaconda" 18 | copyright = f"2022 - {datetime.now().year}, {author}" 19 | 20 | # Load the package version from pyproject.toml 21 | with (project_root / "pyproject.toml").open("r") as fp: 22 | version = toml.load(fp)["project"]["version"] 23 | release = version 24 | 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be 27 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 28 | # ones. 29 | extensions = [ 30 | "myst_parser", 31 | "sphinx.ext.doctest", 32 | "sphinx.ext.intersphinx", 33 | "sphinx.ext.coverage", 34 | "sphinx.ext.mathjax", 35 | "sphinx.ext.autodoc", 36 | "sphinx.ext.napoleon", 37 | "sphinx.ext.viewcode", 38 | "sphinx_autodoc_typehints", 39 | ] 40 | 41 | # Add any paths that contain templates here, relative to this directory. 42 | templates_path = ["_templates"] 43 | exclude_patterns: list[str] = [] 44 | 45 | # The suffix(es) of source filenames. 46 | source_suffix = [".rst", ".md"] 47 | 48 | # The master toctree document. 49 | master_doc = "index" 50 | 51 | # The name of the Pygments (syntax highlighting) style to use. 52 | pygments_style = "sphinx" 53 | 54 | autodoc_default_options = { 55 | "members": None, 56 | "undoc-members": None, 57 | "show-inheritance": None, 58 | } 59 | 60 | # The theme to use for HTML and HTML Help pages. 61 | html_theme = "pydata_sphinx_theme" 62 | # html_logo = "_static/avatar.jpeg" 63 | html_favicon = "_static/avatar.jpeg" 64 | html_theme_options = { 65 | "github_url": "https://github.com/pyscript/pyscript-cli", 66 | "icon_links": [ 67 | { 68 | "name": "PyPI", 69 | "url": "https://pypi.org/project/pyscript-cli", 70 | "icon": "fas fa-box", 71 | }, 72 | ], 73 | } 74 | html_static_path = ["_static"] 75 | -------------------------------------------------------------------------------- /docs/source/index.md: -------------------------------------------------------------------------------- 1 | ```{include} ../../README.md 2 | ``` 3 | 4 | ```{toctree} 5 | :maxdepth: 3 6 | :caption: Contents 7 | 8 | user_guide 9 | api/modules 10 | ``` 11 | -------------------------------------------------------------------------------- /docs/source/user_guide.md: -------------------------------------------------------------------------------- 1 | # User Guide 2 | 3 | ## Developing a new plugin 4 | 5 | Create a function, and then register it like below. 6 | 7 | ```python 8 | from pyscript import console, plugins 9 | 10 | 11 | def create(): 12 | """Creates a new PyScript Project from scratch.""" 13 | console.print("pyscript create cmd not yet available..", style="bold green") 14 | return True 15 | 16 | 17 | @plugins.register 18 | def pyscript_subcommand(): 19 | return create 20 | ``` 21 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.9 3 | files = **/*.py 4 | ignore_missing_imports = true 5 | exclude = build 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>anaconda/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | 6 | def read_version(): 7 | with open("src/pyscript/version") as f: 8 | return f.read().strip("\n") 9 | 10 | 11 | def check_tag_version(): 12 | if os.getenv("CHECK_VERSION", "false").lower() == "true": 13 | tag = os.getenv("GITHUB_REF") 14 | expected_version = read_version() 15 | if tag != f"refs/tags/{expected_version}": 16 | raise Exception( 17 | f"Tag '{tag}' does not match the expected " 18 | f"version '{expected_version}'" 19 | ) 20 | 21 | 22 | with open("README.md") as fh: 23 | long_description = fh.read() 24 | 25 | check_tag_version() 26 | 27 | setup( 28 | name="pyscript-cli", 29 | version=read_version(), 30 | description="Command Line Interface for PyScript", 31 | package_dir={"": "src"}, 32 | packages=find_packages(where="src"), 33 | package_data={"pyscript": ["templates/*.html"]}, 34 | long_description=long_description, 35 | long_description_content_type="text/markdown", 36 | url="https://github.com/pyscript/pyscript-cli", 37 | author="Matt Kramer, Fabio Pliger, Nicholas Tollervey, Fabio Rosado, Madhur Tandon", 38 | author_email=( 39 | "mkramer@anaconda.com, " 40 | "fpliger@anaconda.com, " 41 | "ntollervey@anaconda.com, " 42 | "frosado@anaconda.com, " 43 | "mtandon@anaconda.com" 44 | ), 45 | license="Apache-2.0", 46 | install_requires=[ 47 | 'importlib-metadata; python_version<"3.8"', 48 | "Jinja2<3.2", 49 | "pluggy==1.5.0", 50 | "rich<=13.7.1", 51 | "toml<0.11", 52 | "typer<=0.9.0", 53 | "platformdirs<4.3", 54 | "requests<=2.31.0", 55 | ], 56 | python_requires=">=3.9", 57 | keywords=["pyscript", "cli", "pyodide", "micropython", "pyscript-cli"], 58 | classifiers=[ 59 | "Development Status :: 4 - Beta", 60 | "Environment :: Console", 61 | "Intended Audience :: Developers", 62 | "License :: OSI Approved :: Apache Software License", 63 | "Programming Language :: Python :: 3", 64 | "Programming Language :: Python :: 3.9", 65 | "Programming Language :: Python :: 3.10", 66 | "Topic :: Software Development :: Code Generators", 67 | "Topic :: Software Development :: Libraries :: Python Modules", 68 | "Topic :: Software Development :: Pre-processors", 69 | ], 70 | extras_require={ 71 | "dev": [ 72 | "coverage<7.3", 73 | "mypy<=1.4.1", 74 | "pytest<7.5", 75 | "types-toml<0.11", 76 | "types-requests", 77 | ], 78 | "docs": [ 79 | "Sphinx<5.2", 80 | "sphinx-autobuild<2021.4.0", 81 | "sphinx-autodoc-typehints<1.20", 82 | "myst-parser<0.19.3", 83 | "pydata-sphinx-theme<0.13.4", 84 | ], 85 | }, 86 | entry_points={ 87 | "console_scripts": [ 88 | "pyscript = pyscript.cli:app", 89 | ], 90 | }, 91 | project_urls={ 92 | "Documentation": "https://docs.pyscript.net", 93 | "Examples": "https://pyscript.com/@examples", 94 | "Homepage": "https://pyscript.net", 95 | "Repository": "https://github.com/pyscript/pyscript-cli", 96 | }, 97 | zip_safe=False, 98 | ) 99 | -------------------------------------------------------------------------------- /src/pyscript/__init__.py: -------------------------------------------------------------------------------- 1 | """A CLI for PyScript!""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | import platformdirs 7 | import typer 8 | from rich.console import Console 9 | 10 | LATEST_PYSCRIPT_VERSION = "2024.2.1" 11 | APPNAME = "pyscript" 12 | APPAUTHOR = "python" 13 | DEFAULT_CONFIG_FILENAME = ".pyscriptconfig" 14 | 15 | # Default initial data for the command line. 16 | DEFAULT_CONFIG = { 17 | # Name of config file for PyScript projects. 18 | "project_config_filename": "pyscript.toml", 19 | "project_main_filename": "main.py", 20 | } 21 | 22 | 23 | DATA_DIR = Path(platformdirs.user_data_dir(appname=APPNAME, appauthor=APPAUTHOR)) 24 | CONFIG_FILE = DATA_DIR / Path(DEFAULT_CONFIG_FILENAME) 25 | if not CONFIG_FILE.is_file(): 26 | DATA_DIR.mkdir(parents=True, exist_ok=True) 27 | with CONFIG_FILE.open("w") as config_file: 28 | json.dump(DEFAULT_CONFIG, config_file) 29 | 30 | 31 | try: 32 | from importlib import metadata 33 | except ImportError: # pragma: no cover 34 | import importlib_metadata as metadata # type: ignore 35 | 36 | try: 37 | __version__ = metadata.version("pyscript") 38 | except metadata.PackageNotFoundError: # pragma: no cover 39 | __version__ = "unknown" 40 | 41 | 42 | console = Console() 43 | app = typer.Typer(add_completion=False) 44 | with CONFIG_FILE.open() as config_file: 45 | config = json.load(config_file) 46 | 47 | # Make sure all configuration keys are there. If any is missing, 48 | # we pick from the default config 49 | for k, v in DEFAULT_CONFIG.items(): 50 | if k not in config: 51 | config[k] = v 52 | -------------------------------------------------------------------------------- /src/pyscript/__main__.py: -------------------------------------------------------------------------------- 1 | from pyscript.cli import app 2 | 3 | app() 4 | -------------------------------------------------------------------------------- /src/pyscript/_generator.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | import jinja2 6 | import requests 7 | import toml 8 | 9 | from pyscript import LATEST_PYSCRIPT_VERSION, config 10 | 11 | _env = jinja2.Environment(loader=jinja2.PackageLoader("pyscript")) 12 | TEMPLATE_PYTHON_CODE = """# Replace the code below with your own 13 | print("Hello, world!") 14 | """ 15 | 16 | 17 | def create_project_html( 18 | title: str, 19 | python_file_path: str, 20 | config_file_path: str, 21 | output_file_path: Path, 22 | pyscript_version: str, 23 | template: str = "basic.html", 24 | ) -> None: 25 | """Write a Python script string to an HTML file template. 26 | 27 | 28 | Params: 29 | - title (str): application title, that will be placed as title of the html 30 | - python_file_path (str): path to the python file to be loaded by the app 31 | - config_file_path (str): path to the config file to be loaded by the app 32 | - output_file_path (Path): path where to write the new html file 33 | - pyscript_version (str): version of pyscript to be used 34 | - template (str): name of the template to be used 35 | 36 | Output: 37 | (None) 38 | """ 39 | template_instance = _env.get_template(template) 40 | 41 | with output_file_path.open("w") as fp: 42 | fp.write( 43 | template_instance.render( 44 | python_file_path=python_file_path, 45 | config_file_path=config_file_path, 46 | title=title, 47 | pyscript_version=pyscript_version, 48 | ) 49 | ) 50 | 51 | 52 | def save_config_file(config_file: Path, configuration: dict): 53 | """Write an app configuration dict to `config_file`. 54 | 55 | Params: 56 | 57 | - config_file(Path): path configuration file. (i.e.: "pyscript.toml"). Supported 58 | formats: `toml` and `json`. 59 | - configuration(dict): app configuration to be saved 60 | 61 | Return: 62 | (None) 63 | """ 64 | with config_file.open("w", encoding="utf-8") as fp: 65 | if str(config_file).endswith(".json"): 66 | json.dump(configuration, fp) 67 | else: 68 | toml.dump(configuration, fp) 69 | 70 | 71 | def create_project( 72 | app_or_file_name: Optional[str], 73 | app_description: str, 74 | author_name: str, 75 | author_email: str, 76 | pyscript_version: Optional[str] = None, 77 | project_type: str = "app", 78 | wrap: bool = False, 79 | command: Optional[str] = None, 80 | output: Optional[str] = None, 81 | ) -> None: 82 | """ 83 | New files created: 84 | 85 | pyscript.toml - project metadata and config file 86 | main.py - a "Hello world" python starter module 87 | index.html - start page for the project 88 | """ 89 | 90 | if wrap: 91 | if command: 92 | # app_or_file_name is None in this case 93 | assert app_or_file_name is None 94 | if output: 95 | app_name = output.removesuffix(".html") 96 | else: 97 | app_name = "pyscript-command-app" 98 | else: 99 | assert app_or_file_name is not None 100 | app_name = app_or_file_name.removesuffix(".py") 101 | else: 102 | if app_or_file_name and app_or_file_name.endswith(".py"): 103 | app_name = app_or_file_name.removesuffix(".py") 104 | else: 105 | # At this point we should always have a name, but typing 106 | # was complaining so let's add a default 107 | app_name = app_or_file_name or "my-pyscript-app" 108 | 109 | if not pyscript_version: 110 | pyscript_version = _get_latest_pyscript_version() 111 | 112 | if project_type == "app": 113 | template = "basic.html" 114 | else: 115 | raise ValueError( 116 | f"Unknown project type: {project_type}. Valid values are: 'app'" 117 | ) 118 | 119 | context = { 120 | "name": app_name, 121 | "description": app_description, 122 | "type": project_type, 123 | "author_name": author_name, 124 | "author_email": author_email, 125 | "version": "latest", 126 | } 127 | 128 | app_dir = Path(".") / app_name 129 | app_dir.mkdir() 130 | manifest_file = app_dir / config["project_config_filename"] 131 | 132 | save_config_file(manifest_file, context) 133 | output_path = app_dir / "index.html" if output is None else app_dir / output 134 | 135 | python_filepath = app_dir / "main.py" 136 | 137 | if not wrap: 138 | if app_or_file_name and app_or_file_name.endswith(".py"): 139 | python_filepath.write_bytes(Path(app_or_file_name).read_bytes()) 140 | else: 141 | # Save the new python file 142 | with python_filepath.open("w", encoding="utf-8") as fp: 143 | fp.write(TEMPLATE_PYTHON_CODE) 144 | else: 145 | if command: 146 | with python_filepath.open("w", encoding="utf-8") as fp: 147 | fp.write(command) 148 | else: 149 | assert app_or_file_name is not None 150 | python_filepath.write_bytes(Path(app_or_file_name).read_bytes()) 151 | 152 | create_project_html( 153 | app_name, 154 | config["project_main_filename"], 155 | config["project_config_filename"], 156 | output_path, 157 | pyscript_version=pyscript_version, 158 | template=template, 159 | ) 160 | 161 | 162 | def _get_latest_pyscript_version() -> str: 163 | """Get the latest version of PyScript from GitHub.""" 164 | url = "https://api.github.com/repos/pyscript/pyscript/releases/latest" 165 | try: 166 | response = requests.get(url) 167 | 168 | if not response.ok: 169 | pyscript_version = LATEST_PYSCRIPT_VERSION 170 | else: 171 | 172 | data = response.json() 173 | pyscript_version = data["tag_name"] 174 | except Exception: 175 | pyscript_version = LATEST_PYSCRIPT_VERSION 176 | 177 | return pyscript_version 178 | -------------------------------------------------------------------------------- /src/pyscript/cli.py: -------------------------------------------------------------------------------- 1 | """The main CLI entrypoint and commands.""" 2 | 3 | import sys 4 | from typing import Any, Optional 5 | 6 | from pluggy import PluginManager 7 | 8 | from pyscript import __version__, app, console, plugins, typer 9 | from pyscript.plugins import hookspecs 10 | 11 | DEFAULT_PLUGINS = ["create", "run"] 12 | 13 | 14 | def ok(msg: str = ""): 15 | """ 16 | Simply prints "OK" and an optional message, to the console, before cleanly 17 | exiting. 18 | 19 | Provides a standard way to end/confirm a successful command. 20 | """ 21 | console.print(f"OK. {msg}".rstrip(), style="green") 22 | raise typer.Exit() 23 | 24 | 25 | class Abort(typer.Abort): 26 | """ 27 | Abort with a consistent error message. 28 | """ 29 | 30 | def __init__(self, msg: str, *args: Any, **kwargs: Any): 31 | console.print(msg, style="red") 32 | super().__init__(*args, **kwargs) 33 | 34 | 35 | @app.callback(invoke_without_command=True, no_args_is_help=True) 36 | def main( 37 | version: Optional[bool] = typer.Option( 38 | None, "--version", help="Show project version and exit." 39 | ) 40 | ): 41 | """ 42 | Command Line Interface for PyScript. 43 | """ 44 | if version: 45 | console.print(f"PyScript CLI version: {__version__}", style="bold green") 46 | raise typer.Exit() 47 | 48 | 49 | # Create the default PluginManager 50 | pm = PluginManager("pyscript") 51 | 52 | # Register the hooks specifications available for PyScript Plugins 53 | pm.add_hookspecs(hookspecs) 54 | 55 | # Register the default plugins available with the bare pyscript cli installation 56 | for modname in DEFAULT_PLUGINS: 57 | importspec = f"pyscript.plugins.{modname}" 58 | try: 59 | __import__(importspec) 60 | except ImportError as e: 61 | raise ImportError( 62 | f'Error importing plugin "{modname}": {e.args[0]}' 63 | ).with_traceback(e.__traceback__) from e 64 | else: 65 | mod = sys.modules[importspec] 66 | pm.register(mod, modname) 67 | 68 | 69 | # Load plugins registered via setuptools entrypoints 70 | loaded = pm.load_setuptools_entrypoints("pyscript") 71 | 72 | # Register the commands from plugins that have been loaded and used the 73 | # `pyscript_subcommand` hook. 74 | for cmd in pm.hook.pyscript_subcommand(): 75 | plugins._add_cmd(cmd) 76 | -------------------------------------------------------------------------------- /src/pyscript/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | from pluggy import HookimplMarker 2 | 3 | from pyscript import app 4 | 5 | register = HookimplMarker("pyscript") 6 | 7 | 8 | def _add_cmd(f): 9 | app.command()(f) 10 | -------------------------------------------------------------------------------- /src/pyscript/plugins/create.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import typer 4 | 5 | from pyscript import app, cli, plugins 6 | from pyscript._generator import create_project 7 | 8 | 9 | @app.command() 10 | def create( 11 | app_or_file_name: Optional[str] = typer.Argument( 12 | None, help="The name of your new app or path to an input .py script" 13 | ), 14 | app_description: str = typer.Option(None, help="App description"), 15 | author_name: str = typer.Option(None, help="Name of the author"), 16 | author_email: str = typer.Option(None, help="Email of the author"), 17 | pyscript_version: str = typer.Option( 18 | None, 19 | "--pyscript-version", 20 | help="If provided, defines what version of pyscript will be used to create the app", 21 | ), 22 | project_type: str = typer.Option( 23 | "app", 24 | "--project-type", 25 | help="Type of project that is being created. Supported types are: 'app'", 26 | ), 27 | wrap: bool = typer.Option( 28 | False, 29 | "-w", 30 | "--wrap", 31 | help="Use wrap mode i.e. embed a python script into an HTML file", 32 | ), 33 | command: Optional[str] = typer.Option( 34 | None, 35 | "-c", 36 | "--command", 37 | help="If provided, embed a single command string. Meant to be used with `--wrap`", 38 | ), 39 | output: Optional[str] = typer.Option( 40 | None, 41 | "-o", 42 | "--output", 43 | help="""Name of the resulting HTML output file. Meant to be used with `-w/--wrap`""", 44 | ), 45 | ): 46 | """ 47 | Create a new pyscript project with the passed in name, creating a new 48 | directory in the current directory. Alternatively, use `--wrap` so as to embed 49 | a python file instead. 50 | """ 51 | if not app_or_file_name and not command: 52 | app_or_file_name = typer.prompt("App name", default="my-pyscript-app") 53 | 54 | if app_or_file_name and command: 55 | raise cli.Abort("Cannot provide both an input '.py' file and '-c' option.") 56 | 57 | if (output or command) and (not wrap): 58 | raise cli.Abort( 59 | """`--output/-o`, and `--command/-c` 60 | are meant to be used with `--wrap/-w`""" 61 | ) 62 | 63 | if not app_description: 64 | app_description = typer.prompt("App description", default="") 65 | if not author_name: 66 | author_name = typer.prompt("Author name", default="") 67 | if not author_email: 68 | author_email = typer.prompt("Author email", default="") 69 | 70 | try: 71 | create_project( 72 | app_or_file_name, 73 | app_description, 74 | author_name, 75 | author_email, 76 | pyscript_version, 77 | project_type, 78 | wrap, 79 | command, 80 | output, 81 | ) 82 | except FileExistsError: 83 | raise cli.Abort( 84 | f"A directory called {app_or_file_name} already exists in this location." 85 | ) 86 | 87 | 88 | @plugins.register 89 | def pyscript_subcommand(): 90 | return create 91 | -------------------------------------------------------------------------------- /src/pyscript/plugins/hookspecs.py: -------------------------------------------------------------------------------- 1 | from pluggy import HookspecMarker 2 | 3 | hookspec = HookspecMarker("pyscript") 4 | 5 | 6 | @hookspec 7 | def pyscript_subcommand(): 8 | """My special little hook that you can customize.""" 9 | -------------------------------------------------------------------------------- /src/pyscript/plugins/run.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import socketserver 4 | import threading 5 | import webbrowser 6 | from functools import partial 7 | from http.server import SimpleHTTPRequestHandler 8 | from pathlib import Path 9 | 10 | import typer 11 | 12 | from pyscript import app, cli, console, plugins 13 | 14 | 15 | def get_folder_based_http_request_handler( 16 | folder: Path, 17 | ) -> type[SimpleHTTPRequestHandler]: 18 | """ 19 | Returns a FolderBasedHTTPRequestHandler with the specified directory. 20 | 21 | Args: 22 | folder (str): The folder that will be served. 23 | 24 | Returns: 25 | FolderBasedHTTPRequestHandler: The SimpleHTTPRequestHandler with the 26 | specified directory. 27 | """ 28 | 29 | class FolderBasedHTTPRequestHandler(SimpleHTTPRequestHandler): 30 | def __init__(self, *args, **kwargs): 31 | super().__init__(*args, directory=folder, **kwargs) 32 | 33 | def end_headers(self): 34 | self.send_header("Cross-Origin-Opener-Policy", "same-origin") 35 | self.send_header("Cross-Origin-Embedder-Policy", "require-corp") 36 | self.send_header("Cross-Origin-Resource-Policy", "cross-origin") 37 | self.send_header("Cache-Control", "no-cache, must-revalidate") 38 | SimpleHTTPRequestHandler.end_headers(self) 39 | 40 | return FolderBasedHTTPRequestHandler 41 | 42 | 43 | def split_path_and_filename(path: Path) -> tuple[Path, str]: 44 | """Receives a path to a pyscript project or file and returns the base 45 | path of the project and the filename that should be opened (filename defaults 46 | to "" (empty string) if the path points to a folder). 47 | 48 | Args: 49 | path (str): The path to the pyscript project or file. 50 | 51 | Returns: 52 | tuple(str, str): The base path of the project and the filename 53 | """ 54 | abs_path = path.absolute() 55 | if path.is_file(): 56 | return Path("/".join(abs_path.parts[:-1])), abs_path.parts[-1] 57 | else: 58 | return abs_path, "" 59 | 60 | 61 | def start_server(path: Path, show: bool, port: int): 62 | """ 63 | Creates a local server to run the app on the path and port specified. 64 | 65 | Args: 66 | path(str): The path of the project that will run. 67 | show(bool): Open the app in web browser. 68 | port(int): The port that the app will run on. 69 | 70 | Returns: 71 | None 72 | """ 73 | # We need to set the allow_resuse_address to True because socketserver will 74 | # keep the port in use for a while after the server is stopped. 75 | # see https://stackoverflow.com/questions/31745040/ 76 | socketserver.TCPServer.allow_reuse_address = True 77 | 78 | app_folder, filename = split_path_and_filename(path) 79 | CustomHTTPRequestHandler = get_folder_based_http_request_handler(app_folder) 80 | 81 | # Start the server within a context manager to make sure we clean up after 82 | with socketserver.TCPServer(("", port), CustomHTTPRequestHandler) as httpd: 83 | console.print( 84 | f"Serving from {app_folder} at port {port}. To stop, press Ctrl+C.", 85 | style="green", 86 | ) 87 | 88 | if show: 89 | # Open the web browser in a separate thread after 0.5 seconds. 90 | open_browser = partial( 91 | webbrowser.open_new_tab, f"http://localhost:{port}/{filename}" 92 | ) 93 | threading.Timer(0.5, open_browser).start() 94 | 95 | try: 96 | httpd.serve_forever() 97 | except KeyboardInterrupt: 98 | console.print("\nStopping server... Bye bye!") 99 | 100 | # Clean up resources.... 101 | httpd.shutdown() 102 | httpd.socket.close() 103 | raise typer.Exit(1) 104 | 105 | 106 | @app.command() 107 | def run( 108 | path: Path = typer.Argument( 109 | Path("."), help="The path of the project that will run." 110 | ), 111 | view: bool = typer.Option(True, help="Open the app in web browser."), 112 | port: int = typer.Option(8000, help="The port that the app will run on."), 113 | ): 114 | """ 115 | Creates a local server to run the app on the path and port specified. 116 | """ 117 | 118 | # First thing we need to do is to check if the path exists 119 | if not path.exists(): 120 | raise cli.Abort(f"Error: Path {str(path)} does not exist.", style="red") 121 | 122 | try: 123 | start_server(path, view, port) 124 | except OSError as e: 125 | if e.errno == 48: 126 | console.print( 127 | f"Error: Port {port} is already in use! :( Please, stop the process using that port" 128 | f"or try another port using the --port option.", 129 | style="red", 130 | ) 131 | else: 132 | console.print(f"Error: {e.strerror}", style="red") 133 | 134 | raise cli.Abort("") 135 | 136 | 137 | @plugins.register 138 | def pyscript_subcommand(): 139 | return run 140 | -------------------------------------------------------------------------------- /src/pyscript/templates/basic.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{ title }} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/pyscript/version: -------------------------------------------------------------------------------- 1 | 0.3.4 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any 3 | from unittest.mock import MagicMock, patch 4 | 5 | import pytest 6 | from _pytest.monkeypatch import MonkeyPatch 7 | 8 | from pyscript import LATEST_PYSCRIPT_VERSION 9 | 10 | 11 | @pytest.fixture(scope="session", autouse=True) 12 | def requests(): 13 | mocked_result = {"tag_name": LATEST_PYSCRIPT_VERSION} 14 | 15 | with patch("pyscript._generator.requests") as mocked_requests: 16 | mocked_get = MagicMock() 17 | mocked_get.ok = True 18 | mocked_get.json = MagicMock(return_value=mocked_result) 19 | mocked_requests.get.return_value = mocked_get 20 | yield mocked_requests 21 | 22 | 23 | @pytest.fixture 24 | def auto_enter(monkeypatch): 25 | """ 26 | Monkey patch 'typer.prompt' to always hit ". 27 | """ 28 | 29 | def user_hit_enter(*args, **kwargs): 30 | # This makes sure that if there is a default value on a prompt 31 | # we will return it, otherwise we will return an empty string 32 | # which isn't the same as hitting enter! 33 | default_value = kwargs.get("default", "") 34 | return default_value 35 | 36 | monkeypatch.setattr("typer.prompt", user_hit_enter) 37 | 38 | 39 | @pytest.fixture() 40 | def tmp_cwd(monkeypatch: MonkeyPatch, tmp_path: Path) -> Path: 41 | """Create & return a temporary directory after setting current working directory to it.""" 42 | monkeypatch.chdir(tmp_path) 43 | return tmp_path 44 | 45 | 46 | @pytest.fixture(scope="session") 47 | def is_not_none() -> Any: 48 | """ 49 | An object that can be used to test whether another is None. 50 | 51 | This is particularly useful when testing contents of collections, e.g.: 52 | 53 | ```python 54 | def test_data(data, is_not_none): 55 | assert data == {"some_key": is_not_none, "some_other_key": 5} 56 | ``` 57 | 58 | """ 59 | 60 | class _NotNone: 61 | def __eq__(self, other): 62 | return other is not None 63 | 64 | return _NotNone() 65 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING, Callable 5 | 6 | import pytest 7 | from mypy_extensions import VarArg 8 | from typer.testing import CliRunner, Result 9 | 10 | from pyscript import LATEST_PYSCRIPT_VERSION, __version__, config 11 | from pyscript.cli import app 12 | 13 | if TYPE_CHECKING: 14 | from _pytest.monkeypatch import MonkeyPatch 15 | 16 | CLIInvoker = Callable[[VarArg(str)], Result] 17 | 18 | 19 | @pytest.fixture() 20 | def app_details_args(): 21 | return [ 22 | "--app-description", 23 | "tester-app", 24 | "--author-name", 25 | "tester", 26 | "--author-email", 27 | "tester@me.com", 28 | ] 29 | 30 | 31 | @pytest.fixture() 32 | def invoke_cli(tmp_path: Path, monkeypatch: MonkeyPatch) -> CLIInvoker: 33 | """Returns a function, which can be used to call the CLI from within a temporary directory.""" 34 | runner = CliRunner() 35 | 36 | monkeypatch.chdir(tmp_path) 37 | 38 | def f(*args: str) -> Result: 39 | return runner.invoke(app, args) 40 | 41 | return f 42 | 43 | 44 | def test_version() -> None: 45 | runner = CliRunner() 46 | result = runner.invoke(app, "--version") 47 | assert result.exit_code == 0 48 | assert f"PyScript CLI version: {__version__}" in result.stdout 49 | 50 | 51 | def test_create_command( 52 | invoke_cli: CLIInvoker, tmp_path: Path, app_details_args: list[str], auto_enter 53 | ) -> None: 54 | result = invoke_cli("create", "myapp") 55 | assert result.exit_code == 0 56 | 57 | expected_path = tmp_path / "myapp" 58 | assert expected_path.exists() 59 | 60 | expected_main_py_path = expected_path / "main.py" 61 | assert expected_main_py_path.exists() 62 | 63 | expected_config_path = expected_path / config["project_config_filename"] 64 | assert expected_config_path.exists() 65 | with expected_config_path.open() as fp: 66 | config_text = fp.read() 67 | 68 | assert 'name = "myapp' in config_text 69 | # Assert that description, author name and email are empty 70 | assert 'description = ""' in config_text 71 | assert 'author_name = ""' in config_text 72 | assert 'author_email = ""' in config_text 73 | 74 | 75 | def test_create_command_bad_app_type( 76 | invoke_cli: CLIInvoker, tmp_path: Path, app_details_args: list[str], auto_enter 77 | ) -> None: 78 | result = invoke_cli("create", "myapp", "--project-type", "bad_type") 79 | assert ( 80 | str(result.exception) 81 | == "Unknown project type: bad_type. Valid values are: 'app'" 82 | ) 83 | 84 | 85 | def test_create_command_no_app_name( 86 | invoke_cli: CLIInvoker, tmp_path: Path, app_details_args: list[str], auto_enter 87 | ) -> None: 88 | result = invoke_cli("create") 89 | assert result.exit_code == 0 90 | 91 | expected_path = tmp_path / "my-pyscript-app" 92 | assert expected_path.exists() 93 | 94 | expected_main_py_path = expected_path / "main.py" 95 | assert expected_main_py_path.exists() 96 | 97 | expected_config_path = expected_path / config["project_config_filename"] 98 | assert expected_config_path.exists() 99 | with expected_config_path.open() as fp: 100 | config_text = fp.read() 101 | 102 | assert 'name = "my-pyscript-app' in config_text 103 | # Assert that description, author name and email are empty 104 | assert 'description = ""' in config_text 105 | assert 'author_name = ""' in config_text 106 | assert 'author_email = ""' in config_text 107 | 108 | 109 | def test_create_command_with_single_py_file( 110 | invoke_cli: CLIInvoker, tmp_path: Path, app_details_args: list[str], auto_enter 111 | ): 112 | """ 113 | Test that when create is called with a single python file as input, 114 | the project is created correctly 115 | """ 116 | input_file = tmp_path / "hello.py" 117 | with input_file.open("w") as fp: 118 | fp.write('print("Yo!")') 119 | 120 | result = invoke_cli("create", "hello.py", *app_details_args) 121 | assert result.exit_code == 0 122 | 123 | expected_path = tmp_path / "hello" 124 | assert expected_path.exists() 125 | 126 | expected_main_py_path = expected_path / "main.py" 127 | assert expected_main_py_path.exists() 128 | with expected_main_py_path.open() as fp: 129 | py_text = fp.read() 130 | 131 | assert 'print("Yo!")' in py_text 132 | 133 | expected_config_path = expected_path / config["project_config_filename"] 134 | assert expected_config_path.exists() 135 | with expected_config_path.open() as fp: 136 | config_text = fp.read() 137 | 138 | assert 'name = "hello' in config_text 139 | # Assert that description, author name and email are empty 140 | assert 'description = "tester-app"' in config_text 141 | assert 'author_name = "tester"' in config_text 142 | 143 | 144 | @pytest.mark.parametrize("flag", ["-c", "--command"]) 145 | def test_wrap_command( 146 | invoke_cli: CLIInvoker, tmp_path: Path, flag: str, app_details_args: list[str] 147 | ) -> None: 148 | command = 'print("Hello World!")' 149 | result = invoke_cli( 150 | "create", "--wrap", flag, command, "-o", "output.html", *app_details_args 151 | ) 152 | assert result.exit_code == 0 153 | 154 | expected_html_path = tmp_path / "output" / "output.html" 155 | assert expected_html_path.exists() 156 | 157 | expected_main_py_path = tmp_path / "output" / "main.py" 158 | with expected_main_py_path.open() as fp: 159 | py_text = fp.read() 160 | 161 | assert command in py_text 162 | 163 | 164 | @pytest.mark.parametrize( 165 | "wrap_args", 166 | [tuple(), ("-c", "print()", "script_name.py")], 167 | ids=["empty_args", "command_and_script"], 168 | ) 169 | def test_wrap_abort(invoke_cli: CLIInvoker, wrap_args: tuple[str]): 170 | result = invoke_cli("create", "--wrap", *wrap_args) 171 | assert result.exit_code == 1 172 | 173 | 174 | @pytest.mark.parametrize( 175 | "wrap_args, expected_output_filename", 176 | [(("-o", "output.html"), "output.html"), (tuple(), "index.html")], 177 | ) 178 | def test_wrap_file( 179 | invoke_cli: CLIInvoker, 180 | tmp_path: Path, 181 | wrap_args: tuple[str], 182 | expected_output_filename: str, 183 | app_details_args: list[str], 184 | ) -> None: 185 | command = 'print("Hello World!")' 186 | 187 | input_file = tmp_path / "hello.py" 188 | with input_file.open("w") as fp: 189 | fp.write(command) 190 | 191 | result = invoke_cli( 192 | "create", str(input_file), "--wrap", *wrap_args, *app_details_args 193 | ) 194 | assert result.exit_code == 0 195 | 196 | expected_html_path = tmp_path / "hello" / expected_output_filename 197 | assert expected_html_path.exists() 198 | 199 | expected_main_py_path = tmp_path / "hello" / "main.py" 200 | with expected_main_py_path.open() as fp: 201 | py_text = fp.read() 202 | 203 | assert command in py_text 204 | 205 | 206 | @pytest.mark.parametrize( 207 | "version, expected_version", 208 | [(None, LATEST_PYSCRIPT_VERSION), ("2023.11.1", "2023.11.1")], 209 | ) 210 | def test_wrap_pyscript_version( 211 | invoke_cli: CLIInvoker, 212 | version: str | None, 213 | expected_version: str, 214 | tmp_path: Path, 215 | app_details_args: list[str], 216 | ) -> None: 217 | """ 218 | Test that when wrap is called passing a string code input and an explicit pyscript version 219 | the project is created correctly 220 | """ 221 | command = 'print("Hello World!")' 222 | args = ["create", "--wrap", "-c", command, "-o", "output.html", *app_details_args] 223 | if version is not None: 224 | args.extend(["--pyscript-version", version]) 225 | 226 | # GIVEN a call to wrap with a cmd input and specific pyscript version as arguments 227 | result = invoke_cli(*args) 228 | assert result.exit_code == 0 229 | 230 | # EXPECT the output file to exist 231 | expected_html_path = tmp_path / "output" / "output.html" 232 | assert expected_html_path.exists() 233 | 234 | with expected_html_path.open() as fp: 235 | html_text = fp.read() 236 | 237 | expected_main_py_path = tmp_path / "output" / "main.py" 238 | with expected_main_py_path.open() as fp: 239 | py_text = fp.read() 240 | 241 | assert command in py_text 242 | 243 | # EXPECT the right JS and CSS version to be present in the output file 244 | version_str = ( 245 | '' 247 | ) 248 | css_version_str = ( 249 | '' 251 | ) 252 | assert version_str in html_text 253 | assert css_version_str in html_text 254 | 255 | 256 | @pytest.mark.parametrize( 257 | "version, expected_version", 258 | [(None, LATEST_PYSCRIPT_VERSION), ("2023.11.1", "2023.11.1")], 259 | ) 260 | def test_wrap_pyscript_version_file( 261 | invoke_cli: CLIInvoker, 262 | version: str | None, 263 | expected_version: str, 264 | tmp_path: Path, 265 | app_details_args: list[str], 266 | ) -> None: 267 | """ 268 | Test that when wrap is called passing a file input and an explicit pyscript version 269 | the project is created correctly 270 | """ 271 | command = 'print("Hello World!")' 272 | input_file = tmp_path / "hello.py" 273 | with input_file.open("w") as fp: 274 | fp.write(command) 275 | 276 | args = ["create", "--wrap", str(input_file), "-o", "output.html", *app_details_args] 277 | 278 | if version is not None: 279 | args.extend(["--pyscript-version", version]) 280 | 281 | # GIVEN a call to wrap with a file and specific pyscript version as arguments 282 | result = invoke_cli(*args) 283 | assert result.exit_code == 0 284 | 285 | # EXPECT the output file to exist 286 | expected_html_path = tmp_path / "hello" / "output.html" 287 | assert expected_html_path.exists() 288 | 289 | with expected_html_path.open() as fp: 290 | html_text = fp.read() 291 | 292 | expected_main_py_path = tmp_path / "hello" / "main.py" 293 | with expected_main_py_path.open() as fp: 294 | py_text = fp.read() 295 | 296 | assert command in py_text 297 | 298 | # EXPECT the right JS and CSS version to be present in the output file 299 | version_str = ( 300 | '' 302 | ) 303 | css_version_str = ( 304 | '' 306 | ) 307 | assert version_str in html_text 308 | assert css_version_str in html_text 309 | 310 | 311 | @pytest.mark.parametrize( 312 | "create_args, expected_version", 313 | [ 314 | (("myapp1",), LATEST_PYSCRIPT_VERSION), 315 | (("myapp-w-version", "--pyscript-version", "2023.11.1"), "2023.11.1"), 316 | ], 317 | ) 318 | def test_create_project_version( 319 | invoke_cli: CLIInvoker, 320 | tmp_path: Path, 321 | create_args: tuple[str], 322 | expected_version: str, 323 | app_details_args: list[str], 324 | ) -> None: 325 | """ 326 | Test that project created with an explicit pyscript version are created correctly 327 | """ 328 | command = 'print("Hello World!")' 329 | 330 | input_file = tmp_path / "hello.py" 331 | with input_file.open("w") as fp: 332 | fp.write(command) 333 | 334 | cmd_args = list(create_args) + app_details_args 335 | 336 | # GIVEN a call to wrap with a file and specific pyscript version as arguments 337 | result = invoke_cli("create", *cmd_args) 338 | assert result.exit_code == 0 339 | 340 | # EXPECT the app folder to exist 341 | expected_app_path = tmp_path / create_args[0] 342 | assert expected_app_path.exists() 343 | 344 | # EXPECT the app folder to contain the right index.html file 345 | app_file = expected_app_path / "index.html" 346 | assert app_file.exists() 347 | with app_file.open() as fp: 348 | html_text = fp.read() 349 | 350 | # EXPECT the right JS and CSS version to be present in the html file 351 | version_str = ( 352 | '' 354 | ) 355 | css_version_str = ( 356 | '' 358 | ) 359 | assert version_str in html_text 360 | assert css_version_str in html_text 361 | 362 | # EXPECT the folder to also contain the python main file 363 | py_file = expected_app_path / config["project_main_filename"] 364 | assert py_file.exists() 365 | 366 | # EXPECT the folder to also contain the config file 367 | config_file = expected_app_path / config["project_config_filename"] 368 | assert config_file.exists() 369 | -------------------------------------------------------------------------------- /tests/test_generator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for utility functions in the _generator.py module that cannot be 3 | exercised because of limitations in the Typer testing framework (specifically, 4 | multiple "prompt" arguments). 5 | """ 6 | 7 | import json 8 | from pathlib import Path 9 | from textwrap import dedent 10 | from typing import Any 11 | 12 | import pytest 13 | import toml 14 | 15 | from pyscript import _generator as gen 16 | from pyscript import config 17 | 18 | TESTS_AUTHOR_NAME = "A.Coder" 19 | TESTS_AUTHOR_EMAIL = "acoder@domain.com" 20 | 21 | 22 | def test_create_app(tmp_cwd: Path, is_not_none: Any) -> None: 23 | """ 24 | Test that a new app is created with the correct files and manifest. 25 | """ 26 | app_name = "app_name" 27 | app_description = "A longer, human friendly, app description." 28 | 29 | # GIVEN a a new project 30 | gen.create_project(app_name, app_description, TESTS_AUTHOR_NAME, TESTS_AUTHOR_EMAIL) 31 | 32 | # with a default config path 33 | manifest_path = tmp_cwd / app_name / config["project_config_filename"] 34 | 35 | check_project_manifest(manifest_path, toml, app_name, is_not_none) 36 | check_project_files(tmp_cwd / app_name) 37 | 38 | 39 | def test_create_bad_type(tmp_cwd: Path, is_not_none: Any) -> None: 40 | """ 41 | Test that a new project with a bad type raises a ValueError 42 | """ 43 | app_name = "app_name" 44 | app_description = "A longer, human friendly, app description." 45 | 46 | # GIVEN a a new project with a bad type assert it raises a 47 | with pytest.raises(ValueError): 48 | gen.create_project( 49 | app_name, 50 | app_description, 51 | TESTS_AUTHOR_NAME, 52 | TESTS_AUTHOR_EMAIL, 53 | project_type="bad_type", 54 | ) 55 | 56 | 57 | def test_create_project_twice_raises_error(tmp_cwd: Path) -> None: 58 | """We get a FileExistsError when we try to create an existing project.""" 59 | app_name = "app_name" 60 | app_description = "A longer, human friendly, app description." 61 | gen.create_project(app_name, app_description, TESTS_AUTHOR_NAME, TESTS_AUTHOR_EMAIL) 62 | 63 | with pytest.raises(FileExistsError): 64 | gen.create_project( 65 | app_name, app_description, TESTS_AUTHOR_NAME, TESTS_AUTHOR_EMAIL 66 | ) 67 | 68 | 69 | def test_create_project_explicit_json( 70 | tmp_cwd: Path, is_not_none: Any, monkeypatch 71 | ) -> None: 72 | app_name = "JSON_app_name" 73 | app_description = "A longer, human friendly, app description." 74 | 75 | # Let's patch the config so that the project config file is a JSON file 76 | config_file_name = "pyscript.json" 77 | monkeypatch.setitem(gen.config, "project_config_filename", config_file_name) 78 | 79 | # GIVEN a new project 80 | gen.create_project(app_name, app_description, TESTS_AUTHOR_NAME, TESTS_AUTHOR_EMAIL) 81 | 82 | # get the path where the config file is being created 83 | manifest_path = tmp_cwd / app_name / config["project_config_filename"] 84 | 85 | check_project_manifest(manifest_path, json, app_name, is_not_none) 86 | 87 | 88 | def test_create_project_explicit_toml( 89 | tmp_cwd: Path, is_not_none: Any, monkeypatch 90 | ) -> None: 91 | app_name = "TOML_app_name" 92 | app_description = "A longer, human friendly, app description." 93 | 94 | # Let's patch the config so that the project config file is a JSON file 95 | config_file_name = "mypyscript.toml" 96 | monkeypatch.setitem(gen.config, "project_config_filename", config_file_name) 97 | 98 | # GIVEN a new project 99 | gen.create_project(app_name, app_description, TESTS_AUTHOR_NAME, TESTS_AUTHOR_EMAIL) 100 | 101 | # get the path where the config file is being created 102 | manifest_path = tmp_cwd / app_name / config["project_config_filename"] 103 | 104 | check_project_manifest(manifest_path, toml, app_name, is_not_none) 105 | 106 | 107 | def check_project_manifest( 108 | config_path: Path, 109 | serializer: Any, 110 | app_name: str, 111 | is_not_none: Any, 112 | app_description: str = "A longer, human friendly, app description.", 113 | author_name: str = TESTS_AUTHOR_NAME, 114 | author_email: str = TESTS_AUTHOR_EMAIL, 115 | project_type: str = "app", 116 | ): 117 | """ 118 | Perform the following: 119 | 120 | * checks that `config_path` exists 121 | * loads the contents of `config_path` using `serializer.load` 122 | * check that the contents match with the values provided in input. Specifically: 123 | * "name" == app_name 124 | * "description" == app_description 125 | * "type" == app_type 126 | * "author_name" == author_name 127 | * "author_email" == author_email 128 | * "version" == is_not_none 129 | 130 | Params: 131 | * config_path(Path): path to the app config file 132 | * serializer(json|toml): serializer to be used to load contents of `config_path`. 133 | Supported values are either modules `json` or `toml` 134 | * app_name(str): name of application 135 | * is_not_none(any): pytest fixture 136 | * app_description(str): application description 137 | * author_name(str): application author name 138 | * author_email(str): application author email 139 | * project_type(str): project type 140 | 141 | """ 142 | # assert that the new project config file exists 143 | assert config_path.exists() 144 | 145 | # assert that we can load it as a TOML file (TOML is the default config format) 146 | # and that the contents of the config are as we expect 147 | with config_path.open() as fp: 148 | contents = serializer.load(fp) 149 | 150 | expected = { 151 | "name": app_name, 152 | "description": app_description, 153 | "type": project_type, 154 | "author_name": author_name, 155 | "author_email": author_email, 156 | "version": is_not_none, 157 | } 158 | assert contents == expected 159 | 160 | 161 | def check_project_files( 162 | app_folder: Path, 163 | html_file: str = "index.html", 164 | config_file: str = config["project_config_filename"], 165 | python_file: str = "main.py", 166 | ): 167 | """ 168 | Perform the following checks: 169 | 170 | * checks that app_folder/html_file exists 171 | * checks that app_folder/config_file exists 172 | * checks that app_folder/python_file exists 173 | * checks that html_file actually loads both the python and the config files 174 | 175 | Params: 176 | * config_path(Path): path to the app folder 177 | * html_file(str): name of the html file generated by the template 178 | (default: index.html) 179 | * config_file(str): name of the config file generated by the template 180 | (default: config["project_config_filename"]) 181 | * python_file(str): name of the python file generated by the template 182 | (default: main.py) 183 | 184 | """ 185 | # assert that the new project files exists 186 | html_file_path = app_folder / html_file 187 | assert html_file_path.exists(), f"{html_file} not found! :(" 188 | assert (app_folder / config_file).exists(), f"{config_file} not found! :(" 189 | assert (app_folder / python_file).exists(), f"{python_file} not found! :(" 190 | 191 | with html_file_path.open() as fp: 192 | contents = fp.read() 193 | assert ( 194 | f'