From 7a060cbc2c69562f7e5b721332bcd623ca016c50 Mon Sep 17 00:00:00 2001 From: Aditeya Baral Date: Sun, 28 Jul 2024 23:02:21 +0530 Subject: [PATCH] Deprecated selenium support, updated README and added documentation to code (#9) * Removed selenium and updated README * Updated Dockerfile --- .github/workflows/docker.yml | 12 +- .github/workflows/flake8.yml | 34 ++--- .gitignore | 5 +- Dockerfile | 14 -- README.md | 102 ++++++++----- app/app.py | 28 ++-- app/pesu.py | 273 ++++++++++------------------------- requirements.txt | 85 +++++------ 8 files changed, 219 insertions(+), 334 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f105a1f..9d28de6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,6 +1,6 @@ name: Docker Image Build -on: [push, pull_request] +on: [ push, pull_request ] jobs: @@ -9,8 +9,8 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Build the Docker image - run: docker build . --tag pesu-auth - - name: Spawn a container - run: docker run --name pesu-auth -d -p 5000:5000 pesu-auth + - uses: actions/checkout@v3 + - name: Build the Docker image + run: docker build . --tag pesu-auth + - name: Spawn a container + run: docker run --name pesu-auth -d -p 5000:5000 pesu-auth diff --git a/.github/workflows/flake8.yml b/.github/workflows/flake8.yml index e3a5e5d..31497bb 100644 --- a/.github/workflows/flake8.yml +++ b/.github/workflows/flake8.yml @@ -1,6 +1,6 @@ name: Python Version Compatibility -on: [push, pull_request] +on: [ push, pull_request ] jobs: build-linux: @@ -8,21 +8,21 @@ jobs: strategy: max-parallel: 5 matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: [ "3.9", "3.10", "3.11", "3.12" ] steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - pip install -r requirements.txt - - name: Lint with flake8 - run: | - pip install flake8 - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics \ No newline at end of file + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pip install -r requirements.txt + - name: Lint with flake8 + run: | + pip install flake8 + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1d034cc..4be6d1c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,7 @@ images/ *.log README.html README_tmp* -.venv \ No newline at end of file +.venv +pyrightconfig.json +*.bru +bruno.json diff --git a/Dockerfile b/Dockerfile index 0b5b769..bf7c5ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,5 @@ FROM python:3.10.11-bullseye -RUN apt update -y && apt upgrade -y -RUN apt install wget unzip - -ARG DEBIAN_FRONTEND=noninteractive -RUN wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -RUN apt install ./google-chrome-stable_current_amd64.deb -y -RUN rm ./google-chrome-stable_current_amd64.deb - -RUN pip install get-chrome-driver -RUN get-chrome-driver --auto-download --extract -RUN mv chromedriver/*/bin/chromedriver /usr/bin/chromedriver -RUN chmod +x /usr/bin/chromedriver -RUN rm -rf chromedriver/ - COPY app /app COPY README.md /README.md COPY requirements.txt /requirements.txt diff --git a/README.md b/README.md index c78f52b..2adcbea 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,53 @@ returns the user's profile information. > :warning: **Warning:** The live version is hosted on a free tier server, so you might experience some latency on the > first request since the server might not be awake. Subsequent requests will be faster. -# How to use pesu-auth +## How to run pesu-auth locally + +Running the PESUAuth API locally is simple. Clone the repository and follow the steps below to get started. + +### Running with Docker + +This is the easiest and recommended way to run the API locally. Ensure you have Docker installed on your system. Run the +following commands to start the API. + +1. Build the Docker image + + ```bash + docker build . --tag pesu-auth + ``` + +2. Run the Docker container + + ```bash + docker run --name pesu-auth -d -p 5000:5000 pesu-auth + ``` + +3. Access the API at `http://localhost:5000/` -## Non-Interactive Mode +### Running without Docker -This is the most common and recommended way to use the API. You can send a request to `/authenticate` endpoint with the -user's credentials and the API will return a JSON object, with the user's profile information if requested. +If you don't have Docker installed, you can run the API using Python. Ensure you have Python 3.8 or higher installed on +your system. + +1. Create a virtual environment using `conda` or any other virtual environment manager of your choice and activate it. + Then, install the dependencies using the following command. + + ```bash + pip install -r requirements.txt + ``` + +2. Run the API using the following command. + + ```bash + python app/app.py + ``` + +3. Access the API at `http://localhost:5000/` + +# How to use pesu-auth + +You can send a request to the `/authenticate` endpoint with the user's credentials and the API will return a JSON object, +with the user's profile information if requested. ### Request Parameters @@ -84,7 +125,7 @@ import requests data = { 'username': 'your SRN or PRN here', 'password': 'your password here', - 'profile': False # Optional, defaults to False + 'profile': True # Optional, defaults to False # Set to True if you want to retrieve the user's profile information } @@ -104,47 +145,28 @@ print(response.json()) "program": "Bachelor of Technology", "branch_short_code": "CSE", "branch": "Computer Science and Engineering", - "semester": "Sem-1", - "section": "Section A", + "semester": "NA", + "section": "NA", + "email": "johnnyblaze@gmail.com", + "phone": "1234567890", "campus_code": 1, "campus": "RR" }, - "know_your_class_and_section": { - "prn": "PES1201800001", - "srn": "PES1201800001", - "name": "Johnny Blaze", - "class": "Sem-1", - "section": "Section A", - "cycle": "NA", - "department": "CSE (RR Campus)", - "branch": "CSE", - "institute_name": "PES University (Ring Road)" - }, "message": "Login successful.", - "timestamp": "2023-06-18 20:57:59.979374+05:30" + "know_your_class_and_section": { + "prn": "PES1201800001", + "srn": "PES1201800001", + "name": "JOHNNY BLAZE", + "class": "", + "section": "", + "cycle": "NA", + "department": "", + "branch": "CSE", + "institute_name": "" + }, + "timestamp": "2024-07-28 22:30:10.103368+05:30" } ``` -## Interactive Mode - -You can also use interactive mode which will spawn a browser window and allow the user to sign in to PESU Academy. Send -a request to the `authenticateInteractive` endpoint with the `profile` query parameter set to `true` and the API will -return -a JSON object with the user's profile information. - -> :warning: **Warning:** This will only work if the API is running on the same server as the client. - -
Here is an example using Python - -#### Request - -```python -import requests - -response = requests.post("http://localhost:5000/authenticateInteractive?profile=true") -print(response.json()) -``` - -
\ No newline at end of file diff --git a/app/app.py b/app/app.py index d5a2417..153908d 100644 --- a/app/app.py +++ b/app/app.py @@ -23,6 +23,9 @@ def convert_readme_to_html(): + """ + Convert the README.md file to HTML and save it as README.html so that it can be rendered on the home page. + """ readme_content = open("README.md").read().strip() readme_content = re.sub(r":\w+: ", "", readme_content) with open("README_tmp.md", "w") as f: @@ -34,6 +37,9 @@ def convert_readme_to_html(): @app.route("/") def index(): + """ + Render the home page with the README.md content. + """ try: if "README.html" not in os.listdir(): convert_readme_to_html() @@ -48,6 +54,9 @@ def index(): @app.route("/authenticate", methods=["POST"]) def authenticate(): + """ + Authenticate the user with the provided username and password. + """ username = request.json.get("username") password = request.json.get("password") profile = request.json.get("profile", False) @@ -62,21 +71,10 @@ def authenticate(): return json.dumps(authentication_result), 200 # if either username or password is not provided, we return an error - return json.dumps({ - "status": False, - "message": "Username or password not provided." - }), 400 - - -@app.route("/authenticateInteractive", methods=["GET", "POST"]) -def authenticate_interactive(): - profile = request.args.get("profile", False) - if isinstance(profile, str): - profile = profile.lower() == "true" - current_time = datetime.datetime.now(IST) - authentication_result = pesu_academy.authenticate_selenium_interactive(profile) - authentication_result["timestamp"] = str(current_time) - return json.dumps(authentication_result), 200 + return ( + json.dumps({"status": False, "message": "Username or password not provided."}), + 400, + ) if __name__ == "__main__": diff --git a/app/pesu.py b/app/pesu.py index cd104d5..e14266a 100644 --- a/app/pesu.py +++ b/app/pesu.py @@ -1,25 +1,26 @@ import logging -import os import re -import time import traceback from datetime import datetime from typing import Any, Optional import requests_html from bs4 import BeautifulSoup -from selenium import webdriver -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as ec -from selenium.webdriver.support.wait import WebDriverWait class PESUAcademy: - def __init__(self): - self.chrome: Optional[webdriver.Chrome] = None - self.chrome_options: Optional[webdriver.ChromeOptions] = None + """ + Class to interact with the PESU Academy website. + """ - self.branch_short_code_map: dict[str, str] = { + @staticmethod + def map_branch_to_short_code(branch: str) -> Optional[str]: + """ + Map the branch name to its short code. + :param branch: Branch name + :return: Short code of the branch + """ + branch_short_code_map: dict[str, str] = { "Computer Science and Engineering": "CSE", "Electronics and Communication Engineering": "ECE", "Mechanical Engineering": "ME", @@ -27,91 +28,21 @@ def __init__(self): "Civil Engineering": "CE", "Biotechnology": "BT", } + return branch_short_code_map.get(branch) - def init_chrome(self, headless: bool = True): - logging.info(f"Initializing Chrome with headless={headless}") - self.chrome_options = webdriver.ChromeOptions() - - if headless: - self.chrome_options.add_argument("--headless") - - self.chrome_options.add_argument("--window-size=1920x1080") - self.chrome_options.add_argument("--no-sandbox") - self.chrome_options.add_argument("--disable-dev-shm-usage") - self.chrome_options.add_argument("--ignore-ssl-errors=yes") - self.chrome_options.add_argument("--ignore-certificate-errors") - self.chrome_options.add_argument("--allow-running-insecure-content") - self.chrome_options.add_argument("user-agent=Mozilla/5.0 (X11; Linux x86_64) " - "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.96 Safari/537.36") - - if os.path.expanduser("~").startswith("/bot"): - self.chrome_options.binary_location = "/bot/.apt/usr/bin/google-chrome" - self.chrome = webdriver.Chrome( - executable_path="bot/.chromedriver/bin/chromedriver", - options=self.chrome_options, - ) - else: - if "chromedriver" not in os.listdir(): - self.chrome = webdriver.Chrome(options=self.chrome_options) - else: - self.chrome = webdriver.Chrome( - executable_path="./chromedriver", options=self.chrome_options - ) - self.chrome.execute_cdp_cmd("Emulation.setTimezoneOverride", {"timezoneId": "Asia/Kolkata"}) - - def map_branch_to_short_code(self, branch: str) -> Optional[str]: - return self.branch_short_code_map.get(branch) - - def get_profile_information_from_selenium(self, username: Optional[str] = None) -> dict[str, Any]: - try: - logging.info("Navigating to profile data") - menu_options = self.chrome.find_elements(By.CLASS_NAME, "menu-name") - for option in menu_options: - if option.text == "My Profile": - option.click() - break - time.sleep(3) - self.chrome.implicitly_wait(3) - except Exception as e: - logging.error(f"Unable to find the profile button: {traceback.format_exc()}") - self.chrome.quit() - return {"status": False, "message": "Unable to find the profile button after login.", "error": str(e)} - - try: - logging.info("Fetching profile data from page") - profile = dict() - for field in self.chrome.find_elements(By.CLASS_NAME, "form-group")[6:13]: - if (text := field.text) and "\n" in text: - key, value = list(map(str.strip, text.split("\n"))) - key = "_".join(key.split()).lower() - if key in ["name", "srn", "pesu_id", "program", "branch", "semester", "section"]: - if key == "branch" and (branch_short_code := self.map_branch_to_short_code(value)): - profile["branch_short_code"] = branch_short_code - key = "prn" if key == "pesu_id" else key - profile[key] = value - - # if username starts with PES1, then he is from RR campus, else if it is PES2, then EC campus - key = username if username else profile["pesu_id"] - if campus_code_match := re.match(r"PES(\d)", key): - campus_code = campus_code_match.group(1) - profile["campus_code"] = int(campus_code) - profile["campus"] = "RR" if campus_code == "1" else "EC" - - logging.info("Profile data fetched successfully") - self.chrome.quit() - return {"status": True, "profile": profile, "message": "Login successful."} - except Exception as e: - logging.error(f"Unable to fetch profile data: {traceback.format_exc()}") - self.chrome.quit() - return {"status": False, "message": "Unable to fetch profile data.", "error": str(e)} - - def get_profile_information_from_requests( - self, - session: requests_html.HTMLSession, - username: Optional[str] = None + def get_profile_information( + self, session: requests_html.HTMLSession, username: Optional[str] = None ) -> dict[str, Any]: + """ + Get the profile information of the user. + :param session: The session object + :param username: The username of the user + :return: The profile information + """ try: - profile_url = "https://www.pesuacademy.com/Academy/s/studentProfilePESUAdmin" + profile_url = ( + "https://www.pesuacademy.com/Academy/s/studentProfilePESUAdmin" + ) query = { "menuId": "670", "url": "studentProfilePESUAdmin", @@ -128,7 +59,11 @@ def get_profile_information_from_requests( except Exception as e: logging.error(f"Unable to fetch profile data: {traceback.format_exc()}") - return {"status": False, "message": "Unable to fetch profile data.", "error": str(e)} + return { + "status": False, + "message": "Unable to fetch profile data.", + "error": str(e), + } profile = dict() for element in soup.find_all("div", attrs={"class": "form-group"})[:7]: @@ -139,16 +74,28 @@ def get_profile_information_from_requests( key, value = element.text.strip().split(" ", 1) key = "_".join(key.split()).lower() value = value.strip() - if key in ["name", "srn", "pesu_id", "program", "branch", "semester", "section"]: - if key == "branch" and (branch_short_code := self.map_branch_to_short_code(value)): + if key in [ + "name", + "srn", + "pesu_id", + "program", + "branch", + "semester", + "section", + ]: + if key == "branch" and ( + branch_short_code := self.map_branch_to_short_code(value) + ): profile["branch_short_code"] = branch_short_code key = "prn" if key == "pesu_id" else key profile[key] = value profile["email"] = soup.find("input", attrs={"id": "updateMail"}).get("value") - profile["phone"] = soup.find("input", attrs={"id": "updateContact"}).get("value") + profile["phone"] = soup.find("input", attrs={"id": "updateContact"}).get( + "value" + ) - # if username starts with PES1, then he is from RR campus, else if it is PES2, then EC campus + # if username starts with PES1, then they are from RR campus, else if it is PES2, then EC campus key = username if username else profile["pesu_id"] if campus_code_match := re.match(r"PES(\d)", key): campus_code = campus_code_match.group(1) @@ -163,6 +110,13 @@ def get_know_your_class_and_section( session: Optional[requests_html.HTMLSession] = None, csrf_token: Optional[str] = None, ) -> dict[str, Any]: + """ + Get the class and section information from the public Know Your Class and Section page. + :param username: Username of the user + :param session: The session object + :param csrf_token: The csrf token + :return: The class and section information + """ if not session: session = requests_html.HTMLSession() @@ -190,14 +144,14 @@ def get_know_your_class_and_section( "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin", "x-csrf-token": csrf_token, - "x-requested-with": "XMLHttpRequest" + "x-requested-with": "XMLHttpRequest", }, - data={ - "loginId": username - } + data={"loginId": username}, ) except Exception: - logging.error(f"Unable to get profile from Know Your Class and Section: {traceback.format_exc()}") + logging.error( + f"Unable to get profile from Know Your Class and Section: {traceback.format_exc()}" + ) return {} soup = BeautifulSoup(response.text, "html.parser") @@ -210,95 +164,16 @@ def get_know_your_class_and_section( return profile - def authenticate_selenium_non_interactive( - self, - username: str, - password: str, - profile: bool = False + def authenticate( + self, username: str, password: str, profile: bool = False ) -> dict[str, Any]: - logging.warning("This method is deprecated and will be removed in future versions.") - - self.init_chrome() - try: - logging.info("Connecting to PESU Academy") - self.chrome.get("https://pesuacademy.com/Academy") - self.chrome.implicitly_wait(3) - except Exception as e: - logging.error(f"Unable to connect to PESU Academy: {traceback.format_exc()}") - self.chrome.quit() - return {"status": False, "message": "Unable to connect to PESU Academy.", "error": str(e)} - - try: - logging.info("Logging in to PESU Academy") - self.chrome.find_element(By.ID, "j_scriptusername").send_keys(username) - self.chrome.find_element(By.NAME, "j_password").send_keys(password) - self.chrome.find_element(By.ID, "postloginform#/Academy/j_spring_security_check").click() - self.chrome.implicitly_wait(3) - except Exception as e: - logging.error(f"Unable to find the login form: {traceback.format_exc()}") - self.chrome.quit() - return {"status": False, "message": "Unable to find the login form.", "error": str(e)} - - status = False - try: - logging.info(f"Checking if the login was successful") - if (element := self.chrome.find_element(By.CLASS_NAME, "login-msg")) \ - and element.text in ("Your Username and Password do not match", "User doesn't exist"): - # this element is shown only when the login is unsuccessful - logging.error("Login unsuccessful") - self.chrome.quit() - status = False - return { - "status": status, - "message": "Invalid username or password, or the user does not exist.", - } - except Exception: - # this element is not shown when the login is successful - status = True - logging.info("Login successful") - - if profile: - result = self.get_profile_information_from_selenium(username) - know_your_class_and_section_data = self.get_know_your_class_and_section(username) - result["know_your_class_and_section"] = know_your_class_and_section_data - return result - else: - self.chrome.quit() - return {"status": status, "message": "Login successful."} - - def authenticate_selenium_interactive(self, profile: bool = False) -> dict[str, Any]: - self.init_chrome(headless=False) - - try: - logging.info("Connecting to PESU Academy") - self.chrome.get("https://pesuacademy.com/Academy") - self.chrome.implicitly_wait(3) - WebDriverWait(self.chrome, 10).until( - ec.element_to_be_clickable((By.ID, "postloginform#/Academy/j_spring_security_check"))) - except Exception as e: - logging.error(f"Unable to connect to PESU Academy: {traceback.format_exc()}") - self.chrome.quit() - return {"status": False, "message": "Unable to connect to PESU Academy.", "error": str(e)} - - # wait for user to login manually - try: - logging.info("Waiting for user to login manually") - WebDriverWait(self.chrome, 90).until( - ec.url_contains("https://www.pesuacademy.com/Academy/s/studentProfilePESU")) - self.chrome.implicitly_wait(3) - status = True - except Exception as e: - logging.error(f"Unable to find the login form: {traceback.format_exc()}") - self.chrome.quit() - return {"status": False, "message": "Unable to find the login form.", "error": str(e)} - - if profile: - return self.get_profile_information_from_selenium() - else: - self.chrome.quit() - return {"status": status, "message": "Login successful."} - - def authenticate(self, username: str, password: str, profile: bool = False) -> dict[str, Any]: + """ + Authenticate the user with the provided username and password. + :param username: Username of the user, usually their PRN/email/phone number + :param password: Password of the user + :param profile: Whether to fetch the profile information or not + :return: The authentication result + """ session = requests_html.HTMLSession() try: @@ -310,7 +185,11 @@ def authenticate(self, username: str, password: str, profile: bool = False) -> d except Exception as e: logging.error(f"Unable to fetch csrf token: {traceback.format_exc()}") session.close() - return {"status": False, "message": "Unable to fetch csrf token.", "error": str(e)} + return { + "status": False, + "message": "Unable to fetch csrf token.", + "error": str(e), + } # Prepare the login data for auth call data = { @@ -326,7 +205,11 @@ def authenticate(self, username: str, password: str, profile: bool = False) -> d except Exception as e: logging.error(f"Unable to authenticate: {traceback.format_exc()}") session.close() - return {"status": False, "message": "Unable to authenticate.", "error": str(e)} + return { + "status": False, + "message": "Unable to authenticate.", + "error": str(e), + } # if class login-form is present, login failed if soup.find("div", attrs={"class": "login-form"}): @@ -342,11 +225,9 @@ def authenticate(self, username: str, password: str, profile: bool = False) -> d csrf_token = soup.find("meta", attrs={"name": "csrf-token"})["content"] if profile: - result = self.get_profile_information_from_requests(session, username) + result = self.get_profile_information(session, username) know_your_class_and_section_data = self.get_know_your_class_and_section( - username, - session, - csrf_token + username, session, csrf_token ) result["know_your_class_and_section"] = know_your_class_and_section_data return result diff --git a/requirements.txt b/requirements.txt index fed3f1e..42480c3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,51 +1,46 @@ appdirs==1.4.4 -async-generator==1.10 -attrs==23.1.0 -beautifulsoup4==4.12.2 -blinker==1.6.2 -bs4==0.0.1 -certifi==2023.5.7 -charset-normalizer==3.1.0 -click==8.1.3 +beautifulsoup4==4.12.3 +black==24.4.2 +blinker==1.8.2 +bs4==0.0.2 +certifi==2024.7.4 +charset-normalizer==3.3.2 +click==8.1.7 cssselect==1.2.0 -emoji==2.5.1 -exceptiongroup==1.1.1 -fake-useragent==1.1.3 -Flask==2.3.2 -gh-md-to-html==1.21.2 -h11==0.14.0 -idna==3.4 -importlib-metadata==6.7.0 -itsdangerous==2.1.2 -Jinja2==3.1.2 -lxml==4.9.2 -MarkupSafe==2.1.3 -outcome==1.2.0 -parse==1.19.1 -Pillow==9.5.0 -pip==23.1.2 -pyee==8.2.2 -pyppeteer==1.0.2 +emoji==2.12.1 +fake-useragent==1.5.1 +Flask==3.0.3 +gh-md-to-html==1.21.3 +idna==3.7 +importlib_metadata==8.2.0 +itsdangerous==2.2.0 +Jinja2==3.1.4 +lxml==5.2.2 +lxml_html_clean==0.1.1 +MarkupSafe==2.1.5 +mypy-extensions==1.0.0 +packaging==24.1 +parse==1.20.2 +pathspec==0.12.1 +pillow==10.4.0 +pip==24.0 +platformdirs==4.2.2 +pyee==11.1.0 +pyppeteer==2.0.0 pyquery==2.0.0 -PySocks==1.7.1 -python-dotenv==1.0.0 -pytz==2023.3 -requests==2.31.0 +pytz==2024.1 +requests==2.32.3 requests-html==0.10.0 -selenium==4.10.0 -setuptools==67.8.0 +setuptools==69.5.1 shellescape==3.8.1 -sniffio==1.3.0 -sortedcontainers==2.4.0 -soupsieve==2.4.1 -tqdm==4.65.0 -trio==0.22.0 -trio-websocket==0.10.3 -urllib3==1.26.16 -w3lib==2.1.1 -webcolors==1.13 +soupsieve==2.5 +tomli==2.0.1 +tqdm==4.66.4 +typing_extensions==4.12.2 +urllib3==1.26.19 +w3lib==2.2.1 +webcolors==24.6.0 websockets==10.4 -Werkzeug==2.3.6 -wheel==0.38.4 -wsproto==1.2.0 -zipp==3.15.0 +Werkzeug==3.0.3 +wheel==0.43.0 +zipp==3.19.2