From 0aef7a2dcae462c96193c487fe9d78bd2af41338 Mon Sep 17 00:00:00 2001 From: Mateusz Russak Date: Tue, 23 Jul 2024 17:20:22 +0200 Subject: [PATCH] refactor: use click for CLI (#457) * refactor: use click for CLI * chore: changes according to code review * docs: added docs for cloud deploy * docs: added verbose flag to docs * fix: linter error * feat: added version option for CLI * feat: added version and help commands short options * fix: typo in command line help message * test: add tests for deploy and cli commands * fix: python 3.9 * fix: missing templates in tests * fix: according to CR * chore: added missing change * fix: linter errors * docs: removed mention about service_entrypoint.py * test: cli edit and run commands * fix: linter * ci: build first * fix: windows paths problem * fix: windows problem * fix: windows issue attempt 2 * fix: windows issue attempt 3 * fix: linter --- alfred/ci.py | 6 +- docs/framework/cloud-deploy.mdx | 167 +++++++++ docs/mint.json | 1 + poetry.lock | 337 +++++++++++++++++- pyproject.toml | 2 + src/writer/command_line.py | 201 ++++------- src/writer/deploy.py | 223 ++++++++---- .../backend/fixtures/cloud_deploy_fixtures.py | 132 +++++++ tests/backend/test_cli.py | 101 ++++++ tests/backend/test_deploy.py | 170 +++++++++ tests/conftest.py | 12 + 11 files changed, 1139 insertions(+), 213 deletions(-) create mode 100644 docs/framework/cloud-deploy.mdx create mode 100644 tests/backend/fixtures/cloud_deploy_fixtures.py create mode 100644 tests/backend/test_cli.py create mode 100644 tests/backend/test_deploy.py diff --git a/alfred/ci.py b/alfred/ci.py index 350481c2a..0ac5f4fb7 100644 --- a/alfred/ci.py +++ b/alfred/ci.py @@ -15,13 +15,13 @@ def ci(front, back, e2e, docs): no_flags = (not front and not back and not e2e and not docs) + if front or no_flags: + alfred.invoke_command("npm.lint") + alfred.invoke_command("npm.build") if back or no_flags: alfred.invoke_command("ci.mypy") alfred.invoke_command("ci.ruff") alfred.invoke_command("ci.pytest") - if front or no_flags: - alfred.invoke_command("npm.lint") - alfred.invoke_command("npm.build") if docs or no_flags: alfred.invoke_command("npm.docs.test") if e2e: diff --git a/docs/framework/cloud-deploy.mdx b/docs/framework/cloud-deploy.mdx new file mode 100644 index 000000000..b9452ba91 --- /dev/null +++ b/docs/framework/cloud-deploy.mdx @@ -0,0 +1,167 @@ +--- +title: "Cloud Deployment Using CLI" +--- + +## Table of Contents + +2. [Basic Usage](#basic-usage) +3. [Common Options](#common-options) +4. [Commands](#commands) + - [Deploy](#writer-cloud-deploy) + - [Undeploy](#writer-cloud-undeploy) + - [Logs](#writer-cloud-logs) +5. [Environment Variables](#environment-variables) +6. [API Key](#api-key) +7. [Deployment Process](#deployment-process) +8. [Example Workflow](#example-workflow) + +## Basic Usage + +The `writer cloud` command group includes the following commands: +- `deploy` +- `undeploy` +- `logs` + +## Common Options + +These options are common across multiple commands: +- `--api-key`: The Writer API key used for authentication. If not provided, you will be prompted to enter it. +- `--verbose, -v`: Enable verbose output. + +## Commands + +### `writer cloud deploy` + +Deploys an app from the specified path to the Writer cloud. + +**Usage:** +``` +writer cloud deploy [OPTIONS] PATH +``` + +**Arguments:** +- `PATH`: The path to the folder containing the app to deploy. + +**Options:** +- `--api-key`: Writer API key for authentication. If not provided, you will be prompted to enter it. +- `--env, -e`: Environment variables to set in the deployed app. Use the format `VAR=value`. Multiple environment variables can be specified by repeating the `--env` option. +- `--verbose, -v`: Enable verbose output. + +**Example:** +``` +writer cloud deploy hello --env VAR1=value1 --env VAR2=value2 +``` + +**Description:** +- Deploys the app located in the `PATH` folder. +- Creates a deployment package, ignoring `.git` directories, `Dockerfile`s and all files specified in `.gitignore` file. +- Uploads the package to the deployment server. +- The deployed app will have access to specified environment variables. +- By default, the `WRITER_API_KEY` environment variable will be added to enable AI features. + +**Output Example** + +``` +Creating deployment package from path: /path/to/your/app +[WARNING] Dockerfile found in project root. This will be ignored in the deployment package. +Packing file: pyproject.toml +Packing file: README.md +... +Uploading package to deployment server +Package uploaded. Building... +... +Deployment successful +URL: https://your_app_url +``` + +### `writer cloud undeploy` + +Stops the deployed app and makes it unavailable. + +**Usage:** +``` +writer cloud undeploy [OPTIONS] +``` + +**Options:** +- `--api-key`: Writer API key for authentication. If not provided, you will be prompted to enter it. +- `--verbose, -v`: Enable verbose output. + +**Example:** +``` +writer cloud undeploy +``` + +**Description:** +- Stops and removes the deployed app from the Writer cloud. + +### `writer cloud logs` + +Fetches logs from the deployed app. + +**Usage:** +``` +writer cloud logs [OPTIONS] +``` + +**Options:** +- `--api-key`: Writer API key for authentication. If not provided, you will be prompted to enter it. +- `--verbose, -v`: Enable verbose output. + +**Example:** +``` +writer cloud logs +``` + +**Description:** +- Continuously fetches and displays logs from the deployed app. +- Logs are ordered by date and time. + +**Output Example** + +``` +2024-06-11 09:27:02.190646+00:00 [INFO] Starting container entrypoint... +2024-06-11 09:27:03.798148+00:00 [INFO] BuildService - Downloading build files... +... +``` + +## Environment Variables + +When deploying an app, you can specify environment variables that will be available to the app during runtime. Use the `--env` option to pass these variables. + +**Example:** +``` +writer cloud deploy hello --env DB_HOST=db.example.com --env DB_PORT=5432 +``` + +In this example, `DB_HOST` and `DB_PORT` will be available to the app as environment variables. + +## API Key + +The `WRITER_API_KEY` is crucial for deploying and managing apps. It is used for authenticating requests to the Writer cloud. If not provided as an option, the CLI will prompt you to enter it. The `WRITER_API_KEY` will also be added to the deployed app's environment to enable AI features of the Writer framework. + +## Deployment Process + +1. **Package Creation:** The CLI packages the app, excluding certain files (e.g., Dockerfile, `service_entrypoint.py`). +2. **Upload Package:** The package is uploaded to the Writer deployment server. +3. **Build and Deploy:** The server builds and deploys the app, making it accessible via a URL. +4. **Environment Variables:** Specified environment variables are set, and `WRITER_API_KEY` is added by default. + +## Example Workflow + +1. **Deploying an App:** + ``` + writer cloud deploy /path/to/app --env DB_HOST=db.example.com --env DB_PORT=5432 + ``` + +2. **Fetching Logs:** + ``` + writer cloud logs + ``` + +3. **Undeploying an App:** + ``` + writer cloud undeploy + ``` + +By following this documentation, you should be able to effectively deploy and manage your Writer apps using the Writer Framework CLI. diff --git a/docs/mint.json b/docs/mint.json index 15e0eab03..7735895ce 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -60,6 +60,7 @@ { "group": "Deployment", "pages": [ + "framework/cloud-deploy", "framework/deploy-with-docker", "framework/testing" ] diff --git a/poetry.lock b/poetry.lock index c487023af..0f795f7e6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "alfred-cli" @@ -108,6 +108,27 @@ files = [ [package.dependencies] cryptography = "*" +[[package]] +name = "beautifulsoup4" +version = "4.12.3" +description = "Screen-scraping library" +optional = false +python-versions = ">=3.6.0" +files = [ + {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"}, + {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"}, +] + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +cchardet = ["cchardet"] +chardet = ["chardet"] +charset-normalizer = ["charset-normalizer"] +html5lib = ["html5lib"] +lxml = ["lxml"] + [[package]] name = "certifi" version = "2024.7.4" @@ -463,6 +484,48 @@ typer = ">=0.12.3" [package.extras] standard = ["fastapi", "uvicorn[standard] (>=0.15.0)"] +[[package]] +name = "frozendict" +version = "2.4.4" +description = "A simple immutable dictionary" +optional = false +python-versions = ">=3.6" +files = [ + {file = "frozendict-2.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a59578d47b3949437519b5c39a016a6116b9e787bb19289e333faae81462e59"}, + {file = "frozendict-2.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12a342e439aef28ccec533f0253ea53d75fe9102bd6ea928ff530e76eac38906"}, + {file = "frozendict-2.4.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f79c26dff10ce11dad3b3627c89bb2e87b9dd5958c2b24325f16a23019b8b94"}, + {file = "frozendict-2.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2bd009cf4fc47972838a91e9b83654dc9a095dc4f2bb3a37c3f3124c8a364543"}, + {file = "frozendict-2.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:87ebcde21565a14fe039672c25550060d6f6d88cf1f339beac094c3b10004eb0"}, + {file = "frozendict-2.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:fefeb700bc7eb8b4c2dc48704e4221860d254c8989fb53488540bc44e44a1ac2"}, + {file = "frozendict-2.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:4297d694eb600efa429769125a6f910ec02b85606f22f178bafbee309e7d3ec7"}, + {file = "frozendict-2.4.4-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:812ab17522ba13637826e65454115a914c2da538356e85f43ecea069813e4b33"}, + {file = "frozendict-2.4.4-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fee9420475bb6ff357000092aa9990c2f6182b2bab15764330f4ad7de2eae49"}, + {file = "frozendict-2.4.4-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:3148062675536724502c6344d7c485dd4667fdf7980ca9bd05e338ccc0c4471e"}, + {file = "frozendict-2.4.4-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:78c94991944dd33c5376f720228e5b252ee67faf3bac50ef381adc9e51e90d9d"}, + {file = "frozendict-2.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:1697793b5f62b416c0fc1d94638ec91ed3aa4ab277f6affa3a95216ecb3af170"}, + {file = "frozendict-2.4.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:199a4d32194f3afed6258de7e317054155bc9519252b568d9cfffde7e4d834e5"}, + {file = "frozendict-2.4.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85375ec6e979e6373bffb4f54576a68bf7497c350861d20686ccae38aab69c0a"}, + {file = "frozendict-2.4.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:2d8536e068d6bf281f23fa835ac07747fb0f8851879dd189e9709f9567408b4d"}, + {file = "frozendict-2.4.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:259528ba6b56fa051bc996f1c4d8b57e30d6dd3bc2f27441891b04babc4b5e73"}, + {file = "frozendict-2.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:07c3a5dee8bbb84cba770e273cdbf2c87c8e035903af8f781292d72583416801"}, + {file = "frozendict-2.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6874fec816b37b6eb5795b00e0574cba261bf59723e2de607a195d5edaff0786"}, + {file = "frozendict-2.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8f92425686323a950337da4b75b4c17a3327b831df8c881df24038d560640d4"}, + {file = "frozendict-2.4.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d58d9a8d9e49662c6dafbea5e641f97decdb3d6ccd76e55e79818415362ba25"}, + {file = "frozendict-2.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:93a7b19afb429cbf99d56faf436b45ef2fa8fe9aca89c49eb1610c3bd85f1760"}, + {file = "frozendict-2.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2b70b431e3a72d410a2cdf1497b3aba2f553635e0c0f657ce311d841bf8273b6"}, + {file = "frozendict-2.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:e1b941132d79ce72d562a13341d38fc217bc1ee24d8c35a20d754e79ff99e038"}, + {file = "frozendict-2.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dc2228874eacae390e63fd4f2bb513b3144066a977dc192163c9f6c7f6de6474"}, + {file = "frozendict-2.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63aa49f1919af7d45fb8fd5dec4c0859bc09f46880bd6297c79bb2db2969b63d"}, + {file = "frozendict-2.4.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6bf9260018d653f3cab9bd147bd8592bf98a5c6e338be0491ced3c196c034a3"}, + {file = "frozendict-2.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6eb716e6a6d693c03b1d53280a1947716129f5ef9bcdd061db5c17dea44b80fe"}, + {file = "frozendict-2.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d13b4310db337f4d2103867c5a05090b22bc4d50ca842093779ef541ea9c9eea"}, + {file = "frozendict-2.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:b3b967d5065872e27b06f785a80c0ed0a45d1f7c9b85223da05358e734d858ca"}, + {file = "frozendict-2.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:4ae8d05c8d0b6134bfb6bfb369d5fa0c4df21eabb5ca7f645af95fdc6689678e"}, + {file = "frozendict-2.4.4-py311-none-any.whl", hash = "sha256:705efca8d74d3facbb6ace80ab3afdd28eb8a237bfb4063ed89996b024bc443d"}, + {file = "frozendict-2.4.4-py312-none-any.whl", hash = "sha256:d9647563e76adb05b7cde2172403123380871360a114f546b4ae1704510801e5"}, + {file = "frozendict-2.4.4.tar.gz", hash = "sha256:3f7c031b26e4ee6a3f786ceb5e3abf1181c4ade92dce1f847da26ea2c96008c7"}, +] + [[package]] name = "gitignore-parser" version = "0.1.11" @@ -484,6 +547,27 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +[[package]] +name = "html5lib" +version = "1.1" +description = "HTML parser based on the WHATWG HTML specification" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d"}, + {file = "html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f"}, +] + +[package.dependencies] +six = ">=1.9" +webencodings = "*" + +[package.extras] +all = ["chardet (>=2.2)", "genshi", "lxml"] +chardet = ["chardet (>=2.2)"] +genshi = ["genshi"] +lxml = ["lxml"] + [[package]] name = "httpcore" version = "1.0.5" @@ -651,6 +735,164 @@ files = [ [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "lxml" +version = "5.2.2" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +optional = false +python-versions = ">=3.6" +files = [ + {file = "lxml-5.2.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:364d03207f3e603922d0d3932ef363d55bbf48e3647395765f9bfcbdf6d23632"}, + {file = "lxml-5.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50127c186f191b8917ea2fb8b206fbebe87fd414a6084d15568c27d0a21d60db"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74e4f025ef3db1c6da4460dd27c118d8cd136d0391da4e387a15e48e5c975147"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:981a06a3076997adf7c743dcd0d7a0415582661e2517c7d961493572e909aa1d"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aef5474d913d3b05e613906ba4090433c515e13ea49c837aca18bde190853dff"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e275ea572389e41e8b039ac076a46cb87ee6b8542df3fff26f5baab43713bca"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5b65529bb2f21ac7861a0e94fdbf5dc0daab41497d18223b46ee8515e5ad297"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bcc98f911f10278d1daf14b87d65325851a1d29153caaf146877ec37031d5f36"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:b47633251727c8fe279f34025844b3b3a3e40cd1b198356d003aa146258d13a2"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:fbc9d316552f9ef7bba39f4edfad4a734d3d6f93341232a9dddadec4f15d425f"}, + {file = "lxml-5.2.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:13e69be35391ce72712184f69000cda04fc89689429179bc4c0ae5f0b7a8c21b"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3b6a30a9ab040b3f545b697cb3adbf3696c05a3a68aad172e3fd7ca73ab3c835"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a233bb68625a85126ac9f1fc66d24337d6e8a0f9207b688eec2e7c880f012ec0"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:dfa7c241073d8f2b8e8dbc7803c434f57dbb83ae2a3d7892dd068d99e96efe2c"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a7aca7964ac4bb07680d5c9d63b9d7028cace3e2d43175cb50bba8c5ad33316"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ae4073a60ab98529ab8a72ebf429f2a8cc612619a8c04e08bed27450d52103c0"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ffb2be176fed4457e445fe540617f0252a72a8bc56208fd65a690fdb1f57660b"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e290d79a4107d7d794634ce3e985b9ae4f920380a813717adf61804904dc4393"}, + {file = "lxml-5.2.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96e85aa09274955bb6bd483eaf5b12abadade01010478154b0ec70284c1b1526"}, + {file = "lxml-5.2.2-cp310-cp310-win32.whl", hash = "sha256:f956196ef61369f1685d14dad80611488d8dc1ef00be57c0c5a03064005b0f30"}, + {file = "lxml-5.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:875a3f90d7eb5c5d77e529080d95140eacb3c6d13ad5b616ee8095447b1d22e7"}, + {file = "lxml-5.2.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:45f9494613160d0405682f9eee781c7e6d1bf45f819654eb249f8f46a2c22545"}, + {file = "lxml-5.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b0b3f2df149efb242cee2ffdeb6674b7f30d23c9a7af26595099afaf46ef4e88"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d28cb356f119a437cc58a13f8135ab8a4c8ece18159eb9194b0d269ec4e28083"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:657a972f46bbefdbba2d4f14413c0d079f9ae243bd68193cb5061b9732fa54c1"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b74b9ea10063efb77a965a8d5f4182806fbf59ed068b3c3fd6f30d2ac7bee734"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07542787f86112d46d07d4f3c4e7c760282011b354d012dc4141cc12a68cef5f"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:303f540ad2dddd35b92415b74b900c749ec2010e703ab3bfd6660979d01fd4ed"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2eb2227ce1ff998faf0cd7fe85bbf086aa41dfc5af3b1d80867ecfe75fb68df3"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:1d8a701774dfc42a2f0b8ccdfe7dbc140500d1049e0632a611985d943fcf12df"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:56793b7a1a091a7c286b5f4aa1fe4ae5d1446fe742d00cdf2ffb1077865db10d"}, + {file = "lxml-5.2.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb00b549b13bd6d884c863554566095bf6fa9c3cecb2e7b399c4bc7904cb33b5"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a2569a1f15ae6c8c64108a2cd2b4a858fc1e13d25846be0666fc144715e32ab"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:8cf85a6e40ff1f37fe0f25719aadf443686b1ac7652593dc53c7ef9b8492b115"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:d237ba6664b8e60fd90b8549a149a74fcc675272e0e95539a00522e4ca688b04"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0b3f5016e00ae7630a4b83d0868fca1e3d494c78a75b1c7252606a3a1c5fc2ad"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:23441e2b5339bc54dc949e9e675fa35efe858108404ef9aa92f0456929ef6fe8"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb0ba3e8566548d6c8e7dd82a8229ff47bd8fb8c2da237607ac8e5a1b8312e5"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:79d1fb9252e7e2cfe4de6e9a6610c7cbb99b9708e2c3e29057f487de5a9eaefa"}, + {file = "lxml-5.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6dcc3d17eac1df7859ae01202e9bb11ffa8c98949dcbeb1069c8b9a75917e01b"}, + {file = "lxml-5.2.2-cp311-cp311-win32.whl", hash = "sha256:4c30a2f83677876465f44c018830f608fa3c6a8a466eb223535035fbc16f3438"}, + {file = "lxml-5.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:49095a38eb333aaf44c06052fd2ec3b8f23e19747ca7ec6f6c954ffea6dbf7be"}, + {file = "lxml-5.2.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7429e7faa1a60cad26ae4227f4dd0459efde239e494c7312624ce228e04f6391"}, + {file = "lxml-5.2.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:50ccb5d355961c0f12f6cf24b7187dbabd5433f29e15147a67995474f27d1776"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc911208b18842a3a57266d8e51fc3cfaccee90a5351b92079beed912a7914c2"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33ce9e786753743159799fdf8e92a5da351158c4bfb6f2db0bf31e7892a1feb5"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec87c44f619380878bd49ca109669c9f221d9ae6883a5bcb3616785fa8f94c97"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08ea0f606808354eb8f2dfaac095963cb25d9d28e27edcc375d7b30ab01abbf6"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75a9632f1d4f698b2e6e2e1ada40e71f369b15d69baddb8968dcc8e683839b18"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74da9f97daec6928567b48c90ea2c82a106b2d500f397eeb8941e47d30b1ca85"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:0969e92af09c5687d769731e3f39ed62427cc72176cebb54b7a9d52cc4fa3b73"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:9164361769b6ca7769079f4d426a41df6164879f7f3568be9086e15baca61466"}, + {file = "lxml-5.2.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d26a618ae1766279f2660aca0081b2220aca6bd1aa06b2cf73f07383faf48927"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab67ed772c584b7ef2379797bf14b82df9aa5f7438c5b9a09624dd834c1c1aaf"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:3d1e35572a56941b32c239774d7e9ad724074d37f90c7a7d499ab98761bd80cf"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:8268cbcd48c5375f46e000adb1390572c98879eb4f77910c6053d25cc3ac2c67"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e282aedd63c639c07c3857097fc0e236f984ceb4089a8b284da1c526491e3f3d"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfdc2bfe69e9adf0df4915949c22a25b39d175d599bf98e7ddf620a13678585"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4aefd911793b5d2d7a921233a54c90329bf3d4a6817dc465f12ffdfe4fc7b8fe"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8b8df03a9e995b6211dafa63b32f9d405881518ff1ddd775db4e7b98fb545e1c"}, + {file = "lxml-5.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f11ae142f3a322d44513de1018b50f474f8f736bc3cd91d969f464b5bfef8836"}, + {file = "lxml-5.2.2-cp312-cp312-win32.whl", hash = "sha256:16a8326e51fcdffc886294c1e70b11ddccec836516a343f9ed0f82aac043c24a"}, + {file = "lxml-5.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:bbc4b80af581e18568ff07f6395c02114d05f4865c2812a1f02f2eaecf0bfd48"}, + {file = "lxml-5.2.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e3d9d13603410b72787579769469af730c38f2f25505573a5888a94b62b920f8"}, + {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38b67afb0a06b8575948641c1d6d68e41b83a3abeae2ca9eed2ac59892b36706"}, + {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c689d0d5381f56de7bd6966a4541bff6e08bf8d3871bbd89a0c6ab18aa699573"}, + {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:cf2a978c795b54c539f47964ec05e35c05bd045db5ca1e8366988c7f2fe6b3ce"}, + {file = "lxml-5.2.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:739e36ef7412b2bd940f75b278749106e6d025e40027c0b94a17ef7968d55d56"}, + {file = "lxml-5.2.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:d8bbcd21769594dbba9c37d3c819e2d5847656ca99c747ddb31ac1701d0c0ed9"}, + {file = "lxml-5.2.2-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:2304d3c93f2258ccf2cf7a6ba8c761d76ef84948d87bf9664e14d203da2cd264"}, + {file = "lxml-5.2.2-cp36-cp36m-win32.whl", hash = "sha256:02437fb7308386867c8b7b0e5bc4cd4b04548b1c5d089ffb8e7b31009b961dc3"}, + {file = "lxml-5.2.2-cp36-cp36m-win_amd64.whl", hash = "sha256:edcfa83e03370032a489430215c1e7783128808fd3e2e0a3225deee278585196"}, + {file = "lxml-5.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:28bf95177400066596cdbcfc933312493799382879da504633d16cf60bba735b"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a745cc98d504d5bd2c19b10c79c61c7c3df9222629f1b6210c0368177589fb8"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b590b39ef90c6b22ec0be925b211298e810b4856909c8ca60d27ffbca6c12e6"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b336b0416828022bfd5a2e3083e7f5ba54b96242159f83c7e3eebaec752f1716"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:c2faf60c583af0d135e853c86ac2735ce178f0e338a3c7f9ae8f622fd2eb788c"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:4bc6cb140a7a0ad1f7bc37e018d0ed690b7b6520ade518285dc3171f7a117905"}, + {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7ff762670cada8e05b32bf1e4dc50b140790909caa8303cfddc4d702b71ea184"}, + {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:57f0a0bbc9868e10ebe874e9f129d2917750adf008fe7b9c1598c0fbbfdde6a6"}, + {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:a6d2092797b388342c1bc932077ad232f914351932353e2e8706851c870bca1f"}, + {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:60499fe961b21264e17a471ec296dcbf4365fbea611bf9e303ab69db7159ce61"}, + {file = "lxml-5.2.2-cp37-cp37m-win32.whl", hash = "sha256:d9b342c76003c6b9336a80efcc766748a333573abf9350f4094ee46b006ec18f"}, + {file = "lxml-5.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b16db2770517b8799c79aa80f4053cd6f8b716f21f8aca962725a9565ce3ee40"}, + {file = "lxml-5.2.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7ed07b3062b055d7a7f9d6557a251cc655eed0b3152b76de619516621c56f5d3"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f60fdd125d85bf9c279ffb8e94c78c51b3b6a37711464e1f5f31078b45002421"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a7e24cb69ee5f32e003f50e016d5fde438010c1022c96738b04fc2423e61706"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23cfafd56887eaed93d07bc4547abd5e09d837a002b791e9767765492a75883f"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:19b4e485cd07b7d83e3fe3b72132e7df70bfac22b14fe4bf7a23822c3a35bff5"}, + {file = "lxml-5.2.2-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:7ce7ad8abebe737ad6143d9d3bf94b88b93365ea30a5b81f6877ec9c0dee0a48"}, + {file = "lxml-5.2.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e49b052b768bb74f58c7dda4e0bdf7b79d43a9204ca584ffe1fb48a6f3c84c66"}, + {file = "lxml-5.2.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d14a0d029a4e176795cef99c056d58067c06195e0c7e2dbb293bf95c08f772a3"}, + {file = "lxml-5.2.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:be49ad33819d7dcc28a309b86d4ed98e1a65f3075c6acd3cd4fe32103235222b"}, + {file = "lxml-5.2.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a6d17e0370d2516d5bb9062c7b4cb731cff921fc875644c3d751ad857ba9c5b1"}, + {file = "lxml-5.2.2-cp38-cp38-win32.whl", hash = "sha256:5b8c041b6265e08eac8a724b74b655404070b636a8dd6d7a13c3adc07882ef30"}, + {file = "lxml-5.2.2-cp38-cp38-win_amd64.whl", hash = "sha256:f61efaf4bed1cc0860e567d2ecb2363974d414f7f1f124b1df368bbf183453a6"}, + {file = "lxml-5.2.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:fb91819461b1b56d06fa4bcf86617fac795f6a99d12239fb0c68dbeba41a0a30"}, + {file = "lxml-5.2.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d4ed0c7cbecde7194cd3228c044e86bf73e30a23505af852857c09c24e77ec5d"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54401c77a63cc7d6dc4b4e173bb484f28a5607f3df71484709fe037c92d4f0ed"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:625e3ef310e7fa3a761d48ca7ea1f9d8718a32b1542e727d584d82f4453d5eeb"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:519895c99c815a1a24a926d5b60627ce5ea48e9f639a5cd328bda0515ea0f10c"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c7079d5eb1c1315a858bbf180000757db8ad904a89476653232db835c3114001"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:343ab62e9ca78094f2306aefed67dcfad61c4683f87eee48ff2fd74902447726"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:cd9e78285da6c9ba2d5c769628f43ef66d96ac3085e59b10ad4f3707980710d3"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_ppc64le.whl", hash = "sha256:546cf886f6242dff9ec206331209db9c8e1643ae642dea5fdbecae2453cb50fd"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_s390x.whl", hash = "sha256:02f6a8eb6512fdc2fd4ca10a49c341c4e109aa6e9448cc4859af5b949622715a"}, + {file = "lxml-5.2.2-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:339ee4a4704bc724757cd5dd9dc8cf4d00980f5d3e6e06d5847c1b594ace68ab"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0a028b61a2e357ace98b1615fc03f76eb517cc028993964fe08ad514b1e8892d"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:f90e552ecbad426eab352e7b2933091f2be77115bb16f09f78404861c8322981"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:d83e2d94b69bf31ead2fa45f0acdef0757fa0458a129734f59f67f3d2eb7ef32"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a02d3c48f9bb1e10c7788d92c0c7db6f2002d024ab6e74d6f45ae33e3d0288a3"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6d68ce8e7b2075390e8ac1e1d3a99e8b6372c694bbe612632606d1d546794207"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:453d037e09a5176d92ec0fd282e934ed26d806331a8b70ab431a81e2fbabf56d"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:3b019d4ee84b683342af793b56bb35034bd749e4cbdd3d33f7d1107790f8c472"}, + {file = "lxml-5.2.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb3942960f0beb9f46e2a71a3aca220d1ca32feb5a398656be934320804c0df9"}, + {file = "lxml-5.2.2-cp39-cp39-win32.whl", hash = "sha256:ac6540c9fff6e3813d29d0403ee7a81897f1d8ecc09a8ff84d2eea70ede1cdbf"}, + {file = "lxml-5.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:610b5c77428a50269f38a534057444c249976433f40f53e3b47e68349cca1425"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b537bd04d7ccd7c6350cdaaaad911f6312cbd61e6e6045542f781c7f8b2e99d2"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4820c02195d6dfb7b8508ff276752f6b2ff8b64ae5d13ebe02e7667e035000b9"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a09f6184f17a80897172863a655467da2b11151ec98ba8d7af89f17bf63dae"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:76acba4c66c47d27c8365e7c10b3d8016a7da83d3191d053a58382311a8bf4e1"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b128092c927eaf485928cec0c28f6b8bead277e28acf56800e972aa2c2abd7a2"}, + {file = "lxml-5.2.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ae791f6bd43305aade8c0e22f816b34f3b72b6c820477aab4d18473a37e8090b"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a2f6a1bc2460e643785a2cde17293bd7a8f990884b822f7bca47bee0a82fc66b"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e8d351ff44c1638cb6e980623d517abd9f580d2e53bfcd18d8941c052a5a009"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bec4bd9133420c5c52d562469c754f27c5c9e36ee06abc169612c959bd7dbb07"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:55ce6b6d803890bd3cc89975fca9de1dff39729b43b73cb15ddd933b8bc20484"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8ab6a358d1286498d80fe67bd3d69fcbc7d1359b45b41e74c4a26964ca99c3f8"}, + {file = "lxml-5.2.2-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:06668e39e1f3c065349c51ac27ae430719d7806c026fec462e5693b08b95696b"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9cd5323344d8ebb9fb5e96da5de5ad4ebab993bbf51674259dbe9d7a18049525"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89feb82ca055af0fe797a2323ec9043b26bc371365847dbe83c7fd2e2f181c34"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e481bba1e11ba585fb06db666bfc23dbe181dbafc7b25776156120bf12e0d5a6"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d6c6ea6a11ca0ff9cd0390b885984ed31157c168565702959c25e2191674a14"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3d98de734abee23e61f6b8c2e08a88453ada7d6486dc7cdc82922a03968928db"}, + {file = "lxml-5.2.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:69ab77a1373f1e7563e0fb5a29a8440367dec051da6c7405333699d07444f511"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:34e17913c431f5ae01d8658dbf792fdc457073dcdfbb31dc0cc6ab256e664a8d"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05f8757b03208c3f50097761be2dea0aba02e94f0dc7023ed73a7bb14ff11eb0"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a520b4f9974b0a0a6ed73c2154de57cdfd0c8800f4f15ab2b73238ffed0b36e"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5e097646944b66207023bc3c634827de858aebc226d5d4d6d16f0b77566ea182"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b5e4ef22ff25bfd4ede5f8fb30f7b24446345f3e79d9b7455aef2836437bc38a"}, + {file = "lxml-5.2.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ff69a9a0b4b17d78170c73abe2ab12084bdf1691550c5629ad1fe7849433f324"}, + {file = "lxml-5.2.2.tar.gz", hash = "sha256:bb2dc4898180bea79863d5487e5f9c7c34297414bad54bcd0f0852aee9cfdb87"}, +] + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html-clean = ["lxml-html-clean"] +html5 = ["html5lib"] +htmlsoup = ["BeautifulSoup4"] +source = ["Cython (>=3.0.10)"] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -755,6 +997,17 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "multitasking" +version = "0.0.11" +description = "Non-blocking Python methods using decorators" +optional = false +python-versions = "*" +files = [ + {file = "multitasking-0.0.11-py3-none-any.whl", hash = "sha256:1e5b37a5f8fc1e6cfaafd1a82b6b1cc6d2ed20037d3b89c25a84f499bd7b3dd4"}, + {file = "multitasking-0.0.11.tar.gz", hash = "sha256:4d6bc3cc65f9b2dca72fb5a787850a88dae8f620c2b36ae9b55248e51bcd6026"}, +] + [[package]] name = "mypy" version = "1.10.1" @@ -909,9 +1162,9 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.22.4", markers = "python_version < \"3.11\""}, {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] python-dateutil = ">=2.8.2" pytz = ">=2020.1" @@ -960,6 +1213,32 @@ numpy = [ ] types-pytz = ">=2022.1.1" +[[package]] +name = "peewee" +version = "3.17.6" +description = "a little orm" +optional = false +python-versions = "*" +files = [ + {file = "peewee-3.17.6.tar.gz", hash = "sha256:cea5592c6f4da1592b7cff8eaf655be6648a1f5857469e30037bf920c03fb8fb"}, +] + +[[package]] +name = "platformdirs" +version = "4.2.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] + [[package]] name = "plotly" version = "5.22.0" @@ -1117,8 +1396,8 @@ files = [ annotated-types = ">=0.4.0" pydantic-core = "2.20.1" typing-extensions = [ - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, {version = ">=4.6.1", markers = "python_version < \"3.13\""}, + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, ] [package.extras] @@ -1613,6 +1892,17 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "soupsieve" +version = "2.5" +description = "A modern CSS selector implementation for Beautiful Soup." +optional = false +python-versions = ">=3.8" +files = [ + {file = "soupsieve-2.5-py3-none-any.whl", hash = "sha256:eaa337ff55a1579b6549dc679565eac1e3d000563bcb1c8ab0d0fefbc0c2cdc7"}, + {file = "soupsieve-2.5.tar.gz", hash = "sha256:5663d5a7b3bfaeee0bc4372e7fc48f9cff4940b3eec54a6451cc5299f1097690"}, +] + [[package]] name = "starlette" version = "0.37.2" @@ -1978,6 +2268,17 @@ files = [ {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, ] +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +optional = false +python-versions = "*" +files = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, +] + [[package]] name = "websockets" version = "12.0" @@ -2078,7 +2379,35 @@ pydantic = ">=1.9.0,<3" sniffio = "*" typing-extensions = ">=4.7,<5" +[[package]] +name = "yfinance" +version = "0.2.40" +description = "Download market data from Yahoo! Finance API" +optional = false +python-versions = "*" +files = [ + {file = "yfinance-0.2.40-py2.py3-none-any.whl", hash = "sha256:328176b5690de7aa192456a15b351c20ddde31b35d479f8179f5325bd340fc0b"}, + {file = "yfinance-0.2.40.tar.gz", hash = "sha256:b053ac31229b5dc7f49a17a057f66aa7f688de2f5ddeb95c2455ec13cd89511a"}, +] + +[package.dependencies] +beautifulsoup4 = ">=4.11.1" +frozendict = ">=2.3.4" +html5lib = ">=1.1" +lxml = ">=4.9.1" +multitasking = ">=0.0.7" +numpy = ">=1.16.5" +pandas = ">=1.3.0" +peewee = ">=3.16.2" +platformdirs = ">=2.0.0" +pytz = ">=2022.5" +requests = ">=2.31" + +[package.extras] +nospam = ["requests-cache (>=1.0)", "requests-ratelimiter (>=0.3.1)"] +repair = ["scipy (>=1.6.3)"] + [metadata] lock-version = "2.0" python-versions = ">=3.9.2, <4.0" -content-hash = "087a35e24f0286c5063c952de4bf460b036579e4956f1e598b9ac2172fa82cdd" +content-hash = "1a9fce174185ad89519ad25100e129422322d6015fcd31cbaad40584780ce8dc" diff --git a/pyproject.toml b/pyproject.toml index dda4fb1e8..795d479ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ include = [ [tool.poetry.dependencies] authlib = "^1.3.0" +click = "^8.1.7" fastapi = ">= 0.89.1, < 1" gitignore-parser = "^0.1.11" jinja2 = "^3.1.4" @@ -46,6 +47,7 @@ uvicorn = ">= 0.20.0, < 1" watchdog = ">= 3.0.0, < 4" websockets = ">= 12, < 13" writer-sdk = ">= 0.5.0, < 1" +yfinance = "^0.2.40" [tool.poetry.group.build] diff --git a/src/writer/command_line.py b/src/writer/command_line.py index a6b2d767a..ddd4acc26 100644 --- a/src/writer/command_line.py +++ b/src/writer/command_line.py @@ -1,133 +1,78 @@ -import argparse -import getpass import logging import os -import re import shutil import sys -from typing import List, Optional, Union - -import writer.deploy -import writer.serve +from typing import Optional +import click +import writer.serve +from writer.deploy import cloud + +CONTEXT_SETTINGS = {'help_option_names': ['-h', '--help']} +@click.group( + context_settings=CONTEXT_SETTINGS, + help="Writer Framework CLI", +) +@click.version_option(None, '--version', '-v') def main(): - parser = argparse.ArgumentParser( - description="Run, edit or create a Writer Framework app.") - parser.add_argument("command", choices=[ - "run", "edit", "create", "hello", "deploy", "undeploy", "deployment-logs"]) - parser.add_argument( - "path", nargs="?", help="Path to the app's folder") - parser.add_argument( - "--port", help="The port on which to run the server.") - parser.add_argument( - "--api-key", help="The API key to use for deployment.") - parser.add_argument( - "--host", help="The host on which to run the server. Use 0.0.0.0 to share in your local network.") - parser.add_argument( - "--enable-remote-edit", help="Set this flag to allow non-local requests in edit mode.", action='store_true') - parser.add_argument( - "--enable-server-setup", help="Set this flag to enable server setup hook in edit mode.", action='store_true') - parser.add_argument( - "--template", help="The template to use when creating a new app.") - parser.add_argument( - "--env", nargs="*", help="Env variables for the deploy command in the format ENV_VAR=value.") - - args = parser.parse_args() - command = args.command - default_port = 3006 if command in ("edit", "hello") else 3005 - enable_remote_edit = args.enable_remote_edit - enable_server_setup_hook = args.enable_server_setup - template_name = args.template - - port = int(args.port) if args.port else default_port - absolute_app_path = _get_absolute_app_path( - args.path) if args.path else None - host = args.host if args.host else None - api_key = args.api_key if args.api_key else None - - _perform_checks(command, absolute_app_path, host, enable_remote_edit, api_key) - api_key = _get_api_key(command, api_key) - env = _validate_env_vars(args.env) - _route(command, absolute_app_path, port, host, enable_remote_edit, enable_server_setup_hook, template_name, api_key, env) - -def _validate_env_vars(env: Union[List[str], None]) -> Union[List[str], None]: - if env is None: - return None - for var in env: - regex = r"^[a-zA-Z_]+[a-zA-Z0-9_]*=.*$" - if not re.match(regex, var): - logging.error(f"Invalid environment variable: {var}, please use the format ENV_VAR=value") - sys.exit(1) - return env - -def _get_api_key(command, api_key: Optional[str]) -> Optional[str]: - if command in ("deploy", "undeploy", "deployment-logs") and api_key is None: - env_key = os.getenv("WRITER_API_KEY", None) - if env_key is not None and env_key != "": - return env_key - else: - logging.info("An API key is required to deploy a Writer Framework app.") - api_key = getpass.getpass(prompt='Enter your API key: ', stream=None) - if api_key is None or api_key == "": - logging.error("No API key provided. Exiting.") - sys.exit(1) - return api_key - else: - return api_key - - -def _perform_checks(command: str, absolute_app_path: str, host: Optional[str], enable_remote_edit: Optional[bool], api_key: Optional[str] = None): - is_path_folder = absolute_app_path is not None and os.path.isdir(absolute_app_path) - - if command in ("run", "edit", "deploy") and is_path_folder is False: - logging.error("A path to a folder containing a Writer Framework app is required. For example: writer edit my_app") - sys.exit(1) - - if command in ("create") and absolute_app_path is None: - logging.error("A target folder is required to create a Writer Framework app. For example: writer create my_app") - sys.exit(1) - - if command in ("edit", "hello") and host is not None: - logging.warning("Writer Framework has been enabled in edit mode with a host argument\nThis is enabled for local development purposes (such as a local VM).\nDon't expose Builder to the Internet. We recommend using a SSH tunnel instead.") - - if command in ("edit", "hello") and enable_remote_edit is True: - logging.warning("The remote edit flag is active. Builder will accept non-local requests. Please make sure the host is protected to avoid drive-by attacks.") - - -def _route( - command: str, - absolute_app_path: str, - port: int, - host: Optional[str], - enable_remote_edit: Optional[bool], - enable_server_setup: Optional[bool], - template_name: Optional[str], - api_key: Optional[str] = None, - env: Union[List[str], None] = None -): - if host is None: - host = "127.0.0.1" - if command in ("deploy"): - writer.deploy.deploy(absolute_app_path, api_key, env=env) - if command in ("undeploy"): - writer.deploy.undeploy(api_key) - if command in ("deployment-logs"): - writer.deploy.runtime_logs(api_key) - if command in ("edit"): - writer.serve.serve( - absolute_app_path, mode="edit", port=port, host=host, - enable_remote_edit=enable_remote_edit, enable_server_setup=enable_server_setup) - if command in ("run"): - writer.serve.serve( - absolute_app_path, mode="run", port=port, host=host, enable_server_setup=True) - elif command in ("hello"): - create_app("hello", template_name="hello", overwrite=True) - writer.serve.serve("hello", mode="edit", - port=port, host=host, enable_remote_edit=enable_remote_edit, - enable_server_setup=False) - elif command in ("create"): - create_app(absolute_app_path, template_name=template_name) + pass + +@main.command() +@click.option('--host', default="127.0.0.1", help="Host to run the app on") +@click.option('--port', default=5000, help="Port to run the app on") +@click.argument('path') +def run(path, host, port): + """Run the app from PATH folder in run mode.""" + + abs_path = os.path.abspath(path) + if not os.path.isdir(abs_path): + raise click.ClickException("A path to a folder containing a Writer Framework app is required. For example: writer run my_app") + + writer.serve.serve( + abs_path, mode="run", port=port, host=host, enable_server_setup=True) + +@main.command() +@click.option('--host', default="127.0.0.1", help="Host to run the app on") +@click.option('--port', default=5000, help="Port to run the app on") +@click.option('--enable-remote-edit', help="Set this flag to allow non-local requests in edit mode.", is_flag=True) +@click.option('--enable-server-setup', help="Set this flag to enable server setup hook in edit mode.", is_flag=True) +@click.argument('path') +def edit(path, port, host, enable_remote_edit, enable_server_setup): + """Run the app from PATH folder in edit mode.""" + + abs_path = os.path.abspath(path) + if not os.path.isdir(abs_path): + raise click.ClickException("A path to a folder containing a Writer Framework app is required. For example: writer edit my_app") + + writer.serve.serve( + abs_path, mode="edit", port=port, host=host, + enable_remote_edit=enable_remote_edit, enable_server_setup=enable_server_setup) + +@main.command() +@click.argument('path') +@click.option('--template', help="The template to use when creating a new app.") +def create(path, template): + """Create a new app in PATH folder.""" + + abs_path = os.path.abspath(path) + if os.path.isfile(abs_path): + raise click.ClickException("A target folder is required to create a Writer Framework app. For example: writer create my_app") + + create_app(os.path.abspath(path), template_name=template) + +@main.command() +@click.option('--host', default="127.0.0.1", help="Host to run the app on") +@click.option('--port', default=5000, help="Port to run the app on") +@click.option('--enable-remote-edit', help="Set this flag to allow non-local requests in edit mode.", is_flag=True) +def hello(port, host, enable_remote_edit): + """Create and run an onboarding 'Hello' app.""" + create_app("hello", template_name="hello", overwrite=True) + writer.serve.serve("hello", mode="edit", + port=port, host=host, enable_remote_edit=enable_remote_edit, + enable_server_setup=False) + +main.add_command(cloud) def create_app(app_path: str, template_name: Optional[str], overwrite=False): if template_name is None: @@ -149,15 +94,5 @@ def create_app(app_path: str, template_name: Optional[str], overwrite=False): shutil.copytree(template_path, app_path, dirs_exist_ok=True) - -def _get_absolute_app_path(app_path: str): - is_path_absolute = os.path.isabs(app_path) - if is_path_absolute: - return app_path - else: - return os.path.join(os.getcwd(), app_path) - - - if __name__ == "__main__": main() diff --git a/src/writer/deploy.py b/src/writer/deploy.py index d09bdf654..dfba5ba57 100644 --- a/src/writer/deploy.py +++ b/src/writer/deploy.py @@ -1,29 +1,95 @@ import json +import logging import os +import re import sys import tarfile import tempfile import time from datetime import datetime, timedelta -from typing import List +from typing import List, Union +import click import dateutil.parser import pytz import requests from gitignore_parser import parse_gitignore -WRITER_DEPLOY_URL = os.getenv("WRITER_DEPLOY_URL", "https://api.writer.com/v1/deployment/apps") -def deploy(path, token, env): - check_app(token) - tar = pack_project(path) - upload_package(tar, token, env) +@click.group() +def cloud(): + """A group of commands to deploy the app on writer cloud""" + pass -def undeploy(token): +@cloud.command() +@click.option('--api-key', + default=lambda: os.environ.get("WRITER_API_KEY", None), + allow_from_autoenv=True, + show_envvar=True, + envvar='WRITER_API_KEY', + prompt="Enter your API key", + hide_input=True, help="Writer API key" +) +@click.option('--env', '-e', multiple=True, default=[], help="Environment to deploy the app to") +@click.option('--force', '-f', default=False, is_flag=True, help="Ignores warnings and overwrites the app") +@click.option('--verbose', '-v', default=False, is_flag=True, help="Enable verbose mode") +@click.argument('path') +def deploy(path, api_key, env, verbose, force): + """Deploy the app from PATH folder.""" + + deploy_url = os.getenv("WRITER_DEPLOY_URL", "https://api.writer.com/v1/deployment/apps") + sleep_interval = int(os.getenv("WRITER_DEPLOY_SLEEP_INTERVAL", '5')) + + if not force: + check_app(deploy_url, api_key) + + abs_path = os.path.abspath(path) + if not os.path.isdir(abs_path): + raise click.ClickException("A path to a folder containing a Writer Framework app is required. For example: writer cloud deploy my_app") + + env = _validate_env_vars(env) + tar = pack_project(abs_path) + try: + upload_package(deploy_url, tar, api_key, env, verbose=verbose, sleep_interval=sleep_interval) + except requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + unauthorized_error() + else: + on_error_print_and_raise(e.response, verbose=verbose) + except Exception as e: + print(e) + print("Error deploying app") + sys.exit(1) + finally: + tar.close() + +def _validate_env_vars(env: Union[List[str], None]) -> Union[List[str], None]: + if env is None: + return None + for var in env: + regex = r"^[a-zA-Z_]+[a-zA-Z0-9_]*=.*$" + if not re.match(regex, var): + logging.error(f"Invalid environment variable: {var}, please use the format ENV_VAR=value") + sys.exit(1) + return env + +@cloud.command() +@click.option('--api-key', + default=lambda: os.environ.get("WRITER_API_KEY", None), + allow_from_autoenv=True, + show_envvar=True, + envvar='WRITER_API_KEY', + prompt="Enter your API key", + hide_input=True, help="Writer API key" +) +@click.option('--verbose', '-v', default=False, is_flag=True, help="Enable verbose mode") +def undeploy(api_key, verbose): + """Stop the app, app would not be available anymore.""" try: print("Undeploying app") - with requests.delete(WRITER_DEPLOY_URL, headers={"Authorization": f"Bearer {token}"}) as resp: - resp.raise_for_status() + deploy_url = os.getenv("WRITER_DEPLOY_URL", "https://api.writer.com/v1/deployment/apps") + with requests.delete(deploy_url, headers={"Authorization": f"Bearer {api_key}"}) as resp: + on_error_print_and_raise(resp, verbose=verbose) print("App undeployed") sys.exit(0) except Exception as e: @@ -31,30 +97,45 @@ def undeploy(token): print(e) sys.exit(1) -def runtime_logs(token): +@cloud.command() +@click.option('--api-key', + default=lambda: os.environ.get("WRITER_API_KEY", None), + allow_from_autoenv=True, + show_envvar=True, + envvar='WRITER_API_KEY', + prompt="Enter your API key", + hide_input=True, help="Writer API key" +) +@click.option('--verbose', '-v', default=False, is_flag=True, help="Enable verbose mode") +def logs(api_key, verbose): + """Fetch logs from the deployed app.""" + + deploy_url = os.getenv("WRITER_DEPLOY_URL", "https://api.writer.com/v1/deployment/apps") + sleep_interval = int(os.getenv("WRITER_DEPLOY_SLEEP_INTERVAL", '5')) + try: build_time = datetime.now(pytz.timezone('UTC')) - timedelta(days=4) start_time = build_time while True: prev_start = start_time end_time = datetime.now(pytz.timezone('UTC')) - data = get_logs(token, { + data = get_logs(deploy_url, api_key, { "buildTime": build_time, "startTime": start_time, "endTime": end_time, - }) + }, verbose=verbose) # order logs by date and print logs = data['logs'] for log in logs: start_time = start_time if start_time > log[0] else log[0] if start_time == prev_start: start_time = datetime.now(pytz.timezone('UTC')) - time.sleep(5) + time.sleep(sleep_interval) continue for log in logs: print(log[0], log[1]) print(start_time) - time.sleep(1) + time.sleep(sleep_interval) except Exception as e: print(e) sys.exit(1) @@ -91,9 +172,8 @@ def match(file_path) -> bool: return False return f - -def check_app(token): - url = get_app_url(token) +def check_app(deploy_url, token): + url = _get_app_url(deploy_url, token) if url: print("[WARNING] This token was already used to deploy a different app") print(f"[WARNING] URL: {url}") @@ -101,8 +181,8 @@ def check_app(token): if input("[WARNING] Are you sure you want to overwrite? (y/N)").lower() != "y": sys.exit(1) -def get_app_url(token): - with requests.get(WRITER_DEPLOY_URL, params={"lineLimit": 1}, headers={"Authorization": f"Bearer {token}"}) as resp: +def _get_app_url(deploy_url: str, token: str) -> Union[str, None]: + with requests.get(deploy_url, params={"lineLimit": 1}, headers={"Authorization": f"Bearer {token}"}) as resp: try: resp.raise_for_status() except Exception as e: @@ -112,13 +192,9 @@ def get_app_url(token): data = resp.json() return data['status']['url'] -def get_logs(token, params): - with requests.get(WRITER_DEPLOY_URL, params = params, headers={"Authorization": f"Bearer {token}"}) as resp: - try: - resp.raise_for_status() - except Exception as e: - print(resp.json()) - raise e +def get_logs(deploy_url, token, params, verbose=False): + with requests.get(deploy_url, params = params, headers={"Authorization": f"Bearer {token}"}) as resp: + on_error_print_and_raise(resp, verbose=verbose) data = resp.json() logs = [] @@ -130,8 +206,8 @@ def get_logs(token, params): logs.sort(key=lambda x: x[0]) return {"status": data["status"], "logs": logs} -def check_service_status(token, build_id, build_time, start_time, end_time, last_status): - data = get_logs(token, { +def check_service_status(deploy_url, token, build_id, build_time, start_time, end_time, last_status): + data = get_logs(deploy_url, token, { "buildId": build_id, "buildTime": build_time, "startTime": start_time, @@ -156,53 +232,54 @@ def dictFromEnv(env: List[str]) -> dict: return env_dict -def upload_package(tar, token, env): - try: - print("Uploading package to deployment server") - tar.seek(0) - files = {'file': tar} - start_time = datetime.now(pytz.timezone('UTC')) - build_time = start_time - with requests.post( - url = WRITER_DEPLOY_URL, - headers = { - "Authorization": f"Bearer {token}", - }, - files=files, - data={"envs": json.dumps(dictFromEnv(env))} - ) as resp: - try: - resp.raise_for_status() - except Exception as e: - print(resp.json()) - raise e - data = resp.json() - build_id = data["buildId"] - - print("Package uploaded. Building...") - status = "WAITING" - url = "" - while status not in ["COMPLETED", "FAILED"] and datetime.now(pytz.timezone('UTC')) < build_time + timedelta(minutes=5): - end_time = datetime.now(pytz.timezone('UTC')) - status, url = check_service_status(token, build_id, build_time, start_time, end_time, status) - time.sleep(5) - start_time = end_time - if status == "COMPLETED": - print("Deployment successful") - print(f"URL: {url}") - sys.exit(0) - else: - time.sleep(5) - check_service_status(token, build_id, build_time, start_time, datetime.now(pytz.timezone('UTC')), status) - print("Deployment failed") - sys.exit(1) +def upload_package(deploy_url, tar, token, env, verbose=False, sleep_interval=5): + print("Uploading package to deployment server") + tar.seek(0) + files = {'file': tar} + start_time = datetime.now(pytz.timezone('UTC')) + build_time = start_time - except Exception as e: - print("Error uploading package") - print(e) + with requests.post( + url = deploy_url, + headers = { + "Authorization": f"Bearer {token}", + }, + files=files, + data={"envs": json.dumps(dictFromEnv(env))} + ) as resp: + on_error_print_and_raise(resp, verbose=verbose) + data = resp.json() + build_id = data["buildId"] + + print("Package uploaded. Building...") + status = "WAITING" + url = "" + while status not in ["COMPLETED", "FAILED"] and datetime.now(pytz.timezone('UTC')) < build_time + timedelta(minutes=5): + end_time = datetime.now(pytz.timezone('UTC')) + status, url = check_service_status(deploy_url, token, build_id, build_time, start_time, end_time, status) + time.sleep(sleep_interval) + start_time = end_time + + if status == "COMPLETED": + print("Deployment successful") + print(f"URL: {url}") + sys.exit(0) + else: + time.sleep(sleep_interval) + check_service_status(deploy_url, token, build_id, build_time, start_time, datetime.now(pytz.timezone('UTC')), status) + print("Deployment failed") sys.exit(1) - finally: - tar.close() +def on_error_print_and_raise(resp, verbose=False): + try: + resp.raise_for_status() + except Exception as e: + if verbose: + print(resp.json()) + raise e + +def unauthorized_error(): + print("Unauthorized. Please check your API key.") + sys.exit(1) diff --git a/tests/backend/fixtures/cloud_deploy_fixtures.py b/tests/backend/fixtures/cloud_deploy_fixtures.py new file mode 100644 index 000000000..bfe0b734b --- /dev/null +++ b/tests/backend/fixtures/cloud_deploy_fixtures.py @@ -0,0 +1,132 @@ +import contextlib +import json +import re +import threading +import time +from datetime import datetime, timedelta +from typing import Annotated, Union + +import pytest +import pytz +import uvicorn +from click.testing import CliRunner +from fastapi import Body, Depends, FastAPI, File, Header, UploadFile +from writer.command_line import main + + +def create_app(): + class State: + log_counter = 0 + envs: Union[str, None] = None + + state = State() + app = FastAPI() + + + @app.post("/deploy") + def deploy( + state: Annotated[State, Depends(lambda: state)], + authorization: Annotated[str, Header(description="The API key")], + file: UploadFile = File(...), + envs: Annotated[str, Body(description = 'JSON object of environment variables')] = "{}", + ): + state.envs = envs + return {"status": "ok", "buildId": "123"} + + + @app.get("/deploy") + def get_status( + state: Annotated[State, Depends(lambda: state)], + authorization: Annotated[str, Header(description="The API key")], + ): + + def get_time(n): + return (datetime.now(pytz.timezone('UTC')) + timedelta(seconds=n)).isoformat() + + state.log_counter += 1 + if (authorization == "Bearer full"): + if state.log_counter == 1: # first call is to checking if app exist + return { + "logs": [], + "status": { + "url": None, + "status": "PENDING", + } + } + if state.log_counter == 2: + return { + "logs": [ + {"log": f"{get_time(-7)} stdout F {state.envs}"}, + {"log": f"{get_time(-6)} stdout F "}, + {"log": f"{get_time(-5)} stdout F "}, + ], + "status": { + "url": None, + "status": "BUILDING", + } + } + if state.log_counter == 3: + return { + "logs": [ + {"log": f"{get_time(-2)} stdout F "}, + {"log": f"{get_time(-4)} stdout F "}, + ], + "status": { + "url": "https://full.my-app.com", + "status": "COMPLETED", + } + } + if (authorization == "Bearer test"): + return { + "logs": [ + {"log": f"20210813163223 stdout F {state.envs}"}, + ], + "status": { + "url": "https://my-app.com", + "status": "COMPLETED", + } + } + return { + "logs": [], + "status": { + "url": None, + "status": "FAILED", + } + } + + @app.delete("/deploy") + def undeploy( + authorization: Annotated[str, Header(description="The API key")], + ): + return {"status": "ok"} + return app + + +class Server(uvicorn.Server): + def __init__(self): + config = uvicorn.Config(create_app(), host="127.0.0.1", port=8888, log_level="info") + super().__init__(config) + self.keep_running = True + + def install_signal_handlers(self): + pass + + @contextlib.contextmanager + def run_in_thread(self): + thread = threading.Thread(target=self.run) + thread.start() + try: + while not self.started: + time.sleep(1e-3) + yield + finally: + self.should_exit = True + thread.join() + + + +@contextlib.contextmanager +def use_fake_cloud_deploy_server(): + server = Server() + with server.run_in_thread(): + yield server diff --git a/tests/backend/test_cli.py b/tests/backend/test_cli.py new file mode 100644 index 000000000..9635e809f --- /dev/null +++ b/tests/backend/test_cli.py @@ -0,0 +1,101 @@ +import ctypes +import os +import platform +import subprocess +import time + +import requests +from click.testing import CliRunner +from writer.command_line import main + + +def test_version(): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(main, ['-v']) + assert result.exit_code == 0 + assert 'version' in result.output + +def test_create_default(): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(main, ['create', './my_app']) + print(result.output) + assert result.exit_code == 0 + assert os.path.exists('./my_app') + assert os.path.exists('./my_app/ui.json') + assert os.path.exists('./my_app/main.py') + with open('./my_app/pyproject.toml') as f: + content = f.read() + assert content.find('name = "writer-framework-default"') != -1 + +def test_create_specific_template(): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(main, ['create', './my_app', '--template', 'hello']) + print(result.output) + assert result.exit_code == 0 + assert os.path.exists('./my_app') + assert os.path.exists('./my_app/ui.json') + assert os.path.exists('./my_app/main.py') + with open('./my_app/pyproject.toml') as f: + content = f.read() + assert content.find('name = "writer-framework-hello"') != -1 + + +def test_run(): + runner = CliRunner() + p = None + try: + with runner.isolated_filesystem(): + runner.invoke(main, ['create', './my_app', '--template', 'hello']) + p = subprocess.Popen(["writer", "run", "my_app", "--port", "5001"], shell=(platform.system() == 'Windows')) + + retry = 0 + success = False + while True: + try: + response = requests.get('http://127.0.0.1:5001') + if response.status_code == 200: + success = True + break + if response.status_code != 200: + raise Exception("Status code is not 200") + except Exception: + time.sleep(1) + retry += 1 + if retry > 10: + break + assert success == True + finally: + if p is not None: + p.terminate() + + +def test_edit(): + runner = CliRunner() + p = None + try: + with runner.isolated_filesystem(): + runner.invoke(main, ['create', './my_app', '--template', 'hello']) + p = subprocess.Popen(["writer", "edit", "my_app", "--port", "5002"], shell=(platform.system() == 'Windows')) + + retry = 0 + success = False + while True: + try: + response = requests.get('http://127.0.0.1:5002') + if response.status_code == 200: + success = True + break + if response.status_code != 200: + raise Exception("Status code is not 200") + except Exception: + retry += 1 + time.sleep(1) + if retry > 10: + break + assert success == True + finally: + if p is not None: + p.terminate() diff --git a/tests/backend/test_deploy.py b/tests/backend/test_deploy.py new file mode 100644 index 000000000..637aa0f36 --- /dev/null +++ b/tests/backend/test_deploy.py @@ -0,0 +1,170 @@ +import json +import re + +from click.testing import CliRunner +from writer.command_line import main + +from backend.fixtures.cloud_deploy_fixtures import use_fake_cloud_deploy_server + + +def _assert_warning(result, url = "https://my-app.com"): + found = re.search(f".WARNING. URL: {url}", result.output) + + assert found is not None + + +def _assert_url(result, expectedUrl): + url = re.search("URL: (.*)$", result.output) + assert url and url.group(1) == expectedUrl + +def _extract_envs(result): + content = re.search("(.*)", result.output) + assert content is not None + return json.loads(content.group(1)) + + +def test_deploy(): + runner = CliRunner() + with runner.isolated_filesystem(), use_fake_cloud_deploy_server(): + result = runner.invoke(main, ['create', './my_app']) + assert result.exit_code == 0 + result = runner.invoke(main, ['cloud', 'deploy', './my_app'], env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + 'WRITER_API_KEY': 'test', + }, input='y\n') + print(result.output) + assert result.exit_code == 0 + _assert_warning(result) + _assert_url(result, 'https://my-app.com') + +def test_deploy_force_flag(): + runner = CliRunner() + with runner.isolated_filesystem(), use_fake_cloud_deploy_server(): + + result = runner.invoke(main, ['create', './my_app']) + assert result.exit_code == 0 + result = runner.invoke(main, ['cloud', 'deploy', './my_app', '--force'], env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + 'WRITER_API_KEY': 'test', + }) + print(result.output) + assert result.exit_code == 0 + found = re.search(".WARNING. URL: https://my-app.com", result.output) + assert found is None + _assert_url(result, 'https://my-app.com') + +def test_deploy_api_key_option(): + runner = CliRunner() + with runner.isolated_filesystem(), use_fake_cloud_deploy_server(): + + result = runner.invoke(main, ['create', './my_app']) + assert result.exit_code == 0 + result = runner.invoke(main, ['cloud', 'deploy', './my_app', '--api-key', 'test'], env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + 'WRITER_API_KEY': 'fail', + }, input='y\n') + print(result.output) + assert result.exit_code == 0 + _assert_warning(result) + _assert_url(result, 'https://my-app.com') + +def test_deploy_api_key_prompt(): + runner = CliRunner() + with runner.isolated_filesystem(), use_fake_cloud_deploy_server(): + + result = runner.invoke(main, ['create', './my_app']) + assert result.exit_code == 0 + result = runner.invoke(main, ['cloud', 'deploy', './my_app'], env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + }, input='test\ny\n') + print(result.output) + assert result.exit_code == 0 + _assert_warning(result) + _assert_url(result, 'https://my-app.com') + +def test_deploy_warning(): + runner = CliRunner() + with runner.isolated_filesystem(), use_fake_cloud_deploy_server(): + + result = runner.invoke(main, ['create', './my_app']) + assert result.exit_code == 0 + result = runner.invoke(main, ['cloud', 'deploy', './my_app'], env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + 'WRITER_API_KEY': 'test', + }) + print(result.output) + assert result.exit_code == 1 + +def test_deploy_env(): + runner = CliRunner() + with runner.isolated_filesystem(), use_fake_cloud_deploy_server(): + + result = runner.invoke(main, ['create', './my_app']) + assert result.exit_code == 0 + result = runner.invoke(main, + args = [ + 'cloud', 'deploy', './my_app', + '-e', 'ENV1=test', '-e', 'ENV2=other' + ], + env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + 'WRITER_API_KEY': 'test', + 'WRITER_DEPLOY_SLEEP_INTERVAL': '0' + }, + input='y\n' + ) + print(result.output) + assert result.exit_code == 0 + envs = _extract_envs(result) + assert envs['ENV1'] == 'test' + assert envs['ENV2'] == 'other' + _assert_url(result, 'https://my-app.com') + +def test_deploy_full_flow(): + runner = CliRunner() + with runner.isolated_filesystem(), use_fake_cloud_deploy_server(): + + result = runner.invoke(main, ['create', './my_app']) + assert result.exit_code == 0 + result = runner.invoke(main, + args = [ + 'cloud', 'deploy', './my_app', + '-e', 'ENV1=test', '-e', 'ENV2=other' + ], + env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + 'WRITER_API_KEY': 'full', + 'WRITER_DEPLOY_SLEEP_INTERVAL': '0' + }, + ) + print(result.output) + assert result.exit_code == 0 + envs = _extract_envs(result) + assert envs['ENV1'] == 'test' + assert envs['ENV2'] == 'other' + _assert_url(result, 'https://full.my-app.com') + + logs = re.findall("", result.output) + assert logs[0] == "" + assert logs[1] == "" + assert logs[2] == "" + assert logs[3] == "" + + +def test_undeploy(): + runner = CliRunner() + with runner.isolated_filesystem(), use_fake_cloud_deploy_server(): + result = runner.invoke(main, + args = [ + 'cloud', 'undeploy' + ], + env={ + 'WRITER_DEPLOY_URL': 'http://localhost:8888/deploy', + 'WRITER_API_KEY': 'full', + 'WRITER_DEPLOY_SLEEP_INTERVAL': '0' + }, + ) + print(result.output) + assert re.search("App undeployed", result.output) + assert result.exit_code == 0 + diff --git a/tests/conftest.py b/tests/conftest.py index f75f0b495..d457e68db 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,3 +51,15 @@ def _manage_launch_args(app_dir: str, app_command: Literal["run", "edit"], load: finally: ar.shut_down() return _manage_launch_args + +@pytest.fixture(autouse=True) +def build_app_provisionning(): + import os + import shutil + + root_dir = os.path.dirname(os.path.dirname(__file__)) + + if os.path.isdir(os.path.join(root_dir, 'src/writer/app_templates')): + shutil.rmtree(os.path.join(root_dir, 'src/writer/app_templates')) + + shutil.copytree( os.path.join(root_dir, 'apps'), os.path.join(root_dir, 'src/writer/app_templates'))