├── .env-template ├── .github └── workflows │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .python-version ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── bite_cli.org ├── demos │ ├── display.gif │ ├── download.gif │ ├── init.gif │ └── submit.gif └── docs.md ├── pyproject.toml ├── pytest.ini ├── src └── eatlocal │ ├── __init__.py │ ├── __main__.py │ ├── console.py │ ├── constants.py │ └── eatlocal.py ├── tests ├── __init__.py ├── conftest.py ├── test_cli.py ├── test_e2e.py ├── test_units.py ├── testing_content │ ├── bites_api.json │ ├── summing_content.txt │ └── testing_env └── testing_repo │ ├── .local_bites.json │ └── parse-a-list-of-names │ ├── bite.html │ └── names.py └── uv.lock /.env-template: -------------------------------------------------------------------------------- 1 | PYBITES_USERNAME= 2 | PYBITES_PASSWORD= 3 | PYBITES_REPO= 4 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build-and-publish: 10 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Install uv 17 | uses: astral-sh/setup-uv@v3 18 | with: 19 | version: "0.5.25" 20 | enable-cache: true 21 | cache-dependency-glob: "uv.lock" 22 | 23 | - name: Build distributions 24 | run: uv build 25 | 26 | - name: Publish to TestPyPI (for test tags) 27 | if: startsWith(github.ref, 'refs/tags/test-') 28 | run: uv publish --index-url https://test.pypi.org/legacy/ --token ${{ secrets.TEST_PYPI_API_TOKEN }} 29 | 30 | - name: Publish to PyPI (for versioned releases) 31 | if: startsWith(github.ref, 'refs/tags/v') 32 | run: uv publish --token ${{ secrets.PYPI_API_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Install uv 18 | uses: astral-sh/setup-uv@v3 19 | with: 20 | version: "0.5.25" 21 | enable-cache: true 22 | cache-dependency-glob: "uv.lock" 23 | 24 | - name: Set up Python 25 | run: uv python install 26 | 27 | - name: Install the project 28 | run: uv sync --all-extras --dev 29 | 30 | - name: Run tests 31 | run: uv run pytest tests -k "not test_e2e" 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | venv 3 | dist 4 | __pycache__ 5 | .DS_Store 6 | .env 7 | /* pybites_bite*.zip */ 8 | wily-report.html 9 | /* [0-9]* */ 10 | .coverage 11 | .ropeproject 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.7.2 5 | hooks: 6 | # Run the linter. 7 | - id: ruff 8 | args: [--fix, --exit-non-zero-on-fix, --show-fixes] 9 | # Run the formatter. 10 | - id: ruff-format 11 | 12 | - repo: https://github.com/astral-sh/uv-pre-commit 13 | # uv version. 14 | rev: 0.4.30 15 | hooks: 16 | # Update the uv lockfile 17 | - id: uv-lock 18 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Contributing Guide 2 | 3 | Welcome to the **EatLocal**! We appreciate your interest in contributing. This guide will help you set up the project locally and get started with development. 4 | 5 | Follow these steps to set up the **EatLocal** project locally: 6 | 7 | --- 8 | 9 | ### 1. **Fork and Clone the Repository** 10 | 1. Fork the repository to your GitHub account. 11 | 2. [Clone your forked repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) to your local machine. 12 | 3. Set the upstream repository to keep your fork updated: 13 | ```bash 14 | git remote add upstream https://github.com/PyBites-Open-Source/eatlocal.git 15 | git fetch upstream 16 | ``` 17 | 18 | 19 | ### 2. **Set Up Python Environment and Install Dependencies** 20 | 1. Ensure you have **Python 3.12.0** or greater installed. You can download it from the [official Python website](https://www.python.org/downloads/release/python-3120/). 21 | 22 | 2. [Install uv](https://docs.astral.sh/uv/getting-started/installation/) 23 | 24 | 3. Sync and install all dependencies: 25 | ```Python 26 | uv sync 27 | ``` 28 | * If you want to prevent having to reinstall the package, you can also install it in editable mode: 29 | ```Python 30 | uv pip install --editable . 31 | ``` 32 | 33 | ### 3. Install Pre-commit Hooks 34 | 35 | 1. Install pre-commit hooks to ensure code consistency: 36 | 37 | ```Python 38 | uvx pre-commit install 39 | ``` 40 | 41 | 2. *(Optional)* Run the hooks against all files: 42 | ```Python 43 | uvx pre-commit run --all-files 44 | ``` 45 | 46 | ### 4. Verify the Setup 47 | 48 | Check that everything is set up correctly by running the project: 49 | 50 | ```Python 51 | uv run eatlocal 52 | ``` 53 | 54 | Now you are all set. We look forward to your contributions. Thank you for contributing to **EatLocal**! 🚀 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Russell Helmstedter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![forthebadge](https://forthebadge.com/images/badges/made-with-python.svg)](https://forthebadge.com) 2 | 3 | # eatlocal 4 | 5 | Eatlocal helps users solve [Pybites](https://pybitesplatform.com) code challenges locally. This cli tool allows you to download bites from the platform. You can display bite directions directly in the terminal. Once you have solved the bite you can use eatlocal to submit and it offers to open your default browser the corresponding bite page. 6 | 7 | ## Table of Contents 8 | 9 | - [eatlocal](#eatlocal) 10 | - [Updates](#updates) 11 | - [Version](#version-080) 12 | - [Breaking Changes](#breaking-changes) 13 | - [Table of Contents](#table-of-contents) 14 | - [Usage](#usage) 15 | - [Installation](#installation) 16 | - [macOS/Linux](#macoslinux) 17 | - [Windows](#windows) 18 | - [Setup](#setup) 19 | 20 | ## Updates 21 | 22 | eatlocal 1.0.0+ has been updated to work with version 2.0 of the PyBites platform. 23 | 24 | ### Breaking Changes 25 | 26 | #### Version 1.1.1 27 | + Moved the local bites database to a new location. It used to be in the directory set by the user, now eatlocal will look for it in `~/.eatlocal`. Run `eatlocal init` to set the new location. 28 | 29 | #### Version 1.1.0 30 | + Uses the bite slug for the name of the directory. 31 | 32 | #### Version 1.0.0 33 | + eatlocal version `1.0.0` only works on the new platform (v2). 34 | + eatlocal directory no longer has to be a git repository. 35 | + Submitting a bite no longer pushes it to GitHub. 36 | + Bite directories are now names by the bites instead of the number. 37 | + No need to download chrome and chrome driver. 38 | + No more verbose mode 39 | 40 | 41 | ## Setup 42 | 43 | Run `eatlocal init` to configure your PyBites username, PyBites password*, and local where you will solve your bites. 44 | 45 | *Note: If you signed up for PyBites by authenticating through GitHub or Google, you will need to set a password on the platform first. 46 | 47 | ## Usage 48 | 49 | Set up your email, password, and directory where you will solve your bites. It will also create the local bites database, and the cache database: 50 | 51 | ```bash 52 | eatlocal init 53 | ``` 54 | 55 | Download bites: 56 | 57 | ```bash 58 | # Show all bites 59 | eatlocal download 60 | 61 | # Show only newbie bites 62 | eatlocal download --level newbie 63 | 64 | # Show only intro bites 65 | eatlocal download --level intro 66 | 67 | # Show only beginner bites 68 | eatlocal download --level Beginner 69 | 70 | # Show only intermediate bites 71 | eatlocal download --level Intermediate 72 | 73 | # Show only advanced bites 74 | eatlocal download --level Advanced 75 | ``` 76 | 77 | If you want to force a re-download of a given bite use the `--force` flag. This will overwrite the bite directory. 78 | 79 | ```bash 80 | eatlocal download --force 81 | ``` 82 | 83 | By default, `eatlocal` will cache the bites list and update it every 30 days. If you are not seeing a new bite, then you can clear the cache with the `--clear-cache` flag. 84 | 85 | ```bash 86 | eatlocal download --clear-cache 87 | ``` 88 | 89 | Display bites in the terminal: 90 | 91 | ```bash 92 | # change the theme with -t 93 | eatlocal display 94 | ``` 95 | 96 | Submit bites: 97 | 98 | ```bash 99 | eatlocal submit 100 | ``` 101 | 102 | ## Installation 103 | 104 | There are a few options for install eatlocal. 105 | 106 | ### Using uv 107 | 108 | If you have [uv](https://github.com/astral-sh/uv) installed: 109 | 110 | ```bash 111 | uv tool install eatlocal 112 | ``` 113 | 114 | ### Using pipx 115 | 116 | If you have [pipx](https://pypa.github.io/pipx/) installed: 117 | 118 | ```bash 119 | pipx install eatlocal 120 | ``` 121 | 122 | ### macOS/Linux 123 | 124 | ```bash 125 | pip3 install eatlocal 126 | ``` 127 | 128 | ### Windows 129 | 130 | ```bash 131 | pip install eatlocal 132 | ``` 133 | 134 | ## Contributing 135 | 136 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**, be sure to explore the [contributing guide](CONTRIBUTING.md) for more information.. -------------------------------------------------------------------------------- /docs/bite_cli.org: -------------------------------------------------------------------------------- 1 | #+TITLE: Eatlocal 2 | #+AUTHOR: Russell Helmstedter 3 | #+DATE: <2022-04-26> 4 | 5 | * Original Work Flow 6 | This is an outline for a project that helps people solve PyBites on the command line. Currently the workflow is annoying (at best). Consider the following: 7 | 8 | 1. Navigate to the bite page on the website and 9 | 2. click the drop down button to solve locally. 10 | 3. Download the zip file for that bite. 11 | 4. Navigate to the directory where the bite was downloaded. 12 | 5. Unzip the new directory into a folder named by the bite number: `../pybites/` 13 | 6. Solve the bite. 14 | 7. Go through the steps to push up to GitHub: 15 | + =git add .= 16 | + =git commit -m 'solved '= 17 | + =git push= 18 | 8. Navigate back to website. 19 | 9. Download the code from GitHub. 20 | 10. Submit bite. 21 | 22 | * Wishlist 23 | 24 | What I would like to have happen: 25 | 1. run `eatlocal download ` (Package downloads and extracts a bite into PyBites directory.) 26 | 2. run `eatlocal display ` (Bite instructions and python file are displayed in the terminal.) 27 | 2. Solve bite 28 | 3. run `eatlocal submit ` (Submit bite and open web browser to the corresponding bite page.) 29 | 30 | * Current Work Flow 31 | I have a working version of eatlocal and the [[Wishlist][wishlist]] has been completely fulfilled. It can extract a zipped file that is already in the local repo. It can now submit a bite by first pushing it to GitHub then opening your default web browser to the corresponding bite page. It can download and extract a bite using selenium in headless mode. It works in windows powershell and on macOS. 32 | 33 | * Enhancements 34 | ** TODO Add a display command to read the instructions of the bite in the terminal. 35 | + [X] find a nice way of finding the pybites repo 36 | + [X] nice way of exiting the read display 37 | 38 | ** TODO create command to download data associated with bite 39 | Some bites (e.g.,[[https://codechalleng.es/bites/3/][bite 3]]) download external files and store them in a temp variable. These are usually handled by =urllib.request=. It would be awesome to have eatlocal download the data files to explore while trying to code. 40 | 41 | *** Potential Problems 42 | **** Problem 1 43 | ***** Problem Statement 44 | 45 | Not all bites are formatted the same way. E.g., below are snippets from the source code of two bites. Bite 3 stores the URL and the name of the file in separate variables. Bite 7 only has the URL stored inside the =URLlib.request=. 46 | #+BEGIN_SRC python 47 | ################################## 48 | ##### Source code for bite 3 ##### 49 | ################################## 50 | import os 51 | import urllib.request 52 | 53 | # PREWORK 54 | TMP = os.getenv("TMP", "/tmp") 55 | S3 = "https://bites-data.s3.us-east-2.amazonaws.com/" 56 | DICT = "dictionary.txt" 57 | DICTIONARY = os.path.join(TMP, DICT) 58 | urllib.request.urlretrieve(f"{S3}{DICT}", DICTIONARY) 59 | 60 | ################################## 61 | ##### Source code for bite 7 ##### 62 | ################################## 63 | from datetime import datetime 64 | import os 65 | import urllib.request 66 | 67 | SHUTDOWN_EVENT = "Shutdown initiated" 68 | 69 | # prep: read in the logfile 70 | tmp = os.getenv("TMP", "/tmp") 71 | logfile = os.path.join(tmp, "log") 72 | urllib.request.urlretrieve( 73 | "https://bites-data.s3.us-east-2.amazonaws.com/messages.log", logfile 74 | ) 75 | #+END_SRC 76 | 77 | #+RESULTS: 78 | : None 79 | 80 | ***** Solutions 81 | 1. Ask Bob if the URLs for the data files are easily accessed in the database. 82 | 2. Rewrite the bites containing data files in a consisent manner that can be parsed by =eatlocal=. 83 | 84 | * known issues 85 | + [x] Erik mentioned that he needed to set up a pasword since he had authenticated pybites using github. need to add this caveat to the =readme.md=. 86 | + [X] once Erik's pull requests are merged, I need to restructure the =readme.md=. perhaps have a quickstart documentation directly in the =readme.md= and then the full help documentation linked? 87 | + [ ] need tests that cover submitting a bite 88 | + [X] need tests that cover the display function 89 | 90 | * Testing Strategy 91 | ** Download 92 | ** Display 93 | ** Submit 94 | + [X] submitting from non-git repo 95 | + [X] submitting bite that doesn't exist 96 | + [ ] end2end submit? Maybe have selenium unsubmit a bite and resumbit? 97 | * v2 98 | ** TODO need to deal with submitted bites that don't pass. Currently is times out and crashes. 99 | -------------------------------------------------------------------------------- /docs/demos/display.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyBites-Open-Source/eatlocal/828381fcf18223688897cc964161b6361de27070/docs/demos/display.gif -------------------------------------------------------------------------------- /docs/demos/download.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyBites-Open-Source/eatlocal/828381fcf18223688897cc964161b6361de27070/docs/demos/download.gif -------------------------------------------------------------------------------- /docs/demos/init.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyBites-Open-Source/eatlocal/828381fcf18223688897cc964161b6361de27070/docs/demos/init.gif -------------------------------------------------------------------------------- /docs/demos/submit.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyBites-Open-Source/eatlocal/828381fcf18223688897cc964161b6361de27070/docs/demos/submit.gif -------------------------------------------------------------------------------- /docs/docs.md: -------------------------------------------------------------------------------- 1 | # eatlocal 2 | 3 | Eatlocal helps the user solve [PyBites](https://codechallang.es) code challenges locally. This cli tool allows you to download, unzip, and organize bites according to the expected structure from the directions on the PyBites website. Once you have solved the bite you can use eatlocal to submit and it will open a bowser tab at the correct location. 4 | 5 | ## Table of Contents 6 | 7 | + [Usage](#Usage) 8 | + [Installation](#Installation) 9 | + [macOS/Linux](#macoslinux) 10 | + [Windows](#Windows) 11 | + [Setup](#Setup) 12 | + [Install Chrome and Chromedriver](#Install-Chrome-and-Chromedriver) 13 | + [macOS](#macOS) 14 | + [Linux](#Linux) 15 | + [Windows](#Windows-1) 16 | + [PyBites Credentials](#PyBites-Credentials) 17 | + [macOS/Linux](#macoslinux-1) 18 | + [Windows](#Windows-2) 19 | 20 | 21 | ## Usage 22 | 23 | Navigate to your local PyBites repo. 24 | 25 | Download and extract bites: 26 | ```bash 27 | eatlocal download 28 | ``` 29 | 30 | Display bites in the terminal: 31 | ```bash 32 | eatlocal display 33 | ``` 34 | 35 | Submit bites: 36 | ```bash 37 | eatlocal submit 38 | ``` 39 | 40 | ## Installation 41 | 42 | ### macOS/Linux 43 | 44 | ```bash 45 | pip3 install eatlocal 46 | ``` 47 | ### Windows 48 | 49 | ```bash 50 | pip install eatlocal 51 | ``` 52 | 53 | ## Setup 54 | 55 | 1. Go through the directions on the PyBites website to connect your GitHub account to your PyBites account. 56 | 2. Make sure you have Chrome and chromedriver installed and on `$PATH`. 57 | 3. Setup your PyBites login credentials as environment variables. If you signed up for PyBites by authenticating through GitHub or Google, you may need to set a password manually in order to use eatlocal. 58 | 59 | ### Install Chrome and Chromedriver 60 | 61 | #### macOS 62 | 63 | One option is to use homebrew [homebrew](https://brew.sh/). 64 | 65 | Install chrome: 66 | 67 | ```bash 68 | brew install --cask google-chrome 69 | ``` 70 | 71 | Install chromedriver: 72 | 73 | ```bash 74 | brew install chromedriver 75 | ``` 76 | 77 | Before you run chromedriver for the first time, you must explicitly give permission since the developer has not been verified. Running the following command in the terminal removes the warning put in place by Apple: 78 | 79 | ```bash 80 | xattr -d com.apple.quarantine $(which chromedriver) 81 | ``` 82 | 83 | Homebrew automatically puts chromedriver on `$PATH` for you. And since homebrew handles both chrome and chromedriver installations for me, I can run `brew update && brew upgrade` to help ensure I have the same version number for both chrome and chromedriver. If you do not go the homebrew route, you must manually ensure that your version of chrome matches the version of chromedriver. 84 | 85 | 86 | #### Linux 87 | 88 | Unfortunately, I did not find some fancy package manager for Linux, but I was able to install chrome and chromedriver manually for Linux Mint. 89 | 90 | Navigate to the download page for [google chrome](https://www.google.com/chrome/) and download the appropriate version for your system. Then, open up a terminal and navigate to where you downloaded the file. For me it was `~/Downloads`. I ran the following commands to install and check which version I have. 91 | 92 | ```bash 93 | cd ~/Downloads 94 | sudo dpkg -i google-chrome-stable_current_amd64.deb 95 | google-chrome --version 96 | ``` 97 | 98 | Next, navigate to the [chromedriver download page](https://chromedriver.chromium.org/downloads) and choose the version that matches the output from `google-chrome --version`. Download that file that matches your system. Head back to your terminal. 99 | 100 | 1. Ensure that you have unzip installed: 101 | 102 | ```bash 103 | sudo apt install unzip 104 | ``` 105 | 106 | 2. Unzip the chromedriver file. For me it was located in the downloads folder: 107 | 108 | ```bash 109 | unzip ~/Downloads/chromedriver_linux64.zip -d ~/Downloads 110 | ``` 111 | 112 | 3. Make it executable and move to `/usr/local/share`: 113 | 114 | ```bash 115 | chmod +x ~/Downloads/chromedriver 116 | sudo mv -f ~/Downloads/chromedriver /usr/local/share/chromedriver 117 | ``` 118 | 119 | 4. Create symlinks: 120 | 121 | ```bash 122 | sudo ln -s /usr/local/share/chromedriver /usr/local/bin/chromedriver 123 | ``` 124 | 5. Confirm you have access: 125 | 126 | ```bash 127 | which chromedriver 128 | ``` 129 | 130 | #### Windows 131 | 132 | If working in windows powershell you can use [chocolately](https://chocolatey.org/) to install chromedriver. 133 | 134 | I've found that in order to install packages I have to use an elevated administrative shell, with `choco install chromedriver`. 135 | 136 | I attempted to use `eatlocal` from [WSL2](https://docs.microsoft.com/en-us/windows/wsl/about) but there seems to be an issue with `google-chrome` itself. I could not get it to work. 137 | 138 | ### PyBites Credentials 139 | 140 | You must have your PyBites username and password stored in the environment variables `PYBITES_USERNAME` and `PYBITES_PASSWORD` respectively. 141 | 142 | #### macOS/Linux 143 | 144 | There are two methods to handle this in. 145 | 146 | **Virtual Environment Method** 147 | 148 | A note of warning. If you use this method make sure that your virtual environment is not being pushed to GitHub. If you have pushed your virtual environment you exposed your password and should change it immediately. 149 | 150 | 1. Create a virtual environment for your PyBites repo: 151 | 152 | ```bash 153 | python3 -m venv .venv 154 | ``` 155 | 156 | 2. Add the line `.venv` to your `.gitignore` file. 157 | 158 | ```bash 159 | echo ".venv" >> .gitignore 160 | ``` 161 | 162 | 3. With the environment deactivated, use your favorite text editor (I use nvim, btw) to open the activate file, e.g., `nvim .venv/bin/activate` and add the following lines: 163 | 164 | ```bash 165 | export PYBITES_USERNAME= 166 | export PYBITES_PASSWORD= 167 | ``` 168 | 169 | 4. Activate the environment `source .venv/bin/activate`. 170 | 171 | **Shell RC Method** 172 | 173 | If you are not using a virtual environment, you can add the variables directly to your shell config. 174 | 175 | 1. I use zsh, so I would use my favorite text editor `nvim ~/.zshrc` and set the variables by adding the same two lines as above: 176 | 177 | ```bash 178 | export PYBITES_USERNAME= 179 | export PYBITES_PASSWORD= 180 | ``` 181 | 182 | 2. Either exit your terminal completely and reopen, or source your config file with `source ~/.zshrc`. 183 | 184 | #### Windows 185 | 186 | I don't know of a way to do this other than graphically (Booo!). If you like pictures follow this [tutorial](https://windowsloop.com/add-environment-variable-in-windows-10). 187 | 188 | 1. Open the Start menu by pressing the “Windows Key”. 189 | 2. Type “Environment variables” and click on the “Edit the system environment variables” result. 190 | 3. Click on the "Advanced" tab. 191 | 4. Click "Environment Variables". 192 | 5. Under "User variables" click "New". 193 | 6. In the "Variable name" field enter: PYBITES_USERNAME 194 | 7. In the "Variable value" field enter: 195 | 8. Repeat steps 5-7 for the password variable. 196 | 9. Click "Ok" 197 | 10. Click "Apply" 198 | 11. Restart your computer. 199 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "eatlocal" 3 | version = "1.2.0" 4 | description = "Tool to solve Pybites Platform exercices locally." 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "beautifulsoup4>=4.12.3", 9 | "install-playwright>=0.1.0", 10 | "iterfzf>=1.4.0.54.3", 11 | "pytest-playwright>=0.5.2", 12 | "python-dotenv>=1.0.1", 13 | "requests-cache>=1.2.1", 14 | "requests>=2.32.3", 15 | "rich>=13.9.2", 16 | "typer>=0.12.5", 17 | ] 18 | 19 | [project.scripts] 20 | eatlocal = "eatlocal.__main__:cli" 21 | 22 | [project.optional-dependencies] 23 | dev = [ 24 | "pytest>=8.3.3", 25 | "pytest-cov>=5.0.0", 26 | "pre-commit>=4.0.1", 27 | "pytest-coverage>=0.0", 28 | ] 29 | 30 | [project.urls] 31 | Source = "https://github.com/PyBites-Open-Source/eatlocal" 32 | 33 | [build-system] 34 | requires = ["hatchling"] 35 | build-backend = "hatchling.build" 36 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --strict-markers 3 | markers = 4 | slow: end to end tests 5 | -------------------------------------------------------------------------------- /src/eatlocal/__init__.py: -------------------------------------------------------------------------------- 1 | """A package to solve PyBites locally""" 2 | 3 | import importlib.metadata 4 | 5 | __version__ = importlib.metadata.version("eatlocal") 6 | -------------------------------------------------------------------------------- /src/eatlocal/__main__.py: -------------------------------------------------------------------------------- 1 | """command-line interface for eatlocal""" 2 | 3 | import sys 4 | from pathlib import Path 5 | 6 | import typer 7 | from rich import print 8 | from rich.status import Status 9 | 10 | from . import __version__ 11 | from .constants import EATLOCAL_HOME 12 | from .eatlocal import ( 13 | choose_bite, 14 | choose_local_bite, 15 | create_bite_dir, 16 | display_bite, 17 | download_bite, 18 | initialize_eatlocal, 19 | load_config, 20 | submit_bite, 21 | track_local_bites, 22 | ) 23 | 24 | cli = typer.Typer(add_completion=False) 25 | 26 | 27 | def report_version(display: bool) -> None: 28 | """Print version and exit.""" 29 | if display: 30 | print(f"{Path(sys.argv[0]).name} {__version__}") 31 | raise typer.Exit() 32 | 33 | 34 | @cli.callback() 35 | def global_options( 36 | ctx: typer.Context, 37 | version: bool = typer.Option( 38 | False, 39 | "--version", 40 | "-v", 41 | is_flag=True, 42 | is_eager=True, 43 | callback=report_version, 44 | ), 45 | ): 46 | """Download, extract, display, and submit PyBites code challenges.""" 47 | 48 | 49 | @cli.command() 50 | def init( 51 | ctx: typer.Context, 52 | ) -> None: 53 | """Configure PyBites credentials and directory.""" 54 | initialize_eatlocal() 55 | 56 | 57 | @cli.command() 58 | def download( 59 | ctx: typer.Context, 60 | clear: bool = typer.Option( 61 | False, 62 | "--clear-cache", 63 | "-C", 64 | is_flag=True, 65 | help="Clear the bites cache to fetch fresh bites.", 66 | ), 67 | force: bool = typer.Option( 68 | False, 69 | "--force", 70 | "-F", 71 | is_flag=True, 72 | help="Overwrite bite directory with a fresh version.", 73 | ), 74 | level: str | None = typer.Option( 75 | None, 76 | "--level", 77 | "-l", 78 | help="Filter bites by difficulty level.", 79 | ), 80 | ) -> None: 81 | """Download and extract bite code from pybitesplatform.com.""" 82 | config = load_config(EATLOCAL_HOME / ".env") 83 | bite = choose_bite(clear, level=level) 84 | with Status("Downloading bite..."): 85 | bite.platform_content = download_bite(bite, config) 86 | if bite.platform_content is None: 87 | return 88 | create_bite_dir(bite, config, force) 89 | track_local_bites(bite, config) 90 | 91 | 92 | @cli.command() 93 | def submit( 94 | ctx: typer.Context, 95 | ) -> None: 96 | """Submit a bite back to the PyBites Platform.""" 97 | config = load_config(EATLOCAL_HOME / ".env") 98 | bite = choose_local_bite(config) 99 | submit_bite( 100 | bite, 101 | config, 102 | ) 103 | 104 | 105 | @cli.command() 106 | def display( 107 | ctx: typer.Context, 108 | theme: str = typer.Option( 109 | "material", 110 | "--theme", 111 | "-t", 112 | help="Choose syntax highlighting for code.", 113 | ), 114 | ) -> None: 115 | """Read a bite directly in the terminal.""" 116 | config = load_config(EATLOCAL_HOME / ".env") 117 | bite = choose_local_bite(config) 118 | display_bite(bite, config, theme=theme) 119 | 120 | 121 | if __name__ == "__main__": 122 | cli() 123 | -------------------------------------------------------------------------------- /src/eatlocal/console.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | 3 | console = Console() 4 | -------------------------------------------------------------------------------- /src/eatlocal/constants.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from enum import Enum 3 | 4 | 5 | class ConsoleStyle(Enum): 6 | SUCCESS = "green" 7 | SUGGESTION = "yellow" 8 | WARNING = "red" 9 | 10 | 11 | BITE_URL = "https://pybitesplatform.com/bites/{bite_slug}/" 12 | EATLOCAL_HOME = Path().home() / ".eatlocal" 13 | CACHE_DB_LOCATION = EATLOCAL_HOME / ".bites_list_cache.sqlite" 14 | LOCAL_BITES_DB = EATLOCAL_HOME / ".local_bites.json" 15 | BITES_API = "https://pybitesplatform.com/api/bites/" 16 | FZF_DEFAULT_OPTS = "--height 13 --layout=reverse --border rounded --margin=2%,5%,10%,2%" 17 | LOGIN_URL = "https://pybitesplatform.com/accounts/auth/login/" 18 | PROFILE_URL = "https://pybitesplatform.com/accounts/profile/" 19 | TIMEOUT_LENGTH = 30000 20 | -------------------------------------------------------------------------------- /src/eatlocal/eatlocal.py: -------------------------------------------------------------------------------- 1 | """download and submit bites""" 2 | 3 | import json 4 | import sys 5 | import webbrowser 6 | from dataclasses import dataclass 7 | from datetime import timedelta 8 | from os import environ, makedirs 9 | from pathlib import Path 10 | from typing import FrozenSet 11 | 12 | import install_playwright 13 | import requests 14 | import requests_cache 15 | from bs4 import BeautifulSoup 16 | from dotenv import dotenv_values 17 | from iterfzf import iterfzf 18 | from playwright.sync_api import Page, sync_playwright 19 | from rich.layout import Layout 20 | from rich.panel import Panel 21 | from rich.prompt import Confirm, Prompt 22 | from rich.status import Status 23 | from rich.syntax import Syntax 24 | from rich.traceback import install 25 | 26 | from .console import console 27 | from .constants import ( 28 | BITE_URL, 29 | CACHE_DB_LOCATION, 30 | BITES_API, 31 | EATLOCAL_HOME, 32 | FZF_DEFAULT_OPTS, 33 | LOCAL_BITES_DB, 34 | LOGIN_URL, 35 | PROFILE_URL, 36 | TIMEOUT_LENGTH, 37 | ConsoleStyle, 38 | ) 39 | 40 | install(show_locals=True) 41 | environ["FZF_DEFAULT_OPTS"] = FZF_DEFAULT_OPTS 42 | requests_cache.install_cache( 43 | CACHE_DB_LOCATION, backend="sqlite", expire_after=timedelta(days=30) 44 | ) 45 | 46 | VALID_LEVELS: FrozenSet[str] = frozenset( 47 | ["newbie", "intro", "beginner", "intermediate", "advanced"] 48 | ) 49 | 50 | # ANSI escape code for green color 51 | GREEN = "\033[32m" 52 | # Reset color back to default 53 | RESET = "\033[0m" 54 | 55 | 56 | @dataclass 57 | class Bite: 58 | """Dataclass for a PyBites bite. 59 | 60 | Attributes: 61 | title: The title of the bite. 62 | slug: The slug of the bite. 63 | platform_content: The content of the bite downloaded from the platform. 64 | 65 | """ 66 | 67 | title: str = None 68 | slug: str = None 69 | platform_content: str = None 70 | 71 | @property 72 | def url(self) -> str: 73 | return BITE_URL.format(bite_slug=self.slug) 74 | 75 | def bite_slug_to_dir(self, pybites_repo: Path) -> Path: 76 | return Path(pybites_repo).resolve() / self.slug 77 | 78 | def fetch_local_code(self, config: dict) -> None: 79 | bite_dir = self.bite_slug_to_dir(config["PYBITES_REPO"]) 80 | if not bite_dir.is_dir(): 81 | console.print( 82 | f":warning: Unable to find bite {self.title} locally.", 83 | style=ConsoleStyle.WARNING.value, 84 | ) 85 | console.print( 86 | "Please make sure that your local pybites directory is correct and bite has been downloaded.", 87 | style=ConsoleStyle.SUGGESTION.value, 88 | ) 89 | self.local_code = None 90 | else: 91 | python_file = [ 92 | file 93 | for file in list(bite_dir.glob("*.py")) 94 | if not file.name.startswith("test_") 95 | ][0] 96 | 97 | with open(python_file, encoding="utf-8") as file: 98 | self.local_code = file.read() 99 | 100 | 101 | def load_config(env_path: Path) -> dict[str, str]: 102 | """Load configuration from .env file. 103 | 104 | Args: 105 | env_path: Path to .env file. 106 | 107 | Returns: 108 | dict: Configuration variables. 109 | 110 | """ 111 | config = {"PYBITES_USERNAME": "", "PYBITES_PASSWORD": "", "PYBITES_REPO": ""} 112 | if not env_path.exists(): 113 | console.print( 114 | ":warning: Could not find or read .eatlocal/.env in your home directory.", 115 | style=ConsoleStyle.WARNING.value, 116 | ) 117 | console.print( 118 | "Please run [underline]eatlocal init[/underline] first.", 119 | style=ConsoleStyle.SUGGESTION.value, 120 | ) 121 | sys.exit() 122 | config.update(dotenv_values(dotenv_path=env_path)) 123 | return config 124 | 125 | 126 | def get_credentials() -> tuple[str, str]: 127 | """Prompt the user for their PyBites credentials. 128 | 129 | Returns: 130 | A tuple containing the user's PyBites username and password. 131 | 132 | """ 133 | email = Prompt.ask("Enter your PyBites email address") 134 | while True: 135 | password = Prompt.ask("Enter your PyBites user password", password=True) 136 | confirm_password = Prompt.ask("Confirm PyBites password", password=True) 137 | if password == confirm_password: 138 | break 139 | console.print( 140 | ":warning: Password did not match.", style=ConsoleStyle.WARNING.value 141 | ) 142 | return email, password 143 | 144 | 145 | def set_local_dir() -> Path: 146 | """Set the local directory for PyBites. 147 | 148 | Returns: 149 | The path to the local directory where user's bites will be stored. 150 | 151 | """ 152 | return Path( 153 | Prompt.ask( 154 | "Enter the path to your local directory for PyBites, or press enter for the current directory", 155 | default=Path().cwd(), 156 | show_default=True, 157 | ) 158 | ).expanduser() 159 | 160 | 161 | def install_browser() -> None: 162 | """Install the browser for the Playwright library. 163 | 164 | Returns: 165 | None 166 | 167 | """ 168 | with sync_playwright() as p: 169 | install_playwright.install(p.chromium) 170 | 171 | 172 | def initialize_eatlocal(): 173 | use_existing = False 174 | if (EATLOCAL_HOME / ".env").is_file(): 175 | with open(EATLOCAL_HOME / ".env", "r", encoding="utf-8") as fh: 176 | data = fh.read().splitlines() 177 | username = data[0].split("PYBITES_USERNAME=")[1].strip() 178 | password = data[1].split("PYBITES_PASSWORD=")[1].strip() 179 | local_dir = Path(data[2].split("PYBITES_REPO=")[1].strip()) 180 | print( 181 | f"You have previously initialized eatlocal with the following:\nUsername: {username}\nDirectory: {local_dir}" 182 | ) 183 | if Confirm.ask( 184 | "Would you like to use the same credentials and local directory?" 185 | ): 186 | use_existing = True 187 | 188 | if not use_existing: 189 | while True: 190 | username, password = get_credentials() 191 | local_dir = set_local_dir() 192 | print( 193 | f"Your input - username: {username}\nDirectory where bites will be stored: {local_dir}." 194 | ) 195 | if Confirm.ask( 196 | "Are these inputs correct? If you confirm, they will be stored under .eatlocal in your user home directory" 197 | ): 198 | break 199 | 200 | with Status("Initializing eatlocal..."): 201 | if not EATLOCAL_HOME.is_dir(): 202 | EATLOCAL_HOME.mkdir() 203 | 204 | with open(EATLOCAL_HOME / ".env", "w", encoding="utf-8") as fh: 205 | fh.write(f"PYBITES_USERNAME={username}\n") 206 | fh.write(f"PYBITES_PASSWORD={password}\n") 207 | fh.write(f"PYBITES_REPO={local_dir}\n") 208 | 209 | create_local_bites_db(local_dir) 210 | 211 | with Status("Installing browser..."): 212 | install_browser() 213 | console.print(":tada: Initialization complete.", style=ConsoleStyle.SUCCESS.value) 214 | 215 | 216 | def login(browser, username: str, password: str) -> Page: 217 | """Login to the PyBites platform. 218 | 219 | Args: 220 | browser: Playwright browser object. 221 | username: PyBites username. 222 | password: PyBites password. 223 | 224 | Returns: 225 | An authenticated page object for the PyBites platform. 226 | 227 | """ 228 | page: Page = browser.new_page() 229 | # only shorten for debugging, some bites need in e2e test need longer 230 | page.set_default_timeout(TIMEOUT_LENGTH) 231 | page.goto(LOGIN_URL) 232 | 233 | page.click("#login-link") 234 | page.fill('input[name="login"]', username) 235 | page.fill('input[name="password"]', password) 236 | page.click('button[type="submit"]') 237 | return page 238 | 239 | 240 | def create_local_bites_db(local_dir: Path) -> None: 241 | """Create the local bites database. 242 | 243 | Args: 244 | local_dir: Path to the local directory for PyBites. 245 | 246 | Returns: 247 | None 248 | 249 | """ 250 | with Status("Creating local bites database..."): 251 | if (local_dir / ".local_bites.json").is_file(): 252 | with open(local_dir / ".local_bites.json", "r", encoding="utf-8") as db: 253 | local_bites = json.load(db) 254 | with open(LOCAL_BITES_DB, "w", encoding="utf-8") as db: 255 | json.dump(local_bites, db) 256 | (local_dir / ".local_bites.json").unlink() 257 | 258 | if not LOCAL_BITES_DB.is_file(): 259 | with open(LOCAL_BITES_DB, "w", encoding="utf-8") as fh: 260 | fh.write("{}") 261 | 262 | 263 | def track_local_bites(bite: Bite, config: dict) -> None: 264 | """Track the bites that have been downloaded locally. 265 | 266 | Args: 267 | bite: Bite object containing the title and url of the bite. 268 | config: Dictionary containing the user's PyBites credentials. 269 | 270 | Returns: 271 | None 272 | 273 | """ 274 | with open(LOCAL_BITES_DB, "r") as local_bites: 275 | bites = json.load(local_bites) 276 | bites[bite.title] = bite.slug 277 | with open(LOCAL_BITES_DB, "w") as local_bites: 278 | json.dump(bites, local_bites) 279 | 280 | 281 | def choose_local_bite(config: dict) -> Bite: 282 | """Choose a local bite to submit. 283 | 284 | Args: 285 | config: Dictionary containing the user's PyBites credentials. 286 | 287 | Returns: 288 | A Bite object. 289 | 290 | """ 291 | with open(LOCAL_BITES_DB, "r") as local_bites: 292 | bites = json.load(local_bites) 293 | if Path.cwd().name in bites.values(): 294 | for title, slug in bites.items(): 295 | if Path.cwd().name == slug: 296 | return Bite(title, slug) 297 | bite = iterfzf(bites, multi=False) 298 | if bite is None: 299 | sys.exit() 300 | return Bite(bite, bites[bite]) 301 | 302 | 303 | def _format_bite_key(title: str, level: str, padding: int) -> str: 304 | """Format the bite key with for display. 305 | Returns: 306 | A formatted string with the bite title and level. 307 | """ 308 | return f"{title:<{padding}}{GREEN}{level.capitalize():>40}{RESET}" 309 | 310 | 311 | def _unformat_bite_key(formatted_key: str) -> str: 312 | """Unformat a bite key to get the original title. 313 | 314 | Args: 315 | formatted_key: The formatted string containing title and level. 316 | 317 | Returns: 318 | The original title string. 319 | """ 320 | uncolored = formatted_key.replace(GREEN, "").replace(RESET, "") 321 | title = uncolored.rstrip() 322 | parts = title.split() 323 | return " ".join(parts[:-1]) 324 | 325 | 326 | def choose_bite(clear: bool = False, *, level: str | None = None) -> Bite: 327 | """Choose which level of bite will be downloaded. 328 | 329 | Returns: 330 | A Bite object. 331 | 332 | """ 333 | if clear: 334 | requests_cache.clear() 335 | with Status("Retrieving bites..."): 336 | r = requests.get(BITES_API) 337 | if r.status_code != 200: 338 | console.print( 339 | ":warning: Unable to reach Pybites Platform.", 340 | style=ConsoleStyle.WARNING.value, 341 | ) 342 | console.print( 343 | "Ensure internet connect is good and platform is avaiable.", 344 | style=ConsoleStyle.SUGGESTION.value, 345 | ) 346 | sys.exit() 347 | bites_data = r.json() 348 | if level is not None: 349 | if level.lower() not in VALID_LEVELS: 350 | console.print( 351 | f":warning: Invalid level: {level}.", 352 | style=ConsoleStyle.WARNING.value, 353 | ) 354 | console.print( 355 | f"Valid levels are: {', '.join(VALID_LEVELS)}.", 356 | style=ConsoleStyle.SUGGESTION.value, 357 | ) 358 | sys.exit() 359 | bites = { 360 | bite["title"]: bite["slug"] 361 | for bite in bites_data 362 | if bite["level"].lower() == level.lower() 363 | } 364 | else: 365 | bites = {} 366 | max_title_length = 0 367 | bite_mapping = {} 368 | 369 | for bite in bites_data: 370 | title_length = len(bite["title"]) 371 | max_title_length = max(max_title_length, title_length) 372 | 373 | bites[bite["title"]] = (bite["level"], bite["slug"]) 374 | bite_mapping[bite["title"]] = bite["slug"] 375 | padding = max_title_length + 10 376 | formatted_bites = { 377 | _format_bite_key(title, level, padding): slug 378 | for title, (level, slug) in bites.items() 379 | } 380 | 381 | choices = bites if level is not None else formatted_bites 382 | bite_to_download = iterfzf(choices, multi=False, ansi=True) 383 | 384 | if bite_to_download is None: 385 | sys.exit() 386 | 387 | slug = ( 388 | bites[bite_to_download] 389 | if level is not None 390 | else bite_mapping[_unformat_bite_key(bite_to_download)] 391 | ) 392 | 393 | return Bite(bite_to_download, slug) 394 | 395 | 396 | def download_bite( 397 | bite: Bite, 398 | config: dict, 399 | ) -> str | None: 400 | """Download the bite content from the PyBites platform. 401 | 402 | Args: 403 | config: Dictionary containing the user's PyBites credentials. 404 | bite: Bite object containing the title and url of the bite. 405 | 406 | Returns: 407 | The content of the bite from the platform. 408 | 409 | """ 410 | with sync_playwright() as p: 411 | with p.chromium.launch() as browser: 412 | page = login( 413 | browser, 414 | config["PYBITES_USERNAME"], 415 | config["PYBITES_PASSWORD"], 416 | ) 417 | if page.url != PROFILE_URL: 418 | console.print( 419 | ":warning: Unable to login to PyBites.", 420 | style=ConsoleStyle.WARNING.value, 421 | ) 422 | console.print( 423 | "Ensure your credentials are valid.", 424 | style=ConsoleStyle.SUGGESTION.value, 425 | ) 426 | sys.exit() 427 | page.goto(bite.url) 428 | return page.content() 429 | 430 | 431 | def parse_bite_description(soup: BeautifulSoup) -> str: 432 | """Parse the bite description from the soup object. 433 | 434 | Args: 435 | soup: BeautifulSoup object containing the bite content. 436 | 437 | Returns: 438 | The bite description html as a string. 439 | 440 | """ 441 | bite_description = soup.find(id="bite-description") 442 | write = False 443 | bite_description_str = "" 444 | for line in str(bite_description).splitlines(): 445 | if 'id="filename"' in line: 446 | continue 447 | if write: 448 | bite_description_str += line + "\n" 449 | if """end author and learning paths""" in line: 450 | write = True 451 | return bite_description_str 452 | 453 | 454 | def create_bite_dir( 455 | bite: Bite, 456 | config: dict, 457 | force: bool = False, 458 | ) -> None: 459 | """Create a directory for the bite and write the bite content to it. 460 | 461 | Args: 462 | bite: Bite object. 463 | config: Dictionary containing the user's PyBites credentials. 464 | force: Whether to overwrite the directory if it already exists. 465 | 466 | Returns: 467 | None 468 | 469 | """ 470 | dest_path = bite.bite_slug_to_dir(config["PYBITES_REPO"]) 471 | if dest_path.is_dir() and not force: 472 | console.print( 473 | f":warning: There already exists a directory for { 474 | bite.title}.", 475 | style=ConsoleStyle.WARNING.value, 476 | ) 477 | console.print( 478 | "Use the --force option to overwite.", style=ConsoleStyle.SUGGESTION.value 479 | ) 480 | return 481 | 482 | soup = BeautifulSoup(bite.platform_content, "html.parser") 483 | 484 | bite_description = parse_bite_description(soup) 485 | try: 486 | code = soup.find(id="python-editor").text 487 | tests = soup.find(id="test-python-editor").text 488 | file_name = soup.find(id="filename").text.strip(".py") 489 | except AttributeError: 490 | console.print( 491 | f":warning: Unable to access {bite.title} content on the platform.", 492 | style=ConsoleStyle.WARNING.value, 493 | ) 494 | console.print( 495 | "Please make sure that your credentials are valid and you have access to this bite.", 496 | style=ConsoleStyle.SUGGESTION.value, 497 | ) 498 | sys.exit() 499 | 500 | try: 501 | makedirs(dest_path) 502 | except FileExistsError: 503 | pass 504 | with open(dest_path / "bite.html", "w", encoding="utf-8") as bite_html: 505 | bite_html.write(bite_description) 506 | 507 | with open(dest_path / f"{file_name}.py", "w", encoding="utf-8") as py_file: 508 | py_file.write(code) 509 | 510 | with open(dest_path / f"test_{file_name}.py", "w", encoding="utf-8") as test_file: 511 | test_file.write(tests) 512 | console.print( 513 | f"Wrote {bite.title} to: {dest_path}", style=ConsoleStyle.SUCCESS.value 514 | ) 515 | 516 | 517 | def submit_bite( 518 | bite: str, 519 | config: dict, 520 | ) -> None: 521 | """Submit the bite to the PyBites platform. 522 | 523 | Args: 524 | bite: The name of the bite to submit. 525 | config: Dictionary containing the user's PyBites credentials. 526 | 527 | Returns: 528 | None 529 | 530 | """ 531 | with Status("Submitting bite..."): 532 | bite.fetch_local_code(config) 533 | if bite.local_code is None: 534 | return 535 | 536 | with sync_playwright() as p: 537 | with p.chromium.launch() as browser: 538 | page = login( 539 | browser, 540 | config["PYBITES_USERNAME"], 541 | config["PYBITES_PASSWORD"], 542 | ) 543 | if page.url != PROFILE_URL: 544 | console.print( 545 | ":warning: Unable to login to PyBites.", 546 | style=ConsoleStyle.WARNING.value, 547 | ) 548 | console.print( 549 | "Ensure your credentials are valid.", 550 | style=ConsoleStyle.SUGGESTION.value, 551 | ) 552 | return 553 | page.goto(bite.url) 554 | page.wait_for_url(bite.url) 555 | page.evaluate( 556 | f"""document.querySelector('.CodeMirror').CodeMirror.setValue({ 557 | repr(bite.local_code)})""" 558 | ) 559 | page.click("#validate-button") 560 | page.wait_for_selector("#feedback", state="visible") 561 | page.wait_for_function( 562 | "document.querySelector('#feedback').innerText.includes('test session starts')" 563 | ) 564 | 565 | validate_result = page.text_content("#feedback") 566 | if "Congrats, you passed this Bite" in validate_result: 567 | console.print( 568 | "Congrats, you passed this Bite!", style=ConsoleStyle.SUCCESS.value 569 | ) 570 | else: 571 | console.print( 572 | ":warning: Code did not pass the tests.", style=ConsoleStyle.WARNING.value 573 | ) 574 | 575 | if Confirm.ask(f"Would you like to open {bite.title} in your browser?"): 576 | webbrowser.open(bite.url) 577 | 578 | 579 | def display_bite( 580 | bite: Bite, 581 | config: dict, 582 | theme: str, 583 | ) -> None: 584 | """Display the instructions and source code for a bite. 585 | 586 | Args: 587 | bite: The name of the bite to display. 588 | config: Dictionary containing the user's PyBites credentials. 589 | theme: The color theme for the code. 590 | 591 | Returns: 592 | None 593 | 594 | """ 595 | path = bite.bite_slug_to_dir(config["PYBITES_REPO"]) 596 | if not path.is_dir(): 597 | console.print( 598 | f":warning: Unable to display bite { 599 | bite.title}.", 600 | style=ConsoleStyle.WARNING.value, 601 | ) 602 | console.print( 603 | "Please make sure that path is correct and the bite has been downloaded.", 604 | style=ConsoleStyle.SUGGESTION.value, 605 | ) 606 | return 607 | 608 | html_file = path / list(path.glob("*.html"))[0] 609 | python_file = [ 610 | file for file in list(path.glob("*.py")) if not file.name.startswith("test_") 611 | ][0] 612 | 613 | with open(html_file, "r") as bite_html: 614 | soup = BeautifulSoup(bite_html, "html.parser") 615 | instructions = soup.text 616 | 617 | with open(python_file, "r") as code_file: 618 | code = Syntax( 619 | code_file.read(), 620 | "python", 621 | theme=theme, 622 | background_color="default", 623 | ) 624 | 625 | layout = Layout() 626 | layout.split( 627 | Layout(name="header", size=3), 628 | Layout(name="main", ratio=1), 629 | ) 630 | layout["main"].split_row( 631 | Layout(name="directions"), 632 | Layout(name="code"), 633 | ) 634 | 635 | layout["header"].update( 636 | Panel(f"Displaying {bite.title} at {html_file}", title="eatlocal") 637 | ) 638 | layout["main"]["directions"].update(Panel(instructions, title="Directions")) 639 | layout["main"]["code"].update(Panel(code, title="Code")) 640 | 641 | console.print(layout) 642 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PyBites-Open-Source/eatlocal/828381fcf18223688897cc964161b6361de27070/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """eatlocal specific pytest configuration""" 2 | 3 | from pathlib import Path 4 | 5 | import pytest 6 | from dotenv import dotenv_values 7 | 8 | from eatlocal.constants import EATLOCAL_HOME 9 | 10 | 11 | @pytest.fixture 12 | def testing_config() -> dict[str, str]: 13 | config = {"PYBITES_USERNAME": "", "PYBITES_PASSWORD": "", "PYBITES_REPO": ""} 14 | config.update(dotenv_values(dotenv_path=Path(EATLOCAL_HOME / ".env"))) 15 | config["PYBITES_REPO"] = Path("./tests/testing_repo").resolve() 16 | return config 17 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from typer.testing import CliRunner 2 | 3 | from eatlocal.__main__ import cli 4 | from eatlocal.__init__ import __version__ 5 | 6 | runner = CliRunner() 7 | 8 | 9 | def test_version(): 10 | result = runner.invoke(cli, ["--version"]) 11 | assert result.exit_code == 0 12 | assert __version__ in result.stdout 13 | -------------------------------------------------------------------------------- /tests/test_e2e.py: -------------------------------------------------------------------------------- 1 | """eatlocal End to End Tests""" 2 | 3 | from typer.testing import CliRunner 4 | from eatlocal.eatlocal import download_bite, Bite, _format_bite_key 5 | from eatlocal.__main__ import cli, EATLOCAL_HOME 6 | from unittest.mock import patch, mock_open, MagicMock 7 | from pathlib import Path 8 | import shutil 9 | 10 | import pytest 11 | 12 | runner = CliRunner() 13 | 14 | SUMMING_TEST_BITE = Bite("Sum n numbers", "sum-n-numbers") 15 | SUMMING_TEST_BITE_LEVEL = "Beginner" 16 | PARSE_TEST_BITE = Bite("Parse a list of names", "parse-a-list-of-names") 17 | BAD_CONFIG = { 18 | "PYBITES_USERNAME": "foo", 19 | "PYBITES_PASSWORD": "bar", 20 | "PYBITES_REPO": "baz", 21 | } 22 | 23 | 24 | @pytest.mark.slow 25 | def test_eatlocal_cannot_download_premium_bite_wo_auth( 26 | capfd, 27 | ) -> None: 28 | """Test that a premium bite cannot be downloaded without credentials.""" 29 | with pytest.raises(SystemExit): 30 | download_bite(SUMMING_TEST_BITE, BAD_CONFIG) 31 | output = capfd.readouterr()[0] 32 | assert "Unable to login to PyBites." in output 33 | 34 | 35 | @patch("builtins.open", new_callable=mock_open) 36 | @patch("eatlocal.eatlocal.EATLOCAL_HOME", new=MagicMock()) 37 | @patch("eatlocal.eatlocal.LOCAL_BITES_DB", new=MagicMock()) 38 | @patch("eatlocal.eatlocal.get_credentials") 39 | @patch("eatlocal.eatlocal.set_local_dir") 40 | @patch("eatlocal.eatlocal.Confirm.ask") 41 | @patch("eatlocal.eatlocal.install_browser") 42 | def test_init_command( 43 | mock_install_browser, 44 | mock_confirm_ask, 45 | mock_set_local_dir, 46 | mock_get_credentials, 47 | mock_open, 48 | ): 49 | """Test the init command.""" 50 | mock_get_credentials.return_value = ("test_user", "test_password") 51 | mock_set_local_dir.return_value = Path("/mock/local_dir") 52 | mock_confirm_ask.return_value = True 53 | 54 | runner.invoke(cli, ["init"]) 55 | assert Path(EATLOCAL_HOME).is_dir() 56 | 57 | 58 | @pytest.mark.slow 59 | @patch("eatlocal.__main__.load_config") 60 | @patch("eatlocal.eatlocal.iterfzf") 61 | def test_download_command(mock_iterfzf, mock_load_config, testing_config): 62 | """Test the download command.""" 63 | mock_load_config.return_value = testing_config 64 | formatted_title = _format_bite_key( 65 | SUMMING_TEST_BITE.title, SUMMING_TEST_BITE_LEVEL, len(SUMMING_TEST_BITE.title) 66 | ) 67 | mock_iterfzf.return_value = formatted_title 68 | 69 | runner.invoke(cli, ["download"]) 70 | 71 | assert ( 72 | (Path(testing_config["PYBITES_REPO"]) / SUMMING_TEST_BITE.slug) 73 | .resolve() 74 | .is_dir() 75 | ) 76 | 77 | shutil.rmtree(Path(testing_config["PYBITES_REPO"]) / SUMMING_TEST_BITE.slug) 78 | 79 | 80 | @pytest.mark.slow 81 | @patch("eatlocal.__main__.load_config") 82 | @patch("eatlocal.eatlocal.iterfzf") 83 | def test_submit_command(mock_iterfzf, mock_load_config, testing_config): 84 | """Test the submit command.""" 85 | mock_load_config.return_value = testing_config 86 | mock_iterfzf.return_value = PARSE_TEST_BITE.title 87 | with patch( 88 | "eatlocal.eatlocal.LOCAL_BITES_DB", 89 | Path.cwd() / "tests/testing_repo/.local_bites.json", 90 | ): 91 | result = runner.invoke(cli, ["submit"]) 92 | 93 | assert "Congrats, you passed" in result.output 94 | -------------------------------------------------------------------------------- /tests/test_units.py: -------------------------------------------------------------------------------- 1 | """eatlocal unit tests""" 2 | 3 | import shutil 4 | from pathlib import Path 5 | from unittest.mock import patch, MagicMock 6 | import json 7 | 8 | import pytest 9 | 10 | from eatlocal.eatlocal import ( 11 | Bite, 12 | choose_bite, 13 | choose_local_bite, 14 | create_bite_dir, 15 | display_bite, 16 | get_credentials, 17 | load_config, 18 | set_local_dir, 19 | _unformat_bite_key, 20 | _format_bite_key, 21 | ) 22 | 23 | NOT_DOWNLOADED = ( 24 | Bite( 25 | "Made up bite", 26 | "made-up-bite", 27 | ), 28 | Bite("Write a property", "write-a-property"), 29 | ) 30 | LOCAL_TEST_BITE = Bite( 31 | "Parse a list of names", 32 | "parse-a-list-of-names", 33 | ) 34 | SUMMING_TEST_BITE = Bite( 35 | "Sum n numbers", 36 | "sum-n-numbers", 37 | ) 38 | 39 | 40 | def test_bite_implementation(): 41 | """Test Bite class implementation.""" 42 | bite = SUMMING_TEST_BITE 43 | assert bite.title == "Sum n numbers" 44 | assert bite.slug == "sum-n-numbers" 45 | assert bite.url == "https://pybitesplatform.com/bites/sum-n-numbers/" 46 | assert bite.platform_content is None 47 | 48 | 49 | def test_bite_fetch_local_code(testing_config) -> None: 50 | """Test fetching local code.""" 51 | bite = LOCAL_TEST_BITE 52 | bite_dir = Path(testing_config["PYBITES_REPO"]) / LOCAL_TEST_BITE.slug 53 | with open(bite_dir / "names.py", "r") as f: 54 | local_code = f.read() 55 | bite.fetch_local_code(testing_config) 56 | assert bite.local_code == local_code 57 | 58 | 59 | def test_bite_fetch_local_code_no_file(capsys, testing_config) -> None: 60 | """Test fetching local code when file does not exist.""" 61 | bite = NOT_DOWNLOADED[0] 62 | bite.fetch_local_code(testing_config) 63 | output = capsys.readouterr() 64 | assert "Unable to find bite" in output.out 65 | 66 | 67 | @patch("eatlocal.eatlocal.iterfzf") 68 | def test_choose_local_bite(mock_iterfzf, testing_config) -> None: 69 | """Test choosing a local bite.""" 70 | mock_iterfzf.return_value = LOCAL_TEST_BITE.title 71 | with patch( 72 | "eatlocal.eatlocal.LOCAL_BITES_DB", 73 | Path.cwd() / "tests/testing_repo/.local_bites.json", 74 | ): 75 | bite = choose_local_bite(testing_config) 76 | assert bite.title == LOCAL_TEST_BITE.title 77 | assert bite.slug == LOCAL_TEST_BITE.slug 78 | 79 | 80 | @pytest.fixture 81 | def test_choose_local_bite_from_dir(monkeypatch, testing_config) -> None: 82 | """Test choosing a local bite.""" 83 | with patch( 84 | "eatlocal.eatlocal.LOCAL_BITES_DB", 85 | Path.cwd() / "tests/testing_repo/.local_bites.json", 86 | ): 87 | monkeypatch.chdir("tests/testing_repo/parse-a-list-of-names/") 88 | bite = choose_local_bite(testing_config) 89 | assert bite.title == LOCAL_TEST_BITE.title 90 | assert bite.slug == LOCAL_TEST_BITE.slug 91 | 92 | 93 | @patch("eatlocal.eatlocal.Prompt.ask") 94 | @patch("eatlocal.eatlocal.Path.exists") 95 | def test_set_local_dir(mock_exists, mock_prompt): 96 | mock_prompt.return_value = "/some/path" 97 | mock_exists.return_value = True 98 | local_dir = set_local_dir() 99 | assert local_dir == Path("/some/path") 100 | 101 | 102 | @patch("eatlocal.eatlocal.requests.get") 103 | @patch("eatlocal.eatlocal.iterfzf") 104 | def test_choose_bite(mock_iterfzf, mock_requests): 105 | mock_response = MagicMock() 106 | mock_response.status_code = 200 107 | api_data = json.load(open("./tests/testing_content/bites_api.json")) 108 | mock_response.json.return_value = api_data 109 | mock_requests.return_value = mock_response 110 | 111 | # Create the formatted title that would be displayed in iterfzf 112 | max_title_length = max(len(bite["title"]) for bite in api_data) 113 | padding = max_title_length + 10 114 | formatted_title = _format_bite_key( 115 | SUMMING_TEST_BITE.title, 116 | next( 117 | bite["level"] 118 | for bite in api_data 119 | if bite["title"] == SUMMING_TEST_BITE.title 120 | ), 121 | padding, 122 | ) 123 | 124 | # Mock iterfzf to return the formatted title 125 | mock_iterfzf.return_value = formatted_title 126 | 127 | bite = choose_bite() 128 | assert isinstance(bite, Bite) 129 | # We need to unformat the title to match it with the original 130 | assert _unformat_bite_key(bite.title) == SUMMING_TEST_BITE.title 131 | assert bite.slug == SUMMING_TEST_BITE.slug 132 | 133 | 134 | def test_display_bite( 135 | testing_config, 136 | capsys, 137 | ) -> None: 138 | """Correctly display a bite that has been downloaded and extracted.""" 139 | display_bite(LOCAL_TEST_BITE, testing_config, theme="material") 140 | output = capsys.readouterr().out 141 | assert f"Displaying {LOCAL_TEST_BITE.title} at" in output 142 | assert "Code" in output 143 | assert "Directions" in output 144 | 145 | 146 | @pytest.mark.parametrize("bite", NOT_DOWNLOADED) 147 | def test_cannot_display_missing_bite( 148 | bite, 149 | testing_config, 150 | capsys, 151 | ) -> None: 152 | """Attempt to display a bite that has not been downloaded and extracted.""" 153 | 154 | display_bite(bite, testing_config, theme="material") 155 | output = capsys.readouterr().out 156 | assert "Unable to display bite" in output 157 | 158 | 159 | def test_create_bite_dir( 160 | testing_config, 161 | ) -> None: 162 | """Create a directory for a bite.""" 163 | with open(Path("./tests/testing_content/summing_content.txt"), "r") as f: 164 | platform_content = f.read() 165 | bite = SUMMING_TEST_BITE 166 | bite.platform_content = platform_content 167 | bite_dir = Path(testing_config["PYBITES_REPO"]) / "sum-n-numbers" 168 | 169 | create_bite_dir(bite, testing_config) 170 | html_file = bite_dir / "bite.html" 171 | python_file = bite_dir / "summing.py" 172 | test_file = bite_dir / "test_summing.py" 173 | assert html_file.exists() 174 | assert python_file.exists() 175 | assert test_file.exists() 176 | assert bite_dir.is_dir() 177 | assert bite_dir.name == "sum-n-numbers" 178 | shutil.rmtree(bite_dir) 179 | 180 | 181 | def test_create_bite_dir_without_force(testing_config, capsys): 182 | create_bite_dir(LOCAL_TEST_BITE, testing_config) 183 | output = capsys.readouterr().out 184 | assert "There already exists a directory for" in output 185 | assert "Use the --force option" in output 186 | 187 | 188 | def test_load_config() -> None: 189 | """Load the configuration file.""" 190 | expected = { 191 | "PYBITES_USERNAME": "test_username", 192 | "PYBITES_PASSWORD": "test_password", 193 | "PYBITES_REPO": "test_repo", 194 | } 195 | actual = load_config(Path("./tests/testing_content/testing_env").resolve()) 196 | assert actual == expected 197 | 198 | 199 | def test_load_config_file_not_found(capsys) -> None: 200 | """Test loading a config file that does not exist.""" 201 | with pytest.raises(SystemExit): 202 | load_config(Path("./tests/testing_content/non_existent_file").resolve()) 203 | output = capsys.readouterr().out 204 | assert "Could not find or read .eatlocal/.env in your home directory." in output 205 | 206 | 207 | @patch("eatlocal.eatlocal.Prompt.ask") 208 | def test_get_credentials(mock_prompt) -> None: 209 | """Test getting credentials from the config file.""" 210 | expected = ("test_username", "test_password") 211 | mock_prompt.side_effect = ["test_username", "test_password", "test_password"] 212 | actual = get_credentials() 213 | assert actual == expected 214 | -------------------------------------------------------------------------------- /tests/testing_content/bites_api.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Sum n numbers", 4 | "slug": "sum-n-numbers", 5 | "description": "

Write a Python function that calculates the sum of a list of (int) numbers:

\r\n
    \r\n
  • The function should accept a list of numbers and return the sum of those numbers.
  • \r\n
  • If no argument is provided (that is, numbers is None), return the sum of the numbers 1 to 100 (Note that this is not the same as an empty list of numbers being passed in. In that case the sum returned will be 0).
  • \r\n
\r\n

Have fun!

", 6 | "level": "Beginner", 7 | "tags": [ 8 | "sum", 9 | "None", 10 | "range", 11 | "default args" 12 | ] 13 | }, 14 | { 15 | "title": "Regex fun", 16 | "slug": "regex-fun", 17 | "description": "

Learn some Python regular expressions by completing the following three functions.

\r\n

Each function recieves a text string with different content, it's up you parse out and return the text described in each function's docstring.

\r\n

Note: normally when we parse HTML we use a library of some sort. This Bite helps you appreciate the work that goes into those libraries!

", 18 | "level": "Advanced", 19 | "tags": [ 20 | "re", 21 | "findall", 22 | "regular expressions" 23 | ] 24 | }, 25 | { 26 | "title": "Word values", 27 | "slug": "word-values", 28 | "description": "

Find the dictionary word with the highest value using Scrabble rules.

\r\n

There are three tasks to complete for this Bite:

\r\n
    \r\n
  • Finish the function load_words which creates and returns a list of words from a text file.
  • \r\n
  • Finish the function calc_word_value which calculates and returns a word's Scrabble value.
  • \r\n
  • Finish the function max_word_value which finds and returns the dictionary word with the highest score.
  • \r\n
\r\n

Notes:

\r\n
    \r\n
  • The text of the dictionary is downloaded for you and is available with the path contained in the variable DICTIONARY.
  • \r\n
  • The words in the file are separated by a newline character.
  • \r\n
  • Letters not found in LETTER_SCORES score zero points.
  • \r\n
\r\n

Look at the TESTS tab to see what your code needs to pass. Enjoy!

", 29 | "level": "Intermediate", 30 | "tags": [ 31 | "sum", 32 | "Scrabble", 33 | "max" 34 | ] 35 | } 36 | ] 37 | -------------------------------------------------------------------------------- /tests/testing_content/summing_content.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Pybites Platform 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |

20 | 21 | Pybites Logo 22 | 23 | 24 |

25 | 26 | 97 |
98 |
99 | 100 | 101 |
102 |
103 | 104 |
105 | 106 |
107 | 108 |
109 | 110 |
111 | 112 | 113 |
114 | Back 115 | 116 | 117 | 118 | 119 | Next → 120 | 121 |
122 | 123 | 124 |
125 |
126 | 127 |
128 | 129 |

130 | Sum n numbers 131 |

132 | 133 |
134 | Pybites 135 | 136 |
137 | 138 | 139 |
140 | 141 | View Learning Paths 142 | 145 | 146 | 147 | 156 | 157 |
158 | 159 | 160 | 161 |
162 | 163 |
164 | 165 |

166 | Level: Beginner (score: 2) 167 |

168 | 169 | 170 | 171 |

Write a Python function that calculates the sum of a list of (int) numbers:

172 |
    173 |
  • The function should accept a list of numbers and return the sum of those numbers.
  • 174 |
  • If no argument is provided (that is, numbers is None), return the sum of the numbers 1 to 100 (Note that this is not the same as an empty list of numbers being passed in. In that case the sum returned will be 0).
  • 175 |
176 |

Have fun!

177 | 178 | 179 | 180 | 181 | 182 |
183 | 184 |

185 | 186 | 187 | 188 | 189 | 190 |
191 | 192 | 193 |
194 |
195 | 208 | 209 |
210 |
214 | 219 | 220 | 221 | 222 |
223 | 224 | 244 | 245 | 246 | 252 | 253 | 254 |
255 | 261 | 262 | 263 |
264 | 265 |
266 | 267 |
268 | 269 | 270 |
271 | 272 | 275 |
276 |
277 | 278 | 279 | 280 |
281 | 346 | 347 |
348 | 349 |
350 | 355 |
356 |
357 | 358 | 359 | 360 |
361 |
362 | 363 |
364 | 365 |
366 | 367 |
368 | 369 |
370 | 371 |
372 | 373 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 632 | 633 | 634 | 651 | 652 | 653 | 654 | 655 | 656 | 657 | -------------------------------------------------------------------------------- /tests/testing_content/testing_env: -------------------------------------------------------------------------------- 1 | PYBITES_USERNAME=test_username 2 | PYBITES_PASSWORD=test_password 3 | PYBITES_REPO=test_repo 4 | -------------------------------------------------------------------------------- /tests/testing_repo/.local_bites.json: -------------------------------------------------------------------------------- 1 | {"Rotate string characters": "rotate-string-characters", "Parse a list of names": "parse-a-list-of-names"} 2 | -------------------------------------------------------------------------------- /tests/testing_repo/parse-a-list-of-names/bite.html: -------------------------------------------------------------------------------- 1 |

In this bite you will work with a list of names.

2 |

First you will write a function to take out duplicates and title case them.

3 |

Then you will sort the list in alphabetical descending order by surname and lastly determine what the shortest first name is. For this exercise you can assume there is always one name and one surname.

4 |

With some handy Python builtins you can write this in a pretty concise way. Get it sorted :)

-------------------------------------------------------------------------------- /tests/testing_repo/parse-a-list-of-names/names.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | 4 | NAMES = [ 5 | "arnold schwarzenegger", 6 | "alec baldwin", 7 | "bob belderbos", 8 | "julian sequeira", 9 | "sandra bullock", 10 | "keanu reeves", 11 | "julbob pybites", 12 | "bob belderbos", 13 | "julian sequeira", 14 | "al pacino", 15 | "brad pitt", 16 | "matt damon", 17 | "brad pitt", 18 | ] 19 | 20 | 21 | def dedup_and_title_case_names(names: list) -> List: 22 | """Should return a list of title cased names, 23 | each name appears only once""" 24 | titled = [name.title() for name in names] 25 | return list(set(titled)) 26 | 27 | 28 | def sort_by_surname_desc(names: list) -> List: 29 | """Returns names list sorted desc by surname""" 30 | names = dedup_and_title_case_names(names) 31 | return sorted(names, key=lambda x: x.rsplit(None, 1)[-1], reverse=True) 32 | 33 | 34 | def shortest_first_name(names: list) -> str: 35 | """Returns the shortest first name (str). 36 | You can assume there is only one shortest name. 37 | """ 38 | names = dedup_and_title_case_names(names) 39 | return sorted( 40 | [name.split(" ")[0] for name in names], key=lambda x: len(x.split()[0]) 41 | )[0] 42 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | requires-python = ">=3.12" 3 | 4 | [[package]] 5 | name = "attrs" 6 | version = "24.2.0" 7 | source = { registry = "https://pypi.org/simple" } 8 | sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 } 9 | wheels = [ 10 | { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 }, 11 | ] 12 | 13 | [[package]] 14 | name = "beautifulsoup4" 15 | version = "4.12.3" 16 | source = { registry = "https://pypi.org/simple" } 17 | dependencies = [ 18 | { name = "soupsieve" }, 19 | ] 20 | sdist = { url = "https://files.pythonhosted.org/packages/b3/ca/824b1195773ce6166d388573fc106ce56d4a805bd7427b624e063596ec58/beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051", size = 581181 } 21 | wheels = [ 22 | { url = "https://files.pythonhosted.org/packages/b1/fe/e8c672695b37eecc5cbf43e1d0638d88d66ba3a44c4d321c796f4e59167f/beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed", size = 147925 }, 23 | ] 24 | 25 | [[package]] 26 | name = "cattrs" 27 | version = "24.1.2" 28 | source = { registry = "https://pypi.org/simple" } 29 | dependencies = [ 30 | { name = "attrs" }, 31 | ] 32 | sdist = { url = "https://files.pythonhosted.org/packages/64/65/af6d57da2cb32c076319b7489ae0958f746949d407109e3ccf4d115f147c/cattrs-24.1.2.tar.gz", hash = "sha256:8028cfe1ff5382df59dd36474a86e02d817b06eaf8af84555441bac915d2ef85", size = 426462 } 33 | wheels = [ 34 | { url = "https://files.pythonhosted.org/packages/c8/d5/867e75361fc45f6de75fe277dd085627a9db5ebb511a87f27dc1396b5351/cattrs-24.1.2-py3-none-any.whl", hash = "sha256:67c7495b760168d931a10233f979b28dc04daf853b30752246f4f8471c6d68d0", size = 66446 }, 35 | ] 36 | 37 | [[package]] 38 | name = "certifi" 39 | version = "2024.8.30" 40 | source = { registry = "https://pypi.org/simple" } 41 | sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } 42 | wheels = [ 43 | { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, 44 | ] 45 | 46 | [[package]] 47 | name = "cfgv" 48 | version = "3.4.0" 49 | source = { registry = "https://pypi.org/simple" } 50 | sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } 51 | wheels = [ 52 | { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, 53 | ] 54 | 55 | [[package]] 56 | name = "charset-normalizer" 57 | version = "3.4.0" 58 | source = { registry = "https://pypi.org/simple" } 59 | sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } 60 | wheels = [ 61 | { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 }, 62 | { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 }, 63 | { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 }, 64 | { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 }, 65 | { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 }, 66 | { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 }, 67 | { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 }, 68 | { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 }, 69 | { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 }, 70 | { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 }, 71 | { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 }, 72 | { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 }, 73 | { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 }, 74 | { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 }, 75 | { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 }, 76 | { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, 77 | { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, 78 | { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, 79 | { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, 80 | { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, 81 | { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, 82 | { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, 83 | { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, 84 | { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, 85 | { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, 86 | { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, 87 | { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, 88 | { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, 89 | { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, 90 | { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, 91 | { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, 92 | ] 93 | 94 | [[package]] 95 | name = "click" 96 | version = "8.1.7" 97 | source = { registry = "https://pypi.org/simple" } 98 | dependencies = [ 99 | { name = "colorama", marker = "platform_system == 'Windows'" }, 100 | ] 101 | sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } 102 | wheels = [ 103 | { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, 104 | ] 105 | 106 | [[package]] 107 | name = "colorama" 108 | version = "0.4.6" 109 | source = { registry = "https://pypi.org/simple" } 110 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } 111 | wheels = [ 112 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, 113 | ] 114 | 115 | [[package]] 116 | name = "coverage" 117 | version = "7.6.4" 118 | source = { registry = "https://pypi.org/simple" } 119 | sdist = { url = "https://files.pythonhosted.org/packages/52/12/3669b6382792783e92046730ad3327f53b2726f0603f4c311c4da4824222/coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73", size = 798716 } 120 | wheels = [ 121 | { url = "https://files.pythonhosted.org/packages/7d/e7/9291de916d084f41adddfd4b82246e68d61d6a75747f075f7e64628998d2/coverage-7.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12394842a3a8affa3ba62b0d4ab7e9e210c5e366fbac3e8b2a68636fb19892c2", size = 207013 }, 122 | { url = "https://files.pythonhosted.org/packages/27/03/932c2c5717a7fa80cd43c6a07d3177076d97b79f12f40f882f9916db0063/coverage-7.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2b6b4c83d8e8ea79f27ab80778c19bc037759aea298da4b56621f4474ffeb117", size = 207251 }, 123 | { url = "https://files.pythonhosted.org/packages/d5/3f/0af47dcb9327f65a45455fbca846fe96eb57c153af46c4754a3ba678938a/coverage-7.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d5b8007f81b88696d06f7df0cb9af0d3b835fe0c8dbf489bad70b45f0e45613", size = 240268 }, 124 | { url = "https://files.pythonhosted.org/packages/8a/3c/37a9d81bbd4b23bc7d46ca820e16174c613579c66342faa390a271d2e18b/coverage-7.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b57b768feb866f44eeed9f46975f3d6406380275c5ddfe22f531a2bf187eda27", size = 237298 }, 125 | { url = "https://files.pythonhosted.org/packages/c0/70/6b0627e5bd68204ee580126ed3513140b2298995c1233bd67404b4e44d0e/coverage-7.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5915fcdec0e54ee229926868e9b08586376cae1f5faa9bbaf8faf3561b393d52", size = 239367 }, 126 | { url = "https://files.pythonhosted.org/packages/3c/eb/634d7dfab24ac3b790bebaf9da0f4a5352cbc125ce6a9d5c6cf4c6cae3c7/coverage-7.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b58c672d14f16ed92a48db984612f5ce3836ae7d72cdd161001cc54512571f2", size = 238853 }, 127 | { url = "https://files.pythonhosted.org/packages/d9/0d/8e3ed00f1266ef7472a4e33458f42e39492e01a64281084fb3043553d3f1/coverage-7.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2fdef0d83a2d08d69b1f2210a93c416d54e14d9eb398f6ab2f0a209433db19e1", size = 237160 }, 128 | { url = "https://files.pythonhosted.org/packages/ce/9c/4337f468ef0ab7a2e0887a9c9da0e58e2eada6fc6cbee637a4acd5dfd8a9/coverage-7.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cf717ee42012be8c0cb205dbbf18ffa9003c4cbf4ad078db47b95e10748eec5", size = 238824 }, 129 | { url = "https://files.pythonhosted.org/packages/5e/09/3e94912b8dd37251377bb02727a33a67ee96b84bbbe092f132b401ca5dd9/coverage-7.6.4-cp312-cp312-win32.whl", hash = "sha256:7bb92c539a624cf86296dd0c68cd5cc286c9eef2d0c3b8b192b604ce9de20a17", size = 209639 }, 130 | { url = "https://files.pythonhosted.org/packages/01/69/d4f3a4101171f32bc5b3caec8ff94c2c60f700107a6aaef7244b2c166793/coverage-7.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:1032e178b76a4e2b5b32e19d0fd0abbce4b58e77a1ca695820d10e491fa32b08", size = 210428 }, 131 | { url = "https://files.pythonhosted.org/packages/c2/4d/2dede4f7cb5a70fb0bb40a57627fddf1dbdc6b9c1db81f7c4dcdcb19e2f4/coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9", size = 207039 }, 132 | { url = "https://files.pythonhosted.org/packages/3f/f9/d86368ae8c79e28f1fb458ebc76ae9ff3e8bd8069adc24e8f2fed03c58b7/coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba", size = 207298 }, 133 | { url = "https://files.pythonhosted.org/packages/64/c5/b4cc3c3f64622c58fbfd4d8b9a7a8ce9d355f172f91fcabbba1f026852f6/coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c", size = 239813 }, 134 | { url = "https://files.pythonhosted.org/packages/8a/86/14c42e60b70a79b26099e4d289ccdfefbc68624d096f4481163085aa614c/coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06", size = 236959 }, 135 | { url = "https://files.pythonhosted.org/packages/7f/f8/4436a643631a2fbab4b44d54f515028f6099bfb1cd95b13cfbf701e7f2f2/coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f", size = 238950 }, 136 | { url = "https://files.pythonhosted.org/packages/49/50/1571810ddd01f99a0a8be464a4ac8b147f322cd1e8e296a1528984fc560b/coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b", size = 238610 }, 137 | { url = "https://files.pythonhosted.org/packages/f3/8c/6312d241fe7cbd1f0cade34a62fea6f333d1a261255d76b9a87074d8703c/coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21", size = 236697 }, 138 | { url = "https://files.pythonhosted.org/packages/ce/5f/fef33dfd05d87ee9030f614c857deb6df6556b8f6a1c51bbbb41e24ee5ac/coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a", size = 238541 }, 139 | { url = "https://files.pythonhosted.org/packages/a9/64/6a984b6e92e1ea1353b7ffa08e27f707a5e29b044622445859200f541e8c/coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e", size = 209707 }, 140 | { url = "https://files.pythonhosted.org/packages/5c/60/ce5a9e942e9543783b3db5d942e0578b391c25cdd5e7f342d854ea83d6b7/coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963", size = 210439 }, 141 | { url = "https://files.pythonhosted.org/packages/78/53/6719677e92c308207e7f10561a1b16ab8b5c00e9328efc9af7cfd6fb703e/coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f", size = 207784 }, 142 | { url = "https://files.pythonhosted.org/packages/fa/dd/7054928930671fcb39ae6a83bb71d9ab5f0afb733172543ced4b09a115ca/coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806", size = 208058 }, 143 | { url = "https://files.pythonhosted.org/packages/b5/7d/fd656ddc2b38301927b9eb3aae3fe827e7aa82e691923ed43721fd9423c9/coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11", size = 250772 }, 144 | { url = "https://files.pythonhosted.org/packages/90/d0/eb9a3cc2100b83064bb086f18aedde3afffd7de6ead28f69736c00b7f302/coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3", size = 246490 }, 145 | { url = "https://files.pythonhosted.org/packages/45/44/3f64f38f6faab8a0cfd2c6bc6eb4c6daead246b97cf5f8fc23bf3788f841/coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a", size = 248848 }, 146 | { url = "https://files.pythonhosted.org/packages/5d/11/4c465a5f98656821e499f4b4619929bd5a34639c466021740ecdca42aa30/coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc", size = 248340 }, 147 | { url = "https://files.pythonhosted.org/packages/f1/96/ebecda2d016cce9da812f404f720ca5df83c6b29f65dc80d2000d0078741/coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70", size = 246229 }, 148 | { url = "https://files.pythonhosted.org/packages/16/d9/3d820c00066ae55d69e6d0eae11d6149a5ca7546de469ba9d597f01bf2d7/coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef", size = 247510 }, 149 | { url = "https://files.pythonhosted.org/packages/8f/c3/4fa1eb412bb288ff6bfcc163c11700ff06e02c5fad8513817186e460ed43/coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e", size = 210353 }, 150 | { url = "https://files.pythonhosted.org/packages/7e/77/03fc2979d1538884d921c2013075917fc927f41cd8526909852fe4494112/coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1", size = 211502 }, 151 | ] 152 | 153 | [[package]] 154 | name = "distlib" 155 | version = "0.3.9" 156 | source = { registry = "https://pypi.org/simple" } 157 | sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } 158 | wheels = [ 159 | { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, 160 | ] 161 | 162 | [[package]] 163 | name = "eatlocal" 164 | version = "1.1.5" 165 | source = { editable = "." } 166 | dependencies = [ 167 | { name = "beautifulsoup4" }, 168 | { name = "install-playwright" }, 169 | { name = "iterfzf" }, 170 | { name = "pytest-playwright" }, 171 | { name = "python-dotenv" }, 172 | { name = "requests" }, 173 | { name = "requests-cache" }, 174 | { name = "rich" }, 175 | { name = "typer" }, 176 | ] 177 | 178 | [package.optional-dependencies] 179 | dev = [ 180 | { name = "pre-commit" }, 181 | { name = "pytest" }, 182 | { name = "pytest-cov" }, 183 | { name = "pytest-coverage" }, 184 | ] 185 | 186 | [package.metadata] 187 | requires-dist = [ 188 | { name = "beautifulsoup4", specifier = ">=4.12.3" }, 189 | { name = "install-playwright", specifier = ">=0.1.0" }, 190 | { name = "iterfzf", specifier = ">=1.4.0.54.3" }, 191 | { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0.1" }, 192 | { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.3" }, 193 | { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, 194 | { name = "pytest-coverage", marker = "extra == 'dev'", specifier = ">=0.0" }, 195 | { name = "pytest-playwright", specifier = ">=0.5.2" }, 196 | { name = "python-dotenv", specifier = ">=1.0.1" }, 197 | { name = "requests", specifier = ">=2.32.3" }, 198 | { name = "requests-cache", specifier = ">=1.2.1" }, 199 | { name = "rich", specifier = ">=13.9.2" }, 200 | { name = "typer", specifier = ">=0.12.5" }, 201 | ] 202 | 203 | [[package]] 204 | name = "filelock" 205 | version = "3.16.1" 206 | source = { registry = "https://pypi.org/simple" } 207 | sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } 208 | wheels = [ 209 | { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, 210 | ] 211 | 212 | [[package]] 213 | name = "greenlet" 214 | version = "3.0.3" 215 | source = { registry = "https://pypi.org/simple" } 216 | sdist = { url = "https://files.pythonhosted.org/packages/17/14/3bddb1298b9a6786539ac609ba4b7c9c0842e12aa73aaa4d8d73ec8f8185/greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491", size = 182013 } 217 | wheels = [ 218 | { url = "https://files.pythonhosted.org/packages/a2/2f/461615adc53ba81e99471303b15ac6b2a6daa8d2a0f7f77fd15605e16d5b/greenlet-3.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be", size = 273085 }, 219 | { url = "https://files.pythonhosted.org/packages/e9/55/2c3cfa3cdbb940cf7321fbcf544f0e9c74898eed43bf678abf416812d132/greenlet-3.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e", size = 660514 }, 220 | { url = "https://files.pythonhosted.org/packages/38/77/efb21ab402651896c74f24a172eb4d7479f9f53898bd5e56b9e20bb24ffd/greenlet-3.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676", size = 674295 }, 221 | { url = "https://files.pythonhosted.org/packages/74/3a/92f188ace0190f0066dca3636cf1b09481d0854c46e92ec5e29c7cefe5b1/greenlet-3.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc", size = 669395 }, 222 | { url = "https://files.pythonhosted.org/packages/63/0f/847ed02cdfce10f0e6e3425cd054296bddb11a17ef1b34681fa01a055187/greenlet-3.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230", size = 670455 }, 223 | { url = "https://files.pythonhosted.org/packages/bd/37/56b0da468a85e7704f3b2bc045015301bdf4be2184a44868c71f6dca6fe2/greenlet-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf", size = 625692 }, 224 | { url = "https://files.pythonhosted.org/packages/7c/68/b5f4084c0a252d7e9c0d95fc1cfc845d08622037adb74e05be3a49831186/greenlet-3.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305", size = 1152597 }, 225 | { url = "https://files.pythonhosted.org/packages/a4/fa/31e22345518adcd69d1d6ab5087a12c178aa7f3c51103f6d5d702199d243/greenlet-3.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6", size = 1181043 }, 226 | { url = "https://files.pythonhosted.org/packages/53/80/3d94d5999b4179d91bcc93745d1b0815b073d61be79dd546b840d17adb18/greenlet-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2", size = 293635 }, 227 | ] 228 | 229 | [[package]] 230 | name = "identify" 231 | version = "2.6.1" 232 | source = { registry = "https://pypi.org/simple" } 233 | sdist = { url = "https://files.pythonhosted.org/packages/29/bb/25024dbcc93516c492b75919e76f389bac754a3e4248682fba32b250c880/identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98", size = 99097 } 234 | wheels = [ 235 | { url = "https://files.pythonhosted.org/packages/7d/0c/4ef72754c050979fdcc06c744715ae70ea37e734816bb6514f79df77a42f/identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0", size = 98972 }, 236 | ] 237 | 238 | [[package]] 239 | name = "idna" 240 | version = "3.10" 241 | source = { registry = "https://pypi.org/simple" } 242 | sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } 243 | wheels = [ 244 | { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, 245 | ] 246 | 247 | [[package]] 248 | name = "iniconfig" 249 | version = "2.0.0" 250 | source = { registry = "https://pypi.org/simple" } 251 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } 252 | wheels = [ 253 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, 254 | ] 255 | 256 | [[package]] 257 | name = "install-playwright" 258 | version = "0.1.0" 259 | source = { registry = "https://pypi.org/simple" } 260 | dependencies = [ 261 | { name = "playwright" }, 262 | ] 263 | sdist = { url = "https://files.pythonhosted.org/packages/c3/11/4d7cb2d9404a3c17a65e550ac903c2430e4f8e1b34c3239fa4f85afd086c/install_playwright-0.1.0.tar.gz", hash = "sha256:bd9eb0ee05cfb2734b21d3a42ae3cf253840d858c3a7d867f015b54000f955c8", size = 2969 } 264 | wheels = [ 265 | { url = "https://files.pythonhosted.org/packages/99/26/ac0f806e4fd297df90ee69e3273bf84fc079cfc6254184cadda1f9f58cc0/install_playwright-0.1.0-py3-none-any.whl", hash = "sha256:37850e49bdae09b72c0922b64bb623f4c44fd9ea09ba9ad5e113735399d8f8d2", size = 3236 }, 266 | ] 267 | 268 | [[package]] 269 | name = "iterfzf" 270 | version = "1.4.0.54.3" 271 | source = { registry = "https://pypi.org/simple" } 272 | sdist = { url = "https://files.pythonhosted.org/packages/ed/2d/59f1b1eeaef868b24aba97e651c9c9bbf937b8a6967ffc23d1f9131c8f68/iterfzf-1.4.0.54.3.tar.gz", hash = "sha256:8a0b9dc4f1a126da959dd601643bf31de7fa69febb7c33f4b6e2fa8edc4be2e1", size = 1759273 } 273 | wheels = [ 274 | { url = "https://files.pythonhosted.org/packages/20/2c/3afd6d9911204f8324195a0cf91b39844faaf3ad56bffd35d5d8083d84aa/iterfzf-1.4.0.54.3-py3-none-macosx_10_7_x86_64.macosx_10_9_x86_64.whl", hash = "sha256:0d070c575cb9e706651e2f753d0361639fcd7b79435ef4626a824a0a76b03dce", size = 1643311 }, 275 | { url = "https://files.pythonhosted.org/packages/45/bb/9d195462a6e8348aa9c16b47009f96cfa0cc84f62c53cdc7022c903c6c79/iterfzf-1.4.0.54.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9d3edbd8184490bd9ab8cd399db462e010ae7c2273d933b040ca536d5c1a8915", size = 1557844 }, 276 | { url = "https://files.pythonhosted.org/packages/a8/44/6e7d873d292d17cb6af6c77d0f274408ae9da7dd9ac907eb48a2c83b39e7/iterfzf-1.4.0.54.3-py3-none-manylinux_1_2_aarch64.manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f981dcf71f44bd4aa7119cc8e19373a8a8726db6f829a505d131c263c2fe8d84", size = 1432325 }, 277 | { url = "https://files.pythonhosted.org/packages/ad/3c/a6eebbfa3ab26d49e0f752b23cbd964011bd1de0579c97bab7ffe917bc8c/iterfzf-1.4.0.54.3-py3-none-manylinux_1_2_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c87a093cdd13b9604f3e7f457dd49ec1bc1dad63899476a01f5743ad1f394bb9", size = 1552301 }, 278 | { url = "https://files.pythonhosted.org/packages/8a/88/6962ea965f146fb9ca11062756eb4c5c86d54b20eb38d93e7c1297cb96a9/iterfzf-1.4.0.54.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a7510bc99e503b12513fceca9add5ae6bcd9497e95a098ae682ca5b2916dc3c2", size = 1427831 }, 279 | { url = "https://files.pythonhosted.org/packages/9c/69/ca3abe63b1a7296ff2b8cb173e69283abf5f235268c393b48fe355918aba/iterfzf-1.4.0.54.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6c32312ce5599d84600d920b0db8986cf10b20e4b9fdb2aee5df6b67cc646d27", size = 1545488 }, 280 | { url = "https://files.pythonhosted.org/packages/81/9c/c94bf804b1c18024810ee3ebc4528faec4cedef18824776b9f3a37ad13fa/iterfzf-1.4.0.54.3-py3-none-win_amd64.whl", hash = "sha256:8385d45359d6c392b10095d179f0b2857f64b33eba55e1b6a34eb5d82320377f", size = 1763502 }, 281 | { url = "https://files.pythonhosted.org/packages/6c/24/42c53c9df027b630e54276e03d54d0ab80b4ecf425b9f74e1591765e54a6/iterfzf-1.4.0.54.3-py3-none-win_arm64.whl", hash = "sha256:bf78d55832e172fe7bce451f3b68becde28412a192f7161ca7bd2313bd5e842f", size = 1626719 }, 282 | ] 283 | 284 | [[package]] 285 | name = "markdown-it-py" 286 | version = "3.0.0" 287 | source = { registry = "https://pypi.org/simple" } 288 | dependencies = [ 289 | { name = "mdurl" }, 290 | ] 291 | sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } 292 | wheels = [ 293 | { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, 294 | ] 295 | 296 | [[package]] 297 | name = "mdurl" 298 | version = "0.1.2" 299 | source = { registry = "https://pypi.org/simple" } 300 | sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } 301 | wheels = [ 302 | { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, 303 | ] 304 | 305 | [[package]] 306 | name = "nodeenv" 307 | version = "1.9.1" 308 | source = { registry = "https://pypi.org/simple" } 309 | sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } 310 | wheels = [ 311 | { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, 312 | ] 313 | 314 | [[package]] 315 | name = "packaging" 316 | version = "24.1" 317 | source = { registry = "https://pypi.org/simple" } 318 | sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } 319 | wheels = [ 320 | { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, 321 | ] 322 | 323 | [[package]] 324 | name = "platformdirs" 325 | version = "4.3.6" 326 | source = { registry = "https://pypi.org/simple" } 327 | sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } 328 | wheels = [ 329 | { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, 330 | ] 331 | 332 | [[package]] 333 | name = "playwright" 334 | version = "1.47.0" 335 | source = { registry = "https://pypi.org/simple" } 336 | dependencies = [ 337 | { name = "greenlet" }, 338 | { name = "pyee" }, 339 | ] 340 | wheels = [ 341 | { url = "https://files.pythonhosted.org/packages/f8/70/01cad1d41861cd939fe66bff725771dd03f2de39b7c25b4479de2f583ce0/playwright-1.47.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:f205df24edb925db1a4ab62f1ab0da06f14bb69e382efecfb0deedc4c7f4b8cd", size = 34897583 }, 342 | { url = "https://files.pythonhosted.org/packages/42/17/2300e578b434b56ebfc3d56a5e0fe6dc5e99d6ff43a88fa492b881f3b7e3/playwright-1.47.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fc820faf6885f69a52ba4ec94124e575d3c4a4003bf29200029b4a4f2b2d0ab", size = 33210301 }, 343 | { url = "https://files.pythonhosted.org/packages/5a/6a/3cff2abfa4b4c52e1fa34fa8b71bf09cc2a89b03b7417733e5138f1be61d/playwright-1.47.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:8e212dc472ff19c7d46ed7e900191c7a786ce697556ac3f1615986ec3aa00341", size = 34897582 }, 344 | { url = "https://files.pythonhosted.org/packages/80/a6/c5152c817db664d75c439c2bd99d51f906a31c1df4a04e673ef51008b12f/playwright-1.47.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:a1935672531963e4b2a321de5aa59b982fb92463ee6e1032dd7326378e462955", size = 38064944 }, 345 | { url = "https://files.pythonhosted.org/packages/d6/50/b573c13d3748a1ab94ed45f2faeb868c63263df0055f57028c4cc775419f/playwright-1.47.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e0a1b61473d6f7f39c5d77d4800b3cbefecb03344c90b98f3fbcae63294ad249", size = 37815214 }, 346 | { url = "https://files.pythonhosted.org/packages/7d/6c/34225ee5707db5e34bffa77f05d152c797c0e0b9bf3d3a5b426d99160f8f/playwright-1.47.0-py3-none-win32.whl", hash = "sha256:1b977ed81f6bba5582617684a21adab9bad5676d90a357ebf892db7bdf4a9974", size = 29909693 }, 347 | { url = "https://files.pythonhosted.org/packages/cb/88/9a3c77025702e506fe04275e677676246ff0b2e6964de5d2527dfdab3416/playwright-1.47.0-py3-none-win_amd64.whl", hash = "sha256:0ec1056042d2e86088795a503347407570bffa32cbe20748e5d4c93dba085280", size = 29909697 }, 348 | ] 349 | 350 | [[package]] 351 | name = "pluggy" 352 | version = "1.5.0" 353 | source = { registry = "https://pypi.org/simple" } 354 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } 355 | wheels = [ 356 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, 357 | ] 358 | 359 | [[package]] 360 | name = "pre-commit" 361 | version = "4.0.1" 362 | source = { registry = "https://pypi.org/simple" } 363 | dependencies = [ 364 | { name = "cfgv" }, 365 | { name = "identify" }, 366 | { name = "nodeenv" }, 367 | { name = "pyyaml" }, 368 | { name = "virtualenv" }, 369 | ] 370 | sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678 } 371 | wheels = [ 372 | { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713 }, 373 | ] 374 | 375 | [[package]] 376 | name = "pyee" 377 | version = "12.0.0" 378 | source = { registry = "https://pypi.org/simple" } 379 | dependencies = [ 380 | { name = "typing-extensions" }, 381 | ] 382 | sdist = { url = "https://files.pythonhosted.org/packages/d2/a7/8faaa62a488a2a1e0d56969757f087cbd2729e9bcfa508c230299f366b4c/pyee-12.0.0.tar.gz", hash = "sha256:c480603f4aa2927d4766eb41fa82793fe60a82cbfdb8d688e0d08c55a534e145", size = 29675 } 383 | wheels = [ 384 | { url = "https://files.pythonhosted.org/packages/1d/0d/95993c08c721ec68892547f2117e8f9dfbcef2ca71e098533541b4a54d5f/pyee-12.0.0-py3-none-any.whl", hash = "sha256:7b14b74320600049ccc7d0e0b1becd3b4bd0a03c745758225e31a59f4095c990", size = 14831 }, 385 | ] 386 | 387 | [[package]] 388 | name = "pygments" 389 | version = "2.18.0" 390 | source = { registry = "https://pypi.org/simple" } 391 | sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } 392 | wheels = [ 393 | { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, 394 | ] 395 | 396 | [[package]] 397 | name = "pytest" 398 | version = "8.3.3" 399 | source = { registry = "https://pypi.org/simple" } 400 | dependencies = [ 401 | { name = "colorama", marker = "sys_platform == 'win32'" }, 402 | { name = "iniconfig" }, 403 | { name = "packaging" }, 404 | { name = "pluggy" }, 405 | ] 406 | sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } 407 | wheels = [ 408 | { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, 409 | ] 410 | 411 | [[package]] 412 | name = "pytest-base-url" 413 | version = "2.1.0" 414 | source = { registry = "https://pypi.org/simple" } 415 | dependencies = [ 416 | { name = "pytest" }, 417 | { name = "requests" }, 418 | ] 419 | sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702 } 420 | wheels = [ 421 | { url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302 }, 422 | ] 423 | 424 | [[package]] 425 | name = "pytest-cov" 426 | version = "5.0.0" 427 | source = { registry = "https://pypi.org/simple" } 428 | dependencies = [ 429 | { name = "coverage" }, 430 | { name = "pytest" }, 431 | ] 432 | sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042 } 433 | wheels = [ 434 | { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, 435 | ] 436 | 437 | [[package]] 438 | name = "pytest-cover" 439 | version = "3.0.0" 440 | source = { registry = "https://pypi.org/simple" } 441 | dependencies = [ 442 | { name = "pytest-cov" }, 443 | ] 444 | sdist = { url = "https://files.pythonhosted.org/packages/30/27/20964101a7cdb260f8d6c4e854659026968321d10c90552b1fe7f6c5f913/pytest-cover-3.0.0.tar.gz", hash = "sha256:5bdb6c1cc3dd75583bb7bc2c57f5e1034a1bfcb79d27c71aceb0b16af981dbf4", size = 3211 } 445 | wheels = [ 446 | { url = "https://files.pythonhosted.org/packages/71/9b/7b4700c462628e169bd859c6368d596a6aedc87936bde733bead9f875fce/pytest_cover-3.0.0-py2.py3-none-any.whl", hash = "sha256:578249955eb3b5f3991209df6e532bb770b647743b7392d3d97698dc02f39ebb", size = 3769 }, 447 | ] 448 | 449 | [[package]] 450 | name = "pytest-coverage" 451 | version = "0.0" 452 | source = { registry = "https://pypi.org/simple" } 453 | dependencies = [ 454 | { name = "pytest-cover" }, 455 | ] 456 | sdist = { url = "https://files.pythonhosted.org/packages/01/81/1d954849aed17b254d1c397eb4447a05eedce612a56b627c071df2ce00c1/pytest-coverage-0.0.tar.gz", hash = "sha256:db6af2cbd7e458c7c9fd2b4207cee75258243c8a81cad31a7ee8cfad5be93c05", size = 873 } 457 | wheels = [ 458 | { url = "https://files.pythonhosted.org/packages/5b/4b/d95b052f87db89a2383233c0754c45f6d3b427b7a4bcb771ac9316a6fae1/pytest_coverage-0.0-py2.py3-none-any.whl", hash = "sha256:dedd084c5e74d8e669355325916dc011539b190355021b037242514dee546368", size = 2013 }, 459 | ] 460 | 461 | [[package]] 462 | name = "pytest-playwright" 463 | version = "0.5.2" 464 | source = { registry = "https://pypi.org/simple" } 465 | dependencies = [ 466 | { name = "playwright" }, 467 | { name = "pytest" }, 468 | { name = "pytest-base-url" }, 469 | { name = "python-slugify" }, 470 | ] 471 | sdist = { url = "https://files.pythonhosted.org/packages/14/9a/f5459c9448332a5bee85681b4e1debb47482c1bd5b5c074bbc9081a02538/pytest_playwright-0.5.2.tar.gz", hash = "sha256:c6d603df9e6c50b35f057b0528e11d41c0963283e98c257267117f5ed6ba1924", size = 22624 } 472 | wheels = [ 473 | { url = "https://files.pythonhosted.org/packages/01/6c/3ad6697d0da2279869cb77d5a6bbb4a9c0cec670a861bf5a9f246b39433f/pytest_playwright-0.5.2-py3-none-any.whl", hash = "sha256:2c5720591364a1cdf66610b972ff8492512bc380953e043c85f705b78b2ed582", size = 12160 }, 474 | ] 475 | 476 | [[package]] 477 | name = "python-dotenv" 478 | version = "1.0.1" 479 | source = { registry = "https://pypi.org/simple" } 480 | sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } 481 | wheels = [ 482 | { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, 483 | ] 484 | 485 | [[package]] 486 | name = "python-slugify" 487 | version = "8.0.4" 488 | source = { registry = "https://pypi.org/simple" } 489 | dependencies = [ 490 | { name = "text-unidecode" }, 491 | ] 492 | sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921 } 493 | wheels = [ 494 | { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051 }, 495 | ] 496 | 497 | [[package]] 498 | name = "pyyaml" 499 | version = "6.0.2" 500 | source = { registry = "https://pypi.org/simple" } 501 | sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } 502 | wheels = [ 503 | { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, 504 | { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, 505 | { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, 506 | { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, 507 | { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, 508 | { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, 509 | { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, 510 | { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, 511 | { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, 512 | { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, 513 | { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, 514 | { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, 515 | { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, 516 | { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, 517 | { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, 518 | { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, 519 | { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, 520 | { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, 521 | ] 522 | 523 | [[package]] 524 | name = "requests" 525 | version = "2.32.3" 526 | source = { registry = "https://pypi.org/simple" } 527 | dependencies = [ 528 | { name = "certifi" }, 529 | { name = "charset-normalizer" }, 530 | { name = "idna" }, 531 | { name = "urllib3" }, 532 | ] 533 | sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } 534 | wheels = [ 535 | { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, 536 | ] 537 | 538 | [[package]] 539 | name = "requests-cache" 540 | version = "1.2.1" 541 | source = { registry = "https://pypi.org/simple" } 542 | dependencies = [ 543 | { name = "attrs" }, 544 | { name = "cattrs" }, 545 | { name = "platformdirs" }, 546 | { name = "requests" }, 547 | { name = "url-normalize" }, 548 | { name = "urllib3" }, 549 | ] 550 | sdist = { url = "https://files.pythonhosted.org/packages/1a/be/7b2a95a9e7a7c3e774e43d067c51244e61dea8b120ae2deff7089a93fb2b/requests_cache-1.2.1.tar.gz", hash = "sha256:68abc986fdc5b8d0911318fbb5f7c80eebcd4d01bfacc6685ecf8876052511d1", size = 3018209 } 551 | wheels = [ 552 | { url = "https://files.pythonhosted.org/packages/4e/2e/8f4051119f460cfc786aa91f212165bb6e643283b533db572d7b33952bd2/requests_cache-1.2.1-py3-none-any.whl", hash = "sha256:1285151cddf5331067baa82598afe2d47c7495a1334bfe7a7d329b43e9fd3603", size = 61425 }, 553 | ] 554 | 555 | [[package]] 556 | name = "rich" 557 | version = "13.9.2" 558 | source = { registry = "https://pypi.org/simple" } 559 | dependencies = [ 560 | { name = "markdown-it-py" }, 561 | { name = "pygments" }, 562 | ] 563 | sdist = { url = "https://files.pythonhosted.org/packages/aa/9e/1784d15b057b0075e5136445aaea92d23955aad2c93eaede673718a40d95/rich-13.9.2.tar.gz", hash = "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c", size = 222843 } 564 | wheels = [ 565 | { url = "https://files.pythonhosted.org/packages/67/91/5474b84e505a6ccc295b2d322d90ff6aa0746745717839ee0c5fb4fdcceb/rich-13.9.2-py3-none-any.whl", hash = "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1", size = 242117 }, 566 | ] 567 | 568 | [[package]] 569 | name = "shellingham" 570 | version = "1.5.4" 571 | source = { registry = "https://pypi.org/simple" } 572 | sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } 573 | wheels = [ 574 | { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, 575 | ] 576 | 577 | [[package]] 578 | name = "six" 579 | version = "1.16.0" 580 | source = { registry = "https://pypi.org/simple" } 581 | sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } 582 | wheels = [ 583 | { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, 584 | ] 585 | 586 | [[package]] 587 | name = "soupsieve" 588 | version = "2.6" 589 | source = { registry = "https://pypi.org/simple" } 590 | sdist = { url = "https://files.pythonhosted.org/packages/d7/ce/fbaeed4f9fb8b2daa961f90591662df6a86c1abf25c548329a86920aedfb/soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", size = 101569 } 591 | wheels = [ 592 | { url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186 }, 593 | ] 594 | 595 | [[package]] 596 | name = "text-unidecode" 597 | version = "1.3" 598 | source = { registry = "https://pypi.org/simple" } 599 | sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885 } 600 | wheels = [ 601 | { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154 }, 602 | ] 603 | 604 | [[package]] 605 | name = "typer" 606 | version = "0.12.5" 607 | source = { registry = "https://pypi.org/simple" } 608 | dependencies = [ 609 | { name = "click" }, 610 | { name = "rich" }, 611 | { name = "shellingham" }, 612 | { name = "typing-extensions" }, 613 | ] 614 | sdist = { url = "https://files.pythonhosted.org/packages/c5/58/a79003b91ac2c6890fc5d90145c662fd5771c6f11447f116b63300436bc9/typer-0.12.5.tar.gz", hash = "sha256:f592f089bedcc8ec1b974125d64851029c3b1af145f04aca64d69410f0c9b722", size = 98953 } 615 | wheels = [ 616 | { url = "https://files.pythonhosted.org/packages/a8/2b/886d13e742e514f704c33c4caa7df0f3b89e5a25ef8db02aa9ca3d9535d5/typer-0.12.5-py3-none-any.whl", hash = "sha256:62fe4e471711b147e3365034133904df3e235698399bc4de2b36c8579298d52b", size = 47288 }, 617 | ] 618 | 619 | [[package]] 620 | name = "typing-extensions" 621 | version = "4.12.2" 622 | source = { registry = "https://pypi.org/simple" } 623 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } 624 | wheels = [ 625 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, 626 | ] 627 | 628 | [[package]] 629 | name = "url-normalize" 630 | version = "1.4.3" 631 | source = { registry = "https://pypi.org/simple" } 632 | dependencies = [ 633 | { name = "six" }, 634 | ] 635 | sdist = { url = "https://files.pythonhosted.org/packages/ec/ea/780a38c99fef750897158c0afb83b979def3b379aaac28b31538d24c4e8f/url-normalize-1.4.3.tar.gz", hash = "sha256:d23d3a070ac52a67b83a1c59a0e68f8608d1cd538783b401bc9de2c0fac999b2", size = 6024 } 636 | wheels = [ 637 | { url = "https://files.pythonhosted.org/packages/65/1c/6c6f408be78692fc850006a2b6dea37c2b8592892534e09996e401efc74b/url_normalize-1.4.3-py2.py3-none-any.whl", hash = "sha256:ec3c301f04e5bb676d333a7fa162fa977ad2ca04b7e652bfc9fac4e405728eed", size = 6804 }, 638 | ] 639 | 640 | [[package]] 641 | name = "urllib3" 642 | version = "2.2.3" 643 | source = { registry = "https://pypi.org/simple" } 644 | sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } 645 | wheels = [ 646 | { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, 647 | ] 648 | 649 | [[package]] 650 | name = "virtualenv" 651 | version = "20.27.1" 652 | source = { registry = "https://pypi.org/simple" } 653 | dependencies = [ 654 | { name = "distlib" }, 655 | { name = "filelock" }, 656 | { name = "platformdirs" }, 657 | ] 658 | sdist = { url = "https://files.pythonhosted.org/packages/8c/b3/7b6a79c5c8cf6d90ea681310e169cf2db2884f4d583d16c6e1d5a75a4e04/virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba", size = 6491145 } 659 | wheels = [ 660 | { url = "https://files.pythonhosted.org/packages/ae/92/78324ff89391e00c8f4cf6b8526c41c6ef36b4ea2d2c132250b1a6fc2b8d/virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4", size = 3117838 }, 661 | ] 662 | --------------------------------------------------------------------------------