diff --git a/peppy-poppies/.env.example b/peppy-poppies/.env.example
new file mode 100644
index 00000000..efbe8a46
--- /dev/null
+++ b/peppy-poppies/.env.example
@@ -0,0 +1,13 @@
+# Secret key used for the demo server to generate JWT for login session. CAPTCHA server don't use this
+SECRET_KEY=super-secret-key
+
+# Change if needed (if you follow the instructions in the README, you don't need to change this)
+KEY_PATH=./captcha_data # You can add your public/private key with `public.pem` and `private.pem` key. They must be pair of Ed25519 key
+CODECAPTCHA_DOMAIN=http://127.0.0.1:8001
+
+FONT_PATH=./captcha_data/JetBrainsMono-Regular.ttf # You can edit to a different font and edit this, the provide font use OFL license, included alongside the font
+# OFL font is compatible with MIT
+
+# ======================== Docker only ========================
+# This is used for without domain as the service might not be discoverable in the same way. Use http://captcha:8001 in docker
+CODECAPTCHA_DOMAIN_INTERNAL=
diff --git a/peppy-poppies/.github/workflows/lint.yaml b/peppy-poppies/.github/workflows/lint.yaml
new file mode 100644
index 00000000..7f67e803
--- /dev/null
+++ b/peppy-poppies/.github/workflows/lint.yaml
@@ -0,0 +1,35 @@
+# GitHub Action workflow enforcing our code style.
+
+name: Lint
+
+# Trigger the workflow on both push (to the main repository, on the main branch)
+# and pull requests (against the main repository, but from any repo, from any branch).
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+# Brand new concurrency setting! This ensures that not more than one run can be triggered for the same commit.
+# It is useful for pull requests coming from the main repository since both triggers will match.
+concurrency: lint-${{ github.sha }}
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+
+ env:
+ # The Python version your project uses. Feel free to change this if required.
+ PYTHON_VERSION: "3.12"
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Python ${{ env.PYTHON_VERSION }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+
+ - name: Run pre-commit hooks
+ uses: pre-commit/action@v3.0.1
diff --git a/peppy-poppies/.gitignore b/peppy-poppies/.gitignore
new file mode 100644
index 00000000..5af5e618
--- /dev/null
+++ b/peppy-poppies/.gitignore
@@ -0,0 +1,36 @@
+# Files generated by the interpreter
+__pycache__/
+*.py[cod]
+
+# Environment specific
+.venv
+venv
+.env
+env
+
+# Unittest reports
+.coverage*
+
+# Logs
+*.log
+
+# PyEnv version selector
+.python-version
+
+# Built objects
+*.so
+dist/
+build/
+
+# IDEs
+# PyCharm
+.idea/
+# VSCode
+.vscode/
+# MacOS
+.DS_Store
+
+*.pem
+*.sqlite
+!captcha_data/question_set.json
+test.py
diff --git a/peppy-poppies/.pre-commit-config.yaml b/peppy-poppies/.pre-commit-config.yaml
new file mode 100644
index 00000000..c0a8de23
--- /dev/null
+++ b/peppy-poppies/.pre-commit-config.yaml
@@ -0,0 +1,18 @@
+# Pre-commit configuration.
+# See https://github.com/python-discord/code-jam-template/tree/main#pre-commit-run-linting-before-committing
+
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v5.0.0
+ hooks:
+ - id: check-toml
+ - id: check-yaml
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+ args: [--markdown-linebreak-ext=md]
+
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.12.2
+ hooks:
+ - id: ruff-check
+ - id: ruff-format
diff --git a/peppy-poppies/LICENSE.txt b/peppy-poppies/LICENSE.txt
new file mode 100644
index 00000000..5a04926b
--- /dev/null
+++ b/peppy-poppies/LICENSE.txt
@@ -0,0 +1,7 @@
+Copyright 2021 Python Discord
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/peppy-poppies/README.md b/peppy-poppies/README.md
new file mode 100644
index 00000000..6d810691
--- /dev/null
+++ b/peppy-poppies/README.md
@@ -0,0 +1,198 @@
+# CodeCaptcha
+A CAPTCHA system where the user writes python code to solve a given question.
+
+A normal example:
+
+
+
+
+Chaotic example (Note that it is very long):
+
+This question has 100 steps and ~~AI has failed to solve this :)~~ (I used the wrong model in the original test with Google Gemini 2.5 Flash, and Gemini 2.5 Pro was able to solve this with 13,857 tokens)
+
+
+
+
+Spoiler: Test input
+
+```py
+[16386, 19874, 48296, 41718, 58488, 42810, 58044]
+```
+
+
+
+
+Spoiler: Test output
+
+```py
+[24, 24, 24, 24, 24, 24, 24]
+```
+
+Could you have possibly guessed the result looks like this? I certainly didn't
+
+> Side note: The reason I figured out afterward is that each step in the question is deterministic (a given input would always give the same output for the same step), and many steps in the `question_set.json` have a many-to-one relationship, therefore, when there are a lot of steps (in this case: 100), it is very likely that all the inputs produce the exact same result.
+
+
+
+
+
+## How to Run the project with the demo
+
+Run with docker (recommended)
+
+Prerequisites: Have [docker](https://docs.docker.com/engine/install/) installed with `docker compose`
+
+### Step 1: Setup `.env`
+
+In contrast to running without docker, `.env` setup is necessary for running in docker, It would fail if the following environment variables are not set.
+
+- `CODECAPTCHA_DOMAIN` to `http://127.0.0.1:9201`
+ - It can be changed depending on the configuration in `docker-compose.yml`. This environment variable is the domain in which the **client** accesses the CAPTCHA server
+- `CODECAPTCHA_DOMAIN_INTERNAL` to `http://captcha:8001`
+ - If the CAPTCHA endpoint is from `CODECAPTCHA_DOMAIN` it can be accessible inside the docker container (such as a publicly accessible domain), then this environment variable is not needed
+
+Other configurations can be changed, according to the documentation in `.env.example`. The default values should work for them.
+
+### Step 2: Run the project
+```bash
+docker compose up -d --build
+```
+The [demo site](http://127.0.0.1:9200) and the [captcha site](http://127.0.0.1:9201) can be accessed from http://127.0.0.1:9200 and http://127.0.0.1:9201 respectively
+
+
+Run without docker
+
+Prerequisites: Have [uv](https://docs.astral.sh/uv/getting-started/installation/) and `python3.12` installed
+
+### Step 1: Setup `.venv`
+```bash
+uv sync
+```
+
+### Step 2: Setup `.env`
+By default, the project can be run without creating or setting up `.env`, however, you might want to change some configurations. Check `.env.example` for information to configurate `.env`.
+
+Note: If you changed the `.env` file, or any other files in `frontend/`, you must set the project up from the next step again.
+
+### Step 3: Run the build script
+```bash
+uv run build.py
+```
+This configures the frontend code to create a set of files in `dist/` which link the demo assets to the CAPTCHA server endpoint defined in `CODECAPTCHA_DOMAIN`
+
+### Step 4: Run the project
+Run in 2 seperate terminals
+```bash
+# This must be run first
+uv run litestar --app server.captcha.main:app run --port 8001 --reload
+# Run the following about 5 to 10 seconds later in the other terminal
+uv run litestar --app server.backend.main:app run --port 8000 --reload
+```
+`--host 0.0.0.0` can be added on either command if it need to be accessible from other IP
+
+The [demo site](http://127.0.0.1:8000) and the [captcha site](http://127.0.0.1:8001) can be accessed from http://127.0.0.1:8000 and http://127.0.0.1:8001 respectively
+
+
+## Short video demo
+
+
+## Why is it wrong?
+
+Have you ever seen a meme like this?
+
+
+
+> Mark Rushakoff Sep 19, 2009 from StackOverflow, Answer to "alternative captcha methods", https://stackoverflow.com/a/1448684 CC-BY-SA 2.5. The original source that the author mentioned is no longer available
+
+One that is overcomplicated and no one can solve them? We done something similar, but for coding! You have to write the code to solve very specific problems that have no practical usage, and hopefully it's fun?
+
+### Other reasons it is wrong
+- It is written in python - which makes it really slow (This is why it takes 5-6 seconds to load) and in addition to the complex UI framework used on the CAPTCHA UI for features such as the code editor, it would freeze my tab for about 10s and about 30s to load (hopefully that doesn't happen on yours), and it downloads about 50MiB of packages before it can be run.
+- Statistically LLMs have higher success rates to solve these problems than a human can, without help from LLMs or other people (considering most of the population in the world cannot write python code by themselves).
+- LLMs are not trained to avoid solving these questions, unlike conventional CAPTCHAs, which makes them potentially have a higher chance of success than normal CAPTCHAs.
+- The server also needed high computational effort to know and validate the answer, unlike normal CAPTCHA which the question is generated from answers.
+
+## Project structure
+
+- `server/captcha` - The CAPTCHA server, written with Litestar that is responsible for generating images and creating the questions.
+- `server/backend` - A basic demo server written with Litestar that handles the theoretical login system that is gated with CAPTCHA.
+- `frontend/captcha` - Contains the static assets for the CAPTCHA server.
+
+ Disclosure
+
+ `frontend/captcha/parse.py` is a patch for micropython as it didn't bundle the `urllib.parse` stdlib. The file contains `urllib.parse` and `ipaddress` standard libraries from [cpython](https://github.com/python/cpython) and a patch for some other feature that isn't available in micropython such as `str.isascii` and "too complex" regex which is used by the original code from the standard library, which replaces with equivialent behaviour.
+
+
+- `frontend/demo` - A demo login page for testing
+- `crypto` - A module that both CAPTCHA server and demo server use to generate/handle public private key pair, and to generate and validate the JWT(JSON Web Token) using the Ed25519 key.
+- `captcha_data` - Contains a font file and the a question set
+
+ Disclosure
+
+ - `captcha_data/JetBrainsMono-Regular.ttf` is a font created by JetBrains, and licensed under SIL Open Font License, Version 1.1 (OFL). The license text is inclued in `captcha_data/OFL.txt` as required by the license. The font is included for the purpose to run the project with minimal setup, and any other font can be used. The OFL license is compatible with MIT license according to [FOSSA](https://fossa.com/blog/open-source-licenses-101-sil-open-font-license-ofl/)
+ - `captcha_data/question_set.json` is created with a combination of manual effort and AI generation. Approximately 2 million questions are generated during testing, with only 1 exceeding the execution timeout of 0.5s during testing when calculating a prime number, which likely originated from saturated resource usage from other tasks running on the test device. However, some question descriptions might not match the checking criteria (with 1 known case found after the deadline)
+
+
+- `build.py` A build script which puts all the files from `frontend/` to `dist/` and replaces `[domain]` in specific files to the domain defined by the environment variable `CODECAPTCHA_DOMAIN` so the client can connect to the correct CAPTCHA servers specified in the static file. This is only needed for the demo server, but for consistency, the CAPTCHA server will also use it.
+
+## Contribution list
+
+ harshal6927 (@Harshal6927)
+
+- Demo server
+- CAPTCHA server
+- Demo page UI
+- Demo login system on the backend
+- Setup SQLite database for the servers
+
+
+ deleted.user0 (@i-am-unknown-81514525)
+
+- CAPTCHA UI
+- CAPTCHA frontend
+- Adding login system on the demo frontend
+- JWT generation and validation, Ed25519 key generation and handling
+- Question generator parser
+- Writing question set
+- Update image generation
+- Write the final `README.md`
+
+
+ ununenium817 (@Ununenium817)
+
+- Help writing the CAPTCHA server that was replaced
+- Writing question set
+- Construct system prompt and code to generate part of question set
+
+
+ wrybyte (@tayvona)
+
+- Minor UI improvement on demo page
+
+
+ fiwam (@fiwam)
+
+- Update CAPTCHA frontend to handle image
+- Convert the question generated to send image instead of text to client
+
+
+ dragonsenseiguy (@DragonSenseiGuy)
+
+- Attempt to write the CAPTCHA server that was replaced
+- Minor UI improvement for the CAPTCHA frame for usability
+- Wrote JWT Validator but was scrapped(due to issues).
+- Attempt to write the login system for demo server that didn't get merged.
+- Update various part of `README.md`
+
+
+## Framework choice
+
+The project used a combination of Pyscript and Pyodide from the approved frameworks, with some HTML/js/css when necessary in the CAPTCHA files.
+- Pyscript is used in `frontend/captcha/captcha.py` and `frontend/captcha/captcha_handler.py` to handle the communication between 2 sites to exchange the JWT token.
+- Pyodide is used to run user code in isolation in `frontend/captcha/runner.js`
+
+Other framework:
+- [Panel](https://panel.holoviz.org/) is used as a frontend framework with pyscript as a CAPTCHA UI, defined in `frontend/captcha/captcha.py`.
+- [Litestar](https://litestar.dev/) is used as backend framework for both the CAPTCHA server and DEMO server in `server/`.
+- [pyjwt](https://pyjwt.readthedocs.io/) and [cryptography](https://cryptography.io/) is used for Ed25519 key handling, JWT creation and validation in `crypto/`.
+- [Pillow](https://pillow.readthedocs.io/en/stable/) is used to generate image of question that is sent to the client.
diff --git a/peppy-poppies/build.py b/peppy-poppies/build.py
new file mode 100644
index 00000000..027de56a
--- /dev/null
+++ b/peppy-poppies/build.py
@@ -0,0 +1,35 @@
+from os import getenv
+from pathlib import Path
+from shutil import copytree, rmtree
+
+try:
+ from dotenv import load_dotenv
+
+ load_dotenv(override=True)
+except ImportError:
+ pass
+
+rmtree(Path("./dist"), ignore_errors=True) # remove the folder whether exist or not
+copytree(Path("./frontend"), "./dist/frontend")
+
+index_html_path: Path = Path("./dist") / "frontend" / "demo" / "index.html"
+
+if index_html_path.exists():
+ with index_html_path.open("r", encoding="utf-8") as fp:
+ content = fp.read()
+
+ content = content.replace("[domain]", getenv("CODECAPTCHA_DOMAIN", "http://127.0.0.1:8001"))
+
+ with index_html_path.open("w", encoding="utf-8") as fp:
+ fp.write(content)
+
+app_js_path: Path = Path("./dist") / "frontend" / "demo" / "app.js"
+
+if app_js_path.exists():
+ with app_js_path.open("r", encoding="utf-8") as fp:
+ content = fp.read()
+
+ content = content.replace("[domain]", getenv("CODECAPTCHA_DOMAIN", "http://127.0.0.1:8001"))
+
+ with app_js_path.open("w", encoding="utf-8") as fp:
+ fp.write(content)
diff --git a/peppy-poppies/captcha.Dockerfile b/peppy-poppies/captcha.Dockerfile
new file mode 100644
index 00000000..358ea5f4
--- /dev/null
+++ b/peppy-poppies/captcha.Dockerfile
@@ -0,0 +1,32 @@
+FROM python:3.12-slim-bookworm
+
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
+
+RUN apt-get update && apt-get -y install curl
+
+# Sync the project into a new environment, asserting the lockfile is up to date
+WORKDIR /app
+ENV UV_LINK_MODE=copy
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv sync --locked --only-group backend --only-group math --compile-bytecode --no-install-project
+
+ADD pyproject.toml /app
+ADD uv.lock /app
+ADD server/captcha /app/server/captcha
+ADD frontend/captcha /app/frontend/captcha
+ADD crypto /app/crypto
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv sync --locked --only-group backend --only-group math --compile-bytecode
+
+ADD build.py /app
+ARG CODECAPTCHA_DOMAIN
+ENV CODECAPTCHA_DOMAIN=$CODECAPTCHA_DOMAIN
+RUN ["uv", "run", "--no-sync", "python3", "build.py"]
+
+VOLUME ["/app/captcha_data"]
+
+CMD ["uv", "run", "--no-sync", "litestar", "--app", "server.captcha.main:app", "run", "--port", "8001", "--host", "0.0.0.0"]
diff --git a/peppy-poppies/captcha_data/.gitkeep b/peppy-poppies/captcha_data/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/peppy-poppies/captcha_data/JetBrainsMono-Regular.ttf b/peppy-poppies/captcha_data/JetBrainsMono-Regular.ttf
new file mode 100644
index 00000000..436c982f
Binary files /dev/null and b/peppy-poppies/captcha_data/JetBrainsMono-Regular.ttf differ
diff --git a/peppy-poppies/captcha_data/OFL.txt b/peppy-poppies/captcha_data/OFL.txt
new file mode 100644
index 00000000..0c103197
--- /dev/null
+++ b/peppy-poppies/captcha_data/OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://openfontlicense.org
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/peppy-poppies/captcha_data/question_set.json b/peppy-poppies/captcha_data/question_set.json
new file mode 100644
index 00000000..4401a396
--- /dev/null
+++ b/peppy-poppies/captcha_data/question_set.json
@@ -0,0 +1,644 @@
+{
+ "construct": [
+ "{init} {dyn:base}",
+ "{init} {dyn:base} {cont} {dyn:part}",
+ "{init} {dyn:base} {cont} {dyn:part} {cont} {dyn:part}"
+ ],
+ "base": [
+ {
+ "question": "add {y} squared to x",
+ "validator": "validator=lambda x: x + {y}**2",
+ "range": { "y": [1, 50] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "subtract {y} cubed from x",
+ "validator": "validator=lambda x: x - {y}**3",
+ "range": { "y": [1, 20] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "multiply x by {y} and then add {z}",
+ "validator": "validator=lambda x: x*{y}+{z}",
+ "range": { "y": [2, 200], "z": [-65536, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "integer divide x by {y} and then subtract {z}",
+ "validator": "validator=lambda x: x//{y}-{z}",
+ "range": { "y": [2, 200], "z": [-65536, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x squared plus {y} squared",
+ "validator": "validator=lambda x: x**2 + {y}**2",
+ "range": { "y": [1, 32768] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x cubed minus {y} squared",
+ "validator": "validator=lambda x: x**3 - {y}**2",
+ "range": { "y": [1, 32768] },
+ "input": [1, 4096]
+ },
+ {
+ "question": "bitwise XOR x with {y} then add {z}",
+ "validator": "validator=lambda x: (x ^ {y}) + {z}",
+ "range": { "y": [0, 65535], "z": [-100, 100] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "bitwise AND x with {y} then multiply by {z}",
+ "validator": "validator=lambda x: (x & {y}) * {z}",
+ "range": { "y": [0, 65535], "z": [1, 20] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "bitwise OR x with {y} then subtract {z}",
+ "validator": "validator=lambda x: (x | {y}) - {z}",
+ "range": { "y": [0, 65535], "z": [-50, 50] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "the sum of x and {y} cubed",
+ "validator": "validator=lambda x: x + {y}**3",
+ "range": { "y": [1, 1024] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x subtract by {y} cubed",
+ "validator": "validator=lambda x: x - {y}**3",
+ "range": { "y": [1, 1024] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x subtract from {y} cubed",
+ "validator": "validator=lambda x: {y}**3 - x",
+ "range": { "y": [1, 10] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "the product of x and {y} squared",
+ "validator": "validator=lambda x: x * {y}**2",
+ "range": { "y": [1, 256] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x modulo {y} plus {z}",
+ "validator": "validator=lambda x: x % {y} + {z}",
+ "range": { "y": [1, 100], "z": [-50, 50] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x integer divided by {y} plus {z} squared",
+ "validator": "validator=lambda x: x//{y} + ({z})**2",
+ "range": { "y": [1, 20], "z": [-10, 10] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x plus the factorial of {y}",
+ "validator": "from math import factorial; validator=lambda x: x + factorial({y})",
+ "range": { "y": [0, 6] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x minus the factorial of {y}",
+ "validator": "from math import factorial; validator=lambda x: x - factorial({y})",
+ "range": { "y": [0, 6] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x plus the {y}-th prime number",
+ "validator": "from sympy import prime; validator=lambda x: x + prime({y})",
+ "range": { "y": [1, 4000] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x minus the {y}-th prime number",
+ "validator": "from sympy import prime; validator=lambda x: x - prime({y})",
+ "range": { "y": [1, 4000] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x plus the sum of first {y} natural numbers",
+ "validator": "validator=lambda x: x + {y}*({y}+1)//2",
+ "range": { "y": [1, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x minus the sum of first {y} natural numbers",
+ "validator": "validator=lambda x: x - {y}*({y}+1)//2",
+ "range": { "y": [1, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "{y} plus the sum of first x natural numbers",
+ "validator": "validator=lambda x: x + {y}*({y}+1)//2",
+ "range": { "y": [1, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "{y} minus the sum of first x natural numbers",
+ "validator": "validator=lambda x: {y} - x*(x+1)//2",
+ "range": { "y": [1, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "add {y} to x and then multiply by {z}",
+ "validator": "validator=lambda x: (x + {y}) * {z}",
+ "range": { "y": [1, 100], "z": [1, 20] },
+ "input": [1, 1000]
+ },
+ {
+ "question": "subtract {y} from x and then integer divide by {z}",
+ "validator": "validator=lambda x: (x - {y}) // {z}",
+ "range": { "y": [1, 100], "z": [1, 10] },
+ "input": [1, 1000]
+ },
+ {
+ "question": "bitwise AND x with {y}",
+ "validator": "validator=lambda x: x & {y}",
+ "range": { "y": [0, 65535] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "bitwise OR x with {y}",
+ "validator": "validator=lambda x: x | {y}",
+ "range": { "y": [0, 65535] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "bitwise XOR x with {y}",
+ "validator": "validator=lambda x: x ^ {y}",
+ "range": { "y": [0, 65535] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "multiply x by 2 and add {y}",
+ "validator": "validator=lambda x: x*2 + {y}",
+ "range": { "y": [-50, 50] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "integer divide x by 2 and subtract {y}",
+ "validator": "validator=lambda x: x//2 - {y}",
+ "range": { "y": [-65536, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x modulo {y}",
+ "validator": "validator=lambda x: x % {y}",
+ "range": { "y": [1, 100] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "{y} minus x",
+ "validator": "validator=lambda x: {y} - x",
+ "range": { "y": [1, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "square x and add {y}",
+ "validator": "validator=lambda x: x**2 + {y}",
+ "range": { "y": [-65536, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x cubed and subtract {y}",
+ "validator": "validator=lambda x: x**3 - {y}",
+ "range": { "y": [-65536, 65536] },
+ "input": [1, 1024]
+ },
+ {
+ "question": "shift x left by {y} bits",
+ "validator": "validator=lambda x: x << {y}",
+ "range": { "y": [0, 50] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "shift x right by {y} bits",
+ "validator": "validator=lambda x: x >> {y}",
+ "range": { "y": [0, 50] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "the sum of x and {y} squared",
+ "validator": "validator=lambda x: x + {y}**2",
+ "range": { "y": [1, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "the absolute difference between x and {y} squared",
+ "validator": "validator=lambda x: abs(x - ({y})**2)",
+ "range": { "y": [-65536, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "the product of x and {y} plus {z}",
+ "validator": "validator=lambda x: x*{y} + {z}",
+ "range": { "y": [1, 65536], "z": [-65536, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "the integer division of x by {y} minus {z}",
+ "validator": "validator=lambda x: x//{y} - {z}",
+ "range": { "y": [1, 200], "z": [-65536, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "the absolute difference between x and {y}",
+ "validator": "validator=lambda x: abs(x - {y})",
+ "range": { "y": [0, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "the next multiple of {y} greater than or equal to x",
+ "validator": "import math; validator=lambda x: math.ceil(x/{y})*{y}",
+ "range": { "y": [1, 200] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "the largest multiple of {y} less than or equal to x",
+ "validator": "import math; validator=lambda x: math.floor(x/{y})*{y}",
+ "range": { "y": [1, 200] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "{y}+x",
+ "validator": "validator=lambda x: {y}+x",
+ "range": { "y": [-65536, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x-th prime number",
+ "validator": "from sympy import prime; validator=prime",
+ "range": {},
+ "input": [1, 4000]
+ },
+ {
+ "question": "[x+{y}]-th prime number",
+ "validator": "from sympy import prime; validator=lambda x:prime(x+{y})",
+ "range": { "y": [1, 2000] },
+ "input": [1, 2000]
+ },
+ {
+ "question": "the x-th fibonacci number with starting point at (1, 1) (for clarity, fib(1)=1, fib(2)=1)",
+ "validator": "from sympy import fibonacci; validator=fibonacci",
+ "range": {},
+ "input": [3, 50]
+ },
+ {
+ "question": "the x-th fibonacci number with starting point at ({y}, {z}) (for clarity, fib(1)={y}, fib(2)={z})",
+ "validator": "from sympy import fibonacci; validator=lambda x:{y}*fibonacci(x-2)+{z}*fibonacci(x-1) # Since __base__>=3, no handling needed for 0,1,2",
+ "range": { "y": [0, 9], "z": [1, 9] },
+ "input": [3, 50]
+ },
+ {
+ "question": "{y}-x",
+ "validator": "validator=lambda x:{y}-x",
+ "range": { "y": [-65536, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "x-{y}",
+ "validator": "validator=lambda x:x-{y}",
+ "range": { "y": [-65536, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "{y}*x",
+ "validator": "validator=lambda x:{y}*x",
+ "range": { "y": [-65536, 65536] },
+ "input": [1, 65536]
+ },
+ {
+ "question": "the x-th natural number(exclude 0), k that have a remainder of {y} when divide by {z}",
+ "validator": "validator=lambda x:(x-1)*{z}+{y}",
+ "range": { "y": [1, 20], "z": [21, 200] },
+ "input": [1, 65536]
+ }
+ ],
+ "part": [
+ {
+ "question": "add {y}",
+ "validator": "validator=lambda x:x+{y}",
+ "range": {
+ "y": [-65536, 65536]
+ }
+ },
+ {
+ "question": "subtract {y}",
+ "validator": "validator=lambda x:x-{y}",
+ "range": {
+ "y": [-65536, 65536]
+ }
+ },
+ {
+ "question": "subtract from {y}",
+ "validator": "validator=lambda x:{y}-x",
+ "range": {
+ "y": [-65536, 65536]
+ }
+ },
+ {
+ "question": "multiply by {y}",
+ "validator": "validator=lambda x:x*{y}",
+ "range": {
+ "y": [1, 65536]
+ }
+ },
+ {
+ "question": "multiply by {y}",
+ "validator": "validator=lambda x:x*{y}",
+ "range": {
+ "y": [-65536, -1]
+ }
+ },
+ {
+ "question": "integer divide {y}",
+ "validator": "validator=lambda x:x//{y}",
+ "range": {
+ "y": [1, 65536]
+ }
+ },
+ {
+ "question": "integer divide by {y}",
+ "validator": "validator=lambda x:x//{y}",
+ "range": {
+ "y": [-65536, -1]
+ }
+ },
+ {
+ "question": "bitwise XOR by {y}",
+ "validator": "validator=lambda x:x^{y}",
+ "range": {
+ "y": [-65536, 65536]
+ }
+ },
+ {
+ "question": "bitwise invert",
+ "validator": "validator=lambda x:~x",
+ "range": {}
+ },
+ {
+ "question": "bitwise AND by {y}",
+ "validator": "validator=lambda x:x&{y}",
+ "range": {
+ "y": [-65536, 65536]
+ }
+ },
+ {
+ "question": "bitwise OR by {y}",
+ "validator": "validator=lambda x:x|{y}",
+ "range": {
+ "y": [-65536, 65536]
+ }
+ },
+ {
+ "question": "modulo by {y}",
+ "validator": "validator=lambda x:x%{y}",
+ "range": {
+ "y": [1, 65536]
+ }
+ },
+ {
+ "question": "raise to the power of {y}",
+ "validator": "validator=lambda x:x**{y}",
+ "range": {
+ "y": [1, 5]
+ }
+ },
+ {
+ "question": "bitwise left shift by {y}",
+ "validator": "validator=lambda x:x<<{y}",
+ "range": {
+ "y": [0, 31]
+ }
+ },
+ {
+ "question": "bitwise right shift by {y}",
+ "validator": "validator=lambda x:x>>{y}",
+ "range": {
+ "y": [0, 31]
+ }
+ },
+ {
+ "question": "get the absolute value",
+ "validator": "validator=lambda x:abs(x)",
+ "range": {}
+ },
+ {
+ "question": "get the minimum of the current value and {y}",
+ "validator": "validator=lambda x: min(x, {y})",
+ "range": {
+ "y": [-65536, 65536]
+ }
+ },
+ {
+ "question": "get the maximum of the current value and {y}",
+ "validator": "validator=lambda x: max(x, {y})",
+ "range": {
+ "y": [-65536, 65536]
+ }
+ },
+ {
+ "question": "get the negative of the value",
+ "validator": "validator=lambda x: -x",
+ "range": {}
+ },
+ {
+ "question": "get the integer square root of the absolute value",
+ "validator": "import math; validator=lambda x: math.isqrt(abs(x))",
+ "range": {}
+ },
+ {
+ "question": "count the number of set bits (1s) in the absolute value's binary representation",
+ "validator": "validator=lambda x: bin(abs(x)).count('1')",
+ "range": {}
+ },
+ {
+ "question": "get the negative of the result",
+ "validator": "validator=lambda x: -x",
+ "range": {}
+ },
+ {
+ "question": "find the integer part of its square root (of its absolute value)",
+ "validator": "import math; validator=lambda x: math.isqrt(abs(x))",
+ "range": {}
+ },
+ {
+ "question": "find the absolute difference from {y}",
+ "validator": "validator=lambda x: abs(x - {y})",
+ "range": {
+ "y": [-65536, 65536]
+ }
+ },
+ {
+ "question": "raise the result to the power of {y}, then modulo by {z}",
+ "validator": "validator=lambda x: pow(x, {y}, {z})",
+ "range": {
+ "y": [1, 5],
+ "z": [2, 1000]
+ }
+ },
+ {
+ "question": "find the remainder of the result when divided by the {y}",
+ "validator": "validator=lambda x: x % {y}",
+ "range": {
+ "y": [1, 256]
+ }
+ },
+ {
+ "question": "count the number of set bits in its binary representation of the absolute value",
+ "validator": "validator=lambda x: bin(abs(x)).count('1')",
+ "range": {}
+ },
+ {
+ "question": "find the greatest common divisor with {y}",
+ "validator": "import math; validator=lambda x: math.gcd(x, {y})",
+ "range": {
+ "y": [1, 65536]
+ }
+ },
+ {
+ "question": "square the value",
+ "validator": "validator=lambda x: x*x",
+ "range": {}
+ },
+ {
+ "question": "cube the value",
+ "validator": "validator=lambda x: x**3",
+ "range": {}
+ },
+ {
+ "question": "find the number of decimal digits in the absolute value",
+ "validator": "validator=lambda x: len(str(abs(x)))",
+ "range": {}
+ },
+ {
+ "question": "sum the decimal digits of the absolute value",
+ "validator": "validator=lambda x: sum(int(d) for d in str(abs(x)))",
+ "range": {}
+ },
+ {
+ "question": "find the smallest multiple of {y} that is greater than or equal to the absolute value of the current value",
+ "validator": "import math; validator=lambda x: x//{y} * {y} + (0 if x%{y}==0 else {y})",
+ "range": {
+ "y": [1, 1000]
+ }
+ },
+ {
+ "question": "find the largest multiple of {y} that is less than or equal to the absolute value of the current value",
+ "validator": "import math; validator=lambda x: x//{y} * {y}",
+ "range": {
+ "y": [1, 1000]
+ }
+ },
+ {
+ "question": "compute the natural logarithm of the (absolute value of the result + 1) and rounded down to integer",
+ "validator": "import math; validator=lambda x: int(math.log(abs(x)+1)) if x>0 else 0",
+ "range": {}
+ },
+ {
+ "question": "compute the base-10 logarithm of the absolute valud of the result or 0 if invalid (rounded down to integer)",
+ "validator": "import math; validator=lambda x: len(str(abs(x)))-1",
+ "range": {}
+ },
+ {
+ "question": "compute the absolute value of the negative of the result",
+ "validator": "validator=lambda x: abs(-x)",
+ "range": {}
+ },
+ {
+ "question": "round the absolute value of the result up to nearest multiple of {y}",
+ "validator": "import math; validator=lambda x: x//{y} * {y} + (0 if x%{y}==0 else {y})",
+ "range": {
+ "y": [1, 50]
+ }
+ },
+ {
+ "question": "round the absolute value of the result down to nearest multiple of {y}",
+ "validator": "import math; validator=lambda x: x//{y} * {y}",
+ "range": {
+ "y": [1, 50]
+ }
+ },
+ {
+ "question": "compute the number of divisors of the result mod {y}",
+ "validator": "from sympy import divisors; validator=lambda x: len(divisors(abs(x % {y})))",
+ "range": {
+ "y": [2048, 65536]
+ }
+ },
+ {
+ "question": "compute the sum of all divisors of the result mod {y}",
+ "validator": "from sympy import divisors; validator=lambda x: sum(divisors(abs(x % {y})))",
+ "range": {
+ "y": [2048, 65536]
+ }
+ },
+ {
+ "question": "return the largest prime less than or equal to the ((result mod {y}) + 4)",
+ "validator": "from sympy import prevprime; validator=lambda x: prevprime((x % {y})+4)",
+ "range": {
+ "y": [1000, 4000]
+ }
+ },
+ {
+ "question": "compute the factorial of the last digit of the result",
+ "validator": "from math import factorial; validator=lambda x: factorial(abs(x)%10)",
+ "range": {}
+ },
+ {
+ "question": "compute the factorial of the result mod {y}",
+ "validator": "from math import factorial; validator=lambda x: factorial(abs(x)%{y})",
+ "range": {
+ "y": [10, 200]
+ }
+ },
+ {
+ "question": "compute the sum of squares of the digits of the result",
+ "validator": "validator=lambda x: sum(int(d)**2 for d in str(abs(x)))",
+ "range": {}
+ },
+ {
+ "question": "compute the product of the digits of the result, ignoring 0",
+ "validator": "import math; validator=lambda x: math.prod([int(d) for d in str(abs(x)) if int(d)])",
+ "range": {}
+ }
+ ],
+ "init": [
+ "Write a function `calc(x: int) -> int` to calculate",
+ "Write a function `calc(x: int) -> int` to get",
+ "Write a function `calc(x: int)` to calculate",
+ "Write a function `calc(x: int)` to get",
+ "Write `calc(x: int) -> int` such that it calculate",
+ "Write `calc(x: int) -> int` such that it get",
+ "Write `calc(x: int)` such that it calculate",
+ "Write `calc(x: int)` such that it get",
+ "Create a function `calc(x: int) -> int` that returns",
+ "Create a function `calc(x: int)` that returns",
+ "Implement a Python function `calc(x: int) -> int` which returns",
+ "Implement a Python function `calc(x: int)` which returns",
+ "Define a function `calc(x: int) -> int` to find",
+ "Define a function `calc(x: int)` to find",
+ "Your task is to write a function `calc(x: int) -> int` that computes",
+ "Your task is to write a function `calc(x: int)` that computes",
+ "Your task is to write a function `calc(x: int) -> int` that calculates",
+ "Your task is to write a function `calc(x: int)` that calculates",
+ "Your task is to write a function `calc(x: int) -> int` that finds",
+ "Your task is to write a function `calc(x: int)` that finds"
+ ],
+ "cont": [
+ ", then",
+ ", and then",
+ ", followed by",
+ ", following with",
+ ", and afterwards,",
+ "with the result then being used to",
+ ", with the result then",
+ ", and that result is then used to",
+ ", from which you should",
+ ", which is then followed by"
+ ]
+}
diff --git a/peppy-poppies/cmd.md b/peppy-poppies/cmd.md
new file mode 100644
index 00000000..a6f90e6a
--- /dev/null
+++ b/peppy-poppies/cmd.md
@@ -0,0 +1,26 @@
+### Common command
+
+Create `.venv` with dependency
+```sh
+uv sync --locked --all-extras
+```
+
+Update `uv.lock` with new dependencies
+```sh
+uv sync
+```
+
+Check linting
+```sh
+ruff check
+```
+
+Format code
+```sh
+ruff format
+```
+
+Export current `uv.lock` to `pylock.toml`
+```sh
+uv export --locked -o pylock.toml
+```
diff --git a/peppy-poppies/crypto/__init__.py b/peppy-poppies/crypto/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/peppy-poppies/crypto/_test.py b/peppy-poppies/crypto/_test.py
new file mode 100644
index 00000000..4607e721
--- /dev/null
+++ b/peppy-poppies/crypto/_test.py
@@ -0,0 +1,10 @@
+from pathlib import Path
+
+from key import export_key, generate_key_pair, import_private_key, import_public_key
+
+if __name__ == "__main__":
+ pri, pub = generate_key_pair()
+ export_key(pri, Path() / "private.pem")
+ pri_new = import_private_key(Path() / "private.pem")
+ export_key(pub, Path() / "public.pem")
+ pub_new = import_public_key(Path() / "public.pem")
diff --git a/peppy-poppies/crypto/jwt_generate.py b/peppy-poppies/crypto/jwt_generate.py
new file mode 100644
index 00000000..9d16b332
--- /dev/null
+++ b/peppy-poppies/crypto/jwt_generate.py
@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+from typing import TYPE_CHECKING
+
+import jwt
+
+from crypto.key import get_pem
+
+if TYPE_CHECKING:
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
+
+type JSON = dict[str, JSON | list[JSON] | str | float | int | bool | None]
+
+
+class JWTGenerator:
+ """Generates JSON Web Tokens (JWTs) signed with an Ed25519 private key."""
+
+ def __init__(self, issuer: str, private_key: Ed25519PrivateKey) -> None:
+ self._issuer: str = issuer
+ self._priv: bytes = get_pem(private_key)
+
+ def generate(
+ self,
+ *,
+ website: str,
+ challenge_id: str,
+ valid_duration: float = 600,
+ **kwargs: JSON,
+ ) -> str:
+ """Generate JWT token based on website and challenge_id and any addition attributes.
+
+ Returns:
+ str: The generated JWT token as a string.
+
+ """
+ data = {
+ **kwargs,
+ "challenge_id": challenge_id,
+ }
+ current = datetime.now(UTC)
+ data["nbf"] = current.timestamp() # Not before timestamp
+ data["exp"] = (current + timedelta(seconds=valid_duration)).timestamp() # Expiration timestamp
+ data["aud"] = website # Audience (the website domain)
+ data["iss"] = self._issuer # The issue (the CAPTCHA server domain)
+ data["iat"] = current.timestamp() # Issue timestamp
+
+ return jwt.encode(data, self._priv, algorithm="EdDSA")
+
+
+class JWTValidator:
+ """Validate the JSON Web TOken (JWT) with the public key."""
+
+ def __init__(self, issuer: str, public_key: Ed25519PublicKey) -> None:
+ self._issuer: str = issuer
+ self._pub: Ed25519PublicKey = public_key
+
+ def validate(self, website: str | list[str], jwt_token: str, *, leeway: float = 5) -> JSON:
+ """Validate whether the JWT is valid and return the payload.
+
+ Returns:
+ JSON: The decoded JWT payload if the token is valid.
+
+ """
+ return jwt.decode(
+ jwt_token,
+ key=self._pub,
+ algorithms=["EdDSA"],
+ verify=True,
+ audience=website,
+ issuer=self._issuer,
+ leeway=leeway,
+ options={"require": ["exp", "iss", "iat", "challenge_id", "aud"]},
+ )
diff --git a/peppy-poppies/crypto/key.py b/peppy-poppies/crypto/key.py
new file mode 100644
index 00000000..034c3020
--- /dev/null
+++ b/peppy-poppies/crypto/key.py
@@ -0,0 +1,101 @@
+from pathlib import Path
+
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
+from cryptography.hazmat.primitives.serialization import (
+ Encoding,
+ NoEncryption,
+ PrivateFormat,
+ PublicFormat,
+ load_pem_private_key,
+ load_pem_public_key,
+)
+
+type Ed25519Key = Ed25519PublicKey | Ed25519PrivateKey
+
+
+def generate_key_pair() -> tuple[Ed25519PrivateKey, Ed25519PublicKey]:
+ """Generate a pair of Ed25519 private and public keys.
+
+ Returns:
+ tuple[Ed25519PrivateKey, Ed25519PublicKey]: The generated private and public key pair.
+
+ """
+ private_key = Ed25519PrivateKey.generate()
+ public_key = private_key.public_key()
+ return private_key, public_key
+
+
+def import_public_key(pub_path: Path) -> Ed25519PublicKey:
+ """Import PEM formatted Ed25519 public key.
+
+ Returns:
+ Ed25519PublicKey: The imported Ed25519 public key.
+
+ Raises:
+ TypeError: If the key is not an Ed25519 public key.
+
+ """
+ with pub_path.open("rb") as fp:
+ public_key = load_pem_public_key(fp.read())
+ if not isinstance(public_key, Ed25519PublicKey):
+ raise TypeError(f"The public key from {pub_path.as_posix()} is not Ed25519 public key")
+ return public_key
+
+
+def import_private_key(pri_path: Path) -> Ed25519PrivateKey:
+ """Import PEM formatted Ed25519 private key.
+
+ Returns:
+ Ed25519PrivateKey: The imported Ed25519 private key.
+
+ Raises:
+ TypeError: If the key is not an Ed25519 private key.
+
+ """
+ with pri_path.open("rb") as fp:
+ private_key = load_pem_private_key(fp.read(), None)
+ if not isinstance(private_key, Ed25519PrivateKey):
+ raise TypeError(f"The private key from {pri_path.as_posix()} is not Ed25519 private key")
+ return private_key
+
+
+def import_all(path: Path) -> tuple[Ed25519PrivateKey, Ed25519PublicKey]:
+ """Import a pair of Ed25519 private and public keys from `path/public.pem` and `path/private.pem`.
+
+ Returns:
+ tuple[Ed25519PrivateKey, Ed25519PublicKey]: The imported private and public key pair.
+
+ """
+ return (
+ import_private_key(path / "private.pem"),
+ import_public_key(path / "public.pem"),
+ )
+
+
+def export_key(key: Ed25519Key, path: Path) -> None:
+ """Export Ed25519 public/private key as PEM format to the given path."""
+ with path.open("wb") as fp:
+ fp.write(get_pem(key))
+
+
+def export_all(path: Path, *, pub_key: Ed25519PublicKey, pri_key: Ed25519PrivateKey) -> None:
+ """Export both public and private Ed25519 key in path/public.pem and path/private.pem respectively."""
+ export_key(pub_key, path / "public.pem")
+ export_key(pri_key, path / "private.pem")
+
+
+def get_pem(key: Ed25519Key) -> bytes:
+ """Get PEM formatted Ed25519 public/private key as bytes.
+
+ Returns:
+ bytes: PEM formatted key as bytes.
+
+ Raises:
+ ValueError: If the key is not an Ed25519PublicKey or Ed25519PrivateKey.
+
+ """
+ if isinstance(key, Ed25519PublicKey):
+ return key.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo)
+ if isinstance(key, Ed25519PrivateKey):
+ return key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
+ raise ValueError("The key must be either Ed25519PublicKey or Ed25519PrivateKey")
diff --git a/peppy-poppies/demo.Dockerfile b/peppy-poppies/demo.Dockerfile
new file mode 100644
index 00000000..b991e4fc
--- /dev/null
+++ b/peppy-poppies/demo.Dockerfile
@@ -0,0 +1,32 @@
+FROM python:3.12-slim-bookworm
+
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
+
+
+# Sync the project into a new environment, asserting the lockfile is up to date
+WORKDIR /app
+ENV UV_LINK_MODE=copy
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ --mount=type=bind,source=uv.lock,target=uv.lock \
+ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
+ uv sync --locked --only-group backend --compile-bytecode --no-install-project
+
+ADD pyproject.toml /app
+ADD uv.lock /app
+ADD server/backend /app/server/backend
+ADD frontend/demo /app/frontend/demo
+ADD crypto /app/crypto
+
+RUN --mount=type=cache,target=/root/.cache/uv \
+ uv sync --locked --only-group backend --compile-bytecode
+
+ADD build.py /app
+
+ARG CODECAPTCHA_DOMAIN
+ENV CODECAPTCHA_DOMAIN=$CODECAPTCHA_DOMAIN
+RUN ["uv", "run", "--no-sync", "python3", "build.py"]
+
+VOLUME ["/app/demo_data"]
+
+CMD ["uv", "run", "--no-sync", "litestar", "--app", "server.backend.main:app", "run", "--port", "8000", "--host", "0.0.0.0"]
diff --git a/peppy-poppies/demo_data/.gitkeep b/peppy-poppies/demo_data/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/peppy-poppies/docker-compose.yml b/peppy-poppies/docker-compose.yml
new file mode 100644
index 00000000..544ec9e9
--- /dev/null
+++ b/peppy-poppies/docker-compose.yml
@@ -0,0 +1,40 @@
+services:
+ captcha:
+ build:
+ context: .
+ dockerfile: captcha.Dockerfile
+ args:
+ CODECAPTCHA_DOMAIN: ${CODECAPTCHA_DOMAIN}
+ volumes:
+ - "./captcha_data:/app/captcha_data"
+ ports:
+ - "9201:8001"
+ env_file: ".env"
+ restart: unless-stopped
+ healthcheck:
+ test:
+ [
+ "CMD",
+ "curl",
+ "-f",
+ "http://localhost:8001/api/challenge/get-public-key",
+ ]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+ start_period: 15s
+ demo:
+ build:
+ context: .
+ dockerfile: demo.Dockerfile
+ args:
+ CODECAPTCHA_DOMAIN: ${CODECAPTCHA_DOMAIN}
+ volumes:
+ - "./demo_data:/app/demo_data"
+ ports:
+ - "9200:8000"
+ env_file: ".env"
+ restart: unless-stopped
+ depends_on:
+ captcha:
+ condition: service_healthy
diff --git a/peppy-poppies/docs-assets/16768e08d0bf01f942995f0077ef5e6718aba3298c7b609dd98cf3825fa0db0a.png b/peppy-poppies/docs-assets/16768e08d0bf01f942995f0077ef5e6718aba3298c7b609dd98cf3825fa0db0a.png
new file mode 100644
index 00000000..d96b146f
Binary files /dev/null and b/peppy-poppies/docs-assets/16768e08d0bf01f942995f0077ef5e6718aba3298c7b609dd98cf3825fa0db0a.png differ
diff --git a/peppy-poppies/docs-assets/LICENSE.md b/peppy-poppies/docs-assets/LICENSE.md
new file mode 100644
index 00000000..7a2e4418
--- /dev/null
+++ b/peppy-poppies/docs-assets/LICENSE.md
@@ -0,0 +1,9 @@
+The following file is created using the question generator, with minor modification from `captcha_data/question_set.json`, and the image is created with [Pillow](https://pillow.readthedocs.io/) using the code specified in `server/captcha/controller/challenge.py` by [@i-am-unknown-81514525](https://github.com/i-am-unknown-81514525). The file is licensed under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0) or public domain, whichever is more permissive.
+
+- `docs-assets/de19ebabd51028f36f7ee03dbe4365b54726181300998b3bf1fd3954a17966e1.png`
+- `docs-assets/16768e08d0bf01f942995f0077ef5e6718aba3298c7b609dd98cf3825fa0db0a.png`
+
+The following file is recorded by [@DragonSenseiGuy](https://github.com/DragonSenseiGuy) and licensed under [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
+
+- `docs-assets/Presentation.np4` (removed in replacement for `Presentation.gif`)
+- `docs-assets/Presentation.gif`
diff --git a/peppy-poppies/docs-assets/Presentation.gif b/peppy-poppies/docs-assets/Presentation.gif
new file mode 100644
index 00000000..525f71b2
Binary files /dev/null and b/peppy-poppies/docs-assets/Presentation.gif differ
diff --git a/peppy-poppies/docs-assets/Presentation.mp4 b/peppy-poppies/docs-assets/Presentation.mp4
new file mode 100644
index 00000000..54f6a5d7
Binary files /dev/null and b/peppy-poppies/docs-assets/Presentation.mp4 differ
diff --git a/peppy-poppies/docs-assets/de19ebabd51028f36f7ee03dbe4365b54726181300998b3bf1fd3954a17966e1.png b/peppy-poppies/docs-assets/de19ebabd51028f36f7ee03dbe4365b54726181300998b3bf1fd3954a17966e1.png
new file mode 100644
index 00000000..8e53fddc
Binary files /dev/null and b/peppy-poppies/docs-assets/de19ebabd51028f36f7ee03dbe4365b54726181300998b3bf1fd3954a17966e1.png differ
diff --git a/peppy-poppies/frontend/__init__.py b/peppy-poppies/frontend/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/peppy-poppies/frontend/captcha/__init__.py b/peppy-poppies/frontend/captcha/__init__.py
new file mode 100644
index 00000000..3b101af0
--- /dev/null
+++ b/peppy-poppies/frontend/captcha/__init__.py
@@ -0,0 +1,3 @@
+"""Static file to be served to the browser. DO NOT IMPORT."""
+
+raise ImportError("This folder is intend for being serve as static file. Do not import file from here")
diff --git a/peppy-poppies/frontend/captcha/captcha.html b/peppy-poppies/frontend/captcha/captcha.html
new file mode 100644
index 00000000..3ea7a6e6
--- /dev/null
+++ b/peppy-poppies/frontend/captcha/captcha.html
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/peppy-poppies/frontend/captcha/captcha.py b/peppy-poppies/frontend/captcha/captcha.py
new file mode 100644
index 00000000..2fff325c
--- /dev/null
+++ b/peppy-poppies/frontend/captcha/captcha.py
@@ -0,0 +1,288 @@
+import json
+import traceback
+import urllib.parse
+from base64 import b64decode
+from typing import TypedDict
+
+import panel as pn
+import param
+from pyodide.ffi import create_proxy
+from pyodide.http import pyfetch
+from pyscript import document, window # type: ignore[reportAttributeAccessIssue]
+
+pn.extension("ace", "codeeditor", sizing_mode="stretch_width")
+
+body = document.body
+worker = window.Worker.new("runner.js", type="module")
+
+
+class GetChallengeResponse(TypedDict):
+ """Response schema for /get_challenge endpoint."""
+
+ question: str
+ tasks: list[int]
+
+
+class SolutionCorrectJWTPayload(TypedDict):
+ """Payload data of the JWT token returned from /solution endpoint."""
+
+ session_id: str
+ challenge_id: str
+ nbf: float
+ exp: float
+ aud: str
+ iss: str
+ iat: float
+
+
+def get_challenge_id() -> str:
+ """Get challenge_id of the challenge.
+
+ Returns:
+ str: The challenge_id extracted from the URL query parameters.
+
+ Raises:
+ ValueError: If the challenge_id is not found or is not a valid string.
+
+ """
+ parsed = urllib.parse.urlparse(window.location.href).query
+ print(parsed)
+ query_dict = urllib.parse.parse_qs(parsed)
+ print(query_dict)
+ challenge_id = query_dict.get("challenge_id")
+ if isinstance(challenge_id, list) and len(challenge_id) > 0:
+ challenge_id = challenge_id[0]
+ if not isinstance(challenge_id, str):
+ raise ValueError("Not a running challenge") # noqa: TRY004
+ return challenge_id
+
+
+async def get_challenge() -> tuple[bytes, list[int]]:
+ """Endpoint to collect challenge data.
+
+ Returns:
+ tuple[str, list[int]]: The question string and the associated task list.
+
+ """
+ challenge_id = get_challenge_id()
+ request = await pyfetch(f"/api/challenge/get-challenge/{challenge_id}?width={window.innerWidth - 40}")
+ if not request.ok:
+ error_image = b64decode(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII==",
+ )
+ return (error_image, [1])
+ response: GetChallengeResponse = await request.json()
+ return (b64decode(response["question"]), response["tasks"])
+
+
+def _to_int(x: str) -> int:
+ try:
+ return int(x)
+ except ValueError:
+ return int(float(x))
+
+
+async def _worker_on_message(e) -> None: # noqa: ANN001
+ content: str = e.data
+ key, value = content.split(";", maxsplit=1)
+ get_challenge_id()
+ if key == "result":
+ values = []
+ try:
+ values = list(map(_to_int, json.loads(value)))
+ except Exception: # noqa: BLE001 alternative logging method
+ print("Conversion failed: ")
+ error_str.object = traceback.format_exc()
+ progress_bar.bar_color = "danger"
+ submit_button.disabled = False
+ result = await send_result(values)
+ progress_bar.value = progress_bar.max
+ if result:
+ progress_bar.bar_color = "success"
+ else:
+ progress_bar.bar_color = "danger"
+ elif key == "load":
+ progress_bar.value = 1
+ elif key == "start":
+ progress_bar.bar_color = "primary"
+ progress_bar.value = 0
+ error_str.object = ""
+ elif key == "run":
+ progress_bar.value = 1 + int(value)
+ elif key == "error":
+ progress_bar.bar_color = "danger"
+ submit_button.disabled = False
+ error_str.object = value
+
+ elif key == "pyodide-loaded":
+ print("Pyodide loaded")
+ loaded_item.has_loaded = True
+
+
+def submit(code: str, task: list[int]) -> None:
+ """Submit the code to be executed locally with the given task."""
+ get_challenge_id()
+ worker.postMessage(json.dumps({"code": code, "task": task}))
+
+
+async def send_result(results: list[int]) -> bool:
+ """Send the calculated result to CAPTCHA service to obtain the JWT.
+
+ Returns:
+ bool: True if the result was successfully sent and a valid JWT was received, False otherwise.
+
+ """
+ req_data = json.dumps(
+ {
+ "challenge_id": get_challenge_id(),
+ "answers": list(results), # in case this is a JsProxy
+ },
+ )
+ response = await pyfetch(
+ "/api/challenge/submit-challenge",
+ method="POST",
+ body=req_data,
+ )
+ if not response.ok:
+ submit_button.disabled = False
+ return False
+ try:
+ jwt = (await response.json())["token"]
+ except json.JSONDecodeError:
+ submit_button.disabled = False
+ return False
+ splitted = jwt.split(".")
+ if len(splitted) != 3: # noqa: PLR2004
+ submit_button.disabled = False
+ return False
+ payload_str = b64decode(splitted[1] + "=" * (4 - len(splitted[1]) % 4)).decode()
+ payload: SolutionCorrectJWTPayload = json.loads(payload_str)
+ origin = payload["aud"]
+ if not origin.startswith("http://") and not origin.startswith("https://"):
+ window.parent.postMessage(jwt, f"http://{origin}")
+ origin = f"https://{origin}"
+ window.parent.postMessage(jwt, origin)
+ return True
+
+
+worker.onmessage = create_proxy(_worker_on_message)
+
+
+class PyodideHasLoaded(param.Parameterized):
+ """A trigger on whether the pyodide have been loaded."""
+
+ has_loaded = param.Boolean()
+
+ @param.depends("has_loaded")
+ def render(self) -> pn.Spacer | None:
+ """Update visibility of component on pyodide load."""
+ print(self.has_loaded)
+ if self.has_loaded: # type: ignore[reportGeneralTypeIssues]
+ initial_verify.visible = True
+ initial_loading.visible = False
+ return None
+ return pn.Spacer(width=0)
+
+
+loaded_item = PyodideHasLoaded()
+initial_label = pn.pane.Str(
+ "Verify you are human",
+ align=("start", "center"),
+ styles={"text-wrap": "pretty"},
+ min_width=150,
+)
+initial_verify = pn.widgets.Button(
+ name="Verify",
+ button_type="primary",
+ visible=False,
+ align=("end", "center"),
+)
+question = pn.pane.image.PNG(
+ b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="),
+ sizing_mode="stretch_width",
+)
+initial_loading = pn.indicators.LoadingSpinner(
+ size=20,
+ value=True,
+ color="secondary",
+ bgcolor="light",
+ visible=True,
+)
+question_loading = pn.indicators.LoadingSpinner(size=20, value=True, color="secondary", bgcolor="light", visible=False)
+code_editor = pn.widgets.CodeEditor(
+ value="""
+def calc(x: int) -> int:
+ # Your implementation here
+ pass
+""",
+ language="python",
+ theme="monokai",
+ name="Put your solution here:",
+ sizing_mode="stretch_width",
+)
+submit_button = pn.widgets.Button(name="Submit", button_type="primary", visible=False, sizing_mode="stretch_width")
+progress_bar = pn.indicators.Progress(
+ name="Progress",
+ value=0,
+ max=3,
+ bar_color="primary",
+ sizing_mode="stretch_width",
+)
+error_str = pn.pane.Str("", sizing_mode="stretch_width")
+tasks: list[int] = []
+
+
+def _set_initial_visibility(status: bool) -> None: # noqa: FBT001
+ initial_label.visible = status
+ initial_verify.visible = status
+
+
+def _set_after_visibility(status: bool) -> None: # noqa: FBT001
+ question.visible = status
+ progress_bar.visible = status
+ code_editor.visible = status
+ submit_button.visible = status
+ error_str.visible = status
+
+
+async def _click_initial_verify(_) -> None: # noqa: ANN001
+ global tasks
+ _set_initial_visibility(False) # noqa: FBT003
+ question_loading.visible = True
+ _set_after_visibility(True) # noqa: FBT003
+ question_str, tasks = await get_challenge()
+ question.object = question_str
+ question_loading.visible = False
+ _set_after_visibility(True) # noqa: FBT003
+ progress_bar.max = len(tasks) + 2
+
+
+def _click_submit(_) -> None: # noqa: ANN001
+ code_string: str = code_editor.value # type: ignore[reportAssignmentType]
+ print(f"{code_string=} {code_editor.value_input=}")
+ submit_button.disabled = True
+ submit(code_string, tasks)
+
+
+initial_verify.on_click(_click_initial_verify)
+submit_button.on_click(_click_submit)
+
+initial = pn.Row(
+ initial_label,
+ initial_verify,
+ initial_loading,
+ sizing_mode="stretch_width",
+)
+
+
+after = pn.Column(question, code_editor, progress_bar, submit_button, error_str)
+
+_set_after_visibility(False) # noqa: FBT003
+
+pn.Column(
+ initial,
+ question_loading,
+ after,
+ loaded_item.render,
+ sizing_mode="stretch_width",
+).servable(target="captcha")
diff --git a/peppy-poppies/frontend/captcha/captcha_handler.js b/peppy-poppies/frontend/captcha/captcha_handler.js
new file mode 100644
index 00000000..c2f424b3
--- /dev/null
+++ b/peppy-poppies/frontend/captcha/captcha_handler.js
@@ -0,0 +1,17 @@
+// A version of captcha_handler.py that only offer basic functionality which only load the captcha cookies
+window.addEventListener("message", (e) => {
+ if (e.origin !== window.document.currentScript.getAttribute("domain")) {
+ return;
+ }
+ const date = new Date();
+ date.setTime(date.getTime() + 86400);
+ window.document.cookie = `CODECAPTCHA_JWT=${e.data}; expires=${date.toUTCString()}; path=/`;
+ window.document.cookie = `CODECAPTCHA_REQUIRE_AUTH=false; Max-Age=0; path=/`;
+ const redirect = new URLSearchParams(window.location.search).get(
+ "redirect",
+ );
+ if (redirect !== null) {
+ window.location.href = decodeURIComponent(redirect);
+ }
+ postMessage("captchaCompleted");
+});
diff --git a/peppy-poppies/frontend/captcha/captcha_handler.py b/peppy-poppies/frontend/captcha/captcha_handler.py
new file mode 100644
index 00000000..093b253a
--- /dev/null
+++ b/peppy-poppies/frontend/captcha/captcha_handler.py
@@ -0,0 +1,138 @@
+import time
+
+from pyscript import document, window # type: ignore[reportAttributeAccessIssue]
+from pyscript.ffi import create_proxy # type: ignore[reportAttributeAccessIssue]
+
+try:
+ from typing import TYPE_CHECKING, TypedDict
+ from urllib.parse import parse_qs, quote, unquote, urlparse
+
+ class Cookie(TypedDict): # type: ignore[reportRedeclaration]
+ """A Cookie dictionary with just `name` and `value`."""
+
+ name: str
+ value: str
+except ImportError: # micropython
+ TYPE_CHECKING = False
+ if TYPE_CHECKING:
+ from urllib.parse import parse_qs, quote, unquote, urlparse
+
+ class Cookie(TypedDict):
+ """A Cookie dictionary with just `name` and `value`."""
+
+ name: str
+ value: str
+ else:
+
+ class Cookie:
+ """A Cookie dictionary with just `name` and `value`."""
+
+ name: str
+ value: str
+
+ import mip # type: ignore[reportMissingImport]
+
+ mip.install(
+ "https://gist.githubusercontent.com/i-am-unknown-81514525/088e4a1a19246b440d98515d0cbce44d/raw/f82fd0eaeaad55d49fa82aac858309f9b9b81816/parse.py",
+ )
+ from parse import parse_qs, quote, unquote, urlparse
+
+COOKIE_REQ_AUTH = "CODECAPTCHA_REQUIRE_AUTH"
+COOKIE_CHALLENGE_ID = "CODECAPTCHA_CHALLENGE_ID"
+COOKIE_JWT = "CODECAPTCHA_JWT"
+
+
+curr_script = window.document.currentScript
+DOMAIN: str = curr_script.getAttribute(
+ "domain",
+) #
+MIN_MODE: bool = (
+ curr_script.getAttribute(
+ "min_mode",
+ ).lower()
+ == "true"
+)
+
+
+async def on_load() -> None:
+ """Check for `CODECAPTCHA_REQUIRE_AUTH` and `CODECAPTCHA_CHALLENGE_ID` on script load."""
+ all_cookie = await window.cookieStore.getAll()
+ cookie_list: list[Cookie] = []
+ for cookie in all_cookie:
+ name: str = cookie.name
+ value: str = cookie.value
+ cookie_list.append({"name": name, "value": value})
+ _process_cookie(cookie_list)
+
+
+def on_cookie_change(event) -> None: # event: CookieChangeEvent # noqa: ANN001
+ """Check for `CODECAPTCHA_REQUIRE_AUTH` and `CODECAPTCHA_CHALLENGE_ID` on cookie changes (such as API requests)."""
+ if window.location.href == "/challenge":
+ return
+ cookie_list: list[Cookie] = []
+ for cookie in event.changed: # type: ignore[reportAttributeAccessIssue]
+ name: str = cookie.name
+ value: str = cookie.value
+ cookie_list.append({"name": name, "value": value})
+ _process_cookie(cookie_list)
+
+
+def _process_cookie(cookies: list[Cookie]) -> None:
+ req_auth = False
+ challenge_id = ""
+ for cookie in cookies:
+ name = cookie["name"]
+ value = cookie["value"]
+ if name == COOKIE_REQ_AUTH and value.lower().strip() == "true":
+ req_auth = True
+ if name == COOKIE_CHALLENGE_ID:
+ challenge_id = value.strip()
+ if req_auth and challenge_id:
+ loc = window.location
+ redirect = f"{loc.pathname}{loc.search}{loc.hash}"
+ encoded_redirect = quote(redirect)
+ url = f"/challenge?redirect={encoded_redirect}&=challenge_id={challenge_id}"
+ window.location.href = url
+
+
+def handle_message(message) -> None: # noqa: ANN001
+ """Handle JWT token from inner frame."""
+ print(f"[captcha_handler.py] {message.origin}: {message.data}")
+ if message.origin != DOMAIN: # type: ignore[reportAttributeAccessIssue]
+ return
+ content: str = message.data # type: ignore[reportAttributeAccessIssue]
+ expire_date = time.time() + 86400
+ expire_str = time.strftime("%a, %d %b %Y %H:%H:%S GMT", time.gmtime(expire_date))
+ document.cookie = f"{COOKIE_JWT}={content}; expires={expire_str}; path=/"
+ # delete the cookie or at least set it to false since auth successed
+ document.cookie = f"{COOKIE_REQ_AUTH}=false;Max-Age=0; path=/"
+ parsed = urlparse(window.location.href).query
+ query_dict = parse_qs(parsed)
+ redirect = query_dict.get("redirect", None)
+ if isinstance(redirect, list):
+ redirect = redirect[0] if len(redirect) > 0 else None
+ if redirect is not None:
+ window.location.href = unquote(redirect)
+ print("[captcha_handler.py]: captchaCompleted")
+ window.postMessage("captchaCompleted")
+
+
+window.handle_message = create_proxy(handle_message)
+if window.location.href == "/challenge":
+ window.addEventListener("message", create_proxy(handle_message))
+if not MIN_MODE:
+ if window.location.href == "/challenge":
+ body = document.body
+ parsed = urlparse(window.location.href).query
+ query_dict = parse_qs(parsed)
+ challenge_id = query_dict.get("challenge_id")
+ iframe = document.createElement("iframe")
+ iframe.src = f"{DOMAIN}/static/captcha.html?challenge_id{challenge_id}"
+ iframe.id = "code_captcha_iframe"
+ iframe.style.height = "100vw"
+ iframe.style.height = "100vh"
+ iframe.setAttribute("frameborder", "0")
+ body.appendChild(iframe)
+ else:
+ await on_load() # type: ignore # noqa: F704, PLE1142, PGH003
+ window.cookieStore.addEventListener("change", create_proxy(on_cookie_change))
diff --git a/peppy-poppies/frontend/captcha/parse.py b/peppy-poppies/frontend/captcha/parse.py
new file mode 100644
index 00000000..be1d126e
--- /dev/null
+++ b/peppy-poppies/frontend/captcha/parse.py
@@ -0,0 +1,3614 @@
+# type: ignore # ruff: noqa # noqa: PGH004
+"""Merge between Lib/urllib/parse.py and Lib/ipaddress.py, with removal of some unncessary import that isn't support on micropython"""
+
+"""Parse (absolute and relative) URLs.
+
+urlparse module is based upon the following RFC specifications.
+
+RFC 3986 (STD66): "Uniform Resource Identifiers" by T. Berners-Lee, R. Fielding
+and L. Masinter, January 2005.
+
+RFC 2732 : "Format for Literal IPv6 Addresses in URL's by R.Hinden, B.Carpenter
+and L.Masinter, December 1999.
+
+RFC 2396: "Uniform Resource Identifiers (URI)": Generic Syntax by T.
+Berners-Lee, R. Fielding, and L. Masinter, August 1998.
+
+RFC 2368: "The mailto URL scheme", by P.Hoffman , L Masinter, J. Zawinski, July 1998.
+
+RFC 1808: "Relative Uniform Resource Locators", by R. Fielding, UC Irvine, June
+1995.
+
+RFC 1738: "Uniform Resource Locators (URL)" by T. Berners-Lee, L. Masinter, M.
+McCahill, December 1994
+
+RFC 3986 is considered the current standard and any future changes to
+urlparse module should conform with it. The urlparse module is
+currently not entirely compliant with this RFC due to defacto
+scenarios for parsing, and for backward compatibility purposes, some
+parsing quirks from older RFCs are retained. The testcases in
+test_urlparse.py provides a good indicator of parsing behavior.
+
+The WHATWG URL Parser spec should also be considered. We are not compliant with
+it either due to existing user code API behavior expectations (Hyrum's Law).
+It serves as a useful guide when making changes.
+"""
+
+from collections import namedtuple
+import math
+import re
+# Copyright 2007 Google Inc.
+# Licensed to PSF under a Contributor Agreement.
+
+"""A fast, lightweight IPv4/IPv6 manipulation library in Python.
+
+This library is used to create/poke/manipulate IPv4 and IPv6 addresses
+and networks.
+
+"""
+
+IPV4LENGTH = 32
+IPV6LENGTH = 128
+
+
+class AddressValueError(ValueError):
+ """A Value Error related to the address."""
+
+
+class NetmaskValueError(ValueError):
+ """A Value Error related to the netmask."""
+
+
+def ip_address(address):
+ """Take an IP string/int and return an object of the correct type.
+
+ Args:
+ address: A string or integer, the IP address. Either IPv4 or
+ IPv6 addresses may be supplied; integers less than 2**32 will
+ be considered to be IPv4 by default.
+
+ Returns:
+ An IPv4Address or IPv6Address object.
+
+ Raises:
+ ValueError: if the *address* passed isn't either a v4 or a v6
+ address
+
+ """
+ try:
+ return IPv4Address(address)
+ except (AddressValueError, NetmaskValueError):
+ pass
+
+ try:
+ return IPv6Address(address)
+ except (AddressValueError, NetmaskValueError):
+ pass
+
+ raise ValueError(f'{address!r} does not appear to be an IPv4 or IPv6 address')
+
+
+def ip_network(address, strict=True):
+ """Take an IP string/int and return an object of the correct type.
+
+ Args:
+ address: A string or integer, the IP network. Either IPv4 or
+ IPv6 networks may be supplied; integers less than 2**32 will
+ be considered to be IPv4 by default.
+
+ Returns:
+ An IPv4Network or IPv6Network object.
+
+ Raises:
+ ValueError: if the string passed isn't either a v4 or a v6
+ address. Or if the network has host bits set.
+
+ """
+ try:
+ return IPv4Network(address, strict)
+ except (AddressValueError, NetmaskValueError):
+ pass
+
+ try:
+ return IPv6Network(address, strict)
+ except (AddressValueError, NetmaskValueError):
+ pass
+
+ raise ValueError(f'{address!r} does not appear to be an IPv4 or IPv6 network')
+
+
+def ip_interface(address):
+ """Take an IP string/int and return an object of the correct type.
+
+ Args:
+ address: A string or integer, the IP address. Either IPv4 or
+ IPv6 addresses may be supplied; integers less than 2**32 will
+ be considered to be IPv4 by default.
+
+ Returns:
+ An IPv4Interface or IPv6Interface object.
+
+ Raises:
+ ValueError: if the string passed isn't either a v4 or a v6
+ address.
+
+ Notes:
+ The IPv?Interface classes describe an Address on a particular
+ Network, so they're basically a combination of both the Address
+ and Network classes.
+
+ """
+ try:
+ return IPv4Interface(address)
+ except (AddressValueError, NetmaskValueError):
+ pass
+
+ try:
+ return IPv6Interface(address)
+ except (AddressValueError, NetmaskValueError):
+ pass
+
+ raise ValueError(f'{address!r} does not appear to be an IPv4 or IPv6 interface')
+
+
+def v4_int_to_packed(address):
+ """Represent an address as 4 packed bytes in network (big-endian) order.
+
+ Args:
+ address: An integer representation of an IPv4 IP address.
+
+ Returns:
+ The integer address packed as 4 bytes in network (big-endian) order.
+
+ Raises:
+ ValueError: If the integer is negative or too large to be an
+ IPv4 IP address.
+
+ """
+ try:
+ return address.to_bytes(4) # big endian
+ except OverflowError:
+ raise ValueError("Address negative or too large for IPv4")
+
+
+def v6_int_to_packed(address):
+ """Represent an address as 16 packed bytes in network (big-endian) order.
+
+ Args:
+ address: An integer representation of an IPv6 IP address.
+
+ Returns:
+ The integer address packed as 16 bytes in network (big-endian) order.
+
+ """
+ try:
+ return address.to_bytes(16) # big endian
+ except OverflowError:
+ raise ValueError("Address negative or too large for IPv6")
+
+
+def _split_optional_netmask(address):
+ """Helper to split the netmask and raise AddressValueError if needed"""
+ addr = str(address).split('/')
+ if len(addr) > 2:
+ raise AddressValueError(f"Only one '/' permitted in {address!r}")
+ return addr
+
+
+def _find_address_range(addresses):
+ """Find a sequence of sorted deduplicated IPv#Address.
+
+ Args:
+ addresses: a list of IPv#Address objects.
+
+ Yields:
+ A tuple containing the first and last IP addresses in the sequence.
+
+ """
+ it = iter(addresses)
+ first = last = next(it)
+ for ip in it:
+ if ip._ip != last._ip + 1:
+ yield first, last
+ first = ip
+ last = ip
+ yield first, last
+
+
+def _count_righthand_zero_bits(number, bits):
+ """Count the number of zero bits on the right hand side.
+
+ Args:
+ number: an integer.
+ bits: maximum number of bits to count.
+
+ Returns:
+ The number of zero bits on the right hand side of the number.
+
+ """
+ if number == 0:
+ return bits
+ return min(bits, (~number & (number-1)).bit_length())
+
+
+def summarize_address_range(first, last):
+ """Summarize a network range given the first and last IP addresses.
+
+ Example:
+ >>> list(summarize_address_range(IPv4Address('192.0.2.0'),
+ ... IPv4Address('192.0.2.130')))
+ ... #doctest: +NORMALIZE_WHITESPACE
+ [IPv4Network('192.0.2.0/25'), IPv4Network('192.0.2.128/31'),
+ IPv4Network('192.0.2.130/32')]
+
+ Args:
+ first: the first IPv4Address or IPv6Address in the range.
+ last: the last IPv4Address or IPv6Address in the range.
+
+ Returns:
+ An iterator of the summarized IPv(4|6) network objects.
+
+ Raise:
+ TypeError:
+ If the first and last objects are not IP addresses.
+ If the first and last objects are not the same version.
+ ValueError:
+ If the last object is not greater than the first.
+ If the version of the first address is not 4 or 6.
+
+ """
+ if (not (isinstance(first, _BaseAddress) and
+ isinstance(last, _BaseAddress))):
+ raise TypeError('first and last must be IP addresses, not networks')
+ if first.version != last.version:
+ raise TypeError("%s and %s are not of the same version" % (
+ first, last))
+ if first > last:
+ raise ValueError('last IP address must be greater than first')
+
+ if first.version == 4:
+ ip = IPv4Network
+ elif first.version == 6:
+ ip = IPv6Network
+ else:
+ raise ValueError('unknown IP version')
+
+ ip_bits = first._max_prefixlen
+ first_int = first._ip
+ last_int = last._ip
+ while first_int <= last_int:
+ nbits = min(_count_righthand_zero_bits(first_int, ip_bits),
+ (last_int - first_int + 1).bit_length() - 1)
+ net = ip((first_int, ip_bits - nbits))
+ yield net
+ first_int += 1 << nbits
+ if first_int - 1 == ip._ALL_ONES:
+ break
+
+
+def _collapse_addresses_internal(addresses):
+ """Loops through the addresses, collapsing concurrent netblocks.
+
+ Example:
+
+ ip1 = IPv4Network('192.0.2.0/26')
+ ip2 = IPv4Network('192.0.2.64/26')
+ ip3 = IPv4Network('192.0.2.128/26')
+ ip4 = IPv4Network('192.0.2.192/26')
+
+ _collapse_addresses_internal([ip1, ip2, ip3, ip4]) ->
+ [IPv4Network('192.0.2.0/24')]
+
+ This shouldn't be called directly; it is called via
+ collapse_addresses([]).
+
+ Args:
+ addresses: A list of IPv4Network's or IPv6Network's
+
+ Returns:
+ A list of IPv4Network's or IPv6Network's depending on what we were
+ passed.
+
+ """
+ # First merge
+ to_merge = list(addresses)
+ subnets = {}
+ while to_merge:
+ net = to_merge.pop()
+ supernet = net.supernet()
+ existing = subnets.get(supernet)
+ if existing is None:
+ subnets[supernet] = net
+ elif existing != net:
+ # Merge consecutive subnets
+ del subnets[supernet]
+ to_merge.append(supernet)
+ # Then iterate over resulting networks, skipping subsumed subnets
+ last = None
+ for net in sorted(subnets.values()):
+ if last is not None:
+ # Since they are sorted, last.network_address <= net.network_address
+ # is a given.
+ if last.broadcast_address >= net.broadcast_address:
+ continue
+ yield net
+ last = net
+
+
+def collapse_addresses(addresses):
+ """Collapse a list of IP objects.
+
+ Example:
+ collapse_addresses([IPv4Network('192.0.2.0/25'),
+ IPv4Network('192.0.2.128/25')]) ->
+ [IPv4Network('192.0.2.0/24')]
+
+ Args:
+ addresses: An iterable of IPv4Network or IPv6Network objects.
+
+ Returns:
+ An iterator of the collapsed IPv(4|6)Network objects.
+
+ Raises:
+ TypeError: If passed a list of mixed version objects.
+
+ """
+ addrs = []
+ ips = []
+ nets = []
+
+ # split IP addresses and networks
+ for ip in addresses:
+ if isinstance(ip, _BaseAddress):
+ if ips and ips[-1]._version != ip._version:
+ raise TypeError("%s and %s are not of the same version" % (
+ ip, ips[-1]))
+ ips.append(ip)
+ elif ip._prefixlen == ip._max_prefixlen:
+ if ips and ips[-1]._version != ip._version:
+ raise TypeError("%s and %s are not of the same version" % (
+ ip, ips[-1]))
+ try:
+ ips.append(ip.ip)
+ except AttributeError:
+ ips.append(ip.network_address)
+ else:
+ if nets and nets[-1]._version != ip._version:
+ raise TypeError("%s and %s are not of the same version" % (
+ ip, nets[-1]))
+ nets.append(ip)
+
+ # sort and dedup
+ ips = sorted(set(ips))
+
+ # find consecutive address ranges in the sorted sequence and summarize them
+ if ips:
+ for first, last in _find_address_range(ips):
+ addrs.extend(summarize_address_range(first, last))
+
+ return _collapse_addresses_internal(addrs + nets)
+
+
+def get_mixed_type_key(obj):
+ """Return a key suitable for sorting between networks and addresses.
+
+ Address and Network objects are not sortable by default; they're
+ fundamentally different so the expression
+
+ IPv4Address('192.0.2.0') <= IPv4Network('192.0.2.0/24')
+
+ doesn't make any sense. There are some times however, where you may wish
+ to have ipaddress sort these for you anyway. If you need to do this, you
+ can use this function as the key= argument to sorted().
+
+ Args:
+ obj: either a Network or Address object.
+ Returns:
+ appropriate key.
+
+ """
+ if isinstance(obj, _BaseNetwork):
+ return obj._get_networks_key()
+ elif isinstance(obj, _BaseAddress):
+ return obj._get_address_key()
+ return NotImplemented
+
+
+class _IPAddressBase:
+
+ """The mother class."""
+
+ __slots__ = ()
+
+ @property
+ def exploded(self):
+ """Return the longhand version of the IP address as a string."""
+ return self._explode_shorthand_ip_string()
+
+ @property
+ def compressed(self):
+ """Return the shorthand version of the IP address as a string."""
+ return str(self)
+
+ @property
+ def reverse_pointer(self):
+ """The name of the reverse DNS pointer for the IP address, e.g.:
+ >>> ipaddress.ip_address("127.0.0.1").reverse_pointer
+ '1.0.0.127.in-addr.arpa'
+ >>> ipaddress.ip_address("2001:db8::1").reverse_pointer
+ '1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa'
+
+ """
+ return self._reverse_pointer()
+
+ @property
+ def version(self):
+ msg = '%200s has no version specified' % (type(self),)
+ raise NotImplementedError(msg)
+
+ def _check_int_address(self, address):
+ if address < 0:
+ msg = "%d (< 0) is not permitted as an IPv%d address"
+ raise AddressValueError(msg % (address, self._version))
+ if address > self._ALL_ONES:
+ msg = "%d (>= 2**%d) is not permitted as an IPv%d address"
+ raise AddressValueError(msg % (address, self._max_prefixlen,
+ self._version))
+
+ def _check_packed_address(self, address, expected_len):
+ address_len = len(address)
+ if address_len != expected_len:
+ msg = "%r (len %d != %d) is not permitted as an IPv%d address"
+ raise AddressValueError(msg % (address, address_len,
+ expected_len, self._version))
+
+ @classmethod
+ def _ip_int_from_prefix(cls, prefixlen):
+ """Turn the prefix length into a bitwise netmask
+
+ Args:
+ prefixlen: An integer, the prefix length.
+
+ Returns:
+ An integer.
+
+ """
+ return cls._ALL_ONES ^ (cls._ALL_ONES >> prefixlen)
+
+ @classmethod
+ def _prefix_from_ip_int(cls, ip_int):
+ """Return prefix length from the bitwise netmask.
+
+ Args:
+ ip_int: An integer, the netmask in expanded bitwise format
+
+ Returns:
+ An integer, the prefix length.
+
+ Raises:
+ ValueError: If the input intermingles zeroes & ones
+ """
+ trailing_zeroes = _count_righthand_zero_bits(ip_int,
+ cls._max_prefixlen)
+ prefixlen = cls._max_prefixlen - trailing_zeroes
+ leading_ones = ip_int >> trailing_zeroes
+ all_ones = (1 << prefixlen) - 1
+ if leading_ones != all_ones:
+ byteslen = cls._max_prefixlen // 8
+ details = ip_int.to_bytes(byteslen, 'big')
+ msg = 'Netmask pattern %r mixes zeroes & ones'
+ raise ValueError(msg % details)
+ return prefixlen
+
+ @classmethod
+ def _report_invalid_netmask(cls, netmask_str):
+ msg = '%r is not a valid netmask' % netmask_str
+ raise NetmaskValueError(msg) from None
+
+ @classmethod
+ def _prefix_from_prefix_string(cls, prefixlen_str):
+ """Return prefix length from a numeric string
+
+ Args:
+ prefixlen_str: The string to be converted
+
+ Returns:
+ An integer, the prefix length.
+
+ Raises:
+ NetmaskValueError: If the input is not a valid netmask
+ """
+ # int allows a leading +/- as well as surrounding whitespace,
+ # so we ensure that isn't the case
+ if not (all(0 <= ord(v) < 255 for v in prefixlen_str) and prefixlen_str.isdigit()):
+ cls._report_invalid_netmask(prefixlen_str)
+ try:
+ prefixlen = int(prefixlen_str)
+ except ValueError:
+ cls._report_invalid_netmask(prefixlen_str)
+ if not (0 <= prefixlen <= cls._max_prefixlen):
+ cls._report_invalid_netmask(prefixlen_str)
+ return prefixlen
+
+ @classmethod
+ def _prefix_from_ip_string(cls, ip_str):
+ """Turn a netmask/hostmask string into a prefix length
+
+ Args:
+ ip_str: The netmask/hostmask to be converted
+
+ Returns:
+ An integer, the prefix length.
+
+ Raises:
+ NetmaskValueError: If the input is not a valid netmask/hostmask
+ """
+ # Parse the netmask/hostmask like an IP address.
+ try:
+ ip_int = cls._ip_int_from_string(ip_str)
+ except AddressValueError:
+ cls._report_invalid_netmask(ip_str)
+
+ # Try matching a netmask (this would be /1*0*/ as a bitwise regexp).
+ # Note that the two ambiguous cases (all-ones and all-zeroes) are
+ # treated as netmasks.
+ try:
+ return cls._prefix_from_ip_int(ip_int)
+ except ValueError:
+ pass
+
+ # Invert the bits, and try matching a /0+1+/ hostmask instead.
+ ip_int ^= cls._ALL_ONES
+ try:
+ return cls._prefix_from_ip_int(ip_int)
+ except ValueError:
+ cls._report_invalid_netmask(ip_str)
+
+ @classmethod
+ def _split_addr_prefix(cls, address):
+ """Helper function to parse address of Network/Interface.
+
+ Arg:
+ address: Argument of Network/Interface.
+
+ Returns:
+ (addr, prefix) tuple.
+ """
+ # a packed address or integer
+ if isinstance(address, (bytes, int)):
+ return address, cls._max_prefixlen
+
+ if not isinstance(address, tuple):
+ # Assume input argument to be string or any object representation
+ # which converts into a formatted IP prefix string.
+ address = _split_optional_netmask(address)
+
+ # Constructing from a tuple (addr, [mask])
+ if len(address) > 1:
+ return address
+ return address[0], cls._max_prefixlen
+
+ def __reduce__(self):
+ return self.__class__, (str(self),)
+
+
+_address_fmt_re = None
+
+class _BaseAddress(_IPAddressBase):
+
+ """A generic IP object.
+
+ This IP class contains the version independent methods which are
+ used by single IP addresses.
+ """
+
+ __slots__ = ()
+
+ def __int__(self):
+ return self._ip
+
+ def __eq__(self, other):
+ try:
+ return (self._ip == other._ip
+ and self._version == other._version)
+ except AttributeError:
+ return NotImplemented
+
+ def __lt__(self, other):
+ if not isinstance(other, _BaseAddress):
+ return NotImplemented
+ if self._version != other._version:
+ raise TypeError('%s and %s are not of the same version' % (
+ self, other))
+ if self._ip != other._ip:
+ return self._ip < other._ip
+ return False
+
+ # Shorthand for Integer addition and subtraction. This is not
+ # meant to ever support addition/subtraction of addresses.
+ def __add__(self, other):
+ if not isinstance(other, int):
+ return NotImplemented
+ return self.__class__(int(self) + other)
+
+ def __sub__(self, other):
+ if not isinstance(other, int):
+ return NotImplemented
+ return self.__class__(int(self) - other)
+
+ def __repr__(self):
+ return '%s(%r)' % (self.__class__.__name__, str(self))
+
+ def __str__(self):
+ return str(self._string_from_ip_int(self._ip))
+
+ def __hash__(self):
+ return hash(hex(int(self._ip)))
+
+ def _get_address_key(self):
+ return (self._version, self)
+
+ def __reduce__(self):
+ return self.__class__, (self._ip,)
+
+ def __format__(self, fmt):
+ """Returns an IP address as a formatted string.
+
+ Supported presentation types are:
+ 's': returns the IP address as a string (default)
+ 'b': converts to binary and returns a zero-padded string
+ 'X' or 'x': converts to upper- or lower-case hex and returns a zero-padded string
+ 'n': the same as 'b' for IPv4 and 'x' for IPv6
+
+ For binary and hex presentation types, the alternate form specifier
+ '#' and the grouping option '_' are supported.
+ """
+
+ # Support string formatting
+ if not fmt or fmt[-1] == 's':
+ return format(str(self), fmt)
+
+ # From here on down, support for 'bnXx'
+ global _address_fmt_re
+ if _address_fmt_re is None:
+ import re
+ _address_fmt_re = re.compile('(#?)(_?)([xbnX])')
+
+ m = _address_fmt_re.fullmatch(fmt)
+ if not m:
+ return super().__format__(fmt)
+
+ alternate, grouping, fmt_base = m.groups()
+
+ # Set some defaults
+ if fmt_base == 'n':
+ if self._version == 4:
+ fmt_base = 'b' # Binary is default for ipv4
+ else:
+ fmt_base = 'x' # Hex is default for ipv6
+
+ if fmt_base == 'b':
+ padlen = self._max_prefixlen
+ else:
+ padlen = self._max_prefixlen // 4
+
+ if grouping:
+ padlen += padlen // 4 - 1
+
+ if alternate:
+ padlen += 2 # 0b or 0x
+
+ return format(int(self), f'{alternate}0{padlen}{grouping}{fmt_base}')
+
+
+class _BaseNetwork(_IPAddressBase):
+ """A generic IP network object.
+
+ This IP class contains the version independent methods which are
+ used by networks.
+ """
+
+ def __repr__(self):
+ return '%s(%r)' % (self.__class__.__name__, str(self))
+
+ def __str__(self):
+ return '%s/%d' % (self.network_address, self.prefixlen)
+
+ def hosts(self):
+ """Generate Iterator over usable hosts in a network.
+
+ This is like __iter__ except it doesn't return the network
+ or broadcast addresses.
+
+ """
+ network = int(self.network_address)
+ broadcast = int(self.broadcast_address)
+ for x in range(network + 1, broadcast):
+ yield self._address_class(x)
+
+ def __iter__(self):
+ network = int(self.network_address)
+ broadcast = int(self.broadcast_address)
+ for x in range(network, broadcast + 1):
+ yield self._address_class(x)
+
+ def __getitem__(self, n):
+ network = int(self.network_address)
+ broadcast = int(self.broadcast_address)
+ if n >= 0:
+ if network + n > broadcast:
+ raise IndexError('address out of range')
+ return self._address_class(network + n)
+ else:
+ n += 1
+ if broadcast + n < network:
+ raise IndexError('address out of range')
+ return self._address_class(broadcast + n)
+
+ def __lt__(self, other):
+ if not isinstance(other, _BaseNetwork):
+ return NotImplemented
+ if self._version != other._version:
+ raise TypeError('%s and %s are not of the same version' % (
+ self, other))
+ if self.network_address != other.network_address:
+ return self.network_address < other.network_address
+ if self.netmask != other.netmask:
+ return self.netmask < other.netmask
+ return False
+
+ def __eq__(self, other):
+ try:
+ return (self._version == other._version and
+ self.network_address == other.network_address and
+ int(self.netmask) == int(other.netmask))
+ except AttributeError:
+ return NotImplemented
+
+ def __hash__(self):
+ return hash((int(self.network_address), int(self.netmask)))
+
+ def __contains__(self, other):
+ # always false if one is v4 and the other is v6.
+ if self._version != other._version:
+ return False
+ # dealing with another network.
+ if isinstance(other, _BaseNetwork):
+ return False
+ # dealing with another address
+ else:
+ # address
+ return other._ip & self.netmask._ip == self.network_address._ip
+
+ def overlaps(self, other):
+ """Tell if self is partly contained in other."""
+ return self.network_address in other or (
+ self.broadcast_address in other or (
+ other.network_address in self or (
+ other.broadcast_address in self)))
+
+ def broadcast_address(self):
+ return self._address_class(int(self.network_address) |
+ int(self.hostmask))
+
+ def hostmask(self):
+ return self._address_class(int(self.netmask) ^ self._ALL_ONES)
+
+ @property
+ def with_prefixlen(self):
+ return '%s/%d' % (self.network_address, self._prefixlen)
+
+ @property
+ def with_netmask(self):
+ return '%s/%s' % (self.network_address, self.netmask)
+
+ @property
+ def with_hostmask(self):
+ return '%s/%s' % (self.network_address, self.hostmask)
+
+ @property
+ def num_addresses(self):
+ """Number of hosts in the current subnet."""
+ return int(self.broadcast_address) - int(self.network_address) + 1
+
+ @property
+ def _address_class(self):
+ # Returning bare address objects (rather than interfaces) allows for
+ # more consistent behaviour across the network address, broadcast
+ # address and individual host addresses.
+ msg = '%200s has no associated address class' % (type(self),)
+ raise NotImplementedError(msg)
+
+ @property
+ def prefixlen(self):
+ return self._prefixlen
+
+ def address_exclude(self, other):
+ """Remove an address from a larger block.
+
+ For example:
+
+ addr1 = ip_network('192.0.2.0/28')
+ addr2 = ip_network('192.0.2.1/32')
+ list(addr1.address_exclude(addr2)) =
+ [IPv4Network('192.0.2.0/32'), IPv4Network('192.0.2.2/31'),
+ IPv4Network('192.0.2.4/30'), IPv4Network('192.0.2.8/29')]
+
+ or IPv6:
+
+ addr1 = ip_network('2001:db8::1/32')
+ addr2 = ip_network('2001:db8::1/128')
+ list(addr1.address_exclude(addr2)) =
+ [ip_network('2001:db8::1/128'),
+ ip_network('2001:db8::2/127'),
+ ip_network('2001:db8::4/126'),
+ ip_network('2001:db8::8/125'),
+ ...
+ ip_network('2001:db8:8000::/33')]
+
+ Args:
+ other: An IPv4Network or IPv6Network object of the same type.
+
+ Returns:
+ An iterator of the IPv(4|6)Network objects which is self
+ minus other.
+
+ Raises:
+ TypeError: If self and other are of differing address
+ versions, or if other is not a network object.
+ ValueError: If other is not completely contained by self.
+
+ """
+ if not self._version == other._version:
+ raise TypeError("%s and %s are not of the same version" % (
+ self, other))
+
+ if not isinstance(other, _BaseNetwork):
+ raise TypeError("%s is not a network object" % other)
+
+ if not other.subnet_of(self):
+ raise ValueError('%s not contained in %s' % (other, self))
+ if other == self:
+ return
+
+ # Make sure we're comparing the network of other.
+ other = other.__class__('%s/%s' % (other.network_address,
+ other.prefixlen))
+
+ s1, s2 = self.subnets()
+ while s1 != other and s2 != other:
+ if other.subnet_of(s1):
+ yield s2
+ s1, s2 = s1.subnets()
+ elif other.subnet_of(s2):
+ yield s1
+ s1, s2 = s2.subnets()
+ else:
+ # If we got here, there's a bug somewhere.
+ raise AssertionError('Error performing exclusion: '
+ 's1: %s s2: %s other: %s' %
+ (s1, s2, other))
+ if s1 == other:
+ yield s2
+ elif s2 == other:
+ yield s1
+ else:
+ # If we got here, there's a bug somewhere.
+ raise AssertionError('Error performing exclusion: '
+ 's1: %s s2: %s other: %s' %
+ (s1, s2, other))
+
+ def compare_networks(self, other):
+ """Compare two IP objects.
+
+ This is only concerned about the comparison of the integer
+ representation of the network addresses. This means that the
+ host bits aren't considered at all in this method. If you want
+ to compare host bits, you can easily enough do a
+ 'HostA._ip < HostB._ip'
+
+ Args:
+ other: An IP object.
+
+ Returns:
+ If the IP versions of self and other are the same, returns:
+
+ -1 if self < other:
+ eg: IPv4Network('192.0.2.0/25') < IPv4Network('192.0.2.128/25')
+ IPv6Network('2001:db8::1000/124') <
+ IPv6Network('2001:db8::2000/124')
+ 0 if self == other
+ eg: IPv4Network('192.0.2.0/24') == IPv4Network('192.0.2.0/24')
+ IPv6Network('2001:db8::1000/124') ==
+ IPv6Network('2001:db8::1000/124')
+ 1 if self > other
+ eg: IPv4Network('192.0.2.128/25') > IPv4Network('192.0.2.0/25')
+ IPv6Network('2001:db8::2000/124') >
+ IPv6Network('2001:db8::1000/124')
+
+ Raises:
+ TypeError if the IP versions are different.
+
+ """
+ # does this need to raise a ValueError?
+ if self._version != other._version:
+ raise TypeError('%s and %s are not of the same type' % (
+ self, other))
+ # self._version == other._version below here:
+ if self.network_address < other.network_address:
+ return -1
+ if self.network_address > other.network_address:
+ return 1
+ # self.network_address == other.network_address below here:
+ if self.netmask < other.netmask:
+ return -1
+ if self.netmask > other.netmask:
+ return 1
+ return 0
+
+ def _get_networks_key(self):
+ """Network-only key function.
+
+ Returns an object that identifies this address' network and
+ netmask. This function is a suitable "key" argument for sorted()
+ and list.sort().
+
+ """
+ return (self._version, self.network_address, self.netmask)
+
+ def subnets(self, prefixlen_diff=1, new_prefix=None):
+ """The subnets which join to make the current subnet.
+
+ In the case that self contains only one IP
+ (self._prefixlen == 32 for IPv4 or self._prefixlen == 128
+ for IPv6), yield an iterator with just ourself.
+
+ Args:
+ prefixlen_diff: An integer, the amount the prefix length
+ should be increased by. This should not be set if
+ new_prefix is also set.
+ new_prefix: The desired new prefix length. This must be a
+ larger number (smaller prefix) than the existing prefix.
+ This should not be set if prefixlen_diff is also set.
+
+ Returns:
+ An iterator of IPv(4|6) objects.
+
+ Raises:
+ ValueError: The prefixlen_diff is too small or too large.
+ OR
+ prefixlen_diff and new_prefix are both set or new_prefix
+ is a smaller number than the current prefix (smaller
+ number means a larger network)
+
+ """
+ if self._prefixlen == self._max_prefixlen:
+ yield self
+ return
+
+ if new_prefix is not None:
+ if new_prefix < self._prefixlen:
+ raise ValueError('new prefix must be longer')
+ if prefixlen_diff != 1:
+ raise ValueError('cannot set prefixlen_diff and new_prefix')
+ prefixlen_diff = new_prefix - self._prefixlen
+
+ if prefixlen_diff < 0:
+ raise ValueError('prefix length diff must be > 0')
+ new_prefixlen = self._prefixlen + prefixlen_diff
+
+ if new_prefixlen > self._max_prefixlen:
+ raise ValueError(
+ 'prefix length diff %d is invalid for netblock %s' % (
+ new_prefixlen, self))
+
+ start = int(self.network_address)
+ end = int(self.broadcast_address) + 1
+ step = (int(self.hostmask) + 1) >> prefixlen_diff
+ for new_addr in range(start, end, step):
+ current = self.__class__((new_addr, new_prefixlen))
+ yield current
+
+ def supernet(self, prefixlen_diff=1, new_prefix=None):
+ """The supernet containing the current network.
+
+ Args:
+ prefixlen_diff: An integer, the amount the prefix length of
+ the network should be decreased by. For example, given a
+ /24 network and a prefixlen_diff of 3, a supernet with a
+ /21 netmask is returned.
+
+ Returns:
+ An IPv4 network object.
+
+ Raises:
+ ValueError: If self.prefixlen - prefixlen_diff < 0. I.e., you have
+ a negative prefix length.
+ OR
+ If prefixlen_diff and new_prefix are both set or new_prefix is a
+ larger number than the current prefix (larger number means a
+ smaller network)
+
+ """
+ if self._prefixlen == 0:
+ return self
+
+ if new_prefix is not None:
+ if new_prefix > self._prefixlen:
+ raise ValueError('new prefix must be shorter')
+ if prefixlen_diff != 1:
+ raise ValueError('cannot set prefixlen_diff and new_prefix')
+ prefixlen_diff = self._prefixlen - new_prefix
+
+ new_prefixlen = self.prefixlen - prefixlen_diff
+ if new_prefixlen < 0:
+ raise ValueError(
+ 'current prefixlen is %d, cannot have a prefixlen_diff of %d' %
+ (self.prefixlen, prefixlen_diff))
+ return self.__class__((
+ int(self.network_address) & (int(self.netmask) << prefixlen_diff),
+ new_prefixlen
+ ))
+
+ @property
+ def is_multicast(self):
+ """Test if the address is reserved for multicast use.
+
+ Returns:
+ A boolean, True if the address is a multicast address.
+ See RFC 2373 2.7 for details.
+
+ """
+ return (self.network_address.is_multicast and
+ self.broadcast_address.is_multicast)
+
+ @staticmethod
+ def _is_subnet_of(a, b):
+ try:
+ # Always false if one is v4 and the other is v6.
+ if a._version != b._version:
+ raise TypeError(f"{a} and {b} are not of the same version")
+ return (b.network_address <= a.network_address and
+ b.broadcast_address >= a.broadcast_address)
+ except AttributeError:
+ raise TypeError(f"Unable to test subnet containment "
+ f"between {a} and {b}")
+
+ def subnet_of(self, other):
+ """Return True if this network is a subnet of other."""
+ return self._is_subnet_of(self, other)
+
+ def supernet_of(self, other):
+ """Return True if this network is a supernet of other."""
+ return self._is_subnet_of(other, self)
+
+ @property
+ def is_reserved(self):
+ """Test if the address is otherwise IETF reserved.
+
+ Returns:
+ A boolean, True if the address is within one of the
+ reserved IPv6 Network ranges.
+
+ """
+ return (self.network_address.is_reserved and
+ self.broadcast_address.is_reserved)
+
+ @property
+ def is_link_local(self):
+ """Test if the address is reserved for link-local.
+
+ Returns:
+ A boolean, True if the address is reserved per RFC 4291.
+
+ """
+ return (self.network_address.is_link_local and
+ self.broadcast_address.is_link_local)
+
+ @property
+ def is_private(self):
+ """Test if this network belongs to a private range.
+
+ Returns:
+ A boolean, True if the network is reserved per
+ iana-ipv4-special-registry or iana-ipv6-special-registry.
+
+ """
+ return any(self.network_address in priv_network and
+ self.broadcast_address in priv_network
+ for priv_network in self._constants._private_networks) and all(
+ self.network_address not in network and
+ self.broadcast_address not in network
+ for network in self._constants._private_networks_exceptions
+ )
+
+ @property
+ def is_global(self):
+ """Test if this address is allocated for public networks.
+
+ Returns:
+ A boolean, True if the address is not reserved per
+ iana-ipv4-special-registry or iana-ipv6-special-registry.
+
+ """
+ return not self.is_private
+
+ @property
+ def is_unspecified(self):
+ """Test if the address is unspecified.
+
+ Returns:
+ A boolean, True if this is the unspecified address as defined in
+ RFC 2373 2.5.2.
+
+ """
+ return (self.network_address.is_unspecified and
+ self.broadcast_address.is_unspecified)
+
+ @property
+ def is_loopback(self):
+ """Test if the address is a loopback address.
+
+ Returns:
+ A boolean, True if the address is a loopback address as defined in
+ RFC 2373 2.5.3.
+
+ """
+ return (self.network_address.is_loopback and
+ self.broadcast_address.is_loopback)
+
+
+class _BaseConstants:
+
+ _private_networks = []
+
+
+_BaseNetwork._constants = _BaseConstants
+
+
+class _BaseV4:
+
+ """Base IPv4 object.
+
+ The following methods are used by IPv4 objects in both single IP
+ addresses and networks.
+
+ """
+
+ __slots__ = ()
+ _version = 4
+ # Equivalent to 255.255.255.255 or 32 bits of 1's.
+ _ALL_ONES = (2**IPV4LENGTH) - 1
+
+ _max_prefixlen = IPV4LENGTH
+ # There are only a handful of valid v4 netmasks, so we cache them all
+ # when constructed (see _make_netmask()).
+ _netmask_cache = {}
+
+ def _explode_shorthand_ip_string(self):
+ return str(self)
+
+ @classmethod
+ def _make_netmask(cls, arg):
+ """Make a (netmask, prefix_len) tuple from the given argument.
+
+ Argument can be:
+ - an integer (the prefix length)
+ - a string representing the prefix length (e.g. "24")
+ - a string representing the prefix netmask (e.g. "255.255.255.0")
+ """
+ if arg not in cls._netmask_cache:
+ if isinstance(arg, int):
+ prefixlen = arg
+ if not (0 <= prefixlen <= cls._max_prefixlen):
+ cls._report_invalid_netmask(prefixlen)
+ else:
+ try:
+ # Check for a netmask in prefix length form
+ prefixlen = cls._prefix_from_prefix_string(arg)
+ except NetmaskValueError:
+ # Check for a netmask or hostmask in dotted-quad form.
+ # This may raise NetmaskValueError.
+ prefixlen = cls._prefix_from_ip_string(arg)
+ netmask = IPv4Address(cls._ip_int_from_prefix(prefixlen))
+ cls._netmask_cache[arg] = netmask, prefixlen
+ return cls._netmask_cache[arg]
+
+ @classmethod
+ def _ip_int_from_string(cls, ip_str):
+ """Turn the given IP string into an integer for comparison.
+
+ Args:
+ ip_str: A string, the IP ip_str.
+
+ Returns:
+ The IP ip_str as an integer.
+
+ Raises:
+ AddressValueError: if ip_str isn't a valid IPv4 Address.
+
+ """
+ if not ip_str:
+ raise AddressValueError('Address cannot be empty')
+
+ octets = ip_str.split('.')
+ if len(octets) != 4:
+ raise AddressValueError("Expected 4 octets in %r" % ip_str)
+
+ try:
+ v = 0
+ for o in map(cls._parse_octet, octets):
+ v *= 256
+ v += o
+ return v
+ except ValueError as exc:
+ raise AddressValueError("%s in %r" % (exc, ip_str)) from None
+
+ @classmethod
+ def _parse_octet(cls, octet_str):
+ """Convert a decimal octet into an integer.
+
+ Args:
+ octet_str: A string, the number to parse.
+
+ Returns:
+ The octet as an integer.
+
+ Raises:
+ ValueError: if the octet isn't strictly a decimal from [0..255].
+
+ """
+ if not octet_str:
+ raise ValueError("Empty octet not permitted")
+ # Reject non-ASCII digits.
+ if not (all(0 <= ord(v) < 255 for v in octet_str) and octet_str.isdigit()):
+ msg = "Only decimal digits permitted in %r"
+ raise ValueError(msg % octet_str)
+ # We do the length check second, since the invalid character error
+ # is likely to be more informative for the user
+ if len(octet_str) > 3:
+ msg = "At most 3 characters permitted in %r"
+ raise ValueError(msg % octet_str)
+ # Handle leading zeros as strict as glibc's inet_pton()
+ # See security bug bpo-36384
+ if octet_str != '0' and octet_str[0] == '0':
+ msg = "Leading zeros are not permitted in %r"
+ raise ValueError(msg % octet_str)
+ # Convert to integer (we know digits are legal)
+ octet_int = int(octet_str, 10)
+ if octet_int > 255:
+ raise ValueError("Octet %d (> 255) not permitted" % octet_int)
+ return octet_int
+
+ @classmethod
+ def _string_from_ip_int(cls, ip_int):
+ """Turns a 32-bit integer into dotted decimal notation.
+
+ Args:
+ ip_int: An integer, the IP address.
+
+ Returns:
+ The IP address as a string in dotted decimal notation.
+
+ """
+ return '.'.join(map(str, ip_int.to_bytes(4, 'big')))
+
+ def _reverse_pointer(self):
+ """Return the reverse DNS pointer name for the IPv4 address.
+
+ This implements the method described in RFC1035 3.5.
+
+ """
+ reverse_octets = str(self).split('.')[::-1]
+ return '.'.join(reverse_octets) + '.in-addr.arpa'
+
+ @property
+ def max_prefixlen(self):
+ return self._max_prefixlen
+
+ @property
+ def version(self):
+ return self._version
+
+
+class IPv4Address(_BaseV4, _BaseAddress):
+
+ """Represent and manipulate single IPv4 Addresses."""
+
+ __slots__ = ('_ip', '__weakref__')
+
+ def __init__(self, address):
+
+ """
+ Args:
+ address: A string or integer representing the IP
+
+ Additionally, an integer can be passed, so
+ IPv4Address('192.0.2.1') == IPv4Address(3221225985).
+ or, more generally
+ IPv4Address(int(IPv4Address('192.0.2.1'))) ==
+ IPv4Address('192.0.2.1')
+
+ Raises:
+ AddressValueError: If ipaddress isn't a valid IPv4 address.
+
+ """
+ # Efficient constructor from integer.
+ if isinstance(address, int):
+ self._check_int_address(address)
+ self._ip = address
+ return
+
+ # Constructing from a packed address
+ if isinstance(address, bytes):
+ self._check_packed_address(address, 4)
+ self._ip = int.from_bytes(address) # big endian
+ return
+
+ # Assume input argument to be string or any object representation
+ # which converts into a formatted IP string.
+ addr_str = str(address)
+ if '/' in addr_str:
+ raise AddressValueError(f"Unexpected '/' in {address!r}")
+ self._ip = self._ip_int_from_string(addr_str)
+
+ @property
+ def packed(self):
+ """The binary representation of this address."""
+ return v4_int_to_packed(self._ip)
+
+ @property
+ def is_reserved(self):
+ """Test if the address is otherwise IETF reserved.
+
+ Returns:
+ A boolean, True if the address is within the
+ reserved IPv4 Network range.
+
+ """
+ return self in self._constants._reserved_network
+
+ @property
+ def is_private(self):
+ """``True`` if the address is defined as not globally reachable by
+ iana-ipv4-special-registry_ (for IPv4) or iana-ipv6-special-registry_
+ (for IPv6) with the following exceptions:
+
+ * ``is_private`` is ``False`` for ``100.64.0.0/10``
+ * For IPv4-mapped IPv6-addresses the ``is_private`` value is determined by the
+ semantics of the underlying IPv4 addresses and the following condition holds
+ (see :attr:`IPv6Address.ipv4_mapped`)::
+
+ address.is_private == address.ipv4_mapped.is_private
+
+ ``is_private`` has value opposite to :attr:`is_global`, except for the ``100.64.0.0/10``
+ IPv4 range where they are both ``False``.
+ """
+ return (
+ any(self in net for net in self._constants._private_networks)
+ and all(self not in net for net in self._constants._private_networks_exceptions)
+ )
+
+ @property
+ def is_global(self):
+ """``True`` if the address is defined as globally reachable by
+ iana-ipv4-special-registry_ (for IPv4) or iana-ipv6-special-registry_
+ (for IPv6) with the following exception:
+
+ For IPv4-mapped IPv6-addresses the ``is_private`` value is determined by the
+ semantics of the underlying IPv4 addresses and the following condition holds
+ (see :attr:`IPv6Address.ipv4_mapped`)::
+
+ address.is_global == address.ipv4_mapped.is_global
+
+ ``is_global`` has value opposite to :attr:`is_private`, except for the ``100.64.0.0/10``
+ IPv4 range where they are both ``False``.
+ """
+ return self not in self._constants._public_network and not self.is_private
+
+ @property
+ def is_multicast(self):
+ """Test if the address is reserved for multicast use.
+
+ Returns:
+ A boolean, True if the address is multicast.
+ See RFC 3171 for details.
+
+ """
+ return self in self._constants._multicast_network
+
+ @property
+ def is_unspecified(self):
+ """Test if the address is unspecified.
+
+ Returns:
+ A boolean, True if this is the unspecified address as defined in
+ RFC 5735 3.
+
+ """
+ return self == self._constants._unspecified_address
+
+ @property
+ def is_loopback(self):
+ """Test if the address is a loopback address.
+
+ Returns:
+ A boolean, True if the address is a loopback per RFC 3330.
+
+ """
+ return self in self._constants._loopback_network
+
+ @property
+ def is_link_local(self):
+ """Test if the address is reserved for link-local.
+
+ Returns:
+ A boolean, True if the address is link-local per RFC 3927.
+
+ """
+ return self in self._constants._linklocal_network
+
+ @property
+ def ipv6_mapped(self):
+ """Return the IPv4-mapped IPv6 address.
+
+ Returns:
+ The IPv4-mapped IPv6 address per RFC 4291.
+
+ """
+ return IPv6Address(f'::ffff:{self}')
+
+
+class IPv4Interface(IPv4Address):
+
+ def __init__(self, address):
+ addr, mask = self._split_addr_prefix(address)
+
+ IPv4Address.__init__(self, addr)
+ self.network = IPv4Network((addr, mask), strict=False)
+ self.netmask = self.network.netmask
+ self._prefixlen = self.network._prefixlen
+
+ def hostmask(self):
+ return self.network.hostmask
+
+ def __str__(self):
+ return '%s/%d' % (self._string_from_ip_int(self._ip),
+ self._prefixlen)
+
+ def __eq__(self, other):
+ address_equal = IPv4Address.__eq__(self, other)
+ if address_equal is NotImplemented or not address_equal:
+ return address_equal
+ try:
+ return self.network == other.network
+ except AttributeError:
+ # An interface with an associated network is NOT the
+ # same as an unassociated address. That's why the hash
+ # takes the extra info into account.
+ return False
+
+ def __lt__(self, other):
+ address_less = IPv4Address.__lt__(self, other)
+ if address_less is NotImplemented:
+ return NotImplemented
+ try:
+ return (self.network < other.network or
+ self.network == other.network and address_less)
+ except AttributeError:
+ # We *do* allow addresses and interfaces to be sorted. The
+ # unassociated address is considered less than all interfaces.
+ return False
+
+ def __hash__(self):
+ return hash((self._ip, self._prefixlen, int(self.network.network_address)))
+
+ __reduce__ = _IPAddressBase.__reduce__
+
+ @property
+ def ip(self):
+ return IPv4Address(self._ip)
+
+ @property
+ def with_prefixlen(self):
+ return '%s/%s' % (self._string_from_ip_int(self._ip),
+ self._prefixlen)
+
+ @property
+ def with_netmask(self):
+ return '%s/%s' % (self._string_from_ip_int(self._ip),
+ self.netmask)
+
+ @property
+ def with_hostmask(self):
+ return '%s/%s' % (self._string_from_ip_int(self._ip),
+ self.hostmask)
+
+
+class IPv4Network(_BaseV4, _BaseNetwork):
+
+ """This class represents and manipulates 32-bit IPv4 network + addresses..
+
+ Attributes: [examples for IPv4Network('192.0.2.0/27')]
+ .network_address: IPv4Address('192.0.2.0')
+ .hostmask: IPv4Address('0.0.0.31')
+ .broadcast_address: IPv4Address('192.0.2.32')
+ .netmask: IPv4Address('255.255.255.224')
+ .prefixlen: 27
+
+ """
+ # Class to use when creating address objects
+ _address_class = IPv4Address
+
+ def __init__(self, address, strict=True):
+ """Instantiate a new IPv4 network object.
+
+ Args:
+ address: A string or integer representing the IP [& network].
+ '192.0.2.0/24'
+ '192.0.2.0/255.255.255.0'
+ '192.0.2.0/0.0.0.255'
+ are all functionally the same in IPv4. Similarly,
+ '192.0.2.1'
+ '192.0.2.1/255.255.255.255'
+ '192.0.2.1/32'
+ are also functionally equivalent. That is to say, failing to
+ provide a subnetmask will create an object with a mask of /32.
+
+ If the mask (portion after the / in the argument) is given in
+ dotted quad form, it is treated as a netmask if it starts with a
+ non-zero field (e.g. /255.0.0.0 == /8) and as a hostmask if it
+ starts with a zero field (e.g. 0.255.255.255 == /8), with the
+ single exception of an all-zero mask which is treated as a
+ netmask == /0. If no mask is given, a default of /32 is used.
+
+ Additionally, an integer can be passed, so
+ IPv4Network('192.0.2.1') == IPv4Network(3221225985)
+ or, more generally
+ IPv4Interface(int(IPv4Interface('192.0.2.1'))) ==
+ IPv4Interface('192.0.2.1')
+
+ Raises:
+ AddressValueError: If ipaddress isn't a valid IPv4 address.
+ NetmaskValueError: If the netmask isn't valid for
+ an IPv4 address.
+ ValueError: If strict is True and a network address is not
+ supplied.
+ """
+ addr, mask = self._split_addr_prefix(address)
+
+ self.network_address = IPv4Address(addr)
+ self.netmask, self._prefixlen = self._make_netmask(mask)
+ packed = int(self.network_address)
+ if packed & int(self.netmask) != packed:
+ if strict:
+ raise ValueError('%s has host bits set' % self)
+ else:
+ self.network_address = IPv4Address(packed &
+ int(self.netmask))
+
+ if self._prefixlen == (self._max_prefixlen - 1):
+ self.hosts = self.__iter__
+ elif self._prefixlen == (self._max_prefixlen):
+ self.hosts = lambda: [IPv4Address(addr)]
+
+ @property
+ def is_global(self):
+ """Test if this address is allocated for public networks.
+
+ Returns:
+ A boolean, True if the address is not reserved per
+ iana-ipv4-special-registry.
+
+ """
+ return (not (self.network_address in IPv4Network('100.64.0.0/10') and
+ self.broadcast_address in IPv4Network('100.64.0.0/10')) and
+ not self.is_private)
+
+
+class _IPv4Constants:
+ _linklocal_network = IPv4Network('169.254.0.0/16')
+
+ _loopback_network = IPv4Network('127.0.0.0/8')
+
+ _multicast_network = IPv4Network('224.0.0.0/4')
+
+ _public_network = IPv4Network('100.64.0.0/10')
+
+ # Not globally reachable address blocks listed on
+ # https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml
+ _private_networks = [
+ IPv4Network('0.0.0.0/8'),
+ IPv4Network('10.0.0.0/8'),
+ IPv4Network('127.0.0.0/8'),
+ IPv4Network('169.254.0.0/16'),
+ IPv4Network('172.16.0.0/12'),
+ IPv4Network('192.0.0.0/24'),
+ IPv4Network('192.0.0.170/31'),
+ IPv4Network('192.0.2.0/24'),
+ IPv4Network('192.168.0.0/16'),
+ IPv4Network('198.18.0.0/15'),
+ IPv4Network('198.51.100.0/24'),
+ IPv4Network('203.0.113.0/24'),
+ IPv4Network('240.0.0.0/4'),
+ IPv4Network('255.255.255.255/32'),
+ ]
+
+ _private_networks_exceptions = [
+ IPv4Network('192.0.0.9/32'),
+ IPv4Network('192.0.0.10/32'),
+ ]
+
+ _reserved_network = IPv4Network('240.0.0.0/4')
+
+ _unspecified_address = IPv4Address('0.0.0.0')
+
+
+IPv4Address._constants = _IPv4Constants
+IPv4Network._constants = _IPv4Constants
+
+
+class _BaseV6:
+
+ """Base IPv6 object.
+
+ The following methods are used by IPv6 objects in both single IP
+ addresses and networks.
+
+ """
+
+ __slots__ = ()
+ _version = 6
+ _ALL_ONES = (2**IPV6LENGTH) - 1
+ _HEXTET_COUNT = 8
+ _HEX_DIGITS = frozenset('0123456789ABCDEFabcdef')
+ _max_prefixlen = IPV6LENGTH
+
+ # There are only a bunch of valid v6 netmasks, so we cache them all
+ # when constructed (see _make_netmask()).
+ _netmask_cache = {}
+
+ @classmethod
+ def _make_netmask(cls, arg):
+ """Make a (netmask, prefix_len) tuple from the given argument.
+
+ Argument can be:
+ - an integer (the prefix length)
+ - a string representing the prefix length (e.g. "24")
+ - a string representing the prefix netmask (e.g. "255.255.255.0")
+ """
+ if arg not in cls._netmask_cache:
+ if isinstance(arg, int):
+ prefixlen = arg
+ if not (0 <= prefixlen <= cls._max_prefixlen):
+ cls._report_invalid_netmask(prefixlen)
+ else:
+ prefixlen = cls._prefix_from_prefix_string(arg)
+ netmask = IPv6Address(cls._ip_int_from_prefix(prefixlen))
+ cls._netmask_cache[arg] = netmask, prefixlen
+ return cls._netmask_cache[arg]
+
+ @classmethod
+ def _ip_int_from_string(cls, ip_str):
+ """Turn an IPv6 ip_str into an integer.
+
+ Args:
+ ip_str: A string, the IPv6 ip_str.
+
+ Returns:
+ An int, the IPv6 address
+
+ Raises:
+ AddressValueError: if ip_str isn't a valid IPv6 Address.
+
+ """
+ if not ip_str:
+ raise AddressValueError('Address cannot be empty')
+ if len(ip_str) > 45:
+ shorten = ip_str
+ if len(shorten) > 100:
+ shorten = f'{ip_str[:45]}({len(ip_str)-90} chars elided){ip_str[-45:]}'
+ raise AddressValueError(f"At most 45 characters expected in "
+ f"{shorten!r}")
+
+ # We want to allow more parts than the max to be 'split'
+ # to preserve the correct error message when there are
+ # too many parts combined with '::'
+ _max_parts = cls._HEXTET_COUNT + 1
+ # parts = ip_str.split(':', maxsplit=_max_parts)
+ parts = [""]
+ for c in ip_str:
+ if c == ":":
+ parts.append("")
+ else:
+ parts[-1] += c
+
+ # An IPv6 address needs at least 2 colons (3 parts).
+ _min_parts = 3
+ if len(parts) < _min_parts:
+ msg = "At least %d parts expected in %r" % (_min_parts, ip_str)
+ raise AddressValueError(msg)
+
+ # If the address has an IPv4-style suffix, convert it to hexadecimal.
+ if '.' in parts[-1]:
+ try:
+ ipv4_int = IPv4Address(parts.pop())._ip
+ except AddressValueError as exc:
+ raise AddressValueError("%s in %r" % (exc, ip_str)) from None
+ parts.append('%x' % ((ipv4_int >> 16) & 0xFFFF))
+ parts.append('%x' % (ipv4_int & 0xFFFF))
+
+ # An IPv6 address can't have more than 8 colons (9 parts).
+ # The extra colon comes from using the "::" notation for a single
+ # leading or trailing zero part.
+ if len(parts) > _max_parts:
+ msg = "At most %d colons permitted in %r" % (_max_parts-1, ip_str)
+ raise AddressValueError(msg)
+
+ # Disregarding the endpoints, find '::' with nothing in between.
+ # This indicates that a run of zeroes has been skipped.
+ skip_index = None
+ for i in range(1, len(parts) - 1):
+ if not parts[i]:
+ if skip_index is not None:
+ # Can't have more than one '::'
+ msg = "At most one '::' permitted in %r" % ip_str
+ raise AddressValueError(msg)
+ skip_index = i
+
+ # parts_hi is the number of parts to copy from above/before the '::'
+ # parts_lo is the number of parts to copy from below/after the '::'
+ if skip_index is not None:
+ # If we found a '::', then check if it also covers the endpoints.
+ parts_hi = skip_index
+ parts_lo = len(parts) - skip_index - 1
+ if not parts[0]:
+ parts_hi -= 1
+ if parts_hi:
+ msg = "Leading ':' only permitted as part of '::' in %r"
+ raise AddressValueError(msg % ip_str) # ^: requires ^::
+ if not parts[-1]:
+ parts_lo -= 1
+ if parts_lo:
+ msg = "Trailing ':' only permitted as part of '::' in %r"
+ raise AddressValueError(msg % ip_str) # :$ requires ::$
+ parts_skipped = cls._HEXTET_COUNT - (parts_hi + parts_lo)
+ if parts_skipped < 1:
+ msg = "Expected at most %d other parts with '::' in %r"
+ raise AddressValueError(msg % (cls._HEXTET_COUNT-1, ip_str))
+ else:
+ # Otherwise, allocate the entire address to parts_hi. The
+ # endpoints could still be empty, but _parse_hextet() will check
+ # for that.
+ if len(parts) != cls._HEXTET_COUNT:
+ msg = "Exactly %d parts expected without '::' in %r"
+ raise AddressValueError(msg % (cls._HEXTET_COUNT, ip_str))
+ if not parts[0]:
+ msg = "Leading ':' only permitted as part of '::' in %r"
+ raise AddressValueError(msg % ip_str) # ^: requires ^::
+ if not parts[-1]:
+ msg = "Trailing ':' only permitted as part of '::' in %r"
+ raise AddressValueError(msg % ip_str) # :$ requires ::$
+ parts_hi = len(parts)
+ parts_lo = 0
+ parts_skipped = 0
+
+ try:
+ # Now, parse the hextets into a 128-bit integer.
+ ip_int = 0
+ for i in range(parts_hi):
+ ip_int <<= 16
+ ip_int |= cls._parse_hextet(parts[i])
+ ip_int <<= 16 * parts_skipped
+ for i in range(-parts_lo, 0):
+ ip_int <<= 16
+ ip_int |= cls._parse_hextet(parts[i])
+ return ip_int
+ except ValueError as exc:
+ raise AddressValueError("%s in %r" % (exc, ip_str)) from None
+
+ @classmethod
+ def _parse_hextet(cls, hextet_str):
+ """Convert an IPv6 hextet string into an integer.
+
+ Args:
+ hextet_str: A string, the number to parse.
+
+ Returns:
+ The hextet as an integer.
+
+ Raises:
+ ValueError: if the input isn't strictly a hex number from
+ [0..FFFF].
+
+ """
+ # Reject non-ASCII digits.
+ if not cls._HEX_DIGITS.issuperset(hextet_str):
+ raise ValueError("Only hex digits permitted in %r" % hextet_str)
+ # We do the length check second, since the invalid character error
+ # is likely to be more informative for the user
+ if len(hextet_str) > 4:
+ msg = "At most 4 characters permitted in %r"
+ raise ValueError(msg % hextet_str)
+ # Length check means we can skip checking the integer value
+ return int(hextet_str, 16)
+
+ @classmethod
+ def _compress_hextets(cls, hextets):
+ """Compresses a list of hextets.
+
+ Compresses a list of strings, replacing the longest continuous
+ sequence of "0" in the list with "" and adding empty strings at
+ the beginning or at the end of the string such that subsequently
+ calling ":".join(hextets) will produce the compressed version of
+ the IPv6 address.
+
+ Args:
+ hextets: A list of strings, the hextets to compress.
+
+ Returns:
+ A list of strings.
+
+ """
+ best_doublecolon_start = -1
+ best_doublecolon_len = 0
+ doublecolon_start = -1
+ doublecolon_len = 0
+ for index, hextet in enumerate(hextets):
+ if hextet == '0':
+ doublecolon_len += 1
+ if doublecolon_start == -1:
+ # Start of a sequence of zeros.
+ doublecolon_start = index
+ if doublecolon_len > best_doublecolon_len:
+ # This is the longest sequence of zeros so far.
+ best_doublecolon_len = doublecolon_len
+ best_doublecolon_start = doublecolon_start
+ else:
+ doublecolon_len = 0
+ doublecolon_start = -1
+
+ if best_doublecolon_len > 1:
+ best_doublecolon_end = (best_doublecolon_start +
+ best_doublecolon_len)
+ # For zeros at the end of the address.
+ if best_doublecolon_end == len(hextets):
+ hextets += ['']
+ hextets[best_doublecolon_start:best_doublecolon_end] = ['']
+ # For zeros at the beginning of the address.
+ if best_doublecolon_start == 0:
+ hextets = [''] + hextets
+
+ return hextets
+
+ @classmethod
+ def _string_from_ip_int(cls, ip_int=None):
+ """Turns a 128-bit integer into hexadecimal notation.
+
+ Args:
+ ip_int: An integer, the IP address.
+
+ Returns:
+ A string, the hexadecimal representation of the address.
+
+ Raises:
+ ValueError: The address is bigger than 128 bits of all ones.
+
+ """
+ if ip_int is None:
+ ip_int = int(cls._ip)
+
+ if ip_int > cls._ALL_ONES:
+ raise ValueError('IPv6 address is too large')
+
+ hex_str = '%032x' % ip_int
+ hextets = ['%x' % int(hex_str[x:x+4], 16) for x in range(0, 32, 4)]
+
+ hextets = cls._compress_hextets(hextets)
+ return ':'.join(hextets)
+
+ def _explode_shorthand_ip_string(self):
+ """Expand a shortened IPv6 address.
+
+ Returns:
+ A string, the expanded IPv6 address.
+
+ """
+ if isinstance(self, IPv6Network):
+ ip_str = str(self.network_address)
+ elif isinstance(self, IPv6Interface):
+ ip_str = str(self.ip)
+ else:
+ ip_str = str(self)
+
+ ip_int = self._ip_int_from_string(ip_str)
+ hex_str = '%032x' % ip_int
+ parts = [hex_str[x:x+4] for x in range(0, 32, 4)]
+ if isinstance(self, (_BaseNetwork, IPv6Interface)):
+ return '%s/%d' % (':'.join(parts), self._prefixlen)
+ return ':'.join(parts)
+
+ def _reverse_pointer(self):
+ """Return the reverse DNS pointer name for the IPv6 address.
+
+ This implements the method described in RFC3596 2.5.
+
+ """
+ reverse_chars = self.exploded[::-1].replace(':', '')
+ return '.'.join(reverse_chars) + '.ip6.arpa'
+
+ @staticmethod
+ def _split_scope_id(ip_str):
+ """Helper function to parse IPv6 string address with scope id.
+
+ See RFC 4007 for details.
+
+ Args:
+ ip_str: A string, the IPv6 address.
+
+ Returns:
+ (addr, scope_id) tuple.
+
+ """
+ addr, sep, scope_id = ip_str.partition('%')
+ if not sep:
+ scope_id = None
+ elif not scope_id or '%' in scope_id:
+ raise AddressValueError('Invalid IPv6 address: "%r"' % ip_str)
+ return addr, scope_id
+
+ @property
+ def max_prefixlen(self):
+ return self._max_prefixlen
+
+ @property
+ def version(self):
+ return self._version
+
+
+class IPv6Address(_BaseV6, _BaseAddress):
+
+ """Represent and manipulate single IPv6 Addresses."""
+
+ __slots__ = ('_ip', '_scope_id', '__weakref__')
+
+ def __init__(self, address):
+ """Instantiate a new IPv6 address object.
+
+ Args:
+ address: A string or integer representing the IP
+
+ Additionally, an integer can be passed, so
+ IPv6Address('2001:db8::') ==
+ IPv6Address(42540766411282592856903984951653826560)
+ or, more generally
+ IPv6Address(int(IPv6Address('2001:db8::'))) ==
+ IPv6Address('2001:db8::')
+
+ Raises:
+ AddressValueError: If address isn't a valid IPv6 address.
+
+ """
+ # Efficient constructor from integer.
+ if isinstance(address, int):
+ self._check_int_address(address)
+ self._ip = address
+ self._scope_id = None
+ return
+
+ # Constructing from a packed address
+ if isinstance(address, bytes):
+ self._check_packed_address(address, 16)
+ self._ip = int.from_bytes(address, 'big')
+ self._scope_id = None
+ return
+
+ # Assume input argument to be string or any object representation
+ # which converts into a formatted IP string.
+ addr_str = str(address)
+ if '/' in addr_str:
+ raise AddressValueError(f"Unexpected '/' in {address!r}")
+ addr_str, self._scope_id = self._split_scope_id(addr_str)
+
+ self._ip = self._ip_int_from_string(addr_str)
+
+ def _explode_shorthand_ip_string(self):
+ ipv4_mapped = self.ipv4_mapped
+ if ipv4_mapped is None:
+ return super()._explode_shorthand_ip_string()
+ prefix_len = 30
+ raw_exploded_str = super()._explode_shorthand_ip_string()
+ return f"{raw_exploded_str[:prefix_len]}{ipv4_mapped!s}"
+
+ def _reverse_pointer(self):
+ ipv4_mapped = self.ipv4_mapped
+ if ipv4_mapped is None:
+ return super()._reverse_pointer()
+ prefix_len = 30
+ raw_exploded_str = super()._explode_shorthand_ip_string()[:prefix_len]
+ # ipv4 encoded using hexadecimal nibbles instead of decimals
+ ipv4_int = ipv4_mapped._ip
+ reverse_chars = f"{raw_exploded_str}{ipv4_int:008x}"[::-1].replace(':', '')
+ return '.'.join(reverse_chars) + '.ip6.arpa'
+
+ def _ipv4_mapped_ipv6_to_str(self):
+ """Return convenient text representation of IPv4-mapped IPv6 address
+
+ See RFC 4291 2.5.5.2, 2.2 p.3 for details.
+
+ Returns:
+ A string, 'x:x:x:x:x:x:d.d.d.d', where the 'x's are the hexadecimal values of
+ the six high-order 16-bit pieces of the address, and the 'd's are
+ the decimal values of the four low-order 8-bit pieces of the
+ address (standard IPv4 representation) as defined in RFC 4291 2.2 p.3.
+
+ """
+ ipv4_mapped = self.ipv4_mapped
+ if ipv4_mapped is None:
+ raise AddressValueError("Can not apply to non-IPv4-mapped IPv6 address %s" % str(self))
+ high_order_bits = self._ip >> 32
+ return "%s:%s" % (self._string_from_ip_int(high_order_bits), str(ipv4_mapped))
+
+ def __str__(self):
+ ipv4_mapped = self.ipv4_mapped
+ if ipv4_mapped is None:
+ ip_str = super().__str__()
+ else:
+ ip_str = self._ipv4_mapped_ipv6_to_str()
+ return ip_str + '%' + self._scope_id if self._scope_id else ip_str
+
+ def __hash__(self):
+ return hash((self._ip, self._scope_id))
+
+ def __eq__(self, other):
+ address_equal = super().__eq__(other)
+ if address_equal is NotImplemented:
+ return NotImplemented
+ if not address_equal:
+ return False
+ return self._scope_id == getattr(other, '_scope_id', None)
+
+ def __reduce__(self):
+ return (self.__class__, (str(self),))
+
+ @property
+ def scope_id(self):
+ """Identifier of a particular zone of the address's scope.
+
+ See RFC 4007 for details.
+
+ Returns:
+ A string identifying the zone of the address if specified, else None.
+
+ """
+ return self._scope_id
+
+ @property
+ def packed(self):
+ """The binary representation of this address."""
+ return v6_int_to_packed(self._ip)
+
+ @property
+ def is_multicast(self):
+ """Test if the address is reserved for multicast use.
+
+ Returns:
+ A boolean, True if the address is a multicast address.
+ See RFC 2373 2.7 for details.
+
+ """
+ ipv4_mapped = self.ipv4_mapped
+ if ipv4_mapped is not None:
+ return ipv4_mapped.is_multicast
+ return self in self._constants._multicast_network
+
+ @property
+ def is_reserved(self):
+ """Test if the address is otherwise IETF reserved.
+
+ Returns:
+ A boolean, True if the address is within one of the
+ reserved IPv6 Network ranges.
+
+ """
+ ipv4_mapped = self.ipv4_mapped
+ if ipv4_mapped is not None:
+ return ipv4_mapped.is_reserved
+ return any(self in x for x in self._constants._reserved_networks)
+
+ @property
+ def is_link_local(self):
+ """Test if the address is reserved for link-local.
+
+ Returns:
+ A boolean, True if the address is reserved per RFC 4291.
+
+ """
+ ipv4_mapped = self.ipv4_mapped
+ if ipv4_mapped is not None:
+ return ipv4_mapped.is_link_local
+ return self in self._constants._linklocal_network
+
+ @property
+ def is_site_local(self):
+ """Test if the address is reserved for site-local.
+
+ Note that the site-local address space has been deprecated by RFC 3879.
+ Use is_private to test if this address is in the space of unique local
+ addresses as defined by RFC 4193.
+
+ Returns:
+ A boolean, True if the address is reserved per RFC 3513 2.5.6.
+
+ """
+ return self in self._constants._sitelocal_network
+
+ @property
+ def is_private(self):
+ """``True`` if the address is defined as not globally reachable by
+ iana-ipv4-special-registry_ (for IPv4) or iana-ipv6-special-registry_
+ (for IPv6) with the following exceptions:
+
+ * ``is_private`` is ``False`` for ``100.64.0.0/10``
+ * For IPv4-mapped IPv6-addresses the ``is_private`` value is determined by the
+ semantics of the underlying IPv4 addresses and the following condition holds
+ (see :attr:`IPv6Address.ipv4_mapped`)::
+
+ address.is_private == address.ipv4_mapped.is_private
+
+ ``is_private`` has value opposite to :attr:`is_global`, except for the ``100.64.0.0/10``
+ IPv4 range where they are both ``False``.
+ """
+ ipv4_mapped = self.ipv4_mapped
+ if ipv4_mapped is not None:
+ return ipv4_mapped.is_private
+ return (
+ any(self in net for net in self._constants._private_networks)
+ and all(self not in net for net in self._constants._private_networks_exceptions)
+ )
+
+ @property
+ def is_global(self):
+ """``True`` if the address is defined as globally reachable by
+ iana-ipv4-special-registry_ (for IPv4) or iana-ipv6-special-registry_
+ (for IPv6) with the following exception:
+
+ For IPv4-mapped IPv6-addresses the ``is_private`` value is determined by the
+ semantics of the underlying IPv4 addresses and the following condition holds
+ (see :attr:`IPv6Address.ipv4_mapped`)::
+
+ address.is_global == address.ipv4_mapped.is_global
+
+ ``is_global`` has value opposite to :attr:`is_private`, except for the ``100.64.0.0/10``
+ IPv4 range where they are both ``False``.
+ """
+ ipv4_mapped = self.ipv4_mapped
+ if ipv4_mapped is not None:
+ return ipv4_mapped.is_global
+ return not self.is_private
+
+ @property
+ def is_unspecified(self):
+ """Test if the address is unspecified.
+
+ Returns:
+ A boolean, True if this is the unspecified address as defined in
+ RFC 2373 2.5.2.
+
+ """
+ ipv4_mapped = self.ipv4_mapped
+ if ipv4_mapped is not None:
+ return ipv4_mapped.is_unspecified
+ return self._ip == 0
+
+ @property
+ def is_loopback(self):
+ """Test if the address is a loopback address.
+
+ Returns:
+ A boolean, True if the address is a loopback address as defined in
+ RFC 2373 2.5.3.
+
+ """
+ ipv4_mapped = self.ipv4_mapped
+ if ipv4_mapped is not None:
+ return ipv4_mapped.is_loopback
+ return self._ip == 1
+
+ @property
+ def ipv4_mapped(self):
+ """Return the IPv4 mapped address.
+
+ Returns:
+ If the IPv6 address is a v4 mapped address, return the
+ IPv4 mapped address. Return None otherwise.
+
+ """
+ if (self._ip >> 32) != 0xFFFF:
+ return None
+ return IPv4Address(self._ip & 0xFFFFFFFF)
+
+ @property
+ def teredo(self):
+ """Tuple of embedded teredo IPs.
+
+ Returns:
+ Tuple of the (server, client) IPs or None if the address
+ doesn't appear to be a teredo address (doesn't start with
+ 2001::/32)
+
+ """
+ if (self._ip >> 96) != 0x20010000:
+ return None
+ return (IPv4Address((self._ip >> 64) & 0xFFFFFFFF),
+ IPv4Address(~self._ip & 0xFFFFFFFF))
+
+ @property
+ def sixtofour(self):
+ """Return the IPv4 6to4 embedded address.
+
+ Returns:
+ The IPv4 6to4-embedded address if present or None if the
+ address doesn't appear to contain a 6to4 embedded address.
+
+ """
+ if (self._ip >> 112) != 0x2002:
+ return None
+ return IPv4Address((self._ip >> 80) & 0xFFFFFFFF)
+
+
+class IPv6Interface(IPv6Address):
+
+ def __init__(self, address):
+ addr, mask = self._split_addr_prefix(address)
+
+ IPv6Address.__init__(self, addr)
+ self.network = IPv6Network((addr, mask), strict=False)
+ self.netmask = self.network.netmask
+ self._prefixlen = self.network._prefixlen
+
+ def hostmask(self):
+ return self.network.hostmask
+
+ def __str__(self):
+ return '%s/%d' % (super().__str__(),
+ self._prefixlen)
+
+ def __eq__(self, other):
+ address_equal = IPv6Address.__eq__(self, other)
+ if address_equal is NotImplemented or not address_equal:
+ return address_equal
+ try:
+ return self.network == other.network
+ except AttributeError:
+ # An interface with an associated network is NOT the
+ # same as an unassociated address. That's why the hash
+ # takes the extra info into account.
+ return False
+
+ def __lt__(self, other):
+ address_less = IPv6Address.__lt__(self, other)
+ if address_less is NotImplemented:
+ return address_less
+ try:
+ return (self.network < other.network or
+ self.network == other.network and address_less)
+ except AttributeError:
+ # We *do* allow addresses and interfaces to be sorted. The
+ # unassociated address is considered less than all interfaces.
+ return False
+
+ def __hash__(self):
+ return hash((self._ip, self._prefixlen, int(self.network.network_address)))
+
+ __reduce__ = _IPAddressBase.__reduce__
+
+ @property
+ def ip(self):
+ return IPv6Address(self._ip)
+
+ @property
+ def with_prefixlen(self):
+ return '%s/%s' % (self._string_from_ip_int(self._ip),
+ self._prefixlen)
+
+ @property
+ def with_netmask(self):
+ return '%s/%s' % (self._string_from_ip_int(self._ip),
+ self.netmask)
+
+ @property
+ def with_hostmask(self):
+ return '%s/%s' % (self._string_from_ip_int(self._ip),
+ self.hostmask)
+
+ @property
+ def is_unspecified(self):
+ return self._ip == 0 and self.network.is_unspecified
+
+ @property
+ def is_loopback(self):
+ return super().is_loopback and self.network.is_loopback
+
+
+class IPv6Network(_BaseV6, _BaseNetwork):
+
+ """This class represents and manipulates 128-bit IPv6 networks.
+
+ Attributes: [examples for IPv6('2001:db8::1000/124')]
+ .network_address: IPv6Address('2001:db8::1000')
+ .hostmask: IPv6Address('::f')
+ .broadcast_address: IPv6Address('2001:db8::100f')
+ .netmask: IPv6Address('ffff:ffff:ffff:ffff:ffff:ffff:ffff:fff0')
+ .prefixlen: 124
+
+ """
+
+ # Class to use when creating address objects
+ _address_class = IPv6Address
+
+ def __init__(self, address, strict=True):
+ """Instantiate a new IPv6 Network object.
+
+ Args:
+ address: A string or integer representing the IPv6 network or the
+ IP and prefix/netmask.
+ '2001:db8::/128'
+ '2001:db8:0000:0000:0000:0000:0000:0000/128'
+ '2001:db8::'
+ are all functionally the same in IPv6. That is to say,
+ failing to provide a subnetmask will create an object with
+ a mask of /128.
+
+ Additionally, an integer can be passed, so
+ IPv6Network('2001:db8::') ==
+ IPv6Network(42540766411282592856903984951653826560)
+ or, more generally
+ IPv6Network(int(IPv6Network('2001:db8::'))) ==
+ IPv6Network('2001:db8::')
+
+ strict: A boolean. If true, ensure that we have been passed
+ A true network address, eg, 2001:db8::1000/124 and not an
+ IP address on a network, eg, 2001:db8::1/124.
+
+ Raises:
+ AddressValueError: If address isn't a valid IPv6 address.
+ NetmaskValueError: If the netmask isn't valid for
+ an IPv6 address.
+ ValueError: If strict was True and a network address was not
+ supplied.
+ """
+ addr, mask = self._split_addr_prefix(address)
+
+ self.network_address = IPv6Address(addr)
+ self.netmask, self._prefixlen = self._make_netmask(mask)
+ packed = int(self.network_address)
+ if packed & int(self.netmask) != packed:
+ if strict:
+ raise ValueError('%s has host bits set' % self)
+ else:
+ self.network_address = IPv6Address(packed &
+ int(self.netmask))
+
+ if self._prefixlen == (self._max_prefixlen - 1):
+ self.hosts = self.__iter__
+ elif self._prefixlen == self._max_prefixlen:
+ self.hosts = lambda: [IPv6Address(addr)]
+
+ def hosts(self):
+ """Generate Iterator over usable hosts in a network.
+
+ This is like __iter__ except it doesn't return the
+ Subnet-Router anycast address.
+
+ """
+ network = int(self.network_address)
+ broadcast = int(self.broadcast_address)
+ for x in range(network + 1, broadcast + 1):
+ yield self._address_class(x)
+
+ @property
+ def is_site_local(self):
+ """Test if the address is reserved for site-local.
+
+ Note that the site-local address space has been deprecated by RFC 3879.
+ Use is_private to test if this address is in the space of unique local
+ addresses as defined by RFC 4193.
+
+ Returns:
+ A boolean, True if the address is reserved per RFC 3513 2.5.6.
+
+ """
+ return (self.network_address.is_site_local and
+ self.broadcast_address.is_site_local)
+
+
+class _IPv6Constants:
+
+ _linklocal_network = IPv6Network('fe80::/10')
+
+ _multicast_network = IPv6Network('ff00::/8')
+
+ # Not globally reachable address blocks listed on
+ # https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml
+ _private_networks = [
+ IPv6Network('::1/128'),
+ IPv6Network('::/128'),
+ IPv6Network('::ffff:0:0/96'),
+ IPv6Network('64:ff9b:1::/48'),
+ IPv6Network('100::/64'),
+ IPv6Network('2001::/23'),
+ IPv6Network('2001:db8::/32'),
+ # IANA says N/A, let's consider it not globally reachable to be safe
+ IPv6Network('2002::/16'),
+ # RFC 9637: https://www.rfc-editor.org/rfc/rfc9637.html#section-6-2.2
+ IPv6Network('3fff::/20'),
+ IPv6Network('fc00::/7'),
+ IPv6Network('fe80::/10'),
+ ]
+
+ _private_networks_exceptions = [
+ IPv6Network('2001:1::1/128'),
+ IPv6Network('2001:1::2/128'),
+ IPv6Network('2001:3::/32'),
+ IPv6Network('2001:4:112::/48'),
+ IPv6Network('2001:20::/28'),
+ IPv6Network('2001:30::/28'),
+ ]
+
+ _reserved_networks = [
+ IPv6Network('::/8'), IPv6Network('100::/8'),
+ IPv6Network('200::/7'), IPv6Network('400::/6'),
+ IPv6Network('800::/5'), IPv6Network('1000::/4'),
+ IPv6Network('4000::/3'), IPv6Network('6000::/3'),
+ IPv6Network('8000::/3'), IPv6Network('A000::/3'),
+ IPv6Network('C000::/3'), IPv6Network('E000::/4'),
+ IPv6Network('F000::/5'), IPv6Network('F800::/6'),
+ IPv6Network('FE00::/9'),
+ ]
+
+ _sitelocal_network = IPv6Network('fec0::/10')
+
+
+IPv6Address._constants = _IPv6Constants
+IPv6Network._constants = _IPv6Constants
+
+__all__ = ["urlparse", "urlunparse", "urljoin", "urldefrag",
+ "urlsplit", "urlunsplit", "urlencode", "parse_qs",
+ "parse_qsl", "quote", "quote_plus", "quote_from_bytes",
+ "unquote", "unquote_plus", "unquote_to_bytes",
+ "DefragResult", "ParseResult", "SplitResult",
+ "DefragResultBytes", "ParseResultBytes", "SplitResultBytes"]
+
+# A classification of schemes.
+# The empty string classifies URLs with no scheme specified,
+# being the default value returned by “urlsplit” and “urlparse”.
+
+uses_relative = ['', 'ftp', 'http', 'gopher', 'nntp', 'imap',
+ 'wais', 'file', 'https', 'shttp', 'mms',
+ 'prospero', 'rtsp', 'rtsps', 'rtspu', 'sftp',
+ 'svn', 'svn+ssh', 'ws', 'wss']
+
+uses_netloc = ['', 'ftp', 'http', 'gopher', 'nntp', 'telnet',
+ 'imap', 'wais', 'file', 'mms', 'https', 'shttp',
+ 'snews', 'prospero', 'rtsp', 'rtsps', 'rtspu', 'rsync',
+ 'svn', 'svn+ssh', 'sftp', 'nfs', 'git', 'git+ssh',
+ 'ws', 'wss', 'itms-services']
+
+uses_params = ['', 'ftp', 'hdl', 'prospero', 'http', 'imap',
+ 'https', 'shttp', 'rtsp', 'rtsps', 'rtspu', 'sip',
+ 'sips', 'mms', 'sftp', 'tel']
+
+# These are not actually used anymore, but should stay for backwards
+# compatibility. (They are undocumented, but have a public-looking name.)
+
+non_hierarchical = ['gopher', 'hdl', 'mailto', 'news',
+ 'telnet', 'wais', 'imap', 'snews', 'sip', 'sips']
+
+uses_query = ['', 'http', 'wais', 'imap', 'https', 'shttp', 'mms',
+ 'gopher', 'rtsp', 'rtsps', 'rtspu', 'sip', 'sips']
+
+uses_fragment = ['', 'ftp', 'hdl', 'http', 'gopher', 'news',
+ 'nntp', 'wais', 'https', 'shttp', 'snews',
+ 'file', 'prospero']
+
+# Characters valid in scheme names
+scheme_chars = ('abcdefghijklmnopqrstuvwxyz'
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
+ '0123456789'
+ '+-.')
+
+# Leading and trailing C0 control and space to be stripped per WHATWG spec.
+# == "".join([chr(i) for i in range(0, 0x20 + 1)])
+_WHATWG_C0_CONTROL_OR_SPACE = '\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f '
+
+# Unsafe bytes to be removed per WHATWG spec
+_UNSAFE_URL_BYTES_TO_REMOVE = ['\t', '\r', '\n']
+
+# Helpers for bytes handling
+# For 3.2, we deliberately require applications that
+# handle improperly quoted URLs to do their own
+# decoding and encoding. If valid use cases are
+# presented, we may relax this by using latin-1
+# decoding internally for 3.3
+_implicit_encoding = 'ascii'
+_implicit_errors = 'strict'
+
+def _noop(obj):
+ return obj
+
+def _encode_result(obj, encoding=_implicit_encoding,
+ errors=_implicit_errors):
+ return obj.encode(encoding, errors)
+
+def _decode_args(args, encoding=_implicit_encoding,
+ errors=_implicit_errors):
+ return tuple(x.decode(encoding, errors) if x else '' for x in args)
+
+def _coerce_args(*args):
+ # Invokes decode if necessary to create str args
+ # and returns the coerced inputs along with
+ # an appropriate result coercion function
+ # - noop for str inputs
+ # - encoding function otherwise
+ str_input = isinstance(args[0], str)
+ for arg in args[1:]:
+ # We special-case the empty string to support the
+ # "scheme=''" default argument to some functions
+ if arg and isinstance(arg, str) != str_input:
+ raise TypeError("Cannot mix str and non-str arguments")
+ if str_input:
+ return args + (_noop,)
+ return _decode_args(args) + (_encode_result,)
+
+# Result objects are more helpful than simple tuples
+class _ResultMixinStr(object):
+ """Standard approach to encoding parsed results from str to bytes"""
+ __slots__ = ()
+
+ def encode(self, encoding='ascii', errors='strict'):
+ return self._encoded_counterpart(*(x.encode(encoding, errors) for x in self))
+
+
+class _ResultMixinBytes(object):
+ """Standard approach to decoding parsed results from bytes to str"""
+ __slots__ = ()
+
+ def decode(self, encoding='ascii', errors='strict'):
+ return self._decoded_counterpart(*(x.decode(encoding, errors) for x in self))
+
+
+class _NetlocResultMixinBase(object):
+ """Shared methods for the parsed result objects containing a netloc element"""
+ __slots__ = ()
+
+ @property
+ def username(self):
+ return self._userinfo[0]
+
+ @property
+ def password(self):
+ return self._userinfo[1]
+
+ @property
+ def hostname(self):
+ hostname = self._hostinfo[0]
+ if not hostname:
+ return None
+ # Scoped IPv6 address may have zone info, which must not be lowercased
+ # like http://[fe80::822a:a8ff:fe49:470c%tESt]:1234/keys
+ separator = '%' if isinstance(hostname, str) else b'%'
+ hostname, percent, zone = hostname.partition(separator)
+ return hostname.lower() + percent + zone
+
+ @property
+ def port(self):
+ port = self._hostinfo[1]
+ if port is not None:
+ if port.isdigit() and all(0 <= ord(v) < 255 for v in port):
+ port = int(port)
+ else:
+ raise ValueError(f"Port could not be cast to integer value as {port!r}")
+ if not (0 <= port <= 65535):
+ raise ValueError("Port out of range 0-65535")
+ return port
+
+
+class _NetlocResultMixinStr(_NetlocResultMixinBase, _ResultMixinStr):
+ __slots__ = ()
+
+ @property
+ def _userinfo(self):
+ netloc = self.netloc
+ userinfo, have_info, hostinfo = netloc.rpartition('@')
+ if have_info:
+ username, have_password, password = userinfo.partition(':')
+ if not have_password:
+ password = None
+ else:
+ username = password = None
+ return username, password
+
+ @property
+ def _hostinfo(self):
+ netloc = self.netloc
+ _, _, hostinfo = netloc.rpartition('@')
+ _, have_open_br, bracketed = hostinfo.partition('[')
+ if have_open_br:
+ hostname, _, port = bracketed.partition(']')
+ _, _, port = port.partition(':')
+ else:
+ hostname, _, port = hostinfo.partition(':')
+ if not port:
+ port = None
+ return hostname, port
+
+
+class _NetlocResultMixinBytes(_NetlocResultMixinBase, _ResultMixinBytes):
+ __slots__ = ()
+
+ @property
+ def _userinfo(self):
+ netloc = self.netloc
+ userinfo, have_info, hostinfo = netloc.rpartition(b'@')
+ if have_info:
+ username, have_password, password = userinfo.partition(b':')
+ if not have_password:
+ password = None
+ else:
+ username = password = None
+ return username, password
+
+ @property
+ def _hostinfo(self):
+ netloc = self.netloc
+ _, _, hostinfo = netloc.rpartition(b'@')
+ _, have_open_br, bracketed = hostinfo.partition(b'[')
+ if have_open_br:
+ hostname, _, port = bracketed.partition(b']')
+ _, _, port = port.partition(b':')
+ else:
+ hostname, _, port = hostinfo.partition(b':')
+ if not port:
+ port = None
+ return hostname, port
+
+
+_DefragResultBase = namedtuple('DefragResult', 'url fragment')
+_SplitResultBase = namedtuple(
+ 'SplitResult', 'scheme netloc path query fragment')
+_ParseResultBase = namedtuple(
+ 'ParseResult', 'scheme netloc path params query fragment')
+
+# For backwards compatibility, alias _NetlocResultMixinStr
+# ResultBase is no longer part of the documented API, but it is
+# retained since deprecating it isn't worth the hassle
+ResultBase = _NetlocResultMixinStr
+
+# Structured result objects for string data
+class DefragResult(_DefragResultBase, _ResultMixinStr):
+ __slots__ = ()
+ def geturl(self):
+ if self.fragment:
+ return self.url + '#' + self.fragment
+ else:
+ return self.url
+
+class SplitResult(_SplitResultBase, _NetlocResultMixinStr):
+ __slots__ = ()
+ def geturl(self):
+ return urlunsplit(self)
+
+class ParseResult(_ParseResultBase, _NetlocResultMixinStr):
+ __slots__ = ()
+ def geturl(self):
+ return urlunparse(self)
+
+# Structured result objects for bytes data
+class DefragResultBytes(_DefragResultBase, _ResultMixinBytes):
+ __slots__ = ()
+ def geturl(self):
+ if self.fragment:
+ return self.url + b'#' + self.fragment
+ else:
+ return self.url
+
+class SplitResultBytes(_SplitResultBase, _NetlocResultMixinBytes):
+ __slots__ = ()
+ def geturl(self):
+ return urlunsplit(self)
+
+class ParseResultBytes(_ParseResultBase, _NetlocResultMixinBytes):
+ __slots__ = ()
+ def geturl(self):
+ return urlunparse(self)
+
+# Set up the encode/decode result pairs
+def _fix_result_transcoding():
+ _result_pairs = (
+ (DefragResult, DefragResultBytes),
+ (SplitResult, SplitResultBytes),
+ (ParseResult, ParseResultBytes),
+ )
+ for _decoded, _encoded in _result_pairs:
+ _decoded._encoded_counterpart = _encoded
+ _encoded._decoded_counterpart = _decoded
+
+_fix_result_transcoding()
+del _fix_result_transcoding
+
+def urlparse(url, scheme='', allow_fragments=True):
+ """Parse a URL into 6 components:
+ :///;?#
+
+ The result is a named 6-tuple with fields corresponding to the
+ above. It is either a ParseResult or ParseResultBytes object,
+ depending on the type of the url parameter.
+
+ The username, password, hostname, and port sub-components of netloc
+ can also be accessed as attributes of the returned object.
+
+ The scheme argument provides the default value of the scheme
+ component when no scheme is found in url.
+
+ If allow_fragments is False, no attempt is made to separate the
+ fragment component from the previous component, which can be either
+ path or query.
+
+ Note that % escapes are not expanded.
+ """
+ url, scheme, _coerce_result = _coerce_args(url, scheme)
+ splitresult = urlsplit(url, scheme, allow_fragments)
+ scheme, netloc, url, query, fragment = splitresult
+ if scheme in uses_params and ';' in url:
+ url, params = _splitparams(url)
+ else:
+ params = ''
+ result = ParseResult(scheme, netloc, url, params, query, fragment)
+ return _coerce_result(result)
+
+def _splitparams(url):
+ if '/' in url:
+ i = url.find(';', url.rfind('/'))
+ if i < 0:
+ return url, ''
+ else:
+ i = url.find(';')
+ return url[:i], url[i+1:]
+
+def _splitnetloc(url, start=0):
+ delim = len(url) # position of end of domain part of url, default is end
+ for c in '/?#': # look for delimiters; the order is NOT important
+ wdelim = url.find(c, start) # find first of this delim
+ if wdelim >= 0: # if found
+ delim = min(delim, wdelim) # use earliest delim position
+ return url[start:delim], url[delim:] # return (domain, rest)
+
+def _checknetloc(netloc):
+ if not netloc or all(0 <= ord(v) < 255 for v in netloc):
+ return
+ # looking for characters like \u2100 that expand to 'a/c'
+ # IDNA uses NFKC equivalence, so normalize for this check
+ import unicodedata
+ n = netloc.replace('@', '') # ignore characters already included
+ n = n.replace(':', '') # but not the surrounding text
+ n = n.replace('#', '')
+ n = n.replace('?', '')
+ netloc2 = unicodedata.normalize('NFKC', n)
+ if n == netloc2:
+ return
+ for c in '/?#@:':
+ if c in netloc2:
+ raise ValueError("netloc '" + netloc + "' contains invalid " +
+ "characters under NFKC normalization")
+
+def _check_bracketed_netloc(netloc):
+ # Note that this function must mirror the splitting
+ # done in NetlocResultMixins._hostinfo().
+ hostname_and_port = netloc.rpartition('@')[2]
+ before_bracket, have_open_br, bracketed = hostname_and_port.partition('[')
+ if have_open_br:
+ # No data is allowed before a bracket.
+ if before_bracket:
+ raise ValueError("Invalid IPv6 URL")
+ hostname, _, port = bracketed.partition(']')
+ # No data is allowed after the bracket but before the port delimiter.
+ if port and not port.startswith(":"):
+ raise ValueError("Invalid IPv6 URL")
+ else:
+ hostname, _, port = hostname_and_port.partition(':')
+ _check_bracketed_host(hostname)
+
+# Valid bracketed hosts are defined in
+# https://www.rfc-editor.org/rfc/rfc3986#page-49 and https://url.spec.whatwg.org/
+def _check_bracketed_host(hostname):
+ if hostname.startswith('v'):
+ if not re.match(r"\Av[a-fA-F0-9]+\..+\Z", hostname):
+ raise ValueError(f"IPvFuture address is invalid")
+ else:
+ ip = ip_address(hostname) # Throws Value Error if not IPv6 or IPv4
+ if isinstance(ip, IPv4Address):
+ raise ValueError(f"An IPv4 address cannot be in brackets")
+
+# typed=True avoids BytesWarnings being emitted during cache key
+# comparison since this API supports both bytes and str input.
+def urlsplit(url, scheme='', allow_fragments=True):
+ """Parse a URL into 5 components:
+ :///?#
+
+ The result is a named 5-tuple with fields corresponding to the
+ above. It is either a SplitResult or SplitResultBytes object,
+ depending on the type of the url parameter.
+
+ The username, password, hostname, and port sub-components of netloc
+ can also be accessed as attributes of the returned object.
+
+ The scheme argument provides the default value of the scheme
+ component when no scheme is found in url.
+
+ If allow_fragments is False, no attempt is made to separate the
+ fragment component from the previous component, which can be either
+ path or query.
+
+ Note that % escapes are not expanded.
+ """
+
+ url, scheme, _coerce_result = _coerce_args(url, scheme)
+ # Only lstrip url as some applications rely on preserving trailing space.
+ # (https://url.spec.whatwg.org/#concept-basic-url-parser would strip both)
+ url = url.lstrip(_WHATWG_C0_CONTROL_OR_SPACE)
+ scheme = scheme.strip(_WHATWG_C0_CONTROL_OR_SPACE)
+
+ for b in _UNSAFE_URL_BYTES_TO_REMOVE:
+ url = url.replace(b, "")
+ scheme = scheme.replace(b, "")
+
+ allow_fragments = bool(allow_fragments)
+ netloc = query = fragment = ''
+ i = url.find(':')
+ if i > 0 and all(0 <= ord(v) < 255 for v in url[0]) and url[0].isalpha():
+ for c in url[:i]:
+ if c not in scheme_chars:
+ break
+ else:
+ scheme, url = url[:i].lower(), url[i+1:]
+ if url[:2] == '//':
+ netloc, url = _splitnetloc(url, 2)
+ if (('[' in netloc and ']' not in netloc) or
+ (']' in netloc and '[' not in netloc)):
+ raise ValueError("Invalid IPv6 URL")
+ if '[' in netloc and ']' in netloc:
+ _check_bracketed_netloc(netloc)
+ if allow_fragments and '#' in url:
+ url, fragment = url.split('#', 1)
+ if '?' in url:
+ url, query = url.split('?', 1)
+ _checknetloc(netloc)
+ v = SplitResult(scheme, netloc, url, query, fragment)
+ return _coerce_result(v)
+
+def urlunparse(components):
+ """Put a parsed URL back together again. This may result in a
+ slightly different, but equivalent URL, if the URL that was parsed
+ originally had redundant delimiters, e.g. a ? with an empty query
+ (the draft states that these are equivalent)."""
+ scheme, netloc, url, params, query, fragment, _coerce_result = (
+ _coerce_args(*components))
+ if params:
+ url = "%s;%s" % (url, params)
+ return _coerce_result(urlunsplit((scheme, netloc, url, query, fragment)))
+
+def urlunsplit(components):
+ """Combine the elements of a tuple as returned by urlsplit() into a
+ complete URL as a string. The data argument can be any five-item iterable.
+ This may result in a slightly different, but equivalent URL, if the URL that
+ was parsed originally had unnecessary delimiters (for example, a ? with an
+ empty query; the RFC states that these are equivalent)."""
+ scheme, netloc, url, query, fragment, _coerce_result = (
+ _coerce_args(*components))
+ if netloc:
+ if url and url[:1] != '/': url = '/' + url
+ url = '//' + netloc + url
+ elif url[:2] == '//':
+ url = '//' + url
+ elif scheme and scheme in uses_netloc and (not url or url[:1] == '/'):
+ url = '//' + url
+ if scheme:
+ url = scheme + ':' + url
+ if query:
+ url = url + '?' + query
+ if fragment:
+ url = url + '#' + fragment
+ return _coerce_result(url)
+
+def urljoin(base, url, allow_fragments=True):
+ """Join a base URL and a possibly relative URL to form an absolute
+ interpretation of the latter."""
+ if not base:
+ return url
+ if not url:
+ return base
+
+ base, url, _coerce_result = _coerce_args(base, url)
+ bscheme, bnetloc, bpath, bparams, bquery, bfragment = \
+ urlparse(base, '', allow_fragments)
+ scheme, netloc, path, params, query, fragment = \
+ urlparse(url, bscheme, allow_fragments)
+
+ if scheme != bscheme or scheme not in uses_relative:
+ return _coerce_result(url)
+ if scheme in uses_netloc:
+ if netloc:
+ return _coerce_result(urlunparse((scheme, netloc, path,
+ params, query, fragment)))
+ netloc = bnetloc
+
+ if not path and not params:
+ path = bpath
+ params = bparams
+ if not query:
+ query = bquery
+ return _coerce_result(urlunparse((scheme, netloc, path,
+ params, query, fragment)))
+
+ base_parts = bpath.split('/')
+ if base_parts[-1] != '':
+ # the last item is not a directory, so will not be taken into account
+ # in resolving the relative path
+ del base_parts[-1]
+
+ # for rfc3986, ignore all base path should the first character be root.
+ if path[:1] == '/':
+ segments = path.split('/')
+ else:
+ segments = base_parts + path.split('/')
+ # filter out elements that would cause redundant slashes on re-joining
+ # the resolved_path
+ segments[1:-1] = filter(None, segments[1:-1])
+
+ resolved_path = []
+
+ for seg in segments:
+ if seg == '..':
+ try:
+ resolved_path.pop()
+ except IndexError:
+ # ignore any .. segments that would otherwise cause an IndexError
+ # when popped from resolved_path if resolving for rfc3986
+ pass
+ elif seg == '.':
+ continue
+ else:
+ resolved_path.append(seg)
+
+ if segments[-1] in ('.', '..'):
+ # do some post-processing here. if the last segment was a relative dir,
+ # then we need to append the trailing '/'
+ resolved_path.append('')
+
+ return _coerce_result(urlunparse((scheme, netloc, '/'.join(
+ resolved_path) or '/', params, query, fragment)))
+
+
+def urldefrag(url):
+ """Removes any existing fragment from URL.
+
+ Returns a tuple of the defragmented URL and the fragment. If
+ the URL contained no fragments, the second element is the
+ empty string.
+ """
+ url, _coerce_result = _coerce_args(url)
+ if '#' in url:
+ s, n, p, a, q, frag = urlparse(url)
+ defrag = urlunparse((s, n, p, a, q, ''))
+ else:
+ frag = ''
+ defrag = url
+ return _coerce_result(DefragResult(defrag, frag))
+
+_hexdig = '0123456789ABCDEFabcdef'
+_hextobyte = None
+
+def unquote_to_bytes(string):
+ """unquote_to_bytes('abc%20def') -> b'abc def'."""
+ return bytes(_unquote_impl(string))
+
+def _unquote_impl(string: bytes | bytearray | str) -> bytes | bytearray:
+ # Note: strings are encoded as UTF-8. This is only an issue if it contains
+ # unescaped non-ASCII characters, which URIs should not.
+ if not string:
+ # Is it a string-like object?
+ string.split
+ return b''
+ if isinstance(string, str):
+ string = string.encode('utf-8')
+ bits = string.split(b'%')
+ if len(bits) == 1:
+ return string
+ res = bytearray(bits[0])
+ append = res.extend
+ # Delay the initialization of the table to not waste memory
+ # if the function is never called
+ global _hextobyte
+ if _hextobyte is None:
+ _hextobyte = {(a + b).encode(): bytes.fromhex(a + b)
+ for a in _hexdig for b in _hexdig}
+ for item in bits[1:]:
+ try:
+ append(_hextobyte[item[:2]])
+ append(item[2:])
+ except KeyError:
+ append(b'%')
+ append(item)
+ return res
+
+class ASCIIRE1:
+ def finditer(content: str):
+ o = MATCHVIR_ASCIIRE1(0)
+ for i,v in enumerate(content):
+ if 0 <= ord(v) < 128:
+ if not o.o:
+ o = MATCHVIR_ASCIIRE1(i)
+ o.o += v
+ else:
+ yield o
+ o = MATCHVIR_ASCIIRE1(0)
+ if o.o:
+ yield o
+class MATCHVIR_ASCIIRE1:
+ def __init__(self, start: int):
+ self.start = start
+ self.o = ""
+ def span(self):
+ return (self.start, self.start+len(self.o))
+
+ def __getitem__(self, _):
+ return self.o
+
+# _asciire = re.compile('([\x00-\x7f]+)')
+_asciire = ASCIIRE1()
+
+def _generate_unquoted_parts(string, encoding, errors):
+ previous_match_end = 0
+ for ascii_match in _asciire.finditer(string):
+ start, end = ascii_match.span()
+ yield string[previous_match_end:start] # Non-ASCII
+ # The ascii_match[1] group == string[start:end].
+ yield _unquote_impl(ascii_match[1]).decode(encoding, errors)
+ previous_match_end = end
+ yield string[previous_match_end:] # Non-ASCII tail
+
+def unquote(string, encoding='utf-8', errors='replace'):
+ """Replace %xx escapes by their single-character equivalent. The optional
+ encoding and errors parameters specify how to decode percent-encoded
+ sequences into Unicode characters, as accepted by the bytes.decode()
+ method.
+ By default, percent-encoded sequences are decoded with UTF-8, and invalid
+ sequences are replaced by a placeholder character.
+
+ unquote('abc%20def') -> 'abc def'.
+ """
+ if isinstance(string, bytes):
+ return _unquote_impl(string).decode(encoding, errors)
+ if '%' not in string:
+ # Is it a string-like object?
+ string.split
+ return string
+ if encoding is None:
+ encoding = 'utf-8'
+ if errors is None:
+ errors = 'replace'
+ return ''.join(_generate_unquoted_parts(string, encoding, errors))
+
+
+def parse_qs(qs, keep_blank_values=False, strict_parsing=False,
+ encoding='utf-8', errors='replace', max_num_fields=None, separator='&'):
+ """Parse a query given as a string argument.
+
+ Arguments:
+
+ qs: percent-encoded query string to be parsed
+
+ keep_blank_values: flag indicating whether blank values in
+ percent-encoded queries should be treated as blank strings.
+ A true value indicates that blanks should be retained as
+ blank strings. The default false value indicates that
+ blank values are to be ignored and treated as if they were
+ not included.
+
+ strict_parsing: flag indicating what to do with parsing errors.
+ If false (the default), errors are silently ignored.
+ If true, errors raise a ValueError exception.
+
+ encoding and errors: specify how to decode percent-encoded sequences
+ into Unicode characters, as accepted by the bytes.decode() method.
+
+ max_num_fields: int. If set, then throws a ValueError if there
+ are more than n fields read by parse_qsl().
+
+ separator: str. The symbol to use for separating the query arguments.
+ Defaults to &.
+
+ Returns a dictionary.
+ """
+ parsed_result = {}
+ pairs = parse_qsl(qs, keep_blank_values, strict_parsing,
+ encoding=encoding, errors=errors,
+ max_num_fields=max_num_fields, separator=separator)
+ for name, value in pairs:
+ if name in parsed_result:
+ parsed_result[name].append(value)
+ else:
+ parsed_result[name] = [value]
+ return parsed_result
+
+
+def parse_qsl(qs, keep_blank_values=False, strict_parsing=False,
+ encoding='utf-8', errors='replace', max_num_fields=None, separator='&'):
+ """Parse a query given as a string argument.
+
+ Arguments:
+
+ qs: percent-encoded query string to be parsed
+
+ keep_blank_values: flag indicating whether blank values in
+ percent-encoded queries should be treated as blank strings.
+ A true value indicates that blanks should be retained as blank
+ strings. The default false value indicates that blank values
+ are to be ignored and treated as if they were not included.
+
+ strict_parsing: flag indicating what to do with parsing errors. If
+ false (the default), errors are silently ignored. If true,
+ errors raise a ValueError exception.
+
+ encoding and errors: specify how to decode percent-encoded sequences
+ into Unicode characters, as accepted by the bytes.decode() method.
+
+ max_num_fields: int. If set, then throws a ValueError
+ if there are more than n fields read by parse_qsl().
+
+ separator: str. The symbol to use for separating the query arguments.
+ Defaults to &.
+
+ Returns a list, as G-d intended.
+ """
+
+ if not separator or not isinstance(separator, (str, bytes)):
+ raise ValueError("Separator must be of type string or bytes.")
+ if isinstance(qs, str):
+ if not isinstance(separator, str):
+ separator = str(separator, 'ascii')
+ eq = '='
+ def _unquote(s):
+ return unquote_plus(s, encoding=encoding, errors=errors)
+ else:
+ if not qs:
+ return []
+ # Use memoryview() to reject integers and iterables,
+ # acceptable by the bytes constructor.
+ qs = bytes(memoryview(qs))
+ if isinstance(separator, str):
+ separator = bytes(separator, 'ascii')
+ eq = b'='
+ def _unquote(s):
+ return unquote_to_bytes(s.replace(b'+', b' '))
+
+ if not qs:
+ return []
+
+ # If max_num_fields is defined then check that the number of fields
+ # is less than max_num_fields. This prevents a memory exhaustion DOS
+ # attack via post bodies with many fields.
+ if max_num_fields is not None:
+ num_fields = 1 + qs.count(separator)
+ if max_num_fields < num_fields:
+ raise ValueError('Max number of fields exceeded')
+
+ r = []
+ for name_value in qs.split(separator):
+ if name_value or strict_parsing:
+ name, has_eq, value = name_value.partition(eq)
+ if not has_eq and strict_parsing:
+ raise ValueError("bad query field: %r" % (name_value,))
+ if value or keep_blank_values:
+ name = _unquote(name)
+ value = _unquote(value)
+ r.append((name, value))
+ return r
+
+def unquote_plus(string, encoding='utf-8', errors='replace'):
+ """Like unquote(), but also replace plus signs by spaces, as required for
+ unquoting HTML form values.
+
+ unquote_plus('%7e/abc+def') -> '~/abc def'
+ """
+ string = string.replace('+', ' ')
+ return unquote(string, encoding, errors)
+
+_ALWAYS_SAFE = frozenset(b'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
+ b'abcdefghijklmnopqrstuvwxyz'
+ b'0123456789'
+ b'_.-~')
+_ALWAYS_SAFE_BYTES = bytes(_ALWAYS_SAFE)
+
+def __getattr__(name):
+ if name == 'Quoter':
+ return _Quoter
+ raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
+
+class _Quoter(dict):
+ """A mapping from bytes numbers (in range(0,256)) to strings.
+
+ String values are percent-encoded byte values, unless the key < 128, and
+ in either of the specified safe set, or the always safe set.
+ """
+ # Keeps a cache internally, via __missing__, for efficiency (lookups
+ # of cached keys don't call Python code at all).
+ def __init__(self, safe):
+ """safe: bytes object."""
+ self.safe = _ALWAYS_SAFE.union(safe)
+
+ def __repr__(self):
+ return f""
+
+ def __missing__(self, b):
+ # Handle a cache miss. Store quoted string in cache and return.
+ res = chr(b) if b in self.safe else '%{:02X}'.format(b)
+ self[b] = res
+ return res
+
+def quote(string, safe='/', encoding=None, errors=None):
+ """quote('abc def') -> 'abc%20def'
+
+ Each part of a URL, e.g. the path info, the query, etc., has a
+ different set of reserved characters that must be quoted. The
+ quote function offers a cautious (not minimal) way to quote a
+ string for most of these parts.
+
+ RFC 3986 Uniform Resource Identifier (URI): Generic Syntax lists
+ the following (un)reserved characters.
+
+ unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
+ reserved = gen-delims / sub-delims
+ gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@"
+ sub-delims = "!" / "$" / "&" / "'" / "(" / ")"
+ / "*" / "+" / "," / ";" / "="
+
+ Each of the reserved characters is reserved in some component of a URL,
+ but not necessarily in all of them.
+
+ The quote function %-escapes all characters that are neither in the
+ unreserved chars ("always safe") nor the additional chars set via the
+ safe arg.
+
+ The default for the safe arg is '/'. The character is reserved, but in
+ typical usage the quote function is being called on a path where the
+ existing slash characters are to be preserved.
+
+ Python 3.7 updates from using RFC 2396 to RFC 3986 to quote URL strings.
+ Now, "~" is included in the set of unreserved characters.
+
+ string and safe may be either str or bytes objects. encoding and errors
+ must not be specified if string is a bytes object.
+
+ The optional encoding and errors parameters specify how to deal with
+ non-ASCII characters, as accepted by the str.encode method.
+ By default, encoding='utf-8' (characters are encoded with UTF-8), and
+ errors='strict' (unsupported characters raise a UnicodeEncodeError).
+ """
+ if isinstance(string, str):
+ if not string:
+ return string
+ if encoding is None:
+ encoding = 'utf-8'
+ if errors is None:
+ errors = 'strict'
+ string = string.encode(encoding, errors)
+ else:
+ if encoding is not None:
+ raise TypeError("quote() doesn't support 'encoding' for bytes")
+ if errors is not None:
+ raise TypeError("quote() doesn't support 'errors' for bytes")
+ return quote_from_bytes(string, safe)
+
+def quote_plus(string, safe='', encoding=None, errors=None):
+ """Like quote(), but also replace ' ' with '+', as required for quoting
+ HTML form values. Plus signs in the original string are escaped unless
+ they are included in safe. It also does not have safe default to '/'.
+ """
+ # Check if ' ' in string, where string may either be a str or bytes. If
+ # there are no spaces, the regular quote will produce the right answer.
+ if ((isinstance(string, str) and ' ' not in string) or
+ (isinstance(string, bytes) and b' ' not in string)):
+ return quote(string, safe, encoding, errors)
+ if isinstance(safe, str):
+ space = ' '
+ else:
+ space = b' '
+ string = quote(string, safe + space, encoding, errors)
+ return string.replace(' ', '+')
+
+# Expectation: A typical program is unlikely to create more than 5 of these.
+def _byte_quoter_factory(safe):
+ return _Quoter(safe).__getitem__
+
+def quote_from_bytes(bs, safe='/'):
+ """Like quote(), but accepts a bytes object rather than a str, and does
+ not perform string-to-bytes encoding. It always returns an ASCII string.
+ quote_from_bytes(b'abc def\x3f') -> 'abc%20def%3f'
+ """
+ if not isinstance(bs, (bytes, bytearray)):
+ raise TypeError("quote_from_bytes() expected bytes")
+ if not bs:
+ return ''
+ if isinstance(safe, str):
+ # Normalize 'safe' by converting to bytes and removing non-ASCII chars
+ safe = safe.encode('ascii', 'ignore')
+ else:
+ # List comprehensions are faster than generator expressions.
+ safe = bytes([c for c in safe if c < 128])
+ if not bs.rstrip(_ALWAYS_SAFE_BYTES + safe):
+ return bs.decode()
+ quoter = _byte_quoter_factory(safe)
+ if (bs_len := len(bs)) < 200_000:
+ return ''.join(map(quoter, bs))
+ else:
+ # This saves memory - https://github.com/python/cpython/issues/95865
+ chunk_size = math.isqrt(bs_len)
+ chunks = [''.join(map(quoter, bs[i:i+chunk_size]))
+ for i in range(0, bs_len, chunk_size)]
+ return ''.join(chunks)
+
+def urlencode(query, doseq=False, safe='', encoding=None, errors=None,
+ quote_via=quote_plus):
+ """Encode a dict or sequence of two-element tuples into a URL query string.
+
+ If any values in the query arg are sequences and doseq is true, each
+ sequence element is converted to a separate parameter.
+
+ If the query arg is a sequence of two-element tuples, the order of the
+ parameters in the output will match the order of parameters in the
+ input.
+
+ The components of a query arg may each be either a string or a bytes type.
+
+ The safe, encoding, and errors parameters are passed down to the function
+ specified by quote_via (encoding and errors only if a component is a str).
+ """
+
+ if hasattr(query, "items"):
+ query = query.items()
+ else:
+ # It's a bother at times that strings and string-like objects are
+ # sequences.
+ try:
+ # non-sequence items should not work with len()
+ # non-empty strings will fail this
+ if len(query) and not isinstance(query[0], tuple):
+ raise TypeError
+ # Zero-length sequences of all types will get here and succeed,
+ # but that's a minor nit. Since the original implementation
+ # allowed empty dicts that type of behavior probably should be
+ # preserved for consistency
+ except TypeError as err:
+ raise TypeError("not a valid non-string sequence "
+ "or mapping object") from err
+
+ l = []
+ if not doseq:
+ for k, v in query:
+ if isinstance(k, bytes):
+ k = quote_via(k, safe)
+ else:
+ k = quote_via(str(k), safe, encoding, errors)
+
+ if isinstance(v, bytes):
+ v = quote_via(v, safe)
+ else:
+ v = quote_via(str(v), safe, encoding, errors)
+ l.append(k + '=' + v)
+ else:
+ for k, v in query:
+ if isinstance(k, bytes):
+ k = quote_via(k, safe)
+ else:
+ k = quote_via(str(k), safe, encoding, errors)
+
+ if isinstance(v, bytes):
+ v = quote_via(v, safe)
+ l.append(k + '=' + v)
+ elif isinstance(v, str):
+ v = quote_via(v, safe, encoding, errors)
+ l.append(k + '=' + v)
+ else:
+ try:
+ # Is this a sufficient test for sequence-ness?
+ x = len(v)
+ except TypeError:
+ # not a sequence
+ v = quote_via(str(v), safe, encoding, errors)
+ l.append(k + '=' + v)
+ else:
+ # loop over the sequence
+ for elt in v:
+ if isinstance(elt, bytes):
+ elt = quote_via(elt, safe)
+ else:
+ elt = quote_via(str(elt), safe, encoding, errors)
+ l.append(k + '=' + elt)
+ return '&'.join(l)
+
+
+def to_bytes(url):
+ return _to_bytes(url)
+
+
+def _to_bytes(url):
+ """to_bytes(u"URL") --> 'URL'."""
+ # Most URL schemes require ASCII. If that changes, the conversion
+ # can be relaxed.
+ # XXX get rid of to_bytes()
+ if isinstance(url, str):
+ try:
+ url = url.encode("ASCII").decode()
+ except UnicodeError:
+ raise UnicodeError("URL " + repr(url) +
+ " contains non-ASCII characters")
+ return url
+
+
+def unwrap(url):
+ """Transform a string like '' into 'scheme://host/path'.
+
+ The string is returned unchanged if it's not a wrapped URL.
+ """
+ url = str(url).strip()
+ if url[:1] == '<' and url[-1:] == '>':
+ url = url[1:-1].strip()
+ if url[:4] == 'URL:':
+ url = url[4:].strip()
+ return url
+
+
+def splittype(url):
+ return _splittype(url)
+
+
+_typeprog = None
+def _splittype(url):
+ """splittype('type:opaquestring') --> 'type', 'opaquestring'."""
+ global _typeprog
+ if _typeprog is None:
+ _typeprog = re.compile('([^/:]+):(.*)', re.DOTALL)
+
+ match = _typeprog.match(url)
+ if match:
+ scheme, data = match.groups()
+ return scheme.lower(), data
+ return None, url
+
+
+def splithost(url):
+ return _splithost(url)
+
+
+_hostprog = None
+def _splithost(url):
+ """splithost('//host[:port]/path') --> 'host[:port]', '/path'."""
+ global _hostprog
+ if _hostprog is None:
+ _hostprog = re.compile('//([^/#?]*)(.*)', re.DOTALL)
+
+ match = _hostprog.match(url)
+ if match:
+ host_port, path = match.groups()
+ if path and path[0] != '/':
+ path = '/' + path
+ return host_port, path
+ return None, url
+
+
+def splituser(host):
+ return _splituser(host)
+
+
+def _splituser(host):
+ """splituser('user[:passwd]@host[:port]') --> 'user[:passwd]', 'host[:port]'."""
+ user, delim, host = host.rpartition('@')
+ return (user if delim else None), host
+
+
+def splitpasswd(user):
+ return _splitpasswd(user)
+
+
+def _splitpasswd(user):
+ """splitpasswd('user:passwd') -> 'user', 'passwd'."""
+ user, delim, passwd = user.partition(':')
+ return user, (passwd if delim else None)
+
+
+def splitport(host):
+ return _splitport(host)
+
+
+# splittag('/path#tag') --> '/path', 'tag'
+_portprog = None
+def _splitport(host):
+ """splitport('host:port') --> 'host', 'port'."""
+ global _portprog
+ if _portprog is None:
+ _portprog = re.compile('(.*):([0-9]*)', re.DOTALL)
+
+ match = _portprog.fullmatch(host)
+ if match:
+ host, port = match.groups()
+ if port:
+ return host, port
+ return host, None
+
+
+def splitnport(host, defport=-1):
+ return _splitnport(host, defport)
+
+
+def _splitnport(host, defport=-1):
+ """Split host and port, returning numeric port.
+ Return given default port if no ':' found; defaults to -1.
+ Return numerical port if a valid number is found after ':'.
+ Return None if ':' but not a valid number."""
+ host, delim, port = host.rpartition(':')
+ if not delim:
+ host = port
+ elif port:
+ if port.isdigit() and all(0 <= ord(v) < 255 for v in port):
+ nport = int(port)
+ else:
+ nport = None
+ return host, nport
+ return host, defport
+
+
+def splitquery(url):
+ return _splitquery(url)
+
+
+def _splitquery(url):
+ """splitquery('/path?query') --> '/path', 'query'."""
+ path, delim, query = url.rpartition('?')
+ if delim:
+ return path, query
+ return url, None
+
+
+def splittag(url):
+ return _splittag(url)
+
+
+def _splittag(url):
+ """splittag('/path#tag') --> '/path', 'tag'."""
+ path, delim, tag = url.rpartition('#')
+ if delim:
+ return path, tag
+ return url, None
+
+
+def splitattr(url):
+ return _splitattr(url)
+
+
+def _splitattr(url):
+ """splitattr('/path;attr1=value1;attr2=value2;...') ->
+ '/path', ['attr1=value1', 'attr2=value2', ...]."""
+ words = url.split(';')
+ return words[0], words[1:]
+
+
+def splitvalue(attr):
+ return _splitvalue(attr)
+
+
+def _splitvalue(attr):
+ """splitvalue('attr=value') --> 'attr', 'value'."""
+ attr, delim, value = attr.partition('=')
+ return attr, (value if delim else None)
diff --git a/peppy-poppies/frontend/captcha/pyscript.toml b/peppy-poppies/frontend/captcha/pyscript.toml
new file mode 100644
index 00000000..404d7de7
--- /dev/null
+++ b/peppy-poppies/frontend/captcha/pyscript.toml
@@ -0,0 +1,12 @@
+packages = [
+ "https://cdn.holoviz.org/panel/wheels/bokeh-3.7.3-py3-none-any.whl",
+ "https://cdn.holoviz.org/panel/wheels/panel-1.7.5-py3-none-any.whl",
+ "param==2.2.1",
+ "pyyaml==6.0.2",
+ "numpy",
+ "xyzservices",
+ "jinja2",
+ "packaging",
+ "pyviz-comms",
+ "pandas",
+]
diff --git a/peppy-poppies/frontend/captcha/runner.js b/peppy-poppies/frontend/captcha/runner.js
new file mode 100644
index 00000000..b028b205
--- /dev/null
+++ b/peppy-poppies/frontend/captcha/runner.js
@@ -0,0 +1,46 @@
+console.log("[Worker] Downloading pyodide");
+import { loadPyodide } from "https://cdn.jsdelivr.net/pyodide/v0.28.1/full/pyodide.mjs";
+console.log("[Worker] Loading pyodide");
+let pyodide = await loadPyodide();
+console.log("[Worker] Complete pyodide load");
+self.onmessage = function (e) {
+ let json_value = JSON.parse(e.data);
+ console.log(json_value);
+ let code = json_value["code"];
+ let test_value = json_value["task"];
+ const dict = pyodide.globals.get("dict"); // https://github.com/pyodide/pyodide/issues/703#issuecomment-1937774811
+ const globals = dict();
+ pyodide.runPython(
+ `
+def reformat_exc():
+ import sys
+ from traceback import format_exception
+ return "".join(format_exception(sys.last_type, sys.last_value, sys.last_traceback))
+`,
+ { globals, locals: globals },
+ );
+ postMessage("start;0");
+ let results = [];
+ try {
+ pyodide.runPython(code, { globals, locals: globals });
+ postMessage("load;0");
+ for (let i = 0; i < test_value.length; i++) {
+ let value = test_value[i];
+ let result = pyodide.runPython(`str(calc(${value}))`, {
+ globals,
+ locals: globals,
+ });
+ results.push(result);
+ postMessage(`run;${i}`);
+ }
+ } catch (error) {
+ const message = globals.get("reformat_exc")();
+ console.log(message);
+ postMessage(`error;{message}`);
+ return;
+ }
+ globals.destroy();
+ dict.destroy();
+ postMessage(`result;${JSON.stringify(results)}`);
+};
+postMessage("pyodide-loaded;0");
diff --git a/peppy-poppies/frontend/demo/app.js b/peppy-poppies/frontend/demo/app.js
new file mode 100644
index 00000000..8c0da02d
--- /dev/null
+++ b/peppy-poppies/frontend/demo/app.js
@@ -0,0 +1,169 @@
+// Modern JavaScript with functional programming approach
+// API Configuration
+const API_BASE_URL = "[domain]";
+
+// Application State
+let appState = {
+ captchaCompleted: false,
+};
+
+const NOT_LOGIN = ' Sign In to Account';
+const LOGIN_SUCCESS = ' Logged In';
+const LOGGING_IN =
+ ' Logging in...';
+const SIGN_OUT = ' Sign Out';
+
+// DOM Elements Cache
+const elements = {
+ pythonCaptchaDiv: () => document.getElementById("python-captcha"),
+ loginBtn: () => document.getElementById("loginBtn"),
+ signoutBtn: () => document.getElementById("signoutBtn"),
+ usernameInput: () => document.getElementById("username"),
+ passwordInput: () => document.getElementById("password"),
+ captchaIframe: () => document.getElementById("captchaFrame"),
+ statusIndicator: () => document.getElementById("statusIndicator"),
+};
+
+const showStatus = (message, type = "info") => {
+ console.log(`${type}: ${message}`);
+ const indicator = elements.statusIndicator();
+ if (!indicator) return;
+
+ indicator.textContent = message;
+ indicator.className = `status-indicator status-${type}`;
+ indicator.style.display = "block";
+
+ // Auto-hide after 3 seconds for non-error messages
+ if (type !== "error") {
+ setTimeout(() => {
+ indicator.style.display = "none";
+ }, 3000);
+ }
+};
+
+// Login Management
+const handleLogin = async () => {
+ const usernameInput = elements.usernameInput();
+ const passwordInput = elements.passwordInput();
+ const loginBtn = elements.loginBtn();
+
+ const username = usernameInput ? usernameInput.value.trim() : "";
+ const password = passwordInput ? passwordInput.value.trim() : "";
+
+ if (!username || !password) {
+ showStatus("Please fill in all fields.", "error");
+ return;
+ }
+
+ if (!appState.captchaCompleted) {
+ showStatus("Please complete the Python challenge first.", "error");
+ return;
+ }
+
+ // Simulate login process
+ if (loginBtn) {
+ loginBtn.disabled = true;
+ loginBtn.innerHTML = LOGGING_IN;
+ }
+ const splitted = document.cookie.split(";");
+ let result = "";
+ for (let i = 0; i < splitted.length; i++) {
+ const item = splitted[i].trimStart(" ");
+ if (item.startsWith("CODECAPTCHA_JWT=")) {
+ result = item.substring("CODECAPTCHA_JWT=".length, item.length);
+ }
+ }
+
+ resp = await fetch("/api/auth/login", {
+ method: "POST",
+ body: JSON.stringify({
+ username: username,
+ password: password,
+ captcha_jwt: result,
+ }),
+ });
+ document.cookie = "CODECAPTCHA_JWT=false;Max-Age=0; path=/"; // since single use anyway
+ if (resp.ok) {
+ showStatus("🎉 Login successful! Welcome back.", "success");
+ if (loginBtn) {
+ loginBtn.innerHTML = LOGIN_SUCCESS;
+ loginBtn.className = "btn btn-success";
+ }
+ await hideIFrame();
+ elements.signoutBtn().classList.remove("hidden");
+ } else {
+ showStatus(
+ "Login failed! You need to solve the capture again.",
+ "error",
+ );
+ await captchaReset();
+ }
+};
+
+const handleSignout = async () => {
+ elements.signoutBtn().classList.add("hidden");
+ await fetch("/api/auth/logout");
+ const loginBtn = elements.loginBtn();
+ loginBtn.innerHTML = NOT_LOGIN;
+ loginBtn.className = "btn btn-primary";
+ await captchaReset();
+};
+
+const hideIFrame = async () => {
+ const div = elements.pythonCaptchaDiv();
+ div.classList.add("hidden");
+};
+
+const configIFrame = async () => {
+ showStatus("Loading challenge...", "info");
+ const resp = await fetch("/api/auth/get-challenge"); // Get a challenge ID from website server
+ const json = await resp.json();
+ const challenge_id = json.challenge_id;
+ const element = elements.captchaIframe();
+ const url = element.getAttribute("data-src");
+ element.src = url.replace("[challenge_id]", challenge_id);
+ const div = elements.pythonCaptchaDiv();
+ div.classList.remove("hidden");
+};
+
+// Initialize Application
+const initApp = async () => {
+ resp = await fetch("/api/auth/me");
+ if (resp.ok) {
+ elements.signoutBtn().classList.remove("hidden");
+ const loginBtn = elements.loginBtn();
+ loginBtn.innerHTML = LOGIN_SUCCESS;
+ loginBtn.className = "btn btn-success";
+ } else {
+ await configIFrame();
+ }
+ console.log("Completed init");
+};
+
+const captchaReset = async () => {
+ elements.loginBtn().innerHTML = NOT_LOGIN;
+ loginBtn.className = "btn btn-primary";
+ appState.captchaCompleted = false;
+ elements.loginBtn().disabled = true;
+ await configIFrame();
+};
+
+const captchaCompletionListener = (e) => {
+ console.log(`Received message from ${e.origin}: ${e.data}`);
+ window.handle_message(e);
+ if (e.origin !== window.origin) {
+ return;
+ }
+ if (e.data === "captchaCompleted") {
+ appState.captchaCompleted = true;
+ elements.loginBtn().disabled = false;
+ }
+};
+
+// Global Functions for HTML onclick handlers
+window.handleLogin = handleLogin;
+window.handleSignout = handleSignout;
+
+// Initialize when DOM is loaded
+document.addEventListener("DOMContentLoaded", initApp);
+window.addEventListener("message", captchaCompletionListener);
diff --git a/peppy-poppies/frontend/demo/index.html b/peppy-poppies/frontend/demo/index.html
new file mode 100644
index 00000000..b74619ab
--- /dev/null
+++ b/peppy-poppies/frontend/demo/index.html
@@ -0,0 +1,124 @@
+
+
+
+
+
+ Secure Login with Python Captcha
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+