Repository: AliRn76/panther Branch: master Commit: 676a4623a957 Files: 178 Total size: 1.0 MB Directory structure: gitextract_j6xogok6/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ ├── pull_request.yaml │ └── release.yaml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── benchmark.txt ├── docs/ │ ├── docs/ │ │ ├── admin_panel.md │ │ ├── api.md │ │ ├── authentications.md │ │ ├── background_tasks.md │ │ ├── cors.md │ │ ├── database.md │ │ ├── deployment.md │ │ ├── events.md │ │ ├── file_handling.md │ │ ├── first_crud.md │ │ ├── index.md │ │ ├── log_queries.md │ │ ├── middlewares.md │ │ ├── model.md │ │ ├── open_api.md │ │ ├── query.md │ │ ├── redis.md │ │ ├── release_notes.md │ │ ├── roadmap.md │ │ ├── ruff.md │ │ ├── serializer.md │ │ ├── single_file.md │ │ ├── templates.md │ │ ├── timezone.md │ │ ├── urls.md │ │ ├── user_model.md │ │ └── websocket.md │ ├── mkdocs.yml │ └── pre-finetuning.jsonl ├── examples/ │ ├── blog/ │ │ ├── Dockerfile │ │ ├── Readme.md │ │ ├── app/ │ │ │ ├── apis.py │ │ │ ├── models.py │ │ │ ├── serializers.py │ │ │ └── urls.py │ │ ├── core/ │ │ │ ├── configs.py │ │ │ └── urls.py │ │ ├── docker-compose.yaml │ │ ├── main.py │ │ ├── requirements.txt │ │ └── user/ │ │ ├── apis.py │ │ ├── serializers.py │ │ └── urls.py │ ├── broadcast_websocket.py │ ├── file_upload_example.py │ └── streaming_response.py ├── panther/ │ ├── __init__.py │ ├── _load_configs.py │ ├── _utils.py │ ├── app.py │ ├── authentications.py │ ├── background_tasks.py │ ├── base_request.py │ ├── base_websocket.py │ ├── caching.py │ ├── cli/ │ │ ├── __init__.py │ │ ├── create_project_command.py │ │ ├── create_user_command.py │ │ ├── main.py │ │ ├── monitor_command.py │ │ ├── run_command.py │ │ ├── template.py │ │ └── utils.py │ ├── configs.py │ ├── db/ │ │ ├── __init__.py │ │ ├── connections.py │ │ ├── cursor.py │ │ ├── models.py │ │ ├── queries/ │ │ │ ├── __init__.py │ │ │ ├── base_queries.py │ │ │ ├── mongodb_queries.py │ │ │ ├── pantherdb_queries.py │ │ │ └── queries.py │ │ └── utils.py │ ├── events.py │ ├── exceptions.py │ ├── file_handler.py │ ├── generics.py │ ├── logging.py │ ├── main.py │ ├── middlewares/ │ │ ├── __init__.py │ │ ├── base.py │ │ ├── cors.py │ │ └── monitoring.py │ ├── openapi/ │ │ ├── __init__.py │ │ ├── schemas.py │ │ ├── templates/ │ │ │ ├── rapidoc.html │ │ │ ├── redoc.html │ │ │ ├── scalar.html │ │ │ ├── spotlight.html │ │ │ └── swagger.html │ │ ├── urls.py │ │ ├── utils.py │ │ └── views.py │ ├── pagination.py │ ├── panel/ │ │ ├── __init__.py │ │ ├── authentications.py │ │ ├── middlewares.py │ │ ├── permissions.py │ │ ├── templates/ │ │ │ ├── base.html │ │ │ ├── create.html │ │ │ ├── create.js │ │ │ ├── detail.html │ │ │ ├── home.html │ │ │ ├── home.js │ │ │ ├── login.html │ │ │ ├── sidebar.html │ │ │ ├── table.html │ │ │ └── table.js │ │ ├── urls.py │ │ ├── utils.py │ │ └── views.py │ ├── permissions.py │ ├── request.py │ ├── response.py │ ├── routings.py │ ├── serializer.py │ ├── status.py │ ├── test.py │ ├── throttling.py │ ├── utils.py │ └── websocket.py ├── profiler.py ├── pyproject.toml ├── requirements.txt ├── ruff.toml ├── setup.py └── tests/ ├── __init__.py ├── __main__.py ├── sample_project/ │ ├── app/ │ │ ├── apis.py │ │ ├── models.py │ │ └── urls.py │ └── core/ │ ├── configs.py │ ├── events.py │ └── urls.py ├── test_api_kwargs.py ├── test_authentication.py ├── test_background_task_event_loop_isolation.py ├── test_background_tasks.py ├── test_caching.py ├── test_cli.py ├── test_config.py ├── test_cors.py ├── test_database.py ├── test_database_advance.py ├── test_events.py ├── test_file.py ├── test_find_one_or_insert_race.py ├── test_generics.py ├── test_generics_search_regex_escape.py ├── test_middlewares.py ├── test_multipart.py ├── test_openapi.py ├── test_panel.py ├── test_permission.py ├── test_request.py ├── test_request_isolation.py ├── test_response.py ├── test_response_output_model.py ├── test_routing.py ├── test_run.py ├── test_serializer.py ├── test_status.py ├── test_throttling.py ├── test_utils.py └── test_websockets.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms buy_me_a_coffee: PantherPy ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve labels: bug assignees: AliRn76 --- **Describe the bug** A clear and concise description of what the bug is. **Expected behavior** A clear and concise description of what you expected to happen. **Environment:** - OS: [e.g. iOS] - Python Version [e.g. 3.11.4] - Panther Version [e.g. 2.4.0] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- **Describe the feature you'd like** A clear and concise description of what you want to happen. **Additional context** Add any other context about the feature request here. ================================================ FILE: .github/workflows/pull_request.yaml ================================================ name: Pull Request on: pull_request: types: [ opened, synchronize ] jobs: tests-linux: name: Test Linux runs-on: ubuntu-latest steps: - name: Checkout source uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.12' - name: Install Requirements run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Start MongoDB uses: supercharge/mongodb-github-action@1.10.0 with: mongodb-version: '4.4' - name: Remove Build Directory run: rm -rf build/ - name: Run Tests run: coverage run tests - name: Show Coverage run: coverage report tests-windows: name: Test Windows runs-on: windows-latest needs: [ tests-linux ] steps: - name: Checkout source uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.12' - name: Install Requirements run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Run Tests run: python tests --not_mongodb --not_slow tests-macos: name: Test MacOS runs-on: macos-latest needs: [ tests-linux ] steps: - name: Checkout source uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.12' - name: Install Requirements run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Run Tests run: python tests --not_mongodb --not_slow ================================================ FILE: .github/workflows/release.yaml ================================================ name: Release on: push: tags: - 'v*' jobs: initial-test: name: Initial Test runs-on: ubuntu-latest steps: - name: Checkout source uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.12' - name: Install Requirements run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Run Tests run: python tests --not_mongodb --not_slow tests-linux: name: Test Linux Python${{ matrix.python-version }} runs-on: ubuntu-latest needs: [ initial-test ] strategy: fail-fast: false matrix: python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - name: Checkout source uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install Requirements run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Run Tests run: python tests --not_mongodb tests-windows: name: Test Windows Python${{ matrix.python-version }} runs-on: windows-latest needs: [ initial-test ] strategy: matrix: python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - name: Checkout source uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install Requirements run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Run Tests run: python tests --not_mongodb tests-macos: name: Test MacOS Python${{ matrix.python-version }} runs-on: macos-latest needs: [ initial-test ] strategy: matrix: python-version: [ '3.10', '3.11', '3.12', '3.13', '3.14'] steps: - name: Checkout source uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install Requirements run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Run Tests run: python tests --not_mongodb tests-mongodb: name: Test MongoDB ${{ matrix.mongodb-version }} runs-on: ubuntu-latest needs: [ initial-test ] strategy: matrix: mongodb-version: [ '4.2', '4.4', '5.0', '6.0' ] steps: - name: Checkout source uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.11' - name: Install Requirements run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Install motor run: pip install motor - name: Start MongoDB uses: supercharge/mongodb-github-action@1.10.0 with: mongodb-version: ${{ matrix.mongodb-version }} - name: Run Tests run: python tests --mongodb coverage: name: Coverage runs-on: ubuntu-latest needs: [ tests-linux, tests-mongodb, tests-macos, tests-windows ] steps: - name: Checkout source uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.12' - name: Install Requirements run: | python -m pip install --upgrade pip pip install -r requirements.txt - name: Install motor run: pip install motor - name: Start MongoDB uses: supercharge/mongodb-github-action@1.10.0 with: mongodb-version: '4.4' - name: Remove Build Directory run: rm -rf build/ - name: Run Coverage Tests run: coverage run -m tests - name: Show Coverage run: coverage report - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v3 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} build-n-publish: name: Build and publish to PyPI runs-on: ubuntu-latest needs: [ coverage ] steps: - name: Checkout source uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.12' - name: Build source and wheel distributions run: | python -m pip install --upgrade build twine python -m build twine check --strict dist/* - name: Publish distribution to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_TOKEN }} ================================================ FILE: .gitignore ================================================ .idea/ .venv*/ __pycache__/ panther.egg-info/ dist/ example/panther/ logs/ build/ *.pdb .ruff_cache/ docs/site/* *coverage* .prettierrc .vscode ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at . All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Panther Thank you for your interest in contributing to Panther! This document provides guidelines and instructions for setting up the development environment and contributing to the project. ## Table of Contents - [Prerequisites](#prerequisites) - [Development Setup](#development-setup) - [Running Tests](#running-tests) - [Development Workflow](#development-workflow) - [Code Style](#code-style) - [Submitting Changes](#submitting-changes) - [Getting Help](#getting-help) ## Prerequisites Before you begin, ensure you have the following installed: - **Python 3.10+** (Panther requires Python 3.10 or higher) - **Git** for version control - **Docker** for running test dependencies - **pip** for package management ## Development Setup Follow these steps to set up your development environment: ### 1. Clone the Repository ```bash git clone https://github.com/AliRn76/panther.git cd panther ``` ### 2. Create a Virtual Environment ```bash # Create virtual environment python3.11 -m venv .venv # Activate virtual environment # On Linux/macOS: source .venv/bin/activate # On Windows: # .venv\Scripts\activate ``` ### 3. Install Dependencies ```bash # Install development dependencies pip install -r requirements.txt # Install Panther in development mode pip install -e . ``` ## Running Tests ### Prerequisites Before running tests, you need to start the required services using Docker: ```bash # Start MongoDB (required for database tests) docker run --rm -p 27017:27017 -d --name mongo mongo # Start Redis (required for caching tests) docker run --rm -p 6379:6379 -d --name redis redis ``` **Note:** Make sure Docker is running on your system before executing these commands. ### Running Tests ```bash # Run all tests python tests # Run specific test file python -m pytest tests/test_specific_file.py # Run tests with specific markers python tests --mongodb # Run only MongoDB tests python tests --not_mongodb # Don't run MongoDB tests python tests --slow # Run only slow tests python tests --not_slow # Don't run slow tests ``` ### Test Coverage ```bash # Run tests with coverage coverage run tests # Generate coverage report coverage report # Generate HTML coverage report coverage html # Open htmlcov/index.html in your browser to view the report ``` ## Development Workflow ### Making Changes 1. **Create a new branch** for your feature or fix: ```bash git checkout -b feature/your-feature-name # or git checkout -b fix/your-fix-name ``` 2. **Make your changes** in the source code located in the `panther/` directory. 3. **Test your changes**: ```bash # Run tests to ensure nothing is broken python tests # Run linting ruff check . ``` 4. **Reinstall Panther** after making changes to see them immediately: ```bash pip install -e . ``` ### Code Style Panther uses [Ruff](https://github.com/astral-sh/ruff) for code formatting and linting. The configuration is in `ruff.toml`. ```bash # Format code ruff format . # Check for linting issues ruff check . # Fix auto-fixable issues ruff check --fix . ``` ### Testing Your Changes You can test your changes in real-time by: 1. **Modifying the installed package** (for quick testing): - Edit files in `.venv/lib/python3.11/site-packages/panther/` - Changes are immediately available for testing - **Important:** Remember to copy your changes back to the source files before committing 2. **Using development installation**: - Make changes in the `panther/` directory - Reinstall: `pip install -e .` - Test your changes ## Submitting Changes ### Before Submitting 1. **Ensure all tests pass**: ```bash python tests ``` 2. **Check code style**: ```bash ruff check . ruff format . ``` 3. **Update documentation** if your changes affect public APIs or behavior. ### Creating a Pull Request 1. **Push your branch** to your fork: ```bash git push origin feature/your-feature-name ``` 2. **Create a Pull Request** on GitHub with: - Clear description of the changes - Reference to any related issues - Screenshots or examples if applicable 3. **Wait for review** and address any feedback from maintainers. ## Getting Help - **Documentation**: [PantherPy.GitHub.io](https://pantherpy.github.io) - **Issues**: [GitHub Issues](https://github.com/AliRn76/panther/issues) - **Discussions**: [GitHub Discussions](https://github.com/AliRn76/panther/discussions) ## Additional Resources - **Project Structure**: Explore the `panther/` directory to understand the codebase - **Examples**: Check the `example/` directory for usage examples - **Benchmarks**: Review `benchmark.txt` for performance data --- Thank you for contributing to Panther! 🐾 ================================================ FILE: LICENSE ================================================ BSD 3-Clause License Copyright (c) 2022, Ali Rajab nezhad All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: README.md ================================================ [![PyPI](https://img.shields.io/pypi/v/panther?label=PyPI)](https://pypi.org/project/panther/) [![PyVersion](https://img.shields.io/pypi/pyversions/panther.svg)](https://pypi.org/project/panther/) [![codecov](https://codecov.io/github/AliRn76/panther/graph/badge.svg?token=YWFQA43GSP)](https://codecov.io/github/AliRn76/panther) [![Downloads](https://static.pepy.tech/badge/panther/month)](https://pepy.tech/project/panther) [![license](https://img.shields.io/github/license/alirn76/panther.svg)](https://github.com/alirn76/panther/blob/main/LICENSE)
Panther Logo # Panther **A Fast & Friendly Web Framework for Building Async APIs with Python 3.10+** [📚 Documentation](https://pantherpy.github.io)
--- ## 🐾 Why Choose Panther? Panther is designed to be **fast**, **simple**, and **powerful**. Here's what makes it special: - **One of the fastest Python frameworks** available ([Benchmark](https://www.techempower.com/benchmarks/#section=data-r23&l=zijzen-pa7&c=4)) - **File-based database** ([PantherDB](https://pypi.org/project/pantherdb/)) - No external database setup required - **Document-oriented ODM** - Supports MongoDB & PantherDB with familiar syntax - **API caching system** - In-memory and Redis support - **OpenAPI** - Auto-generated API documentation with multiple UI options - **WebSocket support** - Real-time communication out of the box - **Authentication & Permissions** - Built-in security features - **Background tasks** - Handle long-running operations - **Middleware & Throttling** - Extensible and configurable --- ## Quick Start ### Installation ```bash pip install panther ``` - Create a `main.py` file with one of the examples below. ### Your First API Here's a simple REST API endpoint that returns a "Hello World" message: ```python from datetime import datetime, timedelta from panther import status, Panther from panther.app import GenericAPI from panther.openapi.urls import url_routing as openapi_url_routing from panther.response import Response class HelloAPI(GenericAPI): # Cache responses for 10 seconds cache = timedelta(seconds=10) def get(self): current_time = datetime.now().isoformat() return Response( data={'message': f'Hello from Panther! 🐾 | {current_time}'}, status_code=status.HTTP_200_OK ) # URL routing configuration url_routing = { '/': HelloAPI, 'docs/': openapi_url_routing, # Auto-generated API docs } # Create your Panther app app = Panther(__name__, configs=__name__, urls=url_routing) ``` ### WebSocket Echo Server Here's a simple WebSocket echo server that sends back any message it receives: ```python from panther import Panther from panther.app import GenericAPI from panther.response import HTMLResponse from panther.websocket import GenericWebsocket class EchoWebsocket(GenericWebsocket): async def connect(self, **kwargs): await self.accept() await self.send("Connected to Panther WebSocket!") async def receive(self, data: str | bytes): # Echo back the received message await self.send(f"Echo: {data}") class WebSocketPage(GenericAPI): def get(self): template = """

🐾 Panther WebSocket Echo Server

""" return HTMLResponse(template) url_routing = { '': WebSocketPage, 'ws': EchoWebsocket, } app = Panther(__name__, configs=__name__, urls=url_routing) ``` ### Run Your Application 1. **Start the development server** ```shell $ panther run main:app ``` 2. **Test your application** - For the _API_ example: Visit [http://127.0.0.1:8000/](http://127.0.0.1:8000/) to see the "Hello World" response - For the _WebSocket_ example: Visit [http://127.0.0.1:8000/](http://127.0.0.1:8000/) and send a message. --- ## 🙏 Acknowledgments

Supported by

JetBrains
---

⭐️ If you find Panther useful, please give it a star!

================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions Use this section to tell people about which versions of your project are currently being supported with security updates. | Version | Supported | | ------- | ------------------ | | > 1.7.x | :white_check_mark: | | < 1.7 | :x: | ## Reporting a Vulnerability It is an open-source project and we would be happy if you report an issue if you found any vulnerabilities 😃🖐️ ================================================ FILE: benchmark.txt ================================================ """ Checkout profiler.py for more information """ Clock type: CPU Ordered by: totaltime, desc name ncall tsub ttot tavg /home/ali/dev/panther/panther/main.py:78 Panther.__call__ 1001 0.012665 0.811156 0.000810 /home/ali/dev/panther/panther/main.py:161 Panther.handle_http 1000 0.035138 0.798476 0.000798 /home/ali/dev/panther/panther/response.py:167 Response.send 1000 0.009427 0.473242 0.000473 /home/ali/dev/panther/panther/response.py:161 Response.send_headers 1000 0.012985 0.287794 0.000288 /home/ali/dev/panther/panther/middlewares/base.py:15 TestMiddleware.__call__ 1000 0.005173 0.239159 0.000239 /home/ali/dev/panther/panther/main.py:142 handle_http_endpoint 1000 0.021648 0.233986 0.000234 /home/ali/dev/panther/panther/response.py:164 Response.send_body 1000 0.009923 0.176022 0.000176 /home/ali/dev/panther/panther/app.py:120 wrapper 1000 0.008120 0.133374 0.000133 /home/ali/dev/panther/panther/app.py:136 API.handle_endpoint 1000 0.042525 0.125255 0.000125 /home/ali/dev/panther/panther/response.py:124 Response.bytes_headers 1000 0.024477 0.062525 0.000063 /home/ali/dev/panther/panther/response.py:67 Response.__init__ 1000 0.019294 0.059479 0.000059 /home/ali/dev/panther/panther/routings.py:133 find_endpoint 1000 0.026393 0.043979 0.000044 /home/ali/dev/panther/panther/request.py:40 Request.read_body 1000 0.011115 0.036803 0.000037 /home/ali/dev/panther/panther/response.py:135 prepare_data 2000/1000 0.023992 0.031797 0.000016 /home/ali/dev/panther/panther/base_request.py:129 Request.collect_path_variables 1000 0.019701 0.031360 0.000031 /home/ali/dev/panther/panther/response.py:116 Response.headers 1000 0.009755 0.025101 0.000025 /home/ali/dev/panther/panther/response.py:107 Response.body 2000 0.011792 0.024099 0.000012 /home/ali/dev/panther/panther/base_request.py:139 Request.clean_parameters 1000 0.007809 0.011867 0.000012 /home/ali/dev/panther/panther/request.py:14 Request.__init__ 1000 0.008140 0.010023 0.000010 /home/ali/dev/panther/panther/response.py:154 check_status_code 1000 0.003490 0.004959 0.000005 /home/ali/dev/panther/panther/request.py:19 Request.method 3000 0.004952 0.004952 0.000002 /home/ali/dev/panther/panther/middlewares/base.py:12 TestMiddleware.__init__ 1000 0.004111 0.004111 0.000004 /home/ali/dev/panther/panther/base_request.py:109 Request.path 2000 0.003424 0.003424 0.000002 /home/ali/dev/panther/panther/base_request.py:88 Request.__init__ 1000 0.001883 0.001883 0.000002 /home/ali/dev/panther/panther/app.py:189 API.handle_authentication 1000 0.001752 0.001752 0.000002 /home/ali/dev/panther/panther/response.py:131 Response.headers 1000 0.001583 0.001583 0.000002 /home/ali/dev/panther/panther/events.py:27 run_startups 1 0.000001 0.000001 0.000001 ================================================ FILE: docs/docs/admin_panel.md ================================================ > **⚠️ WARNING: The Panther Admin Panel is currently in development. Do NOT use it in production environments!** > > **Contributions, feedback, and ideas are welcome!** # Panther Admin Panel Panther provides a built-in admin panel that allows you to easily manage your database models through a web interface. ## Enabling the Admin Panel To enable the admin panel in your project, follow these steps: ### 1. Add the Admin Panel URLs First, ensure your main URL configuration includes the admin panel routes. e.g. open your `core/urls.py` and add the following: ```python title="core/urls.py" from panther.panel.urls import url_routing as panel_url_routing url_routing = { 'panel/': panel_url_routing, # ... other routes ... } ``` > **Note:** If you are using a different file for your URL routing, adjust the import and assignment accordingly. ### 2. Update Your Configs (if needed) If your project uses a custom configuration file for URLs, make sure it points to your updated URL routing. For example, in `core/configs.py`: ```python title="core/configs.py" URLs = 'core.urls.url_routing' ``` ### 3. Run Your Application Start your Panther application as usual: ```bash panther run main:app ``` ### 4. Access the Admin Panel Open your browser and navigate to: [http://127.0.0.1:8000/panel/](http://127.0.0.1:8000/panel/) You should see the Panther Admin Panel interface, where you can manage your database models. ### 5. Create an Admin User To access the admin panel, you need at least one user account. You can create a user using the following command: ```bash panther createuser main.py ``` Replace `main.py` with the path to your main application file if it is different. This command will create a new user based on your `USER_MODEL` (by default, `panther.db.models.BaseUser`). Once the user is created, you can log in to the admin panel using the credentials you set during user creation. ================================================ FILE: docs/docs/api.md ================================================ # Panther API Guide This guide assumes you have successfully set up your project and created your first CRUD following the [First CRUD](first_crud.md) guide. In this guide, we discuss the features and structure of Panther's API system, including authentication, permissions, caching, throttling, middlewares, and more. --- ## API Request Flow The typical flow of an API request in Panther is as follows: ``` Middlewares ├── Method ├── Authentication ├── Permissions ├── Throttling ├── Validate Input ├── Get Response From Cache ├── Call Endpoint ├── Set Response To Cache Middlewares ``` --- ## Input Model You can validate incoming data using the `input_model` parameter. Pass a serializer to it, and Panther will send `request.data` to this serializer, placing the validated data in `request.validated_data`. As a result, `request.validated_data` will be an instance of your serializer. > Note: `request.data` is validated only for 'POST', 'PUT', and 'PATCH' methods. ??? question "How do serializers work in Panther?" Refer to [Serializer](serializer.md) to learn more about serializers. ```python title="app/serializers.py" linenums="1" from panther.serializer import ModelSerializer class UserInputSerializer(ModelSerializer): ... ``` === "Function-Base API" ```python title="app/apis.py" linenums="1" from panther import status from panther.app import API from panther.request import Request from panther.response import Response from app.serializers import UserInputSerializer @API(input_model=UserInputSerializer) async def user_api(request: Request): request.validated_data # This is now available ... ``` === "Class-Base API" ```python title="app/apis.py" linenums="1" from panther import status from panther.app import GenericAPI from panther.response import Response from app.serializers import UserInputSerializer class UserAPI(GenericAPI): input_model = UserInputSerializer async def get(self, request: Request): request.validated_data # This is now available ... ``` --- ## Output Model Use the `output_model` parameter to automatically serialize your API response data using a specified serializer. This ensures that the response structure is consistent and validated. **Example Serializer:** ```python title="app/serializers.py" linenums="1" from panther.serializer import ModelSerializer class UserSerializer(ModelSerializer): ... ``` === "Function-Base API" ```python title="app/apis.py" linenums="1" from panther.app import API from app.serializers import UserSerializer @API(output_model=UserSerializer) async def user_api(): ... ``` === "Class-Base API" ```python title="app/apis.py" linenums="1" from panther.app import GenericAPI from app.serializers import UserSerializer class UserAPI(GenericAPI): output_model = UserSerializer ... ``` > **Tip:** Use `output_model` to ensure your API always returns data in the expected format. For OpenAPI documentation, see the `output_schema` section. --- ## Authentication To ensure that each request contains a valid authentication header, use the `auth` parameter. - The `auth` parameter can be an any async function or a class with an async `__call__` method. - If you set `auth`, Panther will use your specified authentication class or function. - If you do not set `auth`, Panther will use the default `AUTHENTICATION` from your config **only if the request contains an authorization header**. - If there is no authorization header in the request, authentication is bypassed, `request.user` will be `None` and you must rely on permissions to check the user and their authorization. ??? question "How do authentications work in Panther?" Refer to [Authentications](authentications.md) to learn more about authentications. === "Function-Base API" ```python title="app/apis.py" linenums="1" from panther import status from panther.app import API from panther.request import Request from panther.response import Response from app.authentications import MyAuthenticationClass @API(auth=MyAuthenticationClass) # You can also use a function async def user_api(request: Request): user = request.user return Response(data=user, status_code=status.HTTP_200_OK) ``` === "Class-Base API" ```python title="app/apis.py" linenums="1" from panther import status from panther.app import GenericAPI from panther.response import Response from app.authentications import MyAuthenticationClass class UserAPI(GenericAPI): auth = MyAuthenticationClass # You can also use a function async def get(self, request: Request): user = request.user return Response(data=user, status_code=status.HTTP_200_OK) ``` --- ## Method You can specify which HTTP methods are allowed for an endpoint by setting `methods` in function-based APIs. Only the following methods are supported: `['GET', 'POST', 'PUT', 'PATCH', 'DELETE']`. > If a method is not allowed, a 405 status code will be returned. === "Function-Base API" ```python title="app/apis.py" linenums="1" from panther.app import API from panther.request import Request @API(methods=['GET', 'POST']) async def user_api(request: Request): match request.method: case 'GET': ... case 'POST': ... ``` === "Class-Base API" ```python title="app/apis.py" linenums="1" from panther.app import GenericAPI class UserAPI(GenericAPI): # (1)! async def get(self): ... async def post(self): ... ``` 1. Now this class only accepts `GET` and `POST` requests. --- ## Permissions You can implement your authorization logic using permission classes or functions. Any async function or class with an async `__call__` method can be used as a permission. Panther will call each permission (asynchronously). Pass a list of permission callables to your API using the `permissions` parameter. If you pass a single permission, it will be automatically wrapped in a list. > Each permission must be async (either an async function or a class with an async `__call__`). **Example Permission Function:** ```python title="app/permissions.py" linenums="1" from panther.request import Request async def custom_permission(request: Request) -> bool: return True ``` **Example Permission Class:** ```python title="app/permissions.py" linenums="1" from panther.permissions import BasePermission from panther.request import Request class CustomPermission(BasePermission): async def __call__(self, request: Request) -> bool: return True ``` === "Function-Base API" ```python title="app/apis.py" linenums="1" from panther.app import API from app.permissions import custom_permission, CustomPermission @API(permissions=[custom_permission, CustomPermission]) async def user_api(): ... ``` Or, if you have only one permission: ```python title="app/apis.py" linenums="1" from panther.app import API from app.permissions import custom_permission @API(permissions=custom_permission) async def user_api(): ... ``` Panther will treat it as a list internally. === "Class-Base API" ```python title="app/apis.py" linenums="1" from panther.app import GenericAPI from app.permissions import CustomPermission class UserAPI(GenericAPI): permissions = [CustomPermission] ... ``` Or, if you have only one permission: ```python title="app/apis.py" linenums="1" from panther.app import GenericAPI from app.permissions import custom_permission class UserAPI(GenericAPI): permissions = custom_permission ... ``` Panther will treat it as a list internally. --- ## Cache Responses can be cached for a specific amount of time per request or IP. Caching is only applied to `GET` requests. The response's headers, data, and status code will be cached. The cache is stored in Redis (if connected) or in memory. The cache key is based on the user ID or IP, request path, query parameters, and validated data: ``` 'user_id or ip - request.path - hash of query param - request.validated_data' ``` The value of `cache` should be an instance of `datetime.timedelta()`. === "Function-Base API" ```python title="app/apis.py" linenums="1" from datetime import timedelta from panther.app import API @API(cache=timedelta(minutes=10)) async def user_api(): ... ``` === "Class-Base API" ```python title="app/apis.py" linenums="1" from datetime import timedelta from panther.app import GenericAPI class UserAPI(GenericAPI): cache = timedelta(minutes=10) ... ``` --- ## Throttling You can throttle requests using the `Throttle` class, either globally via the `THROTTLING` config or per API. The `Throttle` class has two fields: `rate` and `duration`. If a user exceeds the allowed number of requests (`rate`) within the specified `duration`, they will receive a `429 Too Many Requests` response and be banned for the duration. > When you set `throttling` on your API, it takes precedence over the default `THROTTLING`, and the default `THROTTLING` will not be executed. ### Setting Default Throttling ```python from datetime import timedelta from panther.throttling import Throttle # Users can only make 5 requests every minute THROTTLING = Throttle(rate=5, duration=timedelta(minutes=1)) ``` ### Throttling Per API === "Function-Base API" ```python title="app/apis.py" linenums="1" from datetime import timedelta from panther.app import API from panther.throttling import Throttle @API(throttling=Throttle(rate=5, duration=timedelta(minutes=1))) async def user_api(): ... ``` === "Class-Base API" ```python title="app/apis.py" linenums="1" from datetime import timedelta from panther.app import GenericAPI from panther.throttling import Throttle class UserAPI(GenericAPI): throttling = Throttle(rate=5, duration=timedelta(minutes=1)) ... ``` ### Customization Throttling works with `request.user.id` or `request.client.ip`. You can customize its behavior by overriding `build_cache_key()`: ```python title="app/throttlings.py" linenums="1" from panther.request import Request from panther.throttling import Throttle class CustomThrottle(Throttle): def build_cache_key(self, request: Request) -> str: ... ``` --- ## Middlewares You can pass custom middlewares to specific APIs. **Example Middleware:** ```python title="app/middlewares.py" linenums="1" from panther.middlewares.base import HTTPMiddleware from panther.request import Request from panther.response import Response class CustomMiddleware(HTTPMiddleware): async def __call__(self, request: Request) -> Response: print('Hi') response = await self.dispatch(request=request) print('Bye') return response ``` === "Function-Base API" ```python title="app/apis.py" linenums="1" from panther.app import API from app.middlewares import CustomMiddleware @API(middlewares=[CustomMiddleware]) async def user_api(): ... ``` === "Class-Base API" ```python title="app/apis.py" linenums="1" from panther.app import GenericAPI from app.middlewares import CustomMiddleware class UserAPI(GenericAPI): middlewares = [CustomMiddleware] ... ``` ??? question "How do middlewares work in Panther?" Refer to [Middlewares](middlewares.md) to learn more about middlewares. --- ## Output Schema The `output_schema` attribute is used when generating OpenAPI (Swagger) documentation. It should be an instance of `panther.openapi.OutputSchema`, which specifies the desired response data structure and status code. **Example Serializer:** ```python title="app/serializers.py" linenums="1" from panther.serializer import ModelSerializer class UserSerializer(ModelSerializer): ... ``` === "Function-Base API" ```python title="app/apis.py" linenums="1" from panther import status from panther.app import API from panther.openapi import OutputSchema from app.serializers import UserSerializer @API(output_schema=OutputSchema(status_code=status.HTTP_201_CREATED, model=UserSerializer)) async def user_api(): ... ``` === "Class-Base API" ```python title="app/apis.py" linenums="1" from panther.app import GenericAPI from panther.openapi import OutputSchema from app.serializers import UserSerializer class UserAPI(GenericAPI): output_schema = OutputSchema(status_code=status.HTTP_201_CREATED, model=UserSerializer) ... ``` ??? question "How does OpenAPI work in Panther?" Refer to [OpenAPI](open_api.md) to learn more about OpenAPI. --- ## File Handling Panther provides built-in support for file uploads through the `File` and `Image` classes. !!! tip "Comprehensive File Handling Guide" For detailed information about file handling, including advanced features, best practices, and troubleshooting, see the dedicated [File Handling](file_handling.md) documentation. ```python title="app/apis.py" linenums="1" from panther.app import API from panther.db import Model from panther.file_handler import File from panther.request import Request from panther.response import Response from panther.serializer import ModelSerializer class FileUpload(Model): file: File description: str class FileUploadSerializer(ModelSerializer): class Config: model = FileUpload fields = '*' @API(input_model=FileUploadSerializer) async def upload_file(request: Request): file_data = request.validated_data file = file_data.file # Save file to disk saved_path = file.save("uploads/") return Response(data={ "message": "File uploaded successfully", "saved_path": saved_path }) ``` ================================================ FILE: docs/docs/authentications.md ================================================ # Authentication in Panther Authentication in Panther ensures that only authorized users can access your APIs and WebSocket connections. You can configure which authentication class to use in your `configs`. --- ## How Authentication Works - The `auth` parameter for an API or WebSocket should be an async function or a class with an async `__call__` method. Panther will use this callable to verify the user. - If you do not set `auth`, Panther will use the default `AUTHENTICATION` from your config **only if the request contains an authorization header**. If there is no authorization header, authentication is bypassed and `request.user` will be `None`. - If authentication fails, Panther raises `HTTP_401_UNAUTHORIZED`. - The authenticated user is available as: - `request.user` in API views - `self.user` in WebSocket connections > **Note:** When authentication is bypassed (no authorization header), `request.user` will be `None` and you must rely on permissions to check the user and their authorization. --- ## Built-in Authentication Classes Panther provides three built-in authentication classes, all based on JWT (JSON Web Token): ### 1. JWT Authentication - Retrieves the JWT token from the `Authorization` header in the request. - Expects the header format: `Authorization: Bearer ` - Decodes the token, validates it, and fetches the corresponding user. - By default, uses `panther.db.models.BaseUser` as the user model unless you set `USER_MODEL` in your configs. - Handles token revocation if Redis is connected (for logout and refresh scenarios). #### Example usage ```python AUTHENTICATION = 'panther.authentications.JWTAuthentication' ``` #### JWT Configuration You can customize JWT behavior by setting `JWT_CONFIG` in your configs. Example: ```python title="core/configs.py" linenums="1" from datetime import timedelta from panther.utils import load_env from pathlib import Path BASE_DIR = Path(__name__).resolve().parent env = load_env(BASE_DIR / '.env') SECRET_KEY = env['SECRET_KEY'] JWT_CONFIG = { 'key': SECRET_KEY, # Secret key for signing tokens (default: `SECRET_KEY`) 'algorithm': 'HS256', # Algorithm used for JWT (default: `'HS256'`) 'life_time': timedelta(days=2), # Access token lifetime (default: `timedelta(days=1)`) 'refresh_life_time': timedelta(days=10), # Refresh token lifetime (default: `2 * life_time`) } ``` --- ### 2. QueryParam JWT Authentication - Works like `JWTAuthentication`, but expects the token in the query parameters instead of headers. - Useful for WebSocket authentication or scenarios where headers are not available. - Pass the token as a query parameter: - Example: `https://example.com?authorization=` #### Example usage ```python AUTHENTICATION = 'panther.authentications.QueryParamJWTAuthentication' ``` --- ### 3. Cookie JWT Authentication - Works like `JWTAuthentication`, but expects the token in cookies. - Looks for `access_token` in cookies for authentication. - Optionally, can use `refresh_token` in cookies for token refresh. - Pass the token in cookies: - Example: `Cookies: access_token=` #### Example usage ```python AUTHENTICATION = 'panther.authentications.CookieJWTAuthentication' ``` --- ## WebSocket Authentication For WebSocket connections, it is recommended to use `QueryParamJWTAuthentication` since headers are not always available. To enable this, set the following in your configs: ```python WS_AUTHENTICATION = 'panther.authentications.QueryParamJWTAuthentication' ``` --- ## Custom Authentication You can implement your own authentication logic by either: - Inheriting from `panther.authentications.BaseAuthentication` and implementing an async `__call__` method (recommended for consistency), or - Providing any async function or class with an async `__call__` method. ### Steps to create a custom authentication class: 1. **Inherit from `BaseAuthentication` and implement async `__call__`** ```python from panther.request import Request from panther.exceptions import AuthenticationAPIError from panther.authentications import BaseAuthentication class CustomAuthentication(BaseAuthentication): async def __call__(self, request: Request): # Your authentication logic here # Return an instance of USER_MODEL (default: BaseUser) # Or raise AuthenticationAPIError on failure ... ``` Or as a function or plain class: ```python async def custom_authentication(request: Request): # Your authentication logic here # Return an instance of USER_MODEL (default: BaseUser) # Or raise AuthenticationAPIError on failure ... ``` 2. **Configure your custom authentication class or function in your configs (if you want it as default):** ```python AUTHENTICATION = 'project_name.core.authentications.CustomAuthentication' ``` 3. **Or set it per API using the `auth` parameter:** ```python @API(auth=CustomAuthentication) async def my_api(request: Request): ... ``` --- ## Error Handling - If authentication fails, raise `panther.exceptions.AuthenticationAPIError` with an appropriate message. - Panther will automatically handle and return a 401 Unauthorized response. --- ## Summary - Choose and configure the appropriate authentication class or function for your use case. - Use JWT configuration options to control token behavior. - For WebSocket, prefer query parameter-based authentication. - Implement custom authentication as an async function or a class with an async `__call__` method. - If authentication is bypassed, `request.user` will be `None` and you must use permissions to check the user and their authorization. ================================================ FILE: docs/docs/background_tasks.md ================================================ # Background Tasks in Panther Panther can run background tasks in a separate thread at startup if you set `BACKGROUND_TASKS` to `True` in your configuration. ## Quick Start 1. **Enable background tasks** In your `configs`, add: ```python BACKGROUND_TASKS = True ``` 2. **Create and submit a task** ```python linenums="1" from panther.background_tasks import BackgroundTask def do_something(name: str, age: int): print(f"{name} is {age} years old.") BackgroundTask(do_something, name='Ali', age=26).submit() ``` - You must call `.submit()` to add the task to the queue. - The task function can be synchronous or asynchronous. --- ## Task Options ### 1. Interval Control how many times a task runs: ```python BackgroundTask(do_something, name='Ali', age=26).interval(3).submit() ``` - By default, tasks run once (`interval=1`). - Use `interval(-1)` for infinite runs. - Each interval is separated by the schedule you set (see below). ### 2. Scheduling You can schedule tasks to run at specific intervals: - **Every N seconds/minutes/hours/days/weeks:** ```python BackgroundTask(do_something, name='Ali', age=26).every_seconds(10).submit() BackgroundTask(do_something, name='Ali', age=26).every_minutes(5).submit() BackgroundTask(do_something, name='Ali', age=26).every_hours(2).submit() BackgroundTask(do_something, name='Ali', age=26).every_days(1).submit() BackgroundTask(do_something, name='Ali', age=26).every_weeks(1).submit() ``` - Default value for each is `1` (e.g., every 1 minute). - **Custom values:** You can pass a custom value to any of the above, e.g., `every_days(4)` runs every 4 days. ### 3. Time of Day Run a task at a specific time: ```python linenums="1" from datetime import time from panther.background_tasks import BackgroundTask BackgroundTask(do_something, name='Ali', age=26)\ .every_days()\ .at(time(hour=8, minute=0))\ .submit() ``` - The task will run when the system time matches the specified hour, minute, and second. ### 4. Day of Week Run a task on a specific day of the week: ```python linenums="1" from datetime import time from panther.background_tasks import BackgroundTask, WeekDay BackgroundTask(do_something, name='Ali', age=26)\ .every_weeks(2)\ .on(WeekDay.SUNDAY)\ .at(time(hour=8))\ .submit() ``` - Valid days: `WeekDay.MONDAY`, `WeekDay.TUESDAY`, `WeekDay.WEDNESDAY`, `WeekDay.THURSDAY`, `WeekDay.FRIDAY`, `WeekDay.SATURDAY`, `WeekDay.SUNDAY`. --- ## Passing Arguments You can pass arguments to your task function as positional or keyword arguments: ```python BackgroundTask(do_something, name='Ali', age=26) BackgroundTask(do_something, 'Ali', age=26) BackgroundTask(do_something, 'Ali', 26) ``` --- ## Important Notes & Best Practices - **Task function** can be synchronous or asynchronous. - **You must call `.submit()`** to add the task to the background queue. - **Default interval** is 1 (runs once). Use `.interval(-1)` for infinite runs. - If you try to add a task before `BACKGROUND_TASKS` is enabled, it will be ignored and a warning will be logged. - Each task runs in its own thread when triggered. - Tasks are checked every second for their schedule. --- ## Example: Task ```python linenums="1" import datetime from panther.background_tasks import BackgroundTask async def hello(name: str): print(f'Hello {name}') # Run 2 times, every 5 seconds BackgroundTask(hello, 'Ali').interval(2).every_seconds(5).submit() # Run forever, every day at 08:00 BackgroundTask(hello, 'Saba').interval(-1).every_days().at(datetime.time(hour=8)).submit() ``` ================================================ FILE: docs/docs/cors.md ================================================ # CORSMiddleware `CORSMiddleware` is a middleware for Panther applications that handles Cross-Origin Resource Sharing (CORS). It automatically adds the appropriate CORS headers to all HTTP responses based on your configuration, and handles preflight (OPTIONS) requests. ## Purpose CORS is a security feature implemented by browsers to restrict web applications running on one origin from interacting with resources from a different origin. `CORSMiddleware` makes it easy to configure and manage CORS policies in your Panther application. ## Configuration Options Set the following variables in your Panther config file (e.g., `core/configs.py`) to control CORS behavior: | Config Variable | Type | Description | Default | |---------------------|-----------|----------------------------------------------------------------------|--------------------------------------------------------| | _ALLOW_ORIGINS_ | list[str] | List of allowed origins. Use `["*"]` to allow all origins. | `["*"]` | | _ALLOW_METHODS_ | list[str] | List of allowed HTTP methods. | `["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]` | | _ALLOW_HEADERS_ | list[str] | List of allowed request headers. Use `["*"]` to allow all headers. | `["*"]` | | _ALLOW_CREDENTIALS_ | bool | Whether to allow credentials (cookies, authorization headers, etc.). | `False` | | _EXPOSE_HEADERS_ | list[str] | List of headers that can be exposed to the browser. | `[]` | | _CORS_MAX_AGE_ | int | Number of seconds browsers can cache preflight responses. | `600` | ## Usage 1. Set the desired CORS config variables in your config file. 2. Add `'panther.middlewares.cors.CORSMiddleware'` to your `MIDDLEWARES` list. ### Example Configuration ```python title="e.g. core/configs.py" ALLOW_ORIGINS = ["https://example.com", "https://another.com"] ALLOW_METHODS = ["GET", "POST"] ALLOW_HEADERS = ["Content-Type", "Authorization"] ALLOW_CREDENTIALS = True EXPOSE_HEADERS = ["X-Custom-Header"] CORS_MAX_AGE = 3600 MIDDLEWARES = [ # ... other middlewares ... 'panther.middlewares.cors.CORSMiddleware', ] ``` ## How It Works - For every request, the middleware adds the appropriate CORS headers to the response. - For preflight (OPTIONS) requests, it returns a 204 response with the necessary headers. - The headers are set based on your configuration, with sensible defaults if not specified. ## Notes - If you set `ALLOW_ORIGINS = ["*"]`, all origins are allowed. - If you set `ALLOW_HEADERS = ["*"]`, all headers are allowed. - If `ALLOW_CREDENTIALS` is `True`, the `Access-Control-Allow-Credentials` header is set to `true`. - If you specify `EXPOSE_HEADERS`, those headers will be exposed to the browser. For more details, see the source code in `panther/middlewares/cors.py`. --- For a deeper understanding of CORS, you may also want to check the [MDN Web Docs CORS Guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS). ================================================ FILE: docs/docs/database.md ================================================ # Database Support in Panther Panther natively supports two databases: `MongoDB` and `PantherDB`. However, you can also define your own custom database connections and queries. --- ## Configuration Structure Define your database configuration in the following format: ```python DATABASE = { 'engine': { 'class': 'address of engine', 'arg1': 'value1', # ... additional arguments ... }, 'query': 'address of query class', # Optional } ``` ### Notes - **Built-in supported engines:** - `panther.db.connections.PantherDBConnection` - `panther.db.connections.MongoDBConnection` - All values in `engine` (except `class`) are passed to the `__init__` method of the specified class. - The `query` key is optional for the default supported engines, but you can customize it if needed. --- ## PantherDB Example configuration for PantherDB: ```python DATABASE = { 'engine': { 'class': 'panther.db.connections.PantherDBConnection', 'path': BASE_DIR / 'database.pdb', # Optional 'encryption': True # Optional, default is False } } ``` ### Notes - `path` is optional; you can customize the directory and filename of your database. - `encryption` is optional and defaults to `False`. - The `cryptography` package is required if you set `encryption` to `True`. --- ## MongoDB Example configuration for MongoDB: ```python DATABASE = { 'engine': { 'class': 'panther.db.connections.MongoDBConnection', 'host': 'mongodb://127.0.0.1:27017/database_name' } } ``` ### Notes - The parameters for the engine are the same as those for `pymongo.MongoClient`. See the [PyMongo documentation](https://pymongo.readthedocs.io/en/stable/tutorial.html#making-a-connection-with-mongoclient) for details. --- ## How Does It Work? - Panther creates a database connection based on the `DATABASE` configuration you define in your configs. - You can access this connection through your models, or directly via: ```python from panther.db.connections import db ``` ================================================ FILE: docs/docs/deployment.md ================================================ # Deploying Panther Applications Panther is an ASGI-compatible web framework, which means you can deploy your Panther app using any ASGI server, such as **Uvicorn**, **Granian**, **Daphne**, or others. This guide covers best practices and options for deploying your Panther application in production. --- ## 1. Production-Ready ASGI Servers While Panther comes with a convenient CLI, you are not limited to it. You can use any ASGI server to run your app: - **Uvicorn** (default, recommended) - **Granian** - **Daphne** - **Hypercorn** - Any other ASGI-compliant server ### Example: Using Uvicorn Directly ```bash uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 ``` - `main:app` refers to your application instance (e.g., `app = Panther(...)` in `main.py`). - Adjust `--workers` for your server's CPU count. ### Example: Using Gunicorn with Uvicorn Workers For robust process management and multiple workers: ```bash gunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app ``` --- ## 2. Using the Panther CLI Panther provides a built-in CLI for development and deployment. The command: ```bash panther run main:app --reload ``` is **an alias for running Uvicorn** with your Panther app. You can use all Uvicorn options with this command. - `--reload` is for development (auto-reloads on code changes). - Omit `--reload` for production. **Note:** For advanced deployment, prefer running Uvicorn (or another ASGI server) directly, as shown above. --- ## 3. Environment Variables & Configuration - Set environment variables (e.g., `PORT`, `HOST`, `DATABASE_URL`) as needed for your deployment environment. - Use a process manager (e.g., **systemd**, **supervisor**, **pm2**, or **Docker** (recomended)) to keep your app running and restart on failure. --- ## 4. Static Files & Reverse Proxy Panther does not serve static files in production. Use a reverse proxy (like **Nginx** or **Caddy**) to: - Serve static files (JS, CSS, images) - Forward requests to your ASGI server (Uvicorn, etc.) - Handle HTTPS/SSL termination **Example Nginx config:** ```nginx location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } ``` --- ## 5. WebSocket & Multiple Workers If your app uses WebSockets and you want to run multiple workers, configure **Redis** for pub/sub support (see [Redis Integration](redis.md)). Alternatively, use the `--preload` flag with Gunicorn for basic multi-worker support (see [WebSocket docs](websocket.md)). --- ## 6. Example: Docker Deployment Panther can be efficiently containerized using a multi-stage Docker build and the [uv](https://github.com/astral-sh/uv) package manager, which is significantly faster and more reliable than pip. This approach results in smaller, more secure images and faster build times. ### Advantages of Multi-Stage & uv - **Smaller final image size**: Only production dependencies and app code are included. - **Faster dependency installation**: `uv` is much faster than `pip` and supports modern lockfiles. - **Better security**: Build tools and caches are left behind in the builder stage. - **Cleaner builds**: No unnecessary files in the final image. ### Example Multi-Stage Dockerfile with uv ```dockerfile # --- Builder --- FROM python:3.12 AS builder WORKDIR /app # Create virtual environment RUN python -m venv /opt/venv # Install uv into system (can be reused) RUN pip install --no-cache-dir uv # Copy requirements and install into the venv COPY requirements.txt . RUN /usr/local/bin/uv pip install -r requirements.txt --python /opt/venv/bin/python # --- Final image --- FROM python:3.12-slim AS production ENV PYTHONUNBUFFERED=1 ENV PATH="/opt/venv/bin:$PATH" # Copy venv from builder COPY --from=builder /opt/venv /opt/venv # Copy app source WORKDIR /app COPY . /app # Run the app CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] ``` ================================================ FILE: docs/docs/events.md ================================================ # Application Events Panther provides a simple way to execute custom logic during your application's lifecycle using event decorators. You can hook into the **startup** and **shutdown** phases to run code when your app starts or stops. --- ## Startup Event To run code when your application starts, use the `@Event.startup` decorator: ```python title="events.py" linenums="1" from panther.events import Event @Event.startup def do_something_on_startup(): print('Hello, I am at startup') ``` You can define multiple startup event handlers. They will be executed in the order they are registered. --- ## Shutdown Event To run code when your application is shutting down, use the `@Event.shutdown` decorator: ```python title="events.py" linenums="1" from panther.events import Event @Event.shutdown def do_something_on_shutdown(): print('Good Bye, I am at shutdown') ``` You can define multiple shutdown event handlers as well. --- ## Additional Notes - **Multiple Handlers:** You can register multiple functions for both `startup` and `shutdown` events. All will be called. - **Sync & Async Support:** Event handlers can be either synchronous or asynchronous functions. - **Use Cases:** Common use cases include initializing resources (like database connections) on startup and cleaning up resources on shutdown. ================================================ FILE: docs/docs/file_handling.md ================================================ # File Handling in Panther Panther provides file handling capabilities through the `File` and `Image` classes for file uploads and processing. --- ## Overview - **`File`**: General-purpose file handling for any type of file - **`Image`**: Specialized file handling for images with automatic MIME type validation --- ## Basic Usage ### Importing File Classes ```python from panther.file_handler import File, Image ``` ### Creating File Objects ```python # Create from bytes (in-memory file) file = File( file_name='document.pdf', content_type='application/pdf', file=b'PDF content here...' ) # Create image file image = Image( file_name='photo.jpg', content_type='image/jpeg', file=b'JPEG content here...' ) ``` --- ## File Properties | Property | Type | Description | |----------|------|-------------| | `file_name` | str | The name of the file | | `content_type` | str | The MIME type of the file | | `file` | bytes \| None | The file content in bytes | | `size` | int | File size in bytes | --- ## File Methods ```python # Read file content content = file.read() # Save file to disk path = file.save("uploads/") # Use as context manager with file as f: content = f.read() ``` --- ## Saving Files The `save()` method provides flexible file saving with several features: ### Basic Saving ```python # Save with original filename path = file.save() # Save with custom filename path = file.save("custom_name.txt") # Save with overwrite path = file.save("existing_file.txt", overwrite=True) ``` ### Directory Path Handling When you provide a path ending with `/`, it's treated as a directory: ```python # Save to directory with original filename path = file.save("uploads/") # Saves as "uploads/original_filename.ext" # Save to nested directory path = file.save("uploads/images/") # Saves as "uploads/images/original_filename.ext" # Save to directory with custom filename path = file.save("uploads/custom_name.txt") # Saves as "uploads/custom_name.txt" ``` ### Idempotent Behavior The `save()` method is **idempotent** - calling it multiple times on the same file instance returns the same path: ```python file = File(file_name='test.txt', content_type='text/plain', file=b'content') # First call - creates file and returns path path1 = file.save("uploads/") print(path1) # "uploads/test.txt" # Subsequent calls - returns same path without creating new files path2 = file.save("uploads/") path3 = file.save("uploads/") assert path1 == path2 == path3 # All return the same path ``` This prevents accidental creation of duplicate files with different timestamps. ### Automatic Directory Creation The `save()` method automatically creates directories if they don't exist: ```python # This will create the "uploads/images" directory structure if it doesn't exist path = file.save("uploads/images/") ``` --- ## Integration with Models Files can be used as model attributes: ```python from datetime import datetime from panther.db import Model from panther.file_handler import File, Image class Document(Model): title: str file: File uploaded_at: datetime class Profile(Model): name: str avatar: Image bio: str | None = None ``` When using File types in models: - The file metadata (name, content type) is preserved - The file path is stored in the database - The actual file content is automatically saved to disk when inserting the model - You can access file properties and methods on the model instance --- ## Integration with Serializers ```python from panther.serializer import ModelSerializer from panther.file_handler import File, Image class FileUploadSerializer(ModelSerializer): class Config: model = FileUpload fields = ['file', 'description'] required_fields = ['file'] class ImageUploadSerializer(ModelSerializer): class Config: model = ImageUpload fields = ['image', 'title'] required_fields = ['image'] ``` ### File Validation ```python from pydantic import field_validator from panther import status from panther.exceptions import APIError from panther.file_handler import File from panther.serializer import ModelSerializer class DocumentUploadSerializer(ModelSerializer): class Config: model = DocumentUpload fields = ['file', 'title'] required_fields = ['file'] @field_validator('file') @classmethod def validate_file_size(cls, file: File) -> File: if file.size > 10 * 1024 * 1024: # 10MB limit raise APIError( detail='File size must be less than 10MB', status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE ) return file ``` --- ## API Integration ### File Upload API ```python from panther.app import API from panther.request import Request from panther.response import Response @API(input_model=FileUploadSerializer) async def upload_file(request: Request): file_data = request.validated_data file = file_data.file # Save file to disk saved_path = file.save("uploads/") # Store in database document = await Document.insert_one({ 'title': file_data.description, 'file': file, 'uploaded_at': datetime.now() }) return Response(data={ "message": "File uploaded successfully", "saved_path": saved_path }) ``` --- ## Error Handling ```python from panther import status from panther.exceptions import APIError @API(input_model=FileUploadSerializer) async def upload_file(request: Request): try: file_data = request.validated_data file = file_data.file # Check file size if file.size > 10 * 1024 * 1024: # 10MB limit raise APIError( detail="File too large. Maximum size is 10MB.", status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE ) saved_path = file.save("uploads/") return Response(data={"saved_path": saved_path}) except Exception as e: raise APIError( detail=f"File upload failed: {str(e)}", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR ) ``` --- ## Best Practices 1. **Validate file types**: Always check MIME types to prevent malicious uploads 2. **Limit file sizes**: Set reasonable size limits to prevent abuse 3. **Use context managers**: When reading files, use `with` statements for proper cleanup 4. **Handle errors gracefully**: Provide clear error messages for validation failures 5. **Secure file storage**: Always validate and sanitize file names before saving 6. **Leverage idempotency**: The `save()` method is idempotent, so you can call it multiple times safely 7. **Use directory paths**: When saving files, use directory paths ending with `/` for better organization This guide covers the essential aspects of file handling in Panther. For more advanced features and detailed examples, refer to the API documentation and examples. ================================================ FILE: docs/docs/first_crud.md ================================================ We assume you have successfully set up your project following the [Introduction](index.md#installation) guide. In this guide, we will create a `CRUD` (`Create`, `Retrieve`, `Update`, and `Delete`) API for managing `Book` entities. --- ## Project Structure The final structure of your project will be as follows: ``` . ├── app │ ├── apis.py │ ├── models.py │ ├── serializers.py │ └── urls.py └── core ├── configs.py └── urls.py ``` ## Configuring the Database !!! question "How does the database work in Panther?" Refer to [Database](database.md) to learn about supported databases and their functionality. Configure the `DATABASE` settings in `core/configs.py`. In this guide, we will use `PantherDB`. > [PantherDB](https://github.com/PantherPy/PantherDB/#readme) is a simple, file-based, document-oriented database. ```python title="core/configs.py" DATABASE = { 'engine': { 'class': 'panther.db.connections.PantherDBConnection', } } ``` --- ## Defining the Model !!! question "How do models work in Panther?" Refer to [Models](model.md) to learn more about defining models and how they function. !!! tip "File Handling in Models" For models that need to handle file uploads, see [File Handling](file_handling.md) for detailed information about using `File` and `Image` types in your models. Create a model named `Book` in `app/models.py`: ```python title="app/models.py" linenums="1" from panther.db import Model class Book(Model): name: str author: str pages_count: int ``` --- ## Defining URLs !!! question "How do URLs work in Panther?" Refer to [URLs](urls.md) to learn more about URL definitions. The base `urls` configuration should include all application URLs. ```python title="core/urls.py" linenums="1" from app.urls import urls as app_urls urls = { '/': app_urls, } ``` In `app/urls.py`, define the `Book` API URLs: === "Function-Base" ```python title="app/urls.py" linenums="1" from app.apis import book_api, single_book_api urls = { 'book/': book_api, 'book//': single_book_api, } ``` === "Class-Base API/ Generic API (Beta)" ```python title="app/urls.py" linenums="1" from app.apis import BookAPI, SingleBookAPI urls = { 'book/': BookAPI, 'book//': SingleBookAPI, } ``` --- ## Defining the Serializer !!! question "How do serializers work in Panther?" Refer to [Serializer](serializer.md) to learn more about available serializers. !!! tip "File Handling in Serializers" For serializers that need to handle file uploads, see [File Handling](file_handling.md) for information about validating and processing files in your serializers. Serializers transform data between the application and API requests. === "Function-Base/ Class-Base API" The serializer can be inherited from `ModelSerializer` or `pydantic.BaseModel` ```python title="app/serializers.py" linenums="1" from panther.serializer import ModelSerializer from app.models import Book class BookSerializer(ModelSerializer): class Config: model = Book fields = ['name', 'author', 'pages_count'] ``` or ```python title="app/serializers.py" linenums="1" import pydantic from app.models import Book class BookSerializer(pydantic.BaseModel): name: str author: str pages_count: int ``` === "Generic API (Beta)" The serializer should be inherited from `ModelSerializer` to be used in `GenericAPI`. ```python title="app/serializers.py" linenums="1" from panther.serializer import ModelSerializer from app.models import Book class BookSerializer(ModelSerializer): class Config: model = Book fields = ['name', 'author', 'pages_count'] ``` ### APIs !!! question "How do APIs work in Panther?" Refer to [API](api.md) to learn more about API types and their usage. !!! tip "File Upload APIs" For APIs that handle file uploads, see [File Handling](file_handling.md) for comprehensive examples of file upload endpoints and processing. #### Create === "Function-Base API" ```python title="app/apis.py" linenums="1" from panther import status from panther.app import API from panther.request import Request from panther.response import Response from app.serializers import BookSerializer from app.models import Book @API(input_model=BookSerializer, methods=['POST']) async def book_api(request: Request): body: BookSerializer = request.validated_data book: Book = await Book.insert_one( name=body.name, author=body.author, pages_count=body.pages_count, ) return Response(data=book, status_code=status.HTTP_201_CREATED) ``` === "Class-Base API" ```python title="app/apis.py" linenums="1" from panther import status from panther.app import GenericAPI from panther.request import Request from panther.response import Response from app.serializers import BookSerializer from app.models import Book class BookAPI(GenericAPI): input_model = BookSerializer async def post(self, request: Request): body: BookSerializer = request.validated_data book = await Book.insert_one( name=body.name, author=body.author, pages_count=body.pages_count, ) return Response(data=book, status_code=status.HTTP_201_CREATED) ``` === "Generic API (Beta)" ```python title="app/apis.py" linenums="1" from panther.app import CreateAPI from app.serializers import BookSerializer class BookAPI(CreateAPI): input_model = BookSerializer ``` #### List === "Function-Base API" ```python title="app/apis.py" linenums="1" from panther import status from panther.app import API from panther.request import Request from panther.response import Response from app.serializers import BookSerializer from app.models import Book @API(input_model=BookSerializer, methods=['POST', 'GET']) async def book_api(request: Request): ... if request.method == 'GET': books = await Book.find() return Response(data=books, status_code=status.HTTP_200_OK) ``` === "Class-Base API" ```python title="app/apis.py" linenums="1" from panther import status from panther.app import GenericAPI from panther.response import Response from app.serializers import BookSerializer from app.models import Book class BookAPI(GenericAPI): input_model = BookSerializer async def get(self): books = await Book.find() return Response(data=books, status_code=status.HTTP_200_OK) ``` === "Generic API (Beta)" ```python title="app/apis.py" linenums="1" from panther.generics import CreateAPI, ListAPI from panther.pagination import Pagination from panther.request import Request from app.models import Book from app.serializers import BookSerializer class BookAPI(CreateAPI, ListAPI): input_model = BookSerializer pagination = Pagination #(1)! search_fields = ['name', 'author'] #(2)! filter_fields = ['name', 'author'] #(3)! sort_fields = ['name', 'pages_count'] #(4)! async def get_query(self, request: Request, **kwargs): return await Book.find() ``` 1. `Pagination` class will look for the `limit` and `skip` in the `query params` and paginate your response and then return it on its own response template. 2. The query will be changed and looking for the value of the `search` query param in these fields, e.g. query param is the `?search=TheLittlePrince`, we will looking for the Book with `name` or `author` of `TheLittlePrince`. 3. It will look for each value of the `filter_fields` in the `query params` and query on them, e.g. `?name=Something&author=Ali`, it will looks for Book that its `author` is `Ali` and its `name` is `Something`. 4. The query will be sortalbe with the fields which is in `sort_fields`. #### Retrieve === "Function-Base API" ```python title="app/apis.py" linenums="1" from panther import status from panther.app import API from panther.request import Request from panther.response import Response from app.models import Book @API(methods=['GET']) async def single_book_api(request: Request, book_id: int): if book := await Book.find_one(id=book_id): return Response(data=book, status_code=status.HTTP_200_OK) return Response(data={'detail': 'Book not found'}, status_code=status.HTTP_400_BAD_REQUEST) ``` === "Class-Base API" ```python title="app/apis.py" linenums="1" from panther import status from panther.app import GenericAPI from panther.response import Response from app.models import Book class SingleBookAPI(GenericAPI): async def get(self, book_id: int): if book := await Book.find_one(id=book_id): return Response(data=book, status_code=status.HTTP_200_OK) return Response(data={'detail': 'Book not found'}, status_code=status.HTTP_400_BAD_REQUEST) ``` === "Generic API (Beta)" ```python title="app/apis.py" linenums="1" from panther.generics import RetrieveAPI from panther.request import Request from app.models import Book class SingleBookAPI(RetrieveAPI): async def get_instance(self, request: Request, **kwargs): return await Book.find_one_or_raise(id=kwargs['book_id']) ``` #### Update === "Function-Base API" ```python title="app/apis.py" linenums="1" from panther import status from panther.app import API from panther.request import Request from panther.response import Response from app.models import Book from app.serializers import BookSerializer @API(input_model=BookSerializer, methods=['GET', 'PUT']) async def single_book_api(request: Request, book_id: int): ... if request.method == 'PUT': is_updated = await Book.update_one({'id': book_id}, request.validated_data.model_dump()) data = {'is_updated': is_updated} return Response(data=data, status_code=status.HTTP_200_OK) ``` === "Class-Base API" ```python title="app/apis.py" linenums="1" from panther import status from panther.app import GenericAPI from panther.response import Response from app.models import Book class SingleBookAPI(GenericAPI): input_model = BookSerializer async def put(self, request: Request, book_id: int): is_updated = await Book.update_one({'id': book_id}, request.validated_data.model_dump()) data = {'is_updated': is_updated} return Response(data=data, status_code=status.HTTP_200_OK) ``` === "Generic API (Beta)" ```python title="app/apis.py" linenums="1" from panther.generics import RetrieveAPI from panther.request import Request from app.models import Book from app.serializers import BookSerializer class SingleBookAPI(RetrieveAPI, UpdateAPI): input_model = BookSerializer async def get_instance(self, request: Request, **kwargs): return await Book.find_one_or_raise(id=kwargs['book_id']) ``` #### Delete === "Function-Base API" ```python title="apis.py" linenums="1" from panther import status from panther.app import API from panther.request import Request from panther.response import Response from app.models import Book from app.serializers import BookSerializer @API(input_model=BookSerializer, methods=['GET', 'PUT', 'DELETE']) async def single_book_api(request: Request, book_id: int): ... if request.method == 'DELETE': await Book.delete_one(id=book_id) return Response(status_code=status.HTTP_204_NO_CONTENT) ``` === "Class-Base API" ```python title="apis.py" linenums="1" from panther import status from panther.app import GenericAPI from panther.response import Response from app.models import Book class SingleBookAPI(GenericAPI): async def delete(self, book_id: int): await Book.delete_one(id=book_id) return Response(status_code=status.HTTP_204_NO_CONTENT) ``` === "Generic API (Beta)" ```python title="apis.py" linenums="1" from panther.generics import RetrieveAPI from panther.request import Request from app.models import Book from app.serializers import BookSerializer class SingleBookAPI(RetrieveAPI, UpdateAPI, DeleteAPI): input_model = BookSerializer async def get_instance(self, request: Request, **kwargs): return await Book.find_one_or_raise(id=kwargs['book_id']) ``` With this, you now have a complete CRUD API implementation for the `Book` entity. ================================================ FILE: docs/docs/index.md ================================================ ## Panther **A Fast & Friendly Web Framework for Building Async APIs with Python 3.10+**

Panther Logo

[![PyPI](https://img.shields.io/pypi/v/panther?label=PyPI)](https://pypi.org/project/panther/) [![PyVersion](https://img.shields.io/pypi/pyversions/panther.svg)](https://pypi.org/project/panther/) [![codecov](https://codecov.io/github/AliRn76/panther/graph/badge.svg?token=YWFQA43GSP)](https://codecov.io/github/AliRn76/panther) [![Downloads](https://static.pepy.tech/badge/panther/month)](https://pepy.tech/project/panther) [![License](https://img.shields.io/github/license/alirn76/panther.svg)](https://github.com/alirn76/panther/blob/main/LICENSE) --- ## 🐾 Why Choose Panther? Panther is designed to be **fast**, **simple**, and **powerful**. Here's what makes it special: - **One of the fastest Python frameworks** available - **File-based database** ([PantherDB](https://pypi.org/project/pantherdb/)) - No external database setup required - **Document-oriented ODM** - Supports MongoDB & PantherDB with familiar syntax - **API caching system** - In-memory and Redis support - **OpenAPI/Swagger** - Auto-generated API documentation - **WebSocket support** - Real-time communication out of the box - **Authentication & Permissions** - Built-in security features - **Background tasks** - Handle long-running operations - **Middleware & Throttling** - Extensible and configurable - **File upload handling** - Built-in File and Image classes for easy file processing --- ## Benchmark

Benchmark

[[TechEmpower Benchmark]](https://www.techempower.com/benchmarks/#section=data-r23&l=zijzen-pa7&c=4) --- ## Supported by
JetBrains
--- ## Installation ```shell $ pip install panther ``` --- ## Getting Started ### Quick Start Guide 1. **Create a new project directory** ```shell $ mkdir my_panther_app $ cd my_panther_app ``` 2. **Set up your environment** ```shell $ python3 -m venv .venv $ source .venv/bin/activate # On Windows: .\.venv\Scripts\activate $ pip install panther ``` 3. **Create your first application** Create a `main.py` file with one of the examples below. ### Basic API Example Here's a simple REST API endpoint that returns a "Hello World" message: ```python title="main.py" linenums="1" from datetime import datetime, timedelta from panther import status, Panther from panther.app import GenericAPI from panther.openapi.views import ScalarOpenAPI from panther.response import Response class HelloAPI(GenericAPI): # Cache responses for 10 seconds cache = timedelta(seconds=10) def get(self): current_time = datetime.now().isoformat() return Response( data={'message': f'Hello from Panther! 🐾 | {current_time}'}, status_code=status.HTTP_200_OK ) # URL routing configuration url_routing = { '/': HelloAPI, 'docs/': ScalarOpenAPI, # Auto-generated API docs } # Create your Panther app app = Panther(__name__, configs=__name__, urls=url_routing) ``` ### WebSocket Example Here's a simple WebSocket echo server that sends back any message it receives: ```python title="main.py" linenums="1" from panther import Panther from panther.app import GenericAPI from panther.response import HTMLResponse from panther.websocket import GenericWebsocket class EchoWebsocket(GenericWebsocket): async def connect(self, **kwargs): await self.accept() await self.send("Connected to Panther WebSocket!") async def receive(self, data: str | bytes): # Echo back the received message await self.send(f"Echo: {data}") class WebSocketPage(GenericAPI): def get(self): template = """

🐾 Panther WebSocket Echo Server

    """ return HTMLResponse(template) url_routing = { '': WebSocketPage, 'ws': EchoWebsocket, } app = Panther(__name__, configs=__name__, urls=url_routing) ``` ### Running Your Application 1. **Start the development server** ```shell $ panther run main:app --reload ``` > **Note:** Panther uses [Uvicorn](https://github.com/encode/uvicorn) as the default ASGI server, but you can also use [Granian](https://pypi.org/project/granian/), [Daphne](https://pypi.org/project/daphne/), or any ASGI-compatible server. 2. **Test your application** - For the _API_ example: Visit [http://127.0.0.1:8000/](http://127.0.0.1:8000/) to see the "Hello World" response - For the _WebSocket_ example: Visit [http://127.0.0.1:8000/](http://127.0.0.1:8000/) and send a message. ================================================ FILE: docs/docs/log_queries.md ================================================ # Query Performance Logging Panther provides built-in query performance monitoring that logs the execution time of each database query when enabled. ## Configuration To enable query performance logging, set the `LOG_QUERIES` configuration option to `True`: ```python LOG_QUERIES = True # Default is False ``` ## How It Works When `LOG_QUERIES` is enabled, Panther automatically: - **Measures execution time** for every database query - **Logs performance data** to `logs/query.log` - **Includes query details** such as method name and execution time in milliseconds ## Log Format Each log entry follows this format: ``` INFO: | [Timestamp] | [Query] [Method] takes [Duration] ms ``` ### Example Log Entries ```python INFO: | 2023-03-19 20:37:27 | [Query] User.insert_one() takes 1.612 ms INFO: | 2023-03-19 20:37:28 | [Query] User.find() takes 45.234 ms INFO: | 2023-03-19 20:37:29 | [Query] Post.update_one() takes 12.891 ms INFO: | 2023-03-19 20:37:30 | [Query] Comment.delete_one() takes 3.445 ms ``` ## Use Cases Query logging is particularly useful for: - **Development**: Identifying slow queries during development - **Debugging**: Troubleshooting performance issues - **Optimization**: Finding bottlenecks in database operations - **Monitoring**: Tracking query performance over time ## Performance Considerations Keep `LOG_QUERIES = False` in production environments for optimal performance. Query logging adds a small overhead to each database operation, which can impact application performance under high load. ================================================ FILE: docs/docs/middlewares.md ================================================ # Middlewares in Panther Middlewares allow you to process requests and responses globally or for all or specific APIs. They are useful for tasks such as logging, timing, and other cross-cutting concerns. --- ## Global Middlewares To apply middlewares globally, define a `MIDDLEWARES` list in your configs for HTTP middlewares, and a `WS_MIDDLEWARE` list for WebSocket middlewares. **Only HTTP-related middlewares should be placed in `MIDDLEWARES`, and WebSocket middlewares must be placed in `WS_MIDDLEWARE`.** > **Note:** Previously, WebSocket middlewares were also defined in `MIDDLEWARES`. Now, you must define them in `WS_MIDDLEWARE` instead. Each item can be either a string (dotted path to the middleware class) or the class itself (useful for single-file applications): ```python class Middleware(HTTPMiddleware): pass MIDDLEWARES = [ 'core.middlewares.MyMiddleware', # Import by dotted path Middleware, # Or directly by class ] WS_MIDDLEWARE = [ 'core.middlewares.MyWebsocketMiddleware', # WebSocket middleware by dotted path MyWebsocketMiddleware, # Or directly by class ] ``` --- ## Per-API Middlewares You can assign middlewares to specific APIs, either class-based or function-based: ```python title="apis.py" linenums="1" from panther.app import API, GenericAPI from panther.middlewares import HTTPMiddleware class Middleware(HTTPMiddleware): pass # Class-Based API class MyAPI(GenericAPI): middlewares = [Middleware] # Function-Based API @API(middlewares=[Middleware]) def my_api(): pass ``` --- ## Middleware Execution Order Middlewares are executed in the following order: 1. Global middlewares: `__call__()` (enter) 2. Per-API middlewares: `__call__()` (enter) 3. Per-API middlewares: `dispatch` (return) 4. Global middlewares: `dispatch` (return) --- ## Creating Custom Middlewares ### Types of Middlewares Panther provides two types of middleware base classes: - `HTTPMiddleware`: For HTTP requests only - `WebsocketMiddleware`: For WebSocket connections only Make sure to inherit from the correct base class: ```python # For HTTP requests from panther.middlewares import HTTPMiddleware # For WebSocket connections from panther.middlewares import WebsocketMiddleware ``` ### Implementing a Middleware - Create a class inheriting from `HTTPMiddleware` or `WebsocketMiddleware`. - Implement an asynchronous `__call__` method. - Always return either `await self.dispatch(...)` or a `Response`/`GenericWebsocket` instance at the end of `__call__()`. #### Example: HTTP Middleware ```python title="middlewares.py" linenums="1" from datetime import datetime from panther.middlewares.base import HTTPMiddleware from panther.request import Request from panther.response import Response class CustomMiddleware(HTTPMiddleware): async def __call__(self, request: Request) -> Response: start_time = datetime.now() response = await self.dispatch(request=request) duration = datetime.now() - start_time print(f'Request took {duration.total_seconds()} seconds') return response ``` #### Example: WebSocket Middleware ```python title="middlewares.py" linenums="1" from datetime import datetime from panther.middlewares.base import WebsocketMiddleware from panther.websocket import GenericWebsocket, Websocket class TimerMiddleware(WebsocketMiddleware): async def __call__(self, connection: Websocket) -> GenericWebsocket: start_time = datetime.now() response = await self.dispatch(connection=connection) duration = datetime.now() - start_time print(f'Connection lasted {duration.total_seconds()} seconds') return response ``` --- ## Built-in Middlewares Panther provides several built-in middlewares to help with common tasks. Below are the available options and how to use them: ### CORS Middleware - **Purpose:** Enables Cross-Origin Resource Sharing (CORS) for your APIs. - **Usage:** Add `panther.middlewares.CORSMiddleware` to your global `MIDDLEWARES` list. - **Configuration:** Requires specific global settings. See the [CORS Middleware documentation](cors.md) for configuration details. ### Monitoring Middleware - **Purpose:** Logs request and connection data for monitoring and analytics. - **Usage:** Add `panther.middlewares.MonitoringMiddleware` to your global `MIDDLEWARES` list. - **Note:** This middleware or `WebsocketMonitoringMiddleware` is required if you want to use the `panther monitor` command. ### WebSocket Monitoring Middleware - **Purpose:** Similar to `MonitoringMiddleware`, but specifically logs data for WebSocket connections. - **Usage:** Add `panther.middlewares.WebsocketMonitoringMiddleware` to your global `MIDDLEWARES` list if you want to monitor WebSocket traffic. - **Note:** This middleware or `MonitoringMiddleware` is required if you want to use the `panther monitor` command. --- ## Tips - Use global middlewares for logic that should apply to all requests. - Use per-API middlewares for logic specific to certain endpoints. - Always ensure your `__call__` method is asynchronous and returns the appropriate value. ================================================ FILE: docs/docs/model.md ================================================ # Panther Models Panther models allow you to define your data schema and interact with the database using Python classes. They are built on top of `pydantic.BaseModel` and extend its functionality with database query capabilities. ## Creating a Model To create a Panther model, define a class that inherits from `panther.db.Model`: ```python title="app/models.py" linenums="1" from panther.db import Model class User(Model): username: str age: int is_active: bool ``` Panther models inherit from both `pydantic.BaseModel` and `panther.db.Query`, giving you access to data validation and database queries. ## Defining Attributes You can define model attributes (columns) using Python type hints. Each attribute type is handled as follows: ### General Types - **str**, **int**, **bool**: Saved in the database as-is. **Example:** ```python title="app/models.py" linenums="1" from panther.db import Model class Product(Model): name: str price: int in_stock: bool ``` ### List - **list**: Each item in the list is processed according to its type. The child type can be a primitive, a `pydantic.BaseModel`, or another `Model`. **Example:** ```python title="app/models.py" linenums="1" from pydantic import BaseModel from panther.db import Model class Book(BaseModel): title: str author: str tags: list[str] class Library(Model): name: str books: list[Book] class School(Model): name: str libraries: list[Library] ``` ### Dictionary - **dict**: Each value in the dictionary is processed according to its type. Only plain `dict` is supported (not typed dicts like `dict[str, int]`). **Example:** ```python title="app/models.py" linenums="1" from panther.db import Model class Config(Model): settings: dict ``` ### Nested Models - **pydantic.BaseModel**: Treated like a dictionary, but with type information for each item. Each item is processed according to its type. **Example:** ```python title="app/models.py" linenums="1" from pydantic import BaseModel from panther.db import Model class Address(BaseModel): city: str zipcode: str class Customer(Model): name: str address: Address ``` ### Foreign Keys - **panther.db.Model**: Treated as a foreign key relationship. 1. The related model's value is stored in the database. 2. Its `id` is saved in the main document (table). 3. This process is handled automatically, so you always have access to all attributes of the related model. 4. Panther retrieves the corresponding value from the database and returns it as a fully populated model instance. **Example:** ```python title="app/models.py" linenums="1" from panther.db import Model class Department(Model): name: str class Employee(Model): name: str department: Department ``` ### Optional Attributes - You can make an attribute optional by using a union with `None` (e.g., `str | None`) and providing a default value (e.g., `= None`). - If you make an attribute optional, you must assign a default value. **Example:** ```python title="app/models.py" linenums="1" from panther.db import Model class Article(Model): title: str summary: str | None = None # Optional attribute with default value ``` ### File Types - **panther.file_handler.File**: Handles file uploads and provides file manipulation capabilities. - **panther.file_handler.Image**: Specialized File class for image uploads with automatic MIME type validation. !!! tip "Comprehensive File Handling Guide" For detailed information about file handling, including advanced features, best practices, and troubleshooting, see the dedicated [File Handling](file_handling.md) documentation. **Example:** ```python title="app/models.py" linenums="1" from datetime import datetime from panther.db import Model from panther.file_handler import File, Image class Document(Model): title: str file: File uploaded_at: datetime class Profile(Model): name: str avatar: Image bio: str | None = None ``` When using File types in models: - The file metadata (name, content type) is preserved - The file path is stored in the database - The actual file content is automatically saved to disk when inserting the model - You can access file properties and methods on the model instance **Example usage:** ```python # Create a document with file document = await Document.insert_one({ 'title': 'My Document', 'file': File(file_name='document.pdf', content_type='application/pdf', file=file_bytes), 'uploaded_at': datetime.now() }) # Access file properties print(document.file.file_name) # 'document.pdf' print(document.file.content_type) # 'application/pdf' print(document.file.size) # File size in bytes # Read file content with document.file as f: content = f.read() ``` ================================================ FILE: docs/docs/open_api.md ================================================ # OpenAPI Integration in Panther Panther automatically generates an OpenAPI specification for your APIs. This makes it easy to document, test, and share your API endpoints. ## How to Enable OpenAPI in Your Project Panther provides multiple OpenAPI UI options that you can import directly into your project. You don't need to add the bundled `openapi.urls.url_routing` - instead, you can choose the specific UI component you prefer. ### Available OpenAPI UI Components Panther offers several OpenAPI UI options available in `panther.openapi.views`: - **`ScalarOpenAPI`** - Modern, customizable API documentation UI - **`SwaggerOpenAPI`** - Classic Swagger UI interface - **`RedocOpenAPI`** - Clean, responsive documentation interface - **`RapiDocOpenAPI`** - Fast, lightweight API documentation - **`SpotlightOpenAPI`** - Stoplight Elements integration ### Adding OpenAPI to Your Project You can import and use any of these UI components directly in your URL configuration: ```python title="urls.py" linenums="1" from panther.openapi.views import ScalarOpenAPI, SwaggerOpenAPI, RedocOpenAPI url_routing = { 'docs/scalar/': ScalarOpenAPI, # Modern UI at /docs/scalar/ 'docs/swagger/': SwaggerOpenAPI, # Classic Swagger at /docs/swagger/ 'docs/redoc/': RedocOpenAPI, # Clean interface at /docs/redoc/ # Other urls } ``` ### Alternative: Using All UI Components If you want to include all OpenAPI UI options at once, you can still use the bundled routing: ```python title="urls.py" linenums="1" from panther.openapi.urls import url_routing as openapi_url_routing url_routing = { 'docs/': openapi_url_routing, # Includes all UI options # Other urls } ``` This will make the following endpoints available: - `/docs/scalar/` - Scalar UI - `/docs/swagger/` - Swagger UI - `/docs/redoc/` - ReDoc UI - `/docs/rapidoc/` - RapiDoc UI - `/docs/spotlight/` - Spotlight UI ## How Panther Determines Response Models and Status Codes Panther follows a specific logic to generate the OpenAPI YAML for your APIs: 1. **`output_schema`**: Panther first looks for an `output_schema` attribute to generate the OpenAPI documentation. This is the recommended and most accurate way to specify your response model and status code. 2. **`output_model`**: If `output_schema` does not exist, Panther looks for an `output_model` attribute to generate the response type. It will also attempt to extract the status code from your source code. 3. **Source Code Analysis**: If neither `output_schema` nor `output_model` is available, Panther tries to extract the response data and status code directly from your source code using static analysis with `ast`. For best results and more accurate documentation, always specify `output_schema` in your APIs. ## How Panther Generates OpenAPI Docs Panther inspects your API views for an `output_schema` attribute. This attribute should be an instance of `panther.openapi.OutputSchema`, which describes the response model and status code for your endpoint. - `model` in `OutputSchema` can be either a `pydantic.BaseModel` or a `panther.serializer.ModelSerializer`. - `status_code` should be an integer (e.g., `status.HTTP_200_OK`). ### Example === "Function-Base API" ```python linenums="1" from pydantic import BaseModel from panther import status from panther.app import API from panther.openapi import OutputSchema class UserSerializer(BaseModel): username: str age: int @API(output_schema=OutputSchema(model=UserSerializer, status_code=status.HTTP_200_OK)) def user_api(): ... ``` === "Class-Base API" ```python linenums="1" from pydantic import BaseModel from panther import status from panther.app import GenericAPI from panther.openapi import OutputSchema class UserSerializer(BaseModel): username: str age: int class UserAPI(GenericAPI): output_schema = OutputSchema(model=UserSerializer, status_code=status.HTTP_200_OK) ... ``` --- **Note:** The OpenAPI integration is currently in beta. Contributions, feedback, and ideas are very welcome! ================================================ FILE: docs/docs/query.md ================================================ # Querying Documents Panther ODM provides a simple, async interface for interacting with your database models. This guide covers the most common operations. --- ### find_one Get a single document matching the filter. ```python from app.models import User user: User = await User.find_one(id=1, name='Ali') # or user: User = await User.find_one({'id': 1, 'name': 'Ali'}) ``` - **Returns:** An instance of the model or `None` if not found. --- ### find Get multiple documents matching the filter. ```python users: Cursor = await User.find(age=18, name='Ali') ``` - **Returns:** A `Cursor` object (see [Cursor Usage](#cursor-usage)). #### Chaining You can chain `skip()`, `limit()`, and `sort()`: ```python users: Cursor = await User.find(age=18).skip(10).limit(10).sort([('age', -1)]) ``` --- ### all Get all documents in the collection. ```python users: Cursor = await User.all() ``` --- ### first / last Get the first or last document matching the filter. ```python user: User = await User.first(age=18) user: User = await User.last(age=18) ``` --- ### aggregate Perform an aggregation (MongoDB only). ```python pipeline = [ {'$match': {...}}, {'$group': {...}}, # ... ] users: Iterable[dict] = await User.aggregate(pipeline) ``` --- ### count Count documents matching the filter. ```python count: int = await User.count(age=18) ``` --- ## Inserting Documents ### insert_one Insert a single document. ```python user: User = await User.insert_one(age=18, name='Ali') ``` --- ### insert_many Insert multiple documents. ```python users = [ {'age': 18, 'name': 'Ali'}, {'age': 17, 'name': 'Saba'}, ] users: list[User] = await User.insert_many(users) ``` --- ## Updating Documents ### update Update the current document instance. ```python user = await User.find_one(name='Ali') await user.update(name='Saba') ``` --- ### update_one / update_many Update one or more documents matching a filter. ```python is_updated: bool = await User.update_one({'id': 1}, name='Ali') updated_count: int = await User.update_many({'name': 'Saba'}, age=18) ``` --- ### save Save the document (insert or update). ```python user = User(name='Ali') await user.save() ``` --- ## Deleting Documents ### delete Delete the current document instance. ```python user = await User.find_one(name='Ali') await user.delete() ``` --- ### delete_one / delete_many Delete one or more documents matching a filter. ```python is_deleted: bool = await User.delete_one(age=18) deleted_count: int = await User.delete_many(age=18) ``` --- ## Special Methods ### find_one_or_insert Get or insert a document. ```python user, is_inserted = await User.find_one_or_insert(age=18, name='Ali') ``` For correctness under concurrency, define a unique index/constraint on the queried fields. --- ### find_one_or_raise Get a document or raise `NotFoundAPIError`. ```python user: User = await User.find_one_or_raise(age=18) ``` --- ## Cursor Usage - The `find()` and `all()` methods return a `Cursor` object. - You can iterate over it or use it as a list. - For MongoDB: `from panther.db.cursor import Cursor` - For PantherDB: `from pantherdb import Cursor` --- ## Notes - All methods are async unless otherwise noted. - Filters can be passed as keyword arguments or as a dictionary. - Some features (like `aggregate`) are only available for MongoDB. ================================================ FILE: docs/docs/redis.md ================================================ # Redis Integration Redis is a fast, in-memory key-value store commonly used for caching, session management, real-time analytics, and pub/sub messaging. Panther supports Redis natively, allowing you to leverage its power for authentication, caching, throttling, and websocket pub/sub features. ## Configuration To enable Redis in Panther, fill out the `REDIS` block in your configuration file: ```python REDIS = { 'class': 'panther.db.connections.RedisConnection', 'host': 'localhost', # Optional, default is 'localhost' 'port': 6379, # Optional, default is 6379 'db': 0, # Optional, default is 0 # Add any other redis-py supported parameters here } ``` **Note:** The arguments are the same as those accepted by `redis.Redis.__init__()` from the [redis documentation](https://redis.readthedocs.io/en/latest/). ## How It Works - Panther creates an asynchronous Redis connection based on the `REDIS` block you define in your configuration. - You can access the Redis connection via: ```python from panther.db.connections import redis ``` - Example usage: ```python from panther.db.connections import redis await redis.set('name', 'Ali') result = await redis.get('name') print(result) ``` ## Features Using Redis - **Authentication:** Store and retrieve JWT tokens for logout functionality. - **Caching:** Cache responses for faster access. - **Throttling:** Track and limit request rates. - **WebSocket:** Manage pub/sub connections for real-time features. ================================================ FILE: docs/docs/release_notes.md ================================================ # Panther Release Notes ### 5.2.3 - Fix background task async loop mismatch with app event loop registration (#146) - Escape Mongodb search input before building regex (#147) ### 5.2.2 - Fix function API request isolation - Make `find_one_or_insert` race-safe on duplicate key conflicts ### 5.2.0 - Update requirements for `Python3.14` compatibility ### 5.1.0 - Add `FileResponse` ### 5.0.2 - Minor performance improvement. ### 5.0.0 - Add Built-In `OpenAPI` - Add Built-In `AdminPanel` - **File Handling Improvements**: - Make `File.save()` method **idempotent** - multiple calls return the same path - Add directory path support - paths ending with `/` are treated as directories - Automatic directory creation when saving files - Enhanced file path handling for both string and Path objects - Support `Set-Cookie` in Response - Support `application/x-www-form-urlencoded` Content Type - Support Different `Middlewares` Per API - Support `asyncio` in `panther shell` - Support `Boolean` type in `load_env()` - Add `CORSMiddleware` - Add `RedirectResponse` - Optimized `ORM` with smarter `ObjectID` handling - Add CLI `createuser` command - Deprecated: - Usage of `auth` and `permissions` in `API`, `GenericAPI` and `GenericWebsocket` has been change, make sure to check the docs. - `DEFAULT_CACHE_EXP` in configs. - Rename `cache_exp_time` to `cache` in `@API` and `GenricAPI`. - Rename `panther.throttling.Throttling` to `panther.throttling.Throttle`. - Remove `MONITORING` and move its functionality as middleware in `panther.middelwares.monitoring.MonitoringMiddleware` . - `BaseUser.check_password(new_password=...)` is renamed to `password`. - `TemplateResponse(path=...)` is renamed to `name`. - Middlewares does not support custom args for their `__init__`. - New Style of Middleware declaration [[Documentation]](middlewares.md). - New background task declaration, `background_tasks.add_task(BackgroundTask(...))` --> `BackgroundTask(...).submit()`. - `REDIS` block in configs does not support `websocket_db` anymore. - `MIDDLEWARE` does not support WebSocket middlewares anymore, you have to define them in `WS_MIDDLEWARE`. ### 4.3.7 - Improve MultiPart-FormData Regex ### 4.3.6 - Support middleware class in MIDDLEWARES ### 4.3.4 - Cache Response Headers ### 4.3.4 - Fix an issue on TemplateResponse when TEMPLATES_DIR is '.' ### 4.3.3 - Fix not provided context in TemplateResponse ### 4.3.2 - Support `Python 3.13` ### 4.3.0 - Support `Jinja2 Template Engine` ### 4.2.0 - Support `OPTIONS` method ### 4.1.3 - Add `reload()` method to queries - Add `get_input_model()` & `get_output_model()` to `GenericAPI` - Support `per_delete()` & `post_delete()` in `DeleteAPI` - Support mongodb operators in `update(`) & `update_one()` ### 4.1.2 - Fix some issues for `Windows` ### 4.1.1 - Fix an issue in `Response.prepare_data()` when `data` is `Cursor` - Split `paginate` and `template` in `Pagination` ### 4.1.0 - Support `prepare_response` in `Serializers` - Rename `objects()` to `cursor()` ### 4.0.1 - Fix an issue in `startup` lifespan ### 4.0.0 - Move `database` and `redis` connections from `MIDDLEWARES` to their own block, `DATABASE` and `REDIS` - Make `Database` queries `async` - Make `Redis` queries `async` - Add `StreamingResponse` - Add `generics` API classes - Add `login()` & `logout()` to `JWTAuthentication` and used it in `BaseUser` - Support `Authentication` & `Authorization` in `Websocket` - Rename all exceptions suffix from `Exception` to `Error` (https://peps.python.org/pep-0008/#exception-names) - Support `pantherdb 2.0.0` (`Cursor` Added) - Remove `watchfiles` from required dependencies - Support `exclude` and `optional_fields` in `ModelSerializer` - Minor Improvements ### 3.9.0 - Change the style of `ModelSerializer` usage ### 3.8.2 - Add `content-type = application/json` header in raise response of `__call__` ### 3.8.1 - Fix an issue in `_create_pantherdb_session()` ### 3.8.0 - Handle WebSocket connections when we have multiple workers with `multiprocessing.Manager` ### 3.7.0 - Add `ModelSerializer` ### 3.6.0 - Use `observable` pattern for loading database middleware and inheritance of the `Query` class - Remove `IDType` from the `Model` - Change `encrypt_password()` method, now uses `scrypt` + `md5` ### 3.5.1 - Set default behavior of `GenericWebsocket.connect` to ignore the connection (`reject`) ### 3.5.0 - Add `WebsocketTestClient` ### 3.4.0 - Support `WebsocketMiddleware` ### 3.3.2 - Add `content-length` to response header ### 3.3.1 - Check `ruff` installation on startup - Fix an issue in `routing` ### 3.3.0 - Add Auto Reformat Code ### 3.2.4 - Add all() query - Add tests for `pantherdb`, `load_configs()`, `status.py`, `Panel`, `multipart`, `request headers` - Refactor `Headers()` class - Check `uvloop` installation on `Panther init` - Minor Improvement ### 3.2.1 - Move `Startup` to `__call__` ### 3.2.0 - Support `Startup` & `Shutdown` Events ### 3.1.5 - Support `Websocket` in the `monitoring` - Refactor `collect_all_models()` ### 3.1.4 - Check ws redis connection on the `init` - Refactor `Monitoring` class and usage - Improve `logging` config - Check database connection before query ### 3.1.3 - Add `Image` base class - Add `size` to `File` base class - Improve the way of loading `configs` in `single-file` structure - Improve `background_tasks.py`, `generate_ws_connection_id()` - `bpython` removed from being the default python shell - Improve `load_middlewares()` error handling - Print `configs` on the `run` - Add `requirements.txt` for development - Update `roadmap.jpg`, `README.md` ### 3.1.2 - Add new methods to `BackgroundTask` - `every_seconds()` - `every_minutes()` - `every_hours()` - `every_days()` - `every_weeks()` - `at()` ### 3.1.1 - Upgrade `PantherDB` version - Add `first()`, `last()` queries ### 3.1.0 - Add `BackgroundTasks` ### 3.0.3 - Add `find_one_or_raise` query - Add `last_login` to `BaseUser` - Add `refresh_life_time` to `JWTConfig` - Add `encode_refresh_token()` to `JWTAuthentication` - Add `encrypt_password()` - Handle `PantherException` - Handle `RedisConnection` without `connection_pool` ### 3.0.2 - Added 'utf-8' encoding while opening the file "README.md" in setup.py - Fixed panther shell not working issue in windows. - Added a condition to raise error if no argument is passed to panther command in cli. ### 3.0.1 - Assume content-type is 'application/json' if it was empty - Fix an issue on creating instance of model when query is done ### 3.0.0 - Support **Websocket** - Implement **Built-in TestClient** - Support Single-File Structure - Support `bytes` as `Response.data` - Add `methods` to `API()` - Change `Request.pure_data` to `Request.data` - Change `Request.data` to `Request.validated_data` - Change `panther.middlewares.db.Middleware` to `panther.middlewares.db.DatabaseMiddleware` - Change `panther.middlewares.redis.Middleware` to `panther.middlewares.redis.RedisMiddleware` - Fix `panther run` command - Minor Improvement ### 2.4.2 - Don't log content-type when it's not supported ### 2.4.1 - Fix an issue in collect_all_models() in Windows ### 2.4.0 - Handle Complex Multipart-FormData ### 2.3.3 - Fix a bug in response headers ### 2.3.2 - Fix a bug in Template ### 2.3.1 - Handle PlainTextResponse - Handle Custom Header in Response - Change the way of accepting 'URLs' in configs (relative -> dotted) - Fix an issue in collect_all_models() ### 2.3.0 - Handle HTMLResponse ### 2.2.0 - Supporting File ### 2.1.6 - Fix validation errors on nested inputs ### 2.1.5 - Fix response of nested Models in _panel// ### 2.1.4 - Add access-control-allow-origin to response header ### 2.1.3 - Upgrade greenlet version in requirements for python3.12 ### 2.1.2 - Add ruff.toml - Add Coverage to workflows - Fix a bug for running in Windows ### 2.1.1 - Fix a bug in main.py imports ### 2.1.0 - Support Sync APIs ### 2.0.0 - Supporting class-base APIs ### 1.7.20 - Fix an issue in find_endpoint() ### 1.7.19 - Fix an issue in routing - Fix an issue on return complex dict Response ### 1.7.18 - Remove uvloop from requirements for now (we had issue in windows) ### 1.7.16 - Trying to fix requirements for windows - Minor improvement in BaseMongoDBQuery ### 1.7.15 - Fix an issue in handling form-data ### 1.7.14 - Add Cache and Throttling doc to FirstCrud - Fix an issue in BasePantherDBQuery._merge() ### 1.7.13 - Hotfix validation of _id in Model() ### 1.7.12 - Fix a bug in routing ### 1.7.11 - Fix an issue in template ### 1.7.10 - Fix a bug in `collect_urls` and rename it to `flatten_urls` - Add General Tests - Compatible with python3.10 (Not Tested) - Working on docs ### 1.7.9 - Working on doc ### 1.7.8 - Fix a bug - Update docs ### 1.7.8 - Fix a bug - Update docs ### 1.7.7 - Fix a bug ### 1.7.5 - Change the way of raising exception in JWTAuthentication - Rename User model to BaseUser - Fix template ### 1.7.4 - Crop Logo ### 1.7.3 - Add Throttling Doc - Fix some issue in Doc ### 1.7.2 - Add Throttling to example - Customize install_requires in setup.py - Improve monitoring cli command ### 1.7.1 - Rename db BaseModel to Model - Add more docs ### 1.7.0 - Add Throttling ### 1.6.1 - Add AdminPermission ### 1.6.0 - Handle Permissions ### 1.5.2 - Improve Response data serialization - Fix a bug in JWTAuthentication ### 1.5.1 - Fix error messages ### 1.5.0 - Refactor Mongodb ODM - Minor Improvement ### 1.4.0 - Add QUERY_LOG ### 1.3.2 - Add Uvicorn to the setup requirements - Update Readme ### 1.3.1 - Fix a bug in project creation template - Fix a bug in caching ### 1.3.0 - Add PantherDB to Panther - Remove tinydb ### 1.2.7 - Fix a bug while using tinydb ### 1.2.6 - Update Readme ### 1.2.5 - Fix install_requires issue - Add benchmarks to docs ### 1.2.4 - Remove Uvicorn From install_requires - Working on Docs ### 1.2.3 - Fix URL Routing ### 1.2.1 - Path Variable Handled Successfully ### 1.2.0 - Read multipart/form-data with Regex ### 1.1.9 - Refactoring code style with ruff - Add asyncio.TaskGroup() ### 1.1.8 - Refactor cli run command ### 1.1.7 - Add benchmark pictures to doc ### 1.1.5 - Clean Readme - Clean main.py ### 1.1.4 - Update Readme ### 1.1.3 - Fix a query in TinyDB ### 1.1.2 - Add delete_many query to TinyDB ### 1.1.1 - Add TinyDB ### 1.1.0 - Debug the Template ### 1.0.9 - Handle string exceptions (raise them as detail: error) - Little debug on MongoQueries ### 1.0.7 - Working on queries - Fix a bug in query methods ### 1.0.6 - Update the Template ### 1.0.4 - Debug template ### 1.0.2 - Add global config - Split the BaseModels - Worked on MongoQuery - Set Mongo as default database while creating project - Minor Improvement ### 1.0.1 - Add alembic To Project ### 1.0. - Refactor & Complete the CLI ### 0.1.9 - Fix install_requires ### 0.1.8 - Update Readme ### 0.1.7 - Update Readme ### 0.1.6 - Handle Most Types as Data in Response ### 0.1.4 - Working On DB Connection ### 0.0.1 - Make It Ready For PyPI ================================================ FILE: docs/docs/roadmap.md ================================================ # Roadmap ## Current Status ![Roadmap](https://raw.githubusercontent.com/AliRn76/panther/master/docs/docs/images/roadmap.jpg) ## Contributing We welcome contributions! Here's how you can help: 1. Report bugs and suggest features through [GitHub Issues](https://github.com/AliRn76/panther/issues) 2. Submit pull requests for bug fixes and new features 3. Improve documentation 4. Share your experience and use cases 5. Help with testing and quality assurance ## Community Feedback Your input is valuable! If you have any feature requests or suggestions for future development, please: 1. Check existing issues to avoid duplicates 2. Create a new issue with a clear description 3. Include use cases and potential implementation ideas 4. Label the issue appropriately ================================================ FILE: docs/docs/ruff.md ================================================ # Auto-Reformatting with Ruff Panther supports automatic code reformatting using [Ruff](https://docs.astral.sh/ruff/), a fast Python linter and formatter written in Rust. ## Quick Setup To enable automatic code reformatting, set `AUTO_REFORMAT` to `True` in your configuration: ```python AUTO_REFORMAT = True # Default is False ``` ## How It Works When `AUTO_REFORMAT` is enabled, Panther will automatically reformat your code: - **On every application run** - Code is reformatted when you start your application. - **With `--reload` flag** - Code is reformatted on every file change during development This ensures your codebase maintains consistent formatting standards automatically. ## Installation The auto-reformatting feature requires the Ruff package. Install it using pip: ```bash pip install ruff ``` ## Configuration You can create a custom `ruff.toml` file in your project root to configure formatting rules: ```toml title="ruff.toml" linenums="1" # Set the maximum line length line-length = 120 # Set the indentation style indent-width = 4 # Enable/disable specific rules select = ["E", "F", "I"] ignore = ["E501"] [format] # Formatting options quote-style = "single" indent-style = "space" skip-magic-trailing-comma = false line-ending = "auto" ``` ## Benefits - **Consistent Code Style**: Automatic formatting ensures all code follows the same style - **Time Saving**: No need to manually format code or run formatters separately - **Team Collaboration**: Everyone on the team gets consistent formatting automatically - **Fast Performance**: Ruff is extremely fast, making the formatting process seamless ## References - [Ruff Documentation](https://docs.astral.sh/ruff/) - [Ruff Formatter Configuration](https://docs.astral.sh/ruff/formatter/) - [Ruff Rules Reference](https://docs.astral.sh/ruff/rules/) ================================================ FILE: docs/docs/serializer.md ================================================ # Panther Serializers Panther provides flexible ways to define serializers for your APIs. Serializers are responsible for validating and transforming input data. This guide covers the three supported styles and when to use each. --- ## Introduction **serializer** in Panther is a class that defines how input data is validated and (optionally) transformed before being processed by your API logic. Panther supports three main styles: - **Pydantic Serializer**: Use a standard Pydantic model. - **ModelSerializer**: Generate fields from a Panther model. - **ModelSerializer + Pydantic**: Combine model-based fields with Pydantic features and custom validation. --- ## Style 1: Pydantic Serializer Use a regular Pydantic class as your serializer. This is the most direct approach and is ideal for simple use cases or when you want full control over the fields. ```python linenums="1" from pydantic import BaseModel, Field from panther.app import API from panther.request import Request from panther.response import Response class UserSerializer(BaseModel): username: str password: str first_name: str = Field(default='', min_length=2) last_name: str = Field(default='', min_length=4) @API(input_model=UserSerializer) async def serializer_example(request: Request): return Response(data=request.validated_data) ``` --- ## Style 2: ModelSerializer Use Panther's `ModelSerializer` to automatically generate serializer fields from your model. This is useful for DRY code and consistency between your models and serializers. ```python linenums="1" from pydantic import Field from panther.app import API from panther.db import Model from panther.request import Request from panther.response import Response from panther.serializer import ModelSerializer class User(Model): username: str password: str first_name: str = Field(default='', min_length=2) last_name: str = Field(default='', min_length=4) # Option 1: Specify fields explicitly class UserModelSerializer(ModelSerializer): class Config: model = User fields = ['username', 'first_name', 'last_name'] required_fields = ['first_name'] # Option 2: Exclude specific fields class UserModelSerializer(ModelSerializer): class Config: model = User fields = '*' required_fields = ['first_name'] exclude = ['id', 'password'] @API(input_model=UserModelSerializer) async def model_serializer_example(request: Request): return Response(data=request.validated_data) ``` --- ## Style 3: ModelSerializer with Pydantic Features Combine `ModelSerializer` with Pydantic features for advanced use cases. This allows you to add custom fields, validators, and configuration. ```python linenums="1" from pydantic import Field, field_validator, ConfigDict from panther.app import API from panther.db import Model from panther.request import Request from panther.response import Response from panther.serializer import ModelSerializer class User(Model): username: str password: str first_name: str = Field(default='', min_length=2) last_name: str = Field(default='', min_length=4) class UserModelSerializer(ModelSerializer): model_config = ConfigDict(str_to_upper=True) age: int = Field(default=20) is_male: bool username: str class Config: model = User fields = ['first_name', 'last_name'] required_fields = ['first_name'] optional_fields = ['last_name'] @field_validator('username') def validate_username(cls, username): print(f'{username=}') return username @API(input_model=UserModelSerializer) async def model_serializer_example(request: Request): return Response(data=request.validated_data) ``` --- ## Comparison Table | Feature | Pydantic Serializer | ModelSerializer | ModelSerializer + Pydantic | |------------------------|:------------------:|:--------------:|:-------------------------:| | Model-based fields | ❌ | ✅ | ✅ | | Custom fields | ✅ | ❌ | ✅ | | Pydantic validators | ✅ | ❌ | ✅ | | Field inclusion/exclude| Manual | Configurable | Configurable | | Best for | Simple cases | DRY, model-aligned | Advanced/Hybrid | --- ## Notes & Best Practices - `ModelSerializer` uses your model's field types and default values for validation. - `Config.model` and `Config.fields` are required for `ModelSerializer`. - Use `Config.required_fields` to force fields to be required. - Use `Config.optional_fields` to force fields to be optional. - A field cannot be in both `required_fields` and `optional_fields`. - If you use `required_fields` or `optional_fields`, those fields must also be listed in `fields`. - You can use `'*'` for `fields`, `required_fields`, or `optional_fields` to include all model fields. - `Config.exclude` is useful when `fields` is set to `'*'`. - You can add custom fields and validators when combining `ModelSerializer` with Pydantic features. --- ## File Handling in Serializers When working with file uploads, Panther's `File` and `Image` classes integrate seamlessly with serializers. !!! tip "Comprehensive File Handling Guide" For detailed information about file handling, including advanced features, best practices, and troubleshooting, see the dedicated [File Handling](file_handling.md) documentation. ### Basic File Serialization ```python title="app/serializers.py" linenums="1" from panther.serializer import ModelSerializer class FileUploadSerializer(ModelSerializer): class Config: model = FileUpload fields = ['file', 'description'] required_fields = ['file'] class ImageUploadSerializer(ModelSerializer): class Config: model = ImageUpload fields = ['image', 'title'] required_fields = ['image'] ``` ### File Validation ```python title="app/serializers.py" linenums="1" from pydantic import field_validator from panther import status from panther.exceptions import APIError from panther.file_handler import File from panther.serializer import ModelSerializer class DocumentUploadSerializer(ModelSerializer): class Config: model = DocumentUpload fields = ['file', 'title'] required_fields = ['file'] @field_validator('file') @classmethod def validate_file_size(cls, file: File) -> File: if file.size > 10 * 1024 * 1024: # 10MB limit raise APIError( detail='File size must be less than 10MB', status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE ) return file ``` ### File Properties When working with `File` objects in serializers, you have access to: - `file.file_name`: The original filename - `file.content_type`: The MIME type - `file.size`: File size in bytes - `file.file`: The file content as bytes ================================================ FILE: docs/docs/single_file.md ================================================ # Using Panther in a Single-File Structure If you prefer to work with `Panther` in a single Python file, follow the steps below to quickly set up and run your API. ## Step 1: Define Your APIs Write your API endpoints as usual using the `@API()` decorator or inheriting from `GenericAPI`: ```python title="main.py" linenums="1" from panther.app import API @API() async def hello_world_api(): return {'detail': 'Hello World'} ``` ## Step 2: Create a URL Routing Dictionary Map your endpoints to their respective URLs in a dictionary (commonly named `url_routing`): ```python title="main.py" linenums="6" url_routing = { '/': hello_world_api, } ``` ## Step 3: Initialize the Panther App Create an instance of the `Panther` app, passing your current module name and the URL routing dictionary: ```python title="main.py" linenums="1" from panther import Panther from panther.app import API @API() async def hello_world_api(): return {'detail': 'Hello World'} url_routing = { '/': hello_world_api, } app = Panther(__name__, configs=__name__, urls=url_routing) ``` ## Step 4: Run Your Project Use the following command to start your application: ```bash panther run main:app ``` --- ## Additional Notes - The `urls` parameter is required unless you provide the URLs via configuration. - When you pass `configs=__name__` to the `Panther` constructor, Panther will load the configuration from the current file. If you omit this, Panther defaults to loading configuration from `core/configs.py`. ================================================ FILE: docs/docs/templates.md ================================================ # Template Responses Panther provides `TemplateResponse` for serving HTML templates with dynamic content. This guide explains how to configure and use templates in your Panther application. ## Configuration To use `TemplateResponse` with template files, you must configure the `TEMPLATES_DIR` setting in your application configuration. ### Setting Templates Directory The `TEMPLATES_DIR` can be a single string or a list of strings representing template directory paths: ```python # Single directory TEMPLATES_DIR = 'templates/' # Multiple directories (searched in order) TEMPLATES_DIR = ['templates/', 'app/templates/', 'shared/templates/'] ``` **Default value:** `'./'` (current directory) ## Usage ### Using Template Files (Recommended) When you have template files, use the `name` parameter to specify the template file: ```python linenums="1" from panther.app import API from panther.response import TemplateResponse @API() def my_html(): return TemplateResponse( name='index.html', context={'name': 'Ali', 'title': 'Welcome'} ) ``` **Benefits:** - Cleaner code separation - Template reusability - Better maintainability ### Using Inline HTML Content For simple cases or when you need to generate HTML dynamically, you can pass the HTML content directly: ```python linenums="1" from panther.app import API from panther.response import TemplateResponse @API() def my_html(): html_content = open('index.html', 'r').read() return TemplateResponse( source=html_content, context={'name': 'Ali', 'title': 'Welcome'} ) ``` **Note:** This approach requires you to manage the HTML content manually and doesn't provide the benefits of template files. ## Template Context The `context` parameter allows you to pass variables to your templates: ```python linenums="1" from panther.app import API from panther.response import TemplateResponse def get_user(user_id: int): return ... @API() def user_profile(user_id: int): user = get_user(user_id) # Your user fetching logic return TemplateResponse( name='profile.html', context={ 'user': user, 'page_title': f'{user.name}\'s Profile', 'is_admin': user.role == 'admin' } ) ``` ## Example Project Structure ``` my_panther_app/ ├── core/ │ ├── __init__.py │ ├── configs.py │ └── urls.py ├── app/ │ ├── __init__.py │ ├── urls.py │ └── views.py ├── templates/ │ ├── base.html │ ├── index.html │ └── profile.html └── main.py ``` With this structure, your configuration would be: ```python title="core/configs.py" TEMPLATES_DIR = 'templates/' ``` ================================================ FILE: docs/docs/timezone.md ================================================ # Timezone Configuration Panther provides built-in timezone support to ensure consistent datetime handling across your application. ## Configuration You can specify your timezone in the configuration using the `TIMEZONE` setting: ```python TIMEZONE = 'UTC' # Options are available in `pytz.all_timezones` ``` To see all available timezone options, you can run: ```python import pytz print(pytz.all_timezones) ``` This will show you the complete list of timezone identifiers you can use in your `TIMEZONE` configuration. ## Using Panther's Timezone-Aware DateTime Panther provides a utility function `panther.utils.timezone_now()` that returns the current datetime relative to your configured timezone: ```python from panther.utils import timezone_now # Get current datetime in your configured timezone current_time = timezone_now() print(current_time) # 2024-01-15 14:30:00+00:00 (if TIMEZONE='UTC') ``` ## Where It's Used The `timezone_now()` function is automatically used in several Panther components: - **User Authentication**: `BaseUser.date_created` and `BaseUser.last_login` use timezone-aware timestamps - **Background Tasks**: DateTime checking for scheduled and queued tasks > Use `timezone_now()` Instead of `datetime.now()`, to maintain consistency across your application. ================================================ FILE: docs/docs/urls.md ================================================ # URL Configuration in Panther Panther requires you to define your application's URL routing. You can provide this configuration in two ways: 1. **Global Config:** Specify a dotted path to a dictionary of URLs in your configuration file. 2. **Direct Argument:** Pass the URL dictionary directly to the `Panther(...)` constructor (commonly used in single-file applications). ## How to Configure URLs - The `URLs` config should be a dotted path (string) pointing to your root URL dictionary. - Example: `URLs = 'path.to.module.url_dict'` - The target of `URLs` **must** be a Python `dict`. - In the URL dictionary, each **key** is a URL path, and each **value** is either an endpoint (function/class) or another nested dictionary for sub-routing. ### Path Variables You can define variables in your URL paths using angle brackets (``): - Example path: `user//blog//` - The corresponding endpoint must accept parameters with the same names. - Panther will automatically cast arguments to the expected types and raise an error if the types do not match. === "Function-based" ```python title="app/apis.py" @API() async def profile_api(user_id: int, title: str, is_alive: bool): ... ``` === "Class-based" ```python title="app/apis.py" class MyAPI(GenericAPI): async def get(self, user_id: int, title: str, is_alive: bool): ... ``` --- ## Example: Global Config Structure Specify a dotted path to a dictionary of URLs in your configuration file. ``` . ├── app │ ├── apis.py │ └── urls.py └── core ├── configs.py └── urls.py ``` ```python title="core/configs.py" URLs = 'core.urls.url_routing' ``` ```python title="core/urls.py" linenums="1" from app.urls import app_urls url_routing = { 'user/': app_urls, } ``` ```python title="app/urls.py" linenums="1" from app.apis import * app_urls = { 'login/': login_api, 'logout/': logout_api, '<user_id>/blog/<title>/<is_alive>/': profile_api, } ``` ```python title="app/apis.py" linenums="1" from panther import API @API() async def profile_api(user_id: int, title: str, is_alive: bool): ... ``` --- ## Example: Single-File Structure You can pass the URL dictionary directly to Panther: ```python title="main.py" # Other codes ... urls = { '/': first_api, } app = Panther(__name__, configs=__name__, urls=urls) ``` ================================================ FILE: docs/docs/user_model.md ================================================ # User Model Configuration You can specify which model should be used as your User model by setting the `USER_MODEL` variable in your configuration files. The value of `USER_MODEL` should be the import path (address) of a model class that inherits from `panther.db.models.BaseUser`. Panther relies on this model for user authentication and management within the AdminPanel and all built-in authentication classes. ## Usage - **Login and Authentication in AdminPanel:** The specified User model will be used to authenticate users accessing the AdminPanel. - **Built-in Authentication Classes:** All built-in authentication mechanisms will utilize this User model for user-related operations. ## Default Value If you do not specify a `USER_MODEL`, Panther will use `panther.db.models.BaseUser` as the default User model. ## Example ```python # In your configuration file USER_MODEL = 'your_app.models.CustomUser' ``` ================================================ FILE: docs/docs/websocket.md ================================================ # WebSocket Support in Panther **WebSockets** enable you to build interactive, real-time features such as chat, notifications, and live updates. --- ## Structure & Requirements ### Creating a WebSocket Class Create a WebSocket handler class in `app/websockets.py` by inheriting from `GenericWebsocket`: ```python title="app/websockets.py" linenums="1" from panther.websocket import GenericWebsocket class BookWebsocket(GenericWebsocket): async def connect(self): await self.accept() print(f'{self.connection_id=}') async def receive(self, data: str | bytes): # Echo the received message back to the client await self.send(data=data) ``` ### Registering WebSocket URLs Register your WebSocket class in `app/urls.py`: ```python title="app/urls.py" linenums="1" from app.websockets import BookWebsocket urls = { 'ws/book/': BookWebsocket, } ``` > Panther supports WebSocket routing just like APIs. --- ## How It Works 1. **Client Connection:** The client connects to your `ws/book/` URL using the WebSocket protocol. 2. **Connection Handling:** The `connect()` method of your WebSocket class is called. 3. **Validation:** You can validate the connection using `self.headers`, `self.query_params`, etc. 4. **Accept/Reject:** Accept the connection with `self.accept()`. If not accepted, it is rejected by default. 5. **Connection ID:** Each connection gets a unique `connection_id` (accessible via `self.connection_id`). You may want to store this in a database or cache. 6. **Receiving Messages:** Incoming messages are handled by the `receive()` method. Messages can be `str` or `bytes`. 7. **Sending Messages:** - **Within the WebSocket class:** Use `self.send(data)`. - **Outside the WebSocket class:** Use `send_message_to_websocket()`: ```python from panther.websocket import send_message_to_websocket await send_message_to_websocket(connection_id='7e82d57c9ec0478787b01916910a9f45', data='New Message From WS') ``` --- ## Advanced Usage ### Authentication You can enable authentication in your WebSocket class by setting `auth` to an async function or a class with an async `__call__` method. Panther will use this callable to authenticate the user. - If you do not set `auth`, Panther will use the default `WS_AUTHENTICATION` from your configuration **only if the request contains an authorization header/ cookie/ param/ etc.**. - If there is no authorization header, authentication is bypassed and `self.user` will be `None`. There are several built-in options, but we recommend `QueryParamJWTAuthentication` for WebSocket authentication. ```python WS_AUTHENTICATION = 'panther.authentications.QueryParamJWTAuthentication' ``` This will set `self.user` to a `UserModel` instance or `None`. The connection will be rejected if any exception occurs during authentication. ```python title="app/websockets.py" linenums="1" from panther.websocket import GenericWebsocket from app.authentications import MyAuthenticationClass class MyWebSocket(GenericWebsocket): auth = MyAuthenticationClass # Or use an async function async def connect(self): print(self.user) ... ``` > **Note:** When authentication is bypassed (no authorization header), `self.user` will be `None` and you must rely on permissions to check the user and their authorization. ### Permissions You can implement your authorization logic using permission classes or functions. Any async function or class with an async `__call__` method can be used as a permission. Panther will call each permission (asynchronously). - If any return `False`, the connection will be rejected. Pass a list of permission callables to your WebSocket class. - If you pass a single permission, it will be automatically wrapped in a list. > Each permission must be async (either an async function or a class with an async `__call__`). **Example Permission Function:** ```python title="app/permissions.py" linenums="1" from panther.websocket import Websocket async def custom_permission(request: Websocket) -> bool: return True ``` **Example Permission Class:** ```python title="app/permissions.py" linenums="1" from panther.websocket import Websocket from panther.permissions import BasePermission class CustomPermission(BasePermission): async def __call__(self, request: Websocket) -> bool: return True ``` ```python title="app/websockets.py" linenums="1" from panther.websocket import GenericWebsocket from app.permissions import custom_permission, CustomPermission class MyWebSocket(GenericWebsocket): permissions = [custom_permission, CustomPermission] # Or just one async def connect(self): ... ``` ### Multiple Workers & Redis - **Recommended:** For running WebSockets with multiple workers, add Redis to your configuration. [See Adding Redis](redis.md) - **Without Redis:** If you do not use Redis but want to run WebSockets with multiple workers (e.g., with Gunicorn), use the `--preload` flag: ```shell gunicorn -w 10 -k uvicorn.workers.UvicornWorker main:app --preload ``` - **Uvicorn Limitation:** WebSockets do not work properly when using uvicorn directly with the `--workers` flag (e.g., `uvicorn main:app --workers 4`). This is because each worker process maintains its own separate WebSocket connections, and there's no shared state between workers. Use Gunicorn with the `--preload` flag or add Redis for proper WebSocket support with multiple workers. ### Closing Connections - **Within the WebSocket class:** ```python from panther import status await self.close(code=status.WS_1000_NORMAL_CLOSURE, reason='Closing connection') ``` - **Outside the WebSocket class:** ```python from panther import status from panther.websocket import close_websocket_connection await close_websocket_connection(connection_id='7e82d57c9ec0478787b01916910a9f45', code=status.WS_1008_POLICY_VIOLATION, reason='Closing connection') ``` ### Path Variables You can define path variables in your WebSocket URL. These will be passed to the `connect()` method: ```python linenums="1" from panther.websocket import GenericWebsocket class UserWebsocket(GenericWebsocket): async def connect(self, user_id: int, room_id: str): await self.accept() urls = { '/ws/<user_id>/<room_id>/': UserWebsocket } ``` --- ## Example ### Example Client Code **Here's a simple example using JavaScript:** ```js title="websocket.js" linenums="1" const ws = new WebSocket('ws://127.0.0.1:8000/ws/book/'); ws.onopen = () => { ws.send('Hello, server!'); }; ws.onmessage = (event) => { console.log('Received:', event.data); }; ws.onclose = () => { console.log('Connection closed'); }; ``` ### Echo Example **Full echo example with WebSocket:** ```python title="main.py" linenums="1" from panther import Panther from panther.app import GenericAPI from panther.response import HTMLResponse from panther.websocket import GenericWebsocket class EchoWebsocket(GenericWebsocket): async def connect(self, **kwargs): await self.accept() async def receive(self, data: str | bytes): await self.send(data) class MainPage(GenericAPI): def get(self): template = """ <input id="msg"><button onclick="s.send(msg.value)">Send</button> <ul id="log"></ul> <script> const s = new WebSocket('ws://127.0.0.1:8000/ws'); s.onmessage = e => log.innerHTML += `<li><- ${msg.value}</li><li>-> ${e.data}</li>`; </script> """ return HTMLResponse(template) url_routing = { '': MainPage, 'ws': EchoWebsocket, } app = Panther(__name__, configs=__name__, urls=url_routing) ``` **Run** with `panther run main:app` and **visit** `http://127.0.0.1:8000`. --- ## Tips & Notes - **Connection Validation:** Always validate connections in `connect()` using headers or query parameters as needed. - **Connection IDs:** Store `connection_id` if you need to send messages to clients outside the WebSocket class. - **Multiple Workers:** Use Redis for scaling WebSockets across multiple workers. - **Error Handling:** Implement error handling in your WebSocket methods for production use. - **Security:** Always validate and sanitize incoming data. --- Enjoy building with Panther WebSockets! ================================================ FILE: docs/mkdocs.yml ================================================ site_name: Panther site_description: Fast & Friendly, Web Framework For Building Async APIs site_url: https://pantherpy.github.io repo_name: AliRn76/Panther repo_url: https://github.com/AliRn76/Panther edit_uri: edit/master/docs/ theme: name: 'material' custom_dir: docs palette: - scheme: 'default' media: '(prefers-color-scheme: light)' primary: 'blue' toggle: icon: 'material/lightbulb' name: 'Switch to dark mode' - scheme: 'slate' media: '(prefers-color-scheme: dark)' primary: 'blue' toggle: icon: 'material/lightbulb-outline' name: 'Switch to light mode' features: - navigation.path - navigation.footer - content.code.copy - content.code.annotate icon: repo: fontawesome/brands/github nav: - Introduction: 'index.md' - First CRUD: 'first_crud.md' - APIs: 'api.md' - Serializers: 'serializer.md' - URLs: 'urls.md' - Models: 'model.md' - File Handling: 'file_handling.md' - Database: 'database.md' - Query: 'query.md' - Redis: 'redis.md' - Events: 'events.md' - Background Tasks: 'background_tasks.md' - Authentications: 'authentications.md' - Middlewares: 'middlewares.md' - WebSocket: 'websocket.md' - Single-File Application: 'single_file.md' - Cross-Origin Resource Sharing (CORS): 'cors.md' - Open API (Swagger): 'open_api.md' - Advance Configs: - User Model: 'user_model.md' - Template Response: 'templates.md' - Timezone: 'timezone.md' - Auto-Reformatting (Ruff): 'ruff.md' - Log Queries: 'log_queries.md' - Admin Panel: 'admin_panel.md' - Deployment: 'deployment.md' - Roadmap: 'roadmap.md' - Release Notes: 'release_notes.md' markdown_extensions: - admonition - pymdownx.details - pymdownx.superfences - pymdownx.snippets - pymdownx.tabbed: alternate_style: true - toc: permalink: "#" ================================================ FILE: docs/pre-finetuning.jsonl ================================================ {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is Panther?"}, {"role": "assistant", "content": "Panther is a fast, async web framework for building APIs with Python 3.10+. It features a file-based database (PantherDB), MongoDB support, built-in authentication, permissions, OpenAPI docs, WebSocket support, background tasks, middleware, and more. Panther is designed for simplicity, speed, and developer productivity."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I install Panther?"}, {"role": "assistant", "content": "You can install Panther using pip:\n```bash\npip install panther\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the recommended project structure for a Panther app?"}, {"role": "assistant", "content": "A typical Panther project structure looks like this:\n```.\n├── app\n│ ├── apis.py\n│ ├── models.py\n│ ├── serializers.py\n│ └── urls.py\n└── core\n ├── configs.py\n └── urls.py\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create a CRUD API for a Book model in Panther?"}, {"role": "assistant", "content": "1. Define the model in app/models.py:\n```python\nfrom panther.db import Model\n\nclass Book(Model):\n name: str\n author: str\n pages_count: int\n```\n2. Create a serializer in app/serializers.py:\n```python\nfrom panther.serializer import ModelSerializer\nfrom app.models import Book\n\nclass BookSerializer(ModelSerializer):\n class Config:\n model = Book\n fields = ['name', 'author', 'pages_count']\n```\n3. Define API endpoints in app/apis.py (example for function-based):\n```python\nfrom panther.app import API\nfrom panther.request import Request\nfrom panther.response import Response\nfrom app.models import Book\nfrom app.serializers import BookSerializer\n\n@API(input_model=BookSerializer, methods=['POST', 'GET'])\nasync def book_api(request: Request):\n if request.method == 'POST':\n body = request.validated_data\n book = await Book.insert_one(**body.model_dump())\n return Response(data=book)\n elif request.method == 'GET':\n books = await Book.find()\n return Response(data=books)\n```\n4. Add URLs in app/urls.py:\n```python\nfrom app.apis import book_api\nurls = {\n 'book/': book_api,\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I enable authentication in Panther APIs?"}, {"role": "assistant", "content": "Set the auth parameter to an authentication class or function in your API definition. Panther will use this to validate the request. Example:\n```python\nfrom panther.app import API\nfrom panther.authentications import JWTAuthentication\n@API(auth=JWTAuthentication)\nasync def user_api(request):\n ...\n```\nIn your configs, set:\n```python\nAUTHENTICATION = 'panther.authentications.JWTAuthentication'\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What does the auth attribute do in Panther APIs?"}, {"role": "assistant", "content": "The auth parameter can be any async function or a class with an async __call__ method. If you set auth, Panther will use your specified authentication class or function. If you do not set auth, Panther will use the default AUTHENTICATION from your config only if the request contains an authorization header. If there is no authorization header, authentication is bypassed, request.user will be None and you must rely on permissions to check the user and their authorization."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access the authenticated user in a Panther API?"}, {"role": "assistant", "content": "After successful authentication, the authenticated user is available as request.user in your API function or method. This will be an instance of your USER_MODEL (default: BaseUser) or None if authentication was bypassed."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What happens if authentication fails in Panther?"}, {"role": "assistant", "content": "If authentication fails, Panther automatically raises HTTP_401_UNAUTHORIZED and returns a 401 Unauthorized response to the client. You do not need to handle authentication errors manually."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I use a custom authentication class with the auth attribute?"}, {"role": "assistant", "content": "Yes, you can pass a custom authentication class or function to the auth attribute. This will override the default AUTHENTICATION from your configs for that specific API. Example:\n```python\n@API(auth=CustomAuthentication)\nasync def my_api(request):\n ...\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What built-in authentication types does Panther support?"}, {"role": "assistant", "content": "Panther provides three built-in authentication classes, all based on JWT (JSON Web Token):\n1. JWTAuthentication: Reads the token from the 'Authorization' header as 'Bearer <token>'.\n2. QueryParamJWTAuthentication: Reads the token from the 'authorization' query parameter (useful for WebSockets).\n3. CookieJWTAuthentication: Reads the token from the 'access_token' cookie.\nYou can set the authentication class in your configs with the AUTHENTICATION variable."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I configure JWT settings in Panther?"}, {"role": "assistant", "content": "You can customize JWT behavior by setting the JWT_CONFIG variable in your configs. Example:\n```python\nfrom datetime import timedelta\nJWT_CONFIG = {\n 'key': SECRET_KEY,\n 'algorithm': 'HS256',\n 'life_time': timedelta(days=2),\n 'refresh_life_time': timedelta(days=10),\n}\n```\nThis controls the secret key, algorithm, access token lifetime, and refresh token lifetime."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I implement custom authentication in Panther?"}, {"role": "assistant", "content": "To implement custom authentication, create a class inheriting from panther.authentications.BaseAuthentication and implement the async __call__ method. Return a user instance or raise AuthenticationAPIError on failure. Example:\n```python\nfrom panther.authentications import BaseAuthentication\nclass CustomAuthentication(BaseAuthentication):\n async def __call__(self, request):\n # Your logic here\n return user\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What happens if I don't set auth in a Panther API?"}, {"role": "assistant", "content": "If you don't set auth, Panther will use the default AUTHENTICATION from your configs only if the request contains an authorization header. If there is no authorization header, authentication is bypassed, request.user will be None, and you must rely on permissions to check user authorization."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I define a model in Panther?"}, {"role": "assistant", "content": "To define a model, inherit from panther.db.Model and use type hints for fields. Example:\n```python\nfrom panther.db import Model\nclass User(Model):\n username: str\n age: int\n is_active: bool\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What field types does Panther support in models?"}, {"role": "assistant", "content": "Panther supports various field types including:\n- Basic types: str, int, float, bool\n- Complex types: list, dict, tuple\n- Date/time: datetime, date, time\n- Optional fields: type | None\n\nExample:\n```python\nfrom panther.db import Model\nfrom datetime import datetime\nclass Product(Model):\n name: str\n price: float\n is_available: bool\n created_at: datetime\n tags: list[str]\n description: str | str\n metadata: dict\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create nested models in Panther?"}, {"role": "assistant", "content": "Use Pydantic BaseModel for nested fields or another Panther model. Example:\n```python\nfrom panther.db import Model\nfrom pydantic import BaseModel\n\nclass Address(BaseModel):\n street: str\n city: str\n zipcode: str\n country: str\n\nclass User(Model):\n username: str\n email: str\n address: Address # Nested model\n phone_numbers: list[str]\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle foreign keys in Panther models?"}, {"role": "assistant", "content": "Use another Panther model as a field type for foreign keys. Panther will handle the relationship. Example:\n```python\nfrom panther.db import Model\n\nclass Department(Model):\n name: str\n description: str\n\nclass Employee(Model):\n name: str\n email: str\n department: Department # Foreign key relationship\n manager: Employee # Self-referencing foreign key\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create one-to-many relationships in Panther?"}, {"role": "assistant", "content": "Use a list of the related model for one-to-many relationships. Example:\n```python\nfrom panther.db import Model\n\nclass Author(Model):\n name: str\n email: str\n books: list['Book'] # One author can have many books\n\nclass Book(Model):\n title: str\n author: Author # Many books can belong to one author\n isbn: str\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create many-to-many relationships in Panther?"}, {"role": "assistant", "content": "Use lists on both sides of the relationship for many-to-many. Example:\n```python\nfrom panther.db import Model\n\nclass Student(Model):\n name: str\n courses: list['Course'] # Many students can take many courses\n\nclass Course(Model):\n name: str\n students: list[Student] # Many courses can have many students\n instructor: str\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I add validation to model fields in Panther?"}, {"role": "assistant", "content": "Use Pydantic Field with validation rules. Example:\n```python\nfrom panther.db import Model\nfrom pydantic import Field, EmailStr\n\nclass User(Model):\n username: str = Field(min_length=3, max_length=50, regex=r'^[a-zA-Z0-9_]+$')\n email: EmailStr\n age: int = Field(ge=0, le=150)\n password: str = Field(min_length=8)\n bio: str | None = Field(max_length=500, default=None)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I set default values in Panther models?"}, {"role": "assistant", "content": "Use default values in field definitions or Pydantic Field. Example:\n```python\nfrom panther.db import Model\nfrom panther.utils import timezone_now\nfrom pydantic import Field\nfrom datetime import datetime\n\nclass Post(Model):\n title: str\n content: str\n is_published: bool = False # Default value\n created_at: datetime = Field(default_factory=timezone_now)\n tags: list[str] = Field(default_factory=list)\n views: int = 0\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create computed fields in Panther models?"}, {"role": "assistant", "content": "Use Pydantic computed fields with @property or @field_validator. Example:\n```python\nfrom panther.db import Model\nfrom pydantic import computed_field, field_validator\nfrom datetime import datetime\n\nclass Order(Model):\n items: list[dict]\n tax_rate: float = 0.1\n \n @computed_field\n def total_amount(self) -> float:\n subtotal = sum(item['price'] * item['quantity'] for item in self.items)\n return subtotal * (1 + self.tax_rate)\n \n @field_validator('items')\n def validate_items(cls, v):\n if not v:\n raise ValueError('Order must have at least one item')\n return v\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle model inheritance in Panther?"}, {"role": "assistant", "content": "You can inherit from other Panther models to create base classes. Example:\n```python\nfrom panther.db import Model\nfrom panther.utils import timezone_now\nfrom datetime import datetime\n\nclass BaseModel(Model):\n created_at: datetime = Field(default_factory=timezone_now)\n updated_at: datetime = Field(default_factory=timezone_now)\n is_active: bool = True\n\nclass User(BaseModel):\n username: str\n email: str\n\nclass Product(BaseModel):\n name: str\n price: float\n category: str\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create complex nested structures in Panther models?"}, {"role": "assistant", "content": "Combine nested models, lists, and dictionaries for complex structures. Example:\n```python\nfrom panther.db import Model\nfrom pydantic import BaseModel\n\nclass Address(BaseModel):\n street: str\n city: str\n country: str\n\nclass ContactInfo(BaseModel):\n phone: str\n email: str\n website: str | None\n\nclass Company(Model):\n name: str\n address: Address\n contacts: ContactInfo\n departments: list[dict[str, str]]\n employees: list['Employee']\n metadata: dict[str, any]\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle model serialization in Panther?"}, {"role": "assistant", "content": "Panther models are automatically serializable. Use model_dump() for dictionaries and model_dump_json() for JSON. Example:\n```python\nfrom panther.db import Model\n\nclass User(Model):\n username: str\n email: str\n profile: dict\n\n# Serialization\nuser = User(username='john', email='john@example.com', profile={'age': 25})\nuser_dict = user.model_dump()\nuser_json = user.model_dump_json()\n\n# Deserialization\nuser_from_dict = User(**user_dict)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create model methods in Panther?"}, {"role": "assistant", "content": "Add methods to your model classes for business logic. Example:\n```python\nfrom panther.db import Model\nfrom datetime import datetime, timedelta\n\nclass User(Model):\n username: str\n email: str\n last_login: datetime\n is_active: bool = True\n \n def is_recently_active(self, days: int = 7) -> bool:\n return self.last_login > datetime.now() - timedelta(days=days)\n \n def get_display_name(self) -> str:\n return f'{self.username} ({self.email})'\n \n async def update_last_login(self):\n self.last_login = datetime.now()\n await self.save()\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle model validation errors in Panther?"}, {"role": "assistant", "content": "Use try-catch blocks to handle validation errors. Example:\n```python\nfrom panther.db import Model\nfrom pydantic import ValidationError\n\nclass User(Model):\n username: str = Field(min_length=3)\n email: str = Field(regex=r'^[^@]+@[^@]+\\.[^@]+$')\n age: int = Field(ge=0, le=150)\n\n# Handle validation\nasync def create_user(user_data: dict):\n try:\n user = User(**user_data)\n await user.save()\n return user\n except ValidationError as e:\n print(f'Validation error: {e.errors()}')\n raise\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the best practice for designing models in Panther?"}, {"role": "assistant", "content": "Use descriptive field names. Keep models focused and single-purpose. Use proper type hints. Handle validation with Pydantic Field. Use forward references for circular dependencies. Create base models for common fields. Keep nested structures manageable. Document complex relationships. Use computed fields for derived data. Handle errors gracefully."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use serializers in Panther?"}, {"role": "assistant", "content": "Serializers transform data between your models and API requests/responses. Inherit from ModelSerializer and set the Config.model and fields. Example:\n```python\nfrom panther.serializer import ModelSerializer\nfrom app.models import User\nclass UserSerializer(ModelSerializer):\n class Config:\n model = User\n fields = ['username', 'age', 'is_active']\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create a WebSocket endpoint in Panther?"}, {"role": "assistant", "content": "Inherit from GenericWebsocket and implement connect and receive methods. Register the class in your urls. Example:\n```python\nfrom panther.websocket import GenericWebsocket\nclass EchoWebsocket(GenericWebsocket):\n async def connect(self):\n await self.accept()\n async def receive(self, data):\n await self.send(data)\nurls = {\n 'ws/echo/': EchoWebsocket,\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I implement custom permissions in Panther APIs?"}, {"role": "assistant", "content": "Create a class inheriting from panther.permissions.BasePermission and implement async classmethod authorization. Add your permission to the permissions list in your API. Example:\n```python\nfrom panther.permissions import BasePermission\nclass CustomPermission(BasePermission):\n @classmethod\n async def authorization(cls, request):\n return request.user.is_admin\nfrom panther.app import API\n@API(permissions=[CustomPermission])\nasync def admin_api(request):\n ...\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I document my Panther APIs with OpenAPI?"}, {"role": "assistant", "content": "Use the output_schema attribute with panther.openapi.OutputSchema in your API. Example:\n```python\nfrom panther import status\nfrom panther.app import API\nfrom panther.openapi import OutputSchema\nfrom app.serializers import UserSerializer\n@API(output_schema=OutputSchema(model=UserSerializer, status_code=status.HTTP_200_OK))\nasync def user_api():\n ...\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use middleware in Panther?"}, {"role": "assistant", "content": "To use global middleware, add them to the MIDDLEWARES list in your configs. For per-API middleware, set the middlewares attribute on your API class or use the middlewares argument in the @API decorator."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use background tasks in Panther?"}, {"role": "assistant", "content": "1. Set BACKGROUND_TASKS = True in your configs.\n2. Use BackgroundTask to schedule tasks:\n```python\nfrom panther.background_tasks import BackgroundTask\ndef do_something(name):\n print(f'Hello {name}')\nBackgroundTask(do_something, name='Ali').submit()\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does caching work in Panther APIs?"}, {"role": "assistant", "content": "Caching in Panther allows you to store API responses for GET requests for a specified duration. Set the cache attribute to a timedelta value on your API. Panther will cache the response based on user or IP, request path, query parameters, and validated data."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I enable caching in Panther APIs?"}, {"role": "assistant", "content": "Set the cache attribute to a timedelta value on your API. Panther will cache GET responses for the specified duration. Example:\n```python\nfrom datetime import timedelta\nfrom panther.app import API\n@API(cache=timedelta(minutes=10))\nasync def my_api():\n ...\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How is the cache key built in Panther?"}, {"role": "assistant", "content": "The cache key in Panther is built from the user ID or IP, request path, a hash of the query parameters, and validated data. This ensures that cached responses are unique per user (or IP) and request parameters."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I cache non-GET requests in Panther?"}, {"role": "assistant", "content": "No, Panther only caches GET requests. POST, PUT, PATCH, and DELETE requests are not cached as they modify data and should always return fresh responses."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I clear the cache in Panther?"}, {"role": "assistant", "content": "Panther automatically manages cache expiration based on the timedelta you set. You can also manually clear the cache by restarting your application or using Panther's cache management utilities if available."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the best practice for using cache in Panther?"}, {"role": "assistant", "content": "Use caching for GET endpoints that return data that doesn't change frequently to improve performance. Set appropriate cache durations based on how often your data changes. Be careful not to cache sensitive or user-specific data inappropriately."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does caching work with authentication in Panther?"}, {"role": "assistant", "content": "When caching is enabled, Panther includes the user ID in the cache key if the user is authenticated, or the IP address if not authenticated. This ensures that cached responses are user-specific and secure."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I disable caching for specific APIs in Panther?"}, {"role": "assistant", "content": "Yes, you can disable caching for specific APIs by setting cache=None or not setting the cache attribute at all. This will override any global cache settings for that specific API."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does caching affect API performance in Panther?"}, {"role": "assistant", "content": "Caching significantly improves API performance by serving cached responses instead of executing the full request logic. This reduces database queries, computation time, and response latency, especially for frequently accessed data."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does throttling work in Panther APIs?"}, {"role": "assistant", "content": "Throttling in Panther limits the number of requests a user or IP can make in a given time period. Set the throttling attribute on your API to an instance of panther.throttling.Throttle. If the rate is exceeded, Panther returns a 429 Too Many Requests error."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I enable throttling in Panther?"}, {"role": "assistant", "content": "Set the throttling attribute on your API to an instance of panther.throttling.Throttle. Example:\n```python\nfrom datetime import timedelta\nfrom panther.app import API\nfrom panther.throttling import Throttle\n@API(throttling=Throttle(rate=5, duration=timedelta(minutes=1)))\nasync def my_api():\n ...\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I set global throttling in Panther?"}, {"role": "assistant", "content": "Set the THROTTLING variable in your configs to a Throttle instance. This will apply to all APIs unless overridden by a per-API throttling setting. Example:\n```python\nfrom datetime import timedelta\nfrom panther.throttling import Throttle\nTHROTTLING = Throttle(rate=10, duration=timedelta(minutes=1))\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does Panther handle requests that exceed the throttle limit?"}, {"role": "assistant", "content": "If a user exceeds the allowed number of requests within the throttle duration, Panther returns a 429 Too Many Requests response and bans the user for the duration."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I customize how throttling keys are built in Panther?"}, {"role": "assistant", "content": "Yes, you can customize throttling behavior by subclassing panther.throttling.Throttle and overriding the build_cache_key method. Example:\n```python\nfrom panther.throttling import Throttle\nclass CustomThrottle(Throttle):\n def build_cache_key(self, request):\n # Custom logic here\n return ...\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the best practice for using throttling in Panther?"}, {"role": "assistant", "content": "Use throttling to protect your APIs from abuse. Set appropriate rates and durations for your use case. Consider using different throttling rules for different types of endpoints (e.g., stricter limits for authentication endpoints)."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do throttling and caching work together in Panther?"}, {"role": "assistant", "content": "Throttling and caching are independent features. Throttling checks happen before caching, so even if a response is cached, the request still counts toward the throttle limit. This prevents abuse even for cached endpoints."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I use different throttling rules for different HTTP methods?"}, {"role": "assistant", "content": "Yes, you can set different throttling rules for different HTTP methods by creating separate API endpoints or using custom throttling logic in your build_cache_key method to include the HTTP method in the cache key."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does throttling work with authentication in Panther?"}, {"role": "assistant", "content": "Throttling can be based on user ID (if authenticated) or IP address (if not authenticated). When a user is authenticated, throttling is typically per-user. When not authenticated, it's per-IP address to prevent abuse."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What happens if I don't set any throttling in Panther?"}, {"role": "assistant", "content": "If you don't set any throttling (neither global nor per-API), there are no rate limits on your APIs. This means users can make unlimited requests, which could lead to abuse or performance issues in production."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I enable and use the admin panel in Panther?"}, {"role": "assistant", "content": "1. Add the admin panel URLs in your main URL config:\n```python\nfrom panther.panel.urls import url_routing as panel_url_routing\nurl_routing = {\n 'panel/': panel_url_routing,\n}\n```\n2. Start your app and visit http://127.0.0.1:8000/panel/ in your browser."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does the CORS middleware work in Panther?"}, {"role": "assistant", "content": "The CORS middleware in Panther automatically adds Cross-Origin Resource Sharing (CORS) headers to HTTP responses based on your configuration. It handles preflight (OPTIONS) requests and sets headers like Access-Control-Allow-Origin, Access-Control-Allow-Methods, and others."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I enable CORS in Panther?"}, {"role": "assistant", "content": "1. Set CORS config variables in your configs, e.g. ALLOW_ORIGINS, ALLOW_METHODS, etc.\n2. Add 'panther.middlewares.cors.CORSMiddleware' to your MIDDLEWARES list.\nExample:\n```python\nALLOW_ORIGINS = ['*']\nMIDDLEWARES = [\n 'panther.middlewares.cors.CORSMiddleware',\n]\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What CORS configuration options does Panther support?"}, {"role": "assistant", "content": "Panther supports several CORS configuration options:\n- ALLOW_ORIGINS: List of allowed origins (e.g., ['https://example.com'])\n- ALLOW_METHODS: List of allowed HTTP methods (e.g., ['GET', 'POST'])\n- ALLOW_HEADERS: List of allowed headers (e.g., ['Content-Type', 'Authorization'])\n- ALLOW_CREDENTIALS: Boolean for allowing credentials\n- EXPOSE_HEADERS: List of headers to expose\n- CORS_MAX_AGE: Integer for preflight cache duration"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I configure specific CORS settings in Panther?"}, {"role": "assistant", "content": "Set the CORS configuration variables in your configs. Example:\n```python\nALLOW_ORIGINS = ['https://example.com', 'https://app.example.com']\nALLOW_METHODS = ['GET', 'POST', 'PUT', 'DELETE']\nALLOW_HEADERS = ['Content-Type', 'Authorization', 'X-Custom-Header']\nALLOW_CREDENTIALS = True\nEXPOSE_HEADERS = ['X-Custom-Header']\nCORS_MAX_AGE = 3600\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the difference between ALLOW_ORIGINS=['*'] and specific origins?"}, {"role": "assistant", "content": "ALLOW_ORIGINS=['*'] allows requests from any origin, which is convenient for development but not recommended for production. Specific origins (e.g., ['https://example.com']) are more secure as they only allow requests from trusted domains."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle preflight requests with CORS in Panther?"}, {"role": "assistant", "content": "The CORS middleware automatically handles preflight (OPTIONS) requests. When a browser sends a preflight request, the middleware responds with the appropriate CORS headers based on your configuration. You don't need to handle OPTIONS requests manually."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I use CORS with credentials in Panther?"}, {"role": "assistant", "content": "Yes, you can enable credentials by setting ALLOW_CREDENTIALS = True in your configs. However, when using credentials, you cannot use ALLOW_ORIGINS = ['*'] - you must specify specific origins for security reasons."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the best practice for CORS configuration in Panther?"}, {"role": "assistant", "content": "For development, you can use ALLOW_ORIGINS = ['*'] for convenience. For production, always specify exact origins, enable only necessary methods and headers, and set appropriate CORS_MAX_AGE. Consider security implications when allowing credentials."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I debug CORS issues in Panther?"}, {"role": "assistant", "content": "To debug CORS issues, check your browser's developer console for CORS error messages. Verify that your ALLOW_ORIGINS includes the correct origin, that ALLOW_METHODS includes the HTTP method being used, and that the CORS middleware is properly added to your MIDDLEWARES list."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I apply CORS only to specific APIs in Panther?"}, {"role": "assistant", "content": "The CORS middleware applies globally when added to MIDDLEWARES. If you need different CORS settings for different APIs, you would need to create custom middleware or handle CORS headers manually in those specific endpoints."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I log database query performance in Panther?"}, {"role": "assistant", "content": "Set 'LOG_QUERIES = True' in your configs. Panther will log query execution times to 'logs/query.log'. Example:\n```python\nLOG_QUERIES = True\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use environment variables in Panther configs?"}, {"role": "assistant", "content": "You can use Panther's 'load_env' utility to load variables from a .env file. Example:\n```python\nfrom panther.utils import load_env\nfrom pathlib import Path\nBASE_DIR = Path(__name__).resolve().parent\nenv = load_env(BASE_DIR / '.env')\nSECRET_KEY = env['SECRET_KEY']\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I define a model with nested fields or foreign keys in Panther?"}, {"role": "assistant", "content": "You can use pydantic.BaseModel for nested fields or another Panther model for foreign keys. Example:\n```python\nfrom panther.db import Model\nfrom pydantic import BaseModel\nclass Address(BaseModel):\n city: str\n zipcode: str\nclass User(Model):\n username: str\n address: Address\nclass Department(Model):\n name: str\nclass Employee(Model):\n name: str\n department: Department\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What OpenAPI UI types does Panther support?"}, {"role": "assistant", "content": "Panther supports several OpenAPI UI types out of the box. You can use ScalarOpenAPI, SwaggerOpenAPI, RedocOpenAPI, RapiDocOpenAPI, and SpotlightOpenAPI. Import them from panther.openapi.views and register them in your URL routing. Example:\n```python\nfrom panther.openapi.views import ScalarOpenAPI, SwaggerOpenAPI, RedocOpenAPI\nurl_routing = {\n 'docs/scalar/': ScalarOpenAPI,\n 'docs/swagger/': SwaggerOpenAPI,\n 'docs/redoc/': RedocOpenAPI,\n}\n```\nYou can also use the bundled openapi_url_routing to include all UI types at once:\n```python\nfrom panther.openapi.urls import url_routing as openapi_url_routing\nurl_routing = {\n 'docs/': openapi_url_routing,\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does Panther determine response models and status codes for OpenAPI docs?"}, {"role": "assistant", "content": "Panther follows a specific logic to generate OpenAPI documentation:\n1. output_schema: Panther first looks for an output_schema attribute to generate the OpenAPI documentation. This is the recommended and most accurate way.\n2. output_model: If output_schema does not exist, Panther looks for an output_model attribute to generate the response type and attempts to extract the status code from your source code.\n3. Source Code Analysis: If neither is available, Panther tries to extract the response data and status code directly from your source code using static analysis with ast.\nFor best results, always specify output_schema in your APIs."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I add pagination, filtering, and sorting to a Panther List API?"}, {"role": "assistant", "content": "Inherit from ListAPI and set the pagination, search_fields, filter_fields, and sort_fields attributes. Example:\n```python\nfrom panther.generics import ListAPI\nfrom panther.pagination import Pagination\nclass BookAPI(ListAPI):\n pagination = Pagination\n search_fields = ['name', 'author']\n filter_fields = ['name', 'author']\n sort_fields = ['name', 'pages_count']\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does Panther handle error responses?"}, {"role": "assistant", "content": "Panther raises exceptions like AuthenticationAPIError, AuthorizationAPIError, and NotFoundAPIError for common error cases. You can raise these in your code to return standard error responses. Example:\n```python\nfrom panther.exceptions import NotFoundAPIError\nif not user:\n raise NotFoundAPIError('User not found')\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I deploy a Panther app?"}, {"role": "assistant", "content": "You can deploy Panther using any ASGI server such as Uvicorn, Granian, or Daphne. For production, use Gunicorn with Uvicorn workers:\n```bash\ngunicorn -w 4 -k uvicorn.workers.UvicornWorker main:app\n```\nMake sure to set up environment variables and production configs as needed."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use OpenAPI output_schema for best documentation?"}, {"role": "assistant", "content": "Always set the output_schema attribute on your APIs using panther.openapi.OutputSchema. This ensures Panther generates accurate OpenAPI documentation for your endpoints, including the correct response model and status code."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle custom error responses in Panther?"}, {"role": "assistant", "content": "Raise panther.exceptions.APIError or create your own exception class inheriting from APIError. Example:\n```python\nfrom panther.exceptions import APIError\nraise APIError('Custom error message', status_code=418)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What does the input_model attribute do in Panther APIs?"}, {"role": "assistant", "content": "The input_model attribute specifies a serializer class (Pydantic or ModelSerializer) that validates and parses incoming request data. For POST, PUT, and PATCH requests, Panther validates request.data using this serializer and provides the validated data as request.validated_data. Example:\n```python\nfrom panther.app import API\nfrom app.serializers import BookSerializer\n@API(input_model=BookSerializer)\nasync def create_book(request):\n data = request.validated_data\n ...\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does Panther validate input data with input_model?"}, {"role": "assistant", "content": "When you set input_model, Panther uses the specified serializer to validate incoming data for POST, PUT, and PATCH requests. If the data is invalid, Panther returns a 400 Bad Request with details about the validation errors. If valid, the parsed data is available as request.validated_data."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What types of serializers can I use for input_model in Panther?"}, {"role": "assistant", "content": "You can use either a Pydantic model or a Panther ModelSerializer for input_model. ModelSerializer is recommended for models that map directly to your database, while Pydantic models are useful for custom validation or non-database data."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I use input_model for GET requests?"}, {"role": "assistant", "content": "No, input_model is only used for validating data in POST, PUT, and PATCH requests. For GET requests, Panther does not validate request.data using input_model."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What happens if input data does not match the input_model in Panther?"}, {"role": "assistant", "content": "If the incoming data does not match the input_model's schema or fails validation, Panther automatically returns a 400 Bad Request response with details about the validation errors. You do not need to handle this manually."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access validated input data in a Panther API?"}, {"role": "assistant", "content": "After Panther validates the request data using input_model, the validated and parsed data is available as request.validated_data in your API function or method."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What does the output_schema attribute do in Panther APIs?"}, {"role": "assistant", "content": "The output_schema attribute is used for OpenAPI documentation. It should be an instance of panther.openapi.OutputSchema, specifying both the response model and status code. This helps Panther generate accurate OpenAPI docs. Example:\n```python\nfrom panther import status\nfrom panther.app import API\nfrom panther.openapi import OutputSchema\nfrom app.serializers import BookSerializer\n@API(output_schema=OutputSchema(model=BookSerializer, status_code=status.HTTP_201_CREATED))\nasync def create_book():\n ...\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does output_schema improve OpenAPI documentation in Panther?"}, {"role": "assistant", "content": "By setting output_schema, you explicitly define the response model and status code for your API in the OpenAPI docs. This ensures the generated documentation is accurate and matches your API's actual behavior."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the difference between output_model and output_schema in Panther?"}, {"role": "assistant", "content": "output_model only specifies the serializer for the response data, while output_schema specifies both the serializer (model) and the HTTP status code for OpenAPI documentation. For best documentation, use output_schema."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Is it required to use output_schema in Panther APIs?"}, {"role": "assistant", "content": "No, output_schema is optional, but it is recommended for APIs where you want precise OpenAPI documentation, especially for public APIs or when you need to specify custom status codes."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I use output_schema to document multiple response types in Panther?"}, {"role": "assistant", "content": "Currently, output_schema documents a single response model and status code per API. For more complex cases (e.g., multiple possible responses), you may need to extend the OpenAPI docs manually."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What happens if I set both output_model and output_schema in Panther?"}, {"role": "assistant", "content": "If both are set, Panther uses output_schema for OpenAPI documentation and output_model for response serialization. However, for best results, keep them consistent (use the same serializer/model in both)."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What does the output_model attribute do in Panther APIs?"}, {"role": "assistant", "content": "The output_model attribute specifies a serializer class to automatically serialize your API response data. When set, Panther uses this serializer to format the response, ensuring a consistent and validated output structure. Example:\n```python\nfrom panther.app import API\nfrom app.serializers import BookSerializer\n@API(output_model=BookSerializer)\nasync def get_book():\n ...\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does Panther use output_model to serialize responses?"}, {"role": "assistant", "content": "When output_model is set, Panther serializes the data returned by your API using the specified serializer before sending it in the HTTP response. This ensures the response matches the expected schema and is validated."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What types of serializers can I use for output_model in Panther?"}, {"role": "assistant", "content": "You can use either a Pydantic model or a Panther ModelSerializer for output_model. ModelSerializer is recommended for serializing database models, while Pydantic models are useful for custom or computed responses."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does output_model affect OpenAPI documentation in Panther?"}, {"role": "assistant", "content": "When you set output_model, Panther uses it to generate the response schema in the OpenAPI documentation. For best results, use output_schema for full control over both the model and status code."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Is it required to use output_model in Panther APIs?"}, {"role": "assistant", "content": "No, output_model is optional. However, using it is recommended for consistent, validated, and well-documented API responses, especially for public APIs or when using OpenAPI docs."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What happens if the response data does not match the output_model in Panther?"}, {"role": "assistant", "content": "If the response data does not match the output_model's schema, Panther will raise a validation error and return a 500 Internal Server Error. Always ensure your API returns data compatible with the output_model."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do permissions work in Panther APIs?"}, {"role": "assistant", "content": "Permissions in Panther allow you to control access to APIs and WebSockets based on custom logic. You define permission classes that implement an async __call__ method. Panther checks each permission before allowing access. If any permission returns False, a 403 Forbidden error is raised."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I define a custom permission class in Panther?"}, {"role": "assistant", "content": "Create a class that inherits from panther.permissions.BasePermission and implement the async __call__ method. This method receives the request (or WebSocket) and should return True or False. Example:\n```python\nfrom panther.permissions import BasePermission\nclass IsAdmin(BasePermission):\n async def __call__(self, request):\n return getattr(request.user, 'is_admin', False)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use permissions in a Panther API?"}, {"role": "assistant", "content": "Set the permissions attribute (class-based) or argument (function-based) to a list of permission classes. Panther will check all listed permissions before allowing access. Example:\n```python\nfrom panther.app import API\n@API(permissions=[IsAdmin])\nasync def admin_api(request):\n ...\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use permissions in Panther WebSockets?"}, {"role": "assistant", "content": "Set the permissions attribute on your GenericWebsocket subclass to a list of permission classes. Panther will check these before accepting the connection. Example:\n```python\nclass MyWebSocket(GenericWebsocket):\n permissions = [IsAdmin]\n async def connect(self):\n await self.accept()\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What should the permission __call__ method return in Panther?"}, {"role": "assistant", "content": "The __call__ method should return True if access is allowed, or False to deny access. If any permission returns False, Panther raises a 403 Forbidden error."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I use multiple permission classes in Panther?"}, {"role": "assistant", "content": "Yes, you can provide a list of permission classes. Panther will call the __call__ method of each class. All must return True for access to be granted."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What happens if a permission check fails in Panther?"}, {"role": "assistant", "content": "If any permission's __call__ method returns False, Panther raises a panther.exceptions.AuthorizationAPIError and returns a 403 Forbidden response to the client."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the best practice for writing permission classes in Panther?"}, {"role": "assistant", "content": "Keep your permission logic simple and focused. Use one class per rule, and combine them as needed in your APIs. Always make the __call__ method async and return True or False."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I use async functions as permissions in Panther?"}, {"role": "assistant", "content": "Yes, you can use any async function or class with an async __call__ method as a permission. Panther will call each permission asynchronously. Example:\n```python\nasync def custom_permission(request):\n return request.user.is_active\n@API(permissions=[custom_permission])\nasync def my_api(request):\n ...\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do permissions interact with authentication in Panther?"}, {"role": "assistant", "content": "Permissions are checked after authentication. If authentication is bypassed (no auth set or no authorization header), request.user will be None, and you must handle this case in your permission logic. Always check if request.user exists before accessing user attributes."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do middlewares work in Panther?"}, {"role": "assistant", "content": "Middlewares in Panther are functions or classes that process requests and responses. They run in sequence before and after your API endpoints. You can use global middlewares (applied to all APIs) or per-API middlewares (applied to specific endpoints)."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use global middlewares in Panther?"}, {"role": "assistant", "content": "To use global middlewares, add them to the MIDDLEWARES list in your configs. These middlewares will run for all APIs in your application. Example:\n```python\nMIDDLEWARES = [\n 'core.middlewares.MyMiddleware',\n 'panther.middlewares.cors.CORSMiddleware',\n]\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use per-API middlewares in Panther?"}, {"role": "assistant", "content": "You can assign middlewares to specific APIs using the middlewares attribute (class-based) or argument (function-based). These middlewares run only for the specified API, after global middlewares. Example:\n```python\nfrom panther.app import API\n@API(middlewares=[CustomMiddleware])\nasync def my_api(request):\n ...\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create a custom middleware in Panther?"}, {"role": "assistant", "content": "Create a class that inherits from panther.middlewares.HTTPMiddleware and implement the __call__ method. This method should process the request and call self.dispatch to continue the middleware chain. Example:\n```python\nfrom panther.middlewares import HTTPMiddleware\nclass CustomMiddleware(HTTPMiddleware):\n async def __call__(self, request):\n # Process request\n response = await self.dispatch(request=request)\n # Process response\n return response\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the middleware execution order in Panther?"}, {"role": "assistant", "content": "Middlewares execute in the order they are defined. Global middlewares run first, followed by per-API middlewares. Within each group, middlewares execute in the order they appear in the list. The response flows back through the middlewares in reverse order."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What built-in middlewares does Panther provide?"}, {"role": "assistant", "content": "Panther provides several built-in middlewares including CORSMiddleware for handling Cross-Origin Resource Sharing, and other utility middlewares. You can import them from panther.middlewares and use them in your MIDDLEWARES list or per-API."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I use async functions as middlewares in Panther?"}, {"role": "assistant", "content": "Yes, you can use async functions as middlewares. The function should accept a request parameter and return a response. Example:\n```python\nasync def custom_middleware(request):\n # Process request\n response = await dispatch_request(request)\n # Process response\n return response\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle errors in Panther middlewares?"}, {"role": "assistant", "content": "You can catch exceptions in your middleware and handle them appropriately. If you want to stop the middleware chain, you can return a response directly. Otherwise, let the exception propagate to be handled by Panther's error handling system."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the best practice for writing middlewares in Panther?"}, {"role": "assistant", "content": "Keep middlewares focused on a single responsibility. Use global middlewares for cross-cutting concerns like logging, CORS, or authentication. Use per-API middlewares for specific endpoint requirements. Always call self.dispatch to continue the middleware chain unless you want to short-circuit the request."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do middlewares interact with authentication and permissions in Panther?"}, {"role": "assistant", "content": "Middlewares run before authentication and permissions checks. This means you can use middlewares to modify the request before authentication, or to add custom headers that might be needed for authentication. However, user information (request.user) is not available in middlewares that run before authentication."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What types of APIs does Panther support?"}, {"role": "assistant", "content": "Panther supports several types of APIs:\n1. Function-based APIs using the @API decorator\n2. Class-based APIs inheriting from GenericAPI\n3. Generic APIs (ListAPI, CreateAPI, etc.) for common CRUD operations\n4. Each type has different use cases and capabilities."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create a function-based API in Panther?"}, {"role": "assistant", "content": "Use the @API decorator on an async function. You can specify methods, input_model, output_model, auth, permissions, and other attributes. Example:\n```python\nfrom panther.app import API\n@API(methods=['GET', 'POST'])\nasync def my_api(request):\n if request.method == 'GET':\n return {'data': 'Hello'}\n elif request.method == 'POST':\n return {'data': 'Created'}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create a class-based API in Panther?"}, {"role": "assistant", "content": "Inherit from GenericAPI and implement methods like get, post, put, patch, delete. Each method corresponds to an HTTP method. Example:\n```python\nfrom panther.app import GenericAPI\nclass UserAPI(GenericAPI):\n async def get(self, request):\n return {'users': []}\n async def post(self, request):\n return {'message': 'User created'}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the difference between function-based and class-based APIs in Panther?"}, {"role": "assistant", "content": "Function-based APIs use a single function with request.method checks, while class-based APIs use separate methods (get, post, etc.) for each HTTP method. Class-based APIs are more organized and easier to maintain for complex endpoints with multiple HTTP methods."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use ListAPI in Panther?"}, {"role": "assistant", "content": "Inherit from ListAPI to create an API that handles GET requests with pagination, filtering, and sorting. Set attributes like pagination, search_fields, filter_fields, and sort_fields. Example:\n```python\nfrom panther.generics import ListAPI\nfrom panther.pagination import Pagination\nclass BookListAPI(ListAPI):\n pagination = Pagination\n search_fields = ['name', 'author']\n filter_fields = ['name', 'author']\n sort_fields = ['name', 'pages_count']\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What are Generic APIs in Panther?"}, {"role": "assistant", "content": "Generic APIs are pre-built classes that provide common CRUD functionality. They include ListAPI (for listing with pagination), CreateAPI (for creating), RetrieveAPI (for single item retrieval), UpdateAPI (for updating), and DeleteAPI (for deleting). These reduce boilerplate code."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "When should I use function-based vs class-based APIs in Panther?"}, {"role": "assistant", "content": "Use function-based APIs for simple endpoints with one or two HTTP methods. Use class-based APIs for complex endpoints with multiple HTTP methods or when you need better organization. Use Generic APIs when you need standard CRUD operations with built-in features like pagination and filtering."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I mix different API types in the same project?"}, {"role": "assistant", "content": "Yes, you can mix function-based, class-based, and Generic APIs in the same project. Each API type is independent, and you can choose the most appropriate type for each endpoint based on your requirements."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle different HTTP methods in function-based APIs?"}, {"role": "assistant", "content": "Use request.method to check the HTTP method and handle each case. You can also use the methods parameter in the @API decorator to restrict which methods are allowed. Example:\n```python\n@API(methods=['GET', 'POST'])\nasync def my_api(request):\n match request.method:\n case 'GET':\n return {'data': 'get'}\n case 'POST':\n return {'data': 'post'}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What are the advantages of using Generic APIs in Panther?"}, {"role": "assistant", "content": "Generic APIs provide built-in functionality like pagination, filtering, searching, and sorting without writing boilerplate code. They follow REST conventions and provide consistent behavior across your application. They're especially useful for standard CRUD operations."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do WebSockets work in Panther?"}, {"role": "assistant", "content": "WebSockets in Panther provide real-time bidirectional communication between clients and servers. They allow persistent connections where both client and server can send messages at any time. Panther's WebSocket implementation is built on top of ASGI and supports authentication, permissions, and custom message handling."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create a WebSocket endpoint in Panther?"}, {"role": "assistant", "content": "Inherit from GenericWebsocket and implement the required methods. Register the class in your urls. Example:\n```python\nfrom panther.websocket import GenericWebsocket\nclass EchoWebsocket(GenericWebsocket):\n async def connect(self):\n await self.accept()\n async def receive(self, data):\n await self.send(data)\nurls = {\n 'ws/echo/': EchoWebsocket,\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What methods can I implement in a Panther WebSocket?"}, {"role": "assistant", "content": "You can implement these methods in your WebSocket class:\n- connect(): Called when a client connects\n- disconnect(): Called when a client disconnects\n- receive(data): Called when a message is received from the client\n- send(data): Send a message to the client\n- close(): Close the connection\n- accept(): Accept the WebSocket connection"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle WebSocket authentication in Panther?"}, {"role": "assistant", "content": "Set the auth attribute on your WebSocket class to an authentication class. Panther will authenticate the connection before calling connect(). Example:\n```python\nfrom panther.websocket import GenericWebsocket\nfrom panther.authentications import JWTAuthentication\nclass ChatWebsocket(GenericWebsocket):\n auth = JWTAuthentication\n async def connect(self):\n # self.user is available after authentication\n await self.accept()\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use permissions with WebSockets in Panther?"}, {"role": "assistant", "content": "Set the permissions attribute on your WebSocket class to a list of permission classes. Panther will check these before accepting the connection. Example:\n```python\nfrom panther.websocket import GenericWebsocket\nclass AdminWebsocket(GenericWebsocket):\n permissions = [IsAdmin]\n async def connect(self):\n await self.accept()\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I send messages to WebSocket clients in Panther?"}, {"role": "assistant", "content": "Use the send() method to send messages to the connected client. You can send strings, dictionaries, or other serializable data. Example:\n```python\nasync def receive(self, data):\n # Echo the message back\n await self.send(data)\n # Send a custom message\n await self.send({'type': 'notification', 'message': 'Hello!'})\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle different message types in Panther WebSockets?"}, {"role": "assistant", "content": "You can check the message type in the receive() method and handle different types accordingly. Example:\n```python\nasync def receive(self, data):\n if isinstance(data, dict):\n message_type = data.get('type')\n if message_type == 'chat':\n await self.handle_chat(data)\n elif message_type == 'join':\n await self.handle_join(data)\n else:\n await self.send({'error': 'Invalid message format'})\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I broadcast messages to multiple WebSocket clients in Panther?"}, {"role": "assistant", "content": "You can maintain a list of connected clients and broadcast to all of them. Example:\n```python\nclass ChatWebsocket(GenericWebsocket):\n connected_clients = []\n async def connect(self):\n await self.accept()\n self.connected_clients.append(self)\n async def disconnect(self):\n self.connected_clients.remove(self)\n async def broadcast(self, message):\n for client in self.connected_clients:\n await client.send(message)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle WebSocket errors in Panther?"}, {"role": "assistant", "content": "You can catch exceptions in your WebSocket methods and handle them appropriately. Use try-catch blocks and send error messages to clients. Example:\n```python\nasync def receive(self, data):\n try:\n await self.process_message(data)\n except Exception as e:\n await self.send({'error': str(e)})\n await self.close()\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I close a WebSocket connection in Panther?"}, {"role": "assistant", "content": "Use the close() method to close the WebSocket connection. You can also specify a close code and reason. Example:\n```python\nasync def disconnect(self):\n # Connection is automatically closed\n pass\nasync def force_disconnect(self):\n await self.close(code=1000, reason='User requested disconnect')\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access WebSocket connection information in Panther?"}, {"role": "assistant", "content": "You can access connection information through the WebSocket object. Example:\n```python\nasync def connect(self):\n # Get client IP\n client_ip = self.scope.get('client')[0]\n # Get headers\n headers = dict(self.scope.get('headers', []))\n # Get query parameters\n query_params = self.scope.get('query_string', b'').decode()\n await self.accept()\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I implement a chat room with Panther WebSockets?"}, {"role": "assistant", "content": "Create a WebSocket that maintains a list of connected clients and broadcasts messages. Example:\n```python\nclass ChatRoom(GenericWebsocket):\n connected_clients = []\n async def connect(self):\n await self.accept()\n self.connected_clients.append(self)\n await self.broadcast({'type': 'join', 'user': self.user.username})\n async def disconnect(self):\n self.connected_clients.remove(self)\n await self.broadcast({'type': 'leave', 'user': self.user.username})\n async def receive(self, data):\n await self.broadcast({'type': 'message', 'user': self.user.username, 'message': data})\n async def broadcast(self, message):\n for client in self.connected_clients:\n await client.send(message)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle WebSocket reconnection in Panther?"}, {"role": "assistant", "content": "Clients can reconnect by establishing a new WebSocket connection. You can implement reconnection logic by maintaining user sessions and allowing reconnection with the same authentication token. The connect() method will be called again for each new connection."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I implement WebSocket rate limiting in Panther?"}, {"role": "assistant", "content": "You can implement rate limiting by tracking message frequency per client and limiting the number of messages sent within a time period. Example:\n```python\nimport time\nclass RateLimitedWebsocket(GenericWebsocket):\n message_times = {}\n async def receive(self, data):\n client_id = self.user.id\n current_time = time.time()\n if client_id in self.message_times:\n if current_time - self.message_times[client_id] < 1: # 1 second limit\n await self.send({'error': 'Rate limit exceeded'})\n return\n self.message_times[client_id] = current_time\n await self.send(data)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I test WebSocket endpoints in Panther?"}, {"role": "assistant", "content": "You can test WebSocket endpoints using tools like wscat, browser WebSocket APIs, or Python libraries like websockets. Example with wscat:\n```bash\nwscat -c ws://localhost:8000/ws/echo/\n```\nOr with Python:\n```python\nimport asyncio\nimport websockets\nasync def test_websocket():\n async with websockets.connect('ws://localhost:8000/ws/echo/') as websocket:\n await websocket.send('Hello')\n response = await websocket.recv()\n print(response)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the best practice for WebSocket error handling in Panther?"}, {"role": "assistant", "content": "Always wrap WebSocket operations in try-catch blocks. Send meaningful error messages to clients. Log errors for debugging. Handle connection drops gracefully. Use appropriate close codes when closing connections. Example:\n```python\nasync def receive(self, data):\n try:\n await self.process_message(data)\n except ValueError as e:\n await self.send({'error': 'Invalid data format'})\n except Exception as e:\n await self.send({'error': 'Internal server error'})\n await self.close(code=1011)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do WebSockets work with Panther's authentication system?"}, {"role": "assistant", "content": "WebSockets can use the same authentication classes as regular APIs. Set the auth attribute on your WebSocket class. After successful authentication, self.user will contain the authenticated user. If authentication fails, the connection will be rejected before connect() is called."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I use WebSockets with Panther's caching system?"}, {"role": "assistant", "content": "WebSockets don't use Panther's caching system since they maintain persistent connections and send real-time data. Caching is designed for HTTP requests, while WebSockets are for live, bidirectional communication."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I implement WebSocket rooms or channels in Panther?"}, {"role": "assistant", "content": "You can implement rooms by maintaining a dictionary of room members and broadcasting messages only to clients in specific rooms. Example:\n```python\nclass RoomWebsocket(GenericWebsocket):\n rooms = {}\n async def connect(self):\n await self.accept()\n room_id = self.scope.get('url_route', {}).get('kwargs', {}).get('room_id')\n if room_id not in self.rooms:\n self.rooms[room_id] = []\n self.rooms[room_id].append(self)\n async def receive(self, data):\n room_id = self.scope.get('url_route', {}).get('kwargs', {}).get('room_id')\n for client in self.rooms[room_id]:\n await client.send(data)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does routing work in Panther?"}, {"role": "assistant", "content": "Routing in Panther maps URL patterns to API endpoints or WebSocket classes. Panther uses a simple dictionary-based routing system where keys are URL patterns and values are API functions or WebSocket classes. Routes are matched in order, and the first match is used."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I define basic routes in Panther?"}, {"role": "assistant", "content": "Create a urls.py file and define a urls dictionary with URL patterns as keys and API functions or WebSocket classes as values. Example:\n```python\nfrom app.apis import user_api, book_api\nfrom app.websockets import ChatWebsocket\nurls = {\n 'user/': user_api,\n 'book/': book_api,\n 'ws/chat/': ChatWebsocket,\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create nested routes in Panther?"}, {"role": "assistant", "content": "You can create nested routes by defining a urls dictionary within another urls dictionary. Example:\n```python\nfrom app.apis import user_api, user_detail_api\nfrom app.apis import book_api, book_detail_api\nurls = {\n 'user/': {\n '': user_api,\n '<id>/': user_detail_api,\n },\n 'book/': {\n '': book_api,\n '<id>/': book_detail_api,\n },\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use URL parameters in Panther routes?"}, {"role": "assistant", "content": "Use angle brackets to define URL parameters. The parameter name will be available in your API function. Example:\n```python\nfrom app.apis import user_detail_api\nurls = {\n 'user/<id>/': user_detail_api,\n}\n# In your API function:\nasync def user_detail_api(request, id):\n # id parameter is available here\n return {'user_id': id}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle multiple URL parameters in Panther?"}, {"role": "assistant", "content": "You can define multiple parameters in the URL pattern. They will be passed to your API function in the order they appear. Example:\n```python\nfrom app.apis import book_chapter_api\nurls = {\n 'book/<book_id>/chapter/<chapter_id>/': book_chapter_api,\n}\n# In your API function:\nasync def book_chapter_api(request, book_id, chapter_id):\n return {'book_id': book_id, 'chapter_id': chapter_id}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I include routes from other modules in Panther?"}, {"role": "assistant", "content": "Import the urls dictionary from other modules and include it in your main urls dictionary. Example:\n```python\nfrom app.apis import user_urls, book_urls\nfrom app.websockets import websocket_urls\nurls = {\n 'api/': {\n 'user/': user_urls,\n 'book/': book_urls,\n },\n 'ws/': websocket_urls,\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I set up the main URL configuration in Panther?"}, {"role": "assistant", "content": "Create a main.py file and import your urls dictionary. Panther will automatically use this for routing. Example:\n```python\nfrom panther import Panther\nfrom app.urls import urls\napp = Panther(__name__)\napp.include_urls(urls)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle trailing slashes in Panther routes?"}, {"role": "assistant", "content": "Panther automatically handles trailing slashes. You can define routes with or without trailing slashes, and Panther will match both. However, it's recommended to be consistent and use trailing slashes for clarity."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create API versioning in Panther routes?"}, {"role": "assistant", "content": "You can create API versioning by organizing routes with version prefixes. Example:\n```python\nfrom app.apis.v1 import user_api as user_api_v1\nfrom app.apis.v2 import user_api as user_api_v2\nurls = {\n 'api/v1/': {\n 'user/': user_api_v1,\n },\n 'api/v2/': {\n 'user/': user_api_v2,\n },\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle 404 errors for unmatched routes in Panther?"}, {"role": "assistant", "content": "Panther automatically returns a 404 Not Found response when no route matches the requested URL. You don't need to handle this manually. Panther will also return a 405 Method Not Allowed if the route exists but doesn't support the HTTP method."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I organize routes in a large Panther application?"}, {"role": "assistant", "content": "For large applications, organize routes by feature or module. Create separate urls.py files for each module and include them in the main urls dictionary. Example:\n```python\n# app/urls.py\nfrom app.apis.user import urls as user_urls\nfrom app.apis.book import urls as book_urls\nfrom app.apis.order import urls as order_urls\nurls = {\n 'user/': user_urls,\n 'book/': book_urls,\n 'order/': order_urls,\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use regex patterns in Panther routes?"}, {"role": "assistant", "content": "Panther uses simple string matching for routes, not regex patterns. Use URL parameters with angle brackets for dynamic parts of the URL. If you need complex pattern matching, handle it in your API function logic."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create optional URL parameters in Panther?"}, {"role": "assistant", "content": "Panther doesn't support optional URL parameters in the route definition. You can handle optional parameters by creating multiple routes or by using query parameters instead. Example:\n```python\n# Use query parameters for optional data\nurls = {\n 'user/<id>/': user_detail_api,\n}\n# In your API: request.query_params.get('include_details')\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle route conflicts in Panther?"}, {"role": "assistant", "content": "Routes are matched in the order they are defined in the urls dictionary. More specific routes should be defined before more general ones. Panther will use the first matching route it finds."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create a catch-all route in Panther?"}, {"role": "assistant", "content": "You can create a catch-all route by using a very general pattern at the end of your urls dictionary. Example:\n```python\nfrom app.apis import not_found_api\nurls = {\n 'user/': user_api,\n 'book/': book_api,\n '': not_found_api, # Catch-all route\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I debug routing issues in Panther?"}, {"role": "assistant", "content": "To debug routing issues, check the order of your routes (more specific first), verify URL patterns match exactly, ensure API functions are properly imported, and check that URL parameters are correctly defined. Panther will log routing information in debug mode."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create RESTful routes in Panther?"}, {"role": "assistant", "content": "Create RESTful routes by organizing URLs hierarchically and using appropriate HTTP methods. Example:\n```python\nfrom app.apis import UserListAPI, UserDetailAPI\nurls = {\n 'users/': UserListAPI, # GET /users/, POST /users/\n 'users/<id>/': UserDetailAPI, # GET /users/1/, PUT /users/1/, DELETE /users/1/\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle route middleware in Panther?"}, {"role": "assistant", "content": "Middleware in Panther is applied globally or per-API, not per-route. If you need different middleware for different route groups, you can apply middleware to the specific API functions or use global middleware with conditional logic."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create route aliases in Panther?"}, {"role": "assistant", "content": "You can create route aliases by defining multiple URL patterns that point to the same API function. Example:\n```python\nfrom app.apis import user_api\nurls = {\n 'user/': user_api,\n 'users/': user_api, # Alias\n 'u/': user_api, # Short alias\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle route caching in Panther?"}, {"role": "assistant", "content": "Route caching is handled automatically by Panther. The routing table is compiled once at startup and cached for performance. You don't need to manage route caching manually."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the best practice for organizing Panther routes?"}, {"role": "assistant", "content": "Organize routes by feature or domain. Use consistent naming conventions. Group related endpoints together. Use versioning for API evolution. Keep route patterns simple and RESTful. Document your route structure for team collaboration."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What are path variables in Panther?"}, {"role": "assistant", "content": "Path variables in Panther are dynamic parts of URLs that capture values from the URL path and pass them to your API functions as parameters. They are defined using angle brackets (<variable_name>) in route patterns and allow you to create dynamic, parameterized endpoints."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I define path variables in Panther routes?"}, {"role": "assistant", "content": "Use angle brackets with a variable name in your route pattern. The variable name will be passed as a parameter to your API function. Example:\n```python\nfrom app.apis import user_detail_api\nurls = {\n 'user/<id>/': user_detail_api,\n}\n# The API function receives the id parameter:\nasync def user_detail_api(request, id):\n return {'user_id': id}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use multiple path variables in a single route?"}, {"role": "assistant", "content": "Define multiple path variables in the URL pattern. They will be passed to your API function in the order they appear in the URL. Example:\n```python\nfrom app.apis import book_chapter_api\nurls = {\n 'book/<book_id>/chapter/<chapter_id>/': book_chapter_api,\n}\n# The API function receives both parameters:\nasync def book_chapter_api(request, book_id, chapter_id):\n return {'book_id': book_id, 'chapter_id': chapter_id}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What types of values can path variables capture in Panther?"}, {"role": "assistant", "content": "Path variables in Panther capture string values from the URL. You can convert them to other types (int, float, etc.) in your API function logic. Panther doesn't enforce type constraints on path variables at the routing level."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I convert path variables to specific data types in Panther?"}, {"role": "assistant", "content": "Convert path variables to specific types in your API function. Example:\n```python\nasync def user_detail_api(request, id):\n try:\n user_id = int(id)\n user = await User.find_one(id=user_id)\n return user\n except ValueError:\n raise NotFoundAPIError('Invalid user ID')\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I validate path variables in Panther?"}, {"role": "assistant", "content": "Validate path variables in your API function logic. Check for valid formats, ranges, or existence in the database. Example:\n```python\nasync def user_detail_api(request, id):\n if not id.isdigit():\n raise NotFoundAPIError('Invalid user ID format')\n user_id = int(id)\n if user_id <= 0:\n raise NotFoundAPIError('User ID must be positive')\n user = await User.find_one(id=user_id)\n if not user:\n raise NotFoundAPIError('User not found')\n return user\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I use path variables with nested routes in Panther?"}, {"role": "assistant", "content": "Yes, you can use path variables in nested routes. The variables will be passed to the appropriate API function based on the route structure. Example:\n```python\nurls = {\n 'user/': {\n '<id>/': user_detail_api,\n '<id>/posts/': user_posts_api,\n '<id>/posts/<post_id>/': user_post_detail_api,\n }\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle optional path variables in Panther?"}, {"role": "assistant", "content": "Panther doesn't support optional path variables in route definitions. Use query parameters for optional data or create multiple routes. Example:\n```python\n# Use query parameters for optional data\nurls = {\n 'user/<id>/': user_detail_api,\n}\n# In your API function:\nasync def user_detail_api(request, id):\n include_posts = request.query_params.get('include_posts', 'false').lower() == 'true'\n # Handle optional include_posts parameter\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create RESTful URLs with path variables in Panther?"}, {"role": "assistant", "content": "Use path variables to create RESTful resource URLs. Example:\n```python\nfrom app.apis import UserListAPI, UserDetailAPI, UserPostsAPI\nurls = {\n 'users/': UserListAPI, # GET /users/, POST /users/\n 'users/<id>/': UserDetailAPI, # GET /users/1/, PUT /users/1/, DELETE /users/1/\n 'users/<id>/posts/': UserPostsAPI, # GET /users/1/posts/\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle path variable conflicts in Panther?"}, {"role": "assistant", "content": "Routes are matched in order, so define more specific routes before general ones. Panther will use the first matching route. Example:\n```python\nurls = {\n 'user/admin/': admin_api, # More specific first\n 'user/<id>/': user_detail_api, # More general second\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access path variables in class-based APIs in Panther?"}, {"role": "assistant", "content": "Path variables are passed as parameters to your API methods in class-based APIs. Example:\n```python\nfrom panther.app import GenericAPI\nclass UserAPI(GenericAPI):\n async def get(self, request, id):\n user_id = int(id)\n user = await User.find_one(id=user_id)\n return user\n async def put(self, request, id):\n user_id = int(id)\n # Update user logic\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use path variables with Generic APIs in Panther?"}, {"role": "assistant", "content": "Generic APIs like RetrieveAPI, UpdateAPI, and DeleteAPI automatically handle path variables. The variable is typically used as the primary key. Example:\n```python\nfrom panther.generics import RetrieveAPI\nclass UserDetailAPI(RetrieveAPI):\n model = User\n # The path variable will be used as the lookup key\nurls = {\n 'user/<id>/': UserDetailAPI,\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle path variables with authentication in Panther?"}, {"role": "assistant", "content": "Path variables are available after authentication. You can use them along with the authenticated user information. Example:\n```python\nasync def user_detail_api(request, id):\n user_id = int(id)\n # Check if user can access this resource\n if request.user.id != user_id and not request.user.is_admin:\n raise AuthorizationAPIError('Access denied')\n user = await User.find_one(id=user_id)\n return user\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I debug path variable issues in Panther?"}, {"role": "assistant", "content": "To debug path variable issues, check that variable names match between route definition and API function parameters, verify the order of parameters matches the URL pattern, and ensure proper type conversion and validation in your API function."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create SEO-friendly URLs with path variables in Panther?"}, {"role": "assistant", "content": "Use descriptive path variables and combine them with slugs or titles. Example:\n```python\nurls = {\n 'blog/<year>/<month>/<slug>/': blog_post_api,\n 'product/<category>/<product_id>/': product_detail_api,\n}\n# URLs like: /blog/2024/01/my-awesome-post/\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle path variables with special characters in Panther?"}, {"role": "assistant", "content": "Path variables capture the raw string from the URL. Handle special characters, encoding, or URL-encoded values in your API function logic. Example:\n```python\nimport urllib.parse\nasync def search_api(request, query):\n decoded_query = urllib.parse.unquote(query)\n # Process the decoded query\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create hierarchical path variables in Panther?"}, {"role": "assistant", "content": "Create hierarchical structures by nesting path variables. Example:\n```python\nurls = {\n 'organization/<org_id>/': {\n 'department/<dept_id>/': {\n 'employee/<emp_id>/': employee_detail_api,\n }\n }\n}\n# URL: /organization/123/department/456/employee/789/\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle path variables with default values in Panther?"}, {"role": "assistant", "content": "Since Panther doesn't support optional path variables, use query parameters or create multiple routes for different scenarios. Example:\n```python\nurls = {\n 'user/<id>/': user_detail_api,\n 'user/': user_list_api, # Default behavior\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the best practice for naming path variables in Panther?"}, {"role": "assistant", "content": "Use descriptive, lowercase names with underscores for multi-word variables. Be consistent across your application. Use singular nouns for resource identifiers. Examples: user_id, book_title, post_slug, category_name."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access the request object in Panther APIs?"}, {"role": "assistant", "content": "To access the request object in Panther APIs, you must annotate the request parameter with the Request type. This tells Panther to inject the request object. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\n@API()\nasync def my_api(request: Request):\n # Now you can use the request object\n return {'method': request.method}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What happens if I don't annotate the request parameter with Request?"}, {"role": "assistant", "content": "If you don't annotate the request parameter with Request, Panther will not inject the request object. The parameter will be treated as a regular parameter and will be None or cause an error. Always use `request: Request` annotation when you need access to the request object."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What information can I access from the request object in Panther?"}, {"role": "assistant", "content": "The request object provides access to:\n- request.method: HTTP method (GET, POST, etc.)\n- request.data: Request body data\n- request.query_params: Query parameters\n- request.headers: Request headers\n- request.user: Authenticated user (if authentication is enabled)\n- request.validated_data: Validated data from input_model\n- request.path: Request path\n- request.url: Full request URL"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access query parameters in Panther APIs?"}, {"role": "assistant", "content": "Access query parameters through request.query_params. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\n@API()\nasync def search_api(request: Request):\n query = request.query_params.get('q', '')\n page = int(request.query_params.get('page', 1))\n limit = int(request.query_params.get('limit', 10))\n return {'query': query, 'page': page, 'limit': limit}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access request headers in Panther APIs?"}, {"role": "assistant", "content": "Access request headers through request.headers. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\n@API()\nasync def api_with_headers(request: Request):\n content_type = request.headers.get('content-type')\n user_agent = request.headers.get('user-agent')\n authorization = request.headers.get('authorization')\n return {'content_type': content_type, 'user_agent': user_agent}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access the request body data in Panther APIs?"}, {"role": "assistant", "content": "Access request body data through request.data. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\n@API()\nasync def create_user(request: Request):\n if request.method == 'POST':\n user_data = request.data\n # Process the raw request data\n return {'received_data': user_data}\n return {'message': 'Use POST method'}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access validated data from input_model in Panther APIs?"}, {"role": "assistant", "content": "Access validated data through request.validated_data when using input_model. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\nfrom app.serializers import UserSerializer\n@API(input_model=UserSerializer)\nasync def create_user(request: Request):\n validated_data = request.validated_data\n # validated_data is already validated and parsed\n user = await User.insert_one(**validated_data.model_dump())\n return user\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access the authenticated user in Panther APIs?"}, {"role": "assistant", "content": "Access the authenticated user through request.user. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\nfrom panther.authentications import JWTAuthentication\n@API(auth=JWTAuthentication)\nasync def user_profile(request: Request):\n user = request.user\n if user:\n return {'username': user.username, 'email': user.email}\n return {'message': 'Not authenticated'}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle different HTTP methods in Panther APIs?"}, {"role": "assistant", "content": "Use request.method to check the HTTP method and handle each case. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\n@API(methods=['GET', 'POST', 'PUT', 'DELETE'])\nasync def user_api(request: Request):\n match request.method:\n case 'GET':\n return {'action': 'get users'}\n case 'POST':\n return {'action': 'create user'}\n case 'PUT':\n return {'action': 'update user'}\n case 'DELETE':\n return {'action': 'delete user'}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access request path and URL information in Panther APIs?"}, {"role": "assistant", "content": "Access path and URL information through request.path and request.url. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\n@API()\nasync def path_info_api(request: Request):\n path = request.path\n full_url = request.url\n return {\n 'path': path,\n 'full_url': full_url,\n 'method': request.method\n }\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle request data validation in Panther APIs?"}, {"role": "assistant", "content": "Use input_model for automatic validation. Panther will validate request.data and provide validated_data. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\nfrom app.serializers import UserSerializer\n@API(input_model=UserSerializer)\nasync def create_user(request: Request):\n try:\n validated_data = request.validated_data\n # Data is already validated\n user = await User.insert_one(**validated_data.model_dump())\n return user\n except Exception as e:\n return {'error': str(e)}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access request data without input_model in Panther APIs?"}, {"role": "assistant", "content": "Access raw request data through request.data and handle validation manually. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\n@API()\nasync def manual_validation_api(request: Request):\n if request.method == 'POST':\n raw_data = request.data\n # Manual validation logic here\n if not raw_data.get('username'):\n return {'error': 'Username is required'}\n return {'data': raw_data}\n return {'message': 'Use POST method'}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle file uploads in Panther APIs?"}, {"role": "assistant", "content": "Access file uploads through request.data. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\n@API()\nasync def upload_file(request: Request):\n if request.method == 'POST':\n file_data = request.data.get('file')\n if file_data:\n # Handle file upload logic\n return {'message': 'File uploaded successfully'}\n return {'error': 'No file provided'}\n return {'message': 'Use POST method'}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access request cookies in Panther APIs?"}, {"role": "assistant", "content": "Access cookies through request.headers. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\n@API()\nasync def cookie_api(request: Request):\n cookie_header = request.headers.get('cookie', '')\n # Parse cookie header manually or use a cookie parser\n return {'cookies': cookie_header}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle request errors in Panther APIs?"}, {"role": "assistant", "content": "Use try-catch blocks to handle request processing errors. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\nfrom panther.exceptions import APIError\n@API()\nasync def error_handling_api(request: Request):\n try:\n data = request.data\n # Process data\n return {'success': True}\n except ValueError as e:\n raise APIError('Invalid data format', status_code=400)\n except Exception as e:\n raise APIError('Internal server error', status_code=500)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access request metadata in Panther APIs?"}, {"role": "assistant", "content": "Access various request metadata through the request object. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\n@API()\nasync def metadata_api(request: Request):\n return {\n 'method': request.method,\n 'path': request.path,\n 'url': request.url,\n 'headers': dict(request.headers),\n 'query_params': dict(request.query_params),\n 'user': request.user.username if request.user else None\n }\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use request data with path variables in Panther APIs?"}, {"role": "assistant", "content": "Combine path variables with request data. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\n@API()\nasync def user_update_api(request: Request, user_id):\n if request.method == 'PUT':\n update_data = request.data\n # Update user with user_id using update_data\n return {'user_id': user_id, 'updated_data': update_data}\n return {'message': 'Use PUT method'}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the best practice for using the request object in Panther APIs?"}, {"role": "assistant", "content": "Always annotate the request parameter with Request type. Use input_model for automatic validation when possible. Handle different HTTP methods appropriately. Access user information safely (check if request.user exists). Use try-catch blocks for error handling. Keep request processing logic clean and focused."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create responses in Panther APIs?"}, {"role": "assistant", "content": "In Panther APIs, you can return data directly or use the Response class for more control. Panther automatically wraps your return value in a Response object. Example:\n```python\nfrom panther.app import API\n@API()\nasync def simple_api():\n return {'message': 'Hello World'} # Automatically wrapped in Response\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use the Response class in Panther APIs?"}, {"role": "assistant", "content": "Import and use the Response class for more control over your API responses. Example:\n```python\nfrom panther.app import API\nfrom panther.response import Response\nfrom panther import status\n@API()\nasync def custom_response_api():\n return Response(\n data={'message': 'Success'},\n status_code=status.HTTP_201_CREATED,\n headers={'X-Custom-Header': 'value'}\n )\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What are the parameters of the Response class in Panther?"}, {"role": "assistant", "content": "The Response class accepts these parameters:\n- data: The response data (dict, list, or any serializable object)\n- status_code: HTTP status code (default: 200)\n- headers: Custom response headers (dict)\n- cookies: Response cookies (dict)\nExample:\n```python\nfrom panther.response import Response\nResponse(\n data={'result': 'success'},\n status_code=201,\n headers={'Content-Type': 'application/json'},\n cookies={'session_id': 'abc123'}\n)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I set custom status codes in Panther APIs?"}, {"role": "assistant", "content": "Use the Response class with a custom status_code or return a tuple with (data, status_code). Example:\n```python\nfrom panther.app import API\nfrom panther.response import Response\nfrom panther import status\n@API()\nasync def status_code_api():\n # Method 1: Using Response class\n return Response(data={'created': True}, status_code=status.HTTP_201_CREATED)\n # Method 2: Using tuple\n return {'created': True}, 201\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I add custom headers to Panther API responses?"}, {"role": "assistant", "content": "Use the Response class with the headers parameter. Example:\n```python\nfrom panther.app import API\nfrom panther.response import Response\n@API()\nasync def headers_api():\n return Response(\n data={'message': 'Success'},\n headers={\n 'X-Custom-Header': 'custom-value',\n 'Cache-Control': 'no-cache',\n 'Content-Type': 'application/json'\n }\n )\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I set cookies in Panther API responses?"}, {"role": "assistant", "content": "Use the Response class with the cookies parameter. Example:\n```python\nfrom panther.app import API\nfrom panther.response import Response\n@API()\nasync def cookie_api():\n return Response(\n data={'message': 'Cookie set'},\n cookies={\n 'session_id': 'abc123',\n 'user_preference': 'dark_mode'\n }\n )\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle different response types in Panther APIs?"}, {"role": "assistant", "content": "Return different data types based on your needs. Panther handles serialization automatically. Example:\n```python\nfrom panther.app import API\n@API()\nasync def response_types_api():\n # Return dict\n return {'message': 'dict response'}\n # Return list\n return [1, 2, 3, 4, 5]\n # Return string\n return 'string response'\n # Return None (204 No Content)\n return None\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create error responses in Panther APIs?"}, {"role": "assistant", "content": "Raise Panther exceptions or use Response with error status codes. Example:\n```python\nfrom panther.app import API\nfrom panther.response import Response\nfrom panther.exceptions import NotFoundAPIError, APIError\n@API()\nasync def error_responses_api():\n # Method 1: Raise exceptions\n if not found:\n raise NotFoundAPIError('Resource not found')\n # Method 2: Use Response with error status\n return Response(\n data={'error': 'Custom error message'},\n status_code=400\n )\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I return paginated responses in Panther APIs?"}, {"role": "assistant", "content": "Structure your response data to include pagination information. Example:\n```python\nfrom panther.app import API\n@API()\nasync def paginated_api():\n page = 1\n limit = 10\n total = 100\n data = [{'id': i} for i in range(1, 11)]\n return {\n 'data': data,\n 'pagination': {\n 'page': page,\n 'limit': limit,\n 'total': total,\n 'pages': (total + limit - 1) // limit\n }\n }\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle file downloads in Panther APIs?"}, {"role": "assistant", "content": "Use Response with appropriate headers for file downloads. Example:\n```python\nfrom panther.app import API\nfrom panther.response import Response\n@API()\nasync def download_file_api():\n file_content = b'file content here'\n return Response(\n data=file_content,\n headers={\n 'Content-Type': 'application/octet-stream',\n 'Content-Disposition': 'attachment; filename=\"file.txt\"'\n }\n )\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I return JSON responses in Panther APIs?"}, {"role": "assistant", "content": "Panther automatically serializes your response data to JSON. Example:\n```python\nfrom panther.app import API\n@API()\nasync def json_api():\n return {\n 'status': 'success',\n 'data': {\n 'id': 1,\n 'name': 'John Doe',\n 'email': 'john@example.com'\n },\n 'timestamp': '2024-01-01T00:00:00Z'\n }\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle conditional responses in Panther APIs?"}, {"role": "assistant", "content": "Return different responses based on conditions. Example:\n```python\nfrom panther.app import API\nfrom panther.response import Response\n@API()\nasync def conditional_api(request: Request):\n if request.method == 'GET':\n return {'message': 'GET response'}\n elif request.method == 'POST':\n return Response(\n data={'message': 'POST response'},\n status_code=201\n )\n else:\n return Response(\n data={'error': 'Method not allowed'},\n status_code=405\n )\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I return empty responses in Panther APIs?"}, {"role": "assistant", "content": "Return None or empty data structures for empty responses. Example:\n```python\nfrom panther.app import API\nfrom panther.response import Response\n@API()\nasync def empty_responses_api():\n # Method 1: Return None (204 No Content)\n return None\n # Method 2: Return empty dict\n return {}\n # Method 3: Return empty list\n return []\n # Method 4: Use Response with empty data\n return Response(data={}, status_code=204)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle response serialization in Panther APIs?"}, {"role": "assistant", "content": "Panther automatically handles serialization. Use output_model for structured responses. Example:\n```python\nfrom panther.app import API\nfrom app.serializers import UserSerializer\n@API(output_model=UserSerializer)\nasync def serialized_api():\n user_data = {\n 'id': 1,\n 'username': 'john_doe',\n 'email': 'john@example.com'\n }\n return user_data # Will be serialized using UserSerializer\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create streaming responses in Panther APIs?"}, {"role": "assistant", "content": "Return data that can be iterated over for streaming responses. Example:\n```python\nfrom panther.app import API\n@API()\nasync def streaming_api():\n def generate_data():\n for i in range(10):\n yield f'data chunk {i}\\n'\n return generate_data()\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle response caching headers in Panther APIs?"}, {"role": "assistant", "content": "Set cache control headers using the Response class. Example:\n```python\nfrom panther.app import API\nfrom panther.response import Response\n@API()\nasync def cached_api():\n return Response(\n data={'data': 'cached content'},\n headers={\n 'Cache-Control': 'public, max-age=3600',\n 'ETag': 'abc123',\n 'Last-Modified': 'Wed, 01 Jan 2024 00:00:00 GMT'\n }\n )\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I return binary data in Panther APIs?"}, {"role": "assistant", "content": "Return bytes or use Response with appropriate content type. Example:\n```python\nfrom panther.app import API\nfrom panther.response import Response\n@API()\nasync def binary_api():\n binary_data = b'\\x89PNG\\r\\n\\x1a\\n' # PNG file header\n return Response(\n data=binary_data,\n headers={'Content-Type': 'image/png'}\n )\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle response compression in Panther APIs?"}, {"role": "assistant", "content": "Panther handles compression automatically based on client headers. You can also set compression headers manually. Example:\n```python\nfrom panther.app import API\nfrom panther.response import Response\n@API()\nasync def compressed_api():\n return Response(\n data={'large_data': 'x' * 1000},\n headers={'Content-Encoding': 'gzip'}\n )\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the best practice for creating responses in Panther APIs?"}, {"role": "assistant", "content": "Use simple return statements for basic responses. Use Response class for custom status codes, headers, or cookies. Use output_model for structured data serialization. Handle errors with appropriate exceptions. Keep response data consistent and well-structured. Use appropriate HTTP status codes for different scenarios."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use Jinja templates in Panther?"}, {"role": "assistant", "content": "Panther supports Jinja2 templates for rendering HTML responses. You can use TemplateResponse to render templates with context data. First, ensure Jinja2 is installed:\n```bash\npip install jinja2\n```\nThen use TemplateResponse in your APIs:\n```python\nfrom panther.app import API\nfrom panther.response import TemplateResponse\n@API()\nasync def template_api():\n return TemplateResponse(\n template_name='index.html',\n context={'title': 'Welcome', 'users': []}\n )\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create a TemplateResponse in Panther?"}, {"role": "assistant", "content": "Import TemplateResponse and use it to render Jinja templates. Example:\n```python\nfrom panther.app import API\nfrom panther.response import TemplateResponse\n@API()\nasync def render_template_api():\n context = {\n 'title': 'My Website',\n 'message': 'Hello from Panther!',\n 'items': ['item1', 'item2', 'item3']\n }\n return TemplateResponse(\n template_name='page.html',\n context=context\n )\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Where should I place my Jinja templates in a Panther project?"}, {"role": "assistant", "content": "Create a 'templates' directory in your project root or app directory. Panther will look for templates in the 'templates' folder by default. Example structure:\n```\nmy_project/\n├── app/\n│ ├── apis.py\n│ └── templates/\n│ ├── base.html\n│ ├── index.html\n│ └── user/\n│ ├── list.html\n│ └── detail.html\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create a base template in Panther?"}, {"role": "assistant", "content": "Create a base.html template that other templates can extend. Example:\n```html\n<!-- templates/base.html -->\n<!DOCTYPE html>\n<html>\n<head>\n <title>{% block title %}{% endblock %}\n \n\n\n
    \n \n
    \n
    \n {% block content %}{% endblock %}\n
    \n
    \n

    © 2024 My App

    \n
    \n\n\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I extend a base template in Panther?"}, {"role": "assistant", "content": "Use the extends directive to inherit from a base template. Example:\n```html\n\n{% extends \"base.html\" %}\n\n{% block title %}Home Page{% endblock %}\n\n{% block content %}\n

    Welcome to {{ title }}

    \n

    {{ message }}

    \n
      \n {% for item in items %}\n
    • {{ item }}
    • \n {% endfor %}\n
    \n{% endblock %}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I pass data to Jinja templates in Panther?"}, {"role": "assistant", "content": "Pass data through the context parameter in TemplateResponse. Example:\n```python\nfrom panther.app import API\nfrom panther.response import TemplateResponse\nfrom app.models import User\n@API()\nasync def user_list_api():\n users = await User.find()\n return TemplateResponse(\n template_name='user/list.html',\n context={\n 'users': users,\n 'title': 'User List',\n 'count': len(users)\n }\n )\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use Jinja template inheritance in Panther?"}, {"role": "assistant", "content": "Create a hierarchy of templates using extends and blocks. Example:\n```html\n\n\n\n\n {% block title %}Admin{% endblock %}\n\n\n \n {% block content %}{% endblock %}\n\n\n\n\n{% extends \"admin/base.html\" %}\n{% block title %}Admin Dashboard{% endblock %}\n{% block content %}\n

    Dashboard

    \n

    Welcome, {{ user.username }}

    \n{% endblock %}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I include other templates in Panther?"}, {"role": "assistant", "content": "Use the include directive to include other template files. Example:\n```html\n\n{% extends \"base.html\" %}\n\n{% block content %}\n

    {{ user.username }}

    \n {% include \"user/profile.html\" %}\n {% include \"user/posts.html\" with context %}\n{% endblock %}\n\n\n
    \n

    Email: {{ user.email }}

    \n

    Age: {{ user.age }}

    \n
    \n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use Jinja macros in Panther templates?"}, {"role": "assistant", "content": "Define and use macros for reusable template components. Example:\n```html\n\n{% macro render_user_card(user) %}\n
    \n

    {{ user.username }}

    \n

    {{ user.email }}

    \n View Profile\n
    \n{% endmacro %}\n\n\n{% extends \"base.html\" %}\n{% from \"macros.html\" import render_user_card %}\n\n{% block content %}\n

    Users

    \n {% for user in users %}\n {{ render_user_card(user) }}\n {% endfor %}\n{% endblock %}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle forms in Jinja templates with Panther?"}, {"role": "assistant", "content": "Create forms in templates and handle them in your APIs. Example:\n```html\n\n{% extends \"base.html\" %}\n\n{% block content %}\n
    \n
    \n \n \n
    \n
    \n \n \n
    \n \n
    \n{% endblock %}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use Jinja filters in Panther templates?"}, {"role": "assistant", "content": "Use built-in Jinja filters or create custom ones. Example:\n```html\n\n{% extends \"base.html\" %}\n\n{% block content %}\n

    Users ({{ users|length }})

    \n {% for user in users|sort(attribute='username') %}\n
    \n

    {{ user.username|title }}

    \n

    Created: {{ user.created_at|datetime }}

    \n

    Status: {{ user.is_active|yesno('Active', 'Inactive') }}

    \n
    \n {% endfor %}\n{% endblock %}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle template errors in Panther?"}, {"role": "assistant", "content": "Use try-catch blocks to handle template rendering errors. Example:\n```python\nfrom panther.app import API\nfrom panther.response import TemplateResponse\nfrom panther.exceptions import APIError\n@API()\nasync def safe_template_api():\n try:\n return TemplateResponse(\n template_name='page.html',\n context={'data': 'some data'}\n )\n except Exception as e:\n # Fallback to JSON response\n return {'error': 'Template rendering failed', 'message': str(e)}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use static files with Jinja templates in Panther?"}, {"role": "assistant", "content": "Reference static files in your templates and serve them from a static directory. Example:\n```html\n\n\n\n\n {% block title %}{% endblock %}\n \n \n\n\n \"Logo\"\n {% block content %}{% endblock %}\n\n\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create custom Jinja filters in Panther?"}, {"role": "assistant", "content": "You can extend Jinja's environment with custom filters. Example:\n```python\nfrom panther.app import API\nfrom panther.response import TemplateResponse\nfrom datetime import datetime\n\ndef format_date(value, format='%Y-%m-%d'):\n if isinstance(value, datetime):\n return value.strftime(format)\n return value\n\n# Register custom filter with Jinja environment\n# This would typically be done in your app configuration\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use conditional rendering in Jinja templates with Panther?"}, {"role": "assistant", "content": "Use if/elif/else statements for conditional rendering. Example:\n```html\n\n{% extends \"base.html\" %}\n\n{% block content %}\n

    {{ user.username }}

    \n \n {% if user.is_admin %}\n
    Administrator
    \n {% endif %}\n \n {% if user.posts %}\n

    Posts ({{ user.posts|length }})

    \n {% for post in user.posts %}\n
    {{ post.title }}
    \n {% endfor %}\n {% else %}\n

    No posts yet.

    \n {% endif %}\n \n {% if user.is_active and user.email_verified %}\n

    Account is fully verified.

    \n {% elif user.is_active %}\n

    Please verify your email.

    \n {% else %}\n

    Account is inactive.

    \n {% endif %}\n{% endblock %}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle template caching in Panther?"}, {"role": "assistant", "content": "Panther automatically caches compiled templates for performance. In development, templates are reloaded when modified. For production, templates are cached to improve response times."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create a template with dynamic content in Panther?"}, {"role": "assistant", "content": "Pass dynamic data through context and use Jinja expressions. Example:\n```python\nfrom panther.app import API\nfrom panther.response import TemplateResponse\nfrom app.models import Post\n@API()\nasync def dynamic_content_api(request: Request, category=None):\n posts = await Post.find(category=category) if category else await Post.find()\n return TemplateResponse(\n template_name='blog/list.html',\n context={\n 'posts': posts,\n 'category': category,\n 'total_posts': len(posts),\n 'current_user': request.user\n }\n )\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the best practice for organizing Jinja templates in Panther?"}, {"role": "assistant", "content": "Organize templates by feature or module. Use a clear hierarchy with base templates. Keep templates focused and reusable. Use includes and macros for common components. Follow consistent naming conventions. Separate concerns between templates and business logic."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does OpenAPI documentation work in Panther?"}, {"role": "assistant", "content": "Panther automatically generates OpenAPI documentation for your APIs. It analyzes your API definitions, input/output models, and source code to create comprehensive API documentation. Panther supports multiple OpenAPI UI types and provides built-in documentation endpoints."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I enable OpenAPI documentation in Panther?"}, {"role": "assistant", "content": "Add OpenAPI URLs to your main URL configuration. Example:\n```python\nfrom panther.openapi.urls import url_routing as openapi_url_routing\nurl_routing = {\n 'docs/': openapi_url_routing,\n}\n```\nThis will provide access to all OpenAPI UI types at /docs/."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What OpenAPI UI types does Panther support?"}, {"role": "assistant", "content": "Panther supports several OpenAPI UI types:\n- ScalarOpenAPI: Modern, fast OpenAPI UI\n- SwaggerOpenAPI: Classic Swagger UI\n- RedocOpenAPI: Clean, responsive documentation\n- RapiDocOpenAPI: Lightweight, fast UI\n- SpotlightOpenAPI: Modern, feature-rich UI\n\nYou can use them individually or all together via openapi_url_routing."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use output_schema for OpenAPI documentation in Panther?"}, {"role": "assistant", "content": "Use output_schema to explicitly define response models and status codes for OpenAPI docs. Example:\n```python\nfrom panther import status\nfrom panther.app import API\nfrom panther.openapi import OutputSchema\nfrom app.serializers import UserSerializer\n@API(output_schema=OutputSchema(model=UserSerializer, status_code=status.HTTP_201_CREATED))\nasync def create_user():\n return user\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use output_model for OpenAPI documentation in Panther?"}, {"role": "assistant", "content": "Use output_model to specify the response serializer. Panther will use this for OpenAPI docs and response serialization. Example:\n```python\nfrom panther.app import API\nfrom app.serializers import UserSerializer\n@API(output_model=UserSerializer)\nasync def get_user():\n return user\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How does Panther determine response models and status codes for OpenAPI docs?"}, {"role": "assistant", "content": "Panther follows this priority order:\n1. output_schema: Most accurate, specifies both model and status code\n2. output_model: Specifies serializer, Panther extracts status code from source code\n3. Source Code Analysis: Panther analyzes your code to determine response structure\n\nFor best results, always use output_schema."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I document multiple response types in Panther OpenAPI?"}, {"role": "assistant", "content": "Currently, output_schema documents a single response per API. For multiple responses (e.g., success/error), you may need to extend the OpenAPI docs manually or use different APIs for different scenarios."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I add descriptions to my APIs in OpenAPI docs?"}, {"role": "assistant", "content": "Use docstrings in your API functions to add descriptions. Panther will extract these for OpenAPI documentation. Example:\n```python\n@API()\nasync def get_user(request: Request, user_id: int):\n \"\"\"\n Retrieve a user by ID.\n \n Returns user information including profile details.\n \"\"\"\n return user\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I document path parameters in Panther OpenAPI?"}, {"role": "assistant", "content": "Path parameters are automatically detected from your route patterns and function parameters. Panther will document them in the OpenAPI spec. Example:\n```python\n@API()\nasync def user_detail_api(request: Request, user_id: int):\n \"\"\"Get user details by ID\"\"\"\n return user\n# Route: 'user//'\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I document query parameters in Panther OpenAPI?"}, {"role": "assistant", "content": "Query parameters are documented based on your input_model or function parameters. Use Pydantic models with Field descriptions for better documentation. Example:\n```python\nfrom pydantic import BaseModel, Field\nclass UserQueryParams(BaseModel):\n page: int = Field(1, description=\"Page number\")\n limit: int = Field(10, description=\"Items per page\")\n search: str = Field(None, description=\"Search term\")\n\n@API(input_model=UserQueryParams)\nasync def list_users(request: Request):\n return users\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I document request body in Panther OpenAPI?"}, {"role": "assistant", "content": "Use input_model to document request body. Panther will use this for OpenAPI documentation. Example:\n```python\nfrom panther.app import API\nfrom app.serializers import UserCreateSerializer\n@API(input_model=UserCreateSerializer)\nasync def create_user(request: Request):\n data = request.validated_data\n return user\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I add tags to my APIs in OpenAPI docs?"}, {"role": "assistant", "content": "Use the tags parameter in your API decorator to group related endpoints. Example:\n```python\n@API(tags=['users'])\nasync def get_user():\n return user\n\n@API(tags=['users', 'authentication'])\nasync def create_user():\n return user\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I add operation IDs to my APIs in OpenAPI docs?"}, {"role": "assistant", "content": "Use the operation_id parameter in your API decorator. Example:\n```python\n@API(operation_id='getUserById')\nasync def get_user(request: Request, user_id: int):\n return user\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I customize OpenAPI info in Panther?"}, {"role": "assistant", "content": "You can customize OpenAPI metadata by configuring the OpenAPI environment. Example:\n```python\n# In your configs\nOPENAPI_INFO = {\n 'title': 'My API',\n 'version': '1.0.0',\n 'description': 'My awesome API',\n 'contact': {\n 'name': 'API Support',\n 'email': 'support@example.com'\n }\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I add security schemes to OpenAPI docs in Panther?"}, {"role": "assistant", "content": "Security schemes are automatically documented based on your authentication configuration. Panther will include JWT authentication in the OpenAPI spec when you use JWTAuthentication."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I test APIs using OpenAPI docs in Panther?"}, {"role": "assistant", "content": "OpenAPI UI provides interactive testing capabilities. You can:\n- Try out API endpoints directly from the documentation\n- Set authentication tokens\n- Modify request parameters\n- View response examples\n- Test different HTTP methods\n\nVisit your docs endpoint (e.g., /docs/) to access the interactive interface."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I export OpenAPI specification in Panther?"}, {"role": "assistant", "content": "Access the raw OpenAPI specification at /docs/openapi.json. This provides the complete OpenAPI spec in JSON format that can be imported into other tools or used for code generation."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle OpenAPI documentation for WebSockets in Panther?"}, {"role": "assistant", "content": "WebSockets are not typically documented in OpenAPI specs as they use different protocols. OpenAPI is designed for HTTP APIs. For WebSocket documentation, consider using separate documentation or code comments."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I add examples to OpenAPI documentation in Panther?"}, {"role": "assistant", "content": "Use Pydantic Field examples in your serializers. Example:\n```python\nfrom pydantic import BaseModel, Field\nclass UserSerializer(BaseModel):\n username: str = Field(example=\"john_doe\")\n email: str = Field(example=\"john@example.com\")\n age: int = Field(example=25)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I document error responses in Panther OpenAPI?"}, {"role": "assistant", "content": "Panther automatically documents common error responses (400, 401, 403, 404, 500). For custom error responses, you may need to extend the OpenAPI documentation manually or use output_schema with different status codes."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I customize OpenAPI UI themes in Panther?"}, {"role": "assistant", "content": "Different OpenAPI UI types have different customization options. You can modify the UI appearance by customizing the OpenAPI environment or using different UI types that better match your design requirements."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the best practice for OpenAPI documentation in Panther?"}, {"role": "assistant", "content": "Always use output_schema for precise documentation. Add meaningful descriptions using docstrings. Use tags to organize related endpoints. Provide examples in your serializers. Keep documentation up to date with your API changes. Use consistent naming conventions for operation IDs and tags."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do events work in Panther?"}, {"role": "assistant", "content": "Panther supports two specific events: startup and shutdown. These events allow you to run code when your application starts up or shuts down. You can use these events for initialization tasks, cleanup operations, and resource management."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use startup events in Panther?"}, {"role": "assistant", "content": "Use the @startup decorator to register functions that run when your application starts. Import it from panther.events and apply it to functions that should execute during startup. Example:\n```python\nfrom panther.events import startup\n@startup\nasync def initialize_database():\n # Initialize database connection\n await setup_database()\n print('Database initialized')\n\n@startup\nasync def load_configuration():\n # Load application configuration\n await load_config()\n print('Configuration loaded')\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use shutdown events in Panther?"}, {"role": "assistant", "content": "Use the @shutdown decorator to register functions that run when your application shuts down. Import it from panther.events and apply it to functions that should execute during shutdown. Example:\n```python\nfrom panther.events import shutdown\n@shutdown\nasync def cleanup_database():\n # Close database connections\n await close_database_connections()\n print('Database connections closed')\n\n@shutdown\nasync def save_state():\n # Save application state\n await save_application_state()\n print('Application state saved')\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What should I do in startup events in Panther?"}, {"role": "assistant", "content": "Startup events are perfect for:\n- Initializing database connections\n- Loading configuration files\n- Setting up external service connections\n- Creating application caches\n- Running database migrations\n- Validating environment variables\n- Setting up logging systems\n\nExample:\n```python\nfrom panther.events import startup\n@startup\nasync def app_initialization():\n # Validate environment\n validate_env_vars()\n # Setup database\n await setup_database()\n # Load configuration\n load_app_config()\n # Initialize cache\n initialize_cache()\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What should I do in shutdown events in Panther?"}, {"role": "assistant", "content": "Shutdown events are perfect for:\n- Closing database connections\n- Saving application state\n- Cleaning up temporary files\n- Stopping background tasks\n- Closing external service connections\n- Flushing logs\n- Graceful cleanup of resources\n\nExample:\n```python\nfrom panther.events import shutdown\n@shutdown\nasync def app_cleanup():\n # Close database connections\n await close_database()\n # Save state\n await save_state()\n # Cleanup temp files\n cleanup_temp_files()\n # Flush logs\n flush_logs()\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle errors in startup/shutdown events in Panther?"}, {"role": "assistant", "content": "Use try-catch blocks in your startup and shutdown functions to handle errors gracefully. Example:\n```python\nfrom panther.events import startup, shutdown\n@startup\nasync def safe_startup():\n try:\n await initialize_database()\n print('Database initialized successfully')\n except Exception as e:\n print(f'Failed to initialize database: {e}')\n # Decide whether to continue or exit\n raise\n\n@shutdown\nasync def safe_shutdown():\n try:\n await cleanup_resources()\n print('Cleanup completed successfully')\n except Exception as e:\n print(f'Error during cleanup: {e}')\n # Log error but don't prevent shutdown\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "Can I have multiple startup/shutdown events in Panther?"}, {"role": "assistant", "content": "Yes, you can have multiple startup and shutdown events. They will be executed in the order they are defined. Example:\n```python\nfrom panther.events import startup, shutdown\n@startup\nasync def first_startup_task():\n print('First startup task')\n\n@startup\nasync def second_startup_task():\n print('Second startup task')\n\n@shutdown\nasync def first_shutdown_task():\n print('First shutdown task')\n\n@shutdown\nasync def second_shutdown_task():\n print('Second shutdown task')\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use startup events for database initialization in Panther?"}, {"role": "assistant", "content": "Use startup events to initialize database connections and run migrations. Example:\n```python\nfrom panther.events import startup\nfrom panther.db import connect\n@startup\nasync def initialize_database():\n # Connect to database\n await connect()\n # Run migrations\n await run_migrations()\n # Create indexes\n await create_indexes()\n print('Database initialized and ready')\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use shutdown events for database cleanup in Panther?"}, {"role": "assistant", "content": "Use shutdown events to properly close database connections. Example:\n```python\nfrom panther.events import shutdown\nfrom panther.db import close\n@shutdown\nasync def cleanup_database():\n # Close database connections\n await close()\n print('Database connections closed')\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use startup events for configuration loading in Panther?"}, {"role": "assistant", "content": "Use startup events to load and validate configuration. Example:\n```python\nfrom panther.events import startup\nimport os\n@startup\nasync def load_configuration():\n # Load environment variables\n load_env_vars()\n # Validate required config\n validate_config()\n # Setup logging\n setup_logging()\n print('Configuration loaded successfully')\n\ndef validate_config():\n required_vars = ['DATABASE_URL', 'SECRET_KEY']\n for var in required_vars:\n if not os.getenv(var):\n raise ValueError(f'Missing required environment variable: {var}')\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What is the best practice for using startup/shutdown events in Panther?"}, {"role": "assistant", "content": "Keep startup/shutdown functions focused and lightweight. Handle errors gracefully. Use startup for initialization and shutdown for cleanup. Don't perform heavy operations that could slow down startup. Log important events. Ensure shutdown events complete quickly to allow graceful application termination."}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I perform CRUD operations in Panther?"}, {"role": "assistant", "content": "Panther provides async CRUD methods for database operations. Example:\n```python\nfrom app.models import User\n\n# Create\nuser = await User.insert_one(username='john', email='john@example.com')\n\n# Read\nusers = await User.find() # Get all users\nuser = await User.find_one(username='john') # Get single user\n\n# Update\nawait User.update_one({'username': 'john'}, {'email': 'new@example.com'})\n\n# Delete\nawait User.delete_one({'username': 'john'})\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use query filters in Panther?"}, {"role": "assistant", "content": "Use dictionaries to specify query filters. Example:\n```python\nfrom app.models import User\n\n# Simple filters\nusers = await User.find({'age': 25})\nusers = await User.find({'is_active': True, 'city': 'New York'})\n\n# Complex filters with operators\nusers = await User.find({\n 'age': {'$gte': 18, '$lte': 65},\n 'email': {'$regex': '@gmail.com'},\n 'created_at': {'$gt': '2024-01-01'}\n})\n\n# OR conditions\nusers = await User.find({\n '$or': [\n {'age': {'$lt': 18}},\n {'age': {'$gt': 65}}\n ]\n})\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What query operators does Panther support?"}, {"role": "assistant", "content": "Panther supports MongoDB-style query operators:\n- Comparison: $eq, $ne, $gt, $gte, $lt, $lte\n- Logical: $and, $or, $not, $nor\n- Array: $in, $nin, $all, $elemMatch\n- String: $regex, $options\n- Existence: $exists\n- Type: $type\n\nExample:\n```python\nfrom app.models import Product\n\n# Comparison operators\nproducts = await Product.find({'price': {'$gte': 10, '$lte': 100}})\n\n# Array operators\nproducts = await Product.find({'tags': {'$in': ['electronics', 'gadgets']}})\n\n# String regex\nusers = await User.find({'email': {'$regex': '^admin@', '$options': 'i'}})\n\n# Logical operators\nproducts = await Product.find({\n '$and': [\n {'price': {'$gt': 50}},\n {'category': 'electronics'}\n ]\n})\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I use aggregation in Panther?"}, {"role": "assistant", "content": "Use the aggregate method for complex data processing. Example:\n```python\nfrom app.models import Order\n\n# Group by category and calculate totals\nresult = await Order.aggregate([\n {'$group': {\n '_id': '$category',\n 'total_sales': {'$sum': '$amount'},\n 'count': {'$sum': 1}\n }},\n {'$sort': {'total_sales': -1}}\n])\n\n# Match and group\nresult = await Order.aggregate([\n {'$match': {'status': 'completed'}},\n {'$group': {\n '_id': '$user_id',\n 'total_orders': {'$sum': 1},\n 'total_amount': {'$sum': '$amount'}\n }},\n {'$sort': {'total_amount': -1}},\n {'$limit': 10}\n])\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I handle database transactions in Panther?"}, {"role": "assistant", "content": "Use the transaction context manager for atomic operations. Example:\n```python\nfrom panther.db import transaction\nfrom app.models import User, Account\n\nasync def create_user_with_account(user_data, account_data):\n async with transaction() as txn:\n # Create user\n user = await User.insert_one(**user_data, _transaction=txn)\n \n # Create account with user reference\n account = await Account.insert_one(\n user_id=user.id,\n **account_data,\n _transaction=txn\n )\n \n # If any operation fails, both are rolled back\n return user, account\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I manage database connections in Panther?"}, {"role": "assistant", "content": "Panther automatically manages database connections. Use connect() and close() for manual control. Example:\n```python\nfrom panther.db import connect, close\nfrom panther.events import startup, shutdown\n\n@startup\nasync def initialize_database():\n await connect()\n print('Database connected')\n\n@shutdown\nasync def cleanup_database():\n await close()\n print('Database disconnected')\n\n# Or manually in your code\nasync def custom_db_operation():\n await connect()\n try:\n # Your database operations\n pass\n finally:\n await close()\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create custom serializer fields in Panther?"}, {"role": "assistant", "content": "Create custom fields by inheriting from Field or using custom methods. Example:\n```python\nfrom panther.serializer import ModelSerializer, Field\nfrom datetime import datetime\n\nclass UserSerializer(ModelSerializer):\n class Config:\n model = User\n fields = ['id', 'username', 'email', 'age', 'created_at']\n \n # Custom field with computed value\n @Field\n def display_name(self, obj):\n return f'{obj.username} ({obj.email})'\n \n # Custom field with formatting\n @Field\n def formatted_date(self, obj):\n return obj.created_at.strftime('%Y-%m-%d %H:%M:%S')\n \n # Custom field with conditional logic\n @Field\n def status(self, obj):\n if obj.is_active:\n return 'active'\n return 'inactive'\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create nested serializers in Panther?"}, {"role": "assistant", "content": "Use nested serializers for related objects. Example:\n```python\nfrom panther.serializer import ModelSerializer\n\nclass AddressSerializer(ModelSerializer):\n class Config:\n model = Address\n fields = ['street', 'city', 'zipcode']\n\nclass PostSerializer(ModelSerializer):\n class Config:\n model = Post\n fields = ['id', 'title', 'content', 'created_at']\n\nclass UserSerializer(ModelSerializer):\n class Config:\n model = User\n fields = ['id', 'username', 'email', 'address', 'posts']\n \n # Nested serializer for address\n address = AddressSerializer()\n \n # Nested serializer for posts (many)\n posts = PostSerializer(many=True)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I add validation to serializers in Panther?"}, {"role": "assistant", "content": "Use Pydantic validators and custom validation methods. Example:\n```python\nfrom panther.serializer import ModelSerializer\nfrom pydantic import validator, field_validator\n\nclass UserSerializer(ModelSerializer):\n class Config:\n model = User\n fields = ['username', 'email', 'age', 'password']\n \n # Field validation\n @validator('username')\n def validate_username(cls, v):\n if len(v) < 3:\n raise ValueError('Username must be at least 3 characters')\n if not v.isalnum():\n raise ValueError('Username must be alphanumeric')\n return v\n \n @validator('email')\n def validate_email(cls, v):\n if '@' not in v:\n raise ValueError('Invalid email format')\n return v.lower()\n \n @validator('age')\n def validate_age(cls, v):\n if v < 0 or v > 150:\n raise ValueError('Age must be between 0 and 150')\n return v\n \n # Custom validation method\n def validate(self, data):\n if data.get('username') == data.get('password'):\n raise ValueError('Username and password cannot be the same')\n return data\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create read-only and write-only fields in Panther serializers?"}, {"role": "assistant", "content": "Use read_only and write_only parameters in Field decorators. Example:\n```python\nfrom panther.serializer import ModelSerializer, Field\n\nclass UserSerializer(ModelSerializer):\n class Config:\n model = User\n fields = ['id', 'username', 'email', 'password', 'created_at']\n \n # Read-only field (only in responses)\n @Field(read_only=True)\n def id(self, obj):\n return obj.id\n \n # Write-only field (only in requests)\n @Field(write_only=True)\n def password(self, obj):\n return obj.password if hasattr(obj, 'password') else None\n \n # Read-only computed field\n @Field(read_only=True)\n def created_at(self, obj):\n return obj.created_at.strftime('%Y-%m-%d %H:%M:%S')\n \n # Conditional field\n @Field(read_only=True)\n def is_admin(self, obj):\n return getattr(obj, 'is_admin', False)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I test Panther APIs?"}, {"role": "assistant", "content": "Use pytest with async support for testing Panther APIs. Example:\n```python\nimport pytest\nfrom panther.test import TestClient\nfrom main import app\nfrom app.models import User\n\n@pytest.fixture\nasync def client():\n async with TestClient(app) as client:\n yield client\n\n@pytest.fixture\nasync def test_user():\n user = await User.insert_one(\n username='testuser',\n email='test@example.com'\n )\n yield user\n await User.delete_one({'id': user.id})\n\nasync def test_create_user(client):\n response = await client.post('/user/', json={\n 'username': 'newuser',\n 'email': 'new@example.com'\n })\n assert response.status_code == 201\n data = response.json()\n assert data['username'] == 'newuser'\n\nasync def test_get_user(client, test_user):\n response = await client.get(f'/user/{test_user.id}/')\n assert response.status_code == 200\n data = response.json()\n assert data['username'] == 'testuser'\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I test WebSockets in Panther?"}, {"role": "assistant", "content": "Use TestClient for WebSocket testing. Example:\n```python\nimport pytest\nfrom panther.test import TestClient\nfrom main import app\n\n@pytest.fixture\nasync def client():\n async with TestClient(app) as client:\n yield client\n\nasync def test_websocket_connection(client):\n with client.websocket_connect('/ws/echo/') as websocket:\n # Send message\n websocket.send_text('Hello')\n \n # Receive response\n response = websocket.receive_text()\n assert response == 'Hello'\n\nasync def test_websocket_with_auth(client):\n # First authenticate\n auth_response = await client.post('/auth/login/', json={\n 'username': 'testuser',\n 'password': 'password'\n })\n token = auth_response.json()['access_token']\n \n # Connect with authentication\n with client.websocket_connect(\n f'/ws/chat/?authorization={token}'\n ) as websocket:\n websocket.send_json({'type': 'message', 'text': 'Hello'})\n response = websocket.receive_json()\n assert response['type'] == 'message'\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create test fixtures in Panther?"}, {"role": "assistant", "content": "Use pytest fixtures for test data setup and cleanup. Example:\n```python\nimport pytest\nfrom app.models import User, Post, Category\n\n@pytest.fixture\nasync def test_user():\n user = await User.insert_one(\n username='testuser',\n email='test@example.com',\n is_active=True\n )\n yield user\n await User.delete_one({'id': user.id})\n\n@pytest.fixture\nasync def test_category():\n category = await Category.insert_one(\n name='Technology',\n slug='technology'\n )\n yield category\n await Category.delete_one({'id': category.id})\n\n@pytest.fixture\nasync def test_post(test_user, test_category):\n post = await Post.insert_one(\n title='Test Post',\n content='Test content',\n author_id=test_user.id,\n category_id=test_category.id\n )\n yield post\n await Post.delete_one({'id': post.id})\n\n@pytest.fixture\nasync def auth_headers(test_user):\n # Create authentication token\n token = create_test_token(test_user)\n return {'Authorization': f'Bearer {token}'}\n\nasync def test_post_creation(client, test_user, test_category, auth_headers):\n response = await client.post('/post/', \n json={\n 'title': 'New Post',\n 'content': 'New content',\n 'category_id': test_category.id\n },\n headers=auth_headers\n )\n assert response.status_code == 201\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I set up environment-specific configurations in Panther?"}, {"role": "assistant", "content": "Create configuration variables as normal variables in your configs file. Example:\n```python\n# core/configs.py\nimport os\nfrom datetime import timedelta\n\n# Development settings\nDEBUG = True\nLOG_QUERIES = True\nALLOW_ORIGINS = ['*']\nCACHE = None # Disable caching in development\n\n# Production settings (override based on environment)\nif os.getenv('ENVIRONMENT') == 'production':\n DEBUG = False\n LOG_QUERIES = False\n ALLOW_ORIGINS = [\n 'https://yourdomain.com',\n 'https://app.yourdomain.com'\n ]\n CACHE = timedelta(minutes=30)\n THROTTLING = Throttle(rate=100, duration=timedelta(minutes=1))\n\n# Database settings\nDATABASE_URL = os.getenv('DATABASE_URL', 'mongodb://localhost:27017')\nDATABASE_NAME = os.getenv('DATABASE_NAME', 'panther_db')\n\n# Security settings\nSECRET_KEY = os.getenv('SECRET_KEY', 'your-secret-key')\nJWT_ALGORITHM = 'HS256'\nJWT_LIFE_TIME = timedelta(days=1)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I access configuration variables in Panther?"}, {"role": "assistant", "content": "Access configuration variables using panther.configs.config. Example:\n```python\nfrom panther.configs import config\n\n# Access configuration variables\ndebug_mode = config.DEBUG\nsecret_key = config.SECRET_KEY\ndatabase_url = config.DATABASE_URL\n\n# Use in your APIs\n@API()\nasync def debug_info():\n if config.DEBUG:\n return {'debug': True, 'environment': 'development'}\n return {'debug': False}\n\n# Use in middleware\nclass ConfigMiddleware(HTTPMiddleware):\n async def __call__(self, request):\n if config.LOG_QUERIES:\n # Log database queries\n pass\n \n response = await self.dispatch(request=request)\n return response\n\n# Use in models\nclass User(Model):\n username: str = Field(index=True)\n \n async def save(self):\n if config.DEBUG:\n print(f'Saving user: {self.username}')\n return await super().save()\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I validate configuration in Panther?"}, {"role": "assistant", "content": "Add validation logic directly in your configs.py file. Example:\n```python\n# core/configs.py\nimport os\nfrom datetime import timedelta\n\n# Configuration variables\nSECRET_KEY = os.getenv('SECRET_KEY')\nDATABASE_URL = os.getenv('DATABASE_URL')\nALLOW_ORIGINS = os.getenv('ALLOW_ORIGINS', '*').split(',')\nDEBUG = os.getenv('DEBUG', 'False').lower() == 'true'\n\n# Validation functions\ndef validate_config():\n errors = []\n \n # Validate SECRET_KEY\n if not SECRET_KEY or len(SECRET_KEY) < 32:\n errors.append('SECRET_KEY must be at least 32 characters')\n \n # Validate DATABASE_URL\n if not DATABASE_URL:\n errors.append('DATABASE_URL is required')\n elif not DATABASE_URL.startswith(('mongodb://', 'mongodb+srv://')):\n errors.append('Invalid DATABASE_URL format')\n \n # Validate ALLOW_ORIGINS\n if '*' in ALLOW_ORIGINS and len(ALLOW_ORIGINS) > 1:\n errors.append('Cannot use * with specific origins')\n \n if errors:\n raise ValueError(f'Configuration errors: {\\n'.join(errors)}')\n\n# Run validation on import\nvalidate_config()\n\n# Additional computed values\nCACHE_DURATION = timedelta(minutes=30) if not DEBUG else None\nTHROTTLING_RATE = 100 if not DEBUG else 1000\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I manage secrets in Panther?"}, {"role": "assistant", "content": "Store secrets as environment variables and access them through configs. Example:\n```python\n# core/configs.py\nimport os\nfrom cryptography.fernet import Fernet\n\n# Basic secret management\nSECRET_KEY = os.getenv('SECRET_KEY')\nDATABASE_URL = os.getenv('DATABASE_URL')\n\n# API keys\nSTRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY')\nSENDGRID_API_KEY = os.getenv('SENDGRID_API_KEY')\nAWS_SECRET_KEY = os.getenv('AWS_SECRET_KEY')\n\n# Encrypted secrets (for production)\nENCRYPTION_KEY = os.getenv('ENCRYPTION_KEY')\n\n# Helper functions for encrypted secrets\ndef decrypt_secret(encrypted_secret: str) -> str:\n if not ENCRYPTION_KEY:\n return encrypted_secret\n \n f = Fernet(ENCRYPTION_KEY.encode())\n return f.decrypt(encrypted_secret.encode()).decode()\n\n# Use encrypted secrets in production\nif os.getenv('ENVIRONMENT') == 'production':\n ENCRYPTED_DB_PASSWORD = os.getenv('ENCRYPTED_DB_PASSWORD')\n if ENCRYPTED_DB_PASSWORD:\n DATABASE_PASSWORD = decrypt_secret(ENCRYPTED_DB_PASSWORD)\n # Update DATABASE_URL with decrypted password\n DATABASE_URL = DATABASE_URL.replace('PASSWORD_PLACEHOLDER', DATABASE_PASSWORD)\n\n# Access in your code\nfrom panther.configs import config\n\n@API()\nasync def payment_api():\n stripe_key = config.STRIPE_SECRET_KEY\n # Use stripe_key for payment processing\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I optimize performance in Panther?"}, {"role": "assistant", "content": "Use caching, database optimization, and async patterns. Example:\n```python\n# 1. Enable caching for frequently accessed data\n@API(cache=timedelta(minutes=10))\nasync def get_popular_posts():\n return await Post.find().sort('views', -1).limit(10)\n\n# 2. Use database indexes\nclass Post(Model):\n title: str = Field(index=True)\n author_id: str = Field(index=True)\n created_at: datetime = Field(index=True)\n\n# 3. Optimize queries with projection\nasync def get_user_list():\n # Only fetch needed fields\n users = await User.find(\n {'is_active': True},\n projection={'username': 1, 'email': 1, '_id': 0}\n )\n return users\n\n# 4. Use background tasks for heavy operations\nfrom panther.background_tasks import BackgroundTask\n\n@API()\nasync def create_report():\n # Start background task\n BackgroundTask(generate_report).submit()\n return {'message': 'Report generation started'}\n\n# 5. Implement connection pooling\nDATABASE_CONFIG = {\n 'max_pool_size': 10,\n 'min_pool_size': 2,\n 'max_idle_time': 30000\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I implement monitoring and logging in Panther?"}, {"role": "assistant", "content": "Use built-in logging and custom monitoring. Example:\n```python\nimport logging\nfrom panther.middlewares import HTTPMiddleware\nfrom datetime import datetime\nimport time\n\n# Configure logging\nlogging.basicConfig(\n level=logging.INFO,\n format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',\n handlers=[\n logging.FileHandler('app.log'),\n logging.StreamHandler()\n ]\n)\n\nlogger = logging.getLogger(__name__)\n\n# Custom middleware for monitoring\nclass MonitoringMiddleware(HTTPMiddleware):\n async def __call__(self, request):\n start_time = time.time()\n \n # Log request\n logger.info(f'Request: {request.method} {request.path}')\n \n try:\n response = await self.dispatch(request=request)\n \n # Log response\n duration = time.time() - start_time\n logger.info(f'Response: {response.status_code} - {duration:.3f}s')\n \n return response\n except Exception as e:\n # Log errors\n logger.error(f'Error: {str(e)}')\n raise\n\n# Custom monitoring in APIs\n@API()\nasync def monitored_api(request: Request):\n logger.info(f'API called by user: {request.user.username if request.user else \"anonymous\"}')\n \n # Track performance\n start_time = time.time()\n result = await heavy_operation()\n duration = time.time() - start_time\n \n logger.info(f'Heavy operation completed in {duration:.3f}s')\n return result\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I implement security best practices in Panther?"}, {"role": "assistant", "content": "Use input validation, authentication, and security headers. Example:\n```python\nfrom panther.app import API\nfrom panther.request import Request\nfrom panther.response import Response\nimport re\n\n# 1. Input sanitization\n@API()\nasync def secure_api(request: Request):\n user_input = request.data.get('comment', '')\n \n # Sanitize HTML\n sanitized = re.sub(r'<[^>]*>', '', user_input)\n \n # Validate length\n if len(sanitized) > 1000:\n raise ValueError('Comment too long')\n \n return {'comment': sanitized}\n\n# 2. Rate limiting for sensitive endpoints\n@API(throttling=Throttle(rate=5, duration=timedelta(minutes=1)))\nasync def login_api(request: Request):\n # Login logic\n pass\n\n# 3. Security headers middleware\nclass SecurityHeadersMiddleware(HTTPMiddleware):\n async def __call__(self, request):\n response = await self.dispatch(request=request)\n \n # Add security headers\n response.headers.update({\n 'X-Content-Type-Options': 'nosniff',\n 'X-Frame-Options': 'DENY',\n 'X-XSS-Protection': '1; mode=block',\n 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',\n 'Content-Security-Policy': \"default-src 'self'\"\n })\n \n return response\n\n# 4. SQL injection prevention (automatic with Panther)\n@API()\nasync def safe_query(request: Request):\n # Panther automatically prevents SQL injection\n user_id = request.query_params.get('user_id')\n user = await User.find_one({'id': user_id})\n return user\n\n# 5. XSS protection\n@API()\nasync def safe_output(request: Request):\n user_data = request.data\n \n # Escape output\n safe_data = {\n 'username': html.escape(user_data.get('username', '')),\n 'email': user_data.get('email', '')\n }\n \n return safe_data\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create custom database backends in Panther?"}, {"role": "assistant", "content": "Implement custom database backends by extending Panther's database interface. Example:\n```python\nfrom panther.db import BaseDatabase\nfrom typing import Dict, List, Any\n\nclass CustomDatabase(BaseDatabase):\n def __init__(self, connection_string: str):\n self.connection_string = connection_string\n self.connection = None\n \n async def connect(self):\n # Initialize your custom database connection\n self.connection = await create_connection(self.connection_string)\n \n async def disconnect(self):\n if self.connection:\n await self.connection.close()\n \n async def insert_one(self, collection: str, data: Dict) -> Dict:\n # Custom insert implementation\n result = await self.connection.execute(\n f'INSERT INTO {collection} VALUES (:data)',\n {'data': json.dumps(data)}\n )\n return {'id': result.lastrowid, **data}\n \n async def find(self, collection: str, filter_dict: Dict = None) -> List[Dict]:\n # Custom find implementation\n query = f'SELECT * FROM {collection}'\n if filter_dict:\n where_clause = self._build_where_clause(filter_dict)\n query += f' WHERE {where_clause}'\n \n results = await self.connection.fetch(query)\n return [json.loads(row['data']) for row in results]\n \n def _build_where_clause(self, filter_dict: Dict) -> str:\n # Convert Panther filters to SQL\n conditions = []\n for key, value in filter_dict.items():\n if isinstance(value, dict):\n # Handle operators like $gt, $lt\n for op, val in value.items():\n if op == '$gt':\n conditions.append(f\"JSON_EXTRACT(data, '$.{key}') > {val}\")\n elif op == '$lt':\n conditions.append(f\"JSON_EXTRACT(data, '$.{key}') < {val}\")\n else:\n conditions.append(f\"JSON_EXTRACT(data, '$.{key}') = '{value}'\")\n \n return ' AND '.join(conditions)\n\n# Use custom database\nDATABASE = CustomDatabase('sqlite:///custom.db')\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I create custom validators in Panther?"}, {"role": "assistant", "content": "Create custom validators for complex validation logic. Example:\n```python\nfrom pydantic import validator, ValidationError\nimport re\n\n# Custom validators\nclass CustomValidators:\n @staticmethod\n def validate_phone_number(phone: str) -> str:\n # Remove all non-digit characters\n digits = re.sub(r'\\D', '', phone)\n \n if len(digits) < 10 or len(digits) > 15:\n raise ValueError('Invalid phone number length')\n \n return digits\n \n @staticmethod\n def validate_password_strength(password: str) -> str:\n if len(password) < 8:\n raise ValueError('Password must be at least 8 characters')\n \n if not re.search(r'[A-Z]', password):\n raise ValueError('Password must contain uppercase letter')\n \n if not re.search(r'[a-z]', password):\n raise ValueError('Password must contain lowercase letter')\n \n if not re.search(r'\\d', password):\n raise ValueError('Password must contain number')\n \n if not re.search(r'[!@#$%^&*]', password):\n raise ValueError('Password must contain special character')\n \n return password\n \n @staticmethod\n def validate_username_availability(username: str, existing_users: list) -> str:\n if username in existing_users:\n raise ValueError('Username already taken')\n return username\n\n# Use in serializers\nclass UserSerializer(ModelSerializer):\n class Config:\n model = User\n fields = ['username', 'email', 'phone', 'password']\n \n @validator('phone')\n def validate_phone(cls, v):\n return CustomValidators.validate_phone_number(v)\n \n @validator('password')\n def validate_password(cls, v):\n return CustomValidators.validate_password_strength(v)\n \n @validator('username')\n def validate_username(cls, v):\n # Get existing usernames\n existing_users = [user.username for user in User.find()]\n return CustomValidators.validate_username_availability(v, existing_users)\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "How do I implement advanced routing patterns in Panther?"}, {"role": "assistant", "content": "Use dynamic routing and custom route handlers. Example:\n```python\n# 1. Dynamic route generation\nfrom panther.app import API\nfrom panther.request import Request\n\n# Generate routes dynamically\ndef create_dynamic_routes():\n routes = {}\n \n # Create CRUD routes for each model\n models = [User, Post, Category]\n \n for model in models:\n model_name = model.__name__.lower()\n \n # List route\n routes[f'{model_name}/'] = create_list_api(model)\n \n # Detail route\n routes[f'{model_name}//'] = create_detail_api(model)\n \n # Custom routes\n if hasattr(model, 'custom_routes'):\n for route_path, handler in model.custom_routes.items():\n routes[f'{model_name}/{route_path}'] = handler\n \n return routes\n\n# 2. Custom route handlers\n@API()\nasync def create_list_api(model_class):\n async def list_handler(request: Request):\n items = await model_class.find()\n return items\n return list_handler\n\n@API()\nasync def create_detail_api(model_class):\n async def detail_handler(request: Request, id: str):\n item = await model_class.find_one({'id': id})\n if not item:\n raise NotFoundAPIError(f'{model_class.__name__} not found')\n return item\n return detail_handler\n\n# 3. Route middleware\nclass RouteMiddleware(HTTPMiddleware):\n async def __call__(self, request):\n # Add route information to request\n request.route_info = {\n 'path': request.path,\n 'method': request.method,\n 'timestamp': datetime.now()\n }\n \n response = await self.dispatch(request=request)\n return response\n\n# 4. Conditional routing\nurls = {\n 'api/v1/': v1_urls if settings.API_VERSION == 'v1' else v2_urls,\n 'api/v2/': v2_urls,\n 'admin/': admin_urls if settings.ENABLE_ADMIN else None,\n}\n\n# 5. Route aliases with different handlers\nurls = {\n 'user/': user_api,\n 'users/': user_api, # Alias\n 'u/': user_api, # Short alias\n 'profile/': user_profile_api, # Different handler for same data\n}\n```"}]} {"messages": [{"role": "system", "content": "You are learning a new Python backend framework called Panther. I will teach you each component step by step."}, {"role": "user", "content": "What are the best practices for Panther development?"}, {"role": "assistant", "content": "Follow these best practices for Panther development:\n\n1. **Project Structure**:\n - Organize by feature, not by type\n - Use consistent naming conventions\n - Separate concerns (models, APIs, serializers)\n\n2. **Database Design**:\n - Use proper indexes for frequently queried fields\n - Implement soft deletes for important data\n - Use transactions for related operations\n - Optimize queries with projections\n\n3. **API Design**:\n - Use RESTful conventions\n - Implement proper error handling\n - Use appropriate HTTP status codes\n - Add comprehensive validation\n - Document with OpenAPI\n\n4. **Security**:\n - Always validate and sanitize input\n - Use authentication and authorization\n - Implement rate limiting\n - Add security headers\n - Keep dependencies updated\n\n5. **Performance**:\n - Use caching for expensive operations\n - Implement pagination for large datasets\n - Use background tasks for heavy operations\n - Monitor and log performance metrics\n\n6. **Testing**:\n - Write unit tests for all APIs\n - Use fixtures for test data\n - Test error scenarios\n - Implement integration tests\n\n7. **Configuration**:\n - Use environment-specific configs\n - Validate configuration on startup\n - Secure sensitive data\n - Use feature flags for gradual rollouts\n\n8. **Code Quality**:\n - Use type hints consistently\n - Follow PEP 8 style guidelines\n - Add docstrings to complex functions\n - Use meaningful variable names\n - Keep functions focused and small\n\n9. **Monitoring**:\n - Implement comprehensive logging\n - Monitor application health\n - Track performance metrics\n - Set up error alerting\n\n10. **Deployment**:\n - Use containerization (Docker)\n - Implement CI/CD pipelines\n - Use environment variables for config\n - Set up proper backup strategies\n - Monitor production performance"}]} ================================================ FILE: examples/blog/Dockerfile ================================================ # --- Builder --- FROM python:3.12 AS builder WORKDIR /app # Create virtual environment RUN python -m venv /opt/venv # Install uv into system (can be reused) RUN pip install --no-cache-dir uv # Copy requirements and install into the venv COPY requirements.txt . RUN /usr/local/bin/uv pip install -r requirements.txt --python /opt/venv/bin/python # --- Final image --- FROM python:3.12-slim AS production ENV PYTHONUNBUFFERED=1 ENV PATH="/opt/venv/bin:$PATH" # Copy venv from builder COPY --from=builder /opt/venv /opt/venv # Copy app source WORKDIR /app COPY . /app ================================================ FILE: examples/blog/Readme.md ================================================ A blog with Post and Comment. Post has title and content. Comment belongs to a Post. Anonymous users can view posts. Only authenticated users can create comments. ================================================ FILE: examples/blog/app/apis.py ================================================ from datetime import timedelta from panther import status from panther.app import GenericAPI from panther.db import Model from panther.exceptions import BadRequestAPIError from panther.generics import CreateAPI, DeleteAPI, ListAPI, RetrieveAPI, UpdateAPI from panther.pagination import Pagination from panther.permissions import IsAuthenticated, IsAuthenticatedOrReadonly from panther.request import Request from panther.response import Response from panther.throttling import Throttle from app.models import Post from app.serializers import CommentSerializer, PostDetailOutputSerializer, PostOutputSerializer, PostSerializer # PostAPI - For Listing Posts and Creating a Post class PostAPI(ListAPI, CreateAPI): permissions = [IsAuthenticatedOrReadonly] input_model = PostSerializer throttling = Throttle(rate=5, duration=timedelta(minutes=1)) pagination = Pagination output_model = PostOutputSerializer cache = timedelta(minutes=1) async def get_query(self, request: Request, **kwargs): return await Post.find() # PostDetailAPI - For Retrieving, Updating, and Deleting a Post class PostDetailAPI(UpdateAPI, RetrieveAPI, DeleteAPI): input_model = PostSerializer output_model = PostDetailOutputSerializer async def get_instance(self, request: Request, **kwargs) -> Model: return await Post.find_one_or_raise(id=kwargs['post_id']) class CommentAPI(GenericAPI): input_model = CommentSerializer permissions = IsAuthenticated async def post(self, request: Request, post_id: str): if not await Post.exists(id=post_id): raise BadRequestAPIError('Post with this ID does not exists.') instance = await request.validated_data.model.insert_one(request.validated_data, post_id=post_id) return Response(data=instance, status_code=status.HTTP_201_CREATED) ================================================ FILE: examples/blog/app/models.py ================================================ from panther.db import Model class Post(Model): title: str content: str class Comment(Model): content: str post_id: Post # Foreign Key (relating to Post) ================================================ FILE: examples/blog/app/serializers.py ================================================ from typing import Any from app.models import Comment, Post from panther.serializer import ModelSerializer class PostSerializer(ModelSerializer): class Config: model = Post fields = ['title', 'content'] class PostOutputSerializer(ModelSerializer): class Config: model = Post fields = ['id', 'title', 'content'] class PostDetailOutputSerializer(ModelSerializer): class Config: model = Post fields = ['id', 'title', 'content'] async def to_response(self, instance: Any, data: dict) -> dict: data['comments'] = await Comment.find(post_id=instance.id) return data class CommentSerializer(ModelSerializer): class Config: model = Comment fields = ['content'] ================================================ FILE: examples/blog/app/urls.py ================================================ from app.apis import CommentAPI, PostAPI, PostDetailAPI url_routing = { 'posts': PostAPI, 'comments/': CommentAPI, 'posts/': PostDetailAPI, } ================================================ FILE: examples/blog/core/configs.py ================================================ import os from datetime import timedelta from panther.throttling import Throttle # Generate Secret Key with `panther.utils.generate_secret_key` SECRET_KEY = os.environ.get('SECRET_KEY', 'nggtD7uISmU3t61KUpOR642L2MFLUxxY3uoHGfMBH6E=') # Database Configuration DATABASE = { 'engine': { 'class': 'panther.db.connections.PantherDBConnection', 'path': 'database.pdb', } } # Enable Redis for caching and throttling REDIS = { 'class': 'panther.db.connections.RedisConnection', 'host': os.environ.get('REDIS_HOST', '127.0.0.1'), 'port': 6379, 'db': 0, } # Enable JWT authentication AUTHENTICATION = 'panther.authentications.JWTAuthentication' JWT_CONFIG = { 'algorithm': 'HS256', 'life_time': timedelta(days=1), 'refresh_life_time': timedelta(days=7), } # Middleware Configuration (CORS) ALLOW_ORIGINS = ['*'] ALLOW_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] ALLOW_HEADERS = ['*'] ALLOW_CREDENTIALS = True CORS_MAX_AGE = 3600 MIDDLEWARES = [ 'panther.middlewares.cors.CORSMiddleware', 'panther.middlewares.monitoring.MonitoringMiddleware', ] # Throttling Configuration THROTTLING = Throttle(rate=5, duration=timedelta(minutes=1)) URLs = 'core.urls.url_routing' ================================================ FILE: examples/blog/core/urls.py ================================================ from app.urls import url_routing as app_url_routing from user.urls import url_routing as user_url_routing from panther.openapi.views import ScalarOpenAPI from panther.panel.urls import url_routing as panel_url_routing url_routing = { 'admin/': panel_url_routing, 'docs/': ScalarOpenAPI, 'user/': user_url_routing, '/': app_url_routing, } ================================================ FILE: examples/blog/docker-compose.yaml ================================================ volumes: redis_volume: services: application: build: . restart: always volumes: - ./:/app ports: - "8000:8000" environment: REDIS_HOST: redis depends_on: redis: condition: service_healthy command: [ "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000" ] redis: image: redis:7.4-bookworm restart: always volumes: - redis_volume:/data healthcheck: test: [ "CMD", "redis-cli", "ping" ] interval: 10s timeout: 3s retries: 3 start_period: 5s ================================================ FILE: examples/blog/main.py ================================================ from panther import Panther app = Panther(__name__) ================================================ FILE: examples/blog/requirements.txt ================================================ panther==5.0.0b8 redis==6.2.0 python-jose==3.5.0 uvloop==0.21.0 ================================================ FILE: examples/blog/user/apis.py ================================================ from user.serializers import UserOutputSerializer, UserSerializer from panther import status from panther.app import GenericAPI from panther.db.models import BaseUser from panther.exceptions import BadRequestAPIError from panther.request import Request from panther.response import Response from panther.utils import timezone_now class RegisterAPI(GenericAPI): input_model = UserSerializer output_model = UserOutputSerializer async def post(self, request: Request, **kwargs): if await BaseUser.exists(username=request.validated_data.username): raise BadRequestAPIError(detail='User with this username already exists.') instance = await BaseUser.insert_one( username=request.validated_data.username, date_created=timezone_now(), ) await instance.set_password(request.validated_data.password) return Response(data=instance, status_code=status.HTTP_201_CREATED) class LoginAPI(GenericAPI): input_model = UserSerializer async def post(self, request: Request): user = await BaseUser.find_one_or_raise(username=request.validated_data.username) if not user.check_password(request.validated_data.password): raise BadRequestAPIError(detail={'detail': 'username or password was wrong.'}) tokens = await user.login() return Response(data=tokens, status_code=status.HTTP_200_OK) ================================================ FILE: examples/blog/user/serializers.py ================================================ from panther.db.models import BaseUser from panther.serializer import ModelSerializer class UserSerializer(ModelSerializer): class Config: model = BaseUser fields = ['username', 'password'] class UserOutputSerializer(ModelSerializer): class Config: model = BaseUser fields = '*' exclude = ['password'] ================================================ FILE: examples/blog/user/urls.py ================================================ from user.apis import LoginAPI, RegisterAPI url_routing = {'register': RegisterAPI, 'login': LoginAPI} ================================================ FILE: examples/broadcast_websocket.py ================================================ from panther import Panther from panther.app import GenericAPI from panther.db import Model from panther.response import HTMLResponse from panther.websocket import GenericWebsocket, send_message_to_websocket class User(Model): connection_id: str DATABASE = { 'engine': { 'class': 'panther.db.connections.PantherDBConnection', 'path': 'database.pdb', } } class BroadcastWebsocket(GenericWebsocket): async def connect(self, **kwargs): await self.accept() await User.insert_one(connection_id=self.connection_id) async def receive(self, data: str | bytes): users = await User.find() for user in users: await send_message_to_websocket(user.connection_id, data) class MainPage(GenericAPI): def get(self): template = """
      """ return HTMLResponse(template) url_routing = { '': MainPage, 'ws': BroadcastWebsocket, } app = Panther(__name__, configs=__name__, urls=url_routing) ================================================ FILE: examples/file_upload_example.py ================================================ """ File Upload Example for Panther This example demonstrates how to handle file uploads in Panther using the File and Image classes with proper validation and error handling. """ from datetime import datetime from panther import Panther, status from panther.app import API from panther.db import Model from panther.exceptions import APIError from panther.file_handler import File, Image from panther.request import Request from panther.response import Response from panther.serializer import ModelSerializer # Database configuration DATABASE = { 'engine': { 'class': 'panther.db.connections.PantherDBConnection', 'path': 'file_upload_example.pdb', } } # Models class Document(Model): title: str file: File uploaded_at: datetime description: str | None = None class Profile(Model): name: str avatar: Image bio: str | None = None # Serializers class DocumentUploadSerializer(ModelSerializer): class Config: model = Document fields = ['title', 'file', 'description'] required_fields = ['title', 'file'] class ProfileUploadSerializer(ModelSerializer): class Config: model = Profile fields = ['name', 'avatar', 'bio'] required_fields = ['name', 'avatar'] # APIs @API(input_model=DocumentUploadSerializer) async def upload_document(request: Request): """Upload a document with validation""" try: file_data = request.validated_data file = file_data.file # Additional validation if file.size > 10 * 1024 * 1024: # 10MB limit raise APIError( detail='File too large. Maximum size is 10MB.', status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE ) # Check file type allowed_types = ['application/pdf', 'text/plain', 'application/msword'] if file.content_type not in allowed_types: raise APIError( detail='File type not allowed. Only PDF, text, and Word documents are accepted.', status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, ) # Save file to disk saved_path = file.save('uploads/documents/') # Store in database document = await Document.insert_one( { 'title': file_data.title, 'file': file, 'uploaded_at': datetime.now(), 'description': file_data.description, } ) return Response( data={ 'message': 'Document uploaded successfully', 'document_id': document.id, 'file_name': file.file_name, 'file_size': file.size, 'saved_path': saved_path, }, status_code=status.HTTP_201_CREATED, ) except Exception as e: raise APIError(detail=f'File upload failed: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) @API(input_model=ProfileUploadSerializer) async def upload_profile_image(request: Request): """Upload a profile image with automatic image validation""" try: profile_data = request.validated_data image = profile_data.avatar # Image-specific validation if image.size > 5 * 1024 * 1024: # 5MB limit for images raise APIError( detail='Image too large. Maximum size is 5MB.', status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE ) # Save image saved_path = image.save('uploads/images/') # Store in database profile = await Profile.insert_one({'name': profile_data.name, 'avatar': image, 'bio': profile_data.bio}) return Response( data={ 'message': 'Profile image uploaded successfully', 'profile_id': profile.id, 'image_name': image.file_name, 'image_size': image.size, 'saved_path': saved_path, }, status_code=status.HTTP_201_CREATED, ) except Exception as e: raise APIError(detail=f'Image upload failed: {str(e)}', status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) @API() async def list_documents(request: Request): """List all uploaded documents""" documents = await Document.find() # Convert to response format doc_list = [] for doc in documents: doc_list.append( { 'id': doc.id, 'title': doc.title, 'file_name': doc.file.file_name, 'file_size': doc.file.size, 'uploaded_at': doc.uploaded_at.isoformat(), 'description': doc.description, } ) return Response(data=doc_list) @API() async def get_document(document_id: str): """Get a specific document by ID""" document = await Document.find_one_or_raise(document_id) return Response( data={ 'id': document.id, 'title': document.title, 'file_name': document.file.file_name, 'file_size': document.file.size, 'content_type': document.file.content_type, 'uploaded_at': document.uploaded_at.isoformat(), 'description': document.description, } ) # URL routing urls = { 'upload/document/': upload_document, 'upload/profile/': upload_profile_image, 'documents/': list_documents, 'documents//': get_document, } # Create Panther app app = Panther(__name__, configs=__name__, urls=urls) ================================================ FILE: examples/streaming_response.py ================================================ import json from datetime import datetime from time import sleep from panther import Panther from panther.app import GenericAPI from panther.response import StreamingResponse class MainPage(GenericAPI): def generator(self): """Simple number generator with delays""" for i in range(10): sleep(1) yield f'data: {i}\n\n' def get(self): return StreamingResponse(data=self.generator()) class ProgressStream(GenericAPI): def generator(self): """Simulate a long-running task with progress updates""" total_steps = 20 for i in range(total_steps + 1): progress = (i / total_steps) * 100 data = { 'step': i, 'total_steps': total_steps, 'progress': round(progress, 1), 'status': 'processing' if i < total_steps else 'completed', 'timestamp': datetime.now().isoformat(), } yield f'data: {json.dumps(data)}\n\n' sleep(0.5) def get(self): return StreamingResponse(data=self.generator()) class LogStream(GenericAPI): def generator(self): """Simulate real-time log streaming""" log_levels = ['INFO', 'WARNING', 'ERROR', 'DEBUG'] messages = [ 'Server started successfully', 'Processing request from client', 'Database connection established', 'Cache miss, fetching from database', 'User authentication successful', 'File upload completed', 'API rate limit approaching', 'Memory usage: 45%', 'Backup job started', 'Task completed successfully', ] for i, message in enumerate(messages): level = log_levels[i % len(log_levels)] log_entry = {'timestamp': datetime.now().isoformat(), 'level': level, 'message': message, 'id': i + 1} yield f'data: {json.dumps(log_entry)}\n\n' sleep(0.8) def get(self): return StreamingResponse(data=self.generator()) class ChatStream(GenericAPI): def generator(self): """Simulate a chat bot response""" response_parts = [ "Hello! I'm a streaming chatbot.", 'I can help you with various tasks.', 'For example, I can answer questions,', 'provide information, or just chat!', 'What would you like to know today?', ] for i, part in enumerate(response_parts): data = { 'type': 'message', 'content': part, 'part': i + 1, 'total_parts': len(response_parts), 'timestamp': datetime.now().isoformat(), } yield f'data: {json.dumps(data)}\n\n' sleep(0.6) def get(self): return StreamingResponse(data=self.generator()) class ErrorHandlingStream(GenericAPI): def generator(self): """Demonstrate error handling in streaming""" try: for i in range(5): if i == 3: # Simulate an error raise Exception('Simulated error during streaming') data = {'step': i, 'status': 'success', 'message': f'Processed step {i}'} yield f'data: {json.dumps(data)}\n\n' sleep(1) except Exception as e: error_data = {'error': True, 'message': str(e), 'timestamp': datetime.now().isoformat()} yield f'data: {json.dumps(error_data)}\n\n' def get(self): return StreamingResponse(data=self.generator()) url_routing = { '': MainPage, 'progress': ProgressStream, 'logs': LogStream, 'chat': ChatStream, 'error-demo': ErrorHandlingStream, } app = Panther(__name__, configs=__name__, urls=url_routing) ================================================ FILE: panther/__init__.py ================================================ from panther.main import Panther # noqa: F401 __version__ = '5.2.3' def version(): return __version__ ================================================ FILE: panther/_load_configs.py ================================================ import logging import sys import types from importlib import import_module from multiprocessing import Manager import jinja2 from panther._utils import check_class_type_endpoint, check_function_type_endpoint, import_class from panther.authentications import JWTAuthentication from panther.background_tasks import _background_tasks from panther.base_websocket import WebsocketConnections from panther.cli.utils import import_error from panther.configs import JWTConfig, config from panther.db.connections import redis from panther.db.queries.mongodb_queries import BaseMongoDBQuery from panther.db.queries.pantherdb_queries import BasePantherDBQuery from panther.exceptions import PantherError from panther.middlewares.base import HTTPMiddleware, WebsocketMiddleware from panther.middlewares.monitoring import MonitoringMiddleware, WebsocketMonitoringMiddleware from panther.panel.views import HomeView from panther.routings import finalize_urls, flatten_urls __all__ = ( 'check_endpoints_inheritance', 'load_authentication_class', 'load_auto_reformat', 'load_background_tasks', 'load_configs_module', 'load_database', 'load_log_queries', 'load_middlewares', 'load_other_configs', 'load_redis', 'load_secret_key', 'load_templates_dir', 'load_throttling', 'load_timezone', 'load_urls', 'load_user_model', 'load_websocket_connections', ) logger = logging.getLogger('panther') monitoring_logger = logging.getLogger('monitoring') def load_configs_module(module_name: str, /) -> dict: """Read the config file as dict""" if module_name: _module = sys.modules[module_name] else: try: _module = import_module('core.configs') except ModuleNotFoundError: raise _exception_handler(field='core/configs.py', error='Not Found') return _module.__dict__ def load_redis(_configs: dict, /) -> None: if redis_config := _configs.get('REDIS'): # Check redis module installation try: from redis.asyncio import Redis except ImportError as e: raise import_error(e, package='redis') redis_class_path = redis_config.get('class', 'panther.db.connections.RedisConnection') redis_class = import_class(redis_class_path) # We have to create another dict then pop the 'class' else we can't pass the tests args = redis_config.copy() args.pop('class', None) redis_class(**args, init=True) def load_timezone(_configs: dict, /) -> None: if timezone := _configs.get('TIMEZONE'): config.TIMEZONE = timezone def load_templates_dir(_configs: dict, /) -> None: if templates_dir := _configs.get('TEMPLATES_DIR', '.'): config.TEMPLATES_DIR = templates_dir if config.TEMPLATES_DIR == '.': config.TEMPLATES_DIR = config.BASE_DIR config.JINJA_ENVIRONMENT = jinja2.Environment( loader=jinja2.ChoiceLoader( loaders=( jinja2.FileSystemLoader(searchpath=config.TEMPLATES_DIR), jinja2.PackageLoader(package_name='panther', package_path='panel/templates/'), jinja2.PackageLoader(package_name='panther', package_path='openapi/templates/'), ), ), ) def load_database(_configs: dict, /) -> None: database_config = _configs.get('DATABASE', {}) if 'engine' in database_config: if 'class' not in database_config['engine']: raise _exception_handler(field='DATABASE', error='`engine["class"]` not found.') engine_class_path = database_config['engine']['class'] engine_class = import_class(engine_class_path) # We have to create another dict then pop the 'class' else we can't pass the tests args = database_config['engine'].copy() args.pop('class') config.DATABASE = engine_class(**args) if engine_class_path == 'panther.db.connections.PantherDBConnection': config.QUERY_ENGINE = BasePantherDBQuery elif engine_class_path == 'panther.db.connections.MongoDBConnection': config.QUERY_ENGINE = BaseMongoDBQuery if 'query' in database_config: if config.QUERY_ENGINE: logger.warning('`DATABASE.query` has already been filled.') config.QUERY_ENGINE = import_class(database_config['query']) def load_secret_key(_configs: dict, /) -> None: if secret_key := _configs.get('SECRET_KEY'): config.SECRET_KEY = secret_key def load_throttling(_configs: dict, /) -> None: if throttling := _configs.get('THROTTLING'): config.THROTTLING = throttling def load_user_model(_configs: dict, /) -> None: config.USER_MODEL = import_class(_configs.get('USER_MODEL', 'panther.db.models.BaseUser')) if config.USER_MODEL not in config.MODELS: config.MODELS.append(config.USER_MODEL) def load_log_queries(_configs: dict, /) -> None: if _configs.get('LOG_QUERIES'): config.LOG_QUERIES = True def load_middlewares(_configs: dict, /) -> None: # Collect HTTP Middlewares for middleware in _configs.get('MIDDLEWARES') or []: # This block is for Backward Compatibility if isinstance(middleware, list | tuple): if len(middleware) == 1: middleware = middleware[0] elif len(middleware) == 2: _deprecated_warning( field='MIDDLEWARES', message='`data` does not supported in middlewares anymore, as your data is static you may want ' 'to pass them to your middleware with config variables', ) middleware = middleware[0] else: raise _exception_handler( field='MIDDLEWARES', error=f'{middleware} should be dotted path or type of a middleware class', ) # `middleware` can be type or path of a class if not callable(middleware): try: middleware = import_class(middleware) except (AttributeError, ModuleNotFoundError): raise _exception_handler( field='MIDDLEWARES', error=f'{middleware} is not a valid middleware path or type', ) if not issubclass(middleware, HTTPMiddleware): raise _exception_handler(field='MIDDLEWARES', error=f'{middleware} is not a sub class of `HTTPMiddleware`') if issubclass(middleware, MonitoringMiddleware): monitoring_logger.debug('') # Initiated config.MONITORING = True config.HTTP_MIDDLEWARES.insert(0, middleware) # Collect WebSocket Middlewares for middleware in _configs.get('WS_MIDDLEWARES') or []: # `middleware` can be type or path of a class if not callable(middleware): try: middleware = import_class(middleware) except (AttributeError, ModuleNotFoundError): raise _exception_handler( field='WS_MIDDLEWARES', error=f'{middleware} is not a valid middleware path or type', ) if not issubclass(middleware, WebsocketMiddleware): raise _exception_handler( field='WS_MIDDLEWARES', error=f'{middleware} is not a sub class of `WebsocketMiddleware`' ) if issubclass(middleware, WebsocketMonitoringMiddleware): monitoring_logger.debug('') # Initiated config.MONITORING = True config.WS_MIDDLEWARES.insert(0, middleware) def load_auto_reformat(_configs: dict, /) -> None: if _configs.get('AUTO_REFORMAT'): config.AUTO_REFORMAT = True def load_background_tasks(_configs: dict, /) -> None: if _configs.get('BACKGROUND_TASKS'): config.BACKGROUND_TASKS = True _background_tasks.initialize() def load_other_configs(_configs: dict, /) -> None: known_configs = set(config.__dataclass_fields__) for key, value in _configs.items(): if key.isupper() and key not in known_configs: config[key] = value def load_urls(_configs: dict, /, urls: dict | None) -> None: """ Return tuple of all urls (as a flat dict) and (as a nested dict) """ if isinstance(urls, dict): pass elif (url_routing := _configs.get('URLs')) is None: raise _exception_handler(field='URLs', error='required.') elif isinstance(url_routing, dict): error = ( "can't be 'dict', you may want to pass it's value directly to Panther(). Example: Panther(..., urls=...)" ) raise _exception_handler(field='URLs', error=error) elif not isinstance(url_routing, str): error = 'should be dotted string.' raise _exception_handler(field='URLs', error=error) else: try: urls = import_class(url_routing) except ModuleNotFoundError as e: raise _exception_handler(field='URLs', error=e) if not isinstance(urls, dict): raise _exception_handler(field='URLs', error='should point to a dict.') config.FLAT_URLS = flatten_urls(urls) config.URLS = finalize_urls(config.FLAT_URLS) def load_authentication_class(_configs: dict, /) -> None: """Should be after `load_secret_key()` and `load_urls()`""" if authentication := _configs.get('AUTHENTICATION'): config.AUTHENTICATION = import_class(authentication) if ws_authentication := _configs.get('WS_AUTHENTICATION'): config.WS_AUTHENTICATION = import_class(ws_authentication) load_jwt_config(_configs) def load_jwt_config(_configs: dict, /) -> None: """Only Collect JWT Config If Authentication Is JWTAuthentication""" auth_is_jwt = (config.AUTHENTICATION and issubclass(config.AUTHENTICATION, JWTAuthentication)) or ( config.WS_AUTHENTICATION and issubclass(config.WS_AUTHENTICATION, JWTAuthentication) ) jwt_config = _configs.get('JWT_CONFIG', {}) using_panel_views = HomeView in config.FLAT_URLS.values() if auth_is_jwt or using_panel_views: if 'key' not in jwt_config: if config.SECRET_KEY is None: raise _exception_handler(field='JWTConfig', error='`JWTConfig.key` or `SECRET_KEY` is required.') jwt_config['key'] = config.SECRET_KEY config.JWT_CONFIG = JWTConfig(**jwt_config) try: import jose except ImportError as e: raise import_error(e, package='python-jose') def load_websocket_connections(): """Should be after `load_redis()`""" if config.HAS_WS: # Check `websockets` try: import websockets except ImportError as e: raise import_error(e, package='websockets') # Use the redis pubsub if `redis.is_connected`, else use the `multiprocessing.Manager` pubsub_connection = redis.create_connection_for_websocket() if redis.is_connected else Manager() config.WEBSOCKET_CONNECTIONS = WebsocketConnections(pubsub_connection=pubsub_connection) def check_endpoints_inheritance(): """Should be after `load_urls()`""" for endpoint in config.FLAT_URLS.values(): if endpoint == {}: continue if isinstance(endpoint, types.FunctionType): check_function_type_endpoint(endpoint=endpoint) else: check_class_type_endpoint(endpoint=endpoint) def _exception_handler(field: str, error: str | Exception) -> PantherError: return PantherError(f"Invalid '{field}': {error}") def _deprecated_warning(field: str, message: str): return logger.warning(f"DEPRECATED '{field}': {message}") ================================================ FILE: panther/_utils.py ================================================ import asyncio import importlib import inspect import logging import mimetypes import re import subprocess import traceback import types from collections.abc import AsyncGenerator, Callable, Generator, Iterator from datetime import timedelta from pathlib import Path from traceback import TracebackException from typing import Any from panther.exceptions import PantherError from panther.file_handler import File logger = logging.getLogger('panther') ENDPOINT_FUNCTION_BASED_API = 0 ENDPOINT_CLASS_BASED_API = 1 ENDPOINT_WEBSOCKET = 2 def import_class(dotted_path: str, /) -> type[Any]: """ Example: ------- Input: panther.db.models.User Output: User (The Class) """ path, name = dotted_path.rsplit('.', 1) module = importlib.import_module(path) return getattr(module, name) NEWLINE_CRLF = b'\r\n' # Windows-style NEWLINE_LF = b'\n' # Unix/Linux-style # Regex patterns for CRLF (Windows) FIELD_PATTERN_CRLF = re.compile(rb'Content-Disposition: form-data; name="(.*)"\r\n\r\n(.*)', flags=re.DOTALL) FILE_PATTERN_CRLF = re.compile(rb'Content-Disposition: form-data; name="(.*)"; filename="(.*)"\r\nContent-Type: (.*)') # Regex patterns for LF (Linux) FIELD_PATTERN_LF = re.compile(rb'Content-Disposition: form-data; name="(.*)"\n\n(.*)', flags=re.DOTALL) FILE_PATTERN_LF = re.compile(rb'Content-Disposition: form-data; name="(.*)"; filename="(.*)"\nContent-Type: (.*)') def read_multipart_form_data(boundary: str, body: bytes) -> dict: boundary_bytes = b'--' + boundary.encode() # Choose newline type and corresponding patterns if body.endswith(NEWLINE_CRLF): newline = NEWLINE_CRLF field_pattern = FIELD_PATTERN_CRLF file_pattern = FILE_PATTERN_CRLF else: newline = NEWLINE_LF field_pattern = FIELD_PATTERN_LF file_pattern = FILE_PATTERN_LF data = {} for part in body.split(boundary_bytes): part = part.removeprefix(newline).removesuffix(newline) if part in (b'', b'--'): continue if match := field_pattern.match(string=part): field_name, value = match.groups() data[field_name.decode('utf-8')] = value.decode('utf-8') continue try: headers, file_content = part.split(2 * newline, 1) except ValueError: logger.error('Malformed part, skipping.') continue if match := file_pattern.match(string=headers): field_name, file_name, content_type = match.groups() data[field_name.decode('utf-8')] = File( file_name=file_name.decode('utf-8'), content_type=content_type.decode('utf-8'), file=file_content, ) else: logger.error('Unrecognized multipart format') return data def is_function_async(func: Callable) -> bool: """ Sync result is 0 --> False async result is 128 --> True """ return bool(func.__code__.co_flags & (1 << 7)) def traceback_message(exception: Exception) -> str: tb = TracebackException(type(exception), exception, exception.__traceback__) return ''.join(tb.format(chain=False)) def reformat_code(base_dir): try: subprocess.run(['ruff', 'format', base_dir]) subprocess.run(['ruff', 'check', '--select', 'I', '--fix', base_dir]) except FileNotFoundError: raise PantherError("No module named 'ruff', Hint: `pip install ruff`") def check_function_type_endpoint(endpoint: types.FunctionType): # Function Doesn't Have @API Decorator if not hasattr(endpoint, '__wrapped__'): raise PantherError( f'You may have forgotten to use `@API()` on the `{endpoint.__module__}.{endpoint.__name__}()`', ) def check_class_type_endpoint(endpoint: Callable): from panther.app import GenericAPI from panther.websocket import GenericWebsocket if not issubclass(endpoint, (GenericAPI, GenericWebsocket)): raise PantherError( f'You may have forgotten to inherit from `panther.app.GenericAPI` or `panther.app.GenericWebsocket` ' f'on the `{endpoint.__module__}.{endpoint.__name__}()`', ) def async_next(iterator: Iterator): """ The StopIteration exception is a special case in Python, particularly when it comes to asynchronous programming and the use of asyncio. This is because StopIteration is not meant to be caught in the traditional sense; it's used internally by Python to signal the end of an iteration. """ try: return next(iterator) except StopIteration: raise StopAsyncIteration async def to_async_generator(generator: Generator) -> AsyncGenerator: while True: try: yield await asyncio.to_thread(async_next, iter(generator)) except StopAsyncIteration: break def validate_api_auth(auth): """Validate the auth callable or class for correct async signature and argument count.""" if auth is None: return None if not callable(auth): msg = ( f'`{type(auth).__name__}` is not valid for authentication, it should be a callable, a Class with __call__ ' f'method or a single function.' ) logger.error(msg) raise PantherError(msg) # If it's a class, validate its __call__ if inspect.isclass(auth): call_method = getattr(auth, '__call__', None) if not inspect.isfunction(call_method): msg = f'{auth.__name__} must implement __call__() method.' logger.error(msg) raise PantherError(msg) func = call_method expected_args = 2 # self, request func_name = f'{auth.__name__}.__call__()' else: func = auth expected_args = 1 # request func_name = f'{auth.__name__}()' sig = inspect.signature(func) if len(sig.parameters) != expected_args: msg = f'{func_name} requires {expected_args} positional argument(s) ({"self, " if expected_args == 2 else ""}request).' logger.error(msg) raise PantherError(msg) # Check if async if not is_function_async(func): msg = f'{func_name} should be `async`' logger.error(msg) raise PantherError(msg) def validate_api_permissions(permissions): if permissions is None: return permissions for perm in permissions: if not callable(perm): msg = ( f'`{type(perm).__name__}` is not valid for permission, it should be a callable, a Class with __call__ ' f'method or a single function.' ) logger.error(msg) raise PantherError(msg) # If it's a class, validate its __call__ if inspect.isclass(perm): call_method = getattr(perm, '__call__', None) if not inspect.isfunction(call_method): msg = f'{perm.__name__} must implement __call__() method.' logger.error(msg) raise PantherError(msg) func = call_method expected_args = 2 # self, request func_name = f'{perm.__name__}.__call__()' else: func = perm expected_args = 1 # request func_name = f'{perm.__name__}()' sig = inspect.signature(func) if len(sig.parameters) != expected_args: msg = f'{func_name} requires {expected_args} positional argument(s) ({"self, " if expected_args == 2 else ""}request).' logger.error(msg) raise PantherError(msg) # Check if async if not is_function_async(func): msg = f'{func_name} should be `async`' logger.error(msg) raise PantherError(msg) def check_api_deprecations(cache, **kwargs): # Check Cache Usage if kwargs.pop('cache_exp_time', None): deprecation_message = ( traceback.format_stack(limit=2)[0] + '\nThe `cache_exp_time` argument has been removed in Panther v5 and is no longer available.' '\nYou may want to use `cache` instead.' ) raise PantherError(deprecation_message) if cache and not isinstance(cache, timedelta): deprecation_message = ( traceback.format_stack(limit=2)[0] + '\nThe `cache` argument has been changed in Panther v5, ' 'it should be an instance of `datetime.timedelta()`.' ) raise PantherError(deprecation_message) # Check Others if kwargs: msg = f'Unknown kwargs: {kwargs.keys()}' logger.error(msg) raise PantherError(msg) CUSTOM_MIME_TYPES = { # Text & Web 'html': 'text/html', 'htm': 'text/html', 'css': 'text/css', 'csv': 'text/csv', 'txt': 'text/plain', 'md': 'text/markdown', 'xml': 'application/xml', 'json': 'application/json', 'yml': 'text/yaml', 'yaml': 'text/yaml', 'js': 'application/javascript', 'mjs': 'application/javascript', 'ts': 'application/typescript', 'map': 'application/json', # Images 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif', 'bmp': 'image/bmp', 'webp': 'image/webp', 'svg': 'image/svg+xml', 'ico': 'image/x-icon', 'tiff': 'image/tiff', 'avif': 'image/avif', # Fonts 'woff': 'font/woff', 'woff2': 'font/woff2', 'ttf': 'font/ttf', 'otf': 'font/otf', 'eot': 'application/vnd.ms-fontobject', # Audio 'mp3': 'audio/mpeg', 'ogg': 'audio/ogg', 'wav': 'audio/wav', 'flac': 'audio/flac', 'm4a': 'audio/mp4', 'aac': 'audio/aac', # Video 'mp4': 'video/mp4', 'webm': 'video/webm', 'ogv': 'video/ogg', 'mov': 'video/quicktime', 'avi': 'video/x-msvideo', 'mkv': 'video/x-matroska', # Archives & binaries 'zip': 'application/zip', 'gz': 'application/gzip', 'tar': 'application/x-tar', 'rar': 'application/vnd.rar', '7z': 'application/x-7z-compressed', 'bz2': 'application/x-bzip2', # Documents 'pdf': 'application/pdf', 'rtf': 'application/rtf', 'doc': 'application/msword', 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'xls': 'application/vnd.ms-excel', 'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'ppt': 'application/vnd.ms-powerpoint', 'pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', # Code & Config 'py': 'text/x-python', 'go': 'text/x-go', 'java': 'text/x-java-source', 'c': 'text/x-c', 'cpp': 'text/x-c++', 'h': 'text/x-c', 'sh': 'application/x-sh', 'bat': 'application/x-msdos-program', 'ini': 'text/plain', 'env': 'text/plain', } def detect_mime_type(file_path: str | Path) -> str: file_path = str(file_path) # Try extension-based detection mime_type, _ = mimetypes.guess_type(file_path) if mime_type: return mime_type # Try content-based detection (magic numbers) try: with open(file_path, 'rb') as f: header = f.read(16) if header.startswith(b'\x89PNG\r\n\x1a\n'): return 'image/png' elif header.startswith(b'%PDF'): return 'application/pdf' elif header.startswith(b'PK\x03\x04'): return 'application/zip' elif header.startswith(b'\xff\xd8\xff'): return 'image/jpeg' elif header.startswith(b'GIF87a') or header.startswith(b'GIF89a'): return 'image/gif' elif header.startswith(b'BM'): return 'image/bmp' elif header.startswith(b'\x00\x00\x01\x00'): return 'image/x-icon' # ICO file elif header.startswith(b'II*\x00') or header.startswith(b'MM\x00*'): return 'image/tiff' elif header[:4] == b'\x00\x00\x00\x18' and b'ftyp' in header: return 'video/mp4' except Exception: pass # Custom mapping fallback if (ext := file_path.rsplit('.', maxsplit=1)[1].lower()) in CUSTOM_MIME_TYPES: return CUSTOM_MIME_TYPES[ext] # Fallback if no match return 'application/octet-stream' ================================================ FILE: panther/app.py ================================================ import functools import inspect import logging from collections.abc import Callable from datetime import timedelta from typing import Literal from pydantic import BaseModel from panther._utils import ( ENDPOINT_CLASS_BASED_API, ENDPOINT_FUNCTION_BASED_API, check_api_deprecations, is_function_async, validate_api_auth, validate_api_permissions, ) from panther.base_request import BaseRequest from panther.caching import ( get_response_from_cache, set_response_in_cache, ) from panther.configs import config from panther.exceptions import ( AuthorizationAPIError, MethodNotAllowedAPIError, ) from panther.middlewares import HTTPMiddleware from panther.openapi import OutputSchema from panther.request import Request from panther.response import Response from panther.serializer import ModelSerializer from panther.throttling import Throttle __all__ = ('API', 'GenericAPI') logger = logging.getLogger('panther') class API: """ methods: Specify the allowed methods. input_model: The `request.data` will be validated with this attribute, It will raise an `panther.exceptions.BadRequestAPIError` or put the validated data in the `request.validated_data`. output_model: The `response.data` will be passed through this class to filter its attributes. output_schema: This attribute only used in creation of OpenAPI scheme which is available in `panther.openapi.urls` You may want to add its `url` to your urls. auth: It will authenticate the user with header of its request or raise an `panther.exceptions.AuthenticationAPIError`. permissions: List of permissions that will be called sequentially after authentication to authorize the user. throttling: It will limit the users' request on a specific (time-window, path) cache: Specify the duration of the cache (Will be used only in GET requests). middlewares: These middlewares have inner priority than global middlewares. """ func: Callable def __init__( self, *, methods: list[Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE']] | None = None, input_model: type[ModelSerializer] | type[BaseModel] | None = None, output_model: type[ModelSerializer] | type[BaseModel] | None = None, output_schema: OutputSchema | None = None, auth: Callable | None = None, permissions: list[Callable] | Callable | None = None, throttling: Throttle | None = None, cache: timedelta | None = None, middlewares: list[type[HTTPMiddleware]] | None = None, **kwargs, ): self.methods = {m.upper() for m in methods} if methods else {'GET', 'POST', 'PUT', 'PATCH', 'DELETE'} self.input_model = input_model self.output_model = output_model self.output_schema = output_schema self.auth = auth self.permissions = permissions if self.permissions is not None and not isinstance(self.permissions, list): self.permissions = [self.permissions] self.throttling = throttling self.cache = cache self.middlewares = middlewares if self.auth is not None: validate_api_auth(self.auth) validate_api_permissions(self.permissions) check_api_deprecations(self.cache, **kwargs) def __call__(self, func): self.func = func self.is_function_async = is_function_async(self.func) self.function_annotations = { k: v for k, v in func.__annotations__.items() if v in {BaseRequest, Request, bool, int} } @functools.wraps(func) async def wrapper(request: Request) -> Response: chained_func = self.handle_endpoint if self.middlewares: for middleware in reversed(self.middlewares): chained_func = middleware(chained_func) return await chained_func(request=request) # Store attributes on the function, so have the same behaviour as class-based (useful in `openapi.view.OpenAPI`) wrapper.auth = self.auth wrapper.cache = self.cache wrapper.methods = self.methods wrapper.throttling = self.throttling wrapper.permissions = self.permissions wrapper.middlewares = self.middlewares wrapper.input_model = self.input_model wrapper.output_model = self.output_model wrapper.output_schema = self.output_schema wrapper._endpoint_type = ENDPOINT_FUNCTION_BASED_API return wrapper async def handle_endpoint(self, request: Request) -> Response: # 1. Check Method if request.method not in self.methods: raise MethodNotAllowedAPIError # 2. Authentication if self.auth or config.AUTHENTICATION: auth = self.auth or config.AUTHENTICATION if inspect.isclass(auth): auth = auth() request.user = await auth(request) # 3. Permissions if self.permissions: for perm in self.permissions: if inspect.isclass(perm): perm = perm() if await perm(request) is False: raise AuthorizationAPIError # 4. Throttle if self.throttling or config.THROTTLING: throttling = self.throttling or config.THROTTLING await throttling.check_and_increment(request=request) # 5. Validate Input if self.input_model and request.method in {'POST', 'PUT', 'PATCH'}: request.validate_data(model=self.input_model) # 6. Get Cached Response if self.cache and request.method == 'GET': if cached := await get_response_from_cache(request=request, duration=self.cache): return Response(data=cached.data, headers=cached.headers, status_code=cached.status_code) # 7. Put PathVariables and Request(If User Wants It) In kwargs kwargs = request.clean_parameters(self.function_annotations) # 8. Call Endpoint if self.is_function_async: response = await self.func(**kwargs) else: response = self.func(**kwargs) # 9. Clean Response if not isinstance(response, Response): response = Response(data=response) if self.output_model and response.data: await response.serialize_output(output_model=self.output_model) if response.pagination: response.data = await response.pagination.template(response.data) # 10. Set New Response To Cache if self.cache and request.method == 'GET': await set_response_in_cache(request=request, response=response, duration=self.cache) return response class GenericAPI: """ Check out the documentation of `panther.app.API()`. """ _endpoint_type = ENDPOINT_CLASS_BASED_API input_model: type[ModelSerializer] | type[BaseModel] | None = None output_model: type[ModelSerializer] | type[BaseModel] | None = None output_schema: OutputSchema | None = None auth: Callable | None = None permissions: list[Callable] | Callable | None = None throttling: Throttle | None = None cache: timedelta | None = None middlewares: list[HTTPMiddleware] | None = None def __init_subclass__(cls, **kwargs): if cls.permissions is not None and not isinstance(cls.permissions, list): cls.permissions = [cls.permissions] # Creating API instance to validate the attributes. API( input_model=cls.input_model, output_model=cls.output_model, output_schema=cls.output_schema, auth=cls.auth, permissions=cls.permissions, throttling=cls.throttling, cache=cls.cache, middlewares=cls.middlewares, ) async def get(self, *args, **kwargs): raise MethodNotAllowedAPIError async def post(self, *args, **kwargs): raise MethodNotAllowedAPIError async def put(self, *args, **kwargs): raise MethodNotAllowedAPIError async def patch(self, *args, **kwargs): raise MethodNotAllowedAPIError async def delete(self, *args, **kwargs): raise MethodNotAllowedAPIError async def call_method(self, request: Request): match request.method: case 'GET': func = self.get case 'POST': func = self.post case 'PUT': func = self.put case 'PATCH': func = self.patch case 'DELETE': func = self.delete case _: raise MethodNotAllowedAPIError return await API( input_model=self.input_model, output_model=self.output_model, output_schema=self.output_schema, auth=self.auth, permissions=self.permissions, throttling=self.throttling, cache=self.cache, middlewares=self.middlewares, )(func)(request=request) ================================================ FILE: panther/authentications.py ================================================ import logging import time from abc import abstractmethod from datetime import datetime, timezone from typing import Literal from panther.base_websocket import Websocket from panther.configs import config from panther.db.connections import redis from panther.db.models import Model from panther.exceptions import AuthenticationAPIError from panther.request import Request from panther.utils import generate_hash_value_from_string try: from jose import JWTError, jwt except ImportError as e: # This `JWTError` and `jwt` is not going to be used, # If user really wants to use redis, # we are going to force him to install it in `panther._load_configs.load_jwt_config` JWTError = type('JWTError', (), {}) jwt = type('jwt', (), {}) logger = logging.getLogger('panther') class BaseAuthentication: @abstractmethod async def __call__(self, request: Request | Websocket): """Return Instance of User""" msg = f'{self.__class__.__name__}.__call__() is not implemented.' raise self.exception(msg) from None @classmethod def exception(cls, message: str | Exception, /) -> type[AuthenticationAPIError]: logger.error(f'{cls.__name__} Error: "{message}"') return AuthenticationAPIError class JWTAuthentication(BaseAuthentication): """ Retrieve the Authorization from header Example: Headers: {'authorization': 'Bearer the_jwt_token'} """ model = None keyword = 'Bearer' algorithm = 'HS256' HTTP_HEADER_ENCODING = 'iso-8859-1' # RFC5987 async def __call__(self, request: Request | Websocket) -> Model | None: """Authenticate the user based on the JWT token in the Authorization header.""" auth_header = self.get_authorization_header(request) # Set None as `request.user` if auth_header is None: return None token = self.get_token(auth_header=auth_header) if redis.is_connected and await self.is_token_revoked(token=token): msg = 'User logged out' raise self.exception(msg) from None payload = await self.decode_jwt(token) user = await self.get_user(payload) user._auth_token = token return user @classmethod def get_authorization_header(cls, request: Request | Websocket) -> list[str] | None: """Retrieve the Authorization header from the request.""" if auth := request.headers.authorization: return auth.split() return None @classmethod def get_token(cls, auth_header): if len(auth_header) != 2: msg = 'Authorization header must contain 2 parts' raise cls.exception(msg) from None bearer, token = auth_header try: token.encode(JWTAuthentication.HTTP_HEADER_ENCODING) except UnicodeEncodeError as e: raise cls.exception(e) from None if bearer.lower() != cls.keyword.lower(): msg = 'Authorization keyword is not valid' raise cls.exception(msg) from None return token @classmethod async def decode_jwt(cls, token: str) -> dict: """Decode a JWT token and return the payload.""" try: return jwt.decode( token=token, key=config.JWT_CONFIG.key, algorithms=[config.JWT_CONFIG.algorithm], ) except JWTError as e: raise cls.exception(e) from None @classmethod async def get_user(cls, payload: dict) -> Model: """Fetch the user based on the decoded JWT payload from cls.model or config.UserModel""" if (user_id := payload.get('user_id')) is None: msg = 'Payload does not have `user_id`' raise cls.exception(msg) user_model = cls.model or config.USER_MODEL user = await user_model.find_one(id=user_id) if user is None: raise cls.exception('User not found') return user @classmethod def encode_jwt(cls, user_id: str, token_type: Literal['access', 'refresh'] = 'access') -> str: """Generate a JWT token for a given user ID.""" issued_at = datetime.now(timezone.utc).timestamp() if token_type == 'access': expire = issued_at + config.JWT_CONFIG.life_time else: expire = issued_at + config.JWT_CONFIG.refresh_life_time claims = { 'token_type': token_type, 'user_id': user_id, 'iat': issued_at, 'exp': expire, } return jwt.encode( claims, key=config.JWT_CONFIG.key, algorithm=config.JWT_CONFIG.algorithm, ) @classmethod async def login(cls, user) -> dict: """Generate access and refresh tokens for user login.""" return { 'access_token': cls.encode_jwt(user_id=user.id), 'refresh_token': cls.encode_jwt(user_id=user.id, token_type='refresh'), } @classmethod async def logout(cls, user) -> None: """Log out a user by revoking their JWT token.""" payload = await cls.decode_jwt(token=user._auth_token) await cls.revoke_token_in_cache(token=user._auth_token, exp=payload['exp']) @classmethod async def refresh(cls, user): if hasattr(user, '_auth_refresh_token'): # It happens in CookieJWTAuthentication token = user._auth_refresh_token else: token = user._auth_token payload = await cls.decode_jwt(token=token) if payload['token_type'] != 'refresh': raise cls.exception('Invalid token type; expected `refresh` token.') # Revoke after use await cls.revoke_token_in_cache(token=token, exp=payload['exp']) return await cls.login(user=user) @classmethod async def revoke_token_in_cache(cls, token: str, exp: int) -> None: """Mark the token as revoked in the cache.""" if redis.is_connected: key = generate_hash_value_from_string(token) remaining_exp_time = int(exp - time.time()) await redis.set(key, b'', ex=remaining_exp_time) else: logger.error('Redis is not connected; token revocation is not effective.') @classmethod async def is_token_revoked(cls, token: str) -> bool: """Check if the token is revoked by looking it up in the cache.""" key = generate_hash_value_from_string(token) return bool(await redis.exists(key)) class QueryParamJWTAuthentication(JWTAuthentication): """ Retrieve the Authorization from query params Example: https://example.com?authorization=the_jwt_without_bearer """ @classmethod def get_authorization_header(cls, request: Request | Websocket) -> str | None: if 'authorization' in request.query_params: return request.query_params['authorization'] return None @classmethod def get_token(cls, auth_header) -> str: return auth_header class CookieJWTAuthentication(JWTAuthentication): """ Retrieve the Authorization from cookies Example: Cookies: access_token=the_jwt_without_bearer """ async def __call__(self, request: Request | Websocket) -> Model: user = await super().__call__(request=request) if refresh_token := request.headers.get_cookies().get('refresh_token'): # It's used in `cls.refresh()` user._auth_refresh_token = refresh_token return user @classmethod def get_authorization_header(cls, request: Request | Websocket) -> str | None: if token := request.headers.get_cookies().get('access_token'): return token return None @classmethod def get_token(cls, auth_header) -> str: return auth_header ================================================ FILE: panther/background_tasks.py ================================================ """ Example: ------------------------------------------------------------- >>> import datetime >>> async def hello(name: str): >>> print(f'Hello {name}') # Run it every 5 seconds for 2 times >>> BackgroundTask(hello, 'Ali').interval(2).every_seconds(5).submit() # Run it every day at 08:00 O'clock forever >>> BackgroundTask(hello, 'Saba').interval(-1).every_days().at(datetime.time(hour=8)).submit() """ import asyncio import datetime import logging import sys import time from enum import Enum from threading import Lock, Thread from typing import TYPE_CHECKING, Any, Literal from panther._utils import is_function_async from panther.utils import Singleton, timezone_now if TYPE_CHECKING: from collections.abc import Callable __all__ = ('BackgroundTask', 'WeekDay') logger = logging.getLogger('panther') _application_event_loop: asyncio.AbstractEventLoop | None = None def register_application_event_loop(loop: asyncio.AbstractEventLoop | None) -> None: global _application_event_loop _application_event_loop = loop def get_application_event_loop() -> asyncio.AbstractEventLoop | None: return _application_event_loop if sys.version_info.minor >= 11: from typing import Self else: from typing import TypeVar Self = TypeVar('Self', bound='BackgroundTask') class WeekDay(Enum): MONDAY = 0 TUESDAY = 1 WEDNESDAY = 2 THURSDAY = 3 FRIDAY = 4 SATURDAY = 5 SUNDAY = 6 class BackgroundTask: """ Schedules and runs a function periodically in the background. Default: Task runs once. If only a custom interval is specified, default interval time is 1 minute. Use submit() to add the task to the background queue. """ def __init__(self, func: 'Callable', *args: Any, **kwargs: Any): self._func: 'Callable' = func self._args: tuple = args self._kwargs: dict = kwargs self._remaining_interval: int = 1 self._last_run: datetime.datetime | None = None self._timedelta: datetime.timedelta = datetime.timedelta(minutes=1) self._time: datetime.time | None = None self._day_of_week: WeekDay | None = None self._unit: Literal['seconds', 'minutes', 'hours', 'days', 'weeks'] | None = None def interval(self, interval: int, /) -> Self: """Set how many times to run the task. interval = -1 for infinite.""" self._remaining_interval = interval return self def every_seconds(self, seconds: int = 1, /) -> Self: """Run every N seconds (default 1).""" self._unit = 'seconds' self._timedelta = datetime.timedelta(seconds=seconds) return self def every_minutes(self, minutes: int = 1, /) -> Self: """Run every N minutes (default 1).""" self._unit = 'minutes' self._timedelta = datetime.timedelta(minutes=minutes) return self def every_hours(self, hours: int = 1, /) -> Self: """Run every N hours (default 1).""" self._unit = 'hours' self._timedelta = datetime.timedelta(hours=hours) return self def every_days(self, days: int = 1, /) -> Self: """Run every N days (default 1).""" self._unit = 'days' self._timedelta = datetime.timedelta(days=days) return self def every_weeks(self, weeks: int = 1, /) -> Self: """Run every N weeks (default 1).""" self._unit = 'weeks' self._timedelta = datetime.timedelta(weeks=weeks) return self def on(self, day_of_week: WeekDay, /) -> Self: """ Set day to schedule the task. Accepts string like 'monday', 'tuesday', etc. """ self._day_of_week = day_of_week return self def at(self, _time: datetime.time, /) -> Self: """Set a time to schedule the task.""" if isinstance(_time, datetime.time): self._time = _time elif isinstance(_time, datetime.datetime): self._time = _time.time() else: raise TypeError( f'Argument should be instance of `datetime.time()` or `datetime.datetime()` not `{type(_time)}`', ) return self def _should_wait(self) -> bool: """ Returns True if the task should wait (not run yet), False if it should run now. """ now = timezone_now() # Wait if self._last_run and (self._last_run + self._timedelta) > now: return True # Check day of week if self._day_of_week is not None and self._day_of_week.value != now.weekday(): return True # We don't have time condition, so run if self._time is None: self._last_run = now return False # Time is ok, so run if now.hour == self._time.hour and now.minute == self._time.minute and now.second == self._time.second: self._last_run = now return False # Time was not ok, wait return True def __call__(self) -> bool: """ Executes the task if it's time. Returns True if the task should remain scheduled, False if done. """ if self._remaining_interval == 0: return False if self._should_wait(): return True logger.info( f'{self._func.__name__}(' f'{", ".join(str(a) for a in self._args)}, ' f'{", ".join(f"{k}={v}" for k, v in self._kwargs.items())}' f') Remaining Interval -> {"∞" if self._remaining_interval == -1 else self._remaining_interval - 1}', ) if self._remaining_interval != -1: self._remaining_interval -= 1 try: if is_function_async(self._func): coroutine = self._func(*self._args, **self._kwargs) app_loop = _application_event_loop if app_loop and app_loop.is_running() and not app_loop.is_closed(): asyncio.run_coroutine_threadsafe(coroutine, app_loop).result() else: asyncio.run(coroutine) else: self._func(*self._args, **self._kwargs) except Exception as e: logger.error(f'Exception in background task {self._func.__name__}: {e}', exc_info=True) return True def submit(self) -> Self: """Add this task to the background task queue.""" _background_tasks.add_task(self) return self class BackgroundTasks(Singleton): _initialized: bool = False def __init__(self): self.tasks: list[BackgroundTask] = [] self._lock = Lock() def add_task(self, task: BackgroundTask): if self._initialized is False: logger.error('Task will be ignored, `BACKGROUND_TASKS` is not True in `configs`') return if not self._is_instance_of_task(task): return with self._lock: if task not in self.tasks: self.tasks.append(task) logger.info(f'Task {task._func.__name__} submitted.') def initialize(self): """Call once to start background task processing.""" if self._initialized is False: self._initialized = True Thread(target=self._run_tasks, daemon=True).start() def _run_task(self, task: BackgroundTask): should_continue = task() if should_continue is False: with self._lock: if task in self.tasks: self.tasks.remove(task) def _run_tasks(self): while True: with self._lock: tasks_snapshot = self.tasks[:] for task in tasks_snapshot: Thread(target=self._run_task, args=(task,)).start() time.sleep(1) @classmethod def _is_instance_of_task(cls, task: Any, /) -> bool: if not isinstance(task, BackgroundTask): name = getattr(task, '__name__', task.__class__.__name__) logger.error(f'`{name}` should be instance of `background_tasks.BackgroundTask`') return False return True _background_tasks = BackgroundTasks() ================================================ FILE: panther/base_request.py ================================================ from collections.abc import Callable from typing import TYPE_CHECKING from urllib.parse import parse_qsl from panther.exceptions import InvalidPathVariableAPIError if TYPE_CHECKING: from panther.db import Model class Headers: accept: str accept_encoding: str accept_language: str authorization: str cache_control: str connection: str content_length: str content_type: str host: str origin: str pragma: str referer: str sec_fetch_dest: str sec_fetch_mode: str sec_fetch_site: str user_agent: str upgrade: str sec_websocket_version: str sec_websocket_key: str def __init__(self, headers: list): self.__headers = {header[0].decode('utf-8'): header[1].decode('utf-8') for header in headers} self.__pythonic_headers = {k.lower().replace('-', '_'): v for k, v in self.__headers.items()} def __getattr__(self, item: str): if result := self.__pythonic_headers.get(item): return result return self.__headers.get(item) def __getitem__(self, item: str): if result := self.__headers.get(item): return result return self.__pythonic_headers.get(item) def __str__(self): items = ', '.join(f'{k}={v}' for k, v in self.__headers.items()) return f'Headers({items})' def __contains__(self, item): return (item in self.__headers) or (item in self.__pythonic_headers) __repr__ = __str__ @property def __dict__(self): return self.__headers def get_cookies(self) -> dict: """ Example of `request.headers.cookie`: 'csrftoken=aaa; sessionid=bbb; access_token=ccc; refresh_token=ddd' Example of `request.headers.get_cookies()`: { 'csrftoken': 'aaa', 'sessionid': 'bbb', 'access_token': 'ccc', 'refresh_token': 'ddd', } """ if self.cookie: return {k.strip(): v for k, v in (c.split('=', maxsplit=1) for c in self.cookie.split(';'))} return {} class Address: def __init__(self, ip, port): self.ip = ip self.port = port def __str__(self): return f'{self.ip}:{self.port}' class BaseRequest: def __init__(self, scope: dict, receive: Callable, send: Callable): self.scope = scope self.asgi_send = send self.asgi_receive = receive self._headers: Headers | None = None self._params: dict | None = None self.user: Model | None = None self.path_variables: dict | None = None @property def headers(self) -> Headers: if self._headers is None: self._headers = Headers(self.scope['headers']) return self._headers @property def query_params(self) -> dict: if self._params is None: self._params = {k: v for k, v in parse_qsl(self.scope['query_string'].decode('utf-8'))} return self._params @property def path(self) -> str: return self.scope['path'] @property def server(self) -> Address: return Address(*self.scope['server']) @property def client(self) -> Address: return Address(*self.scope['client']) @property def http_version(self) -> str: return self.scope['http_version'] @property def scheme(self) -> str: return self.scope['scheme'] def collect_path_variables(self, found_path: str): self.path_variables = { variable.strip('< >'): value for variable, value in zip(found_path.strip('/').split('/'), self.path.strip('/').split('/')) if variable.startswith('<') } def clean_parameters(self, function_annotations: dict) -> dict: kwargs = self.path_variables.copy() for variable_name, variable_type in function_annotations.items(): # Put Request/ Websocket In kwargs (If User Wants It) if issubclass(variable_type, BaseRequest): kwargs[variable_name] = self elif variable_name in kwargs: # Cast To Boolean if variable_type is bool: value = kwargs[variable_name].lower() if value in ['false', '0']: kwargs[variable_name] = False elif value in ['true', '1']: kwargs[variable_name] = True else: raise InvalidPathVariableAPIError(value=kwargs[variable_name], variable_type=variable_type) # Cast To Int elif variable_type is int: try: kwargs[variable_name] = int(kwargs[variable_name]) except ValueError: raise InvalidPathVariableAPIError(value=kwargs[variable_name], variable_type=variable_type) return kwargs ================================================ FILE: panther/base_websocket.py ================================================ from __future__ import annotations import asyncio import inspect import logging from multiprocessing.managers import SyncManager from typing import TYPE_CHECKING, Any, Callable, Literal import orjson as json import ulid from panther import status from panther.base_request import BaseRequest from panther.configs import config from panther.db.connections import redis from panther.exceptions import BaseError, InvalidPathVariableAPIError from panther.utils import Singleton if TYPE_CHECKING: from redis.asyncio import Redis logger = logging.getLogger('panther') class PubSub: def __init__(self, manager: SyncManager): self._manager = manager self._subscribers = self._manager.list() def subscribe(self): queue = self._manager.Queue() self._subscribers.append(queue) return queue def publish(self, msg): for queue in self._subscribers: queue.put(msg) class WebsocketConnections(Singleton): def __init__(self, pubsub_connection: Redis | SyncManager): self.connections = {} self.connections_count = 0 self.pubsub_connection = pubsub_connection if isinstance(self.pubsub_connection, SyncManager): self.pubsub = PubSub(manager=self.pubsub_connection) async def __call__(self): if isinstance(self.pubsub_connection, SyncManager): # We don't have redis connection, so use the `multiprocessing.Manager` self.pubsub: PubSub queue = self.pubsub.subscribe() logger.info("Subscribed to 'websocket_connections' queue") while True: try: received_message = await asyncio.to_thread(queue.get) if received_message is None: # The None came from the CancelledError, so break the loop break await self._handle_received_message(received_message=received_message) except (InterruptedError, asyncio.CancelledError): # Put the None to the queue, so the executor knows that it ends queue.put(None) break else: # We have a redis connection, so use it for pubsub self.pubsub = self.pubsub_connection.pubsub() await self.pubsub.subscribe('websocket_connections') logger.info("Subscribed to 'websocket_connections' channel") async for channel_data in self.pubsub.listen(): match channel_data['type']: # Subscribed case 'subscribe': continue # Message Received case 'message': loaded_data = json.loads(channel_data['data'].decode()) await self._handle_received_message(received_message=loaded_data) case unknown_type: logger.error(f'Unknown Channel Type: {unknown_type}') async def _handle_received_message(self, received_message): if ( isinstance(received_message, dict) and (connection_id := received_message.get('connection_id')) and connection_id in self.connections and 'action' in received_message and 'data' in received_message ): # Check Action of WS match received_message['action']: case 'send': await self.connections[connection_id].send(data=received_message['data']) case 'close': await self.connections[connection_id].close( code=received_message['data']['code'], reason=received_message['data']['reason'], ) case unknown_action: logger.error(f'Unknown Message Action: {unknown_action}') async def publish(self, connection_id: str, action: Literal['send', 'close'], data: any): publish_data = {'connection_id': connection_id, 'action': action, 'data': data} if redis.is_connected: await redis.publish('websocket_connections', json.dumps(publish_data)) else: self.pubsub.publish(publish_data) async def listen(self, connection: Websocket): # 1. Authentication if not connection.is_rejected: await self.handle_authentication(connection=connection) # 2. Permissions if not connection.is_rejected: await self.handle_permissions(connection=connection) if connection.is_rejected: # Connection is rejected so don't continue the flow ... return None # 3. Put PathVariables and Request(If User Wants It) In kwargs try: kwargs = connection.clean_parameters(connection.connect.__annotations__) except InvalidPathVariableAPIError as e: connection.change_state(state='Rejected', message=e.detail) return await connection.close() # 4. Connect To Endpoint await connection.connect(**kwargs) # 5. Check Connection if not connection.is_connected and not connection.is_rejected: # User didn't call the `self.accept()` or `self.close()` so we `close()` the connection (reject) return await connection.close() # 6. Listen Connection await self.listen_connection(connection=connection) return connection async def listen_connection(self, connection: Websocket): while True: response = await connection.asgi_receive() if response['type'] == 'websocket.connect': continue if response['type'] == 'websocket.disconnect': # Connection has to be closed by the client. await self.connection_closed(connection=connection) break if 'text' in response: await connection.receive(data=response['text']) else: await connection.receive(data=response['bytes']) async def connection_accepted(self, connection: Websocket) -> None: # Generate ConnectionID connection._connection_id = ulid.new() # Save Connection self.connections[connection.connection_id] = connection connection.change_state(state='Accepted') async def connection_closed(self, connection: Websocket, from_server: bool = False) -> None: if connection.is_connected: del self.connections[connection.connection_id] connection.change_state(state='Closed') connection._connection_id = '' elif connection.is_rejected is False and from_server is True: connection.change_state(state='Rejected') connection._is_rejected = True async def start(self): """ Start Websocket Listener (Redis/ Queue) Cause of --preload in gunicorn we have to keep this function here, and we can't move it to __init__ of Panther * Each process should start this listener for itself, but they have same Manager() """ # Schedule the async function to run in the background, # We don't need to await for this task asyncio.create_task(self()) @classmethod async def handle_authentication(cls, connection: Websocket): if auth := (connection.auth or config.WS_AUTHENTICATION): if inspect.isclass(auth): auth = auth() try: connection.user = await auth(connection) except BaseError as e: connection.change_state(state='Rejected', message=e.detail) await connection.close() @classmethod async def handle_permissions(cls, connection: Websocket): permissions = connection.permissions if permissions is not None and not isinstance(permissions, list): permissions = [permissions] if permissions: for perm in permissions: if inspect.isclass(perm): perm = perm() if await perm(connection) is False: connection.change_state(state='Rejected', message='Permission Denied') await connection.close() class Websocket(BaseRequest): auth: Callable | None = None permissions: list[Callable] | Callable | None = None state: str = 'Connected' _connection_id: str = '' _is_rejected: bool = False def __init_subclass__(cls, **kwargs): if cls.__module__ != 'panther.websocket': config.HAS_WS = True async def connect(self, **kwargs) -> None: pass async def receive(self, data: str | bytes) -> None: pass async def accept(self, subprotocol: str | None = None, headers: dict | None = None) -> None: await self.asgi_send({'type': 'websocket.accept', 'subprotocol': subprotocol, 'headers': headers or {}}) await config.WEBSOCKET_CONNECTIONS.connection_accepted(connection=self) async def send(self, data: any = None) -> None: logger.debug(f'Sending WS Message to {self.connection_id}') if isinstance(data, bytes): await self.send_bytes(bytes_data=data) elif isinstance(data, str): await self.send_text(text_data=data) else: await self.send_text(text_data=json.dumps(data).decode()) async def send_text(self, text_data: str) -> None: await self.asgi_send({'type': 'websocket.send', 'text': text_data}) async def send_bytes(self, bytes_data: bytes) -> None: await self.asgi_send({'type': 'websocket.send', 'bytes': bytes_data}) async def close(self, code: int = status.WS_1000_NORMAL_CLOSURE, reason: str = '') -> None: await self.asgi_send({'type': 'websocket.close', 'code': code, 'reason': reason}) await config.WEBSOCKET_CONNECTIONS.connection_closed(connection=self, from_server=True) @property def connection_id(self) -> str: if self.is_connected: return self._connection_id logger.error('You should first `self.accept()` the connection then use the `self.connection_id`') @property def is_connected(self) -> bool: return bool(self._connection_id) @property def is_rejected(self) -> bool: return self._is_rejected def change_state(self, state: Literal['Accepted', 'Closed', 'Rejected'], message: str = ''): self.state = state if message: message = f' | {message}' if self.is_connected: message = f' | {self.connection_id}{message}' logger.debug(f'WS {self.path} --> {state}{message}') ================================================ FILE: panther/caching.py ================================================ import logging from collections import namedtuple from datetime import datetime, timedelta import orjson as json from panther.db.connections import redis from panther.request import Request from panther.response import Response from panther.utils import generate_hash_value_from_string, round_datetime logger = logging.getLogger('panther') caches: dict[str, tuple[bytes, dict, int]] = {} CachedResponse = namedtuple('CachedResponse', ['data', 'headers', 'status_code']) def api_cache_key(request: Request, duration: timedelta | None = None) -> str: client = (request.user and request.user.id) or request.client.ip query_params_hash = generate_hash_value_from_string(request.scope['query_string'].decode('utf-8')) key = f'{client}-{request.path}-{query_params_hash}-{request.validated_data}' if duration: time = round_datetime(datetime.now(), duration) return f'{time}-{key}' return key async def get_response_from_cache(*, request: Request, duration: timedelta) -> CachedResponse | None: """ If redis.is_connected: Get Cached Data From Redis else: Get Cached Data From Memory """ if redis.is_connected: key = api_cache_key(request=request) data = (await redis.get(key) or b'{}').decode() if value := json.loads(data): return CachedResponse(data=value[0].encode(), headers=value[1], status_code=value[2]) else: key = api_cache_key(request=request, duration=duration) if value := caches.get(key): return CachedResponse(*value) async def set_response_in_cache(*, request: Request, response: Response, duration: timedelta | int) -> None: """ If redis.is_connected: Cache The Data In Redis else: Cache The Data In Memory """ if redis.is_connected: key = api_cache_key(request=request) cache_data: tuple[str, dict, int] = (response.body.decode(), response.headers, response.status_code) await redis.set(key, json.dumps(cache_data), ex=duration) else: key = api_cache_key(request=request, duration=duration) caches[key] = (response.body, response.headers, response.status_code) logger.info('`cache` is not very accurate when `redis` is not connected.') ================================================ FILE: panther/cli/__init__.py ================================================ ================================================ FILE: panther/cli/create_project_command.py ================================================ from collections.abc import Callable from pathlib import Path from rich import print as rich_print from rich.console import Console from rich.progress import ProgressBar from rich.prompt import Prompt from panther import version from panther.cli.template import ( AUTHENTICATION_PART, BASE_DIR_PART, DATABASE_MONGODB_PART, DATABASE_PANTHERDB_PART, IMPORT_COMPLETE_LOAD_ENV_PART, IMPORT_LOAD_ENV_PART, IMPORT_PATH_PART, REDIS_PART, SECRET_KEY_PART, SINGLE_FILE_TEMPLATE, TEMPLATE, USER_MODEL_PART, ) from panther.cli.utils import cli_error class CreateProject: # ERASE_LINE = 100 * ' ' ERASE_LINE = '\x1b[2K' # REMOVE_LAST_LINE = f'\033[1A[{ERASE_LINE}' REMOVE_LAST_LINE = f'\x1b[1A{ERASE_LINE}' def __init__(self): self.console = Console() self.input_console = Console(style='bold magenta') self.project_name = '' self.base_directory = '.' self.database = '0' self.database_encryption = False self.redis = False self.authentication = False self.monitoring = True self.log_queries = True self.single_file = False self.questions = [ { 'field': 'project_name', 'message': 'Project Name', 'validation_func': lambda x: x != '', 'error_message': "'{}' is not valid, Can't be empty.", }, { 'field': 'base_directory', 'message': 'Directory (default is ./)', 'validation_func': self._check_all_directories, 'error_message': '"{}" directory already exists.', 'show_validation_error': True, }, { 'field': 'single_file', 'message': 'Do you want Single-File project', 'is_boolean': True, }, { 'field': 'database', 'message': ' 0: PantherDB (File-Base, No Requirements)\n 1: MongoDB (Required `motor`)\n 2: No Database\nChoose your database (default is 0)', 'validation_func': lambda x: x in ['0', '1', '2'], 'error_message': "Invalid choice, '{}' not in ['0', '1', '2']", }, { 'field': 'redis', 'message': 'Do you want to use Redis (Required `redis`)', 'is_boolean': True, }, { 'field': 'authentication', 'message': 'Do you want to use JWT Authentication (Required `python-jose`)', 'is_boolean': True, }, ] self.progress_len = len(self.questions) self.bar = ProgressBar(total=self.progress_len, width=40) def create(self, args: list) -> None: # Get Project Name if len(args) == 0: try: self.collect_creation_data() except KeyboardInterrupt: return self.console.print('\nKeyboardInterrupt', style='bold red') else: self.project_name = args[0] # Get Base Directory self.base_directory: str = '.' if len(args) > 1: self.base_directory = args[1] existence = self._check_all_directories(self.base_directory, return_error=True) if existence is not True: return cli_error(f'"{existence}" directory already exists.') self.project_name = self.project_name.lower().replace(' ', '_') self.base_directory = self.base_directory.replace(' ', '_') template = SINGLE_FILE_TEMPLATE if self.single_file else TEMPLATE # Create Base Directory if self.base_directory != '.': Path(self.base_directory).mkdir() for file_name, data in template.items(): path = f'{self.base_directory}/{file_name}' if isinstance(data, str): # Create File self._create_file(path=path, data=data) else: # Create Sub Directory Path(path).mkdir() # Create Files of Sub Directory for sub_file_name, sub_data in data.items(): inner_path = f'{path}/{sub_file_name}' self._create_file(path=inner_path, data=sub_data) def _create_file(self, *, path: str, data: str): base_dir_part = BASE_DIR_PART if self.authentication or self.database == '1' else '' import_load_env_part = IMPORT_LOAD_ENV_PART if self.authentication else '' import_complete_load_env_part = IMPORT_COMPLETE_LOAD_ENV_PART if self.authentication else '' import_path_part = IMPORT_PATH_PART if self.authentication or self.database == '1' else '' user_model_part = USER_MODEL_PART if self.authentication else '' authentication_part = AUTHENTICATION_PART if self.authentication else '' secret_key_part = SECRET_KEY_PART if self.authentication else '' redis_part = REDIS_PART if self.redis else '' if self.database == '0': database_part = DATABASE_PANTHERDB_PART elif self.database == '1': database_part = DATABASE_MONGODB_PART else: database_part = '' data = data.replace('{IMPORT_LOAD_ENV}', import_load_env_part) data = data.replace('{IMPORT_COMPLETE_LOAD_ENV}', import_complete_load_env_part) data = data.replace('{IMPORT_PATH}', import_path_part) data = data.replace('{BASE_DIR}', base_dir_part) data = data.replace('{SECRET_KEY}', secret_key_part) data = data.replace('{USER_MODEL}', user_model_part) data = data.replace('{AUTHENTICATION}', authentication_part) data = data.replace('{DATABASE}', database_part) data = data.replace('{REDIS}', redis_part) data = data.replace('{PROJECT_NAME}', self.project_name) data = data.replace('{PANTHER_VERSION}', version()) with Path(path).open('x', encoding='utf-8') as file: file.write(data) def collect_creation_data(self): self.progress(0) for i, question in enumerate(self.questions): # Clean Question Data field_name = question.pop('field') question['default'] = getattr(self, field_name) is_boolean = question.pop('is_boolean', False) convert_output = str # Do Nothing if is_boolean: question['message'] += f' (default is {self._to_str(question["default"])})' question['validation_func'] = self._is_boolean question['error_message'] = "Invalid choice, '{}' not in ['y', 'n']" convert_output = self._to_boolean # Check Question Condition if 'condition' in question and eval(question.pop('condition')) is False: print(flush=True) # Ask Question else: setattr(self, field_name, convert_output(self.ask(**question))) self.progress(i + 1) def ask( self, message: str, default: str | bool, error_message: str, validation_func: Callable, show_validation_error: bool = False, ) -> str: value = Prompt.ask(message, console=self.input_console).lower() or default while not validation_func(value): # Remove the last line, show error message and ask again [print(end=self.REMOVE_LAST_LINE, flush=True) for _ in range(message.count('\n') + 1)] error = validation_func(value, return_error=True) if show_validation_error else value self.console.print(error_message.format(error), style='bold red') value = Prompt.ask(message, console=self.input_console).lower() or default print(end=self.REMOVE_LAST_LINE, flush=True) [print(end=self.REMOVE_LAST_LINE, flush=True) for _ in range(message.count('\n'))] return value def progress(self, step: int, /, extra_rows: int = 0): for i in range(extra_rows + 3 if step else 0): print(self.REMOVE_LAST_LINE, flush=True, end='\r') self.bar.update(step) message = 'Created Successfully' if step == self.progress_len else 'Creating Project' rich_print(f'[b]{message:<21}[/b]', end='', flush=True) rich_print(self.bar, flush=True) print('\n', flush=True) @classmethod def _to_boolean(cls, _input: str) -> bool: return _input in ['y', True] @classmethod def _is_boolean(cls, _input: str) -> bool: return _input in ['y', 'n', True, False] @classmethod def _to_str(cls, _input: bool) -> str: return 'y' if _input else 'n' @classmethod def _check_all_directories(cls, base_directory: str, return_error: bool = False) -> str | bool: """Return False or directory_name means that the directory exist.""" if base_directory != '.' and Path(base_directory).is_dir(): return base_directory if return_error else False for file_name, data in TEMPLATE.items(): sub_directory = f'{base_directory}/{file_name}' if Path(sub_directory).exists(): return sub_directory if return_error else False if isinstance(data, dict): for sub_file_name in data: file_path = f'{sub_directory}/{sub_file_name}' if Path(file_path).exists(): return file_path if return_error else False return True create = CreateProject().create ================================================ FILE: panther/cli/create_user_command.py ================================================ import importlib.util import os import sys from rich import print as rich_print from rich.console import Console from rich.prompt import Prompt from panther.cli.utils import cli_error from panther.configs import config from panther.db.models import BaseUser from panther.utils import run_coroutine def get_password(prompt: str) -> str: rich_print(f'[b]{prompt}: [/b]', end='', flush=True) password = '' if sys.platform == 'win32': import msvcrt while True: ch = msvcrt.getch() if ch in (b'\r', b'\n'): print() break elif ch == b'\x08': # Backspace if password: password = password[:-1] sys.stdout.write('\b \b') sys.stdout.flush() elif ch == b'\x03': # Ctrl+C raise KeyboardInterrupt else: try: char = ch.decode() password += char sys.stdout.write('*') sys.stdout.flush() except UnicodeDecodeError: continue else: import termios import tty fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: tty.setraw(fd) while True: ch = sys.stdin.read(1) if ch in ('\r', '\n'): print() break elif ch == '\x7f': # Backspace if password: password = password[:-1] sys.stdout.write('\b \b') sys.stdout.flush() elif ch == '\x03': # Ctrl+C raise KeyboardInterrupt else: password += ch sys.stdout.write('*') sys.stdout.flush() finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) return password def get_username(prompt: str, user_model: BaseUser, username_field: str): console = Console(style='bold') username = Prompt.ask(prompt=prompt, console=console) while run_coroutine(user_model.exists({username_field: username})): console.print(f'{user_model.__name__} with this {username_field} already exists.', style='bold red') username = Prompt.ask(prompt=prompt, console=console) return username def load_application_file(file_location: str): file_name = file_location.removesuffix('.py') script_path = os.path.abspath(path=file_location) script_dir = os.path.dirname(script_path) if script_dir not in sys.path: sys.path.insert(0, script_dir) spec = importlib.util.spec_from_file_location(name=file_name, location=file_location) mod = importlib.util.module_from_spec(spec=spec) spec.loader.exec_module(module=mod) def create_user(args) -> None: if len(args) == 0: return cli_error( 'Not Enough Arguments, Give me a file path that contains `Panther()` app.\n' ' * Make sure to run `panther createuser` in the same directory as that file!\n' ' * Example: `panther createuser main.py`', ) elif len(args) != 1: return cli_error('Too Many Arguments.') load_application_file(file_location=args[0]) # Initialization User: BaseUser = config.USER_MODEL user_model_name = User.__name__ username_field = User.USERNAME_FIELD console = Console() try: username = get_username(f'Enter your `{username_field}`', user_model=User, username_field=username_field) password = get_password('Enter your `password`') except KeyboardInterrupt: console.print('\nKeyboard Interrupt', style='bold red') return # Creating User user = run_coroutine(User.insert_one({username_field: username})) run_coroutine(user.set_password(password=password)) console.print(f'\n{user_model_name}({username_field}={username}) Created Successfully.', style='bold green') ================================================ FILE: panther/cli/main.py ================================================ import logging import sys from panther import version as panther_version from panther.cli.create_project_command import create from panther.cli.create_user_command import create_user from panther.cli.monitor_command import monitor from panther.cli.run_command import run from panther.cli.utils import cli_error, print_help_message logger = logging.getLogger('panther') def shell(args) -> None: if len(args) == 0: return cli_error( 'Not Enough Arguments, Give me a file path that contains `Panther()` app.\n' ' * Make sure to run `panther shell` in the same directory as that file!\n' ' * Example: `panther shell main.py`', ) elif len(args) != 1: return cli_error('Too Many Arguments.') package = args[0].removesuffix('.py') try: from IPython import start_ipython start_ipython(('--gui', 'asyncio', '-c', f'"import {package}"', '-i')) except ImportError: logger.error('Make sure `ipython` is installed -> Hint: `pip install ipython`') def version() -> None: print(panther_version()) def start() -> None: args = sys.argv[2:] match len(sys.argv) > 1 and sys.argv[1]: case 'h' | 'help' | '-h' | '--help': print_help_message() case 'create': create(args) case 'createuser': create_user(args) case 'run': run(args) case 'shell': shell(args) case 'monitor': monitor() case 'version' | '--version': version() case _: cli_error('Invalid Arguments.') ================================================ FILE: panther/cli/monitor_command.py ================================================ import contextlib import logging import os import platform import signal from collections import deque from pathlib import Path from rich import box from rich.align import Align from rich.console import Group from rich.live import Live from rich.panel import Panel from rich.table import Table from panther.cli.utils import import_error from panther.configs import config from panther.middlewares.monitoring import WebsocketMonitoringMiddleware with contextlib.suppress(ImportError): from watchfiles import watch logger = logging.getLogger('panther') class Monitoring: def __init__(self): self.rows = deque() self.monitoring_log_file = Path(config.BASE_DIR / 'logs' / 'monitoring.log') def monitor(self) -> None: if error := self.initialize(): # Don't continue if initialize() has error logger.error(error) return with ( self.monitoring_log_file.open() as f, Live( self.generate_table(), vertical_overflow='visible', screen=True, ) as live, contextlib.suppress(KeyboardInterrupt), ): f.readlines() # Set cursor at the end of the file if platform.system() == 'Windows': watching = watch(self.monitoring_log_file, force_polling=True) else: watching = watch(self.monitoring_log_file) for _ in watching: for line in f.readlines(): # line = datetime | method | path | ip:port | response_time(seconds) | status columns = line.split('|') if len(columns) != 2: # Can be `datetime | ` on initiation. columns[4] = self._clean_response_time(columns[4]) self.rows.append(columns) live.update(self.generate_table()) def initialize(self) -> str: # Check requirements try: from watchfiles import watch except ImportError as e: return import_error(e, package='watchfiles').args[0] # Check log file if not self.monitoring_log_file.exists(): return ( f'`{self.monitoring_log_file}` file not found. ' f'Make sure `panther.middlewares.monitoring.MonitoringMiddleware` is in your `MIDDLEWARES`.\n' f'documentation: https://PantherPy.github.io/middlewares/#monitoring-middleware' ) # Initialize Deque self.update_rows() # Register the signal handler if platform.system() != 'Windows': signal.signal(signal.SIGWINCH, self.update_rows) def generate_table(self) -> Panel: # 2023-03-24 01:42:52 | GET | /user/317/ | 127.0.0.1:48856 | 0.0366 ms | 200 table = Table(box=box.MINIMAL_DOUBLE_HEAD) table.add_column('Datetime', justify='center', style='magenta', no_wrap=True) table.add_column('Method', justify='center', style='cyan', no_wrap=True) table.add_column('Path', justify='center', style='cyan', no_wrap=True) table.add_column('Client', justify='center', style='cyan') table.add_column('Response Time', justify='center', style='blue') table.add_column('Status', justify='center', style='blue', no_wrap=True) for row in self.rows: table.add_row(*row) return Panel( Align.center(Group(table)), box=box.ROUNDED, padding=(0, 2), title='Monitoring', border_style='bright_blue', ) def update_rows(self, *args, **kwargs): # Top = -4, Bottom = -2 --> -6 # Print of each line needs two line, so --> x // 2 lines = (os.get_terminal_size()[1] - 6) // 2 self.rows = deque(self.rows, maxlen=lines) @classmethod def _clean_response_time(cls, response_time: str) -> str: if response_time == WebsocketMonitoringMiddleware.ConnectedConnectionTime: return response_time response_time = float(response_time) time_unit = ' s' if response_time < 0.01: response_time = response_time * 1_000 time_unit = 'ms' elif response_time >= 60: response_time = response_time / 60 time_unit = ' m' return f'{round(response_time, 4)} {time_unit}' monitor = Monitoring().monitor ================================================ FILE: panther/cli/run_command.py ================================================ import contextlib import sys import uvicorn from panther.cli.utils import cli_error def run(args: list[str]) -> None: try: with contextlib.suppress(KeyboardInterrupt): # First arg will be ignored by @Click, so ... sys.argv = ['main'] + args uvicorn.main() except TypeError as e: cli_error(e) ================================================ FILE: panther/cli/template.py ================================================ from datetime import datetime from panther import version from panther.utils import generate_secret_key apis_py = """from datetime import timedelta from panther import status from panther.app import API from panther.response import Response from panther.utils import timezone_now # More Info: https://PantherPy.GitHub.io/api/ @API(cache=timedelta(minutes=1)) async def hello_world_api(): current_time = timezone_now().isoformat() return Response( data={'message': f'Hello from Panther! 🐾 | {current_time}'}, status_code=status.HTTP_200_OK ) """ models_py = """\""" More Info: https://PantherPy.GitHub.io/model/ \""" from panther.db import Model """ serializers_py = """\""" More Info: https://PantherPy.GitHub.io/serializer/ \""" from panther.serializer import ModelSerializer """ middlewares_py = """\""" More Info: https://PantherPy.GitHub.io/middlewares/ \""" """ app_urls_py = """from app.apis import hello_world_api url_routing = { '/': hello_world_api, } """ configs_py = """\""" {PROJECT_NAME} Project (Generated by Panther on %s) \"""{IMPORT_PATH}{IMPORT_COMPLETE_LOAD_ENV} {BASE_DIR}{SECRET_KEY}{DATABASE}{REDIS}{USER_MODEL}{AUTHENTICATION} # More Info: https://PantherPy.GitHub.io/timezone/ TIMEZONE = 'UTC' # More Info: https://PantherPy.GitHub.io/urls/ URLs = 'core.urls.url_routing' """ % datetime.now().date().isoformat() env = """SECRET_KEY='%s' """ % generate_secret_key() main_py = """from panther import Panther app = Panther(__name__) """ urls_py = """from app.urls import url_routing as app_url_routing url_routing = { '/': app_url_routing, } """ git_ignore = """__pycache__/ .venv/ .idea/ logs/ .env *.pdb """ requirements = """panther==%s """ % version() TEMPLATE = { 'app': { '__init__.py': '', 'apis.py': apis_py, 'models.py': models_py, 'serializers.py': serializers_py, 'urls.py': app_urls_py, }, 'core': { '__init__.py': '', 'configs.py': configs_py, 'middlewares.py': middlewares_py, 'urls.py': urls_py, }, 'main.py': main_py, '.env': env, '.gitignore': git_ignore, 'requirements.txt': requirements, } single_main_py = """\""" {PROJECT_NAME} Project (Generated by Panther on %s) \""" from datetime import timedelta{IMPORT_PATH} from panther import Panther, status from panther.app import API from panther.response import Response from panther.utils import {IMPORT_LOAD_ENV}timezone_now {BASE_DIR}{SECRET_KEY}{DATABASE}{REDIS}{USER_MODEL}{AUTHENTICATION} # More Info: https://PantherPy.GitHub.io/timezone/ TIMEZONE = 'UTC' # More Info: https://PantherPy.GitHub.io/api/ @API(cache=timedelta(minutes=1)) async def hello_world_api(): current_time = timezone_now().isoformat() return Response( data={'message': f'Hello from Panther! 🐾 | {current_time}'}, status_code=status.HTTP_200_OK ) url_routing = { '/': hello_world_api, } app = Panther(__name__, configs=__name__, urls=url_routing) """ % datetime.now().date().isoformat() SINGLE_FILE_TEMPLATE = { 'main.py': single_main_py, '.env': env, '.gitignore': git_ignore, 'requirements.txt': requirements, } DATABASE_PANTHERDB_PART = """ # More Info: https://PantherPy.GitHub.io/database/#pantherdb DATABASE = { 'engine': { 'class': 'panther.db.connections.PantherDBConnection', 'path': BASE_DIR / 'database.pdb', } }""" DATABASE_MONGODB_PART = """ # More Info: https://PantherPy.GitHub.io/database/#mongodb DATABASE = { 'engine': { 'class': 'panther.db.connections.MongoDBConnection', 'host': '127.0.0.1', 'port': 27017, 'database': '{PROJECT_NAME}' } }""" REDIS_PART = """ # More Info: https://PantherPy.GitHub.io/redis/ REDIS = { 'class': 'panther.db.connections.RedisConnection', 'host': '127.0.0.1', 'port': 6379, }""" USER_MODEL_PART = """ # More Info: https://PantherPy.GitHub.io/user_model/ USER_MODEL = 'panther.db.models.BaseUser'""" AUTHENTICATION_PART = """ # More Info: https://PantherPy.GitHub.io/authentications/ AUTHENTICATION = 'panther.authentications.JWTAuthentication'""" SECRET_KEY_PART = """ env = load_env(BASE_DIR / '.env') SECRET_KEY = env['SECRET_KEY']""" BASE_DIR_PART = """ BASE_DIR = Path(__name__).resolve().parent""" IMPORT_PATH_PART = """ from pathlib import Path""" IMPORT_LOAD_ENV_PART = """load_env, """ IMPORT_COMPLETE_LOAD_ENV_PART = """ from panther.utils import load_env""" ================================================ FILE: panther/cli/utils.py ================================================ import logging import platform from rich import print as rprint from panther.configs import Config from panther.exceptions import PantherError logger = logging.getLogger('panther') if platform.system() == 'Windows': h = '|' v = '_' tr = ' ' tl = ' ' br = ' ' bl = ' ' else: h = '│' v = '─' tr = '╮' tl = '╭' br = '╯' bl = '╰' top = f'{tl}{60 * v}{tr}' bottom = f'{bl}{60 * v}{br}' logo = rf"""{top} {h} ____ __ __ {h} {h} /\ _`\ /\ \__/\ \ {h} {h} \ \ \L\ \ __ ___\ \ ,_\ \ \___ __ _ __ {h} {h} \ \ ,__/'__`\ /' _ `\ \ \/\ \ _ `\ /'__`\/\`'__\ {h} {h} \ \ \/\ \L\.\_/\ \/\ \ \ \_\ \ \ \ \/\ __/\ \ \/ {h} {h} \ \_\ \__/.\_\ \_\ \_\ \__\\ \_\ \_\ \____\\ \_\ {h} {h} \/_/\/__/\/_/\/_/\/_/\/__/ \/_/\/_/\/____/ \/_/ {h} {h} {h}""" help_message = rf"""{logo} {h} Usage: panther \[options] {h} {h} {h} {h} Commands: {h} {h} - create \[project_name] \[directory] {h} {h} Create a new Panther project. {h} {h} * Interactive mode if no arguments provided. {h} {h} * Non-interactive if project_name and directory {h} {h} are specified (default directory: .). {h} {h} Example: {h} {h} - `panther create` {h} {h} - `panther create myapp myapp` {h} {h} {h} {h} - run \[options] {h} {h} Run your Panther project using Uvicorn. {h} {h} * app: address of your application. {h} {h} * options: Check `uvicorn --help` for options. {h} {h} * `panther run` is alias of `uvicorn`. {h} {h} Example: `panther run main:app --reload` {h} {h} {h} {h} - shell {h} {h} Start an interactive Python shell with your app. {h} {h} * application_file: path to your main app file. {h} {h} Example: `panther shell main.py` {h} {h} {h} {h} - createuser {h} {h} Create a new user based on USER_MODEl from your, {h} {h} configs. (default is panther.db.models.BaseUser) {h} {h} * application_file: path to your main app file. {h} {h} Example: `panther createuser main.py` {h} {h} {h} {h} - monitor {h} {h} Display real-time request monitoring. {h} {h} {h} {h} - version | --version {h} {h} Display the current version of Panther. {h} {h} {h} {h} - help | h | --help | -h {h} {h} Show this help message and exit. {h} {bottom} """ def import_error(message: str | Exception, package: str | None = None) -> PantherError: msg = str(message) if package: msg += f' -> Hint: `pip install {package}`' return PantherError(msg) def cli_error(message: str | Exception) -> None: logger.error(message) logger.info('Use "panther -h" for more help') def cli_warning(message: str | Exception, hint: str = None) -> None: logger.warning(message) if hint: logger.info(hint) def cli_info(message: str) -> None: logger.info(message) logger.info('Use "panther -h" for more help\n') def print_help_message(): rprint(help_message) def print_info(config: Config): from panther.db.connections import redis mo = config.MONITORING lq = config.LOG_QUERIES bt = config.BACKGROUND_TASKS ws = config.HAS_WS rd = redis.is_connected bd = f'{config.BASE_DIR!s:<41}' if len(bd) > 41: bd = f'{bd[:38]}...' # Monitoring monitor = f'{h} * Run "panther monitor" in another session for Monitoring {h}\n' if config.MONITORING else None # Uvloop uvloop_msg = None if platform.system() != 'Windows': try: import uvloop except ImportError: uvloop_msg = ( f'{h} * You may want to install `uvloop` for better performance {h}\n' f'{h} `pip install uvloop` {h}\n' ) # Gunicorn if Websocket gunicorn_msg = None if config.HAS_WS: try: import gunicorn gunicorn_msg = f'{h} * You have WS, so make sure to run gunicorn with --preload {h}\n' except ImportError: pass # Message info_message = f"""{logo} {h} Redis: {rd} \t {h} {h} Websocket: {ws} \t {h} {h} Monitoring: {mo} \t {h} {h} Log Queries: {lq} \t {h} {h} Background Tasks: {bt} \t {h} {h} Base directory: {bd}{h} """ if monitor: info_message += monitor if uvloop_msg: info_message += uvloop_msg if gunicorn_msg: info_message += gunicorn_msg info_message += bottom rprint(info_message) ================================================ FILE: panther/configs.py ================================================ import typing from collections.abc import Callable from dataclasses import dataclass, field from datetime import timedelta from pathlib import Path import jinja2 from pydantic import BaseModel as PydanticBaseModel class JWTConfig: def __init__( self, key: str, algorithm: str = 'HS256', life_time: timedelta | int = timedelta(days=1), refresh_life_time: timedelta | int | None = None, ): self.key = key self.algorithm = algorithm self.life_time = int(life_time.total_seconds()) if isinstance(life_time, timedelta) else life_time if refresh_life_time: if isinstance(refresh_life_time, timedelta): self.refresh_life_time = refresh_life_time.total_seconds() else: self.refresh_life_time = refresh_life_time else: self.refresh_life_time = self.life_time * 2 def __eq__(self, other): return bool( self.key == other.key and self.algorithm == other.algorithm and self.life_time == other.life_time and self.refresh_life_time == other.refresh_life_time, ) class QueryObservable: observers = [] @classmethod def observe(cls, observer): cls.observers.append(observer) @classmethod def update(cls): for observer in cls.observers: observer._reload_bases(parent=config.QUERY_ENGINE) @dataclass class Config: BASE_DIR: Path = Path() MONITORING: bool = False LOG_QUERIES: bool = False THROTTLING = None # type: panther.throttling.Throttle SECRET_KEY: str | None = None HTTP_MIDDLEWARES: list = field(default_factory=list) # Middlewares stored in reversed order WS_MIDDLEWARES: list = field(default_factory=list) # Middlewares stored in reversed order USER_MODEL: type[PydanticBaseModel] | None = None AUTHENTICATION: type[PydanticBaseModel] | None = None WS_AUTHENTICATION: type[PydanticBaseModel] | None = None JWT_CONFIG: JWTConfig | None = None MODELS: list = field(default_factory=list) FLAT_URLS: dict = field(default_factory=dict) URLS: dict = field(default_factory=dict) WEBSOCKET_CONNECTIONS: Callable | None = None BACKGROUND_TASKS: bool = False HAS_WS: bool = False TIMEZONE: str = 'UTC' TEMPLATES_DIR: str | list[str] = '.' JINJA_ENVIRONMENT: jinja2.Environment | None = None AUTO_REFORMAT: bool = False QUERY_ENGINE: Callable | None = None DATABASE: Callable | None = None def refresh(self): """ Reset built-in fields and remove any custom (non-built-in) attributes. * In some tests we need to `refresh` the `config` values """ builtin_fields = set(self.__dataclass_fields__) current_fields = set(self.__dict__) # Reset built-in fields for field_name in builtin_fields: field_def = self.__dataclass_fields__[field_name] default = field_def.default_factory() if callable(field_def.default_factory) else field_def.default setattr(self, field_name, default) # Delete custom attributes for field_name in current_fields - builtin_fields: delattr(self, field_name) def vars(self) -> dict[str, typing.Any]: """Return all config variables (built-in + custom).""" return dict(self.__dict__) def __setattr__(self, key, value): super().__setattr__(key, value) if key == 'QUERY_ENGINE' and value: QueryObservable.update() def __getattr__(self, item: str): try: return object.__getattribute__(self, item) except AttributeError: return None def __setitem__(self, key, value): setattr(self, key.upper(), value) def __getitem__(self, item): return getattr(self, item.upper()) config = Config() ================================================ FILE: panther/db/__init__.py ================================================ from panther.db.models import Model # noqa: F401 ================================================ FILE: panther/db/connections.py ================================================ import asyncio import contextlib from abc import abstractmethod from typing import TYPE_CHECKING, Any from pantherdb import PantherDB from panther.cli.utils import import_error from panther.configs import config from panther.utils import Singleton try: from redis.asyncio import Redis as _Redis except ImportError: # This '_Redis' is not going to be used, # If user really wants to use redis, # we are going to force him to install it in `panther._load_configs.load_redis` _Redis = type('_Redis', (), {'__new__': lambda x: x}) if TYPE_CHECKING: from pymongo.database import Database class BaseDatabaseConnection: def __init__(self, *args, **kwargs): """Initialized in application startup""" self.init(*args, **kwargs) @abstractmethod def init(self, *args, **kwargs): pass @property @abstractmethod def session(self): pass class MongoDBConnection(BaseDatabaseConnection): def init( self, host: str = 'localhost', port: int = 27017, document_class: dict[str, Any] | None = None, tz_aware: bool | None = None, connect: bool | None = None, type_registry=None, # type: bson.codec_options.TypeRegistry database: str | None = None, **kwargs: Any, ) -> None: try: from motor.motor_asyncio import AsyncIOMotorClient except ModuleNotFoundError as e: raise import_error(e, package='motor') with contextlib.suppress(ImportError): import uvloop asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) self._client: AsyncIOMotorClient = AsyncIOMotorClient( host=host, port=port, document_class=document_class, tz_aware=tz_aware, connect=connect, type_registry=type_registry, **kwargs, ) self._database: Database = self._client.get_database(name=database) @property def session(self): return self._database @property def client(self): return self._client class PantherDBConnection(BaseDatabaseConnection): def init(self, path: str | None = None, encryption: bool = False): params = {'db_name': path, 'return_dict': True, 'return_cursor': True} if encryption: try: import cryptography except ImportError as e: raise import_error(e, package='cryptography') params['secret_key'] = config.SECRET_KEY.encode() self._connection: PantherDB = PantherDB(**params) @property def session(self): return self._connection @property def client(self): return self._connection class DatabaseConnection(Singleton): @property def session(self): return config.DATABASE.session @property def is_defined(self): return bool(config.DATABASE) @property def client(self): return config.DATABASE.client class RedisConnection(Singleton, _Redis): is_connected: bool = False def __init__( self, init: bool = False, host: str = 'localhost', port: int = 6379, db: int = 0, **kwargs, ): if init: self.host = host self.port = port self.db = db self.kwargs = kwargs super().__init__(host=host, port=port, db=db, **kwargs) self.is_connected = True self.sync_ping() def sync_ping(self): from redis import Redis Redis(host=self.host, port=self.port, socket_timeout=3, **self.kwargs).ping() async def execute_command(self, *args, **options): if self.is_connected: return await super().execute_command(*args, **options) msg = '`REDIS` is not found in `configs`' raise ValueError(msg) def create_connection_for_websocket(self) -> _Redis: if not hasattr(self, 'websocket_connection'): self.websocket_connection = _Redis(host=self.host, port=self.port, db=0, **self.kwargs) return self.websocket_connection db: DatabaseConnection = DatabaseConnection() redis: RedisConnection = RedisConnection() ================================================ FILE: panther/db/cursor.py ================================================ from __future__ import annotations from sys import version_info from panther.utils import run_coroutine try: from pymongo.cursor import Cursor as _Cursor except ImportError: # This '_Cursor' is not going to be used, # If user really wants to use it, # we are going to force him to install it in `panther.db.connections.MongoDBConnection.init` _Cursor = type('_Cursor', (), {}) if version_info >= (3, 11): from typing import Self else: from typing import TypeVar Self = TypeVar('Self', bound='BaseMongoDBQuery') class Cursor(_Cursor): models = {} def __init__(self, collection, *args, cls=None, **kwargs): # cls.__name__ and collection.name are equal. if cls: self.models[collection.name] = cls self.cls = cls self.filter = kwargs['filter'] else: self.cls = self.models[collection.name] super().__init__(collection, *args, **kwargs) def __aiter__(self) -> Self: return self def __iter__(self) -> Self: return self async def next(self) -> Self: return await self.cls._create_model_instance(document=super().next()) async def __anext__(self) -> Self: try: return await self.cls._create_model_instance(document=super().next()) except StopIteration: raise StopAsyncIteration def __next__(self) -> Self: try: return run_coroutine(self.cls._create_model_instance(document=super().next())) except StopIteration: raise def __getitem__(self, index: int | slice) -> Cursor[Self] | Self: document = super().__getitem__(index) if isinstance(document, dict): return run_coroutine(self.cls._create_model_instance(document=document)) return document ================================================ FILE: panther/db/models.py ================================================ import contextlib import os import sys from datetime import datetime from typing import Annotated, ClassVar from pydantic import BaseModel as PydanticBaseModel from pydantic import Field, PlainSerializer, WrapValidator from panther.configs import config from panther.db.queries import Query from panther.utils import URANDOM_SIZE, scrypt, timezone_now with contextlib.suppress(ImportError): # Only required if user wants to use mongodb import bson if sys.version_info >= (3, 11): from typing import Self else: from typing import TypeVar Self = TypeVar('Self', bound='BaseUser') def validate_object_id(value, handler): if config.DATABASE.__class__.__name__ != 'MongoDBConnection': return str(value) if isinstance(value, bson.ObjectId): return value try: return bson.ObjectId(value) except Exception as e: msg = 'Invalid ObjectId' raise ValueError(msg) from e ID = Annotated[str, WrapValidator(validate_object_id), PlainSerializer(lambda x: str(x), return_type=str)] | None class Model(PydanticBaseModel, Query): def __init_subclass__(cls, **kwargs): if cls.__module__ == 'panther.db.models' and cls.__name__ == 'BaseUser': return config.MODELS.append(cls) id: ID = None @property def _id(self): """ Returns the actual ID value: - For MongoDB: returns ObjectId - For PantherDB: returns str """ if config.DATABASE.__class__.__name__ == 'MongoDBConnection': return bson.ObjectId(self.id) return self.id class BaseUser(Model): username: str password: str = Field('', max_length=64) last_login: datetime | None = None date_created: datetime | None = None USERNAME_FIELD: ClassVar = 'username' @classmethod def insert_one(cls, _document: dict | None = None, /, **kwargs) -> Self: kwargs['date_created'] = timezone_now() return super().insert_one(_document, **kwargs) async def login(self) -> dict: """Return dict of access and refresh tokens""" await self.update(last_login=timezone_now()) return await config.AUTHENTICATION.login(user=self) async def refresh_tokens(self) -> dict: """Return dict of new access and refresh tokens""" return await config.AUTHENTICATION.refresh(user=self) async def logout(self) -> dict: return await config.AUTHENTICATION.logout(user=self) async def set_password(self, password: str): """ URANDOM_SIZE = 16 char --> salt = 16 bytes salt.hex() = 32 char derived_key = 32 char """ salt = os.urandom(URANDOM_SIZE) derived_key = scrypt(password=password, salt=salt, digest=True) hashed_password = f'{salt.hex()}{derived_key}' await self.update(password=hashed_password) def check_password(self, password: str) -> bool: size = URANDOM_SIZE * 2 salt = self.password[:size] stored_hash = self.password[size:] derived_key = scrypt(password=password, salt=bytes.fromhex(salt), digest=True) return derived_key == stored_hash ================================================ FILE: panther/db/queries/__init__.py ================================================ from panther.db.queries.queries import Query ================================================ FILE: panther/db/queries/base_queries.py ================================================ from __future__ import annotations import operator import types import typing from abc import abstractmethod from collections.abc import Iterator from datetime import datetime from functools import reduce from sys import version_info from typing import Any, Union, get_args, get_origin from pantherdb import Cursor from pydantic import BaseModel, ValidationError from pydantic_core._pydantic_core import ValidationError from panther._utils import detect_mime_type from panther.db.cursor import Cursor from panther.db.utils import prepare_id_for_query from panther.exceptions import DatabaseError from panther.file_handler import File if version_info >= (3, 11): from typing import Self else: from typing import TypeVar Self = TypeVar('Self', bound='BaseQuery') class BaseQuery: @classmethod def _merge(cls, *args, is_mongo: bool = False) -> dict: prepare_id_for_query(*args, is_mongo=is_mongo) return reduce(operator.ior, filter(None, args), {}) @classmethod def _clean_error_message(cls, validation_error: ValidationError, is_updating: bool = False) -> str: error = ', '.join( '{field}="{error}"'.format(field='.'.join(str(loc) for loc in e['loc']), error=e['msg']) for e in validation_error.errors() if not is_updating or e['type'] != 'missing' ) return f'{cls.__name__}({error})' if error else '' @classmethod def _validate_data(cls, *, data: dict, is_updating: bool = False): """Validate document before inserting to collection""" try: cls(**data) except ValidationError as validation_error: if error := cls._clean_error_message(validation_error=validation_error, is_updating=is_updating): raise DatabaseError(error) @classmethod def _get_annotation_type(cls, annotation: Any) -> type | None: """ Extracts the underlying, non-optional type from a type annotation. Handles basic types, Pydantic BaseModels, lists, and unions (optionals). Returns None if no single underlying type can be determined (e.g., for list[NoneType]). Raises DatabaseError for unsupported annotations. """ origin = get_origin(annotation) # Handle list[T] and Union[T, None] (T | None or typing.Union[T, None]) if origin is list or origin is types.UnionType or origin is Union: # Extracts the first non-None type from a tuple of type arguments. for arg in get_args(annotation): if arg is not type(None): return arg return None # Handle basic types (str, int, bool, dict, datetime) and Pydantic.BaseModel and File subclasses try: if isinstance(annotation, type) and ( annotation in (str, int, bool, dict, float, datetime) or issubclass(annotation, (BaseModel, File)) ): return annotation except TypeError: # issubclass(dict[str, tests.test_database_advance.Book], BaseModel) in Python3.10 gives us TypeError and # works just fine in other versions, so we have to raise DatabaseError in this situation anyway. pass raise DatabaseError(f'Panther does not support {annotation} as a field type for unwrapping.') @classmethod async def _create_list(cls, field_type: type, value: Any) -> Any: from panther.db import Model # `field_type` is the expected type of items in the list (e.g., int, Model, list[str]) # `value` is a single item from the input list that needs processing. # Handles list[list[int]], list[dict[str,int]] etc. if isinstance(field_type, (types.GenericAlias, typing._GenericAlias)): element_type = cls._get_annotation_type(field_type) # Unwrap further (e.g. list[str] -> str) if element_type is None: raise DatabaseError(f'Cannot determine element type for generic list item: {field_type}') if not isinstance(value, list): # Or check if iterable, matching the structure raise DatabaseError(f'Expected a list for nested generic type {field_type}, got {type(value)}') return [await cls._create_list(field_type=element_type, value=item) for item in value] # Make sure Model condition is before BaseModel. if isinstance(field_type, type) and issubclass(field_type, Model): # `value` is assumed to be an ID for the Model instance. return await field_type.first(id=value) if isinstance(field_type, type) and issubclass(field_type, BaseModel): if not isinstance(value, dict): raise DatabaseError(f'Expected a dictionary for BaseModel {field_type.__name__}, got {type(value)}') return { field_name: await cls._create_field(model=field_type, field_name=field_name, value=value[field_name]) for field_name in value } # `value` is a primitive type (str, int, etc.) return value @classmethod async def _create_field(cls, model: type, field_name: str, value: Any) -> Any: from panther.db import Model # Handle primary key field directly if field_name == 'id': return value if field_name not in model.model_fields: # Field from input data is not defined in the model. # Pydantic's `extra` config on the model will handle this upon instantiation. return value field_annotation = model.model_fields[field_name].annotation unwrapped_type = cls._get_annotation_type(field_annotation) if unwrapped_type is None: raise DatabaseError( f"Could not determine a valid underlying type for field '{field_name}' " f'with annotation {field_annotation} in model {model.__name__}.', ) if get_origin(field_annotation) is list: # Or check for general iterables if applicable if not isinstance(value, list): raise DatabaseError( f"Field '{field_name}' expects a list, got {type(value)} for model {model.__name__}", ) return [await cls._create_list(field_type=unwrapped_type, value=item) for item in value] # Condition of `File` should be on top of `BaseModel` if isinstance(unwrapped_type, type) and issubclass(unwrapped_type, File): # `value` is assumed to be the path of the File. content_type = detect_mime_type(file_path=value) with open(value, 'rb') as f: return File(file_name=value, content_type=content_type, file=f.read()).model_dump() # Condition of `Model` should be on top of `BaseModel` if isinstance(unwrapped_type, type) and issubclass(unwrapped_type, Model): # `value` is assumed to be an ID for the Model instance. if obj := await unwrapped_type.first(id=value): return obj.model_dump() return None if isinstance(unwrapped_type, type) and issubclass(unwrapped_type, BaseModel): if not isinstance(value, dict): raise DatabaseError( f"Field '{field_name}' expects a dictionary for BaseModel {unwrapped_type.__name__}, " f'got {type(value)} in model {model.__name__}', ) return { nested_field_name: await cls._create_field( model=unwrapped_type, field_name=nested_field_name, value=value[nested_field_name], ) for nested_field_name in unwrapped_type.model_fields if nested_field_name in value } return value @classmethod async def _create_model_instance(cls, document: dict, is_updating: bool = False) -> Self: """Prepares document and creates an instance of the model.""" if '_id' in document: document['id'] = document.pop('_id') processed_document = { field_name: await cls._create_field(model=cls, field_name=field_name, value=field_value) for field_name, field_value in document.items() } try: return cls(**processed_document) except ValidationError as validation_error: if error := cls._clean_error_message(validation_error=validation_error, is_updating=is_updating): raise DatabaseError(error) from validation_error @classmethod async def _clean_value(cls, value: Any) -> dict[str, Any] | list[Any]: from panther.db import Model match value: case None: return None case Model() as model: if model.id in [None, '']: await model.save() # We save full object because user didn't specify the type. return model._id case File() as file: # Write file to disk return file.save() case BaseModel() as model: return { field_name: await cls._clean_value(value=getattr(model, field_name)) for field_name in model.__class__.model_fields } case dict() as d: return {k: await cls._clean_value(value=v) for k, v in d.items()} case list() as l: return [await cls._clean_value(value=item) for item in l] return value @classmethod async def _extract_type(cls, field_name: str) -> Any: if field_name not in cls.model_fields: return None field_annotation = cls.model_fields[field_name].annotation unwrapped_type = cls._get_annotation_type(field_annotation) if ( get_origin(field_annotation) is list and isinstance(unwrapped_type, type) and issubclass(unwrapped_type, BaseModel) ): return list[unwrapped_type] if isinstance(unwrapped_type, type) and issubclass(unwrapped_type, BaseModel): return unwrapped_type @classmethod async def _process_document(cls, document): # 1. Check type expected type and cast to that # 2. Check type of field_value and do the stuff (save() or return ._id) processed_document = {} for field_name, field_value in document.items(): if field_name in ['id', '_id']: continue field_type = await cls._extract_type(field_name) if field_type: if get_origin(field_type) is list: cls_type = cls._get_annotation_type(field_type) value = [cls_type(**v) if isinstance(v, dict) else v for v in field_value] else: if isinstance(field_value, dict): value = field_type(**field_value) else: value = field_value else: value = field_value processed_document[field_name] = await cls._clean_value(value) return processed_document @classmethod @abstractmethod async def find_one(cls, *args, **kwargs) -> Self | None: raise NotImplementedError @classmethod @abstractmethod async def find(cls, *args, **kwargs) -> list[Self] | Cursor: raise NotImplementedError @classmethod @abstractmethod async def first(cls, *args, **kwargs) -> Self | None: raise NotImplementedError @classmethod @abstractmethod async def last(cls, *args, **kwargs): raise NotImplementedError @classmethod @abstractmethod async def aggregate(cls, *args, **kwargs) -> Iterator[dict]: raise NotImplementedError # # # # # Count # # # # # @classmethod @abstractmethod async def count(cls, *args, **kwargs) -> int: raise NotImplementedError # # # # # Insert # # # # # @classmethod @abstractmethod async def insert_one(cls, *args, **kwargs) -> Self: raise NotImplementedError @classmethod @abstractmethod async def insert_many(cls, *args, **kwargs) -> list[Self]: raise NotImplementedError # # # # # Delete # # # # # @abstractmethod async def delete(self) -> None: raise NotImplementedError @classmethod @abstractmethod async def delete_one(cls, *args, **kwargs) -> bool: raise NotImplementedError @classmethod @abstractmethod async def delete_many(cls, *args, **kwargs) -> int: raise NotImplementedError # # # # # Update # # # # # @abstractmethod async def update(self, *args, **kwargs) -> None: raise NotImplementedError @classmethod @abstractmethod async def update_one(cls, *args, **kwargs) -> bool: raise NotImplementedError @classmethod @abstractmethod async def update_many(cls, *args, **kwargs) -> int: raise NotImplementedError ================================================ FILE: panther/db/queries/mongodb_queries.py ================================================ from __future__ import annotations import typing from sys import version_info from panther.db.connections import db from panther.db.cursor import Cursor from panther.db.queries.base_queries import BaseQuery from panther.db.utils import prepare_id_for_query if typing.TYPE_CHECKING: from collections.abc import Iterable, Sequence try: from bson.codec_options import CodecOptions from pymongo.results import InsertManyResult, InsertOneResult except ImportError: # MongoDB-related libraries are not required by default. # If the user intends to use MongoDB, they must install the required dependencies explicitly. # This will be enforced in `panther.db.connections.MongoDBConnection.init`. CodecOptions = type('CodecOptions', (), {}) InsertOneResult = type('InsertOneResult', (), {}) InsertManyResult = type('InsertManyResult', (), {}) if version_info >= (3, 11): from typing import Self else: from typing import TypeVar Self = TypeVar('Self', bound='BaseMongoDBQuery') class BaseMongoDBQuery(BaseQuery): @classmethod def _merge(cls, *args, is_mongo: bool = True) -> dict: return super()._merge(*args, is_mongo=is_mongo) # TODO: https://jira.mongodb.org/browse/PYTHON-4192 # @classmethod # def collection(cls): # return db.session.get_collection(name=cls.__name__, codec_options=CodecOptions(document_class=cls)) # # # # # Find # # # # # @classmethod async def find_one(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: if document := await db.session[cls.__name__].find_one(cls._merge(_filter, kwargs)): return await cls._create_model_instance(document=document) return None @classmethod async def find(cls, _filter: dict | None = None, /, **kwargs) -> Cursor: return Cursor(cls=cls, collection=db.session[cls.__name__].delegate, filter=cls._merge(_filter, kwargs)) @classmethod async def first(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: cursor = await cls.find(_filter, **kwargs) async for result in cursor.sort('_id', 1).limit(-1): return result return None @classmethod async def last(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: cursor = await cls.find(_filter, **kwargs) async for result in cursor.sort('_id', -1).limit(-1): return result return None @classmethod async def aggregate(cls, pipeline: Sequence[dict]) -> Iterable[dict]: return await db.session[cls.__name__].aggregate(pipeline).to_list(None) # # # # # Count # # # # # @classmethod async def count(cls, _filter: dict | None = None, /, **kwargs) -> int: return await db.session[cls.__name__].count_documents(cls._merge(_filter, kwargs)) # # # # # Insert # # # # # @classmethod async def insert_one(cls, document: dict) -> Self: insert_one_result: InsertOneResult = await db.session[cls.__name__].insert_one(document) return insert_one_result.inserted_id @classmethod async def insert_many(cls, documents: Iterable[dict]) -> list[Self]: final_documents = [] results = [] for document in documents: prepare_id_for_query(document, is_mongo=True) final_document = await cls._process_document(document) final_documents.append(final_document) results.append(await cls._create_model_instance(document=final_document)) insert_many_result: InsertManyResult = await db.session[cls.__name__].insert_many(final_documents) for obj, inserted_id in zip(results, insert_many_result.inserted_ids): obj.id = inserted_id return results # # # # # Delete # # # # # @classmethod async def delete_one(cls, _filter: dict | None = None, /, **kwargs) -> bool: result = await db.session[cls.__name__].delete_one(cls._merge(_filter, kwargs)) return bool(result.deleted_count) @classmethod async def delete_many(cls, _filter: dict | None = None, /, **kwargs) -> int: result = await db.session[cls.__name__].delete_many(cls._merge(_filter, kwargs)) return result.deleted_count # # # # # Update # # # # # @classmethod async def update_one(cls, _filter: dict, _update: dict | None = None, /, **kwargs) -> bool: prepare_id_for_query(_filter, is_mongo=True) # Step 1: Merge document parameters # Combine the _update dict with keyword arguments into a single document document = cls._merge(_update, kwargs) # Step 2: Process and validate document # - Validate data types and structure # - Convert Model instances to their IDs for database storage # - Handle File objects by saving to disk and storing file paths # - Process nested objects and relationships final_document = await cls._process_document(document) # Step 3: Create model instance (Validating) # - Retrieve Model instances from database using IDs # - Open File objects from their stored paths # - Build the complete model instance with all relationships await cls._create_model_instance(document=final_document, is_updating=True) # Step 4: Create the query update_query = {} for field, value in final_document.items(): if field.startswith('$'): update_query[field] = value else: if not '$set' in update_query: update_query['$set'] = {} update_query['$set'][field] = value result = await db.session[cls.__name__].update_one(_filter, update_query) return bool(result.matched_count) @classmethod async def update_many(cls, _filter: dict, _update: dict | None = None, /, **kwargs) -> int: prepare_id_for_query(_filter, is_mongo=True) # Step 1: Merge document parameters # Combine the _update dict with keyword arguments into a single document document = cls._merge(_update, kwargs) # Step 2: Process and validate document # - Validate data types and structure # - Convert Model instances to their IDs for database storage # - Handle File objects by saving to disk and storing file paths # - Process nested objects and relationships final_document = await cls._process_document(document) # Step 3: Create model instance (Validating) # - Retrieve Model instances from database using IDs # - Open File objects from their stored paths # - Build the complete model instance with all relationships await cls._create_model_instance(document=final_document, is_updating=True) # Step 4: Create the query update_query = {} for field, value in final_document.items(): if field.startswith('$'): update_query[field] = value else: if not '$set' in update_query: update_query['$set'] = {} update_query['$set'][field] = value result = await db.session[cls.__name__].update_many(_filter, update_query) return result.modified_count ================================================ FILE: panther/db/queries/pantherdb_queries.py ================================================ from __future__ import annotations from collections.abc import Iterable from sys import version_info from pantherdb import Cursor from panther.db.connections import db from panther.db.cursor import Cursor from panther.db.queries.base_queries import BaseQuery from panther.db.utils import prepare_id_for_query from panther.exceptions import DatabaseError if version_info >= (3, 11): from typing import Self else: from typing import TypeVar Self = TypeVar('Self', bound='BasePantherDBQuery') class BasePantherDBQuery(BaseQuery): @classmethod def _merge(cls, *args, is_mongo: bool = False) -> dict: return super()._merge(*args, is_mongo=is_mongo) # # # # # Find # # # # # @classmethod async def find_one(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: if document := db.session.collection(cls.__name__).find_one(**cls._merge(_filter, kwargs)): return await cls._create_model_instance(document=document) return None @classmethod async def find(cls, _filter: dict | None = None, /, **kwargs) -> Cursor: cursor = db.session.collection(cls.__name__).find(**cls._merge(_filter, kwargs)) cursor.response_type = cls._create_model_instance cursor.cls = cls return cursor @classmethod async def first(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: if document := db.session.collection(cls.__name__).first(**cls._merge(_filter, kwargs)): return await cls._create_model_instance(document=document) return None @classmethod async def last(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: if document := db.session.collection(cls.__name__).last(**cls._merge(_filter, kwargs)): return await cls._create_model_instance(document=document) return None @classmethod async def aggregate(cls, *args, **kwargs): msg = 'aggregate() does not supported in `PantherDB`.' raise DatabaseError(msg) from None # # # # # Count # # # # # @classmethod async def count(cls, _filter: dict | None = None, /, **kwargs) -> int: return db.session.collection(cls.__name__).count(**cls._merge(_filter, kwargs)) # # # # # Insert # # # # # @classmethod async def insert_one(cls, document: dict) -> Self: insert_one_result = db.session.collection(cls.__name__).insert_one(**document) return insert_one_result['_id'] @classmethod async def insert_many(cls, documents: Iterable[dict]) -> list[Self]: return [await cls.insert_one(document) for document in documents] # # # # # Delete # # # # # @classmethod async def delete_one(cls, _filter: dict | None = None, /, **kwargs) -> bool: return db.session.collection(cls.__name__).delete_one(**cls._merge(_filter, kwargs)) @classmethod async def delete_many(cls, _filter: dict | None = None, /, **kwargs) -> int: return db.session.collection(cls.__name__).delete_many(**cls._merge(_filter, kwargs)) # # # # # Update # # # # # @classmethod async def update_one(cls, _filter: dict, _update: dict | None = None, /, **kwargs) -> bool: prepare_id_for_query(_filter) # Step 1: Merge document parameters # Combine the _update dict with keyword arguments into a single document document = cls._merge(_update, kwargs) # Step 2: Process and validate document # - Validate data types and structure # - Convert Model instances to their IDs for database storage # - Handle File objects by saving to disk and storing file paths # - Process nested objects and relationships final_document = await cls._process_document(document) # Step 3: Create model instance (Validating) # - Retrieve Model instances from database using IDs # - Open File objects from their stored paths # - Build the complete model instance with all relationships await cls._create_model_instance(document=final_document, is_updating=True) return db.session.collection(cls.__name__).update_one(_filter, **final_document) @classmethod async def update_many(cls, _filter: dict, _update: dict | None = None, /, **kwargs) -> int: prepare_id_for_query(_filter) # Step 1: Merge document parameters # Combine the _update dict with keyword arguments into a single document document = cls._merge(_update, kwargs) # Step 2: Process and validate document # - Validate data types and structure # - Convert Model instances to their IDs for database storage # - Handle File objects by saving to disk and storing file paths # - Process nested objects and relationships final_document = await cls._process_document(document) # Step 3: Create model instance (Validating) # - Retrieve Model instances from database using IDs # - Open File objects from their stored paths # - Build the complete model instance with all relationships await cls._create_model_instance(document=final_document, is_updating=True) return db.session.collection(cls.__name__).update_many(_filter, **final_document) ================================================ FILE: panther/db/queries/queries.py ================================================ import sys from collections.abc import Iterable, Sequence from pantherdb import Cursor as PantherDBCursor from panther.configs import QueryObservable from panther.db.cursor import Cursor from panther.db.queries.base_queries import BaseQuery from panther.db.utils import check_connection, log_query from panther.exceptions import NotFoundAPIError __all__ = ('Query',) if sys.version_info >= (3, 11): from typing import Self else: from typing import TypeVar Self = TypeVar('Self', bound='Query') try: from pymongo.errors import DuplicateKeyError as _PyMongoDuplicateKeyError except ImportError: _PyMongoDuplicateKeyError = None def _is_duplicate_key_error(error: Exception) -> bool: if _PyMongoDuplicateKeyError and isinstance(error, _PyMongoDuplicateKeyError): return True if error.__class__.__name__ == 'DuplicateKeyError': return True return getattr(error, 'code', None) in {11000, 11001, 12582} class Query(BaseQuery): def __init_subclass__(cls, **kwargs): QueryObservable.observe(cls) @classmethod def _reload_bases(cls, parent): if not issubclass(parent, BaseQuery): msg = f'Invalid Query Class: `{parent.__name__}` should be subclass of `BaseQuery`' raise ValueError(msg) if cls.__bases__.count(Query): cls.__bases__ = (*cls.__bases__[: cls.__bases__.index(Query) + 1], parent) else: for kls in cls.__bases__: if kls.__bases__.count(Query): kls.__bases__ = (*kls.__bases__[: kls.__bases__.index(Query) + 1], parent) # # # # # Find # # # # # @classmethod @check_connection @log_query async def find_one(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: """ Get a single document from the database. Example: ------- >>> from app.models import User >>> await User.find_one(id=1, name='Ali') or >>> await User.find_one({'id': 1, 'name': 'Ali'}) or >>> await User.find_one({'id': 1}, name='Ali') """ return await super().find_one(_filter, **kwargs) @classmethod @check_connection @log_query async def find(cls, _filter: dict | None = None, /, **kwargs) -> PantherDBCursor | Cursor: """ Get documents from the database. Example: ------- >>> from app.models import User >>> await User.find(age=18, name='Ali') or >>> await User.find({'age': 18, 'name': 'Ali'}) or >>> await User.find({'age': 18}, name='Ali') """ return await super().find(_filter, **kwargs) @classmethod @check_connection @log_query async def first(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: """ Get the first document from the database. Example: ------- >>> from app.models import User >>> await User.first(age=18, name='Ali') or >>> await User.first({'age': 18, 'name': 'Ali'}) or >>> await User.first({'age': 18}, name='Ali') """ return await super().first(_filter, **kwargs) @classmethod @check_connection @log_query async def last(cls, _filter: dict | None = None, /, **kwargs) -> Self | None: """ Get the last document from the database. Example: ------- >>> from app.models import User >>> await User.last(age=18, name='Ali') or >>> await User.last({'age': 18, 'name': 'Ali'}) or >>> await User.last({'age': 18}, name='Ali') """ return await super().last(_filter, **kwargs) @classmethod @check_connection @log_query async def aggregate(cls, pipeline: Sequence[dict]) -> Iterable[dict]: """ Perform an aggregation using the aggregation framework on this collection. Example: ------- >>> from app.models import User >>> pipeline = [ >>> {'$match': {...}}, >>> {'$unwind': ...}, >>> {'$group': {...}}, >>> {'$project': {...}}, >>> {'$sort': {...}} >>> ... >>> ] >>> await User.aggregate(pipeline) """ return await super().aggregate(pipeline) # # # # # Count # # # # # @classmethod @check_connection @log_query async def count(cls, _filter: dict | None = None, /, **kwargs) -> int: """ Count the number of documents in this collection. Example: ------- >>> from app.models import User >>> await User.count(age=18, name='Ali') or >>> await User.count({'age': 18, 'name': 'Ali'}) or >>> await User.count({'age': 18}, name='Ali') """ return await super().count(_filter, **kwargs) # # # # # Insert # # # # # @classmethod @check_connection @log_query async def insert_one(cls, _document: dict | None = None, /, **kwargs) -> Self: """ Insert a single document. Example: ------- >>> from app.models import User >>> await User.insert_one(age=18, name='Ali') or >>> await User.insert_one({'age': 18, 'name': 'Ali'}) or >>> await User.insert_one({'age': 18}, name='Ali') """ # Step 1: Merge document parameters # Combine the _document dict with keyword arguments into a single document document = cls._merge(_document, kwargs) # Step 2: Process and validate document # - Validate data types and structure # - Convert Model instances to their IDs for database storage # - Handle File objects by saving to disk and storing file paths # - Process nested objects and relationships final_document = await cls._process_document(document) # Step 3: Create model instance # - Retrieve Model instances from database using IDs # - Open File objects from their stored paths # - Build the complete model instance with all relationships result = await cls._create_model_instance(document=final_document) # Step 4: Insert into database and return # - Insert the processed document into the database # - Assign the generated ID to the model instance # - Return the complete model instance result.id = await super().insert_one(final_document) return result @classmethod @check_connection @log_query async def insert_many(cls, documents: Iterable[dict]) -> list[Self]: """ Insert an iterable of documents. Example: ------- >>> from app.models import User >>> users = [ >>> {'age': 18, 'name': 'Ali'}, >>> {'age': 17, 'name': 'Saba'}, >>> {'age': 16, 'name': 'Amin'} >>> ] >>> await User.insert_many(users) """ return await super().insert_many(documents) # # # # # Delete # # # # # @check_connection @log_query async def delete(self) -> None: """ Delete the document. Example: ------- >>> from app.models import User >>> user = await User.find_one(name='Ali') >>> await user.delete() """ await self.delete_one(id=self.id) @classmethod @check_connection @log_query async def delete_one(cls, _filter: dict | None = None, /, **kwargs) -> bool: """ Delete a single document matching the filter. Example: ------- >>> from app.models import User >>> await User.delete_one(age=18, name='Ali') or >>> await User.delete_one({'age': 18, 'name': 'Ali'}) or >>> await User.delete_one({'age': 18}, name='Ali') """ return await super().delete_one(_filter, **kwargs) @classmethod @check_connection @log_query async def delete_many(cls, _filter: dict | None = None, /, **kwargs) -> int: """ Delete one or more documents matching the filter. Example: ------- >>> from app.models import User >>> await User.delete_many(age=18, name='Ali') or >>> await User.delete_many({'age': 18, 'name': 'Ali'}) or >>> await User.delete_many({'age': 18}, name='Ali') """ return await super().delete_many(_filter, **kwargs) # # # # # Update # # # # # @check_connection @log_query async def update(self, _update: dict | None = None, /, **kwargs) -> None: """ Update the document. Example: ------- >>> from app.models import User >>> user = await User.find_one(age=18, name='Ali') >>> await user.update(name='Saba', age=19) or >>> await user.update({'name': 'Saba'}, age=19) or >>> await user.update({'name': 'Saba', 'age': 19}) """ await self.update_one({'id': self.id}, _update, **kwargs) await self.reload() @classmethod @check_connection @log_query async def update_one(cls, _filter: dict, _update: dict | None = None, /, **kwargs) -> bool: """ Update a single document matching the filter. Example: ------- >>> from app.models import User >>> await User.update_one({'id': 1}, age=18, name='Ali') or >>> await User.update_one({'id': 1}, {'age': 18, 'name': 'Ali'}) or >>> await User.update_one({'id': 1}, {'age': 18}, name='Ali') """ return await super().update_one(_filter, _update, **kwargs) @classmethod @check_connection @log_query async def update_many(cls, _filter: dict, _update: dict | None = None, /, **kwargs) -> int: """ Update one or more documents that match the filter. Example: ------- >>> from app.models import User >>> await User.update_many({'name': 'Saba'}, age=18, name='Ali') or >>> await User.update_many({'name': 'Saba'}, {'age': 18, 'name': 'Ali'}) or >>> await User.update_many({'name': 'Saba'}, {'age': 18}, name='Ali') """ return await super().update_many(_filter, _update, **kwargs) # # # # # Other # # # # # @classmethod async def all(cls) -> list[Self] | Cursor: """ Alias of find() without args Example: ------- >>> from app.models import User >>> await User.all() """ return await cls.find() @classmethod async def find_one_or_insert(cls, _filter: dict | None = None, /, **kwargs) -> tuple[Self, bool]: """ Get a single document from the database. or Insert a single document. This method requires a unique index/constraint on queried fields to be correct under concurrency. Example: ------- >>> from app.models import User >>> await User.find_one_or_insert(age=18, name='Ali') or >>> await User.find_one_or_insert({'age': 18, 'name': 'Ali'}) or >>> await User.find_one_or_insert({'age': 18}, name='Ali') """ if obj := await cls.find_one(_filter, **kwargs): return obj, False try: return await cls.insert_one(_filter, **kwargs), True except Exception as e: if not _is_duplicate_key_error(e): raise if obj := await cls.find_one(_filter, **kwargs): return obj, False raise @classmethod async def find_one_or_raise(cls, _filter: dict | None = None, /, **kwargs) -> Self: """ Example: ------- >>> from app.models import User >>> await User.find_one_or_raise(age=18, name='Ali') or >>> await User.find_one_or_raise({'age': 18, 'name': 'Ali'}) or >>> await User.find_one_or_raise({'age': 18}, name='Ali') """ if obj := await cls.find_one(_filter, **kwargs): return obj raise NotFoundAPIError(detail=f'{cls.__name__} Does Not Exist') @classmethod async def exists(cls, _filter: dict | None = None, /, **kwargs) -> bool: """ Check if document exists in collection or not Example: ------- >>> from app.models import User >>> await User.exists(age=18, name='Ali') or >>> await User.exists({'age': 18, 'name': 'Ali'}) or >>> await User.exists({'age': 18}, name='Ali') """ return await cls.count(_filter, **kwargs) > 0 async def save(self) -> None: """ Save the document If it has `id` --> Update It else --> Insert It Example: ------- >>> from app.models import User # Update >>> user = await User.find_one(name='Ali') >>> user.name = 'Saba' >>> await user.save() or # Insert >>> user = User(name='Ali') >>> await user.save() """ document = self.model_dump(exclude={'id'}) if self.id: await self.update(document) else: self.id = (await self.insert_one(document)).id async def reload(self): updated_instance = await self.find_one(id=self.id) for field_name in self.__class__.model_fields: setattr(self, field_name, getattr(updated_instance, field_name)) ================================================ FILE: panther/db/utils.py ================================================ import logging from time import perf_counter from panther.configs import config try: # Only required if user wants to use mongodb import bson except ImportError: pass logger = logging.getLogger('query') def log_query(func): async def log(*args, **kwargs): if config.LOG_QUERIES is False: return await func(*args, **kwargs) start = perf_counter() response = await func(*args, **kwargs) end = perf_counter() class_name = getattr(args[0], '__name__', args[0].__class__.__name__) logger.info(f'[Query] {class_name}.{func.__name__}() takes {(end - start) * 1_000:.3} ms') return response return log def check_connection(func): async def wrapper(*args, **kwargs): if config.QUERY_ENGINE is None: msg = ( "You don't have active database connection, Check your DATABASE block in configs" '\nMore Info: https://PantherPy.GitHub.io/database/' ) raise NotImplementedError(msg) return await func(*args, **kwargs) return wrapper def prepare_id_for_query(*args, is_mongo: bool = False): for d in args: if d is None: continue if 'id' in d: d['_id'] = d.pop('id') if '_id' in d: _converter = _convert_to_object_id if is_mongo else str d['_id'] = _converter(d['_id']) def _convert_to_object_id(_id): if _id is None: return None if isinstance(_id, bson.ObjectId): return _id try: return bson.ObjectId(_id) except bson.objectid.InvalidId: logger.warning(f'id={_id} is not a valid bson.ObjectId') return None ================================================ FILE: panther/events.py ================================================ import asyncio import logging from panther._utils import is_function_async from panther.background_tasks import get_application_event_loop from panther.utils import Singleton logger = logging.getLogger('panther') class Event(Singleton): _startups = [] _shutdowns = [] @classmethod def startup(cls, func): cls._startups.append(func) def wrapper(): return func() return wrapper @classmethod def shutdown(cls, func): cls._shutdowns.append(func) def wrapper(): return func() return wrapper @classmethod async def run_startups(cls): for func in cls._startups: try: if is_function_async(func): await func() else: func() except Exception as e: logger.error(f'{func.__name__}() startup event got error: {e}') @classmethod def run_shutdowns(cls): for func in cls._shutdowns: if is_function_async(func): try: app_loop = get_application_event_loop() if app_loop and app_loop.is_running() and not app_loop.is_closed(): asyncio.run_coroutine_threadsafe(func(), app_loop).result() else: asyncio.run(func()) except ModuleNotFoundError: # Error: import of asyncio halted; None in sys.modules # And as I figured it out, it only happens when we are running with # gunicorn and Uvicorn workers (-k uvicorn.workers.UvicornWorker) pass else: func() @classmethod def clear(cls): """Clear all stored events (useful for testing)""" cls._startups.clear() cls._shutdowns.clear() ================================================ FILE: panther/exceptions.py ================================================ from panther import status class PantherError(Exception): pass class DatabaseError(Exception): pass class BaseError(Exception): detail: str | dict | list = 'Internal Server Error' status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR def __init__(self, detail: str | dict | list = None, status_code: int = None, headers: dict = None): self.detail = detail or self.detail self.status_code = status_code or self.status_code self.headers = headers class APIError(BaseError): detail: str | dict | list = 'Internal Server Error' status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR class WebsocketError(BaseError): detail: str | dict | list = 'Internal Error' status_code: int = status.WS_1011_INTERNAL_ERROR class RedirectAPIError(APIError): def __init__(self, url: str, status_code: int = status.HTTP_302_FOUND): super().__init__(headers={'Location': url}, status_code=status_code) class BadRequestAPIError(APIError): detail = 'Bad Request' status_code = status.HTTP_400_BAD_REQUEST class AuthenticationAPIError(APIError): detail = 'Authentication Error' status_code = status.HTTP_401_UNAUTHORIZED class AuthorizationAPIError(APIError): detail = 'Permission Denied' status_code = status.HTTP_403_FORBIDDEN class NotFoundAPIError(APIError): detail = 'Not Found' status_code = status.HTTP_404_NOT_FOUND class MethodNotAllowedAPIError(APIError): detail = 'Method Not Allowed' status_code = status.HTTP_405_METHOD_NOT_ALLOWED class UnprocessableEntityError(APIError): detail = 'Unprocessable Entity Error' status_code = status.HTTP_422_UNPROCESSABLE_ENTITY class UpgradeRequiredError(APIError): detail = 'This service requires use of the WebSocket protocol.' status_code = status.HTTP_426_UPGRADE_REQUIRED class ThrottlingAPIError(APIError): detail = 'Too Many Request' status_code = status.HTTP_429_TOO_MANY_REQUESTS class InvalidPathVariableAPIError(APIError): def __init__(self, value: str, variable_type: type): detail = f'Path variable `{value}` is not `{variable_type.__name__}`' super().__init__(detail=detail, status_code=status.HTTP_400_BAD_REQUEST) ================================================ FILE: panther/file_handler.py ================================================ from functools import cached_property from io import BufferedReader, BytesIO from pathlib import Path from pydantic import BaseModel, field_validator from panther import status from panther.exceptions import APIError from panther.utils import timezone_now class File(BaseModel): file_name: str content_type: str file: bytes | None = None _file_path: Path | None = None _buffer: BytesIO | BufferedReader | None = None def __init__(self, **data): super().__init__(**data) if self.file: self._buffer = BytesIO(self.file) elif 'file_name' in data: self._file_path = Path(data['file_name']) self._saved_path: str | None = None def __enter__(self): if not self._buffer: # Open file lazily in binary read mode self._buffer = open(self._file_path, 'rb') return self def __exit__(self, exc_type, exc_value, traceback): if self._buffer: self._buffer.close() self._buffer = None def read(self, size: int = -1) -> bytes: self._ensure_buffer() return self._buffer.read(size) def seek(self, offset: int, whence: int = 0): self._ensure_buffer() return self._buffer.seek(offset, whence) def tell(self) -> int: self._ensure_buffer() return self._buffer.tell() def write(self, data: bytes): if isinstance(self._buffer, BytesIO): self._buffer.seek(0, 2) self._buffer.write(data) self.file = self._buffer.getvalue() # sync updated bytes else: raise IOError('Write is only supported for in-memory files.') def save(self, path: str | None = None, overwrite: bool = False) -> str: # If already saved, return the same path if self._saved_path is not None: return self._saved_path self._ensure_buffer() # Handle directory paths (ending with slash) if path and str(path).endswith('/'): # Treat as directory, use original file name base_path = Path(path) / self.file_name else: base_path = Path(path or self.file_name) if not overwrite: file_path = base_path if file_path.exists(): # Format: file_YYYYMMDD_HHMMSS[_N].ext timestamp = timezone_now().strftime('%Y%m%d_%H%M%S') file_path = base_path.with_name(f'{base_path.stem}_{timestamp}{base_path.suffix}') # Ensure uniqueness if file with same timestamp exists counter = 1 while file_path.exists(): file_path = base_path.with_name(f'{base_path.stem}_{timestamp}_{counter}{base_path.suffix}') counter += 1 else: file_path = base_path # Ensure directory exists file_path.parent.mkdir(parents=True, exist_ok=True) # Write file with open(file_path, 'wb') as f: f.write(self._buffer.read()) self._buffer.seek(0) # Store the saved path for idempotency self._saved_path = str(file_path) return self._saved_path @cached_property def size(self) -> int: if self.file is not None: return len(self.file) if self._file_path: return self._file_path.stat().st_size return 0 def _ensure_buffer(self): if not self._buffer: if self._file_path: self._buffer = open(self._file_path, 'rb') elif self.file is not None: self._buffer = BytesIO(self.file) else: raise ValueError('No file source to read from.') def __repr__(self) -> str: return f'{self.__repr_name__()}(file_name={self.file_name}, content_type={self.content_type})' __str__ = __repr__ class Image(File): @field_validator('content_type') @classmethod def validate_content_type(cls, content_type: str) -> str: if not content_type.startswith('image/'): msg = f"{content_type} is not a valid image 'content_type'" raise APIError(detail=msg, status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE) return content_type ================================================ FILE: panther/generics.py ================================================ import contextlib import logging import re from abc import abstractmethod from pantherdb import Cursor as PantherDBCursor from panther import status from panther.app import GenericAPI from panther.configs import config from panther.db import Model from panther.db.connections import MongoDBConnection from panther.db.cursor import Cursor from panther.db.models import ID from panther.exceptions import APIError from panther.pagination import Pagination from panther.request import Request from panther.response import Response from panther.serializer import ModelSerializer with contextlib.suppress(ImportError): # Only required if user wants to use mongodb import bson logger = logging.getLogger('panther') class RetrieveAPI(GenericAPI): @abstractmethod async def get_instance(self, request: Request, **kwargs) -> Model: """ Should return an instance of Model, e.g. `await User.find_one()` """ logger.error(f'`get_instance()` method is not implemented in {self.__class__} .') raise APIError(status_code=status.HTTP_501_NOT_IMPLEMENTED) async def get(self, request: Request, **kwargs): instance = await self.get_instance(request=request, **kwargs) return Response(data=instance, status_code=status.HTTP_200_OK) class ListAPI(GenericAPI): sort_fields: list[str] = [] search_fields: list[str] = [] filter_fields: list[str] = [] pagination: type[Pagination] | None = None @abstractmethod async def get_query(self, request: Request, **kwargs): """ Should return a Cursor, e.g. `await User.find()` """ logger.error(f'`get_query()` method is not implemented in {self.__class__} .') raise APIError(status_code=status.HTTP_501_NOT_IMPLEMENTED) async def get(self, request: Request, **kwargs): cursor, pagination = await self.prepare_cursor(request=request, **kwargs) return Response(data=cursor, pagination=pagination, status_code=status.HTTP_200_OK) async def prepare_cursor(self, request: Request, **kwargs) -> tuple[Cursor | PantherDBCursor, Pagination | None]: cursor = await self.get_query(request=request, **kwargs) if not isinstance(cursor, (Cursor, PantherDBCursor)): logger.error(f'`{self.__class__.__name__}.get_query()` should return a Cursor, e.g. `await Model.find()`') raise APIError(status_code=status.HTTP_501_NOT_IMPLEMENTED) query = {} query |= self.process_filters(query_params=request.query_params, cursor=cursor) query |= self.process_search(query_params=request.query_params) if query: cursor = await cursor.cls.find(cursor.filter | query) if sort := self.process_sort(query_params=request.query_params): cursor = cursor.sort(sort) if pagination := self.process_pagination(query_params=request.query_params, cursor=cursor): cursor = pagination.paginate() return cursor, pagination def process_filters(self, query_params: dict, cursor: Cursor | PantherDBCursor) -> dict: _filter = {} for field in self.filter_fields: if field in query_params: _filter[field] = query_params[field] if isinstance(config.DATABASE, MongoDBConnection) and cursor.cls.model_fields[field].annotation == ID: _filter[field] = bson.ObjectId(_filter[field]) return _filter def process_search(self, query_params: dict) -> dict: search_param = query_params.get('search') if not self.search_fields or not search_param: return {} if isinstance(config.DATABASE, MongoDBConnection): escaped_search_param = re.escape(search_param) if search := [{field: {'$regex': escaped_search_param}} for field in self.search_fields]: return {'$or': search} return {field: search_param for field in self.search_fields} def process_sort(self, query_params: dict) -> list: sort_param = query_params.get('sort') if not self.sort_fields or not sort_param: return [] return [ (field, -1 if param.startswith('-') else 1) for param in sort_param.split(',') for field in self.sort_fields if field == param.removeprefix('-') ] def process_pagination(self, query_params: dict, cursor: Cursor | PantherDBCursor) -> Pagination | None: if self.pagination: return self.pagination(query_params=query_params, cursor=cursor) class CreateAPI(GenericAPI): async def post(self, request: Request, **kwargs): instance = await request.validated_data.model.insert_one(request.validated_data.model_dump()) return Response(data=instance, status_code=status.HTTP_201_CREATED) class UpdateAPI(GenericAPI): @abstractmethod async def get_instance(self, request: Request, **kwargs) -> Model: """ Should return an instance of Model, e.g. `await User.find_one()` """ logger.error(f'`get_instance()` method is not implemented in {self.__class__} .') raise APIError(status_code=status.HTTP_501_NOT_IMPLEMENTED) async def put(self, request: Request, **kwargs): instance = await self.get_instance(request=request, **kwargs) await instance.update(request.validated_data.model_dump()) return Response(data=instance, status_code=status.HTTP_200_OK) async def patch(self, request: Request, **kwargs): instance = await self.get_instance(request=request, **kwargs) await instance.update(request.validated_data.model_dump(exclude_none=True)) return Response(data=instance, status_code=status.HTTP_200_OK) class DeleteAPI(GenericAPI): @abstractmethod async def get_instance(self, request: Request, **kwargs) -> Model: """ Should return an instance of Model, e.g. `await User.find_one()` """ logger.error(f'`get_instance()` method is not implemented in {self.__class__} .') raise APIError(status_code=status.HTTP_501_NOT_IMPLEMENTED) async def pre_delete(self, instance, request: Request, **kwargs): """Hook for logic before deletion.""" pass async def post_delete(self, instance, request: Request, **kwargs): """Hook for logic after deletion.""" pass async def delete(self, request: Request, **kwargs): instance = await self.get_instance(request=request, **kwargs) await self.pre_delete(instance, request=request, **kwargs) await instance.delete() await self.post_delete(instance, request=request, **kwargs) return Response(status_code=status.HTTP_204_NO_CONTENT) ================================================ FILE: panther/logging.py ================================================ import logging from pathlib import Path from panther.configs import config LOGS_DIR = config.BASE_DIR / 'logs' class FileHandler(logging.FileHandler): def __init__(self, filename, mode='a', encoding=None, delay=False, errors=None): Path(LOGS_DIR).mkdir(exist_ok=True) logging.FileHandler.__init__(self, filename, mode=mode, encoding=encoding, delay=delay, errors=errors) LOGGING = { 'version': 1, 'disable_existing_loggers': False, 'formatters': { 'default': { '()': 'uvicorn.logging.DefaultFormatter', 'fmt': '%(levelprefix)s | %(asctime)s | %(message)s', 'datefmt': '%Y-%m-%d %H:%M:%S', }, 'panther_file_formatter': { '()': 'uvicorn.logging.DefaultFormatter', 'fmt': '%(asctime)s | %(message)s', 'datefmt': '%Y-%m-%d %H:%M:%S', }, }, 'handlers': { 'panther_file': { 'formatter': 'panther_file_formatter', 'filename': LOGS_DIR / 'main.log', 'class': 'panther.logging.FileHandler', 'delay': True, }, 'monitoring_file': { 'formatter': 'panther_file_formatter', 'filename': LOGS_DIR / 'monitoring.log', 'class': 'panther.logging.FileHandler', 'delay': True, }, 'query_file': { 'formatter': 'panther_file_formatter', 'filename': LOGS_DIR / 'query.log', 'class': 'panther.logging.FileHandler', 'delay': True, }, 'default': { 'formatter': 'default', 'class': 'logging.StreamHandler', 'stream': 'ext://sys.stderr', }, }, 'loggers': { 'panther': { 'handlers': ['default', 'panther_file'], 'level': 'DEBUG', }, 'monitoring': { 'handlers': ['monitoring_file'], 'level': 'DEBUG', }, 'query': { 'handlers': ['default', 'query_file'], 'level': 'DEBUG', }, }, } ================================================ FILE: panther/main.py ================================================ import asyncio import logging import sys from collections.abc import Callable from logging.config import dictConfig from pathlib import Path import panther.logging from panther import status from panther._load_configs import * from panther._utils import ( ENDPOINT_CLASS_BASED_API, ENDPOINT_FUNCTION_BASED_API, ENDPOINT_WEBSOCKET, reformat_code, traceback_message, ) from panther.background_tasks import ( register_application_event_loop, ) from panther.base_websocket import Websocket from panther.cli.utils import print_info from panther.configs import config from panther.events import Event from panther.exceptions import APIError, BaseError, NotFoundAPIError, PantherError, UpgradeRequiredError from panther.request import Request from panther.response import Response from panther.routings import find_endpoint from panther.websocket import GenericWebsocket dictConfig(panther.logging.LOGGING) logger = logging.getLogger('panther') class Panther: def __init__(self, name: str, configs: str | None = None, urls: dict | None = None): """ Initialize a Panther application instance. Args: name: Typically set to `__name__`; used to determine the current directory of the application. configs: The name of the module containing your configuration. If the configuration is defined in the current file, you can also set this to `__name__`. urls: A dictionary containing your URL routing. If not provided, Panther will attempt to load `URLs` from the configs module. """ self._configs_module_name = configs self._urls = urls config.BASE_DIR = Path(name).resolve().parent try: self.load_configs() if config.AUTO_REFORMAT: reformat_code(base_dir=config.BASE_DIR) except Exception as e: logger.error(e.args[0] if isinstance(e, PantherError) else traceback_message(exception=e)) sys.exit() # Print Info print_info(config) def load_configs(self) -> None: # Check & Read The Configs File self._configs_module = load_configs_module(self._configs_module_name) load_redis(self._configs_module) load_timezone(self._configs_module) load_database(self._configs_module) load_secret_key(self._configs_module) load_throttling(self._configs_module) load_user_model(self._configs_module) load_log_queries(self._configs_module) load_templates_dir(self._configs_module) load_middlewares(self._configs_module) load_auto_reformat(self._configs_module) load_background_tasks(self._configs_module) load_other_configs(self._configs_module) load_urls(self._configs_module, urls=self._urls) load_authentication_class(self._configs_module) load_websocket_connections() check_endpoints_inheritance() async def __call__(self, scope: dict, receive: Callable, send: Callable) -> None: register_application_event_loop(asyncio.get_running_loop()) if scope['type'] == 'http': await self.handle_http(scope=scope, receive=receive, send=send) elif scope['type'] == 'websocket': await self.handle_ws(scope=scope, receive=receive, send=send) elif scope['type'] == 'lifespan': message = await receive() if message['type'] == 'lifespan.startup': if config.HAS_WS: await config.WEBSOCKET_CONNECTIONS.start() await Event.run_startups() elif message['type'] == 'lifespan.shutdown': pass return @staticmethod async def handle_ws_endpoint(connection: Websocket): # Find Endpoint endpoint, found_path = find_endpoint(path=connection.path) if endpoint is None: await connection.close() return connection # Check Endpoint Type if endpoint._endpoint_type is not ENDPOINT_WEBSOCKET: logger.warning(f'{endpoint.__name__}() class is not a subclass of `GenericWebsocket`.') await connection.close() return connection # Create The Connection final_connection = endpoint(parent=connection) del connection # Collect Path Variables final_connection.collect_path_variables(found_path=found_path) return await config.WEBSOCKET_CONNECTIONS.listen(connection=final_connection) @classmethod async def handle_ws(cls, scope: dict, receive: Callable, send: Callable) -> None: # Create Temp Connection connection = Websocket(scope=scope, receive=receive, send=send) # Create Middlewares chain chained_func = cls.handle_ws_endpoint for middleware in config.WS_MIDDLEWARES: chained_func = middleware(dispatch=chained_func) # Call Middlewares & Endpoint try: connection = await chained_func(connection=connection) except BaseError as e: connection.log(e.detail) await connection.close() except Exception as e: logger.error(traceback_message(exception=e)) await connection.close() @staticmethod async def handle_http_endpoint(request: Request) -> Response: # Find Endpoint endpoint, found_path = find_endpoint(path=request.path) if endpoint is None: raise NotFoundAPIError # Collect Path Variables request.collect_path_variables(found_path=found_path) if endpoint._endpoint_type is ENDPOINT_FUNCTION_BASED_API: return await endpoint(request=request) if endpoint._endpoint_type is ENDPOINT_CLASS_BASED_API: return await endpoint().call_method(request=request) # ENDPOINT_WEBSOCKET raise UpgradeRequiredError @classmethod async def handle_http(cls, scope: dict, receive: Callable, send: Callable) -> None: # Create `Request` and its body request = Request(scope=scope, receive=receive, send=send) await request.read_body() # Create Middlewares chain chained_func = cls.handle_http_endpoint for middleware in config.HTTP_MIDDLEWARES: chained_func = middleware(dispatch=chained_func) # Call Middlewares & Endpoint try: response = await chained_func(request=request) if response is None: logger.error('You forgot to return `response` on the `Middlewares.__call__()`') response = Response( data={'detail': 'Internal Server Error'}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) except APIError as e: response = Response( data=e.detail if isinstance(e.detail, dict) else {'detail': e.detail}, headers=e.headers, status_code=e.status_code, ) except Exception as e: # Handle Unknown Exceptions logger.error(traceback_message(exception=e)) response = Response( data={'detail': 'Internal Server Error'}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) # Return Response await response.send(send=send, receive=receive) def __del__(self): Event.run_shutdowns() ================================================ FILE: panther/middlewares/__init__.py ================================================ from panther.middlewares.base import HTTPMiddleware, WebsocketMiddleware # noqa: F401 from panther.middlewares.cors import CORSMiddleware # noqa: F401 from panther.middlewares.monitoring import MonitoringMiddleware, WebsocketMonitoringMiddleware # noqa: F401 ================================================ FILE: panther/middlewares/base.py ================================================ import typing from panther.base_websocket import Websocket from panther.request import Request from panther.response import Response from panther.websocket import GenericWebsocket class HTTPMiddleware: """Used only in http requests""" def __init__(self, dispatch: typing.Callable): self.dispatch = dispatch async def __call__(self, request: Request) -> Response: return await self.dispatch(request=request) class WebsocketMiddleware: """Used only in ws requests""" def __init__(self, dispatch: typing.Callable): self.dispatch = dispatch async def __call__(self, connection: Websocket) -> GenericWebsocket: return await self.dispatch(connection=connection) ================================================ FILE: panther/middlewares/cors.py ================================================ from panther.configs import config from panther.middlewares import HTTPMiddleware from panther.request import Request from panther.response import Response class CORSMiddleware(HTTPMiddleware): """ Middleware to handle Cross-Origin Resource Sharing (CORS) for Panther applications. This middleware automatically adds the appropriate CORS headers to all HTTP responses based on configuration variables defined in your Panther config file (e.g., core/configs.py). It also handles preflight (OPTIONS) requests. Configuration attributes (set these in your config): --------------------------------------------------- ALLOW_ORIGINS: list[str] List of allowed origins. Use ["*"] to allow all origins. Default: ["*"] ALLOW_METHODS: list[str] List of allowed HTTP methods. Default: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] ALLOW_HEADERS: list[str] List of allowed request headers. Use ["*"] to allow all headers. Default: ["*"] ALLOW_CREDENTIALS: bool Whether to allow credentials (cookies, authorization headers, etc.). Default: False EXPOSE_HEADERS: list[str] List of headers that can be exposed to the browser. Default: [] CORS_MAX_AGE: int Number of seconds browsers are allowed to cache preflight responses. Default: 600 Usage: ------ 1. Set the above config variables in your config file as needed. 2. Add 'panther.middlewares.cors.CORSMiddleware' to your MIDDLEWARES list. """ async def __call__(self, request: Request) -> Response: # Fetch CORS settings from config, with defaults allow_origins = config.ALLOW_ORIGINS or ['*'] allow_methods = config.ALLOW_METHODS or ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] allow_headers = config.ALLOW_HEADERS or ['*'] allow_credentials = config.ALLOW_CREDENTIALS or False expose_headers = config.EXPOSE_HEADERS or [] max_age = config.CORS_MAX_AGE or 600 # Handle preflight (OPTIONS) requests if request.method == 'OPTIONS': response = Response(status_code=204) else: response = await self.dispatch(request=request) origin = request.headers['origin'] or '*' if '*' in allow_origins: allow_origin = '*' elif origin in allow_origins: allow_origin = origin else: allow_origin = allow_origins[0] if allow_origins else '*' response.headers['Access-Control-Allow-Origin'] = allow_origin response.headers['Access-Control-Allow-Methods'] = ', '.join(allow_methods) response.headers['Access-Control-Allow-Headers'] = ', '.join(allow_headers) response.headers['Access-Control-Max-Age'] = str(max_age) if allow_credentials: response.headers['Access-Control-Allow-Credentials'] = 'true' if expose_headers: response.headers['Access-Control-Expose-Headers'] = ', '.join(expose_headers) return response ================================================ FILE: panther/middlewares/monitoring.py ================================================ import logging from time import perf_counter from panther.base_websocket import Websocket from panther.middlewares import HTTPMiddleware, WebsocketMiddleware from panther.request import Request logger = logging.getLogger('monitoring') class MonitoringMiddleware(HTTPMiddleware): """ Create Log Message Like Below: datetime | method | path | ip:port | response_time(seconds) | status """ async def __call__(self, request: Request): start_time = perf_counter() method = request.scope['method'] response = await self.dispatch(request=request) response_time = perf_counter() - start_time # Seconds logger.info(f'{method} | {request.path} | {request.client} | {response_time} | {response.status_code}') return response class WebsocketMonitoringMiddleware(WebsocketMiddleware): """ Create Log Message Like Below: datetime | WS | path | ip:port | connection_time(seconds) | status """ ConnectedConnectionTime = ' - ' async def __call__(self, connection: Websocket): start_time = perf_counter() logger.info(f'WS | {connection.path} | {connection.client} |{self.ConnectedConnectionTime}| {connection.state}') connection = await self.dispatch(connection=connection) connection_time = perf_counter() - start_time # Seconds logger.info(f'WS | {connection.path} | {connection.client} | {connection_time} | {connection.state}') return connection ================================================ FILE: panther/openapi/__init__.py ================================================ from panther.openapi.schemas import OutputSchema ================================================ FILE: panther/openapi/schemas.py ================================================ import pydantic from panther.serializer import ModelSerializer class OutputSchema: """ Configuration class for defining API endpoint output schemas. This class allows you to specify the response model, status code, and other metadata for API endpoints to generate proper OpenAPI documentation. Attributes: model: The Pydantic model or ModelSerializer class for the response status_code: HTTP status code for the response exclude_in_docs: Whether to exclude this endpoint from OpenAPI docs (defaults to False) tags: List of tags for grouping endpoints in documentation (defaults to Function Name/ Class Name) deprecated: Whether this endpoint is marked as deprecated (defaults to False) """ def __init__( self, model: type[ModelSerializer] | type[pydantic.BaseModel] | None = None, status_code: int | None = None, exclude_in_docs: bool = False, tags: str | None = None, deprecated: bool = False, ): self.model = model self.status_code = status_code self.exclude_in_docs = exclude_in_docs self.tags = tags self.deprecated = deprecated ================================================ FILE: panther/openapi/templates/rapidoc.html ================================================ RapiDoc Inline JSON ================================================ FILE: panther/openapi/templates/redoc.html ================================================ ReDoc API Reference
      ================================================ FILE: panther/openapi/templates/scalar.html ================================================ Scalar API Reference
      ================================================ FILE: panther/openapi/templates/spotlight.html ================================================ Stoplight Elements with CSS ================================================ FILE: panther/openapi/templates/swagger.html ================================================ Swagger UI
      ================================================ FILE: panther/openapi/urls.py ================================================ from panther.openapi.views import RapiDocOpenAPI, RedocOpenAPI, ScalarOpenAPI, SpotlightOpenAPI, SwaggerOpenAPI url_routing = { 'scalar/': ScalarOpenAPI, 'swagger/': SwaggerOpenAPI, 'redoc/': RedocOpenAPI, 'rapidoc/': RapiDocOpenAPI, 'spotlight/': SpotlightOpenAPI, } ================================================ FILE: panther/openapi/utils.py ================================================ """ OpenAPI utilities for parsing endpoints and generating OpenAPI documentation. This module provides classes and functions for analyzing Python endpoints and generating OpenAPI 3.0 specification documents. """ import ast import inspect import logging import types from typing import Any, Dict, List, Optional, Tuple, Union from pydantic import BaseModel from panther import status from panther.app import GenericAPI from panther.configs import config logger = logging.getLogger('panther') class EndpointParser: """ Parses endpoint functions and classes to extract response data and status codes. This class analyzes the AST (Abstract Syntax Tree) of endpoint functions to determine what data they return and what HTTP status codes they use. """ def __init__(self, endpoint: Any, http_method: str): self.ast_tree = ast.parse(inspect.getsource(endpoint)) self.http_method = http_method # Default values self.status_code = status.HTTP_200_OK self.endpoint_name = None self.response_data = {} self._parse_ast() def _parse_ast(self) -> None: """Parse the AST to extract endpoint information.""" for node in self.ast_tree.body: match node: case ast.ClassDef(name=class_name, body=class_body): # Handle class-based endpoints # class ...(GenericAPI): # def get(self, ... self.endpoint_name = class_name self._parse_class_methods(class_body) case ast.FunctionDef(name=func_name, body=func_body): # Handle function-based endpoints # def api(... self.endpoint_name = func_name self._parse_function_body(func_body) case ast.AsyncFunctionDef(name=func_name, body=func_body): # Handle async function-based endpoints # async def api(... self.endpoint_name = func_name self._parse_function_body(func_body) def _parse_class_methods(self, class_body: list[ast.stmt]) -> None: """Parse methods within a class-based endpoint.""" for node in class_body: match node: case ast.FunctionDef(name=method_name, body=method_body): # def get(self, ... if method_name == self.http_method: self._parse_function_body(method_body) break case ast.AsyncFunctionDef(name=method_name, body=method_body): # async def get(self, ... if method_name == self.http_method: self._parse_function_body(method_body) break def _parse_function_body(self, function_body: list[ast.stmt]) -> None: """Parse the body of a function to extract return statements.""" for node in function_body: match node: case ast.Return(value=ast.Dict(keys=dict_keys, values=dict_values)): # return {...} self.status_code = status.HTTP_200_OK self.response_data = self._extract_dict_data(dict_keys, dict_values) case ast.Return(value=ast.Name(id=variable_name)): # return variable_name self._extract_variable_data(function_body, variable_name) case ast.Return(value=ast.Call(args=call_args, keywords=call_keywords, func=func)): # return Response(...) self._parse_call(function_body=function_body, args=call_args, keywords=call_keywords, func=func) def _extract_dict_data(self, keys: list[ast.expr], values: list[ast.expr]) -> dict: """Extract data from a dictionary return statement.""" response = {} for key, value in zip(keys, values): extracted_value = None match value: case ast.Constant(value=constant_value): extracted_value = constant_value if hasattr(key, 'value'): response[key.value] = extracted_value return response def _extract_variable_data(self, function_body: list[ast.stmt], variable_name: str) -> None: """Extract data from a variable that contains a dictionary.""" for variable_value in self._find_variable_assignments(function_body, variable_name): match variable_value: case ast.Dict(keys=dict_keys, values=dict_values): # my_response = {...} self.status_code = status.HTTP_200_OK self.response_data = self._extract_dict_data(dict_keys, dict_values) case ast.List(elts=list_items): # my_response = ['1', '2'] self.status_code = status.HTTP_200_OK self.response_data = [] for item in list_items: match item: case ast.Constant(value=constant_value): self.response_data.append(constant_value) case ast.Constant(value=constant_value): # my_response = 'Hello World!' self.response_data = constant_value case ast.Tuple(elts=tuple_items): # my_response = (1, 2) self.status_code = status.HTTP_200_OK self.response_data = [] for item in tuple_items: match item: case ast.Constant(value=constant_value): self.response_data.append(constant_value) case ast.Call(args=call_args, keywords=call_keywords, func=func): self._parse_call(function_body=function_body, args=call_args, keywords=call_keywords, func=func) case _: breakpoint() def _parse_call(self, function_body: list[ast.stmt], args: list[ast.expr], keywords: list[ast.keyword], func): if func.id == 'Response': self._parse_response_call(function_body=function_body, args=args, keywords=keywords) else: # We assume this is subclass of BaseModel self.status_code = status.HTTP_200_OK for keyword in keywords: match keyword.value: case ast.Constant(value=constant_value): # CustomBaseModel(something='Hi') self.response_data[keyword.arg] = constant_value case ast.List(elts=list_items): # CustomBaseModel(something=['1', '2']) self.response_data[keyword.arg] = [] for item in list_items: match item: case ast.Constant(value=constant_value): self.response_data[keyword.arg].append(constant_value) case ast.Dict(keys=dict_keys, values=dict_values): # CustomBaseModel(something={...}) self.response_data[keyword.arg] = self._extract_dict_data(keys=dict_keys, values=dict_values) case _: logger.warning(f'Schema does not supported yet: {{{keyword.arg}: {keyword.value}}}') def _parse_response_call( self, function_body: list[ast.stmt], args: list[ast.expr], keywords: list[ast.keyword] ) -> None: """Parse Response() function calls.""" # Handle keyword arguments for keyword in keywords: if keyword.arg == 'data': self._parse_data_argument(function_body, keyword.value) elif keyword.arg == 'status_code': self._parse_status_code_argument(function_body, keyword.value) # Handle positional arguments for index, arg in enumerate(args): if index == 0: # First argument is data self._parse_data_argument(function_body=function_body, value=arg) elif index == 1: # Second argument is status_code self._parse_status_code_argument(function_body=function_body, value=arg) def _parse_status_code_argument(self, function_body: list[ast.stmt], value: ast.expr) -> None: """Parse status code from various AST patterns.""" match value: # return Response(?, status_code=my_status) # return Response(?, my_status) case ast.Name(id=variable_name): for variable_value in self._find_variable_assignments(function_body, variable_name): self._extract_status_code_from_value(variable_value) # return Response(?, status_code=status.HTTP_202_ACCEPTED) # return Response(?, status.HTTP_202_ACCEPTED) case ast.Attribute(value=ast.Name(id=module_name), attr=attribute_name): # Handle: my_status = status.HTTP_202_ACCEPTED if module_name == 'status': self.status_code = getattr(status, attribute_name) # return Response(?, status_code=202) # return Response(?, 202) case ast.Constant(value=constant_value): # Handle: my_status = 202 self.status_code = constant_value def _parse_data_argument(self, function_body: list[ast.stmt], value: ast.expr) -> None: """Parse data argument from various AST patterns.""" match value: # return Response(data=my_data, ?) # return Response(my_data, ?) case ast.Name(id=variable_name): # Handle: data=variable_name for variable_value in self._find_variable_assignments(function_body, variable_name): match variable_value: # my_data = {...} case ast.Dict(keys=dict_keys, values=dict_values): self.response_data = self._extract_dict_data(dict_keys, dict_values) # return Response(data={...}, ?) # return Response({...}, ?) case ast.Dict(keys=dict_keys, values=dict_values): # Handle: data={...} self.response_data = self._extract_dict_data(dict_keys, dict_values) def _extract_status_code_from_value(self, value: ast.expr) -> None: """Extract status code from a variable assignment value.""" match value: case ast.Attribute(value=ast.Name(id=module_name), attr=attribute_name): # my_status = status.HTTP_202_ACCEPTED if module_name == 'status': self.status_code = getattr(status, attribute_name) case ast.Constant(value=constant_value): # my_status = 202 self.status_code = constant_value def _find_variable_assignments(self, function_body: list[ast.stmt], variable_name: str): """Find all assignments to a specific variable name.""" for node in function_body: match node: case ast.Assign(targets=targets, value=value): for target in targets: match target: case ast.Name(id=target_name): if target_name == variable_name: yield value class OpenAPIGenerator: """ Generates OpenAPI 3.0 specification documents from Panther endpoints. This class analyzes registered endpoints and generates comprehensive OpenAPI documentation including schemas, paths, and security definitions. """ HTTP_METHODS = ['post', 'get', 'put', 'patch', 'delete'] REQUEST_BODY_METHODS = ['post', 'put', 'patch'] @classmethod def get_model_name(cls, model: type[BaseModel]) -> str: """Get the name of a model class.""" if hasattr(model, '__name__'): return model.__name__ return model.__class__.__name__ @classmethod def extract_path_parameters(cls, endpoint: Any, endpoint_name: str, http_method: str) -> list[dict[str, Any]]: """ Extract path parameters from endpoint function signature. Args: endpoint: The endpoint function or class endpoint_name: Name of the endpoint function or class http_method: The HTTP method Returns: List of parameter schemas for OpenAPI """ param_names = [] for url, endpoint in config.FLAT_URLS.items(): if endpoint.__name__ == endpoint_name: for part in url.split('/'): if part.startswith('<'): param_names.append(part.strip('< >')) if not param_names: return [] signature = cls._get_function_signature(endpoint, http_method) parameters = [] for param_name in param_names: if param_name not in signature.parameters: parameters.append({'name': param_name, 'in': 'path', 'required': True, 'schema': {'type': 'string'}}) else: param_schema = cls._create_parameter_schema(param_name, signature.parameters[param_name]) parameters.append(param_schema) return parameters @classmethod def _get_function_signature(cls, endpoint: Any, http_method: str): """Get the function signature for an endpoint.""" if isinstance(endpoint, types.FunctionType): func = endpoint else: func = getattr(endpoint, http_method) return inspect.signature(obj=func) @classmethod def _create_parameter_schema(cls, param_name: str, param_info: inspect.Parameter) -> dict[str, Any]: """Create OpenAPI parameter schema from function parameter.""" param_schema = {'name': param_name, 'in': 'path', 'required': True, 'schema': {'type': 'string'}} # Map Python types to OpenAPI types if param_info.annotation is int: param_schema['schema']['type'] = 'integer' elif param_info.annotation is bool: param_schema['schema']['type'] = 'boolean' elif param_info.annotation is float: param_schema['schema']['type'] = 'number' return param_schema @classmethod def parse_docstring(cls, docstring: str) -> tuple[str, str]: """Parse docstring into summary and description.""" if not docstring: return '', '' lines = docstring.strip().split('\n') summary = lines[0] description = '
      '.join(lines[1:]).strip() if len(lines) > 1 else '' return summary, description @classmethod def extract_field_constraints(cls, field: Any) -> dict[str, Any]: """Extract validation constraints from Pydantic field.""" constraint_attributes = ['min_length', 'max_length', 'regex', 'ge', 'le', 'gt', 'lt'] constraints = {} for attr in constraint_attributes: value = getattr(field, attr, None) if value is not None: constraints[attr] = value return constraints @classmethod def enrich_schema_with_constraints(cls, schema: dict[str, Any], model: Any) -> dict[str, Any]: """Add field constraints to OpenAPI schema.""" if 'properties' not in schema: return schema for field_name, field in model.model_fields.items(): if field_name in schema['properties']: constraints = cls.extract_field_constraints(field) schema['properties'][field_name].update(constraints) return schema @classmethod def generate_operation_content(cls, endpoint: Any, http_method: str, schemas: dict[str, Any]) -> dict[str, Any]: """Generate OpenAPI operation content for an endpoint.""" # Skip if endpoint is excluded from docs if endpoint.output_schema and endpoint.output_schema.exclude_in_docs: return {} # Parse endpoint response response_parser = EndpointParser(endpoint, http_method) # Extract basic operation info operation_id = f'{response_parser.endpoint_name}_{http_method}' parameters = cls.extract_path_parameters( endpoint=endpoint, endpoint_name=response_parser.endpoint_name, http_method=http_method ) summary, description = cls.parse_docstring(endpoint.__doc__) # Extract tags tags = cls._extract_operation_tags(endpoint=endpoint, response_parser=response_parser) # Extract metadata metadata = cls._extract_endpoint_metadata(endpoint=endpoint, description=description) # Handle response schema response_schema = cls._build_response_schema( endpoint=endpoint, response_parser=response_parser, schemas=schemas ) # Handle request body request_body = cls._build_request_body(endpoint=endpoint, http_method=http_method, schemas=schemas) # Build operation content operation_content = { 'operationId': operation_id, 'summary': summary, 'description': metadata['description'], 'tags': tags, 'parameters': parameters, 'security': metadata['security'], 'deprecated': metadata['deprecated'], } operation_content.update(response_schema) operation_content.update(request_body) return {http_method: operation_content} @classmethod def _extract_operation_tags(cls, endpoint: Any, response_parser: EndpointParser) -> list[str]: """Extract tags for operation grouping.""" if endpoint.output_schema and endpoint.output_schema.tags: return endpoint.output_schema.tags return [response_parser.endpoint_name] if response_parser.endpoint_name else [endpoint.__module__] @classmethod def _extract_endpoint_metadata(cls, endpoint: Any, description: str) -> dict[str, Any]: """Extract metadata like permissions, throttling, etc.""" # Extract permissions permissions = [p.__name__ for p in endpoint.permissions] if endpoint.permissions else None # Extract throttling throttling = None if endpoint.throttling: throttling = f'{endpoint.throttling.rate} per {endpoint.throttling.duration}' # Extract cache cache = str(endpoint.cache) if endpoint.cache else None # Extract middlewares middlewares = None if endpoint.middlewares: middlewares = [getattr(m, '__name__', str(m)) for m in endpoint.middlewares] # Extract deprecated status deprecated = endpoint.output_schema.deprecated if endpoint.output_schema else False # Build security security = [{'BearerAuth': []}] if endpoint.auth else [] # Enhance description with metadata if permissions: description += f'
      - Permissions: {permissions}' if throttling: description += f'
      - Throttling: {throttling}' if cache: description += f'
      - Cache: {cache}' if middlewares: description += f'
      - Middlewares: {middlewares}' return { 'description': description, 'security': security, 'deprecated': deprecated, } @classmethod def _build_response_schema( cls, endpoint: Any, response_parser: EndpointParser, schemas: dict[str, Any] ) -> dict[str, Any]: """Build response schema for the endpoint.""" if endpoint.output_schema: status_code = endpoint.output_schema.status_code model = endpoint.output_schema.model elif endpoint.output_model: status_code = response_parser.status_code model = endpoint.output_model else: status_code = response_parser.status_code schema_ref = {'properties': {k: {'default': v} for k, v in response_parser.response_data.items()}} return {'responses': {status_code: {'content': {'application/json': {'schema': schema_ref}}}}} # Add model to schemas if not present model_name = cls.get_model_name(model) if model_name not in schemas: schema = model.schema(ref_template='#/components/schemas/{model}') schema = cls.enrich_schema_with_constraints(schema, model) schemas[model_name] = schema schema_ref = {'$ref': f'#/components/schemas/{model_name}'} # Build responses responses = {'responses': {status_code: {'content': {'application/json': {'schema': schema_ref}}}}} # Add error responses if error_responses := cls._build_error_responses(endpoint): responses['responses'] |= error_responses return responses @classmethod def _build_error_responses(cls, endpoint: Any) -> dict[int, dict[str, str]]: """Build standard error responses for the endpoint.""" error_responses = {} if endpoint.auth: error_responses[401] = {'description': 'Unauthorized'} if endpoint.permissions: error_responses[403] = {'description': 'Forbidden'} if endpoint.input_model: error_responses[400] = {'description': 'Bad Request'} error_responses[422] = {'description': 'Unprocessable Entity'} return error_responses @classmethod def _build_request_body(cls, endpoint: Any, http_method: str, schemas: dict[str, Any]) -> dict[str, Any]: """Build request body schema for the endpoint.""" if not (endpoint.input_model and http_method in cls.REQUEST_BODY_METHODS): return {} model = endpoint.input_model model_name = cls.get_model_name(model) # Add model to schemas if not present if model_name not in schemas: schema = model.schema(ref_template='#/components/schemas/{model}') schema = cls.enrich_schema_with_constraints(schema, model) schemas[model_name] = schema return { 'requestBody': { 'required': True, 'content': {'application/json': {'schema': {'$ref': f'#/components/schemas/{model_name}'}}}, } } @classmethod def generate_openapi_spec(cls) -> dict[str, Any]: """ Generate complete OpenAPI 3.0 specification. Returns: Complete OpenAPI specification dictionary """ paths = {} schemas = {} # Process all registered endpoints for url, endpoint in config.FLAT_URLS.items(): url = url.replace('<', '{').replace('>', '}') if not url.startswith('/'): url = f'/{url}' paths[url] = {} if isinstance(endpoint, types.FunctionType): # Function-based endpoints for method in cls.HTTP_METHODS: if method.upper() in endpoint.methods: paths[url].update(cls.generate_operation_content(endpoint, method, schemas)) else: # Class-based endpoints for method in cls.HTTP_METHODS: # Check if method is overridden (not the default GenericAPI method) if getattr(endpoint, method) is not getattr(GenericAPI, method): paths[url].update(cls.generate_operation_content(endpoint, method, schemas)) # Build security schemes security_schemes = { 'BearerAuth': { 'type': 'http', 'scheme': 'bearer', 'bearerFormat': 'JWT', } } # Build complete OpenAPI specification openapi_spec = { 'openapi': '3.0.0', 'info': { 'title': 'Panther API', 'version': '1.0.0', 'description': 'Auto-generated OpenAPI documentation for Panther project.', }, 'paths': paths, 'components': {'schemas': schemas, 'securitySchemes': security_schemes}, 'security': [{'BearerAuth': []}], } return openapi_spec ================================================ FILE: panther/openapi/views.py ================================================ from panther.app import GenericAPI from panther.openapi import OutputSchema from panther.openapi.utils import OpenAPIGenerator from panther.response import TemplateResponse class ScalarOpenAPI(GenericAPI): output_schema = OutputSchema(exclude_in_docs=True) def get(self): return TemplateResponse(name='scalar.html', context={'openapi': OpenAPIGenerator.generate_openapi_spec()}) class SwaggerOpenAPI(GenericAPI): output_schema = OutputSchema(exclude_in_docs=True) def get(self): return TemplateResponse(name='swagger.html', context={'openapi': OpenAPIGenerator.generate_openapi_spec()}) class RedocOpenAPI(GenericAPI): output_schema = OutputSchema(exclude_in_docs=True) def get(self): return TemplateResponse(name='redoc.html', context={'openapi': OpenAPIGenerator.generate_openapi_spec()}) class RapiDocOpenAPI(GenericAPI): output_schema = OutputSchema(exclude_in_docs=True) def get(self): return TemplateResponse(name='rapidoc.html', context={'openapi': OpenAPIGenerator.generate_openapi_spec()}) class SpotlightOpenAPI(GenericAPI): output_schema = OutputSchema(exclude_in_docs=True) def get(self): return TemplateResponse(name='spotlight.html', context={'openapi': OpenAPIGenerator.generate_openapi_spec()}) ================================================ FILE: panther/pagination.py ================================================ from pantherdb import Cursor as PantherDBCursor from panther.db.cursor import Cursor class Pagination: """ Request URL: example.com/users?limit=10&skip=0 Response Data: { 'count': 10, 'next': '?limit=10&skip=10', 'previous': None, 'results': [...] } """ DEFAULT_LIMIT = 20 DEFAULT_SKIP = 0 def __init__(self, query_params: dict, cursor: Cursor | PantherDBCursor): self.limit = self.get_limit(query_params=query_params) self.skip = self.get_skip(query_params=query_params) self.cursor = cursor def get_limit(self, query_params: dict) -> int: return int(query_params.get('limit', self.DEFAULT_LIMIT)) def get_skip(self, query_params: dict) -> int: return int(query_params.get('skip', self.DEFAULT_SKIP)) def build_next_params(self): next_skip = self.skip + self.limit return f'?limit={self.limit}&skip={next_skip}' def build_previous_params(self): previous_skip = max(self.skip - self.limit, 0) return f'?limit={self.limit}&skip={previous_skip}' def paginate(self): return self.cursor.skip(skip=self.skip).limit(limit=self.limit) async def template(self, response: list): count = await self.cursor.cls.count(self.cursor.filter) has_next = not bool(self.limit + self.skip >= count) return { 'count': count, 'next': self.build_next_params() if has_next else None, 'previous': self.build_previous_params() if self.skip else None, 'results': response, } ================================================ FILE: panther/panel/__init__.py ================================================ ================================================ FILE: panther/panel/authentications.py ================================================ from panther.authentications import CookieJWTAuthentication from panther.exceptions import AuthenticationAPIError, RedirectAPIError from panther.request import Request class AdminCookieJWTAuthentication(CookieJWTAuthentication): async def __call__(self, request: Request): try: return await super().__call__(request=request) except AuthenticationAPIError: raise RedirectAPIError(url=f'login?redirect_to={request.path}') ================================================ FILE: panther/panel/middlewares.py ================================================ from panther.middlewares import HTTPMiddleware from panther.request import Request from panther.response import RedirectResponse class RedirectToSlashMiddleware(HTTPMiddleware): async def __call__(self, request: Request): if not request.path.endswith('/'): return RedirectResponse(request.path + '/') return await self.dispatch(request=request) ================================================ FILE: panther/panel/permissions.py ================================================ from panther.exceptions import RedirectAPIError from panther.permissions import BasePermission from panther.request import Request class IsAuthenticated(BasePermission): async def __call__(self, request: Request): if request.user is None: raise RedirectAPIError(url=f'login?redirect_to={request.path}') ================================================ FILE: panther/panel/templates/base.html ================================================ {{ title }} {% block content %} {% endblock %} ================================================ FILE: panther/panel/templates/create.html ================================================ {% extends "base.html" %} {% block content %}

      Create New Record

      {% endblock %} ================================================ FILE: panther/panel/templates/create.js ================================================ const schema = JSON.parse(`{{fields|tojson|safe}}`); function toggleObjectVisibility(checkbox, contentId) { const content = document.getElementById(contentId); content.classList.toggle("hidden", !checkbox.checked); // Disable/enable all inputs within the container const inputs = content.querySelectorAll("input, select, textarea"); inputs.forEach((input) => { input.disabled = !checkbox.checked; }); } function createObjectInputs(objectSchema, container, prefix = "") { if (!objectSchema || !objectSchema.fields) return; Object.entries(objectSchema.fields).forEach(([fieldName, field]) => { const fullFieldName = prefix ? `${prefix}.${fieldName}` : fieldName; // Check if it's an array type if (field.type.includes("array")) { // If items is specified, use it if (field.items) { const itemType = field.items.replace("$", ""); createArrayField(fieldName, itemType, container, fullFieldName, field); } else { // Handle array without items specification (simple types) createSimpleArrayField(fieldName, container, fullFieldName, field); } } else if ( Array.isArray(field.type) && field.type.some((t) => t.startsWith("$")) ) { const objectType = field.type .find((t) => t.startsWith("$")) .replace("$", ""); createNestedObjectField( fieldName, objectType, field, container, fullFieldName ); } else { createBasicInput(fieldName, field, container, fullFieldName); } }); } function toggleArrayVisibility(checkbox, contentId) { const content = document.getElementById(contentId); if (content) { content.classList.toggle("hidden", !checkbox.checked); // Also enable/disable inputs const inputs = content.querySelectorAll("input, select, textarea"); inputs.forEach((input) => { input.disabled = !checkbox.checked; }); } } function createSimpleArrayField(fieldName, container, fullFieldName, field) { const arrayContainer = document.createElement("div"); arrayContainer.className = "border border-gray-400 p-4 rounded-lg space-y-2"; const spreadsheetId = `${fullFieldName}-container`; const header = document.createElement("div"); header.className = "flex items-center justify-between mb-4"; header.innerHTML = `

      ${field.title || fieldName}

      ${ field.type.includes("null") ? ` ` : "" } `; const content = document.createElement("div"); content.innerHTML = `
      `; if (field.type.includes("null") && !field.required) { content.classList.add("hidden"); } arrayContainer.appendChild(header); arrayContainer.appendChild(content); container.appendChild(arrayContainer); } function addSimpleArrayRow(arrayName, containerId) { const container = document.getElementById(containerId); if (!container) { console.error("Invalid container"); return; } const rowIndex = container.children.length; const rowContainer = document.createElement("div"); rowContainer.className = "flex items-center space-x-4"; const input = document.createElement("input"); input.type = "text"; input.name = `${arrayName}[${rowIndex}]`; input.className = "flex-grow bg-gray-700 border border-gray-600 rounded px-3 py-2"; const deleteButton = document.createElement("button"); deleteButton.className = "bg-red-600 px-3 py-1 rounded text-sm"; deleteButton.textContent = "Delete"; deleteButton.onclick = () => { rowContainer.remove(); reindexArrayItems(containerId); }; rowContainer.appendChild(input); rowContainer.appendChild(deleteButton); container.appendChild(rowContainer); } function reindexArrayItems(containerId) { const container = document.getElementById(containerId); const items = container.children; // Update the index for each remaining item Array.from(items).forEach((item, newIndex) => { const input = item.querySelector("input"); if (input) { const oldName = input.name; const baseName = oldName.split("[")[0]; input.name = `${baseName}[${newIndex}]`; } }); } function createNestedObjectField( fieldName, objectType, field, container, fullFieldName ) { const objectWrapper = document.createElement("div"); objectWrapper.className = "space-y-4 border border-gray-400 p-4 rounded-lg"; const cleanObjectType = objectType.replace(/^\$/, ""); const header = document.createElement("div"); header.className = "flex items-center justify-between mb-4"; header.innerHTML = `

      ${ field.title || fieldName }

      `; const contentContainer = document.createElement("div"); contentContainer.id = `${fullFieldName}_content`; contentContainer.className = "space-y-4"; // Add toggle for optional objects if (!field.required) { const toggleContainer = document.createElement("div"); toggleContainer.className = "flex items-center space-x-3"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.id = `${fullFieldName}_toggle`; checkbox.className = "form-checkbox h-5 w-5 text-blue-600"; const label = document.createElement("label"); label.htmlFor = `${fullFieldName}_toggle`; label.textContent = `Include ${field.title || fieldName}`; toggleContainer.appendChild(checkbox); toggleContainer.appendChild(label); header.appendChild(toggleContainer); // Toggle handler to enable/disable validation checkbox.addEventListener("change", (e) => { const fields = contentContainer.querySelectorAll( "input, select, textarea" ); fields.forEach((field) => { if (!e.target.checked) { // When unchecked, disable and remove required attribute field.disabled = true; field.required = false; field.value = ""; // Clear the value } else { // When checked, enable and restore required if it was originally required field.disabled = false; field.required = field.dataset.originalRequired === "true"; } }); }); // Initialize as unchecked checkbox.checked = false; setTimeout(() => checkbox.dispatchEvent(new Event("change")), 0); } const nestedSchema = schema.$[cleanObjectType]; createObjectInputs(nestedSchema, contentContainer, fullFieldName); // Store original required state for all nested fields contentContainer .querySelectorAll("input, select, textarea") .forEach((field) => { field.dataset.originalRequired = field.required; if (!field.required) { field.required = false; } }); objectWrapper.appendChild(header); objectWrapper.appendChild(contentContainer); container.appendChild(objectWrapper); } function createObjectInputs(objectSchema, container, prefix = "") { if (!objectSchema || !objectSchema.fields) return; Object.entries(objectSchema.fields).forEach(([fieldName, field]) => { const fullFieldName = prefix ? `${prefix}.${fieldName}` : fieldName; // Check if it's an array type if (field.type.includes("array")) { // If items is specified, use it if (field.items) { const itemType = field.items.replace("$", ""); createArrayField(fieldName, itemType, container, fullFieldName, field); } else { // Handle array without items specification (simple types) createSimpleArrayField(fieldName, container, fullFieldName, field); } } else if ( // Check if type is an array and contains a reference to another type (Array.isArray(field.type) && field.type.some((t) => t.startsWith("$"))) || // Or if type is a string and is a reference (typeof field.type === "string" && field.type.startsWith("$")) ) { const objectType = Array.isArray(field.type) ? field.type.find((t) => t.startsWith("$")) : field.type; createNestedObjectField( fieldName, objectType, field, container, fullFieldName ); } else { createBasicInput(fieldName, field, container, fullFieldName); } }); } function createBasicInput(fieldName, field, container, fullFieldName) { const inputWrapper = document.createElement("div"); inputWrapper.className = "space-y-2"; if (fieldName === "id" && typeof isUpdate === "undefined") { return; } // Hide ID field in update mode if (fieldName === "_id" && isUpdate) { const inputWrapper = document.createElement("div"); inputWrapper.className = "space-y-2"; inputWrapper.style.display = "none"; // Hide the entire wrapper inputWrapper.innerHTML = ` `; container.appendChild(inputWrapper); return; } let inputHTML = ""; const defaultValue = field.default !== undefined ? `value="${field.default}"` : ""; const requiredText = field.required ? `* Required` : ""; if (field.type.includes("boolean")) { inputHTML = ` `; } else if (field.type.includes("string")) { inputHTML = ` `; } else if (field.type.includes("integer")) { const min = field.min !== undefined ? `min="${field.min}"` : ""; const max = field.max !== undefined ? `max="${field.max}"` : ""; inputHTML = ` `; } inputWrapper.innerHTML = inputHTML; container.appendChild(inputWrapper); } function createArrayField(fieldName, itemType, container, fullFieldName) { const arrayContainer = document.createElement("div"); arrayContainer.className = "border border-gray-700 p-4 rounded-lg space-y-4"; const spreadsheetId = `${fullFieldName}-container`; // Make sure itemType exists in schema.$ if (!schema.$ || !schema.$[itemType]) { console.error(`Schema type ${itemType} not found`); return; } arrayContainer.innerHTML = `

      ${fieldName}

      `; container.appendChild(arrayContainer); } function addArrayRow(arrayName, itemType, containerId) { const container = document.getElementById(containerId); if (!container || !schema.$ || !schema.$[itemType]) { console.error("Invalid container or schema type"); return; } const itemSchema = schema.$[itemType]; const rowIndex = container.children.length; const rowContainer = document.createElement("div"); rowContainer.className = "flex gap-2 items-start space-x-4 py-4 pl-3 border pr-2 bg-gray-800/80 border-gray-500 rounded-lg"; const itemContent = document.createElement("div"); itemContent.className = "flex-grow flex flex-col gap-8 w-full"; createObjectInputs(itemSchema, itemContent, `${arrayName}[${rowIndex}]`); const deleteButton = document.createElement("button"); deleteButton.className = "bg-red-600 m-1 p-1 min-w-8 min-h-8 rounded text-sm"; deleteButton.textContent = "X"; deleteButton.onclick = () => { rowContainer.remove(); reindexComplexArrayItems(containerId); }; rowContainer.appendChild(itemContent); rowContainer.appendChild(deleteButton); container.appendChild(rowContainer); } function reindexComplexArrayItems(containerId) { const container = document.getElementById(containerId); const items = container.children; Array.from(items).forEach((item, newIndex) => { const inputs = item.querySelectorAll("input, select, textarea"); inputs.forEach((input) => { const oldName = input.name; const fieldPart = oldName.split("]")[1]; // Get the part after the index const baseName = oldName.split("[")[0]; input.name = `${baseName}[${newIndex}]${fieldPart || ""}`; }); }); } function openObjectModal(fieldName, objectType) { // Create modal for editing nested object const modal = document.createElement("div"); modal.className = "fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center"; modal.innerHTML = `

      Edit ${fieldName}

      `; const objectSchema = schema.$[objectType]; createObjectInputs( objectSchema, modal.querySelector(".object-inputs"), fieldName ); document.body.appendChild(modal); } const dynamicInputs = document.getElementById("dynamicInputs"); createObjectInputs(schema, dynamicInputs); // Check if the page is in update mode if (typeof isUpdate !== "undefined" && isUpdate) { // Populate the form with existing data for update mode populateFormWithExistingData(existingData); } else { console.log("Create mode: No existing data to populate."); } // Function to populate the form with existing data function populateFormWithExistingData(data) { console.log("Populating form with data:", data); // Handle simple fields first Object.entries(data).forEach(([key, value]) => { const input = document.querySelector(`[name="${key}"]`); if (input) { if (input.type === "checkbox") { input.checked = Boolean(value); } else { input.value = value !== null ? value : ""; } } }); // Special case for the `_id` field // Find the ID input field const idInput = document.querySelector(`[name="_id"]`); if (idInput) { // If we're in update mode and have an ID, set the value if (data && data.id) { idInput.value = data.id; } // Hide the input itself idInput.style.display = "none"; // Also hide the parent container (which likely contains the label) const parentContainer = idInput.closest(".form-group") || idInput.parentElement; if (parentContainer) { parentContainer.style.display = "none"; } // Keep the field in the form data by making it readonly but not disabled idInput.setAttribute("readonly", true); } // Handle nested objects and arrays populateNestedData(data); } function populateNestedData(data, prefix = "") { Object.entries(data).forEach(([key, value]) => { const fullPath = prefix ? `${prefix}.${key}` : key; // Handle arrays if (Array.isArray(value)) { // Find the array container const containerID = `${fullPath}-container`; const container = document.getElementById(containerID); if (container) { // Ensure toggle is checked for this array if it exists const toggle = document.getElementById(`${fullPath}_toggle`); if (toggle) { toggle.checked = true; toggleArrayVisibility(toggle, containerID); } // Clear any existing items container.innerHTML = ""; // Recreate each array item value.forEach((item, index) => { if (typeof item === "object" && item !== null) { // Complex object in array const itemType = determineItemType(item); if (itemType) { addArrayRow(fullPath, itemType, containerID); // After adding the row, populate its fields // The new row should be the last child of the container const newRow = container.lastElementChild; if (newRow) { // Find all inputs in this row and set their values Object.entries(item).forEach(([itemKey, itemValue]) => { const itemPath = `${fullPath}[${index}].${itemKey}`; populateItemField(newRow, itemPath, itemValue); }); } } } else { // Simple value in array addSimpleArrayRow(fullPath, containerID); // Get the last added row and set its value const rowInput = container.lastElementChild.querySelector("input"); if (rowInput) { rowInput.value = item; } } }); } } // Handle nested objects else if (typeof value === "object" && value !== null) { // Find the content container for this object const contentId = `${fullPath}_content`; const content = document.getElementById(contentId); if (content) { // Enable the toggle if it exists const toggle = document.getElementById(`${fullPath}_toggle`); if (toggle) { toggle.checked = true; // Manually enable all fields in this container const inputs = content.querySelectorAll("input, select, textarea"); inputs.forEach((input) => { input.disabled = false; }); } // Populate the fields in this object Object.entries(value).forEach(([nestedKey, nestedValue]) => { const nestedPath = `${fullPath}.${nestedKey}`; populateItemField(content, nestedPath, nestedValue); }); } } }); } function populateItemField(container, fieldPath, value) { const input = container.querySelector(`[name="${fieldPath}"]`); if (input) { if (input.type === "checkbox") { input.checked = Boolean(value); } else { input.value = value !== null ? value : ""; } } } function determineItemType(item) { // Try to match the item structure with schema definitions for (const [typeName, typeSchema] of Object.entries(schema.$)) { if (typeSchema && typeSchema.fields) { const fieldNames = Object.keys(typeSchema.fields); // If most of the item keys match the schema fields, assume it's this type const matchingFields = fieldNames.filter((field) => field in item); if ( matchingFields.length > 0 && matchingFields.length / fieldNames.length >= 0.5 ) { return typeName; } } } return null; } document .getElementById(isUpdate ? "updateForm" : "createForm") .addEventListener("submit", async (e) => { e.preventDefault(); // Create an object to hold our updated data const updatedData = {}; // Extract the current form values const formData = new FormData(e.target); // Debug log console.log("Form data entries:"); for (let [key, value] of formData.entries()) { console.log(`FormData Key: ${key}, Value: ${value}`); } // Process each form field for (let [key, value] of formData.entries()) { // Skip disabled fields (they won't be included in FormData anyway) const field = e.target.querySelector(`[name="${key}"]`); if (field && field.disabled) continue; // Parse the key to handle nested structures const parts = key.split(/[\[\].]/).filter(Boolean); // Start at the root of our data object let current = updatedData; // Build the nested structure for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (i === parts.length - 1) { // We're at the leaf node, set the actual value // Handle boolean values from checkboxes if (field && field.type === "checkbox") { value = field.checked; } // Convert string "true"/"false" to boolean else if (value === "true" || value === "false") { value = value === "true"; } // Convert numeric strings to numbers else if (!isNaN(value) && value !== "") { value = Number(value); } // Set the value in our data structure current[part] = value; } else { // We're building the nested structure // Check if the next part is a number (array index) if (/^\d+$/.test(parts[i + 1])) { // Create array if it doesn't exist current[part] = current[part] || []; } else { // Create object if it doesn't exist current[part] = current[part] || {}; } // Move deeper into the structure current = current[part]; } } } // Process unchecked checkboxes (they don't appear in FormData) const allCheckboxes = e.target.querySelectorAll('input[type="checkbox"]'); allCheckboxes.forEach((checkbox) => { if (checkbox.disabled) return; // Skip disabled checkboxes const name = checkbox.name; const parts = name.split(/[\[\].]/).filter(Boolean); // Check if this checkbox's value is already in updatedData // If not, it means it was unchecked let current = updatedData; let exists = true; for (let i = 0; i < parts.length - 1; i++) { if (!current[parts[i]]) { exists = false; break; } current = current[parts[i]]; } const lastPart = parts[parts.length - 1]; if (exists && !(lastPart in current)) { current[lastPart] = false; } }); // Copy over the ID field to ensure it's included if (isUpdate && data && data.id) { updatedData.id = data.id; } try { const response = await fetch(window.location.pathname, { method: isUpdate ? "PUT" : "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(updatedData), }); if (response.ok) { const result = await response.json(); console.log("Success:", result); showToast( "Success", `Your data has been ${ isUpdate ? "updated" : "submitted" } successfully!`, "success" ); } else { const errorText = await response.text(); console.error("Error:", response.status, response.statusText); showToast("Error", `Error ${response.status}: ${errorText}`, "error"); } } catch (error) { console.error("Fetch error:", error); showToast( "Error", "An unexpected error occurred. Please try again.", "error" ); } }); // Toast function function showToast(title, message, type) { const toastContainer = document.getElementById("toastContainer") || createToastContainer(); const toast = document.createElement("div"); toast.className = `toast ${ type === "success" ? "border-green-600" : "border-red-600" } p-4 mb-4 rounded shadow-lg bg-gray-900 text-gray-100 border-l-4 p-4 rounded-lg shadow-md animate-fadeIn`; toast.innerHTML = ` ${title} ${message} `; toastContainer.appendChild(toast); // Automatically remove the toast after 5 seconds setTimeout(() => { toast.remove(); }, 5000); } function createToastContainer() { const container = document.createElement("div"); container.id = "toastContainer"; container.className = "fixed top-4 left-4 z-50 space-y-4"; document.body.appendChild(container); return container; } document.getElementById("deleteButton").addEventListener("click", async () => { if (!data.id) { console.error("No ID found for deletion."); showToast("Error", "No ID found for deletion.", "error"); return; } const confirmDelete = confirm("Are you sure you want to delete this record?"); if (!confirmDelete) return; try { const response = await fetch(window.location.pathname, { method: "DELETE", headers: { "Content-Type": "application/json", }, }); if (response.ok) { console.log("Record deleted successfully."); showToast("Success", "Record deleted successfully!", "success"); const currentUrl = window.location.pathname; const urlParts = currentUrl.split("/").filter((part) => part !== ""); urlParts.pop(); const redirectUrl = "/" + urlParts.join("/") + "/"; console.log("Redirecting to:", redirectUrl); setTimeout(() => { window.location.href = redirectUrl; }, 2000); } else { const errorText = await response.text(); console.error("Error:", response.status, response.statusText); showToast("Error", `Error ${response.status}: ${errorText}`, "error"); } } catch (error) { console.error("Fetch error:", error); showToast( "Error", "An unexpected error occurred. Please try again.", "error" ); } }); // Form Update Real-Time Logger and Debugger // Create a logging container function createLogContainer() { const existingContainer = document.getElementById("formDataLogger"); if (existingContainer) return existingContainer; const logContainer = document.createElement("div"); logContainer.id = "formDataLogger"; logContainer.className = "fixed bottom-4 right-4 w-96 max-h-96 bg-gray-800 border border-gray-600 rounded-lg shadow-lg p-3 overflow-y-auto z-50"; logContainer.style.maxHeight = "400px"; logContainer.innerHTML = `

      Form Data Logger

      `; document.body.appendChild(logContainer); // Add button event listeners document.getElementById("clearLogBtn").addEventListener("click", () => { document.getElementById("logEntries").innerHTML = ""; }); document.getElementById("toggleLogBtn").addEventListener("click", (e) => { const logEntries = document.getElementById("logEntries"); if (logEntries.style.display === "none") { logEntries.style.display = "block"; e.target.textContent = "Hide"; } else { logEntries.style.display = "none"; e.target.textContent = "Show"; } }); document.getElementById("captureFormState").addEventListener("click", () => { const currentState = captureFormState(); logMessage("Form State Snapshot", currentState, "snapshot"); console.log("Current Form State:", currentState); }); return logContainer; } // Capture current form state (all fields) function captureFormState() { const form = document.getElementById(isUpdate ? "updateForm" : "createForm"); if (!form) return null; const formState = {}; // Get all input elements const inputs = form.querySelectorAll("input, select, textarea"); inputs.forEach((input) => { if (!input.name) return; // Skip disabled inputs if we want to capture only enabled fields // if (input.disabled) return; if (input.type === "checkbox") { formState[input.name] = input.checked; } else { formState[input.name] = input.value; } }); // Process the form data into a nested structure const structuredData = {}; Object.entries(formState).forEach(([key, value]) => { // Parse the key to handle nested structures const parts = key.split(/[\[\].]/).filter(Boolean); // Start at the root of our data object let current = structuredData; // Build the nested structure for (let i = 0; i < parts.length; i++) { const part = parts[i]; if (i === parts.length - 1) { // We're at the leaf node, set the actual value // Handle special value conversions if (typeof value === "string") { if (value === "true" || value === "false") { value = value === "true"; } else if (!isNaN(value) && value !== "") { value = Number(value); } } // Set the value in our data structure current[part] = value; } else { // We're building the nested structure // Check if the next part is a number (array index) if (/^\d+$/.test(parts[i + 1])) { // Create array if it doesn't exist current[part] = current[part] || []; } else { // Create object if it doesn't exist current[part] = current[part] || {}; } // Move deeper into the structure current = current[part]; } } }); return structuredData; } // Log a message to the log container function logMessage(title, data, type = "info") { const logEntries = document.getElementById("logEntries"); if (!logEntries) return; const timestamp = new Date().toLocaleTimeString(); const logEntry = document.createElement("div"); // Different styling based on type if (type === "error") { logEntry.className = "p-2 rounded bg-red-900/50 border-l-4 border-red-600"; } else if (type === "warning") { logEntry.className = "p-2 rounded bg-yellow-900/50 border-l-4 border-yellow-600"; } else if (type === "success") { logEntry.className = "p-2 rounded bg-green-900/50 border-l-4 border-green-600"; } else if (type === "snapshot") { logEntry.className = "p-2 rounded bg-blue-900/50 border-l-4 border-blue-600"; } else { logEntry.className = "p-2 rounded bg-gray-800 border border-gray-700"; } // Format data object to string let dataHtml = ""; if (data === null || data === undefined) { dataHtml = 'null or undefined'; } else if (typeof data === "object") { try { const jsonStr = JSON.stringify(data, null, 2); if (jsonStr === "{}") { dataHtml = 'Empty object {}'; } else { dataHtml = `
      ${escapeHtml(
                jsonStr
              )}
      `; } } catch (e) { dataHtml = `Error stringifying: ${e.message}`; } } else { dataHtml = `${escapeHtml(String(data))}`; } logEntry.innerHTML = `
      ${escapeHtml(title)} ${timestamp}
      ${dataHtml}
      `; logEntries.prepend(logEntry); // Limit the number of log entries const maxEntries = 50; while (logEntries.children.length > maxEntries) { logEntries.removeChild(logEntries.lastChild); } } // Escape HTML to prevent XSS function escapeHtml(str) { return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // Monitor form element changes function attachFormMonitors() { const form = document.getElementById(isUpdate ? "updateForm" : "createForm"); if (!form) { logMessage( "Form not found", `Could not find form with ID: ${isUpdate ? "updateForm" : "createForm"}`, "error" ); return; } // Monitor input changes form.addEventListener("input", (e) => { if (!e.target.name) return; const fieldName = e.target.name; const newValue = e.target.type === "checkbox" ? e.target.checked : e.target.value; logMessage(`Field Changed: ${fieldName}`, { value: newValue }, "info"); }); // Monitor checkbox toggles form.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => { if (checkbox.id && checkbox.id.includes("_toggle")) { checkbox.addEventListener("change", () => { const targetId = checkbox.id.replace("_toggle", "_content"); logMessage(`Toggle Section: ${targetId}`, { checked: checkbox.checked, visible: checkbox.checked, }); }); } }); // Intercept form submission const originalSubmit = form.onsubmit; form.onsubmit = async function (e) { e.preventDefault(); logMessage( "Form Submission Started", { time: new Date().toISOString(), isUpdate: isUpdate || false, }, "info" ); // Capture and log the complete form state const formState = captureFormState(); logMessage( "Form Submission Data", formState, formState && Object.keys(formState).length ? "success" : "warning" ); // Collect FormData entries for debugging const formData = new FormData(form); const formDataEntries = {}; for (let [key, value] of formData.entries()) { formDataEntries[key] = value; } logMessage("Raw FormData Entries", formDataEntries); // Check for empty submission if (!formState || Object.keys(formState).length === 0) { logMessage( "Empty Form Submission", "The form data is empty! Check for form field naming issues.", "error" ); } // Call the original submit handler if it exists if (originalSubmit) { return originalSubmit.call(this, e); } }; // Override the existing updateData function if it exists if (typeof window.updateData === "function") { const originalUpdateData = window.updateData; window.updateData = function (data) { logMessage("updateData Called", data, "info"); return originalUpdateData.apply(this, arguments); }; } // Override the populateFormWithExistingData function if (typeof window.populateFormWithExistingData === "function") { const originalPopulate = window.populateFormWithExistingData; window.populateFormWithExistingData = function (data) { logMessage("Populating Form with Data", data, data ? "info" : "warning"); if (!data || Object.keys(data).length === 0) { logMessage( "Empty Data for Form Population", "This might explain missing form values", "warning" ); } // Call original function const result = originalPopulate.apply(this, arguments); // Capture the form state after population setTimeout(() => { const formState = captureFormState(); logMessage("Form State After Population", formState, "snapshot"); // Check if fields are disabled when they shouldn't be const disabledInputs = form.querySelectorAll( "input:disabled, select:disabled, textarea:disabled" ); if (disabledInputs.length > 0) { const disabledFields = Array.from(disabledInputs) .map((el) => el.name) .filter(Boolean); if (disabledFields.length > 0) { logMessage("Found Disabled Fields", disabledFields, "warning"); } } }, 100); return result; }; } // Debug the fetch call const originalFetch = window.fetch; window.fetch = function (...args) { const [url, options] = args; if ( options && options.method && (options.method === "POST" || options.method === "PUT") ) { logMessage( `Fetch Request (${options.method})`, { url: url, method: options.method, bodyData: options.body ? JSON.parse(options.body) : null, }, "info" ); } const fetchPromise = originalFetch.apply(this, args); fetchPromise .then((response) => { if (!response.ok) { logMessage( "Fetch Error", { status: response.status, statusText: response.statusText, }, "error" ); } else { logMessage( "Fetch Success", { status: response.status, }, "success" ); } }) .catch((error) => { logMessage( "Fetch Failed", { error: error.message, }, "error" ); }); return fetchPromise; }; } // Initialize the logger function initFormLogger() { createLogContainer(); attachFormMonitors(); // Log initial system state logMessage( "Form Logger Initialized", { url: window.location.href, isUpdate: typeof isUpdate !== "undefined" ? isUpdate : false, time: new Date().toISOString(), }, "info" ); // Check if existingData is available if (typeof existingData !== "undefined") { logMessage( "Existing Data Found", existingData, existingData ? "info" : "warning" ); } else { logMessage("No Existing Data", "existingData is not defined", "warning"); } // Log initial schema if (typeof schema !== "undefined") { logMessage("Schema Structure", { hasFields: schema.fields ? true : false, nestedTypes: schema.$ ? Object.keys(schema.$) : [], }); } // Check form action is correctly set setTimeout(() => { const form = document.getElementById( isUpdate ? "updateForm" : "createForm" ); if (form) { logMessage("Form Configuration", { action: form.action || window.location.pathname, method: form.method || "POST/PUT via JS", id: form.id, containsInputs: form.querySelectorAll("input, select, textarea").length > 0, }); } }, 500); } // Run on page load document.addEventListener("DOMContentLoaded", function () { setTimeout(initFormLogger, 100); // Slight delay to ensure page is fully loaded }); // For immediate execution if the page is already loaded if ( document.readyState === "complete" || document.readyState === "interactive" ) { setTimeout(initFormLogger, 100); } ================================================ FILE: panther/panel/templates/detail.html ================================================ {% extends "base.html" %} {% block content %}

      Update Record

      {% endblock %} ================================================ FILE: panther/panel/templates/home.html ================================================ {% extends "base.html" %} {% block content %}
      {% include "sidebar.html" %}
      {% endblock %} ================================================ FILE: panther/panel/templates/home.js ================================================ function getCurrentTableIndex() { const path = window.location.pathname; const match = path.match(/\/admin\/(\d+)/); return match ? parseInt(match[1]) : 0; } function selectTable(element) { const index = element.dataset.index; // Always trigger a page reload even if it's the same index window.location.href = `/admin/${index}/`; } function setActiveTableFromUrl() { const currentIndex = getCurrentTableIndex(); const tableItems = document.querySelectorAll('.table-item'); tableItems.forEach((item) => { item.classList.remove('bg-blue-600'); item.classList.add('bg-gray-700'); if (parseInt(item.dataset.index) === currentIndex) { item.classList.remove('bg-gray-700'); item.classList.add('bg-blue-600'); } }); } document.addEventListener('DOMContentLoaded', () => { setActiveTableFromUrl(); }); ================================================ FILE: panther/panel/templates/login.html ================================================ {% extends "base.html" %} {% block content %}

      Login

      {% if error %}
      {{ error }}
      {% endif %}
      {% endblock %} ================================================ FILE: panther/panel/templates/sidebar.html ================================================ ================================================ FILE: panther/panel/templates/table.html ================================================ {% extends "base.html" %} {% block content %}
      {% for record in records %} {% endfor %}
      {% endblock %} ================================================ FILE: panther/panel/templates/table.js ================================================ const fields = JSON.parse(`{{fields|tojson|safe}}`); const fieldsObject = fields.fields; const fieldsArray = Object.entries(fieldsObject).map(([key, value]) => ({ title: value.title || key, type: value.type || [], required: value.required || false, })); function pythonToJSON(str) { str = str.replace(/datetime\.datetime\(([^)]+)\)/g, (match, contents) => { const parts = contents.split(",").map((part) => part.trim()); if (parts.length >= 6) { return `"${parts[0]}-${parts[1].padStart(2, "0")}-${parts[2].padStart( 2, "0" )} ${parts[3].padStart(2, "0")}:${parts[4].padStart( 2, "0" )}:${parts[5].padStart(2, "0")}"`; } return '"Invalid datetime"'; }); str = str.replace(/tzinfo=\)/g, "tzinfo=None)"); return str .replace(/'/g, '"') .replace(/False/g, "false") .replace(/True/g, "true") .replace(/None/g, "null"); } function goToCreatePage() { // Get the current URL without any trailing slash const currentUrl = window.location.href.replace(/\/+$/, ""); // Navigate to the current URL + /create window.location.href = `${currentUrl}/create`; } function getDataType(value) { if (value === null) return "null"; if (Array.isArray(value)) return "array"; return typeof value; } let allRecords = []; function initializeRecords() { if (allRecords.length === 0) { try { allRecords = Array.from(document.querySelectorAll(".record-data")).map( (item) => { try { return JSON.parse(pythonToJSON(item.textContent.trim())); } catch (e) { console.error("Error parsing record:", e); return {}; } } ); } catch (e) { console.error("Error initializing records:", e); allRecords = []; } } } function formatValue(value, type) { if (value === null) return 'null'; switch (type) { case "array": return `
      Array (${value.length} items)
      ${value .map( (item, index) => `
      [${index}]: ${formatValue(item, getDataType(item))}
      ` ) .join("")}
      `; case "object": if (value === null) { return 'null'; } return `
      Object (${Object.keys(value).length} props)
      ${Object.entries(value) .map( ([key, val]) => `
      ${key}: ${formatValue(val, getDataType(val))}
      ` ) .join("")}
      `; case "boolean": return `${value}`; default: return `${String(value)}`; } } function toggleDropdown(button) { const dropdown = button.nextElementSibling; const allDropdowns = document.querySelectorAll( ".relative.inline-block .absolute" ); // Close all other dropdowns allDropdowns.forEach((d) => { if (d !== dropdown) d.classList.add("hidden"); }); // Toggle current dropdown dropdown.classList.toggle("hidden"); // Close dropdown when clicking outside const closeDropdown = (e) => { if (!button.contains(e.target) && !dropdown.contains(e.target)) { dropdown.classList.add("hidden"); document.removeEventListener("click", closeDropdown); } }; document.addEventListener("click", closeDropdown); } // Define pagination variables let currentPage = 1; const rowsPerPage = 10; // Number of rows per page function renderTable() { // Initialize records if not already done initializeRecords(); const thead = document.getElementById("tableHead"); // Use the global records array instead of re-parsing const records = allRecords; console.log(records); if (records.length > 0) { const firstRecord = records[0]; const headers = Object.entries(firstRecord).map(([key, value]) => ({ key, type: getDataType(value), })); // Render table headers thead.innerHTML = ` ${headers .map( (field) => ` ${field.key} (${field.type}) ` ) .join("")} `; // Calculate start and end indices for the current page const startIndex = (currentPage - 1) * rowsPerPage; const endIndex = startIndex + rowsPerPage; // Get the rows for the current page const rowsForPage = records.slice(startIndex, endIndex); // Get all row elements const rowElements = document.querySelectorAll(".record-row"); // Render table rows rowElements.forEach((row, index) => { const record = rowsForPage[index]; if (record) { row.innerHTML = headers .map(({ key }) => { const cellValue = record[key]; const cellType = getDataType(cellValue); // If this is the ID cell, make it clickable if (key === "id") { return ` ${formatValue(cellValue, cellType)} `; } else { return ` ${formatValue(cellValue, cellType)} `; } }) .join(""); row.style.display = ""; // Show the row // Remove the row click event row.style.cursor = "default"; row.onclick = null; } else { row.style.display = "none"; // Hide unused rows } }); // Render pagination controls renderPaginationControls(records.length); } } function renderPaginationControls(totalRows) { const paginationContainer = document.getElementById("paginationControls"); const totalPages = Math.ceil(totalRows / rowsPerPage); // Render "Previous" and "Next" buttons paginationContainer.innerHTML = ` Page ${currentPage} of ${totalPages} `; } function changePage(direction) { const totalPages = Math.ceil(allRecords.length / rowsPerPage); if (direction === "prev" && currentPage > 1) { currentPage--; } else if (direction === "next" && currentPage < totalPages) { currentPage++; } renderTable(); } function getCurrentTableIndex() { const path = window.location.pathname; const match = path.match(/\/admin\/(\d+)/); return match ? parseInt(match[1]) : 0; } function selectTable(element, event) { const index = element.dataset.index; // Hide all add buttons first document.querySelectorAll('.add-record-btn').forEach(btn => { btn.classList.add('hidden'); }); // Show the add button for the clicked table const addButton = element.querySelector('.add-record-btn'); if (addButton) { addButton.classList.remove('hidden'); } // Only update URL if it's different from current if (index !== getCurrentTableIndex().toString()) { window.location.href = `/admin/${index}`; } } // Set active table based on URL function setActiveTableFromUrl() { const currentIndex = getCurrentTableIndex(); const tableItems = document.querySelectorAll(".table-item"); tableItems.forEach((item) => { // Hide all add buttons first const addButton = item.querySelector('.add-record-btn'); if (addButton) { addButton.classList.add('hidden'); } item.classList.remove("bg-blue-800"); item.classList.add("bg-gray-700"); if (parseInt(item.dataset.index) === currentIndex) { item.classList.remove("bg-gray-700"); item.classList.add("bg-blue-800"); // Show add button for the active table if (addButton) { addButton.classList.remove('hidden'); } } }); } // Initialize based on URL document.addEventListener("DOMContentLoaded", () => { initializeRecords(); renderTable(); setActiveTableFromUrl(); }); ================================================ FILE: panther/panel/urls.py ================================================ from panther.panel.views import CreateView, DetailView, HomeView, LoginView, TableView url_routing = { '': HomeView, '/': TableView, '/create/': CreateView, 'login/': LoginView, '//': DetailView, } ================================================ FILE: panther/panel/utils.py ================================================ from collections import defaultdict from types import NoneType from typing import Any from pydantic import BaseModel from panther.configs import config from panther.response import IterableDataTypes def _ref_name(ref: str) -> str: obj_name = ref.rsplit('/', maxsplit=1)[1] return f'${obj_name}' def clean_model_schema(schema: dict) -> dict: """ Example: { 'title': 'Author', '$': { 'Book': { 'title': 'Book', 'fields': { 'title': {'title': 'Title', 'type': ['string'], 'required': True}, 'pages_count': {'title': 'Pages Count', 'type': ['integer'], 'required': True}, 'readers': {'title': 'Readers', 'type': ['array', 'null'], 'items': '$Person', 'default': None, 'required': False}, 'co_owner': {'type': ['$Person', 'null'], 'default': None, 'required': False} } }, 'Parent': { 'title': 'Parent', 'fields': { 'name': {'title': 'Name', 'type': ['string'], 'required': True}, 'age': {'title': 'Age', 'type': ['string'], 'required': True}, 'has_child': {'title': 'Has Child', 'type': ['boolean'], 'required': True} } }, 'Person': { 'title': 'Person', 'fields': { 'age': {'title': 'Age', 'type': ['integer'], 'required': True}, 'real_name': {'title': 'Real Name', 'type': ['string'], 'required': True}, 'parent': {'type': '$Parent', 'required': True}, 'is_alive': {'title': 'Is Alive', 'type': ['boolean'], 'required': True}, 'friends': {'title': 'Friends', 'type': ['array'], 'items': '$Person', 'required': True} } } }, 'fields': { '_id': {'title': ' Id', 'type': ['string', 'null'], 'default': None, 'required': False}, 'name': {'title': 'Name', 'type': ['string'], 'required': True}, 'person': {'type': ['$Person', 'null'], 'default': None, 'required': False}, 'books': {'title': 'Books', 'type': ['array'], 'items': '$Book', 'required': True}, 'is_male': {'title': 'Is Male', 'type': ['boolean', 'null'], 'required': True} } } """ result = defaultdict(dict) result['title'] = schema['title'] if '$defs' in schema: for sk, sv in schema['$defs'].items(): result['$'][sk] = clean_model_schema(sv) for k, v in schema['properties'].items(): result['fields'][k] = {} if 'title' in v: result['fields'][k]['title'] = v['title'] if 'type' in v: result['fields'][k]['type'] = [v['type']] if 'anyOf' in v: result['fields'][k]['type'] = [i['type'] if 'type' in i else _ref_name(i['$ref']) for i in v['anyOf']] if 'array' in result['fields'][k]['type']: # One of them was array, so add the `items` field for t in v['anyOf']: if 'items' in t: result['fields'][k]['items'] = _ref_name(t['items']['$ref']) if 'default' in v: result['fields'][k]['default'] = v['default'] if '$ref' in v: # For obj result['fields'][k]['type'] = _ref_name(v['$ref']) if 'items' in v: # For array result['fields'][k]['items'] = _ref_name(v['items']['$ref']) result['fields'][k]['required'] = k in schema.get('required', []) # Cast it to have a more clear stdout return dict(result) def get_models(): return [ { 'index': i, 'name': model.__name__, 'module': model.__module__, } for i, model in enumerate(config.MODELS) ] def serialize_models(data: Any): if issubclass(type(data), BaseModel): return data.model_dump() elif isinstance(data, IterableDataTypes): return [serialize_models(d) for d in data] ================================================ FILE: panther/panel/views.py ================================================ import logging from panther import status from panther.app import GenericAPI from panther.configs import config from panther.db.models import BaseUser from panther.panel.authentications import AdminCookieJWTAuthentication from panther.panel.middlewares import RedirectToSlashMiddleware from panther.panel.permissions import IsAuthenticated from panther.panel.utils import clean_model_schema, get_models, serialize_models from panther.request import Request from panther.response import Cookie, RedirectResponse, Response, TemplateResponse logger = logging.getLogger('panther') class LoginView(GenericAPI): middlewares = [RedirectToSlashMiddleware] def get(self, request: Request): return TemplateResponse(name='login.html') async def post(self, request: Request): from panther.authentications import JWTAuthentication user: BaseUser = await config.USER_MODEL.find_one({config.USER_MODEL.USERNAME_FIELD: request.data['username']}) if user is None: logger.debug('User not found.') return TemplateResponse( name='login.html', status_code=status.HTTP_400_BAD_REQUEST, context={'error': 'Authentication Error'}, ) if user.check_password(password=request.data['password']) is False: logger.debug('Password is incorrect.') return TemplateResponse( name='login.html', status_code=status.HTTP_400_BAD_REQUEST, context={'error': 'Authentication Error'}, ) tokens = await JWTAuthentication.login(user=user) return RedirectResponse( url=request.query_params.get('redirect_to', '..'), status_code=status.HTTP_302_FOUND, set_cookies=[ Cookie(key='access_token', value=tokens['access_token'], max_age=config.JWT_CONFIG.life_time), Cookie(key='refresh_token', value=tokens['refresh_token'], max_age=config.JWT_CONFIG.refresh_life_time), ], ) class HomeView(GenericAPI): auth = AdminCookieJWTAuthentication permissions = IsAuthenticated def get(self): return TemplateResponse(name='home.html', context={'tables': get_models()}) class TableView(GenericAPI): auth = AdminCookieJWTAuthentication permissions = IsAuthenticated middlewares = [RedirectToSlashMiddleware] async def get(self, request: Request, index: int): model = config.MODELS[index] if data := await model.find(): data = data else: data = [] return TemplateResponse( name='table.html', context={ 'fields': clean_model_schema(model.model_json_schema()), 'tables': get_models(), 'records': serialize_models(data), }, ) class CreateView(GenericAPI): auth = AdminCookieJWTAuthentication permissions = IsAuthenticated middlewares = [RedirectToSlashMiddleware] async def get(self, request: Request, index: int): model = config.MODELS[index] return TemplateResponse( name='create.html', context={ 'fields': clean_model_schema(model.model_json_schema()), 'tables': get_models(), }, ) async def post(self, request: Request, index: int): model = config.MODELS[index] request.validate_data(model=model) instance = await model.insert_one(request.validated_data.model_dump()) if issubclass(model, BaseUser): await instance.set_password(password=instance.password) return instance class DetailView(GenericAPI): auth = AdminCookieJWTAuthentication permissions = IsAuthenticated middlewares = [RedirectToSlashMiddleware] async def get(self, index: int, document_id: str): model = config.MODELS[index] obj = await model.find_one_or_raise(id=document_id) return TemplateResponse( name='detail.html', context={'fields': clean_model_schema(model.model_json_schema()), 'data': obj.model_dump()}, ) async def put(self, request: Request, index: int, document_id: str): model = config.MODELS[index] request.validate_data(model=model) await model.update_one({'id': document_id}, request.validated_data.model_dump()) return await model.find_one(id=document_id) async def delete(self, index: int, document_id: str): model = config.MODELS[index] await model.delete_one(id=document_id) return Response(status_code=status.HTTP_204_NO_CONTENT) ================================================ FILE: panther/permissions.py ================================================ from abc import abstractmethod from panther.request import Request from panther.websocket import Websocket class BasePermission: @abstractmethod async def __call__(self, request: Request | Websocket) -> bool: return True class IsAuthenticated(BasePermission): async def __call__(self, request: Request) -> bool: return bool(request.user) class IsAuthenticatedOrReadonly(BasePermission): async def __call__(self, request: Request) -> bool: return request.user or request.method == 'GET' ================================================ FILE: panther/request.py ================================================ import logging from collections.abc import Callable from typing import Literal from urllib.parse import parse_qsl import orjson as json from orjson import JSONDecodeError from pydantic import ValidationError from panther._utils import read_multipart_form_data from panther.base_request import BaseRequest from panther.exceptions import BadRequestAPIError, UnprocessableEntityError logger = logging.getLogger('panther') class Request(BaseRequest): def __init__(self, scope: dict, receive: Callable, send: Callable): self._data = ... self.validated_data = None # It's been set in self.validate_input() super().__init__(scope=scope, receive=receive, send=send) @property def method(self) -> Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE']: return self.scope['method'] @property def data(self) -> dict | bytes: """Data before validation""" if self._data is ...: match (self.headers.content_type or '').split('; boundary='): case ['' | 'application/json']: self._data = json.loads(self.__body or b'{}') case ['application/x-www-form-urlencoded']: self._data = {k.decode(): v.decode() for k, v in parse_qsl(self.__body)} case ['multipart/form-data', boundary]: self._data = read_multipart_form_data(boundary=boundary, body=self.__body) case [unknown]: # We don't know the `content-type` so just pass the payload to user logger.warning(f"'{unknown}' Content-Type is not supported") self._data = self.__body return self._data async def read_body(self) -> None: """Read the entire body from an incoming ASGI message.""" self.__body = b'' more_body = True while more_body: message = await self.asgi_receive() self.__body += message.get('body', b'') more_body = message.get('more_body', False) def validate_data(self, model): if isinstance(self.data, bytes): raise UnprocessableEntityError(detail='Content-Type is not valid') if self.data is None: raise BadRequestAPIError(detail='Request body is required') try: # `request` will be ignored in regular `BaseModel` self.validated_data = model(**self.data) except ValidationError as validation_error: error = {'.'.join(str(loc) for loc in e['loc']): e['msg'] for e in validation_error.errors()} raise BadRequestAPIError(detail=error) except JSONDecodeError: raise UnprocessableEntityError(detail='JSON Decode Error') ================================================ FILE: panther/response.py ================================================ import asyncio import logging from collections.abc import AsyncGenerator, Generator from dataclasses import dataclass from http import cookies from sys import version_info from types import NoneType from typing import Any, Literal import jinja2 from panther.exceptions import APIError if version_info >= (3, 11): from typing import LiteralString else: from typing import TypeVar LiteralString = TypeVar('LiteralString') import orjson as json from pantherdb import Cursor as PantherDBCursor from pydantic import BaseModel from panther import status from panther._utils import detect_mime_type, to_async_generator from panther.configs import config from panther.db.cursor import Cursor from panther.pagination import Pagination ResponseDataTypes = ( list | tuple | set | Cursor | PantherDBCursor | dict | int | float | str | bool | bytes | NoneType | type[BaseModel] ) IterableDataTypes = list | tuple | set | Cursor | PantherDBCursor StreamingDataTypes = Generator | AsyncGenerator logger = logging.getLogger('panther') @dataclass(slots=True) class Cookie: """ path: [Optional] Indicates the path that must exist in the requested URL for the browser to send the Cookie header. Default is `/` domain: [Optional] Defines the host to which the cookie will be sent. Default is the host of the current document URL, not including subdomains. max_age: [Optional] Indicates the number of seconds until the cookie expires. A zero or negative number will expire the cookie immediately. secure: [Optional] Indicates that the cookie is sent to the server only when a request is made with the https: scheme (except on localhost) httponly: [Optional] Forbids JavaScript from accessing the cookie, for example, through the `Document.cookie` property. samesite: [Optional] Controls whether a cookie is sent with cross-site requests or not, `lax` is the default behavior if not specified. expires: [Deprecated] In HTTP version 1.1, `expires` was deprecated and replaced with the easier-to-use `max-age` """ key: str value: str domain: str = None max_age: int = None secure: bool = False httponly: bool = False samesite: Literal['none', 'lax', 'strict'] = 'lax' path: str = '/' class Response: """ Usage Example: from panther.response import Response def my_api(): data = {"message": "Hello, World!"} return Response(data=data) """ content_type = 'application/json' def __init__( self, data: ResponseDataTypes = None, status_code: int = status.HTTP_200_OK, headers: dict | None = None, pagination: Pagination | None = None, set_cookies: Cookie | list[Cookie] | None = None, ): """ :param data: should be an instance of ResponseDataTypes :param status_code: should be int :param headers: should be dict of headers :param pagination: an instance of Pagination or None The `pagination.template()` method will be used :param set_cookies: single cookie or list of cookies you want to set on the client. Set the `max-age` to `0` if you want to delete a cookie. """ if isinstance(data, (Cursor, PantherDBCursor)): data = list(data) self.data = data self.status_code = status_code self.headers = {'Content-Type': self.content_type} | (headers or {}) self.pagination: Pagination | None = pagination self.cookies = None if set_cookies: c = cookies.SimpleCookie() if not isinstance(set_cookies, list): set_cookies = [set_cookies] for cookie in set_cookies: c[cookie.key] = cookie.value c[cookie.key]['path'] = cookie.path c[cookie.key]['secure'] = cookie.secure c[cookie.key]['httponly'] = cookie.httponly c[cookie.key]['samesite'] = cookie.samesite if cookie.domain is not None: c[cookie.key]['domain'] = cookie.domain if cookie.max_age is not None: c[cookie.key]['max-age'] = cookie.max_age self.cookies = [(b'Set-Cookie', cookie.OutputString().encode()) for cookie in c.values()] def __str__(self): if len(data := str(self.data)) > 30: data = f'{data:.27}...' return f'Response(status_code={self.status_code}, data={data})' __repr__ = __str__ @property def body(self) -> bytes: def default(obj: Any): if isinstance(obj, BaseModel): return obj.model_dump() if isinstance(obj, (Cursor, PantherDBCursor)): return list(obj) if isinstance(obj, bytes): return f'raw bytes is not JSON serializable ({len(obj)} bytes)' raise TypeError(f'Type {type(obj)} not serializable') if isinstance(self.data, bytes): return self.data if self.data is None: return b'' return json.dumps(self.data, default=default) @property def bytes_headers(self) -> list[tuple[bytes, bytes]]: headers = {'Content-Length': len(self.body)} | self.headers result = [(k.encode(), str(v).encode()) for k, v in headers.items()] if self.cookies: result += self.cookies return result async def send(self, send, receive): await send({'type': 'http.response.start', 'status': self.status_code, 'headers': self.bytes_headers}) await send({'type': 'http.response.body', 'body': self.body, 'more_body': False}) async def serialize_output(self, output_model: type[BaseModel]): """Serializes response data using the given output_model.""" async def handle_output(obj): output = output_model(**obj) if isinstance(obj, dict) else output_model(**obj.model_dump()) if hasattr(output_model, 'to_response'): return await output.to_response(instance=obj, data=output.model_dump()) return output.model_dump() if isinstance(self.data, dict) or isinstance(self.data, BaseModel): self.data = await handle_output(self.data) elif isinstance(self.data, IterableDataTypes): results = [] for d in self.data: if isinstance(d, dict) or isinstance(d, BaseModel): results.append(await handle_output(d)) else: msg = 'Type of Response data is not match with `output_model`.\n*hint: You may want to remove `output_model`' raise TypeError(msg) self.data = results else: msg = ( 'Type of Response data is not match with `output_model`.\n*hint: You may want to remove `output_model`' ) raise TypeError(msg) class StreamingResponse(Response): """ Usage Example: from panther.response import StreamingResponse import time def my_generator(): for i in range(5): time.sleep(1) yield f"Chunk {i}\n" def my_api(): return StreamingResponse(data=my_generator()) """ content_type = 'application/octet-stream' def __init__(self, *args, **kwargs): self.connection_closed = False super().__init__(*args, **kwargs) async def listen_to_disconnection(self, receive): message = await receive() if message['type'] == 'http.disconnect': self.connection_closed = True @property def bytes_headers(self) -> list[tuple[bytes, bytes]]: result = [(k.encode(), str(v).encode()) for k, v in self.headers.items()] if self.cookies: result += self.cookies return result @property async def body(self) -> AsyncGenerator: if not isinstance(self.data, (Generator, AsyncGenerator)): raise TypeError(f'Type {type(self.data)} is not streamable, should be `Generator` or `AsyncGenerator`.') if isinstance(self.data, Generator): self.data = to_async_generator(self.data) async for chunk in self.data: if isinstance(chunk, bytes): yield chunk elif chunk is None: yield b'' else: yield json.dumps(chunk) async def send(self, send, receive): # Send Headers await send({'type': 'http.response.start', 'status': self.status_code, 'headers': self.bytes_headers}) # Send Body as chunks asyncio.create_task(self.listen_to_disconnection(receive=receive)) async for chunk in self.body: if self.connection_closed: break await send({'type': 'http.response.body', 'body': chunk, 'more_body': True}) else: await send({'type': 'http.response.body', 'body': b'', 'more_body': False}) class HTMLResponse(Response): """ Usage Example: from panther.response import HTMLResponse def my_api(): html = "

      Hello, World!

      " return HTMLResponse(data=html) """ content_type = 'text/html; charset=utf-8' @property def body(self) -> bytes: if isinstance(self.data, bytes): return self.data return self.data.encode() class PlainTextResponse(Response): """ Usage Example: from panther.response import PlainTextResponse def my_api(): return PlainTextResponse(data="Hello, World!") """ content_type = 'text/plain; charset=utf-8' @property def body(self) -> bytes: if isinstance(self.data, bytes): return self.data return self.data.encode() class FileResponse(Response): """ Usage Example: from panther.response import FileResponse def my_api(): return FileResponse(data="file.txt") """ def __init__(self, file_path: str, headers: dict | NoneType = None, status_code: int = status.HTTP_200_OK): """ :param file_path: path of the file :param headers: should be dict of headers :param status_code: should be int """ file = config.BASE_DIR / file_path if not file.exists(): super().__init__(data={'detail': 'Not Found'}, headers=headers, status_code=status.HTTP_404_NOT_FOUND) else: headers = {'Content-Type': detect_mime_type(file)} | (headers or {}) super().__init__(data=file.read_bytes(), headers=headers, status_code=status_code) class TemplateResponse(HTMLResponse): """ Usage Example: from panther.response import TemplateResponse def my_api(): context = {"name": "Ali"} return TemplateResponse(name="index.html", context=context) """ """ You may want to declare `TEMPLATES_DIR` in your configs, default is '.' Example: TEMPLATES_DIR = 'templates/' """ def __init__( self, source: str | LiteralString | NoneType = None, name: str | NoneType = None, context: dict | NoneType = None, headers: dict | NoneType = None, status_code: int = status.HTTP_200_OK, ): """ :param source: should be a string :param name: name of the template file (should be with its extension, e.g. index.html) :param context: should be dict of items :param headers: should be dict of headers :param status_code: should be int """ if name: try: template = config.JINJA_ENVIRONMENT.get_template(name=name) except jinja2.exceptions.TemplateNotFound: loaded_path = ' - '.join( ' - '.join(loader.searchpath) for loader in config.JINJA_ENVIRONMENT.loader.loaders if isinstance(loader, jinja2.loaders.FileSystemLoader) ) error = ( f'`{name}` Template Not Found.\n' f'* Make sure `TEMPLATES_DIR` in your configs is correct, Current is {loaded_path}' ) logger.error(error) raise APIError else: template = config.JINJA_ENVIRONMENT.from_string(source=source) super().__init__( data=template.render(context or {}), headers=headers, status_code=status_code, ) class RedirectResponse(Response): """ Usage Example: from panther.response import RedirectResponse def my_api(): return RedirectResponse(url="/new-location") """ def __init__( self, url: str, headers: dict | None = None, status_code: int = status.HTTP_307_TEMPORARY_REDIRECT, set_cookies: list[Cookie] | None = None, ): headers = headers or {} headers['Location'] = url super().__init__( headers=headers, status_code=status_code, set_cookies=set_cookies, ) ================================================ FILE: panther/routings.py ================================================ import re import types from collections import Counter from collections.abc import Callable, Mapping, MutableMapping from copy import deepcopy from functools import partial, reduce from panther.configs import config from panther.exceptions import PantherError def flatten_urls(urls: dict) -> dict: return dict(_flattening_urls(urls)) def _flattening_urls(data: dict | Callable, url: str = ''): # Add `/` add the end of url if not url.endswith('/'): url = f'{url}/' if isinstance(data, dict): if data == {}: # User didn't define any endpoint, # So we just reserve this path so won't be used in path variables. yield url.removeprefix('/'), {} for k, v in data.items(): yield from _flattening_urls(v, f'{url}{k}') else: # Remove `/` prefix of url url = url.removeprefix('/') # Collect it, if it doesn't have problem _is_url_endpoint_valid(url=url, endpoint=data) yield url, data def _is_url_endpoint_valid(url: str, endpoint: Callable): if endpoint is ...: raise PantherError(f"URL Can't Point To Ellipsis. ('{url}' -> ...)") if endpoint is None: raise PantherError(f"URL Can't Point To None. ('{url}' -> None)") if url and not re.match(r'^[a-zA-Z<>0-9_/-]+$', url): raise PantherError(f"URL Is Not Valid. --> '{url}'") elif isinstance(endpoint, types.ModuleType): raise PantherError(f"URL Can't Point To Module. --> '{url}'") def finalize_urls(urls: dict) -> dict: """Convert flat dict to nested""" urls_list = [] for url, endpoint in urls.items(): path = {} if url == '': # This condition only happen when # user defines the root url == '' instead of '/' url = '/' for single_path in list(filter(lambda x: x != '', url.split('/')[:-1][::-1])) or ['']: if single_path != '' and not path: path = {single_path: {'': endpoint}} else: path = {single_path: path or endpoint} urls_list.append(path) final_urls = _merge(*urls_list) if urls_list else {} check_urls_path_variables(final_urls) return final_urls def check_urls_path_variables(urls: dict, path: str = '') -> None: middle_route_error = [] last_route_error = [] for key, value in urls.items(): new_path = f'{path}/{key}' if isinstance(value, dict): if key.startswith('<'): middle_route_error.append(new_path) check_urls_path_variables(value, path=new_path) elif key.startswith('<'): last_route_error.append(new_path) if len(middle_route_error) > 1: msg = '\n\t- ' + '\n\t- '.join(e for e in middle_route_error) raise PantherError(f"URLs can't have same-level path variables that point to a dict: {msg}") if len(last_route_error) > 1: msg = '\n\t- ' + '\n\t- '.join(e for e in last_route_error) raise PantherError(f"URLs can't have same-level path variables that point to an endpoint: {msg}") def _merge(destination: MutableMapping, *sources) -> MutableMapping: return _simplify_urls(reduce(partial(_deepmerge), sources, destination)) def _simplify_urls(urls): simplified_urls = {} for key, value in urls.items(): if isinstance(value, dict): simplified_value = _simplify_urls(value) if isinstance(simplified_value, dict) and len(simplified_value) == 1 and '' in simplified_value: simplified_urls[key] = simplified_value[''] else: simplified_urls[key] = simplified_value else: simplified_urls[key] = value return simplified_urls def _deepmerge(dst, src): """Credit to Travis Clarke --> https://github.com/clarketm/mergedeep""" for key in src: if key in dst: if _is_recursive_merge(dst[key], src[key]): _deepmerge(dst[key], src[key]) else: dst[key] = deepcopy(src[key]) else: dst[key] = deepcopy(src[key]) return dst def _is_recursive_merge(a, b): both_mapping = isinstance(a, Mapping) and isinstance(b, Mapping) both_counter = isinstance(a, Counter) and isinstance(b, Counter) return both_mapping and not both_counter ENDPOINT_NOT_FOUND = (None, '') def find_endpoint(path: str) -> tuple[Callable | None, str]: urls = config.URLS # 'user/list/?name=ali' --> 'user/list/' --> 'user/list' --> ['user', 'list'] parts = path.split('?')[0].strip('/').split('/') paths_len = len(parts) found_path = [] for i, part in enumerate(parts): last_path = bool((i + 1) == paths_len) found = urls.get(part) if last_path: # `found` is callable if callable(found): found_path.append(part) return found, '/'.join(found_path) # `found` is dict if isinstance(found, dict): if (endpoint := found.get('')) and callable(endpoint): found_path.append(part) return endpoint, '/'.join(found_path) else: return ENDPOINT_NOT_FOUND # `found` is None for key, value in urls.items(): if key.startswith('<'): if callable(value): found_path.append(key) return value, '/'.join(found_path) elif isinstance(value, dict) and (endpoint := value.get('')): if callable(endpoint): found_path.append(key) return endpoint, '/'.join(found_path) else: return ENDPOINT_NOT_FOUND return ENDPOINT_NOT_FOUND # `found` is dict elif isinstance(found, dict): found_path.append(part) urls = found continue # `found` is callable elif callable(found): return ENDPOINT_NOT_FOUND else: # `found` is None for key, value in urls.items(): if key.startswith('<') and isinstance(value, dict): found_path.append(key) urls = value break else: return ENDPOINT_NOT_FOUND ================================================ FILE: panther/serializer.py ================================================ import datetime import typing from sys import version_info from typing import Any import pydantic import pydantic.fields import pydantic_core._pydantic_core from panther.db import Model from panther.utils import run_coroutine, timezone_now class MetaModelSerializer: KNOWN_CONFIGS = ['model', 'fields', 'exclude', 'required_fields', 'optional_fields'] def __new__(cls, cls_name: str, bases: tuple[type[typing.Any], ...], namespace: dict[str, typing.Any], **kwargs): if cls_name == 'ModelSerializer': # Put `model` to the main class with `create_model()` if version_info < (3, 14): namespace['__annotations__'].pop('model') cls.model_serializer = type(cls_name, (), namespace) return super().__new__(cls) # 1. Initial Check cls.check_config(cls_name=cls_name, namespace=namespace) config = namespace.pop('Config') # 2. Collect `Fields` field_definitions = cls.collect_fields(config=config, namespace=namespace) # 3. Collect `pydantic.model_config` model_config = cls.collect_model_config(config=config, namespace=namespace) namespace |= {'model_config': model_config} # 4. Create a serializer return pydantic.create_model( cls_name, __module__=namespace['__module__'], __validators__=namespace, __base__=(cls.model_serializer, pydantic.BaseModel), model=(typing.ClassVar[type[pydantic.BaseModel]], config.model), **field_definitions, ) @classmethod def check_config(cls, cls_name: str, namespace: dict) -> None: module = namespace['__module__'] address = f'{module}.{cls_name}' # Check `Config` if (config := namespace.get('Config')) is None: msg = f'`class Config` is required in {address}.' raise AttributeError(msg) from None # Check `model` if (model := getattr(config, 'model', None)) is None: msg = f'`{cls_name}.Config.model` is required.' raise AttributeError(msg) from None # Check `model` type try: if not issubclass(model, (Model, pydantic.BaseModel)): msg = f'`{cls_name}.Config.model` is not subclass of `panther.db.Model` or `pydantic.BaseModel`.' raise AttributeError(msg) from None except TypeError: msg = f'`{cls_name}.Config.model` is not subclass of `panther.db.Model` or `pydantic.BaseModel`.' raise AttributeError(msg) from None # Check `fields` if not hasattr(config, 'fields'): msg = f'`{cls_name}.Config.fields` is required.' raise AttributeError(msg) from None if config.fields != '*': for field_name in config.fields: if field_name == '*': msg = f"`{cls_name}.Config.fields.{field_name}` is not valid. Did you mean `fields = '*'`" raise AttributeError(msg) from None if field_name not in model.model_fields: msg = f'`{cls_name}.Config.fields.{field_name}` is not in `{model.__name__}.model_fields`' raise AttributeError(msg) from None # Check `required_fields` if not hasattr(config, 'required_fields'): config.required_fields = [] if config.required_fields != '*': for required in config.required_fields: if required not in model.model_fields: msg = f'`{cls_name}.Config.required_fields.{required}` is not valid.' raise AttributeError(msg) from None if config.fields != '*' and required not in config.fields: msg = f'`{cls_name}.Config.required_fields.{required}` is not defined in `Config.fields`.' raise AttributeError(msg) from None # Check `optional_fields` if not hasattr(config, 'optional_fields'): config.optional_fields = [] if config.optional_fields != '*': for optional in config.optional_fields: if optional not in model.model_fields: msg = f'`{cls_name}.Config.optional_fields.{optional}` is not valid.' raise AttributeError(msg) from None if config.fields != '*' and optional not in config.fields: msg = f'`{cls_name}.Config.optional_fields.{optional}` is not defined in `Config.fields`.' raise AttributeError(msg) from None # Check `required_fields` and `optional_fields` together if (config.optional_fields == '*' and config.required_fields != []) or ( config.required_fields == '*' and config.optional_fields != [] ): msg = ( f'`{cls_name}.Config.optional_fields` and ' f"`{cls_name}.Config.required_fields` can't include same fields at the same time" ) raise AttributeError(msg) from None for optional in config.optional_fields: for required in config.required_fields: if optional == required: msg = ( f"`{optional}` can't be in `{cls_name}.Config.optional_fields` and " f'`{cls_name}.Config.required_fields` at the same time' ) raise AttributeError(msg) from None # Check `exclude` if not hasattr(config, 'exclude'): config.exclude = [] for field_name in config.exclude: if field_name not in model.model_fields: msg = f'`{cls_name}.Config.exclude.{field_name}` is not valid.' raise AttributeError(msg) from None if config.fields != '*' and field_name not in config.fields: msg = f'`{cls_name}.Config.exclude.{field_name}` is not defined in `Config.fields`.' raise AttributeError(msg) from None @classmethod def collect_fields(cls, config: typing.Callable, namespace: dict) -> dict: field_definitions = {} # Define `fields` if config.fields == '*': for field_name, field in config.model.model_fields.items(): field_definitions[field_name] = (field.annotation, field) else: for field_name in config.fields: field_definitions[field_name] = ( config.model.model_fields[field_name].annotation, config.model.model_fields[field_name], ) # Apply `exclude` for field_name in config.exclude: del field_definitions[field_name] # Apply `required_fields` if config.required_fields == '*': for value in field_definitions.values(): value[1].default = pydantic_core._pydantic_core.PydanticUndefined else: for field_name in config.required_fields: field_definitions[field_name][1].default = pydantic_core._pydantic_core.PydanticUndefined # Apply `optional_fields` if config.optional_fields == '*': for value in field_definitions.values(): value[1].default = value[0]() else: for field_name in config.optional_fields: if issubclass(field_definitions[field_name][0], datetime.datetime): field_definitions[field_name][1].default = timezone_now() else: field_definitions[field_name][1].default = field_definitions[field_name][0]() # Collect and Override `Class Fields` if version_info < (3, 14): annotations = namespace.pop('__annotations__', {}) else: annotations = namespace['__annotate_func__'](0) if '__annotate_func__' in namespace else {} for key, value in annotations.items(): field_info = namespace.pop(key, pydantic.fields.FieldInfo(annotation=value)) field_definitions[key] = (value, field_info) # Check Foreign Keys for field_name, field_config in field_definitions.items(): try: if issubclass(field_config[0], Model): validator = pydantic.BeforeValidator(cls.convert_str_to_model(field_config[0])) ann = typing.Annotated[field_config[0], validator] field_definitions[field_name] = (ann, field_config[1]) except TypeError: pass return field_definitions @classmethod def collect_model_config(cls, config: typing.Callable, namespace: dict) -> dict: return ( { attr: getattr(config, attr) for attr in dir(config) if not attr.startswith('__') and attr not in cls.KNOWN_CONFIGS } | namespace.pop('model_config', {}) | {'arbitrary_types_allowed': True} ) @classmethod def convert_str_to_model(cls, model_cls): def _convert(v: Any) -> Any: if isinstance(v, str): return run_coroutine(model_cls.find_one(id=v)) return v return _convert class ModelSerializer(metaclass=MetaModelSerializer): """ A serializer class that automatically generates Pydantic models from database models. Documentation: https://pantherpy.github.io/serializer/#style-2-modelserializer Example: class PersonSerializer(ModelSerializer): class Config: # Required: The model class to serialize model = Person # Required: Fields to include in serialization # Use '*' for all fields, or specify a list of field names fields = '*' # or ['id', 'name', 'email'] # Optional: Fields to exclude from serialization # Must be a list of field names (cannot be '*') exclude = ['created_date', 'updated_date'] # Optional: Fields that are required (no default value) # Can be a list of field names or '*' for all fields required_fields = ['first_name', 'last_name'] # Optional: Fields that are optional (with default values) # Can be a list of field names or '*' for all fields optional_fields = ['age', 'bio'] """ model: type[pydantic.BaseModel] async def to_response(self, instance: Any, data: dict) -> dict: return data ================================================ FILE: panther/status.py ================================================ """ HTTP codes https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml And RFC 2324 - https://tools.ietf.org/html/rfc2324 WEBSOCKET codes https://www.iana.org/assignments/websocket/websocket.xhtml """ HTTP_100_CONTINUE = 100 HTTP_101_SWITCHING_PROTOCOLS = 101 HTTP_102_PROCESSING = 102 HTTP_103_EARLY_HINTS = 103 HTTP_200_OK = 200 HTTP_201_CREATED = 201 HTTP_202_ACCEPTED = 202 HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203 HTTP_204_NO_CONTENT = 204 HTTP_205_RESET_CONTENT = 205 HTTP_206_PARTIAL_CONTENT = 206 HTTP_207_MULTI_STATUS = 207 HTTP_208_ALREADY_REPORTED = 208 HTTP_226_IM_USED = 226 HTTP_300_MULTIPLE_CHOICES = 300 HTTP_301_MOVED_PERMANENTLY = 301 HTTP_302_FOUND = 302 HTTP_303_SEE_OTHER = 303 HTTP_304_NOT_MODIFIED = 304 HTTP_305_USE_PROXY = 305 HTTP_306_RESERVED = 306 HTTP_307_TEMPORARY_REDIRECT = 307 HTTP_308_PERMANENT_REDIRECT = 308 HTTP_400_BAD_REQUEST = 400 HTTP_401_UNAUTHORIZED = 401 HTTP_402_PAYMENT_REQUIRED = 402 HTTP_403_FORBIDDEN = 403 HTTP_404_NOT_FOUND = 404 HTTP_405_METHOD_NOT_ALLOWED = 405 HTTP_406_NOT_ACCEPTABLE = 406 HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407 HTTP_408_REQUEST_TIMEOUT = 408 HTTP_409_CONFLICT = 409 HTTP_410_GONE = 410 HTTP_411_LENGTH_REQUIRED = 411 HTTP_412_PRECONDITION_FAILED = 412 HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413 HTTP_414_REQUEST_URI_TOO_LONG = 414 HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415 HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416 HTTP_417_EXPECTATION_FAILED = 417 HTTP_418_IM_A_TEAPOT = 418 HTTP_421_MISDIRECTED_REQUEST = 421 HTTP_422_UNPROCESSABLE_ENTITY = 422 HTTP_423_LOCKED = 423 HTTP_424_FAILED_DEPENDENCY = 424 HTTP_425_TOO_EARLY = 425 HTTP_426_UPGRADE_REQUIRED = 426 HTTP_428_PRECONDITION_REQUIRED = 428 HTTP_429_TOO_MANY_REQUESTS = 429 HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431 HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS = 451 HTTP_500_INTERNAL_SERVER_ERROR = 500 HTTP_501_NOT_IMPLEMENTED = 501 HTTP_502_BAD_GATEWAY = 502 HTTP_503_SERVICE_UNAVAILABLE = 503 HTTP_504_GATEWAY_TIMEOUT = 504 HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505 HTTP_506_VARIANT_ALSO_NEGOTIATES = 506 HTTP_507_INSUFFICIENT_STORAGE = 507 HTTP_508_LOOP_DETECTED = 508 HTTP_510_NOT_EXTENDED = 510 HTTP_511_NETWORK_AUTHENTICATION_REQUIRED = 511 WS_1000_NORMAL_CLOSURE = 1000 WS_1001_GOING_AWAY = 1001 WS_1002_PROTOCOL_ERROR = 1002 WS_1003_UNSUPPORTED_DATA = 1003 WS_1007_INVALID_FRAME_PAYLOAD_DATA = 1007 WS_1008_POLICY_VIOLATION = 1008 WS_1009_MESSAGE_TOO_BIG = 1009 WS_1010_MANDATORY_EXT = 1010 WS_1011_INTERNAL_ERROR = 1011 WS_1012_SERVICE_RESTART = 1012 WS_1013_TRY_AGAIN_LATER = 1013 WS_1014_BAD_GATEWAY = 1014 """ You can't use these status codes manually: WS_RESERVED = 1004 WS_NO_STATUS_RCVD = 1005 WS_ABNORMAL_CLOSURE = 1006 WS_TLS_HANDSHAKE = 1015 """ status_text = {int(x[5:8]): x[9:].replace('_', ' ').title() for x in globals() if x.startswith('HTTP')} status_text.update({int(x[3:7]): x[8:].replace('_', ' ').title() for x in globals() if x.startswith('WS')}) def is_informational(status_code: int): return bool(100 <= status_code < 200) def is_success(status_code: int): return bool(200 <= status_code < 300) def is_redirect(status_code: int): return bool(300 <= status_code < 400) def is_client_error(status_code: int): return bool(400 <= status_code < 500) def is_server_error(status_code: int): return bool(500 <= status_code < 600) ================================================ FILE: panther/test.py ================================================ import asyncio from collections.abc import Callable from typing import Literal import orjson as json from panther.response import HTMLResponse, PlainTextResponse, Response __all__ = ('APIClient', 'WebsocketClient') class RequestClient: def __init__(self, app: Callable): self.app = app self.response = b'' async def send(self, data: dict): if data['type'] == 'http.response.start': self.header = data else: self.response += data['body'] async def receive(self): return { 'type': 'http.request', 'body': self.payload, 'more_body': False, } async def request( self, path: str, method: Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], payload: bytes | dict | None, headers: dict, query_params: dict, ) -> Response: headers = [(k.encode(), str(v).encode()) for k, v in headers.items()] if not path.startswith('/'): path = f'/{path}' self.payload = payload if isinstance(payload, bytes) else json.dumps(payload) query_params = '&'.join(f'{k}={v}' for k, v in query_params.items()) scope = { 'type': 'http', 'client': ('127.0.0.1', 8585), 'headers': headers, 'method': method, 'path': path, 'raw_path': path.encode(), 'query_string': query_params.encode(), } await self.app( scope=scope, receive=self.receive, send=self.send, ) response_headers = {key.decode(): value.decode() for key, value in self.header['headers']} cookies = [(key, value) for key, value in self.header['headers'] if key.decode() == 'Set-Cookie'] if response_headers['Content-Type'] == 'text/html; charset=utf-8': data = self.response.decode() response = HTMLResponse(data=data, status_code=self.header['status'], headers=response_headers) elif response_headers['Content-Type'] == 'text/plain; charset=utf-8': data = self.response.decode() response = PlainTextResponse(data=data, status_code=self.header['status'], headers=response_headers) elif response_headers['Content-Type'] == 'application/octet-stream': data = self.response.decode() response = PlainTextResponse(data=data, status_code=self.header['status'], headers=response_headers) elif response_headers['Content-Type'] == 'application/json': data = json.loads(self.response or b'null') response = Response(data=data, status_code=self.header['status'], headers=response_headers) else: response = Response(data=self.response, status_code=self.header['status'], headers=response_headers) response.cookies = cookies return response class APIClient: def __init__(self, app: Callable): self._app = app async def _send_request( self, path: str, method: Literal['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], payload: dict | None, headers: dict, query_params: dict, ) -> Response: request_client = RequestClient(app=self._app) return await request_client.request( path=path, method=method, payload=payload, headers=headers, query_params=query_params or {}, ) async def options( self, path: str, headers: dict | None = None, query_params: dict | None = None, ) -> Response: return await self._send_request( path=path, method='OPTIONS', payload=None, headers=headers or {}, query_params=query_params or {}, ) async def get( self, path: str, headers: dict | None = None, query_params: dict | None = None, ) -> Response: return await self._send_request( path=path, method='GET', payload=None, headers=headers or {}, query_params=query_params or {}, ) async def post( self, path: str, payload: bytes | dict | None = None, headers: dict | None = None, query_params: dict | None = None, content_type: str = 'application/json', ) -> Response: headers = {'content-type': content_type} | (headers or {}) return await self._send_request( path=path, method='POST', payload=payload, headers=headers, query_params=query_params or {}, ) async def put( self, path: str, payload: bytes | dict | None = None, headers: dict | None = None, query_params: dict | None = None, content_type: Literal['application/json', 'multipart/form-data'] = 'application/json', ) -> Response: headers = {'content-type': content_type} | (headers or {}) return await self._send_request( path=path, method='PUT', payload=payload, headers=headers, query_params=query_params or {}, ) async def patch( self, path: str, payload: bytes | dict | None = None, headers: dict | None = None, query_params: dict | None = None, content_type: Literal['application/json', 'multipart/form-data'] = 'application/json', ) -> Response: headers = {'content-type': content_type} | (headers or {}) return await self._send_request( path=path, method='PATCH', payload=payload, headers=headers, query_params=query_params or {}, ) async def delete( self, path: str, headers: dict | None = None, query_params: dict | None = None, ) -> Response: return await self._send_request( path=path, method='DELETE', payload=None, headers=headers or {}, query_params=query_params or {}, ) class WebsocketClient: def __init__(self, app: Callable): self.app = app self.responses = [] async def send(self, data: dict): self.responses.append(data) async def receive(self): return {'type': 'websocket.connect'} def connect( self, path: str, headers: dict | None = None, query_params: dict | None = None, ): headers = [(k.encode(), str(v).encode()) for k, v in (headers or {}).items()] if not path.startswith('/'): path = f'/{path}' query_params = '&'.join(f'{k}={v}' for k, v in (query_params or {}).items()) scope = { 'type': 'websocket', 'asgi': {'version': '3.0', 'spec_version': '2.3'}, 'http_version': '1.1', 'scheme': 'ws', 'server': ('127.0.0.1', 8000), 'client': ('127.0.0.1', 55330), 'path': path, 'raw_path': path.encode(), 'query_string': query_params.encode(), 'headers': headers, 'subprotocols': [], 'state': {}, } asyncio.run( self.app( scope=scope, receive=self.receive, send=self.send, ), ) return self.responses ================================================ FILE: panther/throttling.py ================================================ from collections import defaultdict from dataclasses import dataclass from datetime import datetime, timedelta from panther.db.connections import redis from panther.exceptions import ThrottlingAPIError from panther.request import Request from panther.utils import round_datetime # In-memory fallback storage for when Redis is unavailable _fallback_throttle_storage = defaultdict(int) @dataclass(repr=False, eq=False) class Throttle: rate: int duration: timedelta @property def time_window(self) -> datetime: return round_datetime(datetime.now(), self.duration) def build_cache_key(self, request: Request) -> str: """ Generate a unique cache key based on time window, user or IP, and path. This method is intended to be overridden by subclasses to customize throttling logic. """ identifier = request.user.id if request.user else request.client.ip return f'{self.time_window}-{identifier}-{request.path}' async def get_request_count(self, request: Request) -> int: """ Get the current request count for this key from Redis or fallback memory. """ key = self.build_cache_key(request) if redis.is_connected: value = await redis.get(key) return int(value) if value else 0 return _fallback_throttle_storage.get(key, 0) async def increment_request_count(self, request: Request) -> None: """ Increment the request count for this key and ensure TTL is set in Redis. """ key = self.build_cache_key(request) if redis.is_connected: count = await redis.incrby(key, amount=1) if count == 1: ttl = int(self.duration.total_seconds()) await redis.expire(key, ttl) else: _fallback_throttle_storage[key] += 1 async def check_and_increment(self, request: Request) -> None: """ Main throttling logic: - Raises ThrottlingAPIError if limit exceeded. - Otherwise increments the request count. """ count = await self.get_request_count(request) remaining = self.rate - count - 1 reset_time = self.time_window + self.duration retry_after = int((reset_time - datetime.now()).total_seconds()) if remaining < 0: raise ThrottlingAPIError( headers={ 'Retry-After': str(retry_after), 'X-RateLimit-Reset': str(int(reset_time.timestamp())), }, ) await self.increment_request_count(request) ================================================ FILE: panther/utils.py ================================================ import asyncio import base64 import hashlib import logging import os from datetime import datetime, timedelta from pathlib import Path from threading import Thread from typing import ClassVar import pytz from panther.configs import config logger = logging.getLogger('panther') URANDOM_SIZE = 16 class Singleton(object): _instances: ClassVar[dict] = {} def __new__(cls, *args, **kwargs): if cls not in cls._instances: cls._instances[cls] = super().__new__(cls) return cls._instances[cls] def load_env(env_file: str | Path, /) -> dict[str, str]: variables = {} if env_file is None or not Path(env_file).is_file(): raise ValueError(f'"{env_file}" is not a file.') from None with open(env_file) as file: for line in file: striped_line = line.strip() if not striped_line.startswith('#') and '=' in striped_line: key, value = striped_line.split('=', 1) key = key.strip() value = value.strip().strip('"\'') if (boolean_value := value.lower()) in ['true', 'false']: variables[key] = bool(boolean_value == 'true') else: variables[key] = value # Load them as system environment variable os.environ[key] = value return variables def generate_secret_key() -> str: return base64.urlsafe_b64encode(os.urandom(32)).decode() def round_datetime(dt: datetime, delta: timedelta) -> datetime: """ Example: >>> round_datetime(datetime(2024, 7, 15, 13, 22, 11, 562159), timedelta(days=2)) datetime.datetime(2024, 7, 16, 0, 0) >>> round_datetime(datetime(2024, 7, 16, 13, 22, 11, 562159), timedelta(days=2)) datetime.datetime(2024, 7, 16, 0, 0) >>> round_datetime(datetime(2024, 7, 17, 13, 22, 11, 562159), timedelta(days=2)) datetime.datetime(2024, 7, 18, 0, 0) >>> round_datetime(datetime(2024, 7, 18, 13, 22, 11, 562159), timedelta(days=2)) datetime.datetime(2024, 7, 18, 0, 0) """ return datetime.min + round((dt - datetime.min) / delta) * delta def generate_hash_value_from_string(string_value: str, /) -> str: # The point of this method is for maintenance, if we want to change # the hash algorithm in the future, it will be easy. return hashlib.sha256(string_value.encode('utf-8')).hexdigest() def scrypt(password: str, salt: bytes, digest: bool = False) -> str | bytes: """ n: CPU/memory cost parameter – Must be a power of 2 (e.g. 1024) r: Block size parameter, which fine-tunes sequential memory read size and performance. (8 is commonly used) p: Parallelization parameter. (1 .. 232-1 * hLen/MFlen) dk_len: Desired key length in bytes ( Intended output length in octets of the derived key; a positive integer satisfying dkLen ≤ (232− 1) * hLen.) h_len: The length in octets of the hash function (32 for SHA256). mf_len: The length in octets of the output of the mixing function (SMix below). Defined as r * 128 in RFC7914. """ n = 2**14 # 16384 r = 8 p = 10 dk_len = 64 derived_key = hashlib.scrypt(password=password.encode(), salt=salt, n=n, r=r, p=p, dklen=dk_len) if digest: return hashlib.md5(derived_key).hexdigest() return derived_key def timezone_now(): return datetime.now(tz=pytz.timezone(config.TIMEZONE)) def run_coroutine(coroutine): try: # Check if there's an event loop already running in this thread asyncio.get_running_loop() except RuntimeError: # No event loop is running in this thread — safe to use asyncio.run return asyncio.run(coroutine) # Since we cannot block a running event loop with run_until_complete, # we execute the coroutine in a separate thread with its own event loop. result = [] def run_in_thread(): new_loop = asyncio.new_event_loop() asyncio.set_event_loop(new_loop) try: result.append(new_loop.run_until_complete(coroutine)) finally: new_loop.close() thread = Thread(target=run_in_thread) thread.start() thread.join() return result[0] ================================================ FILE: panther/websocket.py ================================================ from __future__ import annotations from typing import Callable from panther import status from panther._utils import ENDPOINT_WEBSOCKET from panther.base_websocket import Websocket from panther.configs import config class GenericWebsocket(Websocket): _endpoint_type = ENDPOINT_WEBSOCKET auth: Callable | None = None permissions: list = [] def __init__(self, parent): self.__dict__ = parent.__dict__.copy() async def connect(self, **kwargs): """ Check your conditions then `accept()` or `close()` the connection """ async def receive(self, data: str | bytes): """ Received `data` of connection, You may want to use json.loads() on the `data` """ async def send(self, data: any = None): """ Send message to the client, You may want to override it with your custom scenario. (not recommended) """ return await super().send(data=data) async def close(self, code: int = status.WS_1000_NORMAL_CLOSURE, reason: str = ''): """ Called whenever server or client, wants to close the connection """ return await super().close(code=code, reason=reason) async def send_message_to_websocket(connection_id: str, data: any): await config.WEBSOCKET_CONNECTIONS.publish(connection_id=connection_id, action='send', data=data) async def close_websocket_connection(connection_id: str, code: int = status.WS_1000_NORMAL_CLOSURE, reason: str = ''): data = {'code': code, 'reason': reason} await config.WEBSOCKET_CONNECTIONS.publish(connection_id=connection_id, action='close', data=data) ================================================ FILE: profiler.py ================================================ """ Panther Framework Performance Profiler Prerequisites: - Install ali: https://github.com/nakabonne/ali - Install yappi: pip install yappi Usage: 1. Run the profiler: python profiler.py > benchmark_$(date +%s).txt 2. Send requests: ali --rate=100 http://127.0.0.1:8000 3. View results in the generated output file * Any contribution or improvement on this profiler would be welcomed. """ import uvicorn import yappi from panther import Panther from panther.app import API @API() async def hello_world(): return {'detail': 'Hello World'} def main(): app = Panther(__name__, configs=__name__, urls={'': hello_world}) yappi.start() uvicorn.run(app, access_log=False) yappi.stop() # Print function-level stats stats = yappi.get_func_stats() stats.print_all(columns={0: ('name', 100), 1: ('ncall', 10), 2: ('tsub', 10), 3: ('ttot', 10), 4: ('tavg', 10)}) if __name__ == '__main__': main() ================================================ FILE: pyproject.toml ================================================ [build-system] requires = [ "setuptools>=42", "wheel" ] build-backend = "setuptools.build_meta" [tool.pytest.ini_options] markers = [ "mongodb: marks mongodb tests", "slow: marks slow tests" ] ================================================ FILE: requirements.txt ================================================ . python-jose faker coverage pytest cryptography~=42.0 websockets motor mkdocs mkdocs-material ruff yappi ================================================ FILE: ruff.toml ================================================ exclude = [ ".venv", "build", ".git", ] select = [ "ALL" ] ignore = [ "C901", "N818", "N805", "D1", "D2", "D400", "D415", "D401", "YTT", "ANN", "S", "B", "C4", "DTZ", "EM", "EXE", "FA", "INP", "PYI024", "PT", "RET503", "RET505", "ARG", "PTH123", "TD", "FIX", "PL", "RUF013", "TRY003", "TRY200" ] line-length = 120 target-version = "py310" [flake8-quotes] inline-quotes = "single" [per-file-ignores] "panther/main.py" = ["F405", "F403"] "panther/app.py" = ["SIM102"] "panther/utils.py" = ["UP004", "UP008"] "panther/cli/run_command.py" = ["PTH109"] "panther/cli/*" = ["T201"] "panther/db/models.py" = ["A003", "SIM108"] "panther/db/queries/queries.py" = ["SIM108"] "panther/routings.py" = ["SIM108"] "panther/status.py" = ["PLR2004"] "panther/_utils.py" = ["ERA001"] "example/app/apis.py" = ["ERA001"] "example/app/websockets.py" = ["T201"] "example/core/configs.py" = ["ERA001"] "example/app/urls.py" = ["F403", "F405"] "tests/*" = ["A003"] [format] quote-style = "single" ================================================ FILE: setup.py ================================================ import re import sys from setuptools import setup def panther_version() -> str: with open('panther/__init__.py') as f: return re.search('__version__ = [\'"]([^\'"]+)[\'"]', f.read()).group(1) VERSION = panther_version() with open('README.md', encoding='utf-8') as file: DESCRIPTION = file.read() INSTALL_REQUIRES = [ 'pantherdb~=2.3.3', 'orjson~=3.11.3', 'pydantic~=2.12.3', 'rich~=14.2.0', 'uvicorn>=0.21.0', 'pytz~=2025.2', 'Jinja2~=3.1.6', 'simple-ulid~=1.0.0', 'httptools~=0.7.1', ] EXTRAS_REQUIRE = { 'full': [ 'redis==6.2.0', 'motor~=3.7.1', 'ipython~=9.4.0', 'python-jose~=3.5.0', 'websockets~=15.0.1', 'cryptography~=45.0.5', 'watchfiles~=1.1.0', ], } setup( name='panther', version=VERSION, python_requires='>=3.10', author='Ali RajabNezhad', author_email='alirn76@yahoo.com', url='https://github.com/alirn76/panther', description='Fast & Friendly, Web Framework For Building Async APIs', long_description=DESCRIPTION, long_description_content_type='text/markdown', include_package_data=True, license='BSD-3-Clause license', classifiers=[ 'Operating System :: OS Independent', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3.14', ], entry_points={ 'console_scripts': ['panther=panther.cli.main:start'], }, package_data={ 'panther': ['cli/*', 'panel/templates/*', 'openapi/templates/*'], }, install_requires=INSTALL_REQUIRES, extras_require=EXTRAS_REQUIRE, ) ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/__main__.py ================================================ import argparse import os import sys import pytest from _pytest.config import ExitCode TEST_DIR = os.path.dirname(__file__) TEST_SUCCESS_CONDITIONS = [ExitCode.OK, ExitCode.NO_TESTS_COLLECTED] def run_test_file(path, flags: list) -> int: print(f'Running {os.path.basename(path)}') return pytest.main([path, *flags]) def main(): """ We have to separate load of test so we can have more isolated test cases, We had issue on testing `config`, some attributes been filled on the load of file, for example we collect models on __init_subclass__ of their parent Example Usage: python tests --not_slow --not_mongodb """ parser = argparse.ArgumentParser(description='Run test files individually.') # Make sure to define each flag in here, pyproject.toml and on top of the test target. parser.add_argument('--mongodb', action='store_true', help='Only run mongodb tests.') parser.add_argument('--not_mongodb', action='store_true', help='Does not run mongodb tests.') parser.add_argument('--slow', action='store_true', help='Only run slow tests.') parser.add_argument('--not_slow', action='store_true', help='Does not run slow tests.') args = parser.parse_args() files = [os.path.join(TEST_DIR, f) for f in os.listdir(TEST_DIR) if f.startswith('test_') and f.endswith('.py')] flags = [] if args.not_mongodb: flags.append('not mongodb') if args.not_slow: flags.append('not slow') if args.mongodb: flags.append('mongodb') if args.slow: flags.append('slow') if flags: flags = ['-m', ' and '.join(f for f in flags)] results = [run_test_file(file, flags) for file in files] for code, file in zip(results, files): if code not in TEST_SUCCESS_CONDITIONS: print(f'[FAIL] Some tests failed in {os.path.basename(file)}', file=sys.stderr) if any(code not in TEST_SUCCESS_CONDITIONS for code in results): sys.exit(1) print('\n[PASS] All tests passed.') if __name__ == '__main__': main() ================================================ FILE: tests/sample_project/app/apis.py ================================================ from panther.app import API @API() def first_api(): pass @API() def second_api(): pass ================================================ FILE: tests/sample_project/app/models.py ================================================ from panther.db.models import BaseUser class User(BaseUser): age: int ================================================ FILE: tests/sample_project/app/urls.py ================================================ from app.apis import first_api, second_api urls = { '/': first_api, 'second': second_api, } ================================================ FILE: tests/sample_project/core/configs.py ================================================ from datetime import timedelta from pathlib import Path from panther.throttling import Throttle from panther.utils import load_env BASE_DIR = Path(__name__).resolve().parent / 'tests/sample_project' env = load_env(BASE_DIR / '.env') SECRET_KEY = env['SECRET_KEY'] MIDDLEWARES = ['panther.middlewares.monitoring.MonitoringMiddleware'] LOG_QUERIES = True DATABASE = { 'engine': {'class': 'panther.db.connections.PantherDBConnection'}, } AUTHENTICATION = 'panther.authentications.JWTAuthentication' JWT_CONFIG = { 'algorithm': 'HS256', 'life_time': timedelta(days=2), 'key': SECRET_KEY, } URLs = 'core.urls.url_routing' USER_MODEL = 'app.models.User' THROTTLING = Throttle(rate=10, duration=timedelta(seconds=10)) ================================================ FILE: tests/sample_project/core/events.py ================================================ from panther.events import Event @Event.startup async def startup(): print('Starting Up') @Event.startup async def shutdown(): print('Shutting Down') ================================================ FILE: tests/sample_project/core/urls.py ================================================ from app.urls import urls url_routing = { '': urls, } ================================================ FILE: tests/test_api_kwargs.py ================================================ from unittest import IsolatedAsyncioTestCase from panther import Panther from panther.app import API, GenericAPI from panther.configs import config from panther.request import Request from panther.test import APIClient @API() async def request_api(request: Request): assert isinstance(request, Request) return True @API() async def req_api(req: Request): assert isinstance(req, Request) return True @API() async def integer_path_api(age: int): assert isinstance(age, int) return age @API() async def boolean_path_api(is_alive: bool): assert isinstance(is_alive, bool) return is_alive @API() async def string_path_api(name: str): assert isinstance(name, str) return name @API() async def unknown_path_api(subject): assert isinstance(subject, str) return subject @API() async def all_kwargs_api(request: Request, is_alive: bool, age: int, name: str, subject): assert isinstance(request, Request) assert isinstance(is_alive, bool) assert isinstance(age, int) assert isinstance(name, str) assert isinstance(subject, str) return is_alive, age, name, subject @API() async def unordered_kwargs_api(subject, is_alive: bool, name: str, request: Request, age: int): assert isinstance(request, Request) assert isinstance(is_alive, bool) assert isinstance(age, int) assert isinstance(name, str) assert isinstance(subject, str) return is_alive, age, name, subject class AllKwargsAPI(GenericAPI): async def get(self, request: Request, is_alive: bool, age: int, name: str, subject): assert isinstance(request, Request) assert isinstance(is_alive, bool) assert isinstance(age, int) assert isinstance(name, str) assert isinstance(subject, str) return is_alive, age, name, subject urls = { 'request': request_api, 'req': req_api, 'integer//': integer_path_api, 'boolean//': boolean_path_api, 'string//': string_path_api, 'unknown//': unknown_path_api, 'all/////': all_kwargs_api, 'unordered/////': unordered_kwargs_api, 'class/////': AllKwargsAPI, } class TestKwargs(IsolatedAsyncioTestCase): @classmethod def setUpClass(cls) -> None: app = Panther(__name__, configs=__name__, urls=urls) cls.client = APIClient(app=app) @classmethod def tearDownClass(cls): config.refresh() async def test_request(self): res = await self.client.get('request') assert res.status_code == 200 async def test_req(self): """Make sure it check the type of kwargs not the name of variable""" res = await self.client.get('req') assert res.status_code == 200 async def test_integer(self): res = await self.client.get('integer/10/') assert res.status_code == 200 assert res.data == 10 async def test_invalid_integer(self): res = await self.client.get('integer/ali/') assert res.status_code == 400 assert res.data == {'detail': 'Path variable `ali` is not `int`'} async def test_boolean_true(self): re1 = await self.client.get('boolean/true/') assert re1.status_code == 200 assert re1.data == True res2 = await self.client.get('boolean/TRUE/') assert res2.status_code == 200 assert res2.data == True res3 = await self.client.get('boolean/true/') assert res3.status_code == 200 assert res3.data == True async def test_boolean_false(self): res1 = await self.client.get('boolean/false/') assert res1.status_code == 200 assert res1.data == False res2 = await self.client.get('boolean/FALSE/') assert res2.status_code == 200 assert res2.data == False res3 = await self.client.get('boolean/False/') assert res3.status_code == 200 assert res3.data == False res4 = await self.client.get('boolean/0/') assert res4.status_code == 200 assert res4.data == False async def test_invalid_boolean(self): res = await self.client.get('boolean/ali/') assert res.status_code == 400 assert res.data == {'detail': 'Path variable `ali` is not `bool`'} async def test_string(self): res = await self.client.get('string/ali/') assert res.status_code == 200 assert res.data == 'ali' async def test_unknown(self): res = await self.client.get('unknown/test/') assert res.status_code == 200 assert res.data == 'test' async def test_all(self): res = await self.client.get('all/true/20/ali/test') assert res.status_code == 200 assert res.data == [True, 20, 'ali', 'test'] async def test_unordered(self): res = await self.client.get('unordered/true/20/ali/test') assert res.status_code == 200 assert res.data == [True, 20, 'ali', 'test'] async def test_class_based(self): res = await self.client.get('class/true/20/ali/test') assert res.status_code == 200 assert res.data == [True, 20, 'ali', 'test'] ================================================ FILE: tests/test_authentication.py ================================================ from pathlib import Path from unittest import IsolatedAsyncioTestCase from panther import Panther from panther.app import API, GenericAPI from panther.authentications import CookieJWTAuthentication, QueryParamJWTAuthentication from panther.configs import config from panther.db.models import BaseUser from panther.request import Request from panther.test import APIClient class CustomAuth: async def __call__(self, request: Request): return 'THIS IS USER' @API() async def auth_required_api(request: Request): return request.user @API() async def refresh_token_api(request: Request): return await request.user.refresh_tokens() @API() async def logout_api(request: Request): return await request.user.logout() @API(auth=CustomAuth) async def custom_auth_api(request: Request): return request.user async def custom_auth(req): return 'THIS IS USER' @API(auth=custom_auth) async def custom_auth_function_api(request: Request): return request.user urls = { 'auth-required': auth_required_api, 'refresh-token': refresh_token_api, 'logout': logout_api, 'custom-auth': custom_auth_api, 'custom-auth-function': custom_auth_function_api, } class User(BaseUser): username: str password: str AUTHENTICATION = 'panther.authentications.JWTAuthentication' SECRET_KEY = 'hvdhRspoTPh1cJVBHcuingQeOKNc1uRhIP2k7suLe2g=' DB_PATH = 'test.pdb' DATABASE = { 'engine': { 'class': 'panther.db.connections.PantherDBConnection', 'path': DB_PATH, }, } USER_MODEL = 'tests.test_authentication.User' class TestJWTAuthentication(IsolatedAsyncioTestCase): SHORT_TOKEN = {'Authorization': 'Token TOKEN'} NOT_ENOUGH_SEGMENT_TOKEN = {'Authorization': 'Bearer XXX'} JUST_BEARER_TOKEN = {'Authorization': 'Bearer'} BAD_UNICODE_TOKEN = {'Authorization': 'Bearer علی'} BAD_SIGNATURE_TOKEN = { 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MX0.JAWUkAU2mWhxcd6MS8r9pd44yBIfkEBmpr3WLeqIccM', } TOKEN_WITHOUT_USER_ID = { 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MX0.PpyXW0PgmGSPaaNirm_Ei4Y2fw9nb4TN26RN1u9RHSo', } TOKEN = { 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxfQ.AF3nsj8IQ6t0ncqIx4quoyPfYaZ-pqUOW4z_euUztPM', } def setUp(self) -> None: app = Panther(__name__, configs=__name__, urls=urls) self.client = APIClient(app=app) def tearDown(self) -> None: Path(DB_PATH).unlink() config.refresh() @classmethod def tearDownClass(cls): config.refresh() async def test_user_auth_required_without_auth_class(self): auth_config = config.AUTHENTICATION config.AUTHENTICATION = None with self.assertNoLogs(level='ERROR'): res = await self.client.get('auth-required') assert res.status_code == 200 assert res.data is None config.AUTHENTICATION = auth_config async def test_user_auth_required_without_token(self): with self.assertNoLogs(level='ERROR'): res = await self.client.get('auth-required') assert res.status_code == 200 assert res.data is None async def test_user_auth_required_with_bad_token_1(self): with self.assertLogs(level='ERROR') as captured: res = await self.client.get('auth-required', headers=self.SHORT_TOKEN) assert len(captured.records) == 1 assert captured.records[0].getMessage() == 'JWTAuthentication Error: "Authorization keyword is not valid"' assert res.status_code == 401 assert res.data['detail'] == 'Authentication Error' async def test_user_auth_required_with_bad_token2(self): with self.assertLogs(level='ERROR') as captured: res = await self.client.get('auth-required', headers=self.JUST_BEARER_TOKEN) assert len(captured.records) == 1 assert ( captured.records[0].getMessage() == 'JWTAuthentication Error: "Authorization header must contain 2 parts"' ) assert res.status_code == 401 assert res.data['detail'] == 'Authentication Error' async def test_user_auth_required_with_bad_token3(self): with self.assertLogs(level='ERROR') as captured: res = await self.client.get('auth-required', headers=self.BAD_UNICODE_TOKEN) assert len(captured.records) == 1 assert captured.records[0].getMessage() == ( "JWTAuthentication Error: \"'latin-1' codec can't encode characters in position 0-2: " 'ordinal not in range(256)"' ) assert res.status_code == 401 assert res.data['detail'] == 'Authentication Error' async def test_user_auth_required_with_bad_token4(self): with self.assertLogs(level='ERROR') as captured: res = await self.client.get('auth-required', headers=self.NOT_ENOUGH_SEGMENT_TOKEN) assert len(captured.records) == 1 assert captured.records[0].getMessage() == 'JWTAuthentication Error: "Not enough segments"' assert res.status_code == 401 assert res.data['detail'] == 'Authentication Error' async def test_user_auth_required_with_invalid_token_signature(self): with self.assertLogs(level='ERROR') as captured: res = await self.client.get('auth-required', headers=self.BAD_SIGNATURE_TOKEN) assert len(captured.records) == 1 assert captured.records[0].getMessage() == 'JWTAuthentication Error: "Signature verification failed."' assert res.status_code == 401 assert res.data['detail'] == 'Authentication Error' async def test_user_auth_required_with_token_without_user_id(self): with self.assertLogs(level='ERROR') as captured: res = await self.client.get('auth-required', headers=self.TOKEN_WITHOUT_USER_ID) assert len(captured.records) == 1 assert captured.records[0].getMessage() == 'JWTAuthentication Error: "Payload does not have `user_id`"' assert res.status_code == 401 assert res.data['detail'] == 'Authentication Error' async def test_user_auth_required_with_token_user_not_found(self): with self.assertLogs(level='ERROR') as captured: res = await self.client.get('auth-required', headers=self.TOKEN) assert len(captured.records) == 1 assert captured.records[0].getMessage() == 'JWTAuthentication Error: "User not found"' assert res.status_code == 401 assert res.data['detail'] == 'Authentication Error' async def test_user_auth_required_with_token(self): user = await User.insert_one(username='Username', password='Password') tokens = await user.login() with self.assertNoLogs(level='ERROR'): res = await self.client.get('auth-required', headers={'Authorization': f'Bearer {tokens["access_token"]}'}) expected_response = { 'id': user.id, 'username': 'Username', 'password': 'Password', 'last_login': user.last_login.isoformat(), 'date_created': user.date_created.isoformat(), } assert res.status_code == 200 assert res.data == expected_response async def test_invalid_refresh_token(self): user = await User.insert_one(username='Username', password='Password') tokens = await user.login() with self.assertLogs(level='ERROR') as captured: res = await self.client.get('refresh-token', headers={'Authorization': f'Bearer {tokens["access_token"]}'}) assert len(captured.records) == 1 assert ( captured.records[0].getMessage() == 'JWTAuthentication Error: "Invalid token type; expected `refresh` token."' ) assert res.status_code == 401 assert res.data['detail'] == 'Authentication Error' async def test_refresh_token(self): user = await User.insert_one(username='Username', password='Password') tokens = await user.login() with self.assertLogs(level='ERROR') as captured: res = await self.client.get('refresh-token', headers={'Authorization': f'Bearer {tokens["refresh_token"]}'}) assert len(captured.records) == 1 assert captured.records[0].getMessage() == 'Redis is not connected; token revocation is not effective.' assert res.status_code == 200 assert res.data.keys() == {'access_token', 'refresh_token'} async def test_cookie_authentication_without_token(self): auth_config = config.AUTHENTICATION config.AUTHENTICATION = CookieJWTAuthentication user = await User.insert_one(username='Username', password='Password') tokens = await user.login() with self.assertNoLogs(level='ERROR'): res = await self.client.get('auth-required', headers={'Authorization': f'Bearer {tokens["access_token"]}'}) assert res.status_code == 200 assert res.data is None config.AUTHENTICATION = auth_config async def test_cookie_authentication_invalid_token(self): auth_config = config.AUTHENTICATION config.AUTHENTICATION = CookieJWTAuthentication user = await User.insert_one(username='Username', password='Password') tokens = await user.login() with self.assertLogs(level='ERROR') as captured: res = await self.client.get('auth-required', {'cookie': f'access_token=s'}) assert len(captured.records) == 1 assert captured.records[0].getMessage() == 'CookieJWTAuthentication Error: "Not enough segments"' assert res.status_code == 401 assert res.data['detail'] == 'Authentication Error' config.AUTHENTICATION = auth_config async def test_cookie_authentication(self): auth_config = config.AUTHENTICATION config.AUTHENTICATION = CookieJWTAuthentication user = await User.insert_one(username='Username', password='Password') tokens = await user.login() with self.assertNoLogs(level='ERROR'): res = await self.client.get('auth-required', headers={'cookie': f'access_token={tokens["access_token"]}'}) expected_response = { 'id': user.id, 'username': 'Username', 'password': 'Password', 'last_login': user.last_login.isoformat(), 'date_created': user.date_created.isoformat(), } assert res.status_code == 200 assert res.data == expected_response config.AUTHENTICATION = auth_config async def test_cookie_invalid_refresh_token(self): auth_config = config.AUTHENTICATION config.AUTHENTICATION = CookieJWTAuthentication user = await User.insert_one(username='Username', password='Password') tokens = await user.login() with self.assertLogs(level='ERROR') as captured: res = await self.client.get('refresh-token', headers={'cookie': f'access_token={tokens["access_token"]}'}) assert len(captured.records) == 1 assert ( captured.records[0].getMessage() == 'CookieJWTAuthentication Error: "Invalid token type; expected `refresh` token."' ) assert res.status_code == 401 assert res.data['detail'] == 'Authentication Error' config.AUTHENTICATION = auth_config async def test_cookie_refresh_token(self): auth_config = config.AUTHENTICATION config.AUTHENTICATION = CookieJWTAuthentication user = await User.insert_one(username='Username', password='Password') tokens = await user.login() with self.assertLogs(level='ERROR') as captured: res = await self.client.get( 'refresh-token', headers={'cookie': f'access_token={tokens["access_token"]}; refresh_token={tokens["refresh_token"]}'}, ) assert len(captured.records) == 1 assert captured.records[0].getMessage() == 'Redis is not connected; token revocation is not effective.' assert res.status_code == 200 assert res.data.keys() == {'access_token', 'refresh_token'} config.AUTHENTICATION = auth_config async def test_query_param_authentication_without_token(self): auth_config = config.AUTHENTICATION config.AUTHENTICATION = QueryParamJWTAuthentication with self.assertNoLogs(level='ERROR'): res = await self.client.get('auth-required') assert res.status_code == 200 assert res.data is None config.AUTHENTICATION = auth_config async def test_query_param_authentication(self): auth_config = config.AUTHENTICATION config.AUTHENTICATION = QueryParamJWTAuthentication user = await User.insert_one(username='Username', password='Password') tokens = await user.login() with self.assertNoLogs(level='ERROR'): res = await self.client.get('auth-required', query_params={'authorization': tokens['access_token']}) expected_response = { 'id': user.id, 'username': 'Username', 'password': 'Password', 'last_login': user.last_login.isoformat(), 'date_created': user.date_created.isoformat(), } assert res.status_code == 200 assert res.data == expected_response config.AUTHENTICATION = auth_config async def test_query_param_invalid_refresh_token(self): auth_config = config.AUTHENTICATION config.AUTHENTICATION = QueryParamJWTAuthentication user = await User.insert_one(username='Username', password='Password') tokens = await user.login() with self.assertLogs(level='ERROR') as captured: res = await self.client.get('refresh-token', query_params={'authorization': tokens['access_token']}) assert len(captured.records) == 1 assert ( captured.records[0].getMessage() == 'QueryParamJWTAuthentication Error: "Invalid token type; expected `refresh` token."' ) assert res.status_code == 401 assert res.data['detail'] == 'Authentication Error' config.AUTHENTICATION = auth_config async def test_query_param_refresh_token(self): auth_config = config.AUTHENTICATION config.AUTHENTICATION = QueryParamJWTAuthentication user = await User.insert_one(username='Username', password='Password') tokens = await user.login() with self.assertLogs(level='ERROR') as captured: res = await self.client.get('refresh-token', query_params={'authorization': tokens['refresh_token']}) assert len(captured.records) == 1 assert captured.records[0].getMessage() == 'Redis is not connected; token revocation is not effective.' assert res.status_code == 200 assert res.data.keys() == {'access_token', 'refresh_token'} config.AUTHENTICATION = auth_config async def test_custom_auth_class(self): with self.assertNoLogs(level='ERROR'): res = await self.client.get('custom-auth') assert res.status_code == 200 assert res.data == 'THIS IS USER' async def test_custom_auth_function(self): with self.assertNoLogs(level='ERROR'): res = await self.client.get('custom-auth-function') assert res.status_code == 200 assert res.data == 'THIS IS USER' async def test_invalid_custom_auth_class(self): class SyncCustomAuth: def __call__(self, request: Request): return 'THIS IS USER' try: @API(auth=SyncCustomAuth) async def invalid_custom_auth_api(request: Request): return request.user except Exception as e: assert e.args[0] == 'SyncCustomAuth.__call__() should be `async`' else: assert False async def test_no_param_custom_auth_class(self): class InvalidCustomAuth: async def __call__(self): return 'THIS IS USER' try: @API(auth=InvalidCustomAuth) async def invalid_custom_auth_api(request: Request): return request.user except Exception as e: assert e.args[0] == 'InvalidCustomAuth.__call__() requires 2 positional argument(s) (self, request).' else: assert False async def test_invalid_custom_auth_function(self): def auth(req: str): pass try: @API(auth=auth) async def invalid_custom_auth_api(request: Request): return request.user except Exception as e: assert e.args[0] == 'auth() should be `async`' else: assert False async def test_no_param_custom_auth_function(self): def auth1(): pass try: @API(auth=auth1) async def invalid_custom_auth_api(request: Request): return request.user except Exception as e: assert e.args[0] == 'auth1() requires 1 positional argument(s) (request).' else: assert False async def test_class_based_api_invalid_auth_type(self): def auth(): pass try: class MyAPI(GenericAPI): auth = True except Exception as e: assert e.args[0] == ( '`bool` is not valid for authentication, it should be a callable, a Class with __call__ ' 'method or a single function.' ) else: assert False async def test_class_based_api_sync_function_auth(self): def auth_func(req): pass try: class MyAPI(GenericAPI): auth = auth_func except Exception as e: assert e.args[0] == 'auth_func() should be `async`' else: assert False async def test_class_based_api_no_param_function_auth(self): def auth_func2(): pass try: class MyAPI(GenericAPI): auth = auth_func2 except Exception as e: assert e.args[0] == 'auth_func2() requires 1 positional argument(s) (request).' else: assert False async def test_class_based_api_sync_class_auth(self): class CustomAuth1: def __call__(self, req): pass try: class MyAPI(GenericAPI): auth = CustomAuth1 except Exception as e: assert e.args[0] == 'CustomAuth1.__call__() should be `async`' else: assert False async def test_class_based_api_no_param_class_auth(self): class CustomAuth2: def __call__(self): pass try: class MyAPI(GenericAPI): auth = CustomAuth2 except Exception as e: assert e.args[0] == 'CustomAuth2.__call__() requires 2 positional argument(s) (self, request).' else: assert False async def test_class_based_api_class_without_call(self): class CustomAuth3: pass try: class MyAPI(GenericAPI): auth = CustomAuth3 except Exception as e: assert e.args[0] == 'CustomAuth3 must implement __call__() method.' else: assert False async def test_class_based_api_class_with_initiated_auth(self): class CustomAuth4: pass try: class MyAPI(GenericAPI): auth = CustomAuth4() except Exception as e: assert ( e.args[0] == '`CustomAuth4` is not valid for authentication, it should be a callable, a Class with __call__ method or a single function.' ) else: assert False async def test_logout(self): user = await User.insert_one(username='Username', password='Password') tokens = await user.login() with self.assertLogs(level='ERROR') as captured: res = await self.client.get('logout', headers={'Authorization': f'Bearer {tokens["access_token"]}'}) assert len(captured.records) == 1 assert captured.records[0].getMessage() == 'Redis is not connected; token revocation is not effective.' assert res.status_code == 200 # TODO: We have to implement a mini redis to work with its functionalities even when its not connected. # with self.assertLogs(level='ERROR') as captured: # res = await self.client.get('logout', headers={'Authorization': f'Bearer {tokens["access_token"]}'}) # assert res.status_code == 401 ================================================ FILE: tests/test_background_task_event_loop_isolation.py ================================================ import asyncio from unittest import TestCase from threading import Event, Thread import panther.background_tasks as background_tasks from panther.background_tasks import BackgroundTask def _set_application_loop(loop: asyncio.AbstractEventLoop | None) -> None: if hasattr(background_tasks, 'register_application_event_loop'): background_tasks.register_application_event_loop(loop) else: setattr(background_tasks, '_application_event_loop', loop) async def _create_loop_bound_future(loop: asyncio.AbstractEventLoop) -> asyncio.Future: return loop.create_future() class TestBackgroundTaskEventLoopIsolation(TestCase): def test_async_background_task_avoids_event_loop_mismatch(self): previous_loop = getattr(background_tasks, '_application_event_loop', None) app_loop = asyncio.new_event_loop() app_loop_ready = Event() app_loop_closed = Event() def run_application_loop(): asyncio.set_event_loop(app_loop) app_loop_ready.set() app_loop.run_forever() app_loop.close() app_loop_closed.set() loop_thread = Thread(target=run_application_loop, daemon=True) loop_thread.start() assert app_loop_ready.wait(timeout=1), 'Application event loop did not start in time.' try: _set_application_loop(app_loop) loop_bound_future = asyncio.run_coroutine_threadsafe( _create_loop_bound_future(app_loop), app_loop ).result(timeout=1) results = [] async def consume_loop_bound_future(_results): await loop_bound_future _results.append('completed') app_loop.call_soon_threadsafe(app_loop.call_later, 0.01, loop_bound_future.set_result, 'ok') BackgroundTask(consume_loop_bound_future, results)() assert results == ['completed'] finally: _set_application_loop(previous_loop) app_loop.call_soon_threadsafe(app_loop.stop) loop_thread.join(timeout=1) assert app_loop_closed.wait(timeout=1), 'Application event loop did not stop in time.' ================================================ FILE: tests/test_background_tasks.py ================================================ import datetime import time from unittest import TestCase import pytest from panther.background_tasks import BackgroundTask, BackgroundTasks, WeekDay, _background_tasks from panther.configs import config from panther.utils import Singleton, timezone_now @pytest.mark.slow class TestBackgroundTasks(TestCase): def setUp(self): self.obj = BackgroundTasks() config.BACKGROUND_TASKS = True def tearDown(self): del Singleton._instances[BackgroundTasks] config.BACKGROUND_TASKS = False @classmethod def tearDownClass(cls): config.refresh() def test_background_tasks_singleton(self): new_obj = BackgroundTasks() assert id(self.obj) == id(new_obj) def test_initialization(self): assert hasattr(self.obj, 'initialize') is True assert self.obj._initialized is False self.obj.initialize() assert self.obj._initialized is True assert self.obj.tasks == [] def test_add_single_task(self): def func(): pass task = BackgroundTask(func) self.obj.initialize() self.obj.add_task(task) assert self.obj.tasks == [task] def test_add_wrong_task(self): def func(): pass self.obj.initialize() assert self.obj.tasks == [] with self.assertLogs() as captured: self.obj.add_task(func) assert len(captured.records) == 1 assert ( captured.records[0].getMessage() == f'`{func.__name__}` ' f'should be instance of `background_tasks.BackgroundTask`' ) assert self.obj.tasks == [] def test_add_task_with_false_background_task(self): numbers = [] def func(_numbers): _numbers.append(1) task = BackgroundTask(func, numbers) with self.assertLogs() as captured: self.obj.add_task(task) assert len(captured.records) == 1 assert captured.records[0].getMessage() == 'Task will be ignored, `BACKGROUND_TASKS` is not True in `configs`' assert self.obj.tasks == [] def test_add_task_with_args(self): numbers = [] def func(_numbers): _numbers.append(1) task = BackgroundTask(func, numbers) self.obj.initialize() self.obj.add_task(task) assert self.obj.tasks == [task] time.sleep(2) assert len(numbers) == 1 def test_add_task_with_kwargs(self): numbers = [] def func(_numbers): _numbers.append(1) task = BackgroundTask(func, _numbers=numbers) self.obj.initialize() self.obj.add_task(task) assert self.obj.tasks == [task] time.sleep(2) assert len(numbers) == 1 def test_add_task_with_custom_interval(self): numbers = [] def func(_numbers): _numbers.append(1) task = BackgroundTask(func, numbers).every_seconds().interval(3) self.obj.initialize() self.obj.add_task(task) assert self.obj.tasks == [task] time.sleep(4) assert len(numbers) == 3 def test_add_task_with_custom_interval_default_timedelta(self): numbers = [] def func(_numbers): _numbers.append(1) task = BackgroundTask(func, numbers).interval(2) self.obj.initialize() self.obj.add_task(task) assert self.obj.tasks == [task] time.sleep(3) # default timedelta is 1 minute, so it can't complete the intervals in 2 seconds assert len(numbers) == 1 def test_add_multiple_tasks(self): numbers = [] def func1(_numbers): _numbers.append(1) def func2(_numbers): _numbers.append(1) task2 = BackgroundTask(func1, numbers) task1 = BackgroundTask(func2, numbers) self.obj.initialize() self.obj.add_task(task1) self.obj.add_task(task2) assert self.obj.tasks == [task1, task2] assert len(numbers) == 0 time.sleep(2) assert len(numbers) == 2 def test_concurrent_tasks(self): numbers = [] def func1(_numbers): time.sleep(3) _numbers.append(1) def func2(_numbers): time.sleep(3) _numbers.append(1) _background_tasks.initialize() BackgroundTask(func1, numbers).submit() BackgroundTask(func2, numbers).submit() assert len(numbers) == 0 time.sleep(5) # Initialization takes 2 second assert len(numbers) == 2 def test_add_task_with_custom_every_3_seconds(self): numbers = [] def func(_numbers): _numbers.append(1) task = BackgroundTask(func, numbers).every_seconds(3).interval(2) self.obj.initialize() self.obj.add_task(task) assert self.obj.tasks == [task] time.sleep(3) assert len(numbers) == 1 time.sleep(3) assert len(numbers) == 2 def test_submit_single_task(self): numbers = [] def func(_numbers): _numbers.append(1) _background_tasks.initialize() task = BackgroundTask(func, numbers).submit() assert _background_tasks.tasks == [task] assert len(numbers) == 0 time.sleep(2) assert len(numbers) == 1 def test_task_at_3second_later(self): numbers = [] def func(_numbers): _numbers.append(1) now = timezone_now() _background_tasks.initialize() BackgroundTask(func, numbers).at((now + datetime.timedelta(seconds=3)).time()).submit() time.sleep(2) assert len(numbers) == 0 time.sleep(3) assert len(numbers) == 1 def test_task_on_specific_weekday(self): numbers = [] def func(_numbers): _numbers.append(1) now = timezone_now() week_days = { 0: WeekDay.MONDAY, 1: WeekDay.TUESDAY, 2: WeekDay.WEDNESDAY, 3: WeekDay.THURSDAY, 4: WeekDay.FRIDAY, 5: WeekDay.SATURDAY, 6: WeekDay.SUNDAY, } _background_tasks.initialize() BackgroundTask(func, numbers).on(week_days[now.weekday()]).submit() assert len(numbers) == 0 time.sleep(1) assert len(numbers) == 1 ================================================ FILE: tests/test_caching.py ================================================ import asyncio import time from datetime import timedelta from unittest import IsolatedAsyncioTestCase import pytest from panther import Panther from panther.app import API from panther.configs import config from panther.response import HTMLResponse from panther.test import APIClient @API() async def without_cache_api(): await asyncio.sleep(0.01) return {'detail': time.time()} @API(cache=timedelta(seconds=2)) async def expired_cache_api(): await asyncio.sleep(0.01) return {'detail': time.time()} @API(cache=timedelta(seconds=2)) async def expired_cache_html_response(): await asyncio.sleep(0.01) return HTMLResponse(data=f'{time.time()}') urls = { 'without-cache': without_cache_api, 'with-expired-cache': expired_cache_api, 'with-html-response-cache': expired_cache_html_response, } class TestInMemoryCaching(IsolatedAsyncioTestCase): @classmethod def setUpClass(cls) -> None: app = Panther(__name__, configs=__name__, urls=urls) cls.client = APIClient(app=app) @classmethod def tearDownClass(cls): config.refresh() async def test_without_cache(self): res1 = await self.client.get('without-cache') assert res1.status_code == 200 res2 = await self.client.get('without-cache') assert res2.status_code == 200 assert res1.data != res2.data async def test_with_cache_5second_exp_time(self): # First Request with self.assertLogs(level='INFO') as captured: res1 = await self.client.get('with-expired-cache') assert res1.status_code == 200 # Check Logs assert len(captured.records) == 1 assert captured.records[0].getMessage() == '`cache` is not very accurate when `redis` is not connected.' # Second Request res2 = await self.client.get('with-expired-cache') assert res2.status_code == 200 # Response should be cached assert res1.data == res2.data await asyncio.sleep(2) # Third Request res3 = await self.client.get('with-expired-cache') assert res3.status_code == 200 # After 5 seconds we should have a new response assert res1.data != res3.data async def test_with_cache_content_type(self): # First Request res1 = await self.client.get('with-html-response-cache') assert res1.status_code == 200 # Second Request res2 = await self.client.get('with-html-response-cache') assert res2.status_code == 200 # Response should be cached assert res1.data == res2.data # Check Content-Type assert res1.headers['Content-Type'] == res2.headers['Content-Type'] ================================================ FILE: tests/test_cli.py ================================================ import os import shutil import sys from io import StringIO from pathlib import Path from unittest import skipIf from unittest.async_case import IsolatedAsyncioTestCase from unittest.mock import patch from rich import print as rprint from panther import Panther from panther.cli.create_project_command import CreateProject from panther.cli.create_user_command import create_user from panther.cli.template import SINGLE_FILE_TEMPLATE, TEMPLATE from panther.configs import config from panther.db.connections import db from panther.db.models import BaseUser interactive_cli_1_index = 0 interactive_cli_2_index = 0 DB_PATH = 'test_db.pdb' # 0.ProjectName, 1.BaseDir, 2.IsSingleFile, 3.Database, # 4.Encryption, 5.Authentication, 6.Monitoring, 7.LogQueries, 8.AutoReformat def interactive_cli_1_mock_responses(index=None): global interactive_cli_1_index if index is None: index = interactive_cli_1_index responses = ['project1', 'project1_dir', 'n', '0', 'y', 'n', 'y', 'y', 'y', 'y'] response = responses[index] interactive_cli_1_index += 1 return response def interactive_cli_2_mock_responses(index=None): global interactive_cli_2_index if index is None: index = interactive_cli_2_index responses = ['project2', 'project2_dir', 'y', '0', 'y', 'n', 'y', 'y', 'y', 'y'] response = responses[index] interactive_cli_2_index += 1 return response class TestCLI(IsolatedAsyncioTestCase): @classmethod def setUpClass(cls) -> None: sys.path.append('tests/sample_project') def tearDown(self) -> None: if db.is_defined: db.session.collection('BaseUser').drop() @classmethod def tearDownClass(cls) -> None: config.refresh() sys.path.pop() @skipIf(sys.platform.startswith('win'), 'Not supported in windows') async def test_print_info(self): with patch('sys.stdout', new=StringIO()) as fake_out1: Panther(__name__) base_dir = f'{Path(__name__).absolute().parent!s:<41}' expected_value = rf"""╭────────────────────────────────────────────────────────────╮ │ ____ __ __ │ │ /\ _`\ /\ \__/\ \ │ │ \ \ \L\ \ __ ___\ \ ,_\ \ \___ __ _ __ │ │ \ \ ,__/'__`\ /' _ `\ \ \/\ \ _ `\ /'__`\/\`'__\ │ │ \ \ \/\ \L\.\_/\ \/\ \ \ \_\ \ \ \ \/\ __/\ \ \/ │ │ \ \_\ \__/.\_\ \_\ \_\ \__\\ \_\ \_\ \____\\ \_\ │ │ \/_/\/__/\/_/\/_/\/_/\/__/ \/_/\/_/\/____/ \/_/ │ │ │ │ Redis: False │ │ Websocket: False │ │ Monitoring: True │ │ Log Queries: True │ │ Background Tasks: False │ │ Base directory: {base_dir}│ │ * Run "panther monitor" in another session for Monitoring │ │ * You may want to install `uvloop` for better performance │ │ `pip install uvloop` │ ╰────────────────────────────────────────────────────────────╯""" with patch('sys.stdout', new=StringIO()) as fake_out2: rprint(expected_value) assert fake_out1.getvalue() == fake_out2.getvalue() @patch('builtins.input', interactive_cli_1_mock_responses) async def test_create_normal_template_with_interactive_cli(self): CreateProject().create([]) project_path = interactive_cli_1_mock_responses(1) for file_name, data in SINGLE_FILE_TEMPLATE.items(): sub_directory = f'{project_path}/{file_name}' assert Path(sub_directory).exists() if isinstance(data, dict): for sub_file_name in data: file_path = f'{sub_directory}/{sub_file_name}' assert Path(file_path).exists() shutil.rmtree(project_path) @patch('builtins.input', interactive_cli_2_mock_responses) async def test_create_single_file_template_with_interactive_cli(self): CreateProject().create([]) project_path = interactive_cli_2_mock_responses(1) for file_name, data in SINGLE_FILE_TEMPLATE.items(): sub_directory = f'{project_path}/{file_name}' assert Path(sub_directory).exists() if isinstance(data, dict): for sub_file_name in data: file_path = f'{sub_directory}/{sub_file_name}' assert Path(file_path).exists() shutil.rmtree(project_path) async def test_create_on_existence_directory(self): project_path = 'test-project-directory' os.mkdir(project_path) with self.assertLogs(level='ERROR') as captured_error: CreateProject().create(['test_project', project_path]) with self.assertLogs(level='INFO') as captured_info: CreateProject().create(['test_project', project_path]) try: assert len(captured_error.records) == 1 assert captured_info.records[0].getMessage() == f'"{project_path}" directory already exists.' assert len(captured_info.records) == 2 assert captured_info.records[0].getMessage() == f'"{project_path}" directory already exists.' assert captured_info.records[1].getMessage() == 'Use "panther -h" for more help' except AssertionError: raise finally: os.removedirs(project_path) async def test_create_project(self): project_path = 'test-project-directory' CreateProject().create(['test_project', project_path]) for file_name, data in TEMPLATE.items(): sub_directory = f'{project_path}/{file_name}' assert Path(sub_directory).exists() if isinstance(data, dict): for sub_file_name in data: file_path = f'{sub_directory}/{sub_file_name}' assert Path(file_path).exists() shutil.rmtree(project_path) @patch('panther.cli.create_user_command.get_password', return_value='testpass') @patch('panther.cli.create_user_command.get_username', return_value='testuser') @patch('panther.cli.create_user_command.load_application_file') async def test_create_user_success(self, mock_load_application_file, mock_get_username, mock_get_password): global DATABASE # Setup mock_get_username.side_effect = ['testuser'] DATABASE = {'engine': {'class': 'panther.db.connections.PantherDBConnection', 'path': DB_PATH}} Panther(__name__, configs=__name__, urls={}) # Run with patch('sys.stdout', new=StringIO()) as fake_out: create_user(['dummy.py']) # Check user created user = await BaseUser.find_one({'username': 'testuser'}) assert user is not None assert user.check_password('testpass') # Cleanup await user.delete() # Check output output = fake_out.getvalue() assert 'Created Successfully' in output @patch('panther.cli.create_user_command.get_password', return_value='testpass') @patch('panther.cli.create_user_command.get_username', return_value='testuser') @patch('panther.cli.create_user_command.load_application_file') async def test_create_user_duplicate(self, mock_load_application_file, mock_get_username, mock_get_password): global DATABASE # Setup mock_get_username.side_effect = ['testuser', 'testuser2'] DATABASE = {'engine': {'class': 'panther.db.connections.PantherDBConnection', 'path': DB_PATH}} Panther(__name__, configs=__name__, urls={}) # Run 1 create_user(['dummy.py']) assert await BaseUser.exists(username='testuser') assert not await BaseUser.exists(username='testuser2') # Run 2 create_user(['dummy.py']) assert await BaseUser.exists(username='testuser') assert await BaseUser.exists(username='testuser2') @patch('panther.cli.create_user_command.get_password', side_effect=KeyboardInterrupt) @patch('panther.cli.create_user_command.get_username', return_value='testuser') @patch('panther.cli.create_user_command.load_application_file') async def test_create_user_keyboard_interrupt( self, mock_load_application_file, mock_get_username, mock_get_password ): global DATABASE DATABASE = {'engine': {'class': 'panther.db.connections.PantherDBConnection', 'path': DB_PATH}} Panther(__name__, configs=__name__, urls={}) # Run with patch('sys.stdout', new=StringIO()) as fake_out: create_user(['dummy.py']) output = fake_out.getvalue() assert 'Keyboard Interrupt' in output ================================================ FILE: tests/test_config.py ================================================ from datetime import timedelta from pathlib import Path from unittest import TestCase import jinja2 from panther import Panther from panther.app import GenericAPI from panther.authentications import CookieJWTAuthentication, QueryParamJWTAuthentication from panther.base_websocket import WebsocketConnections from panther.configs import JWTConfig, config from panther.db import Model from panther.db.connections import PantherDBConnection from panther.db.queries.pantherdb_queries import BasePantherDBQuery from panther.events import Event from panther.middlewares.monitoring import MonitoringMiddleware, WebsocketMonitoringMiddleware from panther.throttling import Throttle from panther.utils import generate_secret_key from panther.websocket import GenericWebsocket class User(Model): pass class Book(Model): pass class Author(Model): pass class DummyAPI(GenericAPI): pass class DummyWS(GenericWebsocket): pass @Event.startup def my_startup1(): pass @Event.startup def my_startup2(): pass @Event.shutdown def my_shutdown1(): pass @Event.shutdown def my_shutdown2(): pass class TestConfig(TestCase): def test_loading_known_configs(self): global \ BASE_DIR, \ MONITORING, \ LOG_QUERIES, \ THROTTLING, \ SECRET_KEY, \ MIDDLEWARES, \ HTTP_MIDDLEWARES, \ WS_MIDDLEWARES, \ USER_MODEL, \ AUTHENTICATION, \ WS_AUTHENTICATION, \ JWT_CONFIG, \ MODELS, \ FLAT_URLS, \ URLS, \ WEBSOCKET_CONNECTIONS, \ BACKGROUND_TASKS, \ HAS_WS, \ TIMEZONE, \ TEMPLATES_DIR, \ JINJA_ENVIRONMENT, \ AUTO_REFORMAT, \ QUERY_ENGINE, \ DATABASE # MIDDLEWARES MIDDLEWARES = ['panther.middlewares.monitoring.MonitoringMiddleware'] WS_MIDDLEWARES = ['panther.middlewares.monitoring.WebsocketMonitoringMiddleware'] LOG_QUERIES = True throttle = Throttle(rate=10, duration=timedelta(seconds=10)) THROTTLING = throttle new_secret_key = generate_secret_key() SECRET_KEY = new_secret_key USER_MODEL = 'tests.test_config.User' AUTHENTICATION = 'panther.authentications.QueryParamJWTAuthentication' WS_AUTHENTICATION = 'panther.authentications.CookieJWTAuthentication' JWT_CONFIG = {'life_time': timedelta(seconds=20)} BACKGROUND_TASKS = True TIMEZONE = 'Asia/Tehran' TEMPLATES_DIR = 'templates/' AUTO_REFORMAT = True DATABASE = { 'engine': { 'class': 'panther.db.connections.PantherDBConnection', }, } # Check before loading configs. # assert Path() == config.BASE_DIR assert config.MONITORING is False assert config.LOG_QUERIES is False assert config.THROTTLING is None assert config.SECRET_KEY is None assert config.HTTP_MIDDLEWARES == [] assert config.WS_MIDDLEWARES == [] assert config.USER_MODEL is None assert config.AUTHENTICATION is None assert config.WS_AUTHENTICATION is None assert config.JWT_CONFIG is None assert [User, Book, Author] == config.MODELS # This is ok. assert config.FLAT_URLS == {} assert config.URLS == {} assert config.WEBSOCKET_CONNECTIONS is None assert config.BACKGROUND_TASKS is False assert config.HAS_WS is True assert config.TIMEZONE == 'UTC' assert config.TEMPLATES_DIR == '.' assert config.JINJA_ENVIRONMENT is None assert config.AUTO_REFORMAT is False assert config.QUERY_ENGINE is None assert config.DATABASE is None with self.assertNoLogs(level='ERROR'): Panther(name=__name__, configs=__name__, urls={'dummy': DummyAPI, 'ws': DummyWS}) # Check vars() assert [*config.vars().keys()] == [ 'BASE_DIR', 'MONITORING', 'LOG_QUERIES', 'SECRET_KEY', 'HTTP_MIDDLEWARES', 'WS_MIDDLEWARES', 'USER_MODEL', 'AUTHENTICATION', 'WS_AUTHENTICATION', 'JWT_CONFIG', 'MODELS', 'FLAT_URLS', 'URLS', 'WEBSOCKET_CONNECTIONS', 'BACKGROUND_TASKS', 'HAS_WS', 'TIMEZONE', 'TEMPLATES_DIR', 'JINJA_ENVIRONMENT', 'AUTO_REFORMAT', 'QUERY_ENGINE', 'DATABASE', 'THROTTLING', 'MIDDLEWARES', ] # Check after loading configs. assert Path.cwd() == config.BASE_DIR assert config.MONITORING is True assert config.LOG_QUERIES is True assert throttle == config.THROTTLING assert new_secret_key == config.SECRET_KEY assert [MonitoringMiddleware] == config.HTTP_MIDDLEWARES assert [WebsocketMonitoringMiddleware] == config.WS_MIDDLEWARES assert config.USER_MODEL is User assert config.AUTHENTICATION is QueryParamJWTAuthentication assert config.WS_AUTHENTICATION is CookieJWTAuthentication assert JWTConfig(key=new_secret_key, algorithm='HS256', life_time=20, refresh_life_time=40) == config.JWT_CONFIG assert [User, Book, Author] == config.MODELS assert {'dummy/': DummyAPI, 'ws/': DummyWS} == config.FLAT_URLS assert {'dummy': DummyAPI, 'ws': DummyWS} == config.URLS assert isinstance(config.WEBSOCKET_CONNECTIONS, WebsocketConnections) assert config.BACKGROUND_TASKS is True assert config.HAS_WS is True assert config.TIMEZONE == 'Asia/Tehran' assert config.TEMPLATES_DIR == 'templates/' assert isinstance(config.JINJA_ENVIRONMENT, jinja2.environment.Environment) assert config.AUTO_REFORMAT is True assert config.QUERY_ENGINE is BasePantherDBQuery assert isinstance(config.DATABASE, PantherDBConnection) def test_loading_unknown_configs(self): global CUSTOM_KEY CUSTOM_KEY = 'I am custom' # Check before loading configs. assert config.CUSTOM_KEY is None with self.assertNoLogs(level='ERROR'): Panther(name=__name__, configs=__name__, urls={}) # Check after loading configs. assert config.CUSTOM_KEY == 'I am custom' def test_loading_unknown_configs_invalid_name(self): global CUSTOM_key CUSTOM_key = 'I am custom' # Check before loading configs. assert config.CUSTOM_key is None with self.assertNoLogs(level='ERROR'): Panther(name=__name__, configs=__name__, urls={}) # Check after loading configs. assert config.CUSTOM_key is None def test_refresh_configs(self): global \ BASE_DIR, \ MONITORING, \ LOG_QUERIES, \ THROTTLING, \ SECRET_KEY, \ MIDDLEWARES, \ HTTP_MIDDLEWARES, \ WS_MIDDLEWARES, \ USER_MODEL, \ AUTHENTICATION, \ WS_AUTHENTICATION, \ JWT_CONFIG, \ MODELS, \ FLAT_URLS, \ URLS, \ WEBSOCKET_CONNECTIONS, \ BACKGROUND_TASKS, \ HAS_WS, \ TIMEZONE, \ TEMPLATES_DIR, \ JINJA_ENVIRONMENT, \ AUTO_REFORMAT, \ QUERY_ENGINE, \ DATABASE # MIDDLEWARES MIDDLEWARES = ['panther.middlewares.monitoring.MonitoringMiddleware'] WS_MIDDLEWARES = ['panther.middlewares.monitoring.WebsocketMonitoringMiddleware'] LOG_QUERIES = True throttle = Throttle(rate=10, duration=timedelta(seconds=10)) THROTTLING = throttle new_secret_key = generate_secret_key() SECRET_KEY = new_secret_key USER_MODEL = 'tests.test_config.User' AUTHENTICATION = 'panther.authentications.QueryParamJWTAuthentication' WS_AUTHENTICATION = 'panther.authentications.CookieJWTAuthentication' JWT_CONFIG = {'life_time': timedelta(seconds=20)} BACKGROUND_TASKS = True TIMEZONE = 'Asia/Tehran' TEMPLATES_DIR = 'templates/' AUTO_REFORMAT = True DATABASE = { 'engine': { 'class': 'panther.db.connections.PantherDBConnection', }, } with self.assertNoLogs(level='ERROR'): Panther(name=__name__, configs=__name__, urls={'dummy': DummyAPI, 'ws': DummyWS}) config.refresh() assert Path() == config.BASE_DIR assert config.MONITORING is False assert config.LOG_QUERIES is False assert config.THROTTLING is None assert config.SECRET_KEY is None assert config.HTTP_MIDDLEWARES == [] assert config.WS_MIDDLEWARES == [] assert config.USER_MODEL is None assert config.AUTHENTICATION is None assert config.WS_AUTHENTICATION is None assert config.JWT_CONFIG is None assert config.MODELS == [] assert config.FLAT_URLS == {} assert config.URLS == {} assert config.WEBSOCKET_CONNECTIONS is None assert config.BACKGROUND_TASKS is False assert config.HAS_WS is False assert config.TIMEZONE == 'UTC' assert config.TEMPLATES_DIR == '.' assert config.JINJA_ENVIRONMENT is None assert config.AUTO_REFORMAT is False assert config.QUERY_ENGINE is None assert config.DATABASE is None def test_loading_unknown_config_types(self): global CUSTOM_INT, CUSTOM_LIST, CUSTOM_DICT, CUSTOM_BOOL CUSTOM_INT = 5 CUSTOM_LIST = [1, 2] CUSTOM_DICT = {'name': 'ali'} CUSTOM_BOOL = True # Check before loading configs. assert config.CUSTOM_INT is None assert config.CUSTOM_LIST is None assert config.CUSTOM_DICT is None assert config.CUSTOM_BOOL is None with self.assertNoLogs(level='ERROR'): Panther(name=__name__, configs=__name__, urls={}) # Check after loading configs. assert config.CUSTOM_INT == 5 assert config.CUSTOM_LIST == [1, 2] assert config.CUSTOM_DICT == {'name': 'ali'} assert config.CUSTOM_BOOL is True ================================================ FILE: tests/test_cors.py ================================================ from unittest import IsolatedAsyncioTestCase from panther import Panther from panther.app import API from panther.configs import config from panther.test import APIClient @API() async def simple_api(): return {'detail': 'ok'} urls = {'test': simple_api} ALLOW_ORIGINS = ['*'] ALLOW_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'] ALLOW_HEADERS = ['*'] ALLOW_CREDENTIALS = False EXPOSE_HEADERS = [] CORS_MAX_AGE = 600 MIDDLEWARES = ['panther.middlewares.cors.CORSMiddleware'] class TestCORSMiddlewares(IsolatedAsyncioTestCase): @classmethod def setUpClass(cls) -> None: cls.app = Panther(__name__, configs=__name__, urls=urls) cls.client = APIClient(app=cls.app) @classmethod def tearDownClass(cls) -> None: config.refresh() async def test_default_cors_headers(self): allow_origins = config.ALLOW_ORIGINS allow_methods = config.ALLOW_METHODS allow_headers = config.ALLOW_HEADERS allow_credentials = config.ALLOW_CREDENTIALS expose_headers = config.EXPOSE_HEADERS res = await self.client.get(path='test', headers={'origin': 'http://example.com'}) assert res.status_code == 200 assert res.headers['Access-Control-Allow-Origin'] == '*' assert res.headers['Access-Control-Allow-Methods'] == ', '.join(ALLOW_METHODS) assert res.headers['Access-Control-Allow-Headers'] == ', '.join(ALLOW_HEADERS) assert res.headers['Access-Control-Max-Age'] == str(CORS_MAX_AGE) assert 'Access-Control-Allow-Credentials' not in res.headers assert 'Access-Control-Expose-Headers' not in res.headers config.ALLOW_ORIGINS = allow_origins config.ALLOW_METHODS = allow_methods config.ALLOW_HEADERS = allow_headers config.ALLOW_CREDENTIALS = allow_credentials config.EXPOSE_HEADERS = expose_headers async def test_preflight_options(self): res = await self.client.options(path='test', headers={'origin': 'http://example.com'}) assert res.status_code == 204 assert res.headers['Access-Control-Allow-Origin'] == '*' assert res.headers['Access-Control-Allow-Methods'] == ', '.join(ALLOW_METHODS) assert res.headers['Access-Control-Allow-Headers'] == ', '.join(ALLOW_HEADERS) assert res.headers['Access-Control-Max-Age'] == str(CORS_MAX_AGE) async def test_custom_origins(self): allow_origins = config.ALLOW_ORIGINS config.ALLOW_ORIGINS = ['https://allowed.com'] res = await self.client.get(path='test', headers={'origin': 'https://allowed.com'}) assert res.headers['Access-Control-Allow-Origin'] == 'https://allowed.com' res = await self.client.get(path='test', headers={'origin': 'https://not-allowed.com'}) assert res.headers['Access-Control-Allow-Origin'] == 'https://allowed.com' config.ALLOW_ORIGINS = allow_origins async def test_credentials(self): allow_credentials = config.ALLOW_CREDENTIALS config.ALLOW_CREDENTIALS = True res = await self.client.get(path='test', headers={'origin': 'http://example.com'}) assert res.headers['Access-Control-Allow-Credentials'] == 'true' config.ALLOW_CREDENTIALS = allow_credentials async def test_expose_headers(self): expose_headers = config.EXPOSE_HEADERS config.EXPOSE_HEADERS = ['X-My-Header', 'X-Another-Header'] res = await self.client.get(path='test', headers={'origin': 'http://example.com'}) assert res.headers['Access-Control-Expose-Headers'] == 'X-My-Header, X-Another-Header' config.EXPOSE_HEADERS = expose_headers ================================================ FILE: tests/test_database.py ================================================ import random from pathlib import Path from unittest import IsolatedAsyncioTestCase import faker import pytest from pantherdb import Cursor as PantherDBCursor from pydantic import BaseModel from panther import Panther from panther.configs import config from panther.db import Model from panther.db.connections import db from panther.db.cursor import Cursor as MongoCursor f = faker.Faker() class Book(Model): name: str author: str pages_count: int class Viewer(BaseModel): first_name: str class Library(Model): name: str books: list[Book] viewer: Viewer class _BaseDatabaseTestCase: # # # Insert async def test_insert_one(self): name = f.name() author = f.name() pages_count = random.randint(0, 10) book = await Book.insert_one(name=name, author=author, pages_count=pages_count) assert isinstance(book, Book) assert book.id assert book.name == name assert book.pages_count == pages_count async def test_insert_many_with_insert_one(self): insert_count = await self._insert_many() assert insert_count > 1 async def test_insert_many(self): insert_count = random.randint(2, 10) initial_books_data = [ {'name': f.name(), 'author': f.name(), 'pages_count': random.randint(0, 10)} for _ in range(insert_count) ] books = await Book.insert_many(initial_books_data) actual_books = [{'name': book.name, 'author': book.author, 'pages_count': book.pages_count} for book in books] expected_books = [ {'name': book['name'], 'author': book['author'], 'pages_count': book['pages_count']} for book in initial_books_data ] assert len(actual_books) == len(expected_books) assert actual_books == expected_books # # # FindOne async def test_find_one_not_found(self): # Insert Many await self._insert_many() # Find One book = await Book.find_one(name='NotFound', author='NotFound', pages_count=0) assert book is None async def test_find_one_in_many_when_its_last(self): # Insert Many await self._insert_many() # Insert One name = f.name() author = f.name() pages_count = random.randint(0, 10) created_book = await Book.insert_one(name=name, author=author, pages_count=pages_count) # Find One book = await Book.find_one(name=name, author=author, pages_count=pages_count) assert isinstance(book, Book) assert book.id assert str(book._id) == str(book.id) assert book.name == name assert book.pages_count == pages_count assert created_book == book async def test_find_one_in_many_when_its_middle(self): # Insert Many await self._insert_many() # Insert One name = f.name() author = f.name() pages_count = random.randint(0, 10) created_book = await Book.insert_one(name=name, author=author, pages_count=pages_count) # Insert Many await self._insert_many() # Find One book = await Book.find_one(name=name, author=author, pages_count=pages_count) assert isinstance(book, Book) assert book.id assert book.name == name assert book.pages_count == pages_count assert created_book == book async def test_first(self): # Insert Many await self._insert_many() # Insert Many With Same Params name = f.name() author = f.name() pages_count = random.randint(0, 10) await self._insert_many_with_specific_params(name=name, author=author, pages_count=pages_count) # Find First book = await Book.first(name=name, author=author, pages_count=pages_count) assert isinstance(book, Book) assert book.id assert book.name == name assert book.pages_count == pages_count async def test_first_not_found(self): # Insert Many await self._insert_many() # Find First book = await Book.first(name='NotFound', author='NotFound', pages_count=0) assert book is None async def test_last(self): # Insert Many await self._insert_many() # Insert Many With Same Params name = f.name() author = f.name() pages_count = random.randint(0, 10) await self._insert_many_with_specific_params(name=name, author=author, pages_count=pages_count) last_obj = await Book.insert_one(name=name, author=author, pages_count=pages_count) # Find One book = await Book.last(name=name, author=author, pages_count=pages_count) assert isinstance(book, Book) assert book.id == last_obj.id assert book.name == name assert book.pages_count == pages_count async def test_last_not_found(self): await self._insert_many() # Find Last book = await Book.last(name='NotFound', author='NotFound', pages_count=0) assert book is None # # # Find async def test_find(self): # Insert Many await self._insert_many() # Insert Many With Specific Name name = f.name() insert_count = await self._insert_many_with_specific_params(name=name) # Find books = await Book.find(name=name) _len = sum(1 for _ in books) if self.__class__.__name__ == 'TestMongoDB': assert isinstance(books, MongoCursor) else: assert isinstance(books, PantherDBCursor) assert _len == insert_count for book in books: assert isinstance(book, Book) assert book.name == name async def test_find_not_found(self): # Insert Many await self._insert_many() # Find books = await Book.find(name='NotFound') _len = sum(1 for _ in books) if self.__class__.__name__ == 'TestMongoDB': assert isinstance(books, MongoCursor) else: assert isinstance(books, PantherDBCursor) assert _len == 0 async def test_find_without_filter(self): # Insert Many insert_count = await self._insert_many() # Find All books = await Book.find() _len = sum(1 for _ in books) if self.__class__.__name__ == 'TestMongoDB': assert isinstance(books, MongoCursor) else: assert isinstance(books, PantherDBCursor) assert _len == insert_count for book in books: assert isinstance(book, Book) async def test_all(self): # Insert Many insert_count = await self._insert_many() # Find All books = await Book.all() _len = sum(1 for _ in books) if self.__class__.__name__ == 'TestMongoDB': assert isinstance(books, MongoCursor) else: assert isinstance(books, PantherDBCursor) assert _len == insert_count for book in books: assert isinstance(book, Book) async def test_aggregation(self): # Insert Many insert_count = await self._insert_many() # Find All with aggregate books = await Book.aggregate([]) _len = sum(1 for _ in books) assert isinstance(books, list) assert _len == insert_count for book in books: assert isinstance(book, dict) assert {*book.keys()} == {'_id', 'name', 'author', 'pages_count'} # # # Count async def test_count_all(self): # Insert Many insert_count = await self._insert_many() # Count All books_count = await Book.count() assert isinstance(books_count, int) assert books_count == insert_count async def test_count_with_filter(self): # Insert Many await self._insert_many() # Insert Many With Specific Name name = f.name() insert_count = await self._insert_many_with_specific_params(name=name) # Count books_count = await Book.count(name=name) assert isinstance(books_count, int) assert books_count == insert_count async def test_count_not_found(self): # Insert Many await self._insert_many() # Count books_count = await Book.count(name='NotFound') assert isinstance(books_count, int) assert books_count == 0 # # # Delete One async def test_delete_one(self): # Insert Many await self._insert_many() # Insert With Specific Name name = f.name() insert_count = await self._insert_many_with_specific_params(name=name) # Delete One is_deleted = await Book.delete_one(name=name) assert isinstance(is_deleted, bool) assert is_deleted is True # Count Them After Deletion assert await Book.count(name=name) == insert_count - 1 async def test_delete_self(self): # Insert Many await self._insert_many() # Insert With Specific Name name = f.name() insert_count = await self._insert_many_with_specific_params(name=name) # Delete One book = await Book.find_one(name=name) await book.delete() # Count Them After Deletion assert await Book.count(name=name) == insert_count - 1 async def test_delete_one_not_found(self): # Insert Many insert_count = await self._insert_many() # Delete One is_deleted = await Book.delete_one(name='_Invalid_Name_') assert isinstance(is_deleted, bool) assert is_deleted is False # Count All assert await Book.count() == insert_count # # # Delete Many async def test_delete_many(self): # Insert Many pre_insert_count = await self._insert_many() # Insert With Specific Name name = f.name() insert_count = await self._insert_many_with_specific_params(name=name) # Delete Many deleted_count = await Book.delete_many(name=name) assert isinstance(deleted_count, int) assert deleted_count == insert_count # Count Them After Deletion assert await Book.count(name=name) == 0 assert await Book.count() == pre_insert_count async def test_delete_many_not_found(self): # Insert Many pre_insert_count = await self._insert_many() # Delete Many name = 'NotFound' deleted_count = await Book.delete_many(name=name) assert isinstance(deleted_count, int) assert deleted_count == 0 # Count Them After Deletion assert await Book.count(name=name) == 0 assert await Book.count() == pre_insert_count # # # Update async def test_update_one(self): # Insert Many insert_count = await self._insert_many() # Insert With Specific Name name = f.name() author = f.name() pages_count = random.randint(0, 10) await Book.insert_one(name=name, author=author, pages_count=pages_count) # Update One new_name = 'New Name' is_updated = await Book.update_one({'name': name}, name=new_name) assert isinstance(is_updated, bool) assert is_updated is True book = await Book.find_one(name=new_name) assert isinstance(book, Book) assert book.author == author assert book.pages_count == pages_count # Count Them After Update assert await Book.count(name=name) == 0 assert await Book.count() == insert_count + 1 async def test_update_self(self): # Insert Many insert_count = await self._insert_many() # Insert With Specific Name name = f.name() author = f.name() pages_count = random.randint(0, 10) await Book.insert_one(name=name, author=author, pages_count=pages_count) # Update One book = await Book.find_one(name=name) new_name = 'New Name' await book.update(name=new_name) assert book.name == new_name assert book.author == author book = await Book.find_one(name=new_name) assert isinstance(book, Book) assert book.author == author assert book.pages_count == pages_count # Count Them After Update assert await Book.count(name=name) == 0 assert await Book.count() == insert_count + 1 async def test_update_one_not_found(self): # Insert Many insert_count = await self._insert_many() # Update One new_name = 'New Name' is_updated = await Book.update_one({'name': 'NotFound'}, name=new_name) assert isinstance(is_updated, bool) assert is_updated is False book = await Book.find_one(name=new_name) assert book is None # Count Them After Update assert await Book.count() == insert_count # # # Update Many async def test_update_many(self): # Insert Many pre_insert_count = await self._insert_many() # Insert With Specific Name name = f.name() author = f.name() pages_count = random.randint(0, 10) insert_count = await self._insert_many_with_specific_params(name=name, author=author, pages_count=pages_count) # Update Many new_name = 'New Name' updated_count = await Book.update_many({'name': name}, name=new_name) assert isinstance(updated_count, int) assert updated_count == insert_count books = await Book.find(name=new_name) _len = sum(1 for _ in books) if self.__class__.__name__ == 'TestMongoDB': assert isinstance(books, MongoCursor) else: assert isinstance(books, PantherDBCursor) assert _len == updated_count == insert_count for book in books: assert book.author == author assert book.pages_count == pages_count # Count Them After Update assert await Book.count() == pre_insert_count + insert_count async def test_update_many_not_found(self): # Insert Many insert_count = await self._insert_many() # Update Many new_name = 'New Name' updated_count = await Book.update_many({'name': 'NotFound'}, name=new_name) assert isinstance(updated_count, int) assert updated_count == 0 book = await Book.find_one(name=new_name) assert book is None # Count Them After Update assert await Book.count() == insert_count async def test_save_update(self): await self._insert_many() # Insert With Specific Name name = f.name() author = f.name() pages_count = random.randint(0, 10) book = await Book.insert_one(name=name, author=author, pages_count=pages_count) # Save assert book.id assert book.author == author book.author = 'New' await book.save() # Make sure instance is updated assert book.author == 'New' # Make sure database is updated book = await Book.find_one(name=name) assert isinstance(book, Book) assert book.author == 'New' assert book.pages_count == pages_count async def test_save_insert(self): name = f.name() author = f.name() pages_count = random.randint(0, 10) book = Book(name=name, author=author, pages_count=pages_count) await book.save() assert book.id assert book.author == author assert book.pages_count == pages_count async def test_save_nested_models(self): library = await Library.insert_one( name='Name1', viewer=Viewer(first_name='Ali'), books=[Book(name='Book1', author='Author1', pages_count=1)] ) assert library.id assert library.name == 'Name1' assert library.viewer.first_name == 'Ali' assert library.books[0].name == 'Book1' assert library.books[0].author == 'Author1' assert library.books[0].pages_count == 1 @classmethod async def _insert_many(cls) -> int: insert_count = random.randint(2, 10) for _ in range(insert_count): await Book.insert_one(name=f.name(), author=f.name(), pages_count=random.randint(0, 10)) return insert_count @classmethod async def _insert_many_with_specific_params( cls, name: str = f.name(), author: str = f.name(), pages_count: int = random.randint(0, 10), ) -> int: insert_count = random.randint(2, 10) for _ in range(insert_count): await Book.insert_one(name=name, author=author, pages_count=pages_count) return insert_count class TestPantherDB(_BaseDatabaseTestCase, IsolatedAsyncioTestCase): DB_PATH = 'test.pdb' @classmethod def setUpClass(cls) -> None: global DATABASE DATABASE = {'engine': {'class': 'panther.db.connections.PantherDBConnection', 'path': cls.DB_PATH}} Panther(__name__, configs=__name__, urls={}) def tearDown(self) -> None: db.session.collection('Book').drop() @classmethod def tearDownClass(cls): config.refresh() Path(cls.DB_PATH).unlink(missing_ok=True) async def test_aggregation(self): pass @pytest.mark.mongodb class TestMongoDB(_BaseDatabaseTestCase, IsolatedAsyncioTestCase): DB_NAME = 'test.pdb' @classmethod def setUpClass(cls) -> None: global DATABASE DATABASE = { 'engine': { 'class': 'panther.db.connections.MongoDBConnection', 'host': f'mongodb://127.0.0.1:27017/{cls.DB_NAME}', }, } def setUp(self): Panther(__name__, configs=__name__, urls={}) def tearDown(self) -> None: db.session.drop_collection('Book') @classmethod def tearDownClass(cls): config.refresh() ================================================ FILE: tests/test_database_advance.py ================================================ import asyncio import contextlib from pathlib import Path from typing import List from unittest import IsolatedAsyncioTestCase import faker import pytest from pydantic import BaseModel from panther import Panther from panther.configs import config from panther.db import Model from panther.db.connections import db from panther.exceptions import DatabaseError, NotFoundAPIError with contextlib.suppress(ImportError): # Only required if user wants to use mongodb import bson f = faker.Faker() class Book(Model): name: str class BookDetail(BaseModel): detail: str book: Book more_books: list[list[Book]] class Viewer(BaseModel): first_name: str class Library(Model): name: str books: list[Book] viewer: Viewer class TestBook(Model): name: str author: str pages_count: int class Author(Model): name: str books: list[Book] books2: List[Book] book: Book book2: None | Book = None book_detail: dict our_book_detail: BookDetail class InvalidModel(Model): new_book: dict[str, Book] class Publisher(Model): name: str rating: float is_active: bool class _BaseDatabaseTestCase: async def test_insert_one(self): book = await Book.insert_one(name='my_test') author = await Author.insert_one( {'name': 'ali'}, books=[book], books2=[book.id, book.model_dump()], book=Book(name='test_book1'), book2=None, book_detail={'book1': book}, our_book_detail=BookDetail(detail='ok', book=book, more_books=[[book, book]]), ) assert isinstance(book, Book) assert book.id assert book.name == 'my_test' assert author.name == 'ali' assert isinstance(author.books, list) assert len(author.books) == 1 assert author.books[0] == book assert author.books[0] assert isinstance(author.books2, list) assert len(author.books2) == 2 assert author.books2[0] == book assert author.books2[1] == book assert isinstance(author.book, Book) assert author.book.id assert author.book.name == 'test_book1' assert author.book2 is None assert isinstance(author.book_detail, dict) assert list(author.book_detail.keys()) == ['book1'] assert author.book_detail['book1'] == book.id # Known Issue assert isinstance(author.our_book_detail, BookDetail) assert author.our_book_detail.detail == 'ok' assert author.our_book_detail.book == book assert isinstance(author.our_book_detail.more_books, list) assert len(author.our_book_detail.more_books) == 1 assert isinstance(author.our_book_detail.more_books[0], list) assert len(author.our_book_detail.more_books[0]) == 2 assert author.our_book_detail.more_books[0][0] == book assert author.our_book_detail.more_books[0][1] == book async def test_insert_one_with_model_obj(self): book = await Book.insert_one(name='my_test') author = await Author.insert_one( Author( name='ali', books=[book], books2=[book, book], book=Book(name='test_book1'), book2=None, book_detail={'book1': book}, our_book_detail=BookDetail(detail='ok', book=book, more_books=[[book, book]]), ) ) assert isinstance(book, Book) assert book.id assert book.name == 'my_test' assert author.name == 'ali' assert isinstance(author.books, list) assert len(author.books) == 1 assert author.books[0] == book assert author.books[0] assert isinstance(author.books2, list) assert len(author.books2) == 2 assert author.books2[0] == book assert author.books2[1] == book assert isinstance(author.book, Book) assert author.book.id assert author.book.name == 'test_book1' assert author.book2 is None assert isinstance(author.book_detail, dict) assert list(author.book_detail.keys()) == ['book1'] assert author.book_detail['book1'] == book.id # Known Issue assert isinstance(author.our_book_detail, BookDetail) assert author.our_book_detail.detail == 'ok' assert author.our_book_detail.book == book assert isinstance(author.our_book_detail.more_books, list) assert len(author.our_book_detail.more_books) == 1 assert isinstance(author.our_book_detail.more_books[0], list) assert len(author.our_book_detail.more_books[0]) == 2 assert author.our_book_detail.more_books[0][0] == book assert author.our_book_detail.more_books[0][1] == book async def test_insert_one_with_model_dump(self): book = await Book.insert_one(name='my_test') author = await Author.insert_one( Author( name='ali', books=[book], books2=[book, book], book=book, book2=None, book_detail={'book1': book}, our_book_detail=BookDetail(detail='ok', book=book, more_books=[[book, book]]), ).model_dump() ) assert isinstance(book, Book) assert book.id assert book.name == 'my_test' assert author.name == 'ali' assert isinstance(author.books, list) assert len(author.books) == 1 assert author.books[0] == book assert author.books[0] assert isinstance(author.books2, list) assert len(author.books2) == 2 assert author.books2[0] == book assert author.books2[1] == book assert isinstance(author.book, Book) assert author.book == book assert author.book2 is None assert isinstance(author.book_detail, dict) assert list(author.book_detail.keys()) == ['book1'] assert author.book_detail['book1'] == book.model_dump() assert isinstance(author.our_book_detail, BookDetail) assert author.our_book_detail.detail == 'ok' assert author.our_book_detail.book == book assert isinstance(author.our_book_detail.more_books, list) assert len(author.our_book_detail.more_books) == 1 assert isinstance(author.our_book_detail.more_books[0], list) assert len(author.our_book_detail.more_books[0]) == 2 assert author.our_book_detail.more_books[0][0] == book assert author.our_book_detail.more_books[0][1] == book async def test_insert_many(self): book = await Book.insert_one(name='my_test') authors = await Author.insert_many( [ { 'name': 'ali', 'books': [book], 'books2': [book.id, book], 'book': Book(name='test_book1'), 'book2': None, 'book_detail': {'book1': book.model_dump()}, 'our_book_detail': BookDetail(detail='ok', book=book, more_books=[[book, book]]), }, { 'name': 'ali', 'books': [book], 'books2': [book.id, book], 'book': Book(name='test_book2'), 'book2': None, 'book_detail': {'book1': book.model_dump()}, 'our_book_detail': BookDetail(detail='ok', book=book, more_books=[[book, book]]), }, ], ) for author in authors: assert isinstance(book, Book) assert book.id assert book.name == 'my_test' assert author.name == 'ali' assert isinstance(author.books, list) assert len(author.books) == 1 assert author.books[0] == book assert author.books[0] assert isinstance(author.books2, list) assert len(author.books2) == 2 assert author.books2[0] == book assert author.books2[1] == book assert isinstance(author.book, Book) assert author.book.id assert author.book.name in ['test_book1', 'test_book2'] assert author.book2 is None assert isinstance(author.book_detail, dict) assert list(author.book_detail.keys()) == ['book1'] assert author.book_detail['book1'] == book.model_dump() assert isinstance(author.our_book_detail, BookDetail) assert author.our_book_detail.detail == 'ok' assert author.our_book_detail.book == book assert isinstance(author.our_book_detail.more_books, list) assert len(author.our_book_detail.more_books) == 1 assert isinstance(author.our_book_detail.more_books[0], list) assert len(author.our_book_detail.more_books[0]) == 2 assert author.our_book_detail.more_books[0][0] == book assert author.our_book_detail.more_books[0][1] == book async def test_find_one(self): book = await Book.insert_one(name='my_test') await Author.insert_one( name='ali', books=[book], books2=[book, book], book=Book(name='test_book1'), book2=None, book_detail={'book1': book}, our_book_detail=BookDetail(detail='ok', book=book, more_books=[[book, book]]), ) author = await Author.find_one(name='ali') assert isinstance(book, Book) assert book.id assert book.name == 'my_test' assert author.name == 'ali' assert isinstance(author.books, list) assert len(author.books) == 1 assert author.books[0] == book assert author.books[0] assert isinstance(author.books2, list) assert len(author.books2) == 2 assert author.books2[0] == book assert author.books2[1] == book assert isinstance(author.book, Book) assert author.book.id assert author.book.name == 'test_book1' assert author.book2 is None assert isinstance(author.book_detail, dict) assert list(author.book_detail.keys()) == ['book1'] assert author.book_detail['book1'] == book.id # Known Issue assert isinstance(author.our_book_detail, BookDetail) assert author.our_book_detail.detail == 'ok' assert author.our_book_detail.book == book assert isinstance(author.our_book_detail.more_books, list) assert len(author.our_book_detail.more_books) == 1 assert isinstance(author.our_book_detail.more_books[0], list) assert len(author.our_book_detail.more_books[0]) == 2 assert author.our_book_detail.more_books[0][0] == book assert author.our_book_detail.more_books[0][1] == book async def test_find(self): book = await Book.insert_one(name='my_test') await Author.insert_one( name='ali', books=[book], books2=[book, book], book=Book(name='test_book1'), book2=None, book_detail={'book1': book}, our_book_detail=BookDetail(detail='ok', book=book, more_books=[[book, book]]), ) _authors = await Author.find(name='ali') authors = [i for i in _authors] assert len(authors) == 1 author = authors[0] assert isinstance(book, Book) assert book.id assert book.name == 'my_test' assert author.name == 'ali' assert isinstance(author.books, list) assert len(author.books) == 1 assert author.books[0] == book assert author.books[0] assert isinstance(author.books2, list) assert len(author.books2) == 2 assert author.books2[0] == book assert author.books2[1] == book assert isinstance(author.book, Book) assert author.book.id assert author.book.name == 'test_book1' assert author.book2 is None assert isinstance(author.book_detail, dict) assert list(author.book_detail.keys()) == ['book1'] assert author.book_detail['book1'] == book.id # Known Issue assert isinstance(author.our_book_detail, BookDetail) assert author.our_book_detail.detail == 'ok' assert author.our_book_detail.book == book assert isinstance(author.our_book_detail.more_books, list) assert len(author.our_book_detail.more_books) == 1 assert isinstance(author.our_book_detail.more_books[0], list) assert len(author.our_book_detail.more_books[0]) == 2 assert author.our_book_detail.more_books[0][0] == book assert author.our_book_detail.more_books[0][1] == book async def test_insert_one_invalid_model(self): book = await Book.insert_one(name='my_test') try: await InvalidModel.insert_one(new_book={'book': book}) except DatabaseError as e: assert ( e.args[0] == 'Panther does not support dict[str, tests.test_database_advance.Book] as a field type for unwrapping.' ) # New comprehensive test cases async def test_find_one_or_insert_existing(self): """Test find_one_or_insert when document exists""" book = await Book.insert_one(name='existing_book') result, is_inserted = await Book.find_one_or_insert(name='existing_book') assert not is_inserted assert result == book assert result.name == 'existing_book' async def test_find_one_or_insert_new(self): """Test find_one_or_insert when document doesn't exist""" result, is_inserted = await Book.find_one_or_insert(name='new_book') assert is_inserted assert isinstance(result, Book) assert result.name == 'new_book' assert result.id async def test_find_one_or_raise_found(self): """Test find_one_or_raise when document exists""" book = await Book.insert_one(name='existing_book') result = await Book.find_one_or_raise(name='existing_book') assert result == book assert result.name == 'existing_book' async def test_find_one_or_raise_not_found(self): """Test find_one_or_raise when document doesn't exist""" try: await Book.find_one_or_raise(name='non_existent_book') assert False, 'Should have raised NotFoundAPIError' except NotFoundAPIError as e: assert e.detail == 'Book Does Not Exist' async def test_exists_true(self): """Test exists when document exists""" await Book.insert_one(name='existing_book') result = await Book.exists(name='existing_book') assert result is True async def test_exists_false(self): """Test exists when document doesn't exist""" result = await Book.exists(name='non_existent_book') assert result is False async def test_cursor_operations(self): """Test cursor operations: skip, limit, sort""" # Insert multiple books books = [] for i in range(10): book = await Book.insert_one(name=f'book_{i}') books.append(book) # Test skip and limit cursor = await Book.find() cursor = cursor.skip(2).limit(3) results = [book async for book in cursor] assert len(results) == 3 assert results[0].name == 'book_2' assert results[1].name == 'book_3' assert results[2].name == 'book_4' async def test_cursor_sort(self): """Test cursor sorting""" # Insert books with different names await Book.insert_one(name='zebra') await Book.insert_one(name='apple') await Book.insert_one(name='banana') # Test ascending sort cursor = await Book.find() cursor = cursor.sort([('name', 1)]) results = [book async for book in cursor] assert len(results) == 3 assert results[0].name == 'apple' assert results[1].name == 'banana' assert results[2].name == 'zebra' # Test descending sort cursor = await Book.find() cursor = cursor.sort([('name', -1)]) results = [book async for book in cursor] assert len(results) == 3 assert results[0].name == 'zebra' assert results[1].name == 'banana' assert results[2].name == 'apple' async def test_update_with_complex_data(self): """Test update operations with complex nested data""" book = await Book.insert_one(name='original_book') author = await Author.insert_one( name='original_author', books=[book], books2=[book], book=book, book2=None, book_detail={'book1': book}, our_book_detail=BookDetail(detail='original', book=book, more_books=[[book]]), ) # Update with new book new_book = await Book.insert_one(name='new_book') await Author.update_one( {'name': 'original_author'}, name='updated_author', books=[new_book], book=new_book, our_book_detail=BookDetail(detail='updated', book=new_book, more_books=[[new_book]]), ) updated_author = await Author.find_one(name='updated_author') assert updated_author.name == 'updated_author' assert len(updated_author.books) == 1 assert updated_author.books[0] == new_book assert updated_author.book == new_book assert updated_author.our_book_detail.detail == 'updated' async def test_bulk_operations(self): """Test bulk insert and update operations""" # Bulk insert books_data = [{'name': 'bulk_book'} for _ in range(5)] books = await Book.insert_many(books_data) assert len(books) == 5 for book in books: assert book.name == 'bulk_book' # Bulk update updated_count = await Book.update_many({'name': 'bulk_book'}, name='updated_bulk_book') assert updated_count == 5 # Verify updates updated_books = await Book.find(name='updated_bulk_book') count = sum(1 for _ in updated_books) assert count == 5 async def test_empty_collection_operations(self): """Test operations on empty collections""" # Test find on empty collection books = await Book.find() count = sum(1 for _ in books) assert count == 0 # Test count on empty collection count = await Book.count() assert count == 0 # Test find_one on empty collection book = await Book.find_one(name='any_name') assert book is None # Test first/last on empty collection first_book = await Book.first() assert first_book is None last_book = await Book.last() assert last_book is None async def test_model_validation_errors(self): """Test handling of invalid data""" # Test with missing required fields try: await Book.insert_one() # Missing name field assert False, 'Should have raised validation error' except DatabaseError as e: assert 'Book(name="Field required")' == str(e) async def test_complex_nested_queries(self): """Test complex nested queries with multiple conditions""" # Create test data book1 = await Book.insert_one(name='book1') book2 = await Book.insert_one(name='book2') author1 = await Author.insert_one( name='author1', books=[book1], books2=[book1], book=book1, book_detail={'book1': book1}, our_book_detail=BookDetail(detail='detail1', book=book1, more_books=[[book1]]), ) author2 = await Author.insert_one( name='author2', books=[book2], books2=[book2], book=book2, book_detail={'book2': book2}, our_book_detail=BookDetail(detail='detail2', book=book2, more_books=[[book2]]), ) # Complex query authors = await Author.find(name='author1') results = [author for author in authors] assert len(results) == 1 assert results[0].name == 'author1' async def test_save_operations(self): """Test save operations for both insert and update""" # Test save for new object (insert) new_book = Book(name='new_book_save') await new_book.save() assert new_book.id is not None assert new_book.name == 'new_book_save' # Verify it was actually saved saved_book = await Book.find_one(name='new_book_save') assert saved_book is not None assert saved_book.id == new_book.id # Test save for existing object (update) new_book.name = 'updated_book_save' await new_book.save() # Verify update updated_book = await Book.find_one(name='updated_book_save') assert updated_book is not None assert updated_book.id == new_book.id async def test_concurrent_operations(self): """Test concurrent operations (basic simulation)""" # Insert multiple books concurrently import asyncio async def insert_book(name): return await Book.insert_one(name=name) # Create multiple concurrent insertions tasks = [insert_book(f'concurrent_book_{i}') for i in range(5)] books = await asyncio.gather(*tasks) assert len(books) == 5 for i, book in enumerate(books): assert book.name == f'concurrent_book_{i}' assert book.id is not None async def test_large_dataset_operations(self): """Test operations with larger datasets""" # Insert larger dataset books_data = [{'name': f'large_book_{i}'} for i in range(100)] books = await Book.insert_many(books_data) assert len(books) == 100 # Test pagination with large dataset cursor = await Book.find() cursor = cursor.skip(50).limit(25) results = [book async for book in cursor] assert len(results) == 25 assert results[0].name == 'large_book_50' assert results[-1].name == 'large_book_74' # Test count on large dataset count = await Book.count() assert count >= 100 async def test_edge_cases(self): """Test various edge cases""" # Test with empty string book = await Book.insert_one(name='') assert book.name == '' # Test with very long name long_name = 'a' * 1000 book = await Book.insert_one(name=long_name) assert book.name == long_name # Test with special characters special_name = 'book_with_!@#$%^&*()_+-=[]{}|;:,.<>?' book = await Book.insert_one(name=special_name) assert book.name == special_name async def test_relationship_integrity(self): """Test relationship integrity and cascading effects""" # Create books and authors with relationships book1 = await Book.insert_one(name='book1') book2 = await Book.insert_one(name='book2') author = await Author.insert_one( name='test_author', books=[book1, book2], books2=[book1, book2], book=book1, book_detail={'book1': book1, 'book2': book2}, our_book_detail=BookDetail(detail='test', book=book1, more_books=[[book1, book2]]), ) # Verify relationships are properly established assert len(author.books) == 2 assert len(author.books2) == 2 assert author.book == book1 assert len(author.our_book_detail.more_books[0]) == 2 async def test_error_handling_edge_cases(self): """Test error handling for various edge cases""" # Test with None values where not allowed try: await Book.insert_one(name=None) assert False, 'Should have raised validation error' except Exception: pass # Expected to fail # Test with invalid data types try: await Book.insert_one(name=123) # Should be string assert False, 'Should have raised validation error' except Exception: pass # Expected to fail async def test_performance_optimizations(self): """Test performance-related operations""" # Test batch operations batch_size = 50 books_data = [{'name': f'batch_book_{i}'} for i in range(batch_size)] start_time = asyncio.get_event_loop().time() books = await Book.insert_many(books_data) end_time = asyncio.get_event_loop().time() assert len(books) == batch_size # Basic performance check (should complete in reasonable time) assert end_time - start_time < 0.1 # Should complete within 0.1 seconds async def test_data_consistency(self): """Test data consistency across operations""" # Insert book book = await Book.insert_one(name='consistency_test') # Verify it exists found_book = await Book.find_one(name='consistency_test') assert found_book == book # Update it await Book.update_one({'name': 'consistency_test'}, name='updated_consistency_test') # Verify update updated_book = await Book.find_one(name='updated_consistency_test') assert updated_book is not None assert updated_book.id == book.id # Verify old name doesn't exist old_book = await Book.find_one(name='consistency_test') assert old_book is None async def test_partial_update(self): """Test partial update (patch) only updates specified fields""" # Insert a book with all fields book = await TestBook.insert_one(name='partial_book', author='original_author', pages_count=123) # Partial update: only update author await TestBook.update_one({'id': book.id}, author='patched_author') # Fetch and check updated = await TestBook.find_one(id=book.id) assert updated.author == 'patched_author' assert updated.name == 'partial_book' assert updated.pages_count == 123 # Partial update: only update pages_count await TestBook.update_one({'id': book.id}, pages_count=456) updated2 = await TestBook.find_one(id=book.id) assert updated2.pages_count == 456 assert updated2.author == 'patched_author' assert updated2.name == 'partial_book' async def test_first(self): """Test getting the first document matching a filter""" await TestBook.insert_one(name='first1', author='a', pages_count=1) await TestBook.insert_one(name='first2', author='a', pages_count=2) first = await TestBook.first(author='a') assert first is not None assert first.author == 'a' assert first.name in ['first1', 'first2'] async def test_last(self): """Test getting the last document matching a filter""" await TestBook.insert_one(name='last1', author='b', pages_count=1) await TestBook.insert_one(name='last2', author='b', pages_count=2) last = await TestBook.last(author='b') assert last is not None assert last.author == 'b' assert last.name in ['last1', 'last2'] async def test_save_nested_models(self): """Test saving a model with nested BaseModel fields""" viewer = Viewer(first_name='Ali') book = await Book.insert_one(name='nested_book') library = await Library.insert_one(name='Central', books=[book], viewer=viewer) assert library.id assert library.name == 'Central' assert isinstance(library.books, list) assert library.books[0] == book assert isinstance(library.viewer, Viewer) assert library.viewer.first_name == 'Ali' class TestPantherDB(_BaseDatabaseTestCase, IsolatedAsyncioTestCase): DB_PATH = 'test.pdb' @classmethod def setUpClass(cls) -> None: global DATABASE DATABASE = { 'engine': {'class': 'panther.db.connections.PantherDBConnection', 'path': cls.DB_PATH}, } Panther(__name__, configs=__name__, urls={}) def tearDown(self) -> None: db.session.collection('Book').drop() db.session.collection('Author').drop() db.session.collection('Publisher').drop() db.session.collection('TestBook').drop() db.session.collection('Library').drop() @classmethod def tearDownClass(cls): config.refresh() Path(cls.DB_PATH).unlink(missing_ok=True) @pytest.mark.mongodb class TestMongoDB(_BaseDatabaseTestCase, IsolatedAsyncioTestCase): DB_NAME = 'test.pdb' @classmethod def setUpClass(cls) -> None: global DATABASE DATABASE = { 'engine': { 'class': 'panther.db.connections.MongoDBConnection', 'host': f'mongodb://127.0.0.1:27017/{cls.DB_NAME}', }, } def setUp(self): Panther(__name__, configs=__name__, urls={}) def tearDown(self) -> None: db.session.drop_collection('Book') db.session.drop_collection('Author') db.session.drop_collection('Publisher') db.session.drop_collection('TestBook') db.session.drop_collection('Library') @classmethod def tearDownClass(cls) -> None: config.refresh() async def test_insert_one_raw_document(self): book = await Book.insert_one(name='my_test') author = await Author.insert_one( name='ali', books=[book.model_dump()], books2=[book.id, book], book=Book(name='test_book1'), book2=None, book_detail={'book1': book}, our_book_detail=BookDetail(detail='ok', book=book, more_books=[[book, book]]), ) document = await db.session['Author'].find_one() assert isinstance(document, dict) assert list(document.keys()) == [ '_id', 'name', 'books', 'books2', 'book', 'book2', 'book_detail', 'our_book_detail', ] assert document['_id'] == author._id assert document['name'] == 'ali' assert isinstance(document['books'], list) assert len(document['books']) == 1 assert isinstance(document['books'][0], bson.ObjectId) assert document['books'][0] == book._id assert isinstance(document['books2'], list) assert len(document['books2']) == 2 assert isinstance(document['books2'][0], bson.ObjectId) assert document['books2'][0] == book._id assert isinstance(document['books2'][1], bson.ObjectId) assert document['books2'][1] == book._id assert isinstance(document['book'], bson.ObjectId) assert document['book'] != book._id # A new book assert document['book2'] is None assert isinstance(document['book_detail'], dict) assert list(document['book_detail'].keys()) == ['book1'] assert isinstance(document['book_detail']['book1'], bson.ObjectId) assert document['book_detail']['book1'] == book._id assert isinstance(document['our_book_detail'], dict) assert list(document['our_book_detail'].keys()) == ['detail', 'book', 'more_books'] assert document['our_book_detail']['detail'] == 'ok' assert isinstance(document['our_book_detail']['book'], bson.ObjectId) assert document['our_book_detail']['book'] == book._id assert isinstance(document['our_book_detail']['more_books'], list) assert len(document['our_book_detail']['more_books']) == 1 assert isinstance(document['our_book_detail']['more_books'][0], list) assert len(document['our_book_detail']['more_books'][0]) == 2 assert isinstance(document['our_book_detail']['more_books'][0][0], bson.ObjectId) assert document['our_book_detail']['more_books'][0][0] == book._id assert isinstance(document['our_book_detail']['more_books'][0][1], bson.ObjectId) assert document['our_book_detail']['more_books'][0][1] == book._id async def test_aggregation_pipeline(self): """Test MongoDB aggregation pipeline""" # Insert test data publishers = [ {'name': 'Publisher A', 'rating': 4.5, 'is_active': True}, {'name': 'Publisher B', 'rating': 3.8, 'is_active': True}, {'name': 'Publisher C', 'rating': 4.2, 'is_active': False}, {'name': 'Publisher D', 'rating': 4.7, 'is_active': True}, ] await Publisher.insert_many(publishers) # Test aggregation pipeline pipeline = [{'$match': {'is_active': True}}, {'$group': {'_id': None, 'avg_rating': {'$avg': '$rating'}}}] results = await Publisher.aggregate(pipeline) results_list = list(results) assert len(results_list) == 1 assert 'avg_rating' in results_list[0] # Should be average of 4.5, 3.8, and 4.7 (active publishers only) expected_avg = (4.5 + 3.8 + 4.7) / 3 assert abs(results_list[0]['avg_rating'] - expected_avg) < 0.01 async def test_mongodb_specific_features(self): """Test MongoDB-specific features like ObjectId handling""" # Test ObjectId conversion book = await Book.insert_one(name='mongodb_test') # Verify _id property works correctly assert hasattr(book, '_id') assert isinstance(book._id, bson.ObjectId) assert str(book._id) == str(book.id) # Test querying by ObjectId found_book = await Book.find_one(id=book._id) assert found_book == book # Test querying by string ID found_book_str = await Book.find_one(id=str(book._id)) assert found_book_str == book async def test_mongodb_complex_queries(self): """Test complex MongoDB queries with operators""" # Insert test data with various ratings publishers = [ {'name': 'High Rated', 'rating': 4.8, 'is_active': True}, {'name': 'Medium Rated', 'rating': 3.5, 'is_active': True}, {'name': 'Low Rated', 'rating': 2.1, 'is_active': False}, {'name': 'Top Rated', 'rating': 4.9, 'is_active': True}, {'name': 'Top Rated', 'rating': 5.9, 'is_active': False}, ] await Publisher.insert_many(publishers) # Test complex query with multiple conditions high_rated_active = await Publisher.find({'rating': {'$gte': 4.0}, 'is_active': True}) results = [pub for pub in high_rated_active] assert len(results) == 2 # High Rated, Top Rated, and one more for pub in results: assert pub.rating >= 4.0 assert pub.is_active is True async def test_mongodb_bulk_operations(self): """Test MongoDB-specific bulk operations""" # Test bulk write operations books_data = [{'name': f'mongodb_bulk_{i}'} for i in range(10)] books = await Book.insert_many(books_data) # Test bulk update with MongoDB operators update_result = await Book.update_many( {'name': {'$regex': 'mongodb_bulk_'}}, {'$set': {'name': 'updated_mongodb_bulk'}} ) # Verify all documents were updated updated_books = await Book.find(name='updated_mongodb_bulk') count = sum(1 for _ in updated_books) assert count == 10 async def test_mongodb_data_types(self): """Test MongoDB-specific data types and handling""" # Test with various data types that MongoDB handles well test_data = { 'name': 'data_type_test', 'rating': 4.5, 'is_active': True, 'tags': ['fiction', 'adventure'], 'metadata': {'pages': 300, 'language': 'English'}, } # Create a test model for this class TestModel(Model): name: str rating: float is_active: bool tags: list[str] metadata: dict publisher = await TestModel.insert_one(**test_data) assert publisher.name == 'data_type_test' assert publisher.rating == 4.5 assert publisher.is_active is True assert publisher.tags == ['fiction', 'adventure'] assert publisher.metadata == {'pages': 300, 'language': 'English'} async def test_mongodb_error_handling(self): """Test MongoDB-specific error handling""" # Test with invalid ObjectId try: await Book.find_one(id='invalid_object_id') # Should handle gracefully except Exception as e: # Should not crash the application assert 'invalid' in str(e).lower() or 'objectid' in str(e).lower() ================================================ FILE: tests/test_events.py ================================================ import logging from unittest import IsolatedAsyncioTestCase from panther.configs import config from panther.events import Event logger = logging.getLogger('panther') class TestEvents(IsolatedAsyncioTestCase): def setUp(self): Event.clear() @classmethod def tearDownClass(cls): config.refresh() async def test_async_startup(self): assert len(Event._startups) == 0 async def startup_event(): logger.info('This Is Startup.') Event.startup(startup_event) assert len(Event._startups) == 1 assert Event._startups[0] == startup_event with self.assertLogs(level='INFO') as capture: await Event.run_startups() assert len(capture.records) == 1 assert capture.records[0].getMessage() == 'This Is Startup.' async def test_sync_startup(self): assert len(Event._startups) == 0 def startup_event(): logger.info('This Is Startup.') Event.startup(startup_event) assert len(Event._startups) == 1 assert Event._startups[0] == startup_event with self.assertLogs(level='INFO') as capture: await Event.run_startups() assert len(capture.records) == 1 assert capture.records[0].getMessage() == 'This Is Startup.' async def test_startup(self): assert len(Event._startups) == 0 def startup_event1(): logger.info('This Is Startup1.') async def startup_event2(): logger.info('This Is Startup2.') Event.startup(startup_event1) Event.startup(startup_event2) assert len(Event._startups) == 2 assert Event._startups[0] == startup_event1 assert Event._startups[1] == startup_event2 with self.assertLogs(level='INFO') as capture: await Event.run_startups() assert len(capture.records) == 2 assert capture.records[0].getMessage() == 'This Is Startup1.' assert capture.records[1].getMessage() == 'This Is Startup2.' async def test_sync_shutdown(self): assert len(Event._shutdowns) == 0 def shutdown_event(): logger.info('This Is Shutdown.') Event.shutdown(shutdown_event) assert len(Event._shutdowns) == 1 assert Event._shutdowns[0] == shutdown_event with self.assertLogs(level='INFO') as capture: Event.run_shutdowns() assert len(capture.records) == 1 assert capture.records[0].getMessage() == 'This Is Shutdown.' async def shutdown_event(self): logger.info('This Is Shutdown.') def test_async_shutdown(self): assert len(Event._shutdowns) == 0 Event.shutdown(self.shutdown_event) assert len(Event._shutdowns) == 1 assert Event._shutdowns[0] == self.shutdown_event with self.assertLogs(level='INFO') as capture: Event.run_shutdowns() assert len(capture.records) == 1 assert capture.records[0].getMessage() == 'This Is Shutdown.' def test_shutdown(self): assert len(Event._shutdowns) == 0 def shutdown_event_sync(): logger.info('This Is Sync Shutdown.') Event.shutdown(self.shutdown_event) Event.shutdown(shutdown_event_sync) assert len(Event._shutdowns) == 2 assert Event._shutdowns[0] == self.shutdown_event assert Event._shutdowns[1] == shutdown_event_sync with self.assertLogs(level='INFO') as capture: Event.run_shutdowns() assert len(capture.records) == 2 assert capture.records[0].getMessage() == 'This Is Shutdown.' assert capture.records[1].getMessage() == 'This Is Sync Shutdown.' def test_singleton_pattern(self): """Test that Event class works as a singleton""" event1 = Event() event2 = Event() assert event1 is event2 # Test that the same instance is used for storing events def test_func(): pass Event.startup(test_func) assert len(Event._startups) == 1 assert Event._startups[0] == test_func ================================================ FILE: tests/test_file.py ================================================ import platform import tempfile import unittest from pathlib import Path from panther._utils import detect_mime_type from panther.file_handler import File class TestFile(unittest.TestCase): def test_file_init_with_bytes(self): data = b'hello world' f = File(file_name='test.txt', content_type='text/plain', file=data) assert f.file_name == 'test.txt' assert f.content_type == 'text/plain' assert f.file == data assert f.size == len(data) def test_file_init_with_path(self): with tempfile.TemporaryDirectory() as tmp_dir_name: file_path = Path(tmp_dir_name) / 'foo.txt' file_path.write_bytes(b'abc123') f = File(file_name=str(file_path), content_type='text/plain') assert f.file_name == str(file_path) assert f.content_type == 'text/plain' assert f.size == 6 def test_file_read_and_seek(self): with tempfile.TemporaryDirectory() as tmp_dir_name: file_path = Path(tmp_dir_name) / 'bar.txt' file_path.write_bytes(b'abcdef') f = File(file_name=str(file_path), content_type='text/plain') with f: assert f.read(3) == b'abc' assert f.tell() == 3 f.seek(0) assert f.read() == b'abcdef' def test_file_write_in_memory(self): f = File(file_name='baz.txt', content_type='text/plain', file=b'foo') f.write(b'bar') assert f.file == b'foobar' assert f.size == 6 def test_file_write_on_disk_raises(self): with tempfile.TemporaryDirectory() as tmp_dir_name: file_path = Path(tmp_dir_name) / 'baz.txt' file_path.write_bytes(b'foo') f = File(file_name=str(file_path), content_type='text/plain') with f: try: f.write(b'bar') assert False, 'Expected IOError to be raised' except IOError: pass def test_file_save_overwrite(self): with tempfile.TemporaryDirectory() as tmp_dir_name: f = File(file_name='test.txt', content_type='text/plain', file=b'data') save_path = Path(tmp_dir_name) / 'out.txt' result_path = f.save(str(save_path), overwrite=True) assert Path(result_path).read_bytes() == b'data' # Overwrite again f2 = File(file_name='test.txt', content_type='text/plain', file=b'new') result_path2 = f2.save(str(save_path), overwrite=True) assert Path(result_path2).read_bytes() == b'new' assert result_path == result_path2 def test_file_save_no_overwrite(self): with tempfile.TemporaryDirectory() as tmp_dir_name: f = File(file_name='test.txt', content_type='text/plain', file=b'data') save_path = Path(tmp_dir_name) / 'out.txt' result_path1 = f.save(str(save_path), overwrite=False) assert Path(result_path1).read_bytes() == b'data' # Save again, should create a new file with timestamp f2 = File(file_name='test.txt', content_type='text/plain', file=b'new') result_path2 = f2.save(str(save_path), overwrite=False) assert Path(result_path2).read_bytes() == b'new' assert result_path1 != result_path2 def test_file_save_idempotent(self): """Test that save() method is idempotent - returns same path on multiple calls""" with tempfile.TemporaryDirectory() as tmp_dir_name: f = File(file_name='test.txt', content_type='text/plain', file=b'data') save_path = Path(tmp_dir_name) / 'out.txt' # First call result_path1 = f.save(str(save_path), overwrite=True) assert Path(result_path1).read_bytes() == b'data' # Second call - should return same path result_path2 = f.save(str(save_path), overwrite=True) assert result_path1 == result_path2 # Third call - should return same path result_path3 = f.save(str(save_path), overwrite=True) assert result_path1 == result_path3 # Verify file content is still the same assert Path(result_path1).read_bytes() == b'data' def test_file_save_idempotent_with_different_paths(self): """Test that idempotency works even when called with different paths""" with tempfile.TemporaryDirectory() as tmp_dir_name: f = File(file_name='test.txt', content_type='text/plain', file=b'data') # First call result_path1 = f.save(Path(tmp_dir_name) / 'first.txt', overwrite=True) # Second call with different path - should still return first path result_path2 = f.save(Path(tmp_dir_name) / 'second.txt', overwrite=True) assert result_path1 == result_path2 # Third call with no path - should still return first path result_path3 = f.save(overwrite=True) assert result_path1 == result_path3 def test_file_save_directory_path(self): """Test saving to directory path (ending with /)""" with tempfile.TemporaryDirectory() as tmp_dir_name: f = File(file_name='document.pdf', content_type='application/pdf', file=b'pdf content') # Save to directory result_path = f.save(f'{tmp_dir_name}/uploads/') expected_path = Path(tmp_dir_name) / 'uploads' / 'document.pdf' assert result_path == str(expected_path) assert expected_path.exists() assert expected_path.read_bytes() == b'pdf content' def test_file_save_nested_directory_path(self): """Test saving to nested directory path""" with tempfile.TemporaryDirectory() as tmp_dir_name: f = File(file_name='image.jpg', content_type='image/jpeg', file=b'jpeg content') # Save to nested directory result_path = f.save(f'{tmp_dir_name}/uploads/images/') expected_path = Path(tmp_dir_name) / 'uploads' / 'images' / 'image.jpg' assert result_path == str(expected_path) assert expected_path.exists() assert expected_path.read_bytes() == b'jpeg content' def test_file_save_directory_path_with_custom_filename(self): """Test saving to directory with custom filename""" with tempfile.TemporaryDirectory() as tmp_dir_name: f = File(file_name='original.txt', content_type='text/plain', file=b'content') # Save to directory with custom filename result_path = f.save(f'{tmp_dir_name}/uploads/custom_name.txt') expected_path = Path(tmp_dir_name) / 'uploads' / 'custom_name.txt' assert result_path == str(expected_path) assert expected_path.exists() assert expected_path.read_bytes() == b'content' def test_file_save_directory_creation(self): """Test that directories are created automatically""" with tempfile.TemporaryDirectory() as tmp_dir_name: f = File(file_name='test.txt', content_type='text/plain', file=b'data') # Try to save to non-existent directory target_dir = Path(tmp_dir_name) / 'new' / 'nested' / 'directory' result_path = f.save(str(target_dir / 'test.txt'), overwrite=True) # Directory should be created assert target_dir.exists() assert Path(result_path).exists() def test_file_save_directory_path_idempotent(self): """Test that directory path saving is also idempotent""" with tempfile.TemporaryDirectory() as tmp_dir_name: f = File(file_name='test.txt', content_type='text/plain', file=b'data') # First call result_path1 = f.save(f'{tmp_dir_name}/uploads/') # Second call - should return same path result_path2 = f.save(f'{tmp_dir_name}/uploads/') assert result_path1 == result_path2 # Third call with different directory - should still return first path result_path3 = f.save(f'{tmp_dir_name}/different/') assert result_path1 == result_path3 def test_file_save_without_path_uses_filename(self): """Test that save() without path uses the original filename""" with tempfile.TemporaryDirectory() as tmp_dir_name: original_cwd = Path.cwd() try: # Change to temp directory for testing import os os.chdir(tmp_dir_name) f = File(file_name='test.txt', content_type='text/plain', file=b'data') result_path = f.save() # Should save as 'test.txt' in current directory expected_path = Path(tmp_dir_name) / 'test.txt' assert Path(result_path).resolve() == expected_path.resolve() assert Path(result_path).exists() finally: os.chdir(original_cwd) def test_file_repr_and_str(self): f = File(file_name='foo.txt', content_type='text/plain', file=b'bar') s = str(f) assert 'foo.txt' in s assert 'text/plain' in s def test_file_ensure_buffer_error(self): f = File(file_name='foo.txt', content_type='text/plain') f.file = None f._file_path = None f._buffer = None try: f._ensure_buffer() assert False, 'Expected ValueError to be raised' except ValueError: pass class TestDetectMimeType(unittest.TestCase): def test_extension_based_detection(self): with tempfile.TemporaryDirectory() as tmp_dir_name: file_path = Path(tmp_dir_name) / 'foo.txt' file_path.write_text('hello') assert detect_mime_type(str(file_path)) == 'text/plain' def test_png_magic_number(self): with tempfile.TemporaryDirectory() as tmp_dir_name: file_path = Path(tmp_dir_name) / 'img.png' file_path.write_bytes(b'\x89PNG\r\n\x1a\n' + b'rest') assert detect_mime_type(str(file_path)) == 'image/png' def test_pdf_magic_number(self): with tempfile.TemporaryDirectory() as tmp_dir_name: file_path = Path(tmp_dir_name) / 'doc.pdf' file_path.write_bytes(b'%PDF-1.4 some pdf data') assert detect_mime_type(str(file_path)) == 'application/pdf' def test_zip_magic_number(self): with tempfile.TemporaryDirectory() as tmp_dir_name: file_path = Path(tmp_dir_name) / 'archive.zip' file_path.write_bytes(b'PK\x03\x04rest') if platform.system() == 'Windows': assert detect_mime_type(str(file_path)) == 'application/x-zip-compressed' else: assert detect_mime_type(str(file_path)) == 'application/zip' def test_jpeg_magic_number(self): with tempfile.TemporaryDirectory() as tmp_dir_name: file_path = Path(tmp_dir_name) / 'img.jpg' file_path.write_bytes(b'\xff\xd8\xffrest') assert detect_mime_type(str(file_path)) == 'image/jpeg' def test_gif_magic_number(self): with tempfile.TemporaryDirectory() as tmp_dir_name: file_path = Path(tmp_dir_name) / 'img.gif' file_path.write_bytes(b'GIF89a rest') assert detect_mime_type(str(file_path)) == 'image/gif' def test_bmp_magic_number(self): with tempfile.TemporaryDirectory() as tmp_dir_name: file_path = Path(tmp_dir_name) / 'img.bmp' file_path.write_bytes(b'BMrest') assert detect_mime_type(str(file_path)) == 'image/bmp' def test_ico_magic_number(self): with tempfile.TemporaryDirectory() as tmp_dir_name: file_path = Path(tmp_dir_name) / 'icon' file_path.write_bytes(b'\x00\x00\x01\x00rest') assert detect_mime_type(str(file_path)) == 'image/x-icon' def test_tiff_magic_number(self): with tempfile.TemporaryDirectory() as tmp_dir_name: file_path = Path(tmp_dir_name) / 'img.tiff' file_path.write_bytes(b'II*\x00rest') assert detect_mime_type(str(file_path)) == 'image/tiff' def test_mp4_magic_number(self): with tempfile.TemporaryDirectory() as tmp_dir_name: file_path = Path(tmp_dir_name) / 'video.mp4' file_path.write_bytes(b'\x00\x00\x00\x18ftyprest') assert detect_mime_type(str(file_path)) == 'video/mp4' def test_fallback_octet_stream(self): with tempfile.TemporaryDirectory() as tmp_dir_name: file_path = Path(tmp_dir_name) / 'unknown.bin' file_path.write_bytes(b'randomdata123456') assert detect_mime_type(str(file_path)) == 'application/octet-stream' ================================================ FILE: tests/test_find_one_or_insert_race.py ================================================ import asyncio from unittest import IsolatedAsyncioTestCase from unittest.mock import patch import pytest from panther import Panther from panther.configs import config from panther.db import Model from panther.db.connections import db class RaceBook(Model): name: str @pytest.mark.mongodb class TestFindOneOrInsertRaceMongoDB(IsolatedAsyncioTestCase): DB_NAME = 'test_find_one_or_insert_race' @classmethod def setUpClass(cls) -> None: global DATABASE DATABASE = { 'engine': { 'class': 'panther.db.connections.MongoDBConnection', 'host': f'mongodb://127.0.0.1:27017/{cls.DB_NAME}', }, } def setUp(self): Panther(__name__, configs=__name__, urls={}) def tearDown(self) -> None: db.session.drop_collection('RaceBook') @classmethod def tearDownClass(cls) -> None: config.refresh() async def test_find_one_or_insert_with_unique_index_returns_single_record_under_concurrency(self): await db.session['RaceBook'].create_index('name', unique=True) concurrent_calls = 8 target_name = 'one-book-only' initial_find_calls = 0 release_initial_finds = asyncio.Event() find_lock = asyncio.Lock() original_find_one = RaceBook.find_one async def controlled_find_one(cls, _filter=None, /, **kwargs): nonlocal initial_find_calls should_force_initial_none = False async with find_lock: if initial_find_calls < concurrent_calls: initial_find_calls += 1 should_force_initial_none = True if initial_find_calls == concurrent_calls: release_initial_finds.set() if should_force_initial_none: await release_initial_finds.wait() return None return await original_find_one(_filter, **kwargs) with patch.object(RaceBook, 'find_one', new=classmethod(controlled_find_one)): results = await asyncio.gather( *[RaceBook.find_one_or_insert(name=target_name) for _ in range(concurrent_calls)], ) inserted_count = sum(1 for _, is_inserted in results if is_inserted) returned_ids = {obj.id for obj, _ in results} assert inserted_count == 1 assert len(returned_ids) == 1 assert await RaceBook.count(name=target_name) == 1 ================================================ FILE: tests/test_generics.py ================================================ from pathlib import Path from unittest import IsolatedAsyncioTestCase import pytest from panther import Panther from panther.configs import config from panther.db import Model from panther.db.connections import db from panther.generics import CreateAPI, DeleteAPI, ListAPI, RetrieveAPI, UpdateAPI from panther.pagination import Pagination from panther.request import Request from panther.serializer import ModelSerializer from panther.test import APIClient class User(Model): name: str class Person(User): age: int class RetrieveAPITest(RetrieveAPI): async def get_instance(self, request: Request, **kwargs) -> Model: return await User.find_one(id=kwargs['id']) class ListAPITest(ListAPI): async def get_query(self, request: Request, **kwargs): return await User.find() class FullListAPITest(ListAPI): sort_fields = ['name', 'age'] search_fields = ['name'] filter_fields = ['id', 'name', 'age'] pagination = Pagination async def get_query(self, request: Request, **kwargs): return await Person.find() class UserSerializer(ModelSerializer): class Config: model = User fields = '*' class UpdateAPITest(UpdateAPI): input_model = UserSerializer async def get_instance(self, request: Request, **kwargs) -> Model: return await User.find_one(id=kwargs['id']) class CreateAPITest(CreateAPI): input_model = UserSerializer class DeleteAPITest(DeleteAPI): async def get_instance(self, request: Request, **kwargs) -> Model: return await User.find_one(id=kwargs['id']) urls = { 'retrieve/': RetrieveAPITest, 'list': ListAPITest, 'full-list': FullListAPITest, 'update/': UpdateAPITest, 'create': CreateAPITest, 'delete/': DeleteAPITest, } class _BaseGenericTestCases: async def test_retrieve(self): user = await User.insert_one(name='Ali') res = await self.client.get(f'retrieve/{user.id}') assert res.status_code == 200 assert res.data == {'id': str(user.id), 'name': user.name} async def test_list(self): users = await User.insert_many([{'name': 'Ali'}, {'name': 'Hamed'}]) res = await self.client.get('list') assert res.status_code == 200 assert res.data == [{'id': str(u.id), 'name': u.name} for u in users] async def test_list_features(self): users = await Person.insert_many( [ {'name': 'Ali', 'age': 0}, {'name': 'Ali', 'age': 1}, {'name': 'Saba', 'age': 0}, {'name': 'Saba', 'age': 1}, ], ) res = await self.client.get('full-list') assert res.status_code == 200 assert set(res.data.keys()) == {'results', 'count', 'previous', 'next'} # Normal response = [{'name': r['name'], 'age': r['age']} for r in res.data['results']] assert response == [ {'name': 'Ali', 'age': 0}, {'name': 'Ali', 'age': 1}, {'name': 'Saba', 'age': 0}, {'name': 'Saba', 'age': 1}, ] # Sort 1 res = await self.client.get('full-list', query_params={'sort': '-name'}) response = [{'name': r['name'], 'age': r['age']} for r in res.data['results']] assert response == [ {'name': 'Saba', 'age': 0}, {'name': 'Saba', 'age': 1}, {'name': 'Ali', 'age': 0}, {'name': 'Ali', 'age': 1}, ] # Sort 2 res = await self.client.get('full-list', query_params={'sort': '-name,-age'}) response = [{'name': r['name'], 'age': r['age']} for r in res.data['results']] assert response == [ {'name': 'Saba', 'age': 1}, {'name': 'Saba', 'age': 0}, {'name': 'Ali', 'age': 1}, {'name': 'Ali', 'age': 0}, ] # Sort 3 res = await self.client.get('full-list', query_params={'sort': 'name,-age'}) response = [{'name': r['name'], 'age': r['age']} for r in res.data['results']] assert response == [ {'name': 'Ali', 'age': 1}, {'name': 'Ali', 'age': 0}, {'name': 'Saba', 'age': 1}, {'name': 'Saba', 'age': 0}, ] # Filter 1 res = await self.client.get('full-list', query_params={'sort': 'name,-age', 'name': 'Ali'}) response = [{'name': r['name'], 'age': r['age']} for r in res.data['results']] assert response == [ {'name': 'Ali', 'age': 1}, {'name': 'Ali', 'age': 0}, ] # Filter 2 res = await self.client.get('full-list', query_params={'sort': 'name,-age', 'id': users[1].id}) response = [{'id': r['id'], 'name': r['name'], 'age': r['age']} for r in res.data['results']] assert response == [{'id': str(users[1].id), 'name': 'Ali', 'age': 1}] # Filter 3 res = await self.client.get('full-list', query_params={'sort': 'name,-age', 'name': 'Alex'}) response = [{'name': r['name'], 'age': r['age']} for r in res.data['results']] assert response == [] # Search res = await self.client.get('full-list', query_params={'sort': 'name,-age', 'search': 'Ali'}) response = [{'name': r['name'], 'age': r['age']} for r in res.data['results']] assert response == [ {'name': 'Ali', 'age': 1}, {'name': 'Ali', 'age': 0}, ] # Pagination 1 res = await self.client.get('full-list', query_params={'sort': 'name,-age'}) assert res.data['previous'] is None assert res.data['next'] is None assert res.data['count'] == 4 # Pagination 2 res = await self.client.get('full-list', query_params={'limit': 2}) assert res.data['previous'] is None assert res.data['next'] == '?limit=2&skip=2' assert res.data['count'] == 4 response = [{'name': r['name'], 'age': r['age']} for r in res.data['results']] assert response == [ {'name': 'Ali', 'age': 0}, {'name': 'Ali', 'age': 1}, ] res = await self.client.get('full-list', query_params={'limit': 2, 'skip': 2}) assert res.data['previous'] == '?limit=2&skip=0' assert res.data['next'] is None assert res.data['count'] == 4 response = [{'name': r['name'], 'age': r['age']} for r in res.data['results']] assert response == [ {'name': 'Saba', 'age': 0}, {'name': 'Saba', 'age': 1}, ] async def test_update(self): users = await User.insert_many([{'name': 'Ali'}, {'name': 'Hamed'}]) res = await self.client.put(f'update/{users[1].id}', payload={'name': 'NewName'}) assert res.status_code == 200 assert res.data['name'] == 'NewName' new_users = await User.find() users[1].name = 'NewName' assert {(u.id, u.name) for u in new_users} == {(u.id, u.name) for u in users} async def test_create(self): res = await self.client.post('create', payload={'name': 'Sara'}) assert res.status_code == 201 assert res.data['name'] == 'Sara' new_users = list(await User.find()) assert len(new_users) assert new_users[0].name == 'Sara' async def test_delete(self): users = await User.insert_many([{'name': 'Ali'}, {'name': 'Hamed'}]) res = await self.client.delete(f'delete/{users[1].id}') assert res.status_code == 204 new_users = list(await User.find()) assert len(new_users) == 1 assert new_users[0].model_dump() == users[0].model_dump() class TestPantherDBGeneric(_BaseGenericTestCases, IsolatedAsyncioTestCase): DB_PATH = 'test.pdb' @classmethod def setUpClass(cls) -> None: global DATABASE DATABASE = { 'engine': {'class': 'panther.db.connections.PantherDBConnection', 'path': cls.DB_PATH}, } app = Panther(__name__, configs=__name__, urls=urls) cls.client = APIClient(app=app) def tearDown(self) -> None: db.session.collection('User').drop() db.session.collection('Person').drop() @classmethod def tearDownClass(cls): config.refresh() Path(cls.DB_PATH).unlink(missing_ok=True) @pytest.mark.mongodb class TestMongoDBGeneric(_BaseGenericTestCases, IsolatedAsyncioTestCase): DB_NAME = 'test.pdb' @classmethod def setUpClass(cls) -> None: global DATABASE DATABASE = { 'engine': { 'class': 'panther.db.connections.MongoDBConnection', 'host': f'mongodb://127.0.0.1:27017/{cls.DB_NAME}', }, } def setUp(self): app = Panther(__name__, configs=__name__, urls=urls) self.client = APIClient(app=app) def tearDown(self) -> None: db.session.drop_collection('User') db.session.drop_collection('Person') @classmethod def tearDownClass(cls): config.refresh() ================================================ FILE: tests/test_generics_search_regex_escape.py ================================================ import multiprocessing as mp import re import time from typing import ClassVar from unittest import TestCase from panther.configs import config from panther.db.connections import MongoDBConnection from panther.generics import ListAPI from panther.request import Request def _run_regex_search(pattern: str, text: str, repeats: int, done: mp.Queue): start = time.perf_counter() for _ in range(repeats): re.search(pattern, text) done.put(time.perf_counter() - start) class ListAPISearchTest(ListAPI): search_fields: ClassVar[list[str]] = ['name'] async def get_query(self, request: Request, **kwargs): raise NotImplementedError class TestListAPISearchRegexEscape(TestCase): REDOS_PATTERN = r'([a-zA-Z]+)*$' PAYLOAD_LENGTH_RANGE = range(24, 42) ESCAPED_TIMEOUT_SECONDS = 3.0 RAW_STALL_TIMEOUT_SECONDS = 0.5 def setUp(self): self.previous_database = config.DATABASE config.DATABASE = object.__new__(MongoDBConnection) def tearDown(self): config.DATABASE = self.previous_database def test_mongodb_search_pattern_escapes_user_input(self): user_input = '(a+)+$' protected_pattern = self._extract_mongodb_search_pattern(user_input) assert protected_pattern == re.escape(user_input) def test_raw_regex_stalls_on_near_miss_payload_but_escaped_pattern_completes(self): raw_pattern = self.REDOS_PATTERN escaped_pattern = self._extract_mongodb_search_pattern(raw_pattern) trigger_length, attempts = self._find_redos_trigger_length( raw_pattern=raw_pattern, escaped_pattern=escaped_pattern, ) assert trigger_length is not None, f'ReDoS probe did not find a trigger: {attempts}' raw_matching_completed, _ = self._run_with_deadline( pattern=raw_pattern, text='A' * trigger_length, repeats=5_000, timeout_seconds=self.ESCAPED_TIMEOUT_SECONDS, ) assert raw_matching_completed is True, f'ReDoS probe: {attempts}' def _extract_mongodb_search_pattern(self, user_input: str) -> str: query = ListAPISearchTest().process_search(query_params={'search': user_input}) return query['$or'][0]['name']['$regex'] def _find_redos_trigger_length(self, *, raw_pattern: str, escaped_pattern: str) -> tuple[int | None, list[dict]]: attempts: list[dict] = [] for length in self.PAYLOAD_LENGTH_RANGE: near_miss_payload = 'A' * length + '!' raw_completed, raw_elapsed = self._run_with_deadline( pattern=raw_pattern, text=near_miss_payload, repeats=1, timeout_seconds=self.RAW_STALL_TIMEOUT_SECONDS, ) escaped_completed, escaped_elapsed = self._run_with_deadline( pattern=escaped_pattern, text=near_miss_payload, repeats=5_000, timeout_seconds=self.ESCAPED_TIMEOUT_SECONDS, ) attempts.append( { 'payload_length': length, 'raw_completed': raw_completed, 'raw_elapsed': raw_elapsed, 'escaped_completed': escaped_completed, 'escaped_elapsed': escaped_elapsed, }, ) if raw_completed is False and escaped_completed is True: return length, attempts return None, attempts @staticmethod def _run_with_deadline( *, pattern: str, text: str, repeats: int, timeout_seconds: float, ) -> tuple[bool, float | None]: done: mp.Queue = mp.Queue() process = mp.Process(target=_run_regex_search, args=(pattern, text, repeats, done)) process.start() process.join(timeout=timeout_seconds) if process.is_alive(): process.terminate() process.join() return False, None if process.exitcode != 0: raise AssertionError(f'regex worker failed with exit code {process.exitcode}') elapsed_seconds = done.get_nowait() if not done.empty() else None return True, elapsed_seconds ================================================ FILE: tests/test_middlewares.py ================================================ from unittest import IsolatedAsyncioTestCase, TestCase from panther import Panther from panther.app import API from panther.base_websocket import Websocket from panther.configs import config from panther.middlewares.base import HTTPMiddleware, WebsocketMiddleware from panther.request import Request from panther.test import APIClient, WebsocketClient from panther.websocket import GenericWebsocket class MyMiddleware(HTTPMiddleware): async def __call__(self, request: Request): request.middlewares = [*getattr(request, 'middlewares', []), 'MyMiddleware'] response = await self.dispatch(request=request) response.data = [*response.data, 'MyMiddleware'] return response class BeforeMiddleware1(HTTPMiddleware): async def __call__(self, request: Request): request.middlewares = [*getattr(request, 'middlewares', []), 'BeforeMiddleware1'] return await self.dispatch(request=request) class BeforeMiddleware2(HTTPMiddleware): async def __call__(self, request: Request): request.middlewares = [*getattr(request, 'middlewares', []), 'BeforeMiddleware2'] return await self.dispatch(request=request) class AfterMiddleware1(HTTPMiddleware): async def __call__(self, request: Request): response = await self.dispatch(request=request) response.data = [*response.data, 'AfterMiddleware1'] return response class AfterMiddleware2(HTTPMiddleware): async def __call__(self, request: Request): response = await self.dispatch(request=request) response.data = [*response.data, 'AfterMiddleware2'] return response class AfterMiddleware3(HTTPMiddleware): async def __call__(self, request: Request): response = await self.dispatch(request=request) response.data = [*response.data, 'AfterMiddleware3'] return response class MyWSMiddleware1(WebsocketMiddleware): async def __call__(self, connection: Websocket): connection.middlewares = [*getattr(connection, 'middlewares', []), 'MyWSMiddleware1'] return await self.dispatch(connection=connection) class MyWSMiddleware2(WebsocketMiddleware): async def __call__(self, connection: Websocket): connection.middlewares = [*getattr(connection, 'middlewares', []), 'MyWSMiddleware2'] return await self.dispatch(connection=connection) class PrivateMiddleware1(HTTPMiddleware): async def __call__(self, request: Request): request.middlewares = [*getattr(request, 'middlewares', []), 'PrivateMiddleware1'] response = await self.dispatch(request=request) response.data = [*response.data, 'PrivateMiddleware1'] return response class PrivateMiddleware2(HTTPMiddleware): async def __call__(self, request: Request): request.middlewares = [*getattr(request, 'middlewares', []), 'PrivateMiddleware2'] response = await self.dispatch(request=request) response.data = [*response.data, 'PrivateMiddleware2'] return response @API() async def handle_middlewares(request: Request): states = ['FunctionCall'] if hasattr(request, 'middlewares'): states = request.middlewares + states return states @API(middlewares=[]) async def handle_private_empty_middlewares(request: Request): states = ['FunctionCall'] if hasattr(request, 'middlewares'): states = request.middlewares + states return states @API(middlewares=[PrivateMiddleware1, PrivateMiddleware2]) async def handle_private_middlewares(request: Request): states = ['FunctionCall'] if hasattr(request, 'middlewares'): states = request.middlewares + states return states class WebsocketHandleMiddlewares(GenericWebsocket): async def connect(self): await self.accept() states = ['WebsocketConnect'] if hasattr(self, 'middlewares'): states = self.middlewares + states await self.send(states) await self.close() urls = { '': handle_middlewares, 'private-empty': handle_private_empty_middlewares, 'private': handle_private_middlewares, 'websocket': WebsocketHandleMiddlewares, } class TestMiddleware(IsolatedAsyncioTestCase): def tearDown(self): config.refresh() async def test_before_base_middleware(self): global MIDDLEWARES MIDDLEWARES = [BeforeMiddleware1] app = Panther(__name__, configs=__name__, urls=urls) client = APIClient(app=app) response = await client.get('') assert response.status_code == 200 assert response.data == ['BeforeMiddleware1', 'FunctionCall'] MIDDLEWARES = [] async def test_after_base_middleware(self): global MIDDLEWARES MIDDLEWARES = [AfterMiddleware1] app = Panther(__name__, configs=__name__, urls=urls) client = APIClient(app=app) response = await client.get('') assert response.status_code == 200 assert response.data == ['FunctionCall', 'AfterMiddleware1'] MIDDLEWARES = [] async def test_a_normal_middleware(self): global MIDDLEWARES MIDDLEWARES = [MyMiddleware] app = Panther(__name__, configs=__name__, urls=urls) client = APIClient(app=app) response = await client.get('') assert response.status_code == 200 assert response.data == ['MyMiddleware', 'FunctionCall', 'MyMiddleware'] async def test_websocket_middleware_in_http(self): global MIDDLEWARES MIDDLEWARES = [MyWSMiddleware1] with self.assertLogs(level='ERROR') as captured: try: Panther(name=__name__, configs=__name__, urls={}) except SystemExit: assert True else: assert False finally: MIDDLEWARES = [] assert len(captured.records) == 1 assert ( captured.records[0].getMessage() == "Invalid 'MIDDLEWARES': is not a sub class of `HTTPMiddleware`" ) async def test_private_empty_middleware(self): app = Panther(__name__, configs=__name__, urls=urls) client = APIClient(app=app) response = await client.get('private-empty') assert response.status_code == 200 assert response.data == ['FunctionCall'] async def test_private_middleware(self): app = Panther(__name__, configs=__name__, urls=urls) client = APIClient(app=app) response = await client.get('private') assert response.status_code == 200 assert response.data == [ 'PrivateMiddleware1', 'PrivateMiddleware2', 'FunctionCall', 'PrivateMiddleware2', 'PrivateMiddleware1', ] async def test_middlewares_order(self): global MIDDLEWARES MIDDLEWARES = [ BeforeMiddleware2, AfterMiddleware3, MyMiddleware, BeforeMiddleware1, AfterMiddleware1, AfterMiddleware2, MyMiddleware, ] app = Panther(__name__, configs=__name__, urls=urls) client = APIClient(app=app) response = await client.get('private') assert response.status_code == 200 assert response.data == [ 'BeforeMiddleware2', 'MyMiddleware', 'BeforeMiddleware1', 'MyMiddleware', 'PrivateMiddleware1', 'PrivateMiddleware2', 'FunctionCall', 'PrivateMiddleware2', 'PrivateMiddleware1', 'MyMiddleware', 'AfterMiddleware2', 'AfterMiddleware1', 'MyMiddleware', 'AfterMiddleware3', ] MIDDLEWARES = [] class TestWebsocketMiddleware(TestCase): @classmethod def tearDownClass(cls): config.refresh() def setUp(self): config.HAS_WS = True def tearDown(self): config.refresh() def test_websocket_middleware(self): global WS_MIDDLEWARES WS_MIDDLEWARES = [MyWSMiddleware1, MyWSMiddleware2] app = Panther(__name__, configs=__name__, urls=urls) ws = WebsocketClient(app=app) responses = ws.connect('websocket') assert responses[0]['type'] == 'websocket.accept' assert responses[0]['subprotocol'] is None assert responses[0]['headers'] == {} assert responses[1]['type'] == 'websocket.send' assert responses[1]['text'] == '["MyWSMiddleware1","MyWSMiddleware2","WebsocketConnect"]' assert responses[2]['type'] == 'websocket.close' assert responses[2]['code'] == 1000 assert responses[2]['reason'] == '' WS_MIDDLEWARES = [] def test_http_middleware_in_websocket(self): global WS_MIDDLEWARES WS_MIDDLEWARES = [MyWSMiddleware1, MyMiddleware, MyWSMiddleware2] with self.assertLogs(level='ERROR') as captured: try: Panther(name=__name__, configs=__name__, urls={}) except SystemExit: assert True else: assert False finally: WS_MIDDLEWARES = [] assert len(captured.records) == 1 assert ( captured.records[0].getMessage() == "Invalid 'WS_MIDDLEWARES': is not a sub class of `WebsocketMiddleware`" ) def test_base_middleware(self): global WS_MIDDLEWARES WS_MIDDLEWARES = [MyWSMiddleware1, MyWSMiddleware2] app = Panther(__name__, configs=__name__, urls=urls) ws = WebsocketClient(app=app) responses = ws.connect('websocket') assert responses[0]['type'] == 'websocket.accept' assert responses[0]['subprotocol'] is None assert responses[0]['headers'] == {} assert responses[1]['type'] == 'websocket.send' assert responses[1]['text'] == '["MyWSMiddleware1","MyWSMiddleware2","WebsocketConnect"]' assert responses[2]['type'] == 'websocket.close' assert responses[2]['code'] == 1000 assert responses[2]['reason'] == '' WS_MIDDLEWARES = [] ================================================ FILE: tests/test_multipart.py ================================================ from unittest import IsolatedAsyncioTestCase from panther import Panther from panther.app import API from panther.configs import config from panther.request import Request from panther.test import APIClient @API() async def flat_multipart_api(request: Request): return request.data @API() async def single_file_multipart_api(request: Request): return { 'file_name': request.data['file'].file_name, 'content_type': request.data['file'].content_type, 'file': request.data['file'].file.decode(), } @API() async def several_file_multipart_api(request: Request): return { 'file1': { 'file_name': request.data['file1'].file_name, 'content_type': request.data['file1'].content_type, 'file': request.data['file1'].file.decode(), }, 'file2': { 'file_name': request.data['file2'].file_name, 'content_type': request.data['file2'].content_type, 'file': request.data['file2'].file.decode(), }, } @API() async def complex_multipart_api(request: Request): return { 'name': request.data['name'], 'age': request.data['age'], 'file1': { 'file_name': request.data['file1'].file_name, 'content_type': request.data['file1'].content_type, 'file': request.data['file1'].file.decode(), }, 'file2': { 'file_name': request.data['file2'].file_name, 'content_type': request.data['file2'].content_type, 'file': request.data['file2'].file.decode(), }, } @API() async def multipart_api(request: Request): return request.data urls = { 'flat_multipart': flat_multipart_api, 'single_file_multipart': single_file_multipart_api, 'several_file_multipart': several_file_multipart_api, 'complex_multipart': complex_multipart_api, 'multiline_multipart': multipart_api, } class TestMultipart(IsolatedAsyncioTestCase): CONTENT_TYPE_1 = 'multipart/form-data; boundary=--------------------------201301649688174364392792' CONTENT_TYPE_2 = 'multipart/form-data; boundary=----geckoformboundaryc30219e1237602175b34337f41ace019' FLAT_PAYLOAD = ( b'----------------------------201301649688174364392792\r\n' b'Content-Disposition: form-data; name="name"\r\n\r\n' b'Ali Rn\r\n' b'----------------------------201301649688174364392792\r\n' b'Content-Disposition: form-data; name="age"\r\n\r\n' b'25\r\n' b'----------------------------201301649688174364392792--\r\n' ) SINGLE_FILE_PAYLOAD = ( b'----------------------------201301649688174364392792\r\n' b'Content-Disposition: form-data; name="file"; filename="hello_world.txt"\r\n' b'Content-Type: text/plain\r\n\r\n' b'Hello World\n\r\n' b'----------------------------201301649688174364392792--\r\n' ) SEVERAL_FILE_PAYLOAD = ( b'----------------------------201301649688174364392792\r\n' b'Content-Disposition: form-data; name="file1"; filename="hello_world1.txt"\r\n' b'Content-Type: text/plain\r\n\r\n' b'Hello World1\n\r\n' b'----------------------------201301649688174364392792\r\n' b'Content-Disposition: form-data; name="file2"; filename="hello_world2.txt"\r\n' b'Content-Type: text/plain\r\n\r\n' b'Hello World2\n\r\n' b'----------------------------201301649688174364392792--\r\n' ) COMPLEX_PAYLOAD = ( b'----------------------------201301649688174364392792\r\n' b'Content-Disposition: form-data; name="name"\r\n\r\n' b'Ali Rn\r\n' b'----------------------------201301649688174364392792\r\n' b'Content-Disposition: form-data; name="file1"; filename="hello_world1.txt"\r\n' b'Content-Type: text/plain\r\n\r\n' b'Hello World1\n\r\n' b'----------------------------201301649688174364392792\r\n' b'Content-Disposition: form-data; name="file2"; filename="hello_world2.txt"\r\n' b'Content-Type: text/plain\r\n\r\n' b'Hello World2\n\r\n' b'----------------------------201301649688174364392792\r\n' b'Content-Disposition: form-data; name="age"\r\n\r\n' b'25\r\n' b'----------------------------201301649688174364392792--\r\n' ) MULTI_LINE_PAYLOAD = ( b'------geckoformboundaryc30219e1237602175b34337f41ace019\r\n' b'Content-Disposition: form-data; name="team"\r\n\r\n' b'SRE\r\n' b'------geckoformboundaryc30219e1237602175b34337f41ace019\r\n' b'Content-Disposition: form-data; name="phone"\r\n\r\n' b'09033333333\r\n' b'------geckoformboundaryc30219e1237602175b34337f41ace019\r\n' b'Content-Disposition: form-data; name="message"\r\n\r\n' b'My\r\nName\r\nIs\r\nAli\r\n\r\n' b'------geckoformboundaryc30219e1237602175b34337f41ace019--\r\n' ) @classmethod def setUpClass(cls) -> None: app = Panther(__name__, configs=__name__, urls=urls) cls.client = APIClient(app=app) @classmethod def tearDownClass(cls): config.refresh() async def test_flat_multipart(self): res = await self.client.post( 'flat_multipart', content_type=self.CONTENT_TYPE_1, payload=self.FLAT_PAYLOAD, ) assert res.status_code == 200 assert res.data == {'name': 'Ali Rn', 'age': '25'} async def test_single_file_multipart(self): res = await self.client.post( 'single_file_multipart', content_type=self.CONTENT_TYPE_1, payload=self.SINGLE_FILE_PAYLOAD, ) assert res.status_code == 200 assert res.data == { 'file_name': 'hello_world.txt', 'content_type': 'text/plain', 'file': 'Hello World\n', } async def test_several_file_multipart(self): res = await self.client.post( 'several_file_multipart', content_type=self.CONTENT_TYPE_1, payload=self.SEVERAL_FILE_PAYLOAD, ) assert res.status_code == 200 assert res.data == { 'file1': { 'file_name': 'hello_world1.txt', 'content_type': 'text/plain', 'file': 'Hello World1\n', }, 'file2': { 'file_name': 'hello_world2.txt', 'content_type': 'text/plain', 'file': 'Hello World2\n', }, } async def test_complex_multipart(self): res = await self.client.post( 'complex_multipart', content_type=self.CONTENT_TYPE_1, payload=self.COMPLEX_PAYLOAD, ) assert res.status_code == 200 assert res.data == { 'name': 'Ali Rn', 'age': '25', 'file1': { 'file_name': 'hello_world1.txt', 'content_type': 'text/plain', 'file': 'Hello World1\n', }, 'file2': { 'file_name': 'hello_world2.txt', 'content_type': 'text/plain', 'file': 'Hello World2\n', }, } async def test_multiline_multipart(self): res = await self.client.post( 'multiline_multipart', content_type=self.CONTENT_TYPE_2, payload=self.MULTI_LINE_PAYLOAD, ) assert res.data == {'team': 'SRE', 'phone': '09033333333', 'message': 'My\r\nName\r\nIs\r\nAli\r\n'} ================================================ FILE: tests/test_openapi.py ================================================ from unittest import IsolatedAsyncioTestCase from pydantic import BaseModel from panther import Panther, status from panther.app import API, GenericAPI from panther.configs import config from panther.db import Model from panther.openapi.urls import url_routing from panther.openapi.utils import EndpointParser from panther.response import Response from panther.test import APIClient @API() def my_api1(): return {'detail': 'ok'} @API() def my_api2(): data = {'detail': 'ok'} return data @API() def my_api3(): my_data = {'detail': 'ok'} return Response(data=my_data) @API() async def my_api4(): return Response(data={'detail': 'ok'}) @API() async def my_api5(): return Response(status_code=201) @API() async def my_api6(): return Response(status_code=status.HTTP_207_MULTI_STATUS) @API() def my_api7(): my_status = status.HTTP_207_MULTI_STATUS return Response(status_code=my_status) @API() def my_api8(): my_status = 207 return Response(status_code=my_status) @API() def my_api9(): return Response({'detail': 'ok'}) @API() def my_api10(): my_data = {'detail': 'ok'} return Response(my_data) @API() def my_api11(): my_data = {'detail': 'ok'} return Response(my_data, status_code=207) @API() def my_api12(): my_data = {'detail': 'ok'} return Response(my_data, status_code=status.HTTP_207_MULTI_STATUS) @API() def my_api13(): my_data = {'detail': 'ok'} my_status = 207 return Response(my_data, status_code=my_status) @API() def my_api14(): my_data = {'detail': 'ok'} my_status = status.HTTP_207_MULTI_STATUS return Response(my_data, status_code=my_status) @API() def my_api15(): my_data = {'detail': 'ok'} my_status = status.HTTP_207_MULTI_STATUS return Response(my_data, my_status) @API() def my_api16(): my_data = {'detail': 'ok'} return Response(my_data, status.HTTP_207_MULTI_STATUS) @API() def my_api17(): return Response({}, 207) class API18(GenericAPI): def get(self, *args, **kwargs): return {'detail': 'ok'} def post(self, *args, **kwargs): my_data = {'detail': 'ok'} return my_data async def put(self, *args, **kwargs): my_data = {'detail': 'ok'} return Response(data=my_data) def patch(self, *args, **kwargs): my_data = {'detail': 'ok'} return Response(data=my_data, status_code=201) def delete(self, *args, **kwargs): return Response({}, status.HTTP_204_NO_CONTENT) @API() def my_api19(): my_response = ['1', '2'] return my_response @API() def my_api20(): my_response = 'Hello World!' return my_response @API() def my_api21(): my_response = True return my_response @API() def my_api22(): my_response = (6, 9) return my_response @API() def my_api23(): my_response = Response(data={'detail': 'Hello'}, status_code=207) return my_response class CustomBaseModel(BaseModel): name: str children: list[str] | None = None @API() def my_api24(): my_response = CustomBaseModel(name='Ali', children=['A', 'B', 'C']) return my_response class CustomModel(Model): title: str @API() def my_api25(): my_response = CustomModel(title='Book') return my_response @API() def my_api26(): return CustomBaseModel(name='Ali') @API() def my_api27(): return CustomModel(title='Book') class TestOpenAPI(IsolatedAsyncioTestCase): @classmethod def setUpClass(cls) -> None: app = Panther(__name__, configs=__name__, urls={'docs': url_routing}) cls.client = APIClient(app=app) @classmethod def tearDownClass(cls) -> None: config.refresh() # TODO: Uncomment and improve it at end of this pr async def test_swagger(self): response = await self.client.get('/docs/swagger/') expected_response = """ Swagger UI
      """ assert expected_response == response.data async def test_scalar(self): response = await self.client.get('/docs/scalar/') expected_response = """ Scalar API Reference
      """ assert expected_response == response.data async def test_redoc(self): response = await self.client.get('/docs/redoc/') expected_response = """ ReDoc API Reference
      """ assert expected_response == response.data async def test_rapidoc(self): response = await self.client.get('/docs/rapidoc/') expected_response = """ RapiDoc Inline JSON """ assert expected_response == response.data async def test_spotlight(self): response = await self.client.get('/docs/spotlight/') expected_response = """ Stoplight Elements with CSS """ assert expected_response == response.data async def test_my_api1(self): parsed = EndpointParser(my_api1, 'get') assert parsed.status_code == 200 assert parsed.response_data == {'detail': 'ok'} async def test_my_api2(self): parsed = EndpointParser(my_api2, 'get') assert parsed.status_code == 200 assert parsed.response_data == {'detail': 'ok'} async def test_my_api3(self): parsed = EndpointParser(my_api3, 'get') assert parsed.status_code == 200 assert parsed.response_data == {'detail': 'ok'} async def test_my_api4(self): parsed = EndpointParser(my_api4, 'get') assert parsed.status_code == 200 assert parsed.response_data == {'detail': 'ok'} async def test_my_api5(self): parsed = EndpointParser(my_api5, 'get') assert parsed.status_code == 201 assert parsed.response_data == {} async def test_my_api6(self): parsed = EndpointParser(my_api6, 'get') assert parsed.status_code == 207 assert parsed.response_data == {} async def test_my_api7(self): parsed = EndpointParser(my_api7, 'get') assert parsed.status_code == 207 assert parsed.response_data == {} async def test_my_api8(self): parsed = EndpointParser(my_api8, 'get') assert parsed.status_code == 207 assert parsed.response_data == {} async def test_my_api9(self): parsed = EndpointParser(my_api9, 'get') assert parsed.status_code == 200 assert parsed.response_data == {'detail': 'ok'} async def test_my_api10(self): parsed = EndpointParser(my_api10, 'get') assert parsed.status_code == 200 assert parsed.response_data == {'detail': 'ok'} async def test_my_api11(self): parsed = EndpointParser(my_api11, 'get') assert parsed.status_code == 207 assert parsed.response_data == {'detail': 'ok'} async def test_my_api12(self): parsed = EndpointParser(my_api12, 'get') assert parsed.status_code == 207 assert parsed.response_data == {'detail': 'ok'} async def test_my_api13(self): parsed = EndpointParser(my_api13, 'get') assert parsed.status_code == 207 assert parsed.response_data == {'detail': 'ok'} async def test_my_api14(self): parsed = EndpointParser(my_api14, 'get') assert parsed.status_code == 207 assert parsed.response_data == {'detail': 'ok'} async def test_my_api15(self): parsed = EndpointParser(my_api15, 'get') assert parsed.status_code == 207 assert parsed.response_data == {'detail': 'ok'} async def test_my_api16(self): parsed = EndpointParser(my_api16, 'get') assert parsed.status_code == 207 assert parsed.response_data == {'detail': 'ok'} async def test_my_api17(self): parsed = EndpointParser(my_api17, 'get') assert parsed.status_code == 207 assert parsed.response_data == {} async def test_my_api18_get(self): parsed = EndpointParser(API18, 'get') assert parsed.status_code == 200 assert parsed.response_data == {'detail': 'ok'} async def test_my_api18_post(self): parsed = EndpointParser(API18, 'post') assert parsed.status_code == 200 assert parsed.response_data == {'detail': 'ok'} async def test_my_api18_put(self): parsed = EndpointParser(API18, 'put') assert parsed.status_code == 200 assert parsed.response_data == {'detail': 'ok'} async def test_my_api18_patch(self): parsed = EndpointParser(API18, 'patch') assert parsed.status_code == 201 assert parsed.response_data == {'detail': 'ok'} async def test_my_api18_delete(self): parsed = EndpointParser(API18, 'delete') assert parsed.status_code == 204 assert parsed.response_data == {} async def test_my_api19(self): parsed = EndpointParser(my_api19, 'get') assert parsed.status_code == 200 assert parsed.response_data == ['1', '2'] async def test_my_api20(self): parsed = EndpointParser(my_api20, 'get') assert parsed.status_code == 200 assert parsed.response_data == 'Hello World!' async def test_my_api21(self): parsed = EndpointParser(my_api21, 'get') assert parsed.status_code == 200 assert parsed.response_data == True async def test_my_api22(self): parsed = EndpointParser(my_api22, 'get') assert parsed.status_code == 200 assert parsed.response_data == [6, 9] async def test_my_api23(self): parsed = EndpointParser(my_api23, 'get') assert parsed.status_code == 207 assert parsed.response_data == {'detail': 'Hello'} async def test_my_api24(self): parsed = EndpointParser(my_api24, 'get') assert parsed.status_code == 200 assert parsed.response_data == {'name': 'Ali', 'children': ['A', 'B', 'C']} async def test_my_api25(self): parsed = EndpointParser(my_api25, 'get') assert parsed.status_code == 200 assert parsed.response_data == {'title': 'Book'} async def test_my_api26(self): parsed = EndpointParser(my_api26, 'get') assert parsed.status_code == 200 assert parsed.response_data == {'name': 'Ali'} async def test_my_api27(self): parsed = EndpointParser(my_api27, 'get') assert parsed.status_code == 200 assert parsed.response_data == {'title': 'Book'} ================================================ FILE: tests/test_panel.py ================================================ from pathlib import Path from unittest import IsolatedAsyncioTestCase from panther import Panther from panther.configs import config from panther.db import Model from panther.db.connections import db from panther.db.models import BaseUser from panther.panel.urls import url_routing from panther.test import APIClient class CustomUser(BaseUser): username: str password: str class CustomModel(Model): name: str description: str = 'Default description' is_active: bool = True # Test configuration AUTHENTICATION = 'panther.authentications.JWTAuthentication' SECRET_KEY = 'hvdhRspoTPh1cJVBHcuingQeOKNc1uRhIP2k7suLe2g=' DB_PATH = 'test_panel.pdb' DATABASE = { 'engine': { 'class': 'panther.db.connections.PantherDBConnection', 'path': DB_PATH, }, } USER_MODEL = 'tests.test_panel.CustomUser' class TestPanel(IsolatedAsyncioTestCase): @classmethod def setUpClass(cls) -> None: app = Panther(__name__, configs=__name__, urls={'': url_routing}) cls.client = APIClient(app=app) def tearDown(self) -> None: db.session.collection('CustomUser').drop() db.session.collection('CustomModel').drop() @classmethod def tearDownClass(cls): config.refresh() Path(DB_PATH).unlink(missing_ok=True) @classmethod def check_in_cookies(cls, *args, cookies): cookies = {cookie[1] for cookie in cookies} for arg in args: for c in cookies: c.startswith(arg) break else: assert False async def test_login_view_get(self): """Test GET request to login page""" res = await self.client.get('login/') assert res.status_code == 200 assert res.headers['Content-Type'] == 'text/html; charset=utf-8' async def test_login_view_post_success(self): """Test successful login POST request""" # Create a test user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') # Test successful login res = await self.client.post( path='login/', payload={'username': 'testuser', 'password': 'testpass'}, ) self.check_in_cookies(b'access_token', b'refresh_token', cookies=res.cookies) assert res.status_code == 302 # Redirect async def test_login_view_post_user_not_found(self): """Test login POST request with non-existent user""" res = await self.client.post( path='login/', payload={'username': 'nonexistent', 'password': 'testpass'}, ) assert res.status_code == 400 assert res.headers['Content-Type'] == 'text/html; charset=utf-8' async def test_login_view_post_wrong_password(self): """Test login POST request with wrong password""" # Create a test user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') # Test wrong password res = await self.client.post( path='login/', payload={'username': 'testuser', 'password': 'wrongpass'}, ) assert res.status_code == 400 assert res.headers['Content-Type'] == 'text/html; charset=utf-8' async def test_login_view_post_with_redirect(self): """Test login POST request with redirect_to parameter""" # Create a test user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') # Test login with redirect res = await self.client.post( path='login/', payload={'username': 'testuser', 'password': 'testpass'}, query_params={'redirect_to': '/admin/dashboard'}, ) assert res.status_code == 302 self.check_in_cookies(b'access_token', b'refresh_token', cookies=res.cookies) async def test_home_view_without_auth(self): """Test home view without authentication""" res = await self.client.get('') assert res.status_code == 302 # Should redirect to login async def test_home_view_with_auth(self): """Test home view with authentication""" # Create and login user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') tokens = await user.login() # Test home view with auth res = await self.client.get(path='', headers={'Cookie': f'access_token={tokens["access_token"]}'}) assert res.status_code == 200 assert res.headers['Content-Type'] == 'text/html; charset=utf-8' async def test_table_view_without_auth(self): """Test table view without authentication""" res = await self.client.get('0/') assert res.status_code == 302 # Should redirect to login async def test_table_view_with_auth_empty_data(self): """Test table view with authentication but no data""" # Create and login user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') tokens = await user.login() # Test table view with auth but no data res = await self.client.get(path='0/', headers={'Cookie': f'access_token={tokens["access_token"]}'}) assert res.status_code == 200 assert res.headers['Content-Type'] == 'text/html; charset=utf-8' async def test_table_view_with_auth_with_data(self): """Test table view with authentication and data""" # Create and login user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') tokens = await user.login() # Create some test data await CustomModel.insert_one(name='Test Item 1', description='Description 1') await CustomModel.insert_one(name='Test Item 2', description='Description 2') # Test table view with auth and data res = await self.client.get( path='1/', # CustomModel should be at index 1 (after CustomUser at index 0) headers={'Cookie': f'access_token={tokens["access_token"]}'}, ) assert res.status_code == 200 assert res.headers['Content-Type'] == 'text/html; charset=utf-8' async def test_create_view_get_without_auth(self): """Test create view GET without authentication""" res = await self.client.get('0/create/') assert res.status_code == 302 # Should redirect to login async def test_create_view_get_with_auth(self): """Test create view GET with authentication""" # Create and login user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') tokens = await user.login() # Test create view GET with auth res = await self.client.get(path='0/create/', headers={'Cookie': f'access_token={tokens["access_token"]}'}) assert res.status_code == 200 assert res.headers['Content-Type'] == 'text/html; charset=utf-8' async def test_create_view_post_without_auth(self): """Test create view POST without authentication""" res = await self.client.post('0/create/', payload={'username': 'newuser', 'password': 'newpass'}) assert res.status_code == 302 # Should redirect to login async def test_create_view_post_with_auth_user_model(self): """Test create view POST with authentication for user model""" # Create and login user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') tokens = await user.login() # Test create user with auth res = await self.client.post( path='0/create/', payload={'username': 'newuser', 'password': 'newpass'}, headers={'Cookie': f'access_token={tokens["access_token"]}'}, ) assert res.status_code == 200 assert res.data['username'] == 'newuser' # Password should be hashed assert res.data['password'] != 'newpass' async def test_create_view_post_with_auth_regular_model(self): """Test create view POST with authentication for regular model""" # Create and login user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') tokens = await user.login() # Test create regular model with auth res = await self.client.post( path='1/create/', payload={'name': 'New Item', 'description': 'New Description', 'is_active': True}, headers={'Cookie': f'access_token={tokens["access_token"]}'}, ) assert res.status_code == 200 assert res.data['name'] == 'New Item' assert res.data['description'] == 'New Description' assert res.data['is_active'] is True async def test_detail_view_get_without_auth(self): """Test detail view GET without authentication""" res = await self.client.get('0/test-id/') assert res.status_code == 302 # Should redirect to login async def test_detail_view_get_with_auth(self): """Test detail view GET with authentication""" # Create and login user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') tokens = await user.login() # Create a test item test_item = await CustomModel.insert_one(name='Test Item', description='Test Description') # Test detail view GET with auth res = await self.client.get( path=f'1/{test_item.id}/', headers={'Cookie': f'access_token={tokens["access_token"]}'} ) assert res.status_code == 200 assert res.headers['Content-Type'] == 'text/html; charset=utf-8' async def test_detail_view_put_without_auth(self): """Test detail view PUT without authentication""" res = await self.client.put('0/test-id/', payload={'username': 'updated'}) assert res.status_code == 302 # Should redirect to login async def test_detail_view_put_with_auth(self): """Test detail view PUT with authentication""" # Create and login user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') tokens = await user.login() # Create a test item test_item = await CustomModel.insert_one(name='Test Item', description='Test Description') # Test detail view PUT with auth res = await self.client.put( path=f'1/{test_item.id}/', payload={'name': 'Updated Item', 'description': 'Updated Description'}, headers={'Cookie': f'access_token={tokens["access_token"]}'}, ) assert res.status_code == 200 assert res.data['name'] == 'Updated Item' assert res.data['description'] == 'Updated Description' async def test_detail_view_delete_without_auth(self): """Test detail view DELETE without authentication""" res = await self.client.delete('0/test-id/') assert res.status_code == 302 # Should redirect to login async def test_detail_view_delete_with_auth(self): """Test detail view DELETE with authentication""" # Create and login user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') tokens = await user.login() # Create a test item test_item = await CustomModel.insert_one(name='Test Item', description='Test Description') # Test detail view DELETE with auth res = await self.client.delete( path=f'1/{test_item.id}/', headers={'Cookie': f'access_token={tokens["access_token"]}'} ) assert res.status_code == 204 # No content # Verify item was deleted deleted_item = await CustomModel.find_one(id=test_item.id) assert deleted_item is None async def test_invalid_model_index(self): """Test accessing invalid model index""" # Create and login user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') tokens = await user.login() # Test invalid model index res = await self.client.get(path='999/', headers={'Cookie': f'access_token={tokens["access_token"]}'}) assert res.status_code == 500 # Should raise an error for invalid index async def test_invalid_document_id(self): """Test accessing invalid document ID""" # Create and login user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') tokens = await user.login() # Test invalid document ID res = await self.client.get(path='0/invalid-id/', headers={'Cookie': f'access_token={tokens["access_token"]}'}) assert res.status_code == 404 # Should raise an error for invalid document ID async def test_middleware_redirect_to_slash(self): """Test RedirectToSlashMiddleware functionality""" # Create and login user user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') tokens = await user.login() # Test without trailing slash (should redirect) res = await self.client.get(path='login', headers={'Cookie': f'access_token={tokens["access_token"]}'}) assert res.status_code == 307 # Redirect to add trailing slash async def test_cookie_authentication_invalid_token(self): """Test cookie authentication with invalid token""" res = await self.client.get(path='', headers={'Cookie': 'access_token=invalid_token'}) assert res.status_code == 302 # Should redirect to login async def test_cookie_authentication_expired_token(self): """Test cookie authentication with expired token""" # Create a user but don't use their token user = await CustomUser.insert_one(username='testuser', password='testpass') await user.set_password('testpass') # Use an expired token expired_token = ( 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJpYXQiOjE2MzQ1Njc4NzQsImV4cCI6MTYzNDU2Nzg3NX0.invalid_signature' ) res = await self.client.get(path='', headers={'Cookie': f'access_token={expired_token}'}) assert res.status_code == 302 # Should redirect to login ================================================ FILE: tests/test_permission.py ================================================ from unittest import IsolatedAsyncioTestCase from panther import Panther from panther.app import API, GenericAPI from panther.configs import config from panther.exceptions import PantherError from panther.permissions import BasePermission from panther.request import Request from panther.test import APIClient class AlwaysDeniedPermission(BasePermission): async def __call__(self, request: Request) -> bool: return False class NotInheritedPermission: async def __call__(self, request: Request) -> bool: return False class SyncPermission(BasePermission): def __call__(self, request: Request) -> bool: return False class WithoutParamPermission: def __call__(self) -> bool: return False def without_param_permission(): return False class WithoutCallPermission: pass def sync_permission(req): return False async def accept_permission(req): return True async def deny_permission(req): return True @API() async def without_permission_api(request: Request): return request.user @API(permissions=[AlwaysDeniedPermission]) async def denied_permission_api(request: Request): return request.user @API(permissions=AlwaysDeniedPermission) async def single_denied_permission_api(request: Request): return request.user @API(permissions=[NotInheritedPermission]) async def not_inherited_permission_api(request: Request): return request.user @API(permissions=[accept_permission, accept_permission, AlwaysDeniedPermission]) async def multiple_permissions_api(request: Request): return request.user urls = { 'without': without_permission_api, 'denied-permission': denied_permission_api, 'single-denied-permission': single_denied_permission_api, 'not-inherited-permission': not_inherited_permission_api, 'multiple-permission': multiple_permissions_api, } class TestPermission(IsolatedAsyncioTestCase): def setUp(self) -> None: app = Panther(__name__, configs=__name__, urls=urls) self.client = APIClient(app=app) @classmethod def tearDownClass(cls): config.refresh() async def test_without_permission(self): res = await self.client.get('without') assert res.status_code == 200 assert res.data is None async def test_denied_permission(self): with self.assertNoLogs(level='ERROR'): res = await self.client.get('denied-permission') assert res.status_code == 403 assert res.data['detail'] == 'Permission Denied' async def test_single_denied_permission(self): with self.assertNoLogs(level='ERROR'): res = await self.client.get('single-denied-permission') assert res.status_code == 403 assert res.data['detail'] == 'Permission Denied' async def test_not_inherited_permission(self): with self.assertNoLogs(level='ERROR'): res = await self.client.get('not-inherited-permission') assert res.status_code == 403 assert res.data['detail'] == 'Permission Denied' async def test_multiple_permission(self): with self.assertNoLogs(level='ERROR'): res = await self.client.get('multiple-permission') assert res.status_code == 403 assert res.data['detail'] == 'Permission Denied' async def test_sync_permission(self): try: @API(permissions=[SyncPermission]) async def sync_permission_api(request: Request): return request.user except PantherError as e: assert e.args[0] == 'SyncPermission.__call__() should be `async`' else: assert False async def test_sync_permission_classbased(self): try: class SyncPermissionAPI(GenericAPI): permissions = [SyncPermission] except PantherError as e: assert e.args[0] == 'SyncPermission.__call__() should be `async`' else: assert False async def test_without_call_permission(self): try: @API(permissions=[WithoutCallPermission]) async def permission_api(request: Request): return request.user except PantherError as e: assert e.args[0] == 'WithoutCallPermission must implement __call__() method.' else: assert False async def test_without_call_permission_classbased(self): try: class PermissionAPI(GenericAPI): permissions = [WithoutCallPermission] except PantherError as e: assert e.args[0] == 'WithoutCallPermission must implement __call__() method.' else: assert False async def test_sync_function_permission(self): try: @API(permissions=[sync_permission]) async def permission_api(request: Request): return request.user except PantherError as e: assert e.args[0] == 'sync_permission() should be `async`' else: assert False async def test_sync_function_permission_classbased(self): try: class PermissionAPI(GenericAPI): permissions = [sync_permission] except PantherError as e: assert e.args[0] == 'sync_permission() should be `async`' else: assert False async def test_class_without_param_permission(self): try: @API(permissions=[WithoutParamPermission]) async def permission_api(request: Request): return request.user except PantherError as e: assert e.args[0] == 'WithoutParamPermission.__call__() requires 2 positional argument(s) (self, request).' else: assert False async def test_class_without_param_permission_classbased(self): try: class PermissionAPI(GenericAPI): permissions = [WithoutParamPermission] except PantherError as e: assert e.args[0] == 'WithoutParamPermission.__call__() requires 2 positional argument(s) (self, request).' else: assert False async def test_function_without_param_permission(self): try: @API(permissions=[without_param_permission]) async def permission_api(request: Request): return request.user except PantherError as e: assert e.args[0] == 'without_param_permission() requires 1 positional argument(s) (request).' else: assert False async def test_function_without_param_permission_classbased(self): try: class PermissionAPI(GenericAPI): permissions = [without_param_permission] except PantherError as e: assert e.args[0] == 'without_param_permission() requires 1 positional argument(s) (request).' else: assert False ================================================ FILE: tests/test_request.py ================================================ from unittest import IsolatedAsyncioTestCase import orjson as json from panther import Panther from panther.app import API, GenericAPI from panther.configs import config from panther.request import Request from panther.response import Response from panther.test import APIClient @API() async def request_path(request: Request): return request.path @API() async def request_client(request: Request): return request.client.__dict__ @API() async def request_query_params(request: Request): return request.query_params @API() async def request_data(request: Request): return request.data @API() async def request_path_variables(name: str, age: int, is_alive: bool): return {'name': name, 'age': age, 'is_alive': is_alive} @API() async def request_header(request: Request): return request.headers.__dict__ @API() async def request_header_str(request: Request): return request.headers.__str__(), request.headers.__repr__() @API() async def request_header_contains(request: Request, header: str): return header in request.headers @API() async def request_header_by_attr(request: Request): return request.headers.authorization @API() async def request_header_by_item(request: Request): return request.headers['Authorization'], request.headers['authorization'] # # # Methods class AllMethods(GenericAPI): def get(self, *args, **kwargs): return Response() def post(self, *args, **kwargs): return Response() def put(self, *args, **kwargs): return Response() def patch(self, *args, **kwargs): return Response() def delete(self, *args, **kwargs): return Response() @API() async def all_methods(): return Response() class GetMethod(GenericAPI): def get(self, *args, **kwargs): return Response() @API(methods=['GET']) async def get_method(): return Response() class PostMethod(GenericAPI): def post(self, *args, **kwargs): return Response() @API(methods=['POST']) async def post_method(): return Response() class PutMethod(GenericAPI): def put(self, *args, **kwargs): return Response() @API(methods=['PUT']) async def put_method(): return Response() class PatchMethod(GenericAPI): def patch(self, *args, **kwargs): return Response() @API(methods=['PATCH']) async def patch_method(): return Response() class DeleteMethod(GenericAPI): def delete(self, *args, **kwargs): return Response() @API(methods=['DELETE']) async def delete_method(): return Response() class GetPostPatchMethods(GenericAPI): def get(self, *args, **kwargs): return Response() def post(self, *args, **kwargs): return Response() def patch(self, *args, **kwargs): return Response() @API(methods=['GET', 'POST', 'PATCH']) async def get_post_patch_methods(): return Response() urls = { 'path': request_path, 'client': request_client, 'query-params': request_query_params, 'data': request_data, 'path//variable///': request_path_variables, 'header': request_header, 'header-str': request_header_str, 'header-attr': request_header_by_attr, 'header-item': request_header_by_item, 'header-contains/
      /': request_header_contains, 'all-func': all_methods, 'all-class': AllMethods, 'get-func': get_method, 'get-class': GetMethod, 'post-func': post_method, 'post-class': PostMethod, 'put-func': put_method, 'put-class': PutMethod, 'patch-func': patch_method, 'patch-class': PatchMethod, 'delete-func': delete_method, 'delete-class': DeleteMethod, 'get-post-patch-func': get_post_patch_methods, 'get-post-patch-class': GetPostPatchMethods, } class TestRequest(IsolatedAsyncioTestCase): @classmethod def setUpClass(cls) -> None: app = Panther(__name__, configs=__name__, urls=urls) cls.client = APIClient(app=app) @classmethod def tearDownClass(cls) -> None: config.refresh() async def test_path(self): res = await self.client.get('path/') assert res.status_code == 200 assert res.data == '/path/' async def test_client(self): res = await self.client.get('client/') assert res.status_code == 200 assert res.data == {'ip': '127.0.0.1', 'port': 8585} async def test_query_params(self): res = await self.client.get( 'query-params/', query_params={'my': 'name', 'is': 'ali', 'how': 'are'}, ) assert res.status_code == 200 assert res.data == {'my': 'name', 'is': 'ali', 'how': 'are'} async def test_data(self): payload = {'detail': 'ok'} res = await self.client.post('data/', payload=json.dumps(payload)) assert res.status_code == 200 assert res.data == payload async def test_path_variables(self): res = await self.client.post('path/Ali/variable/27/true/') expected_response = {'name': 'Ali', 'age': 27, 'is_alive': True} assert res.status_code == 200 assert res.data == expected_response # # # Headers async def test_headers_none(self): res = await self.client.get('header') expected_headers = {} assert res.data == expected_headers async def test_headers_content_type(self): res = await self.client.post('header') expected_headers = {'content-type': 'application/json'} assert res.data == expected_headers async def test_headers_full_items(self): headers = { 'User-Agent': 'PostmanRuntime/7.36.0', 'Accept': '*/*', 'Cache-Control': 'no-cache', 'Host': '127.0.0.1:8000', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive', 'Content-Length': 546, } res = await self.client.post('header', headers=headers) expected_headers = { 'content-type': 'application/json', 'User-Agent': 'PostmanRuntime/7.36.0', 'Accept': '*/*', 'Cache-Control': 'no-cache', 'Host': '127.0.0.1:8000', 'Accept-Encoding': 'gzip, deflate, br', 'Connection': 'keep-alive', 'Content-Length': '546', } assert res.data == expected_headers async def test_headers_unknown_items(self): headers = { 'Header1': 'PostmanRuntime/7.36.0', 'Header2': '*/*', } res = await self.client.post('header', headers=headers) expected_headers = { 'content-type': 'application/json', 'Header1': 'PostmanRuntime/7.36.0', 'Header2': '*/*', } assert res.data == expected_headers async def test_headers_authorization_by_getattr(self): headers = { 'Authorization': 'Token xxx', } res = await self.client.post('header-attr', headers=headers) assert res.data == 'Token xxx' async def test_headers_authorization_by_getitem(self): headers = { 'Authorization': 'Token xxx', } res = await self.client.post('header-item', headers=headers) assert res.data == ['Token xxx', 'Token xxx'] async def test_headers_str(self): res = await self.client.post('header-str') assert res.data == ['Headers(content-type=application/json)', 'Headers(content-type=application/json)'] async def test_headers_contains(self): res = await self.client.post('header-contains/content-type/') assert res.data is True res = await self.client.post('header-contains/accept/') assert res.data is False # # # Methods async def test_method_all(self): res_func = await self.client.get('all-func/') res_class = await self.client.get('all-class/') assert res_func.status_code == 200 assert res_class.status_code == 200 res_func = await self.client.post('all-func/') res_class = await self.client.post('all-class/') assert res_func.status_code == 200 assert res_class.status_code == 200 res_func = await self.client.put('all-func/') res_class = await self.client.put('all-class/') assert res_func.status_code == 200 assert res_class.status_code == 200 res_func = await self.client.patch('all-func/') res_class = await self.client.patch('all-class/') assert res_func.status_code == 200 assert res_class.status_code == 200 res_func = await self.client.delete('all-func/') res_class = await self.client.delete('all-class/') assert res_func.status_code == 200 assert res_class.status_code == 200 async def test_method_get(self): res_func = await self.client.get('get-func/') res_class = await self.client.get('get-class/') assert res_func.status_code == 200 assert res_class.status_code == 200 res_func = await self.client.post('get-func/') res_class = await self.client.post('get-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.put('get-func/') res_class = await self.client.put('get-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.patch('get-func/') res_class = await self.client.patch('get-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.delete('get-func/') res_class = await self.client.delete('get-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 async def test_method_post(self): res_func = await self.client.get('post-func/') res_class = await self.client.get('post-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.post('post-func/') res_class = await self.client.post('post-class/') assert res_func.status_code == 200 assert res_class.status_code == 200 res_func = await self.client.put('post-func/') res_class = await self.client.put('post-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.patch('post-func/') res_class = await self.client.patch('post-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.delete('post-func/') res_class = await self.client.delete('post-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 async def test_method_put(self): res_func = await self.client.get('put-func/') res_class = await self.client.get('put-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.post('put-func/') res_class = await self.client.post('put-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.put('put-func/') res_class = await self.client.put('put-class/') assert res_func.status_code == 200 assert res_class.status_code == 200 res_func = await self.client.patch('put-func/') res_class = await self.client.patch('put-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.delete('put-func/') res_class = await self.client.delete('put-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 async def test_method_patch(self): res_func = await self.client.get('patch-func/') res_class = await self.client.get('patch-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.post('patch-func/') res_class = await self.client.post('patch-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.put('patch-func/') res_class = await self.client.put('patch-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.patch('patch-func/') res_class = await self.client.patch('patch-class/') assert res_func.status_code == 200 assert res_class.status_code == 200 res_func = await self.client.delete('patch-func/') res_class = await self.client.delete('patch-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 async def test_method_delete(self): res_func = await self.client.get('delete-func/') res_class = await self.client.get('delete-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.post('delete-func/') res_class = await self.client.post('delete-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.put('delete-func/') res_class = await self.client.put('delete-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.patch('delete-func/') res_class = await self.client.patch('delete-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.delete('delete-func/') res_class = await self.client.delete('delete-class/') assert res_func.status_code == 200 assert res_class.status_code == 200 async def test_method_get_post_patch(self): res_func = await self.client.get('get-post-patch-func/') res_class = await self.client.get('get-post-patch-class/') assert res_func.status_code == 200 assert res_class.status_code == 200 res_func = await self.client.post('get-post-patch-func/') res_class = await self.client.post('get-post-patch-class/') assert res_func.status_code == 200 assert res_class.status_code == 200 res_func = await self.client.put('get-post-patch-func/') res_class = await self.client.put('get-post-patch-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 res_func = await self.client.patch('get-post-patch-func/') res_class = await self.client.patch('get-post-patch-class/') assert res_func.status_code == 200 assert res_class.status_code == 200 res_func = await self.client.delete('get-post-patch-func/') res_class = await self.client.delete('get-post-patch-class/') assert res_func.status_code == 405 assert res_class.status_code == 405 ================================================ FILE: tests/test_request_isolation.py ================================================ import asyncio from typing import ClassVar from unittest import IsolatedAsyncioTestCase from panther import Panther from panther.app import API, GenericAPI from panther.configs import config from panther.request import Request from panther.test import APIClient class RequestIsolationRaceAuth: seen_paths: ClassVar[set[str]] = set() second_request_seen: ClassVar[asyncio.Event | None] = None @classmethod def reset(cls) -> None: cls.seen_paths.clear() cls.second_request_seen = asyncio.Event() async def __call__(self, request: Request) -> str: cls = type(self) if cls.second_request_seen is None: cls.second_request_seen = asyncio.Event() cls.seen_paths.add(request.path) if request.path.endswith('/second/'): cls.second_request_seen.set() if request.path.endswith('/first/'): await cls.second_request_seen.wait() await asyncio.sleep(0) return 'user' @API(auth=RequestIsolationRaceAuth) async def function_api_race_endpoint(name: str, request: Request): return {'name': name, 'path': request.path} class ClassBasedRaceEndpoint(GenericAPI): auth = RequestIsolationRaceAuth async def get(self, name: str, request: Request): return {'name': name, 'path': request.path} test_urls = { 'race//': function_api_race_endpoint, 'race-class//': ClassBasedRaceEndpoint, } class TestAPIRequestIsolation(IsolatedAsyncioTestCase): @classmethod def setUpClass(cls) -> None: app = Panther(__name__, configs=__name__, urls=test_urls) cls.client = APIClient(app=app) @classmethod def tearDownClass(cls) -> None: config.refresh() async def test_function_api_keeps_request_isolation_under_concurrency(self): RequestIsolationRaceAuth.reset() first_response, second_response = await asyncio.gather( self.client.get('race/first/'), self.client.get('race/second/'), ) assert first_response.status_code == 200 assert second_response.status_code == 200 assert first_response.data == {'name': 'first', 'path': '/race/first/'} assert second_response.data == {'name': 'second', 'path': '/race/second/'} async def test_class_based_apis_keep_request_isolation(self): RequestIsolationRaceAuth.reset() first_response, second_response = await asyncio.gather( self.client.get('race-class/first/'), self.client.get('race-class/second/'), ) assert first_response.status_code == 200 assert second_response.status_code == 200 assert first_response.data == {'name': 'first', 'path': '/race-class/first/'} assert second_response.data == {'name': 'second', 'path': '/race-class/second/'} ================================================ FILE: tests/test_response.py ================================================ import platform from unittest import IsolatedAsyncioTestCase from pydantic import BaseModel from panther import Panther from panther.app import API, GenericAPI from panther.configs import config from panther.db import Model from panther.response import ( Cookie, FileResponse, HTMLResponse, PlainTextResponse, Response, StreamingResponse, TemplateResponse, ) from panther.test import APIClient @API() async def return_nothing(): pass class ReturnNothing(GenericAPI): def get(self): pass @API() async def return_none(): return None class ReturnNone(GenericAPI): def get(self): return None @API() async def return_string(): return 'Hello' class ReturnString(GenericAPI): def get(self): return 'Hello' @API() async def return_dict(): return {'detail': 'ok'} class ReturnDict(GenericAPI): def get(self): return {'detail': 'ok'} @API() async def return_list(): return [1, 2, 3] class ReturnList(GenericAPI): def get(self): return [1, 2, 3] @API() async def return_tuple(): return 1, 2, 3, 4 class ReturnTuple(GenericAPI): def get(self): return 1, 2, 3, 4 @API() async def return_response_none(): return Response() class ReturnResponseNone(GenericAPI): def get(self): return Response() @API() async def return_response_dict(): return Response(data={'detail': 'ok'}) class ReturnResponseDict(GenericAPI): def get(self): return Response(data={'detail': 'ok'}) @API() async def return_response_list(): return Response(data=['car', 'home', 'phone']) class ReturnResponseList(GenericAPI): def get(self): return Response(data=['car', 'home', 'phone']) @API() async def return_response_tuple(): return Response(data=('car', 'home', 'phone', 'book')) class ReturnResponseTuple(GenericAPI): def get(self): return Response(data=('car', 'home', 'phone', 'book')) class CustomUser(BaseModel): name: str age: int is_alive: bool class CustomBook(BaseModel): title: str author: CustomUser readers: list[CustomUser] class ReturnResponseBaseModel(GenericAPI): def get(self): return Response(data=CustomUser(name='John', age=21, is_alive=True)) class ReturnResponseNestedBaseModel(GenericAPI): def get(self): return Response( data={ 'name': 'Ali', 'book': CustomBook( title='Boo1', author=CustomUser(name='John', age=21, is_alive=True), readers=[ CustomUser(name='Sara', age=22, is_alive=True), CustomUser(name='Sam', age=5, is_alive=False), ], ), 'user': CustomUser(name='Ali', age=2, is_alive=True), 'books': [ CustomBook(title='Book1', author=CustomUser(name='John1', age=21, is_alive=True), readers=[]), CustomBook(title='Book2', author=CustomUser(name='John2', age=22, is_alive=True), readers=[]), ], } ) class CustomProduct(Model): title: str class ReturnResponseModel(GenericAPI): def get(self): return Response(data=CustomProduct(title='Fruit')) @API() async def return_html_response(): return HTMLResponse('') class ReturnHTMLResponse(GenericAPI): def get(self): return HTMLResponse('') @API() async def return_template_response() -> TemplateResponse: return TemplateResponse(source='

      {{ content }}

      ', context={'content': 'Hello World'}) class ReturnTemplateResponse(GenericAPI): def get(self) -> TemplateResponse: return TemplateResponse( source='

      {{ content }}

      ', context={'content': 'Hello World'}, ) @API() async def return_file_not_found_response(): return FileResponse('random.file') @API() async def return_file_response(): return FileResponse('README.md') @API() async def return_plain_response(): return PlainTextResponse('Hello World') class ReturnPlainResponse(GenericAPI): def get(self): return PlainTextResponse('Hello World') class ReturnStreamingResponse(GenericAPI): def get(self): def f(): yield from range(5) return StreamingResponse(f()) class ReturnAsyncStreamingResponse(GenericAPI): async def get(self): async def f(): for i in range(6): yield i return StreamingResponse(f()) @API() def full_cookie_api(): return Response( set_cookies=Cookie( key='custom_key', value='custom_value', domain='example.com', max_age=100, secure=True, httponly=True, samesite='strict', path='/here/', ) ) @API() def multiple_cookies_api(): return Response( set_cookies=[ Cookie( key='custom_key1', value='custom_value1', domain='example.com', max_age=100, secure=True, httponly=True, samesite='strict', path='/here/', ), Cookie( key='custom_key2', value='custom_value2', domain='example.com', max_age=100, secure=True, httponly=True, samesite='strict', path='/here/', ), ] ) @API() def default_cookies_api(): return Response(set_cookies=Cookie(key='custom_key', value='custom_value')) urls = { 'nothing': return_nothing, 'none': return_none, 'dict': return_dict, 'str': return_string, 'list': return_list, 'tuple': return_tuple, 'response-none': return_response_none, 'response-dict': return_response_dict, 'response-list': return_response_list, 'response-tuple': return_response_tuple, 'html': return_html_response, 'file-not-found': return_file_not_found_response, 'file': return_file_response, 'plain': return_plain_response, 'template': return_template_response, 'nothing-cls': ReturnNothing, 'none-cls': ReturnNone, 'dict-cls': ReturnDict, 'str-cls': ReturnString, 'list-cls': ReturnList, 'tuple-cls': ReturnTuple, 'basemodel': ReturnResponseBaseModel, 'nested-basemodel': ReturnResponseNestedBaseModel, 'model': ReturnResponseModel, 'response-none-cls': ReturnResponseNone, 'response-dict-cls': ReturnResponseDict, 'response-list-cls': ReturnResponseList, 'response-tuple-cls': ReturnResponseTuple, 'html-cls': ReturnHTMLResponse, 'template-cls': ReturnTemplateResponse, 'plain-cls': ReturnPlainResponse, 'stream': ReturnStreamingResponse, 'async-stream': ReturnAsyncStreamingResponse, 'full-cookies': full_cookie_api, 'multiple-cookies': multiple_cookies_api, 'default-cookies': default_cookies_api, } class TestResponses(IsolatedAsyncioTestCase): @classmethod def setUpClass(cls) -> None: app = Panther(__name__, configs=__name__, urls=urls) cls.client = APIClient(app=app) @classmethod def tearDownClass(cls): config.refresh() async def test_nothing(self): res = await self.client.get('nothing/') assert res.status_code == 200 assert res.data is None assert res.body == b'' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '0' async def test_nothing_cls(self): res = await self.client.get('nothing-cls/') assert res.status_code == 200 assert res.data is None assert res.body == b'' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '0' async def test_none(self): res = await self.client.get('none/') assert res.status_code == 200 assert res.data is None assert res.body == b'' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '0' async def test_none_cls(self): res = await self.client.get('none-cls/') assert res.status_code == 200 assert res.data is None assert res.body == b'' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '0' async def test_dict(self): res = await self.client.get('dict/') assert res.status_code == 200 assert res.data == {'detail': 'ok'} assert res.body == b'{"detail":"ok"}' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '15' async def test_dict_cls(self): res = await self.client.get('dict-cls/') assert res.status_code == 200 assert res.data == {'detail': 'ok'} assert res.body == b'{"detail":"ok"}' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '15' async def test_string(self): res = await self.client.get('str/') assert res.status_code == 200 assert res.data == 'Hello' assert res.body == b'"Hello"' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '7' async def test_string_cls(self): res = await self.client.get('str-cls/') assert res.status_code == 200 assert res.data == 'Hello' assert res.body == b'"Hello"' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '7' async def test_list(self): res = await self.client.get('list/') assert res.status_code == 200 assert res.data == [1, 2, 3] assert res.body == b'[1,2,3]' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '7' async def test_list_cls(self): res = await self.client.get('list-cls/') assert res.status_code == 200 assert res.data == [1, 2, 3] assert res.body == b'[1,2,3]' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '7' async def test_tuple(self): res = await self.client.get('tuple/') assert res.status_code == 200 assert res.data == [1, 2, 3, 4] assert res.body == b'[1,2,3,4]' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '9' async def test_tuple_cls(self): res = await self.client.get('tuple-cls/') assert res.status_code == 200 assert res.data == [1, 2, 3, 4] assert res.body == b'[1,2,3,4]' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '9' async def test_basemodel(self): res = await self.client.get('basemodel/') assert res.status_code == 200 assert res.data == {'name': 'John', 'age': 21, 'is_alive': True} assert res.body == b'{"name":"John","age":21,"is_alive":true}' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '40' async def test_nested_basemodel(self): res = await self.client.get('nested-basemodel/') assert res.status_code == 200 assert res.data == { 'name': 'Ali', 'book': { 'title': 'Boo1', 'author': {'name': 'John', 'age': 21, 'is_alive': True}, 'readers': [ {'name': 'Sara', 'age': 22, 'is_alive': True}, {'name': 'Sam', 'age': 5, 'is_alive': False}, ], }, 'user': {'name': 'Ali', 'age': 2, 'is_alive': True}, 'books': [ {'title': 'Book1', 'author': {'name': 'John1', 'age': 21, 'is_alive': True}, 'readers': []}, {'title': 'Book2', 'author': {'name': 'John2', 'age': 22, 'is_alive': True}, 'readers': []}, ], } assert res.body == ( b'{"name":"Ali","book":{"title":"Boo1","author":{"name":"John","age":21,"is_alive":true},' b'"readers":[{"name":"Sara","age":22,"is_alive":true},{"name":"Sam","age":5,"is_alive":false}]},' b'"user":{"name":"Ali","age":2,"is_alive":true},' b'"books":[{"title":"Book1","author":{"name":"John1","age":21,"is_alive":true},"readers":[]},' b'{"title":"Book2","author":{"name":"John2","age":22,"is_alive":true},"readers":[]}]}' ) assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '401' async def test_model(self): res = await self.client.get('model/') assert res.status_code == 200 assert res.data == {'id': None, 'title': 'Fruit'} assert res.body == b'{"id":null,"title":"Fruit"}' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '27' async def test_response_none(self): res = await self.client.get('response-none/') assert res.status_code == 200 assert res.data is None assert res.body == b'' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '0' async def test_response_none_cls(self): res = await self.client.get('response-none-cls/') assert res.status_code == 200 assert res.data is None assert res.body == b'' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '0' async def test_response_dict(self): res = await self.client.get('response-dict/') assert res.status_code == 200 assert res.data == {'detail': 'ok'} assert res.body == b'{"detail":"ok"}' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '15' async def test_response_dict_cls(self): res = await self.client.get('response-dict-cls/') assert res.status_code == 200 assert res.data == {'detail': 'ok'} assert res.body == b'{"detail":"ok"}' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '15' async def test_response_list(self): res = await self.client.get('response-list/') assert res.status_code == 200 assert res.data == ['car', 'home', 'phone'] assert res.body == b'["car","home","phone"]' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '22' async def test_response_list_cls(self): res = await self.client.get('response-list-cls/') assert res.status_code == 200 assert res.data == ['car', 'home', 'phone'] assert res.body == b'["car","home","phone"]' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '22' async def test_response_tuple(self): res = await self.client.get('response-tuple/') assert res.status_code == 200 assert res.data == ['car', 'home', 'phone', 'book'] assert res.body == b'["car","home","phone","book"]' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '29' async def test_response_tuple_cls(self): res = await self.client.get('response-tuple-cls/') assert res.status_code == 200 assert res.data == ['car', 'home', 'phone', 'book'] assert res.body == b'["car","home","phone","book"]' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '29' async def test_response_html(self): res = await self.client.get('html/') assert res.status_code == 200 assert res.data == '' assert res.body == b'' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'text/html; charset=utf-8' assert res.headers['Content-Length'] == '41' async def test_response_html_cls(self): res = await self.client.get('html-cls/') assert res.status_code == 200 assert res.data == '' assert res.body == b'' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'text/html; charset=utf-8' assert res.headers['Content-Length'] == '41' async def test_response_template(self) -> None: res: Response = await self.client.get('template/') assert res.status_code == 200 assert res.data == '

      Hello World

      ' assert res.body == b'

      Hello World

      ' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'text/html; charset=utf-8' assert res.headers['Content-Length'] == '44' async def test_response_template_cls(self) -> None: res: Response = await self.client.get('template-cls/') assert res.status_code == 200 assert res.data == '

      Hello World

      ' assert res.body == b'

      Hello World

      ' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'text/html; charset=utf-8' assert res.headers['Content-Length'] == '44' async def test_response_file_not_found(self): res = await self.client.get('file-not-found/') assert res.status_code == 404 assert res.data == {'detail': 'Not Found'} assert res.body == b'{"detail":"Not Found"}' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'application/json' assert res.headers['Content-Length'] == '22' async def test_response_file(self): res = await self.client.get('file/') assert res.status_code == 200 assert res.data assert res.body assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'text/markdown' if platform.system() == 'Windows': # Line breaks are \n\r assert res.headers['Content-Length'] == '4783' else: assert res.headers['Content-Length'] == '4645' async def test_response_plain(self): res = await self.client.get('plain/') assert res.status_code == 200 assert res.data == 'Hello World' assert res.body == b'Hello World' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'text/plain; charset=utf-8' assert res.headers['Content-Length'] == '11' async def test_response_plain_cls(self): res = await self.client.get('plain-cls/') assert res.status_code == 200 assert res.data == 'Hello World' assert res.body == b'Hello World' assert set(res.headers.keys()) == {'Content-Type', 'Content-Length'} assert res.headers['Content-Type'] == 'text/plain; charset=utf-8' assert res.headers['Content-Length'] == '11' async def test_streaming_response(self): res = await self.client.get('stream/') assert res.status_code == 200 assert res.headers['Content-Type'] == 'application/octet-stream' assert res.data == '01234' assert res.body == b'01234' async def test_async_streaming_response(self): res = await self.client.get('async-stream/') assert res.status_code == 200 assert res.headers['Content-Type'] == 'application/octet-stream' assert res.data == '012345' assert res.body == b'012345' async def test_full_cookie(self): res = await self.client.get('full-cookies/') assert res.status_code == 200 assert 'Set-Cookie' in res.headers assert res.headers['Set-Cookie'] == ( 'custom_key=custom_value; Domain=example.com; HttpOnly; Max-Age=100; Path=/here/; SameSite=strict; Secure' ) assert res.cookies == [ ( b'Set-Cookie', b'custom_key=custom_value; ' b'Domain=example.com; ' b'HttpOnly; ' b'Max-Age=100; ' b'Path=/here/; ' b'SameSite=strict; ' b'Secure', ) ] async def test_multiple_cookies(self): res = await self.client.get('multiple-cookies/') assert res.status_code == 200 assert 'Set-Cookie' in res.headers assert res.cookies == [ ( b'Set-Cookie', b'custom_key1=custom_value1; Domain=example.com; HttpOnly; Max-Age=100; ' b'Path=/here/; SameSite=strict; Secure', ), ( b'Set-Cookie', b'custom_key2=custom_value2; Domain=example.com; HttpOnly; Max-Age=100; ' b'Path=/here/; SameSite=strict; Secure', ), ] async def test_default_cookie(self): res = await self.client.get('default-cookies/') assert res.status_code == 200 assert 'Set-Cookie' in res.headers assert res.cookies == [(b'Set-Cookie', b'custom_key=custom_value; Path=/; SameSite=lax')] ================================================ FILE: tests/test_response_output_model.py ================================================ from pathlib import Path from typing import ClassVar from unittest import IsolatedAsyncioTestCase from pydantic import BaseModel, Field from panther import Panther from panther.configs import config from panther.db import Model from panther.db.connections import db from panther.db.models import ID from panther.response import Response class OutputModelWithAlias(BaseModel): id: ID | None = Field(None, validation_alias='_id', alias='_id') name: str class OutputModelWithPrepare(BaseModel): value: int async def to_response(self, instance, data): # Just return the data with a custom key for test return {'custom': data['value']} class User(Model): username: str password: str class UserOutputSerializer(BaseModel): id: str username: str class OutputModelWithInstanceCheck(BaseModel): value: int called_with: ClassVar = [] # class variable to record calls async def to_response(self, instance, data): # Record the arguments for assertion OutputModelWithInstanceCheck.called_with.append((instance, data)) return {'value': data['value'], 'instance_type': type(instance).__name__} class OutputModelWithInstanceCheckIterable(BaseModel): value: int called_with: ClassVar = [] async def to_response(self, instance, data): OutputModelWithInstanceCheckIterable.called_with.append((instance, data)) return {'value': data['value'], 'instance_type': type(instance).__name__} class TestResponsesOutputModel(IsolatedAsyncioTestCase): DB_PATH = 'test.pdb' @classmethod def setUpClass(cls) -> None: global DATABASE DATABASE = { 'engine': {'class': 'panther.db.connections.PantherDBConnection', 'path': cls.DB_PATH}, } Panther(__name__, configs=__name__, urls={}) def tearDown(self) -> None: db.session.collection('Book').drop() @classmethod def tearDownClass(cls): config.refresh() Path(cls.DB_PATH).unlink(missing_ok=True) async def test_dict(self): resp = Response(data={'_id': 1, 'name': 'foo'}) await resp.serialize_output(OutputModelWithAlias) assert resp.data == {'id': '1', 'name': 'foo'} async def test_iterable(self): resp = Response(data=[{'_id': 1, 'name': 'foo'}, {'_id': 2, 'name': 'bar'}]) await resp.serialize_output(OutputModelWithAlias) assert resp.data == [{'id': '1', 'name': 'foo'}, {'id': '2', 'name': 'bar'}] async def test_to_response(self): resp = Response(data={'value': 42}) await resp.serialize_output(OutputModelWithPrepare) assert resp.data == {'custom': 42} async def test_iterable_to_response(self): resp = Response(data=[{'value': 1}, {'value': 2}]) await resp.serialize_output(OutputModelWithPrepare) assert resp.data == [{'custom': 1}, {'custom': 2}] async def test_type_error(self): resp = Response(data='not a dict') with self.assertRaises(TypeError): await resp.serialize_output(OutputModelWithAlias) async def test_model(self): user = await User.insert_one(username='Ali', password='1234') resp = Response(data=user) await resp.serialize_output(UserOutputSerializer) assert resp.data == {'id': user.id, 'username': 'Ali'} async def test_to_response_instance_argument(self): OutputModelWithInstanceCheck.called_with.clear() resp = Response(data={'value': 123, 'etc': 'ok'}) await resp.serialize_output(OutputModelWithInstanceCheck) # The instance should be the original data dict assert resp.data == {'value': 123, 'instance_type': 'dict'} assert OutputModelWithInstanceCheck.called_with[0][0] == {'value': 123, 'etc': 'ok'} assert OutputModelWithInstanceCheck.called_with[0][1] == {'value': 123} async def test_iterable_to_response_instance_argument(self): OutputModelWithInstanceCheckIterable.called_with.clear() data = [{'value': 1, 'etc': 'ok'}, {'value': 2, 'etc': 'ok'}] resp = Response(data=data) await resp.serialize_output(OutputModelWithInstanceCheckIterable) # The instance should be the original list for each call assert resp.data == [ {'value': 1, 'instance_type': 'dict'}, {'value': 2, 'instance_type': 'dict'}, ] # Both calls should have the same instance (the dict) assert OutputModelWithInstanceCheckIterable.called_with[0][0] == {'value': 1, 'etc': 'ok'} assert OutputModelWithInstanceCheckIterable.called_with[1][0] == {'value': 2, 'etc': 'ok'} assert OutputModelWithInstanceCheckIterable.called_with[0][1] == {'value': 1} assert OutputModelWithInstanceCheckIterable.called_with[1][1] == {'value': 2} async def test_model_instance_to_response_argument(self): class UserOutputWithInstance(BaseModel): id: str username: str called_with: ClassVar = [] async def to_response(self, instance, data): UserOutputWithInstance.called_with.append((instance, data)) return {'id': data['id'], 'username': data['username'], 'instance_type': type(instance).__name__} UserOutputWithInstance.called_with.clear() user = await User.insert_one(username='Ali', password='1234') resp = Response(data=user) await resp.serialize_output(UserOutputWithInstance) assert resp.data['id'] == user.id assert resp.data['username'] == 'Ali' assert resp.data['instance_type'] == 'User' assert UserOutputWithInstance.called_with[0][0] is user assert UserOutputWithInstance.called_with[0][1]['id'] == user.id ================================================ FILE: tests/test_routing.py ================================================ import random from unittest import TestCase from panther.base_request import BaseRequest from panther.configs import config from panther.exceptions import PantherError from panther.routings import ( ENDPOINT_NOT_FOUND, finalize_urls, find_endpoint, flatten_urls, ) class TestRoutingFunctions(TestCase): def tearDown(self) -> None: config.refresh() @classmethod def tearDownClass(cls): config.refresh() # Collecting def test_flatten_urls_ellipsis_endpoints(self): urls1 = { 'user/': { '/': ..., }, } urls2 = { 'user/': { 'list/': ..., }, } try: flatten_urls(urls1) except PantherError as exc: assert exc.args[0] == "URL Can't Point To Ellipsis. ('user//' -> ...)" else: assert False try: flatten_urls(urls2) except PantherError as exc: assert exc.args[0] == "URL Can't Point To Ellipsis. ('user/list/' -> ...)" else: assert False def test_flatten_urls_none_endpoints(self): urls1 = { 'user/': { 'list/': None, }, } urls2 = { 'user/': { '/': None, }, } try: flatten_urls(urls1) except PantherError as exc: assert exc.args[0] == "URL Can't Point To None. ('user/list/' -> None)" else: assert False try: flatten_urls(urls2) except PantherError as exc: assert exc.args[0] == "URL Can't Point To None. ('user//' -> None)" else: assert False def test_flatten_urls_invalid_url(self): def temp_func(): pass urls1 = { 'user/': { '?': temp_func, }, } urls2 = { 'user/': { 'لیست': temp_func, }, } try: flatten_urls(urls1) except PantherError as exc: assert exc.args[0] == "URL Is Not Valid. --> 'user/?/'" else: assert False try: flatten_urls(urls2) except PantherError as exc: assert exc.args[0] == "URL Is Not Valid. --> 'user/لیست/'" else: assert False def test_flatten_urls_empty_url(self): def temp_func(): pass urls = { '': temp_func, } collected_urls = flatten_urls(urls) expected_result = { '': temp_func, } assert collected_urls == expected_result def test_flatten_urls_nested_empty_urls(self): def temp_func(): pass urls = { '': { '': { 'nested//': temp_func, 'nested/profile/': temp_func, 'nested/list/': temp_func, }, '/': temp_func, 'profile/': temp_func, 'list/': temp_func, }, } collected_urls = flatten_urls(urls) expected_result = { '/': temp_func, 'profile/': temp_func, 'list/': temp_func, 'nested//': temp_func, 'nested/profile/': temp_func, 'nested/list/': temp_func, } assert collected_urls == expected_result def test_flatten_urls_slash_url(self): def temp_func(): pass urls = { '/': temp_func, } collected_urls = flatten_urls(urls) expected_result = { '/': temp_func, } assert collected_urls == expected_result def test_flatten_urls_nested_slash_urls(self): def temp_func(): pass urls = { '/': { '/': { 'nested//': temp_func, 'nested/profile/': temp_func, 'nested/list/': temp_func, }, '/': temp_func, 'profile/': temp_func, 'list/': temp_func, }, } collected_urls = flatten_urls(urls) expected_result = { '//': temp_func, '/profile/': temp_func, '/list/': temp_func, '//nested//': temp_func, '//nested/profile/': temp_func, '//nested/list/': temp_func, } assert collected_urls == expected_result def test_flatten_urls_simple_urls(self): def temp_func(): pass urls = { '/': temp_func, 'profile/': temp_func, 'list/': temp_func, } collected_urls = flatten_urls(urls) expected_result = { '/': temp_func, 'profile/': temp_func, 'list/': temp_func, } assert collected_urls == expected_result def test_flatten_urls_simple_nested_urls(self): def temp_func(): pass urls = { 'user/': { '/': temp_func, 'profile/': temp_func, 'list/': temp_func, }, } collected_urls = flatten_urls(urls) expected_result = { 'user//': temp_func, 'user/profile/': temp_func, 'user/list/': temp_func, } assert collected_urls == expected_result def test_flatten_urls_simple_nested_urls_without_slash_at_end(self): def temp_func(): pass urls = { 'user': { '': temp_func, 'profile': temp_func, 'list': temp_func, }, } collected_urls = flatten_urls(urls) expected_result = { 'user//': temp_func, 'user/profile/': temp_func, 'user/list/': temp_func, } assert collected_urls == expected_result def test_flatten_urls_complex_nested_urls(self): def temp_func(): pass urls = { 'user/': { '/profile/': temp_func, 'profile/': temp_func, 'list/': temp_func, }, '': { 'payments': temp_func, 'notifications': temp_func, }, 'admin/v1': { 'profile/avatar': temp_func, '': temp_func, 'users/': { 'list': { 'registered': temp_func, 'not-registered': temp_func, }, 'detail': { 'registered': temp_func, 'not-registered': temp_func, }, }, }, 'admin/v2': {}, } collected_urls = flatten_urls(urls) expected_result = { 'user//profile//': temp_func, 'user/profile/': temp_func, 'user/list/': temp_func, 'payments/': temp_func, 'notifications/': temp_func, 'admin/v1/profile/avatar/': temp_func, 'admin/v1//': temp_func, 'admin/v1/users/list/registered/': temp_func, 'admin/v1/users/list/not-registered/': temp_func, 'admin/v1/users/detail/registered/': temp_func, 'admin/v1/users/detail/not-registered/': temp_func, 'admin/v2/': {}, } assert collected_urls == expected_result # Finalize def test_finalize_empty_url(self): def temp_func(): pass urls = { '': temp_func, } collected_urls = flatten_urls(urls) finalized_urls = finalize_urls(collected_urls) expected_result = { '': temp_func, } assert finalized_urls == expected_result def test_finalize_empty_url_nested(self): def temp_func(): pass urls = { '': { '': { 'nested//': temp_func, 'nested/profile/': temp_func, 'nested/list/': temp_func, }, '/': temp_func, 'profile/': temp_func, 'list/': temp_func, }, } collected_urls = flatten_urls(urls) finalized_urls = finalize_urls(collected_urls) expected_result = { 'nested': { '': temp_func, 'profile': temp_func, 'list': temp_func, }, '': temp_func, 'profile': temp_func, 'list': temp_func, } assert finalized_urls == expected_result def test_finalize_root_url(self): def temp_func(): pass urls = { '/': temp_func, } collected_urls = flatten_urls(urls) finalized_urls = finalize_urls(collected_urls) expected_result = { '': temp_func, } assert finalized_urls == expected_result def test_finalize_root_url_nested(self): def temp_func(): pass urls = { '/': { '/': { 'nested//': temp_func, 'nested/profile/': temp_func, 'nested/list/': temp_func, }, '/': temp_func, 'profile/': temp_func, 'list/': temp_func, }, } collected_urls = flatten_urls(urls) finalized_urls = finalize_urls(collected_urls) expected_result = { 'nested': { '': temp_func, 'profile': temp_func, 'list': temp_func, }, '': temp_func, 'profile': temp_func, 'list': temp_func, } assert finalized_urls == expected_result def test_finalize_root_and_empty_url_nested(self): def temp_func(): pass urls = { '/': { '': { 'nested//': temp_func, 'nested/profile/': temp_func, 'nested/list/': temp_func, }, '/': temp_func, 'profile/': temp_func, 'list/': temp_func, }, } collected_urls = flatten_urls(urls) finalized_urls = finalize_urls(collected_urls) expected_result = { 'nested': { '': temp_func, 'profile': temp_func, 'list': temp_func, }, '': temp_func, 'profile': temp_func, 'list': temp_func, } assert finalized_urls == expected_result def test_finalize_empty_and_root_url_nested(self): def temp_func(): pass urls = { '': { '/': { 'nested//': temp_func, 'nested/profile/': temp_func, 'nested/list/': temp_func, }, '': temp_func, 'profile/': temp_func, 'list/': temp_func, }, } collected_urls = flatten_urls(urls) finalized_urls = finalize_urls(collected_urls) expected_result = { 'nested': { '': temp_func, 'profile': temp_func, 'list': temp_func, }, '': temp_func, 'profile': temp_func, 'list': temp_func, } assert finalized_urls == expected_result def test_finalize_urls(self): def temp_func(): pass urls = { 'user/': { '/profile/': temp_func, 'profile/': temp_func, 'list/': temp_func, }, '': { 'payments': temp_func, 'notifications': temp_func, }, 'admin/v1': { 'profile/avatar': temp_func, '': temp_func, 'users/': { 'list': { 'registered': temp_func, 'not-registered': temp_func, }, 'detail': { 'registered': temp_func, 'not-registered': temp_func, }, }, }, 'admin/v2': {}, } collected_urls = flatten_urls(urls) finalized_urls = finalize_urls(collected_urls) expected_result = { 'user': { '': { 'profile': { '': temp_func, }, }, 'profile': temp_func, 'list': temp_func, }, 'payments': temp_func, 'notifications': temp_func, 'admin': { 'v1': { 'profile': { 'avatar': temp_func, }, '': temp_func, 'users': { 'list': { 'registered': temp_func, 'not-registered': temp_func, }, 'detail': { 'registered': temp_func, 'not-registered': temp_func, }, }, }, 'v2': {}, }, } assert finalized_urls == expected_result def test_finalize_urls_same_pre_path_variable(self): def temp_func(): pass urls = { '': temp_func, '/': temp_func, '//': temp_func, } collected_urls = flatten_urls(urls) finalized_urls = finalize_urls(collected_urls) expected_result = { '': temp_func, '': { '': temp_func, '': temp_func, }, } assert finalized_urls == expected_result def test_finalize_urls_with_same_level_path_variables(self): def temp_func(): pass urls1 = { 'user': { '/': temp_func, '/': temp_func, }, } urls2 = { 'user': { '/': {'detail': temp_func}, '/': temp_func, '/': {'detail': temp_func}, '/': {'detail': temp_func}, }, } try: finalize_urls(flatten_urls(urls1)) except PantherError as exc: assert exc.args[0] == ( "URLs can't have same-level path variables that point to an endpoint: " '\n\t- /user/' '\n\t- /user/' ) else: assert False try: finalize_urls(flatten_urls(urls2)) except PantherError as exc: assert exc.args[0] == ( "URLs can't have same-level path variables that point to a dict: " '\n\t- /user/' '\n\t- /user/' '\n\t- /user/' ) else: assert False # Find Endpoint def test_find_endpoint_root_url(self): def temp_func(): pass from panther.configs import config config.URLS = { '': temp_func, } _func, _ = find_endpoint('') assert _func == temp_func def test_find_endpoint_success(self): def user_id_profile_id(): pass def user_profile(): pass def payment(): pass def admin_v1_profile_avatar(): pass def admin_v1_id(): pass def admin_v2_users_list_registered(): pass def admin_v2_users_detail_not_registered(): pass from panther.configs import config config.URLS = { 'user': { '': { 'profile': { '': user_id_profile_id, }, }, 'profile': user_profile, 'list': ..., }, 'payments': payment, 'notifications': ..., 'admin': { 'v1': { 'profile': { 'avatar': admin_v1_profile_avatar, }, '': admin_v1_id, 'users': { 'list': { 'registered': admin_v2_users_list_registered, 'not-registered': ..., }, 'detail': { 'registered': ..., 'not-registered': admin_v2_users_detail_not_registered, }, }, }, }, } user_id_profile_id_func, _ = find_endpoint(f'user/{random.randint(0, 100)}/profile/{random.randint(2, 100)}') user_profile_func, _ = find_endpoint('user/profile/') payment_func, _ = find_endpoint('payments/') admin_v1_profile_avatar_func, _ = find_endpoint('admin/v1/profile/avatar') admin_v1_id_func, _ = find_endpoint(f'admin/v1/{random.randint(0, 100)}') admin_v2_users_list_registered_func, _ = find_endpoint('admin/v1/users/list/registered/') admin_v2_users_detail_not_registered_func, _ = find_endpoint('admin/v1/users/detail/not-registered') assert user_id_profile_id_func == user_id_profile_id assert user_profile_func == user_profile assert payment_func == payment assert admin_v1_profile_avatar_func == admin_v1_profile_avatar assert admin_v1_id_func == admin_v1_id assert admin_v2_users_list_registered_func == admin_v2_users_list_registered assert admin_v2_users_detail_not_registered_func == admin_v2_users_detail_not_registered def test_find_endpoint_success_path(self): def user_id_profile_id(): pass def user_profile(): pass def payment(): pass def admin_v1_profile_avatar(): pass def admin_v1_id(): pass def admin_v2_users_list_registered(): pass def admin_v2_users_detail_not_registered(): pass from panther.configs import config config.URLS = { 'user': { '': { 'profile': { '': user_id_profile_id, }, }, 'profile': user_profile, 'list': ..., }, 'payments': payment, 'notifications': ..., 'admin': { 'v1': { 'profile': { 'avatar': admin_v1_profile_avatar, }, '': admin_v1_id, '': { 'list': { '': {}, '': admin_v2_users_list_registered, 'registered': admin_v2_users_list_registered, }, }, 'users': { 'list': { 'registered': admin_v2_users_list_registered, 'not-registered': ..., }, 'detail': { 'registered': ..., 'not-registered': admin_v2_users_detail_not_registered, }, }, }, }, } _, user_id_profile_id_path = find_endpoint(f'user/{random.randint(0, 100)}/profile/{random.randint(2, 100)}') _, user_profile_path = find_endpoint('user/profile/') _, payment_path = find_endpoint('payments/') _, admin_v1_profile_avatar_path = find_endpoint('admin/v1/profile/avatar') _, admin_v1_id_path = find_endpoint(f'admin/v1/{random.randint(0, 100)}') _, admin_v1_id_registered_path = find_endpoint(f'admin/v1/{random.randint(0, 100)}/list/registered') _, admin_v1_id_registered1_path = find_endpoint(f'admin/v1/{random.randint(0, 100)}/list/1/') _, admin_v2_users_list_registered_path = find_endpoint('admin/v1/users/list/registered/') _, admin_v2_users_detail_not_registered_path = find_endpoint('admin/v1/users/detail/not-registered') assert user_id_profile_id_path == 'user//profile/' assert user_profile_path == 'user/profile' assert payment_path == 'payments' assert admin_v1_profile_avatar_path == 'admin/v1/profile/avatar' assert admin_v1_id_path == 'admin/v1/' assert admin_v1_id_registered_path == 'admin/v1//list/registered' assert admin_v1_id_registered1_path == 'admin/v1//list/' assert admin_v2_users_list_registered_path == 'admin/v1/users/list/registered' assert admin_v2_users_detail_not_registered_path == 'admin/v1/users/detail/not-registered' def test_find_endpoint_not_found(self): def temp_func(): pass from panther.configs import config config.URLS = { 'user': { 'list': temp_func, }, } user_id_profile_id_func, _ = find_endpoint(f'user/{random.randint(0, 100)}/profile/{random.randint(2, 100)}') user_profile_func, _ = find_endpoint('user/profile/') payment_func, _ = find_endpoint('payments/') admin_v1_profile_avatar_func, _ = find_endpoint('admin/v1/profile/avatar') admin_v1_id_func, _ = find_endpoint(f'admin/v1/{random.randint(0, 100)}') admin_v2_users_list_registered_func, _ = find_endpoint('admin/v1/users/list/registered/') admin_v2_users_detail_not_registered_func, _ = find_endpoint('admin/v1/users/detail/not-registered') assert user_id_profile_id_func is None assert user_profile_func is None assert payment_func is None assert admin_v1_profile_avatar_func is None assert admin_v1_id_func is None assert admin_v2_users_list_registered_func is None assert admin_v2_users_detail_not_registered_func is None def test_find_endpoint_not_found_path(self): def temp_func(): pass from panther.configs import config config.URLS = { 'user': { 'list': temp_func, }, } _, user_id_profile_id_path = find_endpoint(f'user/{random.randint(0, 100)}/profile/{random.randint(2, 100)}') _, user_profile_path = find_endpoint('user/profile/') _, payment_path = find_endpoint('payments/') _, admin_v1_profile_avatar_path = find_endpoint('admin/v1/profile/avatar') _, admin_v1_id_path = find_endpoint(f'admin/v1/{random.randint(0, 100)}') _, admin_v2_users_list_registered_path = find_endpoint('admin/v1/users/list/registered/') _, admin_v2_users_detail_not_registered_path = find_endpoint('admin/v1/users/detail/not-registered') assert user_id_profile_id_path == '' assert user_profile_path == '' assert payment_path == '' assert admin_v1_profile_avatar_path == '' assert admin_v1_id_path == '' assert admin_v2_users_list_registered_path == '' assert admin_v2_users_detail_not_registered_path == '' def test_find_endpoint_not_found_last_is_path_variable(self): def temp_func(): pass from panther.configs import config config.URLS = { 'user': { '': temp_func, }, } user_id_profile_id_func, _ = find_endpoint(f'user/{random.randint(0, 100)}/profile/{random.randint(2, 100)}') user_profile_func, _ = find_endpoint('user/ali/') payment_func, _ = find_endpoint('payments/') admin_v1_profile_avatar_func, _ = find_endpoint('admin/v1/profile/avatar') admin_v1_id_func, _ = find_endpoint(f'admin/v1/{random.randint(0, 100)}') admin_v2_users_list_registered_func, _ = find_endpoint('admin/v1/users/list/registered/') admin_v2_users_detail_not_registered_func, _ = find_endpoint('admin/v1/users/detail/not-registered') assert user_id_profile_id_func is None assert user_profile_func is not None assert payment_func is None assert admin_v1_profile_avatar_func is None assert admin_v1_id_func is None assert admin_v2_users_list_registered_func is None assert admin_v2_users_detail_not_registered_func is None def test_find_endpoint_not_found_path_last_is_path_variable(self): def temp_func(): pass from panther.configs import config config.URLS = { 'user': { '': temp_func, }, } _, user_id_profile_id_path = find_endpoint(f'user/{random.randint(0, 100)}/profile/{random.randint(2, 100)}') _, user_profile_path = find_endpoint('user/ali/') _, payment_path = find_endpoint('payments/') _, admin_v1_profile_avatar_path = find_endpoint('admin/v1/profile/avatar') _, admin_v1_id_path = find_endpoint(f'admin/v1/{random.randint(0, 100)}') _, admin_v2_users_list_registered_path = find_endpoint('admin/v1/users/list/registered/') _, admin_v2_users_detail_not_registered_path = find_endpoint('admin/v1/users/detail/not-registered') assert user_id_profile_id_path == '' assert user_profile_path != '' assert payment_path == '' assert admin_v1_profile_avatar_path == '' assert admin_v1_id_path == '' assert admin_v2_users_list_registered_path == '' assert admin_v2_users_detail_not_registered_path == '' def test_find_endpoint_not_found_too_many(self): def temp_func(): pass from panther.configs import config config.URLS = { 'user/name': temp_func, } func, path = find_endpoint('user/name/troublemaker') assert path == '' assert func is None def test_find_endpoint_not_found_not_enough(self): def temp_func(): pass from panther.configs import config config.URLS = { 'user/name': temp_func, } func, path = find_endpoint('user/') assert path == '' assert func is None def test_find_endpoint_same_pre_path_variable(self): def temp_1(): pass def temp_2(): pass def temp_3(): pass from panther.configs import config config.URLS = { '': temp_1, '': { '': temp_2, '': temp_3, }, } temp_1_func, _ = find_endpoint('') temp_2_func, _ = find_endpoint(f'{random.randint(2, 100)}') temp_3_func, _ = find_endpoint(f'{random.randint(2, 100)}/{random.randint(2, 100)}') assert temp_1_func == temp_1 assert temp_2_func == temp_2 assert temp_3_func == temp_3 def test_find_endpoint_same_pre_path_variable_path(self): def temp_1(): pass def temp_2(): pass def temp_3(): pass from panther.configs import config config.URLS = { '': temp_1, '': { '': temp_2, '': temp_3, }, } _, temp_1_path = find_endpoint('') _, temp_2_path = find_endpoint(f'{random.randint(2, 100)}') _, temp_3_path = find_endpoint(f'{random.randint(2, 100)}/{random.randint(2, 100)}') assert temp_1_path == '' assert temp_2_path == '' assert temp_3_path == '/' def test_find_endpoint_same_pre_key(self): def temp_1(): pass def temp_2(): pass def temp_3(): pass from panther.configs import config config.URLS = { '': temp_1, 'hello': { '': temp_2, '': temp_3, }, } temp_1_func, _ = find_endpoint('') temp_2_func, _ = find_endpoint('hello') temp_3_func, _ = find_endpoint(f'hello/{random.randint(2, 100)}') assert temp_1_func == temp_1 assert temp_2_func == temp_2 assert temp_3_func == temp_3 def test_find_endpoint_same_pre_key_path(self): def temp_1(): pass def temp_2(): pass def temp_3(): pass from panther.configs import config config.URLS = { '': temp_1, 'hello': { '': temp_2, '': temp_3, }, } _, temp_1_path = find_endpoint('') _, temp_2_path = find_endpoint('hello') _, temp_3_path = find_endpoint(f'hello/{random.randint(2, 100)}') assert temp_1_path == '' assert temp_2_path == 'hello' assert temp_3_path == 'hello/' def test_find_endpoint_with_params(self): def user_id_profile_id(): pass from panther.configs import config config.URLS = { 'user': { '': { 'profile': user_id_profile_id, }, }, } user_id_profile_id_func, _ = find_endpoint(f'user/{random.randint(0, 100)}/profile?name=ali') assert user_id_profile_id_func == user_id_profile_id # Collect PathVariables def test_collect_path_variables(self): def temp_func(): pass from panther.configs import config config.URLS = { 'user': { '': { 'profile': { '': temp_func, }, }, }, } _user_id = random.randint(0, 100) _id = random.randint(0, 100) request_path = f'user/{_user_id}/profile/{_id}' _, found_path = find_endpoint(request_path) request = BaseRequest(scope={'path': request_path}, receive=lambda x: x, send=lambda x: x) request.collect_path_variables(found_path=found_path) path_variables = request.path_variables assert isinstance(path_variables, dict) assert 'user_id' in path_variables assert 'id' in path_variables assert path_variables['user_id'] == str(_user_id) assert path_variables['id'] == str(_id) # Complete test ready for benchmarking def test_find_endpoint_complete(self): def _(): pass def _3(): pass def _1(): pass def _4(): pass def _159(): pass def _16(): pass def _1710(): pass def _18(): pass def _19(): pass def _211(): pass def _2121516(): pass def _2141718(): pass def _220(): pass def _22122(): pass from panther.configs import config config.URLS = { '': _, '0': {'21': {}}, '1': { '': _1, '<5>': {'<9>': _159}, '6': _16, '7': {'10': _1710}, '<8>': _18, }, '<2>': { '11': _211, '12': {'15': {'16': _2121516}}, '<14>': {'<17>': {'<18>': _2141718}}, '19': {'': _19}, '<20>': _220, '21': {'<22>': {'': _22122}}, }, '3': _3, '<4>': _4, } test_cases = { '': (_, ''), '0': ENDPOINT_NOT_FOUND, '0/21': ENDPOINT_NOT_FOUND, '3': (_3, '3'), '_4': (_4, '<4>'), '1': (_1, '1'), '1/_5/_9': (_159, '1/<5>/<9>'), '1/6': (_16, '1/6'), '1/7/10': (_1710, '1/7/10'), '1/7': ENDPOINT_NOT_FOUND, '1/_8': (_18, '1/<8>'), '_2/11': (_211, '<2>/11'), '_2/12/15/16': (_2121516, '<2>/12/15/16'), '_2/_14/_17/_18': (_2141718, '<2>/<14>/<17>/<18>'), '_2/19': (_19, '<2>/19'), '_2/_20': (_220, '<2>/<20>'), '_2/21/_22': (_22122, '<2>/21/<22>'), # # # '/': (_, ''), '/0/': ENDPOINT_NOT_FOUND, '/0/21/': ENDPOINT_NOT_FOUND, '/3/': (_3, '3'), '/_4/': (_4, '<4>'), '/1/': (_1, '1'), '/1/_5/_9/': (_159, '1/<5>/<9>'), '/1/6/': (_16, '1/6'), '/1/7/10/': (_1710, '1/7/10'), '/1/7/': ENDPOINT_NOT_FOUND, '/1/_8/': (_18, '1/<8>'), '/_2/11/': (_211, '<2>/11'), '/_2/12/15/16/': (_2121516, '<2>/12/15/16'), '/_2/_14/_17/_18/': (_2141718, '<2>/<14>/<17>/<18>'), '/_2/_14/_17/': ENDPOINT_NOT_FOUND, '/_2/19/': (_19, '<2>/19'), '/_2/_20/': (_220, '<2>/<20>'), '/_2/21/_22/': (_22122, '<2>/21/<22>'), } for test_url, expected in test_cases.items(): actual = find_endpoint(path=test_url) assert actual == expected ================================================ FILE: tests/test_run.py ================================================ import sys from datetime import timedelta from pathlib import Path from unittest import TestCase import tests.sample_project.app.models from panther import Panther from panther.configs import Config, config class TestRun(TestCase): @classmethod def setUpClass(cls) -> None: sys.path.append('tests/sample_project') @classmethod def tearDownClass(cls) -> None: config.refresh() sys.path.pop() def tearDown(self) -> None: config.refresh() def test_init(self): app = Panther(__name__) assert isinstance(app, Panther) def test_load_configs(self): base_dir = Path(__name__).resolve().parent secret_key = 'fHrIYx3yK0J_UG0K0zD6miLPNy1esoYXzVsvif6e7rY=' Panther(__name__) assert isinstance(config, Config) assert base_dir == config.BASE_DIR assert config.LOG_QUERIES is True assert config.THROTTLING.rate == 10 assert config.THROTTLING.duration == timedelta(seconds=10) assert secret_key == config.SECRET_KEY assert len(config.HTTP_MIDDLEWARES) == 1 assert len(config.WS_MIDDLEWARES) == 0 assert config.QUERY_ENGINE.__name__ == 'BasePantherDBQuery' assert config.USER_MODEL.__name__ == tests.sample_project.app.models.User.__name__ assert config.USER_MODEL.__module__.endswith('app.models') assert config.JWT_CONFIG.algorithm == 'HS256' assert config.JWT_CONFIG.life_time == int(timedelta(days=2).total_seconds()) assert config.JWT_CONFIG.key == secret_key assert '' in config.URLS config.URLS.pop('') assert 'second' in config.URLS config.URLS.pop('second') assert config.URLS == {} ================================================ FILE: tests/test_serializer.py ================================================ from pathlib import Path from unittest import IsolatedAsyncioTestCase from pydantic import ConfigDict, Field, field_validator from panther import Panther from panther.app import API from panther.configs import config from panther.db import Model from panther.db.connections import db from panther.request import Request from panther.serializer import ModelSerializer from panther.test import APIClient DB_PATH = 'test_db.pdb' DATABASE = { 'engine': {'class': 'panther.db.connections.PantherDBConnection', 'path': DB_PATH}, } class Book(Model): name: str author: str = Field('default_author') pages_count: int = Field(0) class Author(Model): name: str book: Book class NotRequiredFieldsSerializer(ModelSerializer): class Config: model = Book fields = ['author', 'pages_count'] class RequiredFieldsSerializer(ModelSerializer): class Config: model = Book fields = ['name', 'author', 'pages_count'] class OnlyRequiredFieldsSerializer(ModelSerializer): class Config: model = Book fields = ['name', 'author', 'pages_count'] required_fields = ['author', 'pages_count'] class WithValidatorsSerializer(ModelSerializer): class Config: model = Book fields = ['name', 'author', 'pages_count'] required_fields = ['author', 'pages_count'] @field_validator('name', 'author', 'pages_count') def validate_other(cls, field): return 'validated' @field_validator('pages_count') def validate_pages_count(cls, field): return 100 class WithClassFieldsSerializer(ModelSerializer): age: int = Field(10) class Config: model = Book fields = ['name', 'author', 'pages_count'] required_fields = ['author', 'pages_count'] class WithForeignKeySerializer(ModelSerializer): class Config: model = Author fields = ['name', 'book'] @API(input_model=NotRequiredFieldsSerializer) async def not_required(request: Request): return request.validated_data @API(input_model=RequiredFieldsSerializer) async def required(request: Request): return request.validated_data @API(input_model=OnlyRequiredFieldsSerializer) async def only_required(request: Request): return request.validated_data @API(input_model=WithValidatorsSerializer) async def with_validators(request: Request): return request.validated_data @API(input_model=WithClassFieldsSerializer) async def with_class_fields(request: Request): return request.validated_data @API(input_model=WithForeignKeySerializer) async def with_foreign_key(request: Request): return request.validated_data urls = { 'not-required': not_required, 'required': required, 'only-required': only_required, 'with-validators': with_validators, 'class-fields': with_class_fields, 'foreign-key': with_foreign_key, } class TestModelSerializer(IsolatedAsyncioTestCase): @classmethod def setUpClass(cls) -> None: app = Panther(__name__, configs=__name__, urls=urls) cls.client = APIClient(app=app) def tearDown(self) -> None: db.session.collection('Book').drop() db.session.collection('Author').drop() @classmethod def tearDownClass(cls) -> None: config.refresh() Path(DB_PATH).unlink(missing_ok=True) # # # Class Usage async def test_not_required_fields_empty_response(self): payload = {} res = await self.client.post('not-required', payload=payload) assert res.status_code == 200 assert res.data == {'author': 'default_author', 'pages_count': 0} async def test_not_required_fields_full_response(self): payload = {'author': 'ali', 'pages_count': '12'} res = await self.client.post('not-required', payload=payload) assert res.status_code == 200 assert res.data == {'author': 'ali', 'pages_count': 12} async def test_required_fields_error(self): payload = {} res = await self.client.post('required', payload=payload) assert res.status_code == 400 assert res.data == {'name': 'Field required'} async def test_required_fields_success(self): payload = {'name': 'how to code', 'author': 'ali', 'pages_count': '12'} res = await self.client.post('required', payload=payload) assert res.status_code == 200 assert res.data == {'name': 'how to code', 'author': 'ali', 'pages_count': 12} async def test_only_required_fields_error(self): payload = {} res = await self.client.post('only-required', payload=payload) assert res.status_code == 400 assert res.data == {'name': 'Field required', 'author': 'Field required', 'pages_count': 'Field required'} async def test_only_required_fields_success(self): payload = {'name': 'how to code', 'author': 'ali', 'pages_count': '12'} res = await self.client.post('only-required', payload=payload) assert res.status_code == 200 assert res.data == {'name': 'how to code', 'author': 'ali', 'pages_count': 12} async def test_with_validators(self): payload = {'name': 'how to code', 'author': 'ali', 'pages_count': '12'} res = await self.client.post('with-validators', payload=payload) assert res.status_code == 200 assert res.data == {'name': 'validated', 'author': 'validated', 'pages_count': 100} async def test_with_class_fields_success(self): # Test Default Value payload1 = {'name': 'how to code', 'author': 'ali', 'pages_count': '12'} res = await self.client.post('class-fields', payload=payload1) assert res.status_code == 200 assert res.data == {'name': 'how to code', 'author': 'ali', 'pages_count': 12, 'age': 10} # Test Validation payload2 = {'name': 'how to code', 'author': 'ali', 'pages_count': '12', 'age': 30} res = await self.client.post('class-fields', payload=payload2) assert res.status_code == 200 assert res.data == {'name': 'how to code', 'author': 'ali', 'pages_count': 12, 'age': 30} async def test_foreign_key(self): book = await Book.insert_one(name='Book1', author='Ali', pages_count=2) await Author.insert_one(name='AnotherAli', book=book) payload = {'name': 'Something', 'book': book.id} res = await self.client.post('foreign-key', payload=payload) assert res.status_code == 200 assert res.data == { 'name': 'Something', 'book': {'id': book.id, 'name': 'Book1', 'author': 'Ali', 'pages_count': 2}, } # # # Class Definition async def test_define_class_without_meta(self): try: class Serializer0(ModelSerializer): pass except Exception as e: assert isinstance(e, AttributeError) assert e.args[0] == '`class Config` is required in tests.test_serializer.Serializer0.' else: assert False async def test_define_class_without_model(self): try: class Serializer1(ModelSerializer): class Config: pass except Exception as e: assert isinstance(e, AttributeError) assert e.args[0] == '`Serializer1.Config.model` is required.' else: assert False async def test_define_class_without_fields(self): try: class Serializer2(ModelSerializer): class Config: model = Book except Exception as e: assert isinstance(e, AttributeError) assert e.args[0] == '`Serializer2.Config.fields` is required.' else: assert False async def test_define_class_with_invalid_fields(self): try: class Serializer3(ModelSerializer): class Config: model = Book fields = ['ok', 'no'] except Exception as e: assert isinstance(e, AttributeError) assert e.args[0] == '`Serializer3.Config.fields.ok` is not in `Book.model_fields`' else: assert False async def test_define_class_with_invalid_required_fields(self): try: class Serializer4(ModelSerializer): class Config: model = Book fields = ['name', 'author'] required_fields = ['pages_count'] except Exception as e: assert isinstance(e, AttributeError) assert e.args[0] == '`Serializer4.Config.required_fields.pages_count` is not defined in `Config.fields`.' else: assert False async def test_define_class_with_invalid_model(self): try: class Serializer5(ModelSerializer): class Config: model = ModelSerializer fields = ['name', 'author', 'pages_count'] except Exception as e: assert isinstance(e, AttributeError) assert ( e.args[0] == '`Serializer5.Config.model` is not subclass of `panther.db.Model` or `pydantic.BaseModel`.' ) else: assert False async def test_define_class_with_invalid_exclude_1(self): try: class Serializer6(ModelSerializer): class Config: model = Book fields = ['name', 'author', 'pages_count'] exclude = ['not_found'] except Exception as e: assert isinstance(e, AttributeError) assert e.args[0] == '`Serializer6.Config.exclude.not_found` is not valid.' else: assert False async def test_define_class_with_invalid_exclude_2(self): try: class Serializer7(ModelSerializer): class Config: model = Book fields = ['name', 'pages_count'] exclude = ['author'] except Exception as e: assert isinstance(e, AttributeError) assert e.args[0] == '`Serializer7.Config.exclude.author` is not defined in `Config.fields`.' else: assert False async def test_with_star_fields_with_exclude3(self): try: class Serializer8(ModelSerializer): class Config: model = Book fields = ['*'] exclude = ['author'] except Exception as e: assert isinstance(e, AttributeError) assert e.args[0] == "`Serializer8.Config.fields.*` is not valid. Did you mean `fields = '*'`" else: assert False # # # Serializer Usage async def test_with_simple_model_config(self): class Serializer(ModelSerializer): model_config = ConfigDict(str_to_upper=True) class Config: model = Book fields = ['name', 'author', 'pages_count'] serialized = Serializer(name='book', author='AliRn', pages_count='12') assert serialized.name == 'BOOK' assert serialized.author == 'ALIRN' assert serialized.pages_count == 12 async def test_with_inner_model_config(self): class Serializer(ModelSerializer): class Config: str_to_upper = True model = Book fields = ['name', 'author', 'pages_count'] serialized = Serializer(name='book', author='AliRn', pages_count='12') assert serialized.name == 'BOOK' assert serialized.author == 'ALIRN' assert serialized.pages_count == 12 async def test_with_dual_model_config(self): class Serializer(ModelSerializer): model_config = ConfigDict(str_to_upper=False) class Config: str_to_upper = True model = Book fields = ['name', 'author', 'pages_count'] serialized = Serializer(name='book', author='AliRn', pages_count='12') assert serialized.name == 'book' assert serialized.author == 'AliRn' assert serialized.pages_count == 12 async def test_serializer_doc(self): class Serializer1(ModelSerializer): """Hello I'm Doc""" class Config: model = Book fields = ['name', 'author', 'pages_count'] serialized = Serializer1(name='book', author='AliRn', pages_count='12') assert serialized.__doc__ == "Hello I'm Doc" class Serializer2(ModelSerializer): class Config: model = Book fields = ['name', 'author', 'pages_count'] serialized = Serializer2(name='book', author='AliRn', pages_count='12') assert serialized.__doc__ is None async def test_with_exclude(self): class Serializer(ModelSerializer): class Config: model = Book fields = ['name', 'author', 'pages_count'] exclude = ['author'] serialized = Serializer(name='book', author='AliRn', pages_count='12') assert set(serialized.model_dump().keys()) == {'name', 'pages_count'} assert serialized.name == 'book' assert serialized.pages_count == 12 async def test_with_star_fields(self): class Serializer(ModelSerializer): class Config: model = Book fields = '*' serialized = Serializer(name='book', author='AliRn', pages_count='12') assert set(serialized.model_dump().keys()) == {'id', 'name', 'author', 'pages_count'} assert serialized.name == 'book' assert serialized.author == 'AliRn' assert serialized.pages_count == 12 async def test_with_star_fields_with_exclude(self): class Serializer(ModelSerializer): class Config: model = Book fields = '*' exclude = ['author'] serialized = Serializer(name='book', author='AliRn', pages_count='12') assert set(serialized.model_dump().keys()) == {'id', 'name', 'pages_count'} assert serialized.name == 'book' assert serialized.pages_count == 12 ================================================ FILE: tests/test_status.py ================================================ from unittest import TestCase from panther import status class TestStatus(TestCase): @classmethod def setUpClass(cls): cls.statuses = { 'HTTP_100_CONTINUE': 100, 'HTTP_101_SWITCHING_PROTOCOLS': 101, 'HTTP_102_PROCESSING': 102, 'HTTP_103_EARLY_HINTS': 103, 'HTTP_200_OK': 200, 'HTTP_201_CREATED': 201, 'HTTP_202_ACCEPTED': 202, 'HTTP_203_NON_AUTHORITATIVE_INFORMATION': 203, 'HTTP_204_NO_CONTENT': 204, 'HTTP_205_RESET_CONTENT': 205, 'HTTP_206_PARTIAL_CONTENT': 206, 'HTTP_207_MULTI_STATUS': 207, 'HTTP_208_ALREADY_REPORTED': 208, 'HTTP_226_IM_USED': 226, 'HTTP_300_MULTIPLE_CHOICES': 300, 'HTTP_301_MOVED_PERMANENTLY': 301, 'HTTP_302_FOUND': 302, 'HTTP_303_SEE_OTHER': 303, 'HTTP_304_NOT_MODIFIED': 304, 'HTTP_305_USE_PROXY': 305, 'HTTP_306_RESERVED': 306, 'HTTP_307_TEMPORARY_REDIRECT': 307, 'HTTP_308_PERMANENT_REDIRECT': 308, 'HTTP_400_BAD_REQUEST': 400, 'HTTP_401_UNAUTHORIZED': 401, 'HTTP_402_PAYMENT_REQUIRED': 402, 'HTTP_403_FORBIDDEN': 403, 'HTTP_404_NOT_FOUND': 404, 'HTTP_405_METHOD_NOT_ALLOWED': 405, 'HTTP_406_NOT_ACCEPTABLE': 406, 'HTTP_407_PROXY_AUTHENTICATION_REQUIRED': 407, 'HTTP_408_REQUEST_TIMEOUT': 408, 'HTTP_409_CONFLICT': 409, 'HTTP_410_GONE': 410, 'HTTP_411_LENGTH_REQUIRED': 411, 'HTTP_412_PRECONDITION_FAILED': 412, 'HTTP_413_REQUEST_ENTITY_TOO_LARGE': 413, 'HTTP_414_REQUEST_URI_TOO_LONG': 414, 'HTTP_415_UNSUPPORTED_MEDIA_TYPE': 415, 'HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE': 416, 'HTTP_417_EXPECTATION_FAILED': 417, 'HTTP_418_IM_A_TEAPOT': 418, 'HTTP_421_MISDIRECTED_REQUEST': 421, 'HTTP_422_UNPROCESSABLE_ENTITY': 422, 'HTTP_423_LOCKED': 423, 'HTTP_424_FAILED_DEPENDENCY': 424, 'HTTP_425_TOO_EARLY': 425, 'HTTP_426_UPGRADE_REQUIRED': 426, 'HTTP_428_PRECONDITION_REQUIRED': 428, 'HTTP_429_TOO_MANY_REQUESTS': 429, 'HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE': 431, 'HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS': 451, 'HTTP_500_INTERNAL_SERVER_ERROR': 500, 'HTTP_501_NOT_IMPLEMENTED': 501, 'HTTP_502_BAD_GATEWAY': 502, 'HTTP_503_SERVICE_UNAVAILABLE': 503, 'HTTP_504_GATEWAY_TIMEOUT': 504, 'HTTP_505_HTTP_VERSION_NOT_SUPPORTED': 505, 'HTTP_506_VARIANT_ALSO_NEGOTIATES': 506, 'HTTP_507_INSUFFICIENT_STORAGE': 507, 'HTTP_508_LOOP_DETECTED': 508, 'HTTP_510_NOT_EXTENDED': 510, 'HTTP_511_NETWORK_AUTHENTICATION_REQUIRED': 511, 'WS_1000_NORMAL_CLOSURE': 1000, 'WS_1001_GOING_AWAY': 1001, 'WS_1002_PROTOCOL_ERROR': 1002, 'WS_1003_UNSUPPORTED_DATA': 1003, 'WS_1007_INVALID_FRAME_PAYLOAD_DATA': 1007, 'WS_1008_POLICY_VIOLATION': 1008, 'WS_1009_MESSAGE_TOO_BIG': 1009, 'WS_1010_MANDATORY_EXT': 1010, 'WS_1011_INTERNAL_ERROR': 1011, 'WS_1012_SERVICE_RESTART': 1012, 'WS_1013_TRY_AGAIN_LATER': 1013, 'WS_1014_BAD_GATEWAY': 1014, } def test_status_numbers(self): for _status, number in self.statuses.items(): assert getattr(status, _status) == number def test_status_is_informational(self): for number in range(100, 600): if 100 <= number < 200: assert status.is_informational(number) is True else: assert status.is_informational(number) is False def test_status_is_success(self): for number in range(100, 600): if 200 <= number < 300: assert status.is_success(number) is True else: assert status.is_success(number) is False def test_status_is_redirect(self): for number in range(100, 600): if 300 <= number < 400: assert status.is_redirect(number) is True else: assert status.is_redirect(number) is False def test_status_is_client_error(self): for number in range(100, 600): if 400 <= number < 500: assert status.is_client_error(number) is True else: assert status.is_client_error(number) is False def test_status_is_server_error(self): for number in range(100, 600): if 500 <= number < 600: assert status.is_server_error(number) is True else: assert status.is_server_error(number) is False def test_statuses_text(self): for _status, number in self.statuses.items(): if _status.startswith('HTTP'): assert status.status_text[number] == _status[9:].replace('_', ' ').title() else: # WS assert status.status_text[number] == _status[8:].replace('_', ' ').title() ================================================ FILE: tests/test_throttling.py ================================================ import asyncio from datetime import datetime, timedelta from unittest import IsolatedAsyncioTestCase from panther import Panther from panther.app import API from panther.configs import config from panther.test import APIClient from panther.throttling import Throttle from panther.utils import round_datetime @API() async def without_throttling_api(): return 'ok' @API(throttling=Throttle(rate=3, duration=timedelta(seconds=1))) async def with_throttling_api(): return 'ok' @API(throttling=Throttle(rate=1, duration=timedelta(seconds=1))) async def throttling_headers_api(): return 'ok' THROTTLING = Throttle(rate=1, duration=timedelta(seconds=10)) urls = { 'without-throttling': without_throttling_api, 'with-throttling': with_throttling_api, 'throttling-headers': throttling_headers_api, } class TestThrottling(IsolatedAsyncioTestCase): @classmethod def setUpClass(cls) -> None: app = Panther(__name__, configs=__name__, urls=urls) cls.client = APIClient(app=app) @classmethod def tearDownClass(cls): config.refresh() async def test_without_throttling(self): throttling = config.THROTTLING config.THROTTLING = None # Disable Global Throttling res1 = await self.client.get('without-throttling') assert res1.status_code == 200 res2 = await self.client.get('without-throttling') assert res2.status_code == 200 res3 = await self.client.get('without-throttling') assert res3.status_code == 200 config.THROTTLING = throttling async def test_with_throttling(self): res1 = await self.client.get('with-throttling') assert res1.status_code == 200 res2 = await self.client.get('with-throttling') assert res2.status_code == 200 res3 = await self.client.get('with-throttling') assert res3.status_code == 200 res4 = await self.client.get('with-throttling') assert res4.status_code == 429 res5 = await self.client.get('with-throttling') assert res5.status_code == 429 await asyncio.sleep(1) # Sleep and try again res6 = await self.client.get('with-throttling') assert res6.status_code == 200 res7 = await self.client.get('with-throttling') assert res7.status_code == 200 res8 = await self.client.get('with-throttling') assert res8.status_code == 200 res9 = await self.client.get('with-throttling') assert res9.status_code == 429 res10 = await self.client.get('with-throttling') assert res10.status_code == 429 async def test_throttling_header(self): await self.client.get('throttling-headers') res = await self.client.get('throttling-headers') assert res.status_code == 429 reset_time = round_datetime(datetime.now(), timedelta(seconds=1)) + timedelta(seconds=1) assert res.headers == { 'Content-Type': 'application/json', 'Content-Length': '29', 'Retry-After': str(int((reset_time - datetime.now()).total_seconds())), 'X-RateLimit-Reset': str(int(reset_time.timestamp())), } async def test_global_throttling(self): res1 = await self.client.get('without-throttling') assert res1.status_code == 200 res2 = await self.client.get('without-throttling') assert res2.status_code == 429 ================================================ FILE: tests/test_utils.py ================================================ import os from datetime import datetime, timedelta from pathlib import Path from unittest import TestCase from panther import Panther from panther.configs import config from panther.middlewares import HTTPMiddleware from panther.utils import generate_hash_value_from_string, generate_secret_key, load_env, round_datetime class TestLoadEnvFile(TestCase): file_path = 'tests/env-test' is_active = True db_host = '127.0.0.1' db_port = 27017 def tearDown(self) -> None: Path(self.file_path).unlink(missing_ok=True) def _create_env_file(self, file_data): with open(self.file_path, 'w') as file: file.write(file_data) def test_load_env_invalid_file(self): try: load_env('fake.file') except ValueError as e: assert e.args[0] == '"fake.file" is not a file.' def test_load_env_double_quote(self): self._create_env_file(f""" IS_ACTIVE = "{self.is_active}" DB_HOST = "{self.db_host}" DB_PORT = "{self.db_port}" """) variables = load_env(self.file_path) assert variables['IS_ACTIVE'] == self.is_active assert variables['DB_HOST'] == self.db_host assert variables['DB_PORT'] == str(self.db_port) def test_load_env_single_quote(self): self._create_env_file(f""" IS_ACTIVE = '{self.is_active}' DB_HOST = '{self.db_host}' DB_PORT = '{self.db_port}' """) variables = load_env(self.file_path) assert variables['IS_ACTIVE'] == self.is_active assert variables['DB_HOST'] == self.db_host assert variables['DB_PORT'] == str(self.db_port) def test_load_env_no_quote(self): self._create_env_file(f""" IS_ACTIVE = {self.is_active} DB_HOST = {self.db_host} DB_PORT = {self.db_port} """) variables = load_env(self.file_path) assert variables['IS_ACTIVE'] == self.is_active assert variables['DB_HOST'] == self.db_host assert variables['DB_PORT'] == str(self.db_port) def test_load_env_no_space(self): self._create_env_file(f""" IS_ACTIVE={self.is_active} DB_HOST={self.db_host} DB_PORT={self.db_port} """) variables = load_env(self.file_path) assert variables['IS_ACTIVE'] == self.is_active assert variables['DB_HOST'] == self.db_host assert variables['DB_PORT'] == str(self.db_port) def test_load_env_not_striped(self): self._create_env_file(f""" IS_ACTIVE = {self.is_active} DB_HOST = {self.db_host} DB_PORT = {self.db_port} """) variables = load_env(self.file_path) assert variables['IS_ACTIVE'] == self.is_active assert variables['DB_HOST'] == self.db_host assert variables['DB_PORT'] == str(self.db_port) def test_load_env_and_read_from_system_env(self): self._create_env_file(f""" IS_ACTIVE = '{self.is_active}' DB_HOST = '{self.db_host}' DB_PORT = '{self.db_port}' """) load_env(self.file_path) assert (os.environ['IS_ACTIVE'] == 'True') == self.is_active assert os.environ['DB_HOST'] == self.db_host assert os.environ['DB_PORT'] == str(self.db_port) class TestUtilFunctions(TestCase): def test_round_datetime_second_1(self): _datetime = datetime(year=1997, month=12, day=25, hour=12, minute=10, second=30) _delta = timedelta(seconds=20) rounded_datetime = round_datetime(_datetime, _delta) expected_datetime = datetime(year=1997, month=12, day=25, hour=12, minute=10, second=40) assert rounded_datetime == expected_datetime def test_round_datetime_second_2(self): _datetime = datetime(year=1997, month=12, day=25, hour=12, minute=10, second=35) _delta = timedelta(seconds=20) rounded_datetime = round_datetime(_datetime, _delta) expected_datetime = datetime(year=1997, month=12, day=25, hour=12, minute=10, second=40) assert rounded_datetime == expected_datetime def test_round_datetime_minute_1(self): _datetime = datetime(year=1997, month=12, day=25, hour=12, minute=15, second=30) _delta = timedelta(minutes=20) rounded_datetime = round_datetime(_datetime, _delta) expected_datetime = datetime(year=1997, month=12, day=25, hour=12, minute=20) assert rounded_datetime == expected_datetime def test_round_datetime_minute_2(self): _datetime = datetime(year=1997, month=12, day=25, hour=12, minute=10, second=30) _delta = timedelta(minutes=20) rounded_datetime = round_datetime(_datetime, _delta) expected_datetime = datetime(year=1997, month=12, day=25, hour=12, minute=20) assert rounded_datetime == expected_datetime def test_round_datetime_hour_1(self): _datetime = datetime(year=1997, month=12, day=25, hour=12, minute=10, second=30) _delta = timedelta(hours=5) rounded_datetime = round_datetime(_datetime, _delta) expected_datetime = datetime(year=1997, month=12, day=25, hour=12) assert rounded_datetime == expected_datetime def test_round_datetime_hour_2(self): _datetime = datetime(year=1997, month=12, day=25, hour=10, minute=10, second=30) _delta = timedelta(hours=5) rounded_datetime = round_datetime(_datetime, _delta) expected_datetime = datetime(year=1997, month=12, day=25, hour=12) assert rounded_datetime == expected_datetime def test_round_datetime_day_1(self): _datetime = datetime(year=1997, month=12, day=25, hour=10, minute=10, second=30) _delta = timedelta(days=5) rounded_datetime = round_datetime(_datetime, _delta) expected_datetime = datetime(year=1997, month=12, day=23) assert rounded_datetime == expected_datetime def test_round_datetime_day_2(self): _datetime = datetime(year=1997, month=12, day=22, hour=10, minute=10, second=30) _delta = timedelta(days=5) rounded_datetime = round_datetime(_datetime, _delta) expected_datetime = datetime(year=1997, month=12, day=23) assert rounded_datetime == expected_datetime def test_generate_hash_value_from_string(self): text = 'Hello World' hashed_1 = generate_hash_value_from_string(text) hashed_2 = generate_hash_value_from_string(text) assert hashed_1 == hashed_2 assert text != hashed_1 class TestLoadConfigs(TestCase): def tearDown(self): config.refresh() @classmethod def tearDownClass(cls): config.refresh() def test_urls_not_found(self): global URLs URLs = None with self.assertLogs(level='ERROR') as captured: try: Panther(name=__name__, configs=__name__) except SystemExit: assert True else: assert False assert len(captured.records) == 1 assert captured.records[0].getMessage() == "Invalid 'URLs': required." def test_urls_cant_be_dict(self): global URLs URLs = {} with self.assertLogs(level='ERROR') as captured: try: Panther(name=__name__, configs=__name__) except SystemExit: assert True else: assert False assert len(captured.records) == 1 msg = ( "Invalid 'URLs': can't be 'dict', you may want to pass it's value directly to Panther(). " 'Example: Panther(..., urls=...)' ) assert captured.records[0].getMessage() == msg def test_urls_not_string(self): global URLs URLs = True with self.assertLogs(level='ERROR') as captured: try: Panther(name=__name__, configs=__name__) except SystemExit: assert True else: assert False assert len(captured.records) == 1 assert captured.records[0].getMessage() == "Invalid 'URLs': should be dotted string." def test_urls_invalid_target(self): global URLs URLs = 'tests.test_utils.TestLoadConfigs' with self.assertLogs(level='ERROR') as captured: try: Panther(name=__name__, configs=__name__) except SystemExit: assert True else: assert False assert len(captured.records) == 1 assert captured.records[0].getMessage() == "Invalid 'URLs': should point to a dict." def test_urls_invalid_module_path(self): global URLs URLs = 'fake.module' with self.assertLogs(level='ERROR') as captured: try: Panther(name=__name__, configs=__name__) except SystemExit: assert True else: assert False assert len(captured.records) == 1 assert captured.records[0].getMessage() == "Invalid 'URLs': No module named 'fake'" def test_middlewares_invalid_path(self): global MIDDLEWARES MIDDLEWARES = [('fake.module', {})] with self.assertLogs() as captured: try: Panther(name=__name__, configs=__name__, urls={}) except SystemExit: assert True else: assert False finally: MIDDLEWARES = [] assert len(captured.records) == 2 assert captured.records[0].levelname == 'WARNING' assert ( captured.records[0].getMessage() == "DEPRECATED 'MIDDLEWARES': `data` does not supported in middlewares anymore, as your data is static you may want to pass them to your middleware with config variables" ) assert captured.records[1].levelname == 'ERROR' assert ( captured.records[1].getMessage() == "Invalid 'MIDDLEWARES': fake.module is not a valid middleware path or type" ) def test_middlewares_invalid_structure(self): global MIDDLEWARES MIDDLEWARES = ['fake.module'] with self.assertLogs(level='ERROR') as captured: try: Panther(name=__name__, configs=__name__, urls={}) except SystemExit: assert True else: assert False finally: MIDDLEWARES = [] assert len(captured.records) == 1 assert ( captured.records[0].getMessage() == "Invalid 'MIDDLEWARES': fake.module is not a valid middleware path or type" ) def test_middlewares_too_many_args(self): global MIDDLEWARES MIDDLEWARES = [('fake.module', 1, 2)] with self.assertLogs(level='ERROR') as captured: try: Panther(name=__name__, configs=__name__, urls={}) except SystemExit: assert True else: assert False finally: MIDDLEWARES = [] assert len(captured.records) == 1 assert ( captured.records[0].getMessage() == "Invalid 'MIDDLEWARES': ('fake.module', 1, 2) should be dotted path or type of a middleware class" ) def test_middlewares_without_args(self): global MIDDLEWARES MIDDLEWARES = [('tests.test_utils.CorrectTestMiddleware',)] with self.assertNoLogs(level='ERROR'): Panther(name=__name__, configs=__name__, urls={}) MIDDLEWARES = [] def test_middlewares_invalid_middleware_parent(self): global MIDDLEWARES MIDDLEWARES = [('tests.test_utils.TestMiddleware',)] with self.assertLogs(level='ERROR') as captured: try: Panther(name=__name__, configs=__name__, urls={}) except SystemExit: assert True else: assert False finally: MIDDLEWARES = [] assert len(captured.records) == 1 assert ( captured.records[0].getMessage() == "Invalid 'MIDDLEWARES': is not a sub class of `HTTPMiddleware`" ) def test_jwt_auth_without_secret_key(self): global AUTHENTICATION AUTHENTICATION = 'panther.authentications.JWTAuthentication' with self.assertLogs(level='ERROR') as captured: try: Panther(name=__name__, configs=__name__, urls={}) except SystemExit: assert True else: assert False finally: AUTHENTICATION = None assert len(captured.records) == 1 assert captured.records[0].getMessage() == "Invalid 'JWTConfig': `JWTConfig.key` or `SECRET_KEY` is required." def test_jwt_auth_with_secret_key(self): global AUTHENTICATION, SECRET_KEY AUTHENTICATION = 'panther.authentications.JWTAuthentication' SECRET_KEY = generate_secret_key() with self.assertNoLogs(level='ERROR'): try: Panther(name=__name__, configs=__name__, urls={}) except SystemExit: assert False else: assert True finally: AUTHENTICATION = None SECRET_KEY = None def test_check_function_endpoint_decorator(self): with self.assertLogs(level='ERROR') as captured: try: Panther(name=__name__, configs=__name__, urls={'/': invalid_api}) except SystemExit: assert True else: assert False assert len(captured.records) == 1 assert ( captured.records[0].getMessage() == 'You may have forgotten to use `@API()` on the `tests.test_utils.invalid_api()`' ) def test_check_class_endpoint_inheritance(self): with self.assertLogs(level='ERROR') as captured: try: Panther(name=__name__, configs=__name__, urls={'/': InvalidAPI}) except SystemExit: assert True else: assert False assert len(captured.records) == 1 assert captured.records[0].getMessage() == ( 'You may have forgotten to inherit from `panther.app.GenericAPI` or `panther.app.GenericWebsocket` ' 'on the `tests.test_utils.InvalidAPI()`' ) def test_check_websocket_inheritance(self): with self.assertLogs(level='ERROR') as captured: try: Panther(name=__name__, configs=__name__, urls={'/': InvalidWebsocket}) except SystemExit: assert True else: assert False assert len(captured.records) == 1 assert captured.records[0].getMessage() == ( 'You may have forgotten to inherit from `panther.app.GenericAPI` or `panther.app.GenericWebsocket` ' 'on the `tests.test_utils.InvalidWebsocket()`' ) def invalid_api(): pass class InvalidAPI: pass class InvalidWebsocket: pass class CorrectTestMiddleware(HTTPMiddleware): pass class TestMiddleware: pass ================================================ FILE: tests/test_websockets.py ================================================ from unittest import TestCase import orjson as json from panther import Panther, status from panther.authentications import QueryParamJWTAuthentication from panther.configs import config from panther.test import WebsocketClient from panther.websocket import GenericWebsocket class WithoutAcceptWebsocket(GenericWebsocket): async def connect(self): pass class CloseOnConnectWebsocket(GenericWebsocket): async def connect(self): await self.accept() await self.close() class CustomCloseWebsocket(GenericWebsocket): async def connect(self): await self.accept() await self.close(code=status.WS_1013_TRY_AGAIN_LATER, reason='Come Back Later') class MessageOnConnectWebsocket(GenericWebsocket): async def connect(self): await self.accept() await self.send('Hello') await self.close() class QueryParamWebsocket(GenericWebsocket): async def connect(self): await self.accept() await self.send(self.query_params) await self.close() class PathVariableWebsocket(GenericWebsocket): async def connect(self, name: str, age: int, is_male: bool): await self.accept() await self.send( f'{type(name).__name__}({name}), {type(age).__name__}({age}), {type(is_male).__name__}({is_male})', ) await self.close() class SendAllTypesWebsocket(GenericWebsocket): async def connect(self): await self.accept() # String await self.send('Hello Again') # Int await self.send(12) # Dict await self.send({'detail': 'ok'}) # List await self.send([1, 2, 3, 'Ali', 4]) # Tuple await self.send((1, 2, 3, 'Ali', 4)) # Bytes await self.send(b'It Is Value Of A File') # Nothing await self.send() # None await self.send(None) # Close await self.close() class WebsocketWithoutAuthentication(GenericWebsocket): async def connect(self): await self.accept() await self.send(str(self.user)) await self.close() class WebsocketWithAuthentication(GenericWebsocket): auth = QueryParamJWTAuthentication async def connect(self): await self.accept() await self.send(self.user) await self.close() class Permission: async def __call__(self, connection) -> bool: return connection.path == '/with-permission' class WebsocketWithPermission(GenericWebsocket): permissions = [Permission] async def connect(self): await self.accept() await self.send('ok') await self.close() class WebsocketWithoutPermission(GenericWebsocket): permissions = [Permission] async def connect(self): await self.accept() await self.send('no') await self.close() urls = { 'without-accept': WithoutAcceptWebsocket, 'close-on-connect': CloseOnConnectWebsocket, 'custom-close': CustomCloseWebsocket, 'message-after-connect': MessageOnConnectWebsocket, 'query-params': QueryParamWebsocket, 'path-variable////': PathVariableWebsocket, 'all-types': SendAllTypesWebsocket, 'without-auth': WebsocketWithoutAuthentication, 'with-auth': WebsocketWithAuthentication, 'with-permission': WebsocketWithPermission, 'without-permission': WebsocketWithoutPermission, } class TestWebsocket(TestCase): @classmethod def setUpClass(cls) -> None: cls.app = Panther(__name__, configs=__name__, urls=urls) @classmethod def tearDownClass(cls): config.refresh() def test_without_accept(self): ws = WebsocketClient(app=self.app) responses = ws.connect('without-accept') assert responses[0]['type'] == 'websocket.close' assert responses[0]['code'] == 1000 assert responses[0]['reason'] == '' def test_close_on_connect(self): ws = WebsocketClient(app=self.app) responses = ws.connect('close-on-connect') assert responses[0]['type'] == 'websocket.accept' assert responses[0]['subprotocol'] is None assert responses[0]['headers'] == {} assert responses[1]['type'] == 'websocket.close' assert responses[1]['code'] == 1000 assert responses[1]['reason'] == '' def test_custom_close(self): ws = WebsocketClient(app=self.app) responses = ws.connect('custom-close') assert responses[0]['type'] == 'websocket.accept' assert responses[0]['subprotocol'] is None assert responses[0]['headers'] == {} assert responses[1]['type'] == 'websocket.close' assert responses[1]['code'] == 1013 assert responses[1]['reason'] == 'Come Back Later' def test_query_params(self): ws = WebsocketClient(app=self.app) responses = ws.connect('query-params', query_params={'name': 'ali', 'age': 27}) assert responses[0]['type'] == 'websocket.accept' assert responses[0]['subprotocol'] is None assert responses[0]['headers'] == {} assert responses[1]['type'] == 'websocket.send' params = json.loads(responses[1]['text']) assert [*params.keys()] == ['name', 'age'] assert params['name'] == 'ali' assert params['age'] == '27' assert responses[2]['type'] == 'websocket.close' assert responses[2]['code'] == 1000 assert responses[2]['reason'] == '' def test_message_after_connect(self): ws = WebsocketClient(app=self.app) responses = ws.connect('message-after-connect') assert responses[0]['type'] == 'websocket.accept' assert responses[0]['subprotocol'] is None assert responses[0]['headers'] == {} assert responses[1]['type'] == 'websocket.send' assert responses[1]['text'] == 'Hello' assert responses[2]['type'] == 'websocket.close' assert responses[2]['code'] == 1000 assert responses[2]['reason'] == '' def test_path_variables(self): ws = WebsocketClient(app=self.app) responses = ws.connect('path-variable/Ali/25/true') assert responses[0]['type'] == 'websocket.accept' assert responses[0]['subprotocol'] is None assert responses[0]['headers'] == {} assert responses[1]['type'] == 'websocket.send' assert responses[1]['text'] == 'str(Ali), int(25), bool(True)' assert responses[2]['type'] == 'websocket.close' assert responses[2]['code'] == 1000 assert responses[2]['reason'] == '' def test_all_types(self): ws = WebsocketClient(app=self.app) responses = ws.connect('all-types') assert responses[0]['type'] == 'websocket.accept' assert responses[0]['subprotocol'] is None assert responses[0]['headers'] == {} # String assert responses[1]['type'] == 'websocket.send' assert responses[1]['text'] == 'Hello Again' assert 'bytes' not in responses[1] # Int assert responses[2]['type'] == 'websocket.send' assert responses[2]['text'] == '12' assert 'bytes' not in responses[2] # Dict assert responses[3]['type'] == 'websocket.send' assert responses[3]['text'] == '{"detail":"ok"}' assert 'bytes' not in responses[3] # List assert responses[4]['type'] == 'websocket.send' assert responses[4]['text'] == '[1,2,3,"Ali",4]' assert 'bytes' not in responses[4] # Tuple assert responses[5]['type'] == 'websocket.send' assert responses[5]['text'] == '[1,2,3,"Ali",4]' assert 'bytes' not in responses[5] # Bytes assert responses[6]['type'] == 'websocket.send' assert responses[6]['bytes'] == b'It Is Value Of A File' assert 'text' not in responses[6] # Nothing assert responses[7]['type'] == 'websocket.send' assert responses[7]['text'] == 'null' assert 'bytes' not in responses[7] # None assert responses[8]['type'] == 'websocket.send' assert responses[8]['text'] == 'null' assert 'bytes' not in responses[8] # Close assert responses[9]['type'] == 'websocket.close' assert responses[9]['code'] == 1000 assert responses[9]['reason'] == '' def test_without_auth(self): ws = WebsocketClient(app=self.app) responses = ws.connect('without-auth') assert responses[0]['type'] == 'websocket.accept' assert responses[0]['subprotocol'] is None assert responses[0]['headers'] == {} assert responses[1]['type'] == 'websocket.send' assert responses[1]['text'] == 'None' assert responses[2]['type'] == 'websocket.close' assert responses[2]['code'] == 1000 assert responses[2]['reason'] == '' def test_with_auth_failed(self): ws = WebsocketClient(app=self.app) responses = ws.connect('with-auth') assert responses[0]['type'] == 'websocket.accept' assert responses[0]['subprotocol'] is None assert responses[0]['headers'] == {} assert responses[1]['type'] == 'websocket.send' assert responses[1]['text'] == 'null' assert responses[2]['type'] == 'websocket.close' assert responses[2]['code'] == 1000 assert responses[2]['reason'] == '' def test_with_auth_success(self): global WS_AUTHENTICATION, SECRET_KEY, DATABASE DATABASE = { 'engine': { 'class': 'panther.db.connections.PantherDBConnection', }, } WS_AUTHENTICATION = 'panther.authentications.QueryParamJWTAuthentication' SECRET_KEY = 'hvdhRspoTPh1cJVBHcuingQeOKNc1uRhIP2k7suLe2g=' token = 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxfQ.AF3nsj8IQ6t0ncqIx4quoyPfYaZ-pqUOW4z_euUztPM' app = Panther(__name__, configs=__name__, urls=urls) ws = WebsocketClient(app=app) with self.assertLogs(level='ERROR') as captured: responses = ws.connect('with-auth', query_params={'authorization': token}) assert len(captured.records) == 1 assert captured.records[0].getMessage() == 'QueryParamJWTAuthentication Error: "User not found"' assert responses[0]['type'] == 'websocket.close' assert responses[0]['code'] == 1000 assert responses[0]['reason'] == '' def test_with_permission(self): ws = WebsocketClient(app=self.app) responses = ws.connect('with-permission') assert responses[0]['type'] == 'websocket.accept' assert responses[0]['subprotocol'] is None assert responses[0]['headers'] == {} assert responses[1]['type'] == 'websocket.send' assert responses[1]['text'] == 'ok' assert responses[2]['type'] == 'websocket.close' assert responses[2]['code'] == 1000 assert responses[2]['reason'] == '' def test_without_permission(self): ws = WebsocketClient(app=self.app) responses = ws.connect('without-permission') assert responses[0]['type'] == 'websocket.close' assert responses[0]['code'] == 1000 assert responses[0]['reason'] == ''