54 Commits

Author SHA1 Message Date
926b3a198d update hashes 2025-09-22 14:40:01 -05:00
8db441612f Bump version to 0.2.2
- Updated build workflow to fix fetching artifacts
- Ruff formatting and fixes
- Fix config path handling for android
- Codebase performance and bug risk fixes (Deepsource)
2025-09-22 14:13:30 -05:00
b34b8f23ff Fix log setup condition in setup_rns_logging to use 'is not' for comparison 2025-09-22 14:10:02 -05:00
13ad0bcef6 Fix config path handling in StorageManager for Android 2025-09-22 14:01:24 -05:00
64b9ac3df4 ruff formatting and fixes 2025-09-22 13:56:04 -05:00
ee521a9f60 ruff formatting and fixes 2025-09-22 13:55:52 -05:00
fd4e0c8a14 Update build workflow 2025-09-22 13:48:43 -05:00
b056271da7 Bump version to 0.2.1
- Fix broken APK and Linux builds due to webview_flutter_android compatibility issue
- Add manual workflow trigger for easier testing and deployment
- Add automated draft release creation with build artifacts
- Override webview_flutter_android to v4.10.1 for Dart 3.7 compatibility
2025-09-20 16:31:55 -05:00
189256edd7 Update README 2025-09-20 16:25:09 -05:00
62d3502f99 Add create-release job to GitHub Actions workflow for automated draft releases with Linux and APK artifacts 2025-09-20 16:24:59 -05:00
cb218f2b29 add manual trigger 2025-09-20 16:14:11 -05:00
871f626555 possible fix for broken apk/linux builds (flutter) 2025-09-20 16:13:59 -05:00
9a20152a70 update README 2025-09-20 16:08:22 -05:00
52163c4d6d Bump version to 0.2.0 2025-09-20 15:41:37 -05:00
2ce356e750 fix 2025-09-20 15:18:00 -05:00
ab3ea64ecf add full-length commit hashes for actions. 2025-09-20 15:11:53 -05:00
5b5c2a3d2c Add Security.md 2025-09-20 15:09:43 -05:00
aabbd510ed Add Safety workflow for vulnerability checks 2025-09-20 15:05:49 -05:00
93530387a4 Remove pull_request trigger from Docker workflow 2025-09-20 15:05:41 -05:00
ea09d520aa Update 2025-09-20 15:04:18 -05:00
e9ecef79e5 Add Contributing.md 2025-09-20 15:04:12 -05:00
be40fc9eac Add @staticmethod to improve performance. 2025-09-20 14:51:10 -05:00
fc5396f91d move exclude_patterns to top level 2025-09-20 14:28:55 -05:00
4754fed238 ignore tests 2025-09-20 14:26:55 -05:00
9eb85e45b9 Fix mocking reticulum config file 2025-09-20 14:25:58 -05:00
ce8ece45a3 add workflow timeout 2025-09-20 14:21:59 -05:00
7c8e8e41cb Fix RNS initialization and logging setup
- Moved RNS initialization and logging setup to the main application to ensure proper logging capture before RNS is used.
- Updated AnnounceService and PageFetcher to remove redundant RNS initialization, assuming it is handled in the main app.
- Enhanced settings interface with a refresh button for better user experience and updated error and Reticulum log displays.
2025-09-20 14:17:41 -05:00
66bcf0d25c add test workflow 2025-09-20 14:09:24 -05:00
ed9b487d62 Update README 2025-09-20 14:06:17 -05:00
d30456096e Refactor config path handling in TestStorageManager
- Updated expected config path to use 'config' instead of 'config.txt'.
- Simplified test for Reticulum config path to return the home directory path.
- Mocked Reticulum config path in multiple tests to ensure consistent behavior when loading configurations.
- Adjusted assertions to reflect changes in expected output for loading configurations.
2025-09-20 14:06:01 -05:00
03e2ac9c89 Update to not create default config as RNS does that (should), also update to config name. 2025-09-20 14:05:22 -05:00
6c0c89969f Add docstrings for the main UI construction in ui.py
- Enhanced documentation for the main UI build function and overall module.
- Improved clarity on function parameters and purpose, aiding future development and maintenance.
2025-09-20 13:57:04 -05:00
408a5a3423 Add storage management and enhance settings interface in settings.py
- Integrated storage management for loading and saving configuration.
- Added storage information display for debugging purposes.
- Improved docstrings for better clarity on function usage and parameters.
2025-09-20 13:56:54 -05:00
7e9775c358 Add detailed docstrings for the TabsManager class and its methods in tabs.py, enhancing code documentation and clarity. 2025-09-20 13:56:39 -05:00
1d507cff19 Add cross-platform storage management for Ren Browser with comprehensive docstrings.
Implements configuration, bookmarks, and history handling, ensuring persistent data storage across platforms.
2025-09-20 13:56:33 -05:00
2aa9afeb15 improve docstrings 2025-09-20 13:56:16 -05:00
069967cb51 docstring 2025-09-20 13:56:02 -05:00
6baf6e1807 add docstring 2025-09-20 13:55:50 -05:00
1aead1935b add logging and docstrings 2025-09-20 13:55:40 -05:00
c01d86c25d add docstrings 2025-09-20 13:55:26 -05:00
8ac3364420 Improve documentation and logging setup 2025-09-20 13:55:18 -05:00
bb4c9aef78 Add centralized logging system for Ren Browser with detailed function documentation 2025-09-20 13:55:00 -05:00
7571b6b13d Add initialization with storage setup and config directory handling 2025-09-20 13:54:52 -05:00
70a4675092 add pytest dev dependencies 2025-09-20 13:26:38 -05:00
aac9a1a107 Add basic test suite 2025-09-20 13:26:22 -05:00
0532dfdd55 add 2025-09-20 12:56:16 -05:00
272eeac62c Update dependencies 2025-09-20 12:55:16 -05:00
34f47dc678 Update order of imports and improve error handling in various modules 2025-09-20 12:53:04 -05:00
d56a6934f9 Remove subprocess call, update import order 2025-09-20 12:52:45 -05:00
73e11b1083 Update GitHub Actions workflows to use specific versions of actions using hashes (supply chain security) 2025-09-20 12:47:48 -05:00
Sudo-Ivan
cd2f70641f update link again 2025-05-29 18:02:03 -05:00
Sudo-Ivan
d19a6165e3 update 2025-05-29 18:00:06 -05:00
Sudo-Ivan
57a8af5557 update instructions 2025-05-29 17:59:30 -05:00
Sudo-Ivan
91a7148afe update 2025-05-29 17:50:24 -05:00
40 changed files with 3294 additions and 286 deletions

14
.deepsource.toml Normal file
View File

@@ -0,0 +1,14 @@
version = 1
exclude_patterns = [
"tests/**"
]
[[analyzers]]
name = "python"
[analyzers.meta]
runtime_version = "3.x.x"
[[analyzers]]
name = "docker"

View File

@@ -4,6 +4,7 @@ on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
jobs:
build-linux:
@@ -12,7 +13,7 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Install Linux dependencies
run: |
@@ -20,7 +21,7 @@ jobs:
sudo apt-get install -y libgtk-3-dev cmake ninja-build clang pkg-config libgtk-3-dev liblzma-dev
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.13'
@@ -35,7 +36,7 @@ jobs:
run: poetry run flet build linux
- name: Upload Linux artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ren-browser-linux
path: build/linux
@@ -46,10 +47,10 @@ jobs:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Set up JDK
uses: actions/setup-java@v3
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00
with:
distribution: 'zulu'
java-version: '17'
@@ -60,7 +61,7 @@ jobs:
sudo apt-get install -y cmake ninja-build clang pkg-config
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.13'
@@ -75,7 +76,45 @@ jobs:
run: poetry run flet build apk
- name: Upload APK artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ren-browser-apk
path: build/apk
create-release:
needs: [build-linux, build-android]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Download Linux artifact
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0
with:
name: ren-browser-linux
path: ./artifacts/linux
- name: Download APK artifact
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0
with:
name: ren-browser-apk
path: ./artifacts/apk
- name: Create draft release
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
if: github.ref_type == 'tag'
with:
draft: true
files: |
./artifacts/linux/*
./artifacts/apk/*
name: Release ${{ github.ref_name }}
body: |
## Release ${{ github.ref_name }}
This release contains:
- Linux binary package
- Android APK package

View File

@@ -4,8 +4,6 @@ on:
push:
branches: [ main ]
tags: [ 'v*' ]
pull_request:
branches: [ main ]
env:
REGISTRY: ghcr.io
@@ -20,13 +18,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
- name: Log in to the Container registry
uses: docker/login-action@v3
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -34,7 +32,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
@@ -46,7 +44,7 @@ jobs:
type=sha,format=short
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25
with:
context: .
file: Dockerfile

17
.github/workflows/safety.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Safety
on:
push:
branches: [ main ]
schedule:
- cron: '0 0 * * 0' # weekly
jobs:
security:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Run Safety CLI to check for vulnerabilities
uses: pyupio/safety-action@7baf6605473beffc874c1313ddf2db085c0cacf2
with:
api-key: ${{ secrets.SAFETY_API_KEY }}

73
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,73 @@
# TODO: Update to use specific commit hashes for the actions for better supply chain security.
name: Run Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
strategy:
matrix:
python-version: ['3.13']
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c
with:
python-version: ${{ matrix.python-version }}
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev
- name: Install Poetry
run: |
python -m pip install --upgrade pip
pip install poetry
- name: Configure Poetry
run: |
poetry config virtualenvs.create true
poetry config virtualenvs.in-project true
- name: Cache Poetry dependencies
uses: actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f
with:
path: .venv
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
restore-keys: |
venv-${{ runner.os }}-${{ matrix.python-version }}-
- name: Install dependencies
run: poetry install
- name: Run linting with ruff
run: |
poetry run ruff check ./ren_browser/
poetry run ruff check ./tests/
- name: Run tests with pytest
run: |
poetry run pytest -v --cov=ren_browser --cov-report=xml --cov-report=term
- name: Upload coverage reports
uses: codecov/codecov-action@ab904c41d6ece82784817410c45d8b8c02684457
if: matrix.python-version == '3.13'
with:
file: ./coverage.xml
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false

24
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,24 @@
# Contributing to Ren Browser
I welcome all contributions to the project.
## Places to help out
- Styling/Design (I am bad at this)
- Documentation
- Micron Renderer/Parser
## Rules
1. Be nice to each other.
2. If you use an AI tool that generates the code, such as a LLM, please indicate that in the PR.
3. Add or update docstrings and tests if necessary.
4. Make sure you run the tests before submitting the PR.
## Testing
To run the tests, use the following command:
```bash
poetry run pytest
```

View File

@@ -17,7 +17,7 @@ Built using [Flet](https://flet.dev/).
- Python 3.13+
- Flet
- Reticulum 0.9.6+
- Reticulum 1.0.0+
- Poetry
**Setup**
@@ -56,7 +56,7 @@ poetry run ren-browser-ios-dev
```bash
docker build -t ren-browser .
docker run -p 8550:8550 ren-browser
docker run -p 8550:8550 -v ./config:/app/config ren-browser
```
## Building

12
SECURITY.md Normal file
View File

@@ -0,0 +1,12 @@
# Security Policy
## Tools/Services Used
- [Socket.dev](https://socket.dev/)
- [Deepsource](https://deepsource.io/)
- [Ruff](https://github.com/astral-sh/ruff)
- [Safety](https://github.com/pyupio/safety)
## Reporting a Vulnerability
Use GitHub reporting or email report to `rns@quad4.io`

View File

@@ -1,5 +1,11 @@
# To-Do
## Bugs
- [ ] Test Config Saving on Android. In my testing and also reported via Email.
- [ ] Fix persisting app state in background on Android. https://github.com/Sudo-Ivan/Ren-Browser/issues/1
- [ ] Fix tabs dragging/reordering and overflow issues. https://github.com/Sudo-Ivan/Ren-Browser/issues/1
## UI
- [ ] Tab dragging/reordering.
@@ -33,7 +39,6 @@
- [ ] Interface status page.
- [ ] Plugins.
## Distribution
- [ ] Add Docker images to build Windows, Linux, MacOS, Android, iOS.

589
poetry.lock generated
View File

@@ -2,15 +2,15 @@
[[package]]
name = "anyio"
version = "4.9.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
version = "4.10.0"
description = "High-level concurrency and networking framework on top of asyncio or Trio"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_system != \"Pyodide\""
files = [
{file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"},
{file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"},
{file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"},
{file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"},
]
[package.dependencies]
@@ -18,162 +18,308 @@ idna = ">=2.8"
sniffio = ">=1.1"
[package.extras]
doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""]
trio = ["trio (>=0.26.1)"]
[[package]]
name = "certifi"
version = "2025.4.26"
version = "2025.8.3"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
python-versions = ">=3.7"
groups = ["main"]
markers = "platform_system != \"Pyodide\""
files = [
{file = "certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3"},
{file = "certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6"},
{file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
{file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
]
[[package]]
name = "cffi"
version = "1.17.1"
version = "2.0.0"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
markers = "platform_python_implementation != \"PyPy\""
files = [
{file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"},
{file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"},
{file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"},
{file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"},
{file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"},
{file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"},
{file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"},
{file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"},
{file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"},
{file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"},
{file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"},
{file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"},
{file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"},
{file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"},
{file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"},
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"},
{file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"},
{file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"},
{file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"},
{file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"},
{file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"},
{file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"},
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"},
{file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"},
{file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"},
{file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"},
{file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"},
{file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"},
{file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"},
{file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"},
{file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"},
{file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"},
{file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"},
{file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"},
{file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"},
{file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"},
{file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"},
{file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
{file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
{file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"},
{file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"},
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"},
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"},
{file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"},
{file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"},
{file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"},
{file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"},
{file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"},
{file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"},
{file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"},
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"},
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"},
{file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"},
{file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"},
{file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"},
{file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"},
{file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"},
{file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"},
{file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"},
{file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"},
{file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"},
{file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"},
{file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"},
{file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"},
{file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"},
{file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"},
{file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"},
{file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"},
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"},
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"},
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"},
{file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"},
{file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"},
{file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"},
{file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"},
{file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"},
{file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"},
{file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"},
{file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"},
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"},
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"},
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"},
{file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"},
{file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"},
{file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"},
{file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"},
{file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"},
{file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"},
{file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"},
{file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"},
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"},
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"},
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"},
{file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"},
{file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"},
{file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"},
{file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"},
{file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"},
{file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"},
{file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"},
{file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"},
{file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"},
{file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"},
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"},
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"},
{file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"},
{file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"},
{file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
{file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
]
[package.dependencies]
pycparser = "*"
pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "coverage"
version = "7.10.6"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "coverage-7.10.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70e7bfbd57126b5554aa482691145f798d7df77489a177a6bef80de78860a356"},
{file = "coverage-7.10.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e41be6f0f19da64af13403e52f2dec38bbc2937af54df8ecef10850ff8d35301"},
{file = "coverage-7.10.6-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c61fc91ab80b23f5fddbee342d19662f3d3328173229caded831aa0bd7595460"},
{file = "coverage-7.10.6-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10356fdd33a7cc06e8051413140bbdc6f972137508a3572e3f59f805cd2832fd"},
{file = "coverage-7.10.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80b1695cf7c5ebe7b44bf2521221b9bb8cdf69b1f24231149a7e3eb1ae5fa2fb"},
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c33e6378b9d52d3454bd08847a8651f4ed23ddbb4a0520227bd346382bbc6"},
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c8a3ec16e34ef980a46f60dc6ad86ec60f763c3f2fa0db6d261e6e754f72e945"},
{file = "coverage-7.10.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7d79dabc0a56f5af990cc6da9ad1e40766e82773c075f09cc571e2076fef882e"},
{file = "coverage-7.10.6-cp310-cp310-win32.whl", hash = "sha256:86b9b59f2b16e981906e9d6383eb6446d5b46c278460ae2c36487667717eccf1"},
{file = "coverage-7.10.6-cp310-cp310-win_amd64.whl", hash = "sha256:e132b9152749bd33534e5bd8565c7576f135f157b4029b975e15ee184325f528"},
{file = "coverage-7.10.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c706db3cabb7ceef779de68270150665e710b46d56372455cd741184f3868d8f"},
{file = "coverage-7.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8e0c38dc289e0508ef68ec95834cb5d2e96fdbe792eaccaa1bccac3966bbadcc"},
{file = "coverage-7.10.6-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:752a3005a1ded28f2f3a6e8787e24f28d6abe176ca64677bcd8d53d6fe2ec08a"},
{file = "coverage-7.10.6-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:689920ecfd60f992cafca4f5477d55720466ad2c7fa29bb56ac8d44a1ac2b47a"},
{file = "coverage-7.10.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec98435796d2624d6905820a42f82149ee9fc4f2d45c2c5bc5a44481cc50db62"},
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b37201ce4a458c7a758ecc4efa92fa8ed783c66e0fa3c42ae19fc454a0792153"},
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2904271c80898663c810a6b067920a61dd8d38341244a3605bd31ab55250dad5"},
{file = "coverage-7.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5aea98383463d6e1fa4e95416d8de66f2d0cb588774ee20ae1b28df826bcb619"},
{file = "coverage-7.10.6-cp311-cp311-win32.whl", hash = "sha256:e3fb1fa01d3598002777dd259c0c2e6d9d5e10e7222976fc8e03992f972a2cba"},
{file = "coverage-7.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:f35ed9d945bece26553d5b4c8630453169672bea0050a564456eb88bdffd927e"},
{file = "coverage-7.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:99e1a305c7765631d74b98bf7dbf54eeea931f975e80f115437d23848ee8c27c"},
{file = "coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea"},
{file = "coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634"},
{file = "coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6"},
{file = "coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9"},
{file = "coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c"},
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a"},
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5"},
{file = "coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972"},
{file = "coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d"},
{file = "coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629"},
{file = "coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80"},
{file = "coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6"},
{file = "coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80"},
{file = "coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003"},
{file = "coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27"},
{file = "coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4"},
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d"},
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc"},
{file = "coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc"},
{file = "coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e"},
{file = "coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32"},
{file = "coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2"},
{file = "coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b"},
{file = "coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393"},
{file = "coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27"},
{file = "coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df"},
{file = "coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb"},
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282"},
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4"},
{file = "coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21"},
{file = "coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0"},
{file = "coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5"},
{file = "coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b"},
{file = "coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e"},
{file = "coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb"},
{file = "coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034"},
{file = "coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1"},
{file = "coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a"},
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb"},
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d"},
{file = "coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747"},
{file = "coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5"},
{file = "coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713"},
{file = "coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32"},
{file = "coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65"},
{file = "coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6"},
{file = "coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0"},
{file = "coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e"},
{file = "coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5"},
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7"},
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5"},
{file = "coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0"},
{file = "coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7"},
{file = "coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930"},
{file = "coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b"},
{file = "coverage-7.10.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90558c35af64971d65fbd935c32010f9a2f52776103a259f1dee865fe8259352"},
{file = "coverage-7.10.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8953746d371e5695405806c46d705a3cd170b9cc2b9f93953ad838f6c1e58612"},
{file = "coverage-7.10.6-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c83f6afb480eae0313114297d29d7c295670a41c11b274e6bca0c64540c1ce7b"},
{file = "coverage-7.10.6-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7eb68d356ba0cc158ca535ce1381dbf2037fa8cb5b1ae5ddfc302e7317d04144"},
{file = "coverage-7.10.6-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b15a87265e96307482746d86995f4bff282f14b027db75469c446da6127433b"},
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fc53ba868875bfbb66ee447d64d6413c2db91fddcfca57025a0e7ab5b07d5862"},
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:efeda443000aa23f276f4df973cb82beca682fd800bb119d19e80504ffe53ec2"},
{file = "coverage-7.10.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9702b59d582ff1e184945d8b501ffdd08d2cee38d93a2206aa5f1365ce0b8d78"},
{file = "coverage-7.10.6-cp39-cp39-win32.whl", hash = "sha256:2195f8e16ba1a44651ca684db2ea2b2d4b5345da12f07d9c22a395202a05b23c"},
{file = "coverage-7.10.6-cp39-cp39-win_amd64.whl", hash = "sha256:f32ff80e7ef6a5b5b606ea69a36e97b219cd9dc799bcf2963018a4d8f788cfbf"},
{file = "coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3"},
{file = "coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90"},
]
[package.extras]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "cryptography"
version = "45.0.3"
version = "46.0.1"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.7"
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
groups = ["main"]
files = [
{file = "cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca"},
{file = "cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1"},
{file = "cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578"},
{file = "cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497"},
{file = "cryptography-45.0.3-cp311-abi3-win32.whl", hash = "sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710"},
{file = "cryptography-45.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490"},
{file = "cryptography-45.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b"},
{file = "cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782"},
{file = "cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65"},
{file = "cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b"},
{file = "cryptography-45.0.3-cp37-abi3-win32.whl", hash = "sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab"},
{file = "cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed43d396f42028c1f47b5fec012e9e12631266e3825e95c00e3cf94d472dac49"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fed5aaca1750e46db870874c9c273cd5182a9e9deb16f06f7bdffdb5c2bde4b9"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:00094838ecc7c6594171e8c8a9166124c1197b074cfca23645cee573910d76bc"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:92d5f428c1a0439b2040435a1d6bc1b26ebf0af88b093c3628913dd464d13fa1"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:ec64ee375b5aaa354b2b273c921144a660a511f9df8785e6d1c942967106438e"},
{file = "cryptography-45.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:71320fbefd05454ef2d457c481ba9a5b0e540f3753354fff6f780927c25d19b0"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c"},
{file = "cryptography-45.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19"},
{file = "cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899"},
{file = "cryptography-46.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:1cd6d50c1a8b79af1a6f703709d8973845f677c8e97b1268f5ff323d38ce8475"},
{file = "cryptography-46.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ff483716be32690c14636e54a1f6e2e1b7bf8e22ca50b989f88fa1b2d287080"},
{file = "cryptography-46.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9873bf7c1f2a6330bdfe8621e7ce64b725784f9f0c3a6a55c3047af5849f920e"},
{file = "cryptography-46.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0dfb7c88d4462a0cfdd0d87a3c245a7bc3feb59de101f6ff88194f740f72eda6"},
{file = "cryptography-46.0.1-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e22801b61613ebdebf7deb18b507919e107547a1d39a3b57f5f855032dd7cfb8"},
{file = "cryptography-46.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:757af4f6341ce7a1e47c326ca2a81f41d236070217e5fbbad61bbfe299d55d28"},
{file = "cryptography-46.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f7a24ea78de345cfa7f6a8d3bde8b242c7fac27f2bd78fa23474ca38dfaeeab9"},
{file = "cryptography-46.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e8776dac9e660c22241b6587fae51a67b4b0147daa4d176b172c3ff768ad736"},
{file = "cryptography-46.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9f40642a140c0c8649987027867242b801486865277cbabc8c6059ddef16dc8b"},
{file = "cryptography-46.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:449ef2b321bec7d97ef2c944173275ebdab78f3abdd005400cc409e27cd159ab"},
{file = "cryptography-46.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2dd339ba3345b908fa3141ddba4025568fa6fd398eabce3ef72a29ac2d73ad75"},
{file = "cryptography-46.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7411c910fb2a412053cf33cfad0153ee20d27e256c6c3f14d7d7d1d9fec59fd5"},
{file = "cryptography-46.0.1-cp311-abi3-win32.whl", hash = "sha256:cbb8e769d4cac884bb28e3ff620ef1001b75588a5c83c9c9f1fdc9afbe7f29b0"},
{file = "cryptography-46.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:92e8cfe8bd7dd86eac0a677499894862cd5cc2fd74de917daa881d00871ac8e7"},
{file = "cryptography-46.0.1-cp311-abi3-win_arm64.whl", hash = "sha256:db5597a4c7353b2e5fb05a8e6cb74b56a4658a2b7bf3cb6b1821ae7e7fd6eaa0"},
{file = "cryptography-46.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:4c49eda9a23019e11d32a0eb51a27b3e7ddedde91e099c0ac6373e3aacc0d2ee"},
{file = "cryptography-46.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9babb7818fdd71394e576cf26c5452df77a355eac1a27ddfa24096665a27f8fd"},
{file = "cryptography-46.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9f2c4cc63be3ef43c0221861177cee5d14b505cd4d4599a89e2cd273c4d3542a"},
{file = "cryptography-46.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:41c281a74df173876da1dc9a9b6953d387f06e3d3ed9284e3baae3ab3f40883a"},
{file = "cryptography-46.0.1-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0a17377fa52563d730248ba1f68185461fff36e8bc75d8787a7dd2e20a802b7a"},
{file = "cryptography-46.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:0d1922d9280e08cde90b518a10cd66831f632960a8d08cb3418922d83fce6f12"},
{file = "cryptography-46.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:af84e8e99f1a82cea149e253014ea9dc89f75b82c87bb6c7242203186f465129"},
{file = "cryptography-46.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ef648d2c690703501714588b2ba640facd50fd16548133b11b2859e8655a69da"},
{file = "cryptography-46.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:e94eb5fa32a8a9f9bf991f424f002913e3dd7c699ef552db9b14ba6a76a6313b"},
{file = "cryptography-46.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:534b96c0831855e29fc3b069b085fd185aa5353033631a585d5cd4dd5d40d657"},
{file = "cryptography-46.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9b55038b5c6c47559aa33626d8ecd092f354e23de3c6975e4bb205df128a2a0"},
{file = "cryptography-46.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ec13b7105117dbc9afd023300fb9954d72ca855c274fe563e72428ece10191c0"},
{file = "cryptography-46.0.1-cp314-cp314t-win32.whl", hash = "sha256:504e464944f2c003a0785b81668fe23c06f3b037e9cb9f68a7c672246319f277"},
{file = "cryptography-46.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c52fded6383f7e20eaf70a60aeddd796b3677c3ad2922c801be330db62778e05"},
{file = "cryptography-46.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9495d78f52c804b5ec8878b5b8c7873aa8e63db9cd9ee387ff2db3fffe4df784"},
{file = "cryptography-46.0.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:d84c40bdb8674c29fa192373498b6cb1e84f882889d21a471b45d1f868d8d44b"},
{file = "cryptography-46.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9ed64e5083fa806709e74fc5ea067dfef9090e5b7a2320a49be3c9df3583a2d8"},
{file = "cryptography-46.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:341fb7a26bc9d6093c1b124b9f13acc283d2d51da440b98b55ab3f79f2522ead"},
{file = "cryptography-46.0.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6ef1488967e729948d424d09c94753d0167ce59afba8d0f6c07a22b629c557b2"},
{file = "cryptography-46.0.1-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7823bc7cdf0b747ecfb096d004cc41573c2f5c7e3a29861603a2871b43d3ef32"},
{file = "cryptography-46.0.1-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:f736ab8036796f5a119ff8211deda416f8c15ce03776db704a7a4e17381cb2ef"},
{file = "cryptography-46.0.1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e46710a240a41d594953012213ea8ca398cd2448fbc5d0f1be8160b5511104a0"},
{file = "cryptography-46.0.1-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:84ef1f145de5aee82ea2447224dc23f065ff4cc5791bb3b506615957a6ba8128"},
{file = "cryptography-46.0.1-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9394c7d5a7565ac5f7d9ba38b2617448eba384d7b107b262d63890079fad77ca"},
{file = "cryptography-46.0.1-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ed957044e368ed295257ae3d212b95456bd9756df490e1ac4538857f67531fcc"},
{file = "cryptography-46.0.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f7de12fa0eee6234de9a9ce0ffcfa6ce97361db7a50b09b65c63ac58e5f22fc7"},
{file = "cryptography-46.0.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7fab1187b6c6b2f11a326f33b036f7168f5b996aedd0c059f9738915e4e8f53a"},
{file = "cryptography-46.0.1-cp38-abi3-win32.whl", hash = "sha256:45f790934ac1018adeba46a0f7289b2b8fe76ba774a88c7f1922213a56c98bc1"},
{file = "cryptography-46.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:7176a5ab56fac98d706921f6416a05e5aff7df0e4b91516f450f8627cda22af3"},
{file = "cryptography-46.0.1-cp38-abi3-win_arm64.whl", hash = "sha256:efc9e51c3e595267ff84adf56e9b357db89ab2279d7e375ffcaf8f678606f3d9"},
{file = "cryptography-46.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fd4b5e2ee4e60425711ec65c33add4e7a626adef79d66f62ba0acfd493af282d"},
{file = "cryptography-46.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:48948940d0ae00483e85e9154bb42997d0b77c21e43a77b7773c8c80de532ac5"},
{file = "cryptography-46.0.1-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b9c79af2c3058430d911ff1a5b2b96bbfe8da47d5ed961639ce4681886614e70"},
{file = "cryptography-46.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0ca4be2af48c24df689a150d9cd37404f689e2968e247b6b8ff09bff5bcd786f"},
{file = "cryptography-46.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:13e67c4d3fb8b6bc4ef778a7ccdd8df4cd15b4bcc18f4239c8440891a11245cc"},
{file = "cryptography-46.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:15b5fd9358803b0d1cc42505a18d8bca81dabb35b5cfbfea1505092e13a9d96d"},
{file = "cryptography-46.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e34da95e29daf8a71cb2841fd55df0511539a6cdf33e6f77c1e95e44006b9b46"},
{file = "cryptography-46.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:34f04b7311174469ab3ac2647469743720f8b6c8b046f238e5cb27905695eb2a"},
{file = "cryptography-46.0.1.tar.gz", hash = "sha256:ed570874e88f213437f5cf758f9ef26cbfc3f336d889b1e592ee11283bb8d1c7"},
]
[package.dependencies]
cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""}
cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""}
[package.extras]
docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""]
docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"]
docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""]
pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
nox = ["nox[uv] (>=2024.4.15)"]
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==45.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test = ["certifi (>=2024)", "cryptography-vectors (==46.0.1)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]]
@@ -276,17 +422,29 @@ files = [
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.1.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"},
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
]
[[package]]
name = "oauthlib"
version = "3.2.2"
version = "3.3.1"
description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic"
optional = false
python-versions = ">=3.6"
python-versions = ">=3.8"
groups = ["main"]
markers = "platform_system != \"Pyodide\""
files = [
{file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"},
{file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"},
{file = "oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1"},
{file = "oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9"},
]
[package.extras]
@@ -294,19 +452,62 @@ rsa = ["cryptography (>=3.0.0)"]
signals = ["blinker (>=1.4.0)"]
signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"]
[[package]]
name = "packaging"
version = "25.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pycparser"
version = "2.22"
version = "2.23"
description = "C parser in Python"
optional = false
python-versions = ">=3.8"
groups = ["main"]
markers = "platform_python_implementation != \"PyPy\""
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
files = [
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
{file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
{file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
]
[[package]]
name = "pygments"
version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyserial"
version = "3.5"
@@ -322,6 +523,85 @@ files = [
[package.extras]
cp2110 = ["hidapi"]
[[package]]
name = "pytest"
version = "8.4.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
{file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1"
packaging = ">=20"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "1.2.0"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99"},
{file = "pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57"},
]
[package.dependencies]
pytest = ">=8.2,<9"
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-cov"
version = "7.0.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
{file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
]
[package.dependencies]
coverage = {version = ">=7.10.6", extras = ["toml"]}
pluggy = ">=1.2"
pytest = ">=7"
[package.extras]
testing = ["process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytest-mock"
version = "3.15.1"
description = "Thin-wrapper around the mock package for easier use with pytest"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"},
{file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"},
]
[package.dependencies]
pytest = ">=6.2.5"
[package.extras]
dev = ["pre-commit", "pytest-asyncio", "tox"]
[[package]]
name = "repath"
version = "0.9.0"
@@ -339,13 +619,14 @@ six = ">=1.9.0"
[[package]]
name = "rns"
version = "0.9.6"
version = "1.0.0"
description = "Self-configuring, encrypted and resilient mesh networking stack for LoRa, packet radio, WiFi and everything in between"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "rns-0.9.6-py3-none-any.whl", hash = "sha256:a23c64a04c1e83fd0ab449f564ac904da7fd4f61c0faf68a063f486cc48b44bd"},
{file = "rns-1.0.0-py3-none-any.whl", hash = "sha256:5a9f18840510b69f89c6706d130177e2843c9e19c774707ae2661030d693dfc1"},
{file = "rns-1.0.0.tar.gz", hash = "sha256:9f1c594e4eabd64dea4c1bd59ad1b9291e6a28b1d8ab5689a19708f13100735b"},
]
[package.dependencies]
@@ -354,30 +635,30 @@ pyserial = ">=3.5"
[[package]]
name = "ruff"
version = "0.11.11"
version = "0.11.13"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "ruff-0.11.11-py3-none-linux_armv6l.whl", hash = "sha256:9924e5ae54125ed8958a4f7de320dab7380f6e9fa3195e3dc3b137c6842a0092"},
{file = "ruff-0.11.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c8a93276393d91e952f790148eb226658dd275cddfde96c6ca304873f11d2ae4"},
{file = "ruff-0.11.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6e333dbe2e6ae84cdedefa943dfd6434753ad321764fd937eef9d6b62022bcd"},
{file = "ruff-0.11.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7885d9a5e4c77b24e8c88aba8c80be9255fa22ab326019dac2356cff42089fc6"},
{file = "ruff-0.11.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b5ab797fcc09121ed82e9b12b6f27e34859e4227080a42d090881be888755d4"},
{file = "ruff-0.11.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e231ff3132c1119ece836487a02785f099a43992b95c2f62847d29bace3c75ac"},
{file = "ruff-0.11.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:a97c9babe1d4081037a90289986925726b802d180cca784ac8da2bbbc335f709"},
{file = "ruff-0.11.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8c4ddcbe8a19f59f57fd814b8b117d4fcea9bee7c0492e6cf5fdc22cfa563c8"},
{file = "ruff-0.11.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6224076c344a7694c6fbbb70d4f2a7b730f6d47d2a9dc1e7f9d9bb583faf390b"},
{file = "ruff-0.11.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:882821fcdf7ae8db7a951df1903d9cb032bbe838852e5fc3c2b6c3ab54e39875"},
{file = "ruff-0.11.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:dcec2d50756463d9df075a26a85a6affbc1b0148873da3997286caf1ce03cae1"},
{file = "ruff-0.11.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:99c28505ecbaeb6594701a74e395b187ee083ee26478c1a795d35084d53ebd81"},
{file = "ruff-0.11.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9263f9e5aa4ff1dec765e99810f1cc53f0c868c5329b69f13845f699fe74f639"},
{file = "ruff-0.11.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:64ac6f885e3ecb2fdbb71de2701d4e34526651f1e8503af8fb30d4915a3fe345"},
{file = "ruff-0.11.11-py3-none-win32.whl", hash = "sha256:1adcb9a18802268aaa891ffb67b1c94cd70578f126637118e8099b8e4adcf112"},
{file = "ruff-0.11.11-py3-none-win_amd64.whl", hash = "sha256:748b4bb245f11e91a04a4ff0f96e386711df0a30412b9fe0c74d5bdc0e4a531f"},
{file = "ruff-0.11.11-py3-none-win_arm64.whl", hash = "sha256:6c51f136c0364ab1b774767aa8b86331bd8e9d414e2d107db7a2189f35ea1f7b"},
{file = "ruff-0.11.11.tar.gz", hash = "sha256:7774173cc7c1980e6bf67569ebb7085989a78a103922fb83ef3dfe230cd0687d"},
{file = "ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46"},
{file = "ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48"},
{file = "ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b"},
{file = "ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a"},
{file = "ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc"},
{file = "ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629"},
{file = "ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933"},
{file = "ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165"},
{file = "ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71"},
{file = "ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9"},
{file = "ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc"},
{file = "ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7"},
{file = "ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432"},
{file = "ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492"},
{file = "ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250"},
{file = "ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3"},
{file = "ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b"},
{file = "ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514"},
]
[[package]]
@@ -408,4 +689,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = ">=3.13"
content-hash = "7c33d5fc8c448ce0080a3dd31c3e54ef6b559cad67354012ffb822867c21fbda"
content-hash = "1164f4cb57e282bd41d46df9cdbb43e0756ee0269442235c80aa96df57f740dc"

View File

@@ -1,6 +1,6 @@
[project]
name = "ren-browser"
version = "0.1.0"
version = "0.2.2"
description = "A browser for the Reticulum Network."
authors = [
{name = "Sudo-Ivan"}
@@ -10,7 +10,7 @@ readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"flet (>=0.28.3,<0.29.0)",
"rns (>=0.9.6,<0.10.0)"
"rns (>=1.0.0,<1.5.0)"
]
[build-system]
@@ -29,4 +29,11 @@ ren-browser-ios-dev = "ren_browser.app:ios_dev"
[tool.poetry.group.dev.dependencies]
ruff = "^0.11.11"
pytest = "^8.4.2"
pytest-cov = "^7.0.0"
pytest-mock = "^3.15.1"
pytest-asyncio = "^1.2.0"
[tool.flet.flutter.pubspec.dependency_overrides]
webview_flutter_android = "4.10.1"

17
pytest.ini Normal file
View File

@@ -0,0 +1,17 @@
[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--verbose
--tb=short
--strict-markers
--disable-warnings
markers =
unit: Unit tests
integration: Integration tests
slow: Slow running tests
filterwarnings =
ignore::DeprecationWarning
ignore::PendingDeprecationWarning

View File

@@ -1,46 +1,71 @@
"""Reticulum network announce handling for Ren Browser.
This module provides services for listening to and collecting network
announces from the Reticulum network.
"""
import time
from dataclasses import dataclass
import pathlib
import RNS
@dataclass
class Announce:
"""Represents a Reticulum network announce.
Contains destination hash, display name, and timestamp.
"""
destination_hash: str
display_name: str | None
timestamp: int
class AnnounceService:
"""
Service to listen for Reticulum announces and collect them.
"""Service to listen for Reticulum announces and collect them.
Calls update_callback whenever a new announce is received.
"""
def __init__(self, update_callback):
"""Initialize the announce service.
Args:
update_callback: Function called when new announces are received.
"""
self.aspect_filter = "nomadnetwork.node"
self.receive_path_responses = True
self.announces: list[Announce] = []
self.update_callback = update_callback
config_dir = pathlib.Path(__file__).resolve().parents[2] / "config"
try:
RNS.Reticulum(str(config_dir))
except (OSError, ValueError):
pass
# RNS should already be initialized by main app
RNS.Transport.register_announce_handler(self)
RNS.log("AnnounceService: registered announce handler")
def received_announce(self, destination_hash, announced_identity, app_data):
"""Handle received announce from Reticulum network.
Args:
destination_hash: Hash of the announcing destination.
announced_identity: Identity of the announcer.
app_data: Optional application data from the announce.
"""
RNS.log(f"AnnounceService: received announce from {destination_hash.hex()}")
ts = int(time.time())
display_name = None
if app_data:
try:
display_name = app_data.decode("utf-8")
except:
except UnicodeDecodeError:
pass
announce = Announce(destination_hash.hex(), display_name, ts)
self.announces = [ann for ann in self.announces if ann.destination_hash != announce.destination_hash]
self.announces = [
ann
for ann in self.announces
if ann.destination_hash != announce.destination_hash
]
self.announces.insert(0, announce)
if self.update_callback:
self.update_callback(self.announces)

View File

@@ -1,32 +1,74 @@
"""Ren Browser main application module.
This module provides the entry point and platform-specific launchers for the
Ren Browser, a browser for the Reticulum Network built with Flet.
"""
import argparse
import subprocess
import sys
import pathlib
import flet as ft
import RNS
from flet import AppView, Page
from ren_browser.storage.storage import initialize_storage
from ren_browser.ui.ui import build_ui
import RNS
RENDERER = "plaintext"
RNS_CONFIG_DIR = None
async def main(page: Page):
"""Initialize and launch the Ren Browser application.
Sets up the loading screen, initializes Reticulum network,
and builds the main UI.
"""
page.title = "Ren Browser"
page.theme_mode = ft.ThemeMode.DARK
loader = ft.Container(
expand=True,
alignment=ft.alignment.center,
bgcolor=ft.Colors.SURFACE,
content=ft.Column(
[ft.ProgressRing(), ft.Text("Initializing reticulum network")],
[
ft.ProgressRing(color=ft.Colors.PRIMARY, width=50, height=50),
ft.Container(height=20),
ft.Text(
"Initializing Reticulum Network...",
size=16,
color=ft.Colors.ON_SURFACE,
text_align=ft.TextAlign.CENTER,
),
],
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
spacing=10,
),
)
page.add(loader)
page.update()
def init_ret():
config_dir = pathlib.Path(__file__).resolve().parents[1] / "config"
import time
time.sleep(0.5)
# Initialize storage system
storage = initialize_storage(page)
# Get Reticulum config directory from storage manager
config_dir = storage.get_reticulum_config_path()
# Update the global RNS_CONFIG_DIR so RNS uses the right path
global RNS_CONFIG_DIR
RNS_CONFIG_DIR = str(config_dir)
try:
# Set up logging capture first, before RNS init
import ren_browser.logs
ren_browser.logs.setup_rns_logging()
RNS.Reticulum(str(config_dir))
except (OSError, ValueError):
pass
@@ -36,15 +78,42 @@ async def main(page: Page):
page.run_thread(init_ret)
def run():
global RENDERER
"""Run Ren Browser with command line argument parsing."""
global RENDERER, RNS_CONFIG_DIR
parser = argparse.ArgumentParser(description="Ren Browser")
parser.add_argument("-r", "--renderer", choices=["plaintext", "micron"], default=RENDERER, help="Select renderer (plaintext or micron)")
parser.add_argument("-w", "--web", action="store_true", help="Launch in web browser mode")
parser.add_argument("-p", "--port", type=int, default=None, help="Port for web server")
parser.add_argument(
"-r",
"--renderer",
choices=["plaintext", "micron"],
default=RENDERER,
help="Select renderer (plaintext or micron)",
)
parser.add_argument(
"-w", "--web", action="store_true", help="Launch in web browser mode"
)
parser.add_argument(
"-p", "--port", type=int, default=None, help="Port for web server"
)
parser.add_argument(
"-c",
"--config-dir",
type=str,
default=None,
help="RNS config directory (default: ~/.reticulum/)",
)
args = parser.parse_args()
RENDERER = args.renderer
# Set RNS config directory
if args.config_dir:
RNS_CONFIG_DIR = args.config_dir
else:
import pathlib
RNS_CONFIG_DIR = str(pathlib.Path.home() / ".reticulum")
if args.web:
if args.port is not None:
ft.app(main, view=AppView.WEB_BROWSER, port=args.port)
@@ -53,49 +122,41 @@ def run():
else:
ft.app(main)
if __name__ == "__main__":
run()
def web():
"""Launch Ren Browser in web mode via Flet CLI."""
script_path = pathlib.Path(__file__).resolve()
rc = subprocess.call(["flet", "run", str(script_path), "--web"])
sys.exit(rc)
"""Launch Ren Browser in web mode."""
ft.app(main, view=AppView.WEB_BROWSER)
def android():
"""Launch Ren Browser in Android mode via Flet CLI."""
script_path = pathlib.Path(__file__).resolve()
rc = subprocess.call(["flet", "run", str(script_path), "--android"])
sys.exit(rc)
"""Launch Ren Browser in Android mode."""
ft.app(main, view=AppView.FLET_APP_WEB)
def ios():
"""Launch Ren Browser in iOS mode via Flet CLI."""
script_path = pathlib.Path(__file__).resolve()
rc = subprocess.call(["flet", "run", str(script_path), "--ios"])
sys.exit(rc)
"""Launch Ren Browser in iOS mode."""
ft.app(main, view=AppView.FLET_APP_WEB)
# Hot reload (dev) mode entrypoints
def run_dev():
"""Launch Ren Browser in desktop mode via Flet CLI with hot reload."""
script_path = pathlib.Path(__file__).resolve()
rc = subprocess.call(["flet", "run", "-d", "-r", str(script_path)])
sys.exit(rc)
"""Launch Ren Browser in desktop mode."""
ft.app(main)
def web_dev():
"""Launch Ren Browser in web mode via Flet CLI with hot reload."""
script_path = pathlib.Path(__file__).resolve()
rc = subprocess.call(["flet", "run", "--web", "-d", "-r", str(script_path)])
sys.exit(rc)
"""Launch Ren Browser in web mode."""
ft.app(main, view=AppView.WEB_BROWSER)
def android_dev():
"""Launch Ren Browser in Android mode via Flet CLI with hot reload."""
script_path = pathlib.Path(__file__).resolve()
rc = subprocess.call(["flet", "run", "--android", "-d", "-r", str(script_path)])
sys.exit(rc)
"""Launch Ren Browser in Android mode."""
ft.app(main, view=AppView.FLET_APP_WEB)
def ios_dev():
"""Launch Ren Browser in iOS mode via Flet CLI with hot reload."""
script_path = pathlib.Path(__file__).resolve()
rc = subprocess.call(["flet", "run", "--ios", "-d", "-r", str(script_path)])
sys.exit(rc)
"""Launch Ren Browser in iOS mode."""
ft.app(main, view=AppView.FLET_APP_WEB)

View File

@@ -1,14 +1,37 @@
"""Keyboard shortcuts handling for Ren Browser.
Provides keyboard event handling and delegation to tab manager
and UI components.
"""
import flet as ft
class Shortcuts:
"""Handles keyboard shortcuts for the Ren Browser.
Provides shortcuts for tab management, navigation, and UI actions.
"""
def __init__(self, page: ft.Page, tab_manager):
"""Attach keyboard event handler to page and delegate actions to tab_manager and UI."""
"""Initialize shortcuts handler.
Args:
page: Flet page instance to attach keyboard events to.
tab_manager: Tab manager instance for tab-related actions.
"""
self.page = page
self.tab_manager = tab_manager
page.on_keyboard_event = self.on_keyboard
def on_keyboard(self, e: ft.KeyboardEvent):
"""Handle keyboard events and execute corresponding actions.
Args:
e: Keyboard event from Flet.
"""
# Support Ctrl (and Meta on macOS)
ctrl = e.ctrl or e.meta
if not ctrl:

View File

@@ -1,20 +1,60 @@
"""Logging system for Ren Browser.
Provides centralized logging for application events, errors, and
Reticulum network activities.
"""
import datetime
import RNS
APP_LOGS: list[str] = []
ERROR_LOGS: list[str] = []
RET_LOGS: list[str] = []
_original_RNS_log = RNS.log
_original_rns_log = RNS.log
def log_ret(msg, *args, **kwargs):
"""Log Reticulum messages with timestamp.
Args:
msg: Log message.
*args: Additional arguments passed to original RNS.log.
**kwargs: Additional keyword arguments passed to original RNS.log.
"""
timestamp = datetime.datetime.now().isoformat()
RET_LOGS.append(f"[{timestamp}] {msg}")
return _original_RNS_log(msg, *args, **kwargs)
return _original_rns_log(msg, *args, **kwargs)
def setup_rns_logging():
"""Set up RNS log replacement. Call this after RNS.Reticulum initialization."""
global _original_rns_log
# Only set up if not already done and if RNS.log is not already our function
if RNS.log is not log_ret and _original_rns_log is not log_ret:
_original_rns_log = RNS.log
RNS.log = log_ret
def log_error(msg: str):
"""Log error messages to both error and application logs.
Args:
msg: Error message to log.
"""
timestamp = datetime.datetime.now().isoformat()
ERROR_LOGS.append(f"[{timestamp}] {msg}")
APP_LOGS.append(f"[{timestamp}] ERROR: {msg}")
def log_app(msg: str):
"""Log application messages.
Args:
msg: Application message to log.
"""
timestamp = datetime.datetime.now().isoformat()
APP_LOGS.append(f"[{timestamp}] {msg}")

View File

@@ -1,34 +1,52 @@
"""Page fetching functionality for Ren Browser.
Handles downloading pages from the Reticulum network using
the nomadnetwork protocol.
"""
import threading
import time
import pathlib
from dataclasses import dataclass
import RNS
from dataclasses import dataclass
@dataclass
class PageRequest:
"""Represents a request for a page from the Reticulum network.
Contains the destination hash, page path, and optional field data.
"""
destination_hash: str
page_path: str
field_data: dict | None = None
class PageFetcher:
"""
Fetcher to download pages from the Reticulum network.
"""
"""Fetcher to download pages from the Reticulum network."""
def __init__(self):
config_dir = pathlib.Path(__file__).resolve().parents[2] / "config"
try:
RNS.Reticulum(str(config_dir))
except (OSError, ValueError):
pass
"""Initialize the page fetcher and Reticulum connection."""
# RNS should already be initialized by main app
@staticmethod
def fetch_page(req: PageRequest) -> str:
"""Download page content for the given PageRequest.
Args:
req: PageRequest containing destination and path information.
Returns:
str: The downloaded page content.
Raises:
Exception: If no path to destination or identity not found.
def fetch_page(self, req: PageRequest) -> str:
RNS.log(f"PageFetcher: starting fetch of {req.page_path} from {req.destination_hash}")
"""
Download page content for the given PageRequest.
"""
RNS.log(
f"PageFetcher: starting fetch of {req.page_path} from {req.destination_hash}"
)
dest_bytes = bytes.fromhex(req.destination_hash)
if not RNS.Transport.has_path(dest_bytes):
RNS.Transport.request_path(dest_bytes)
@@ -39,34 +57,41 @@ class PageFetcher:
time.sleep(0.1)
identity = RNS.Identity.recall(dest_bytes)
if not identity:
raise Exception('Identity not found')
raise Exception("Identity not found")
destination = RNS.Destination(
identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
'nomadnetwork',
'node',
"nomadnetwork",
"node",
)
link = RNS.Link(destination)
result = {'data': None}
result = {"data": None}
ev = threading.Event()
def on_response(receipt):
data = receipt.response
if isinstance(data, bytes):
result['data'] = data.decode('utf-8')
result["data"] = data.decode("utf-8")
else:
result['data'] = str(data)
result["data"] = str(data)
ev.set()
def on_failed(_):
ev.set()
link.set_link_established_callback(
lambda l: l.request(req.page_path, req.field_data, response_callback=on_response, failed_callback=on_failed)
lambda link: link.request(
req.page_path,
req.field_data,
response_callback=on_response,
failed_callback=on_failed,
)
)
ev.wait(timeout=15)
data_str = result['data'] or 'No content received'
RNS.log(f"PageFetcher: received data for {req.destination_hash}:{req.page_path}")
data_str = result["data"] or "No content received"
RNS.log(
f"PageFetcher: received data for {req.destination_hash}:{req.page_path}"
)
return data_str

View File

@@ -1 +1,6 @@
"""Performance profiler for Ren Browser.
Provides profiling capabilities for monitoring browser performance
and resource usage.
"""
# Add a profiler to the browser.

View File

@@ -0,0 +1,5 @@
"""Content rendering package for Ren Browser.
Provides rendering capabilities for different content types
including micron markup and plaintext.
"""

View File

@@ -1,9 +1,23 @@
"""Micron markup renderer for Ren Browser.
Provides rendering capabilities for micron markup content,
currently implemented as a placeholder.
"""
import flet as ft
def render_micron(content: str) -> ft.Control:
"""Render micron markup content to a Flet control placeholder.
Currently displays raw content.
Args:
content: Micron markup content to render.
Returns:
ft.Control: Rendered content as a Flet control.
"""
return ft.Text(
content,

View File

@@ -1,10 +1,13 @@
"""Plaintext renderer for Ren Browser.
Provides fallback rendering for plaintext content and source viewing.
"""
import flet as ft
def render_plaintext(content: str) -> ft.Control:
"""
Fallback plaintext renderer: displays raw text safely in a monospace, selectable control.
"""
"""Fallback plaintext renderer: displays raw text safely in a monospace, selectable control."""
return ft.Text(
content,
selectable=True,

View File

@@ -1 +1,306 @@
# Add storage system/management, eg handling downloading files, saving bookmarks, caching, tabs and history.
"""Cross-platform storage management for Ren Browser.
Provides persistent storage for configuration, bookmarks, history,
and other application data across different platforms.
"""
import json
import os
import pathlib
from typing import Any, Dict, Optional
import flet as ft
class StorageManager:
"""Cross-platform storage manager for Ren Browser.
Handles configuration, bookmarks, history, and other persistent data
with platform-specific storage locations.
"""
def __init__(self, page: Optional[ft.Page] = None):
"""Initialize storage manager.
Args:
page: Optional Flet page instance for client storage access.
"""
self.page = page
self._storage_dir = self._get_storage_directory()
self._ensure_storage_directory()
def _get_storage_directory(self) -> pathlib.Path:
"""Get the appropriate storage directory for the current platform."""
# Try to use Flet's client storage if available (works on all platforms)
if self.page and hasattr(self.page, "client_storage"):
pass
if os.name == "posix" and "ANDROID_ROOT" in os.environ:
# Android - use app's private files directory
storage_dir = pathlib.Path("/data/data/com.ren_browser/files")
elif hasattr(os, "uname") and "iOS" in str(
getattr(os, "uname", lambda: "")()
).replace("iPhone", "iOS"):
# iOS - use app's documents directory
storage_dir = pathlib.Path.home() / "Documents" / "ren_browser"
else:
# Desktop (Linux, Windows, macOS) - use home directory
if "APPDATA" in os.environ: # Windows
storage_dir = pathlib.Path(os.environ["APPDATA"]) / "ren_browser"
elif "XDG_CONFIG_HOME" in os.environ: # Linux XDG standard
storage_dir = (
pathlib.Path(os.environ["XDG_CONFIG_HOME"]) / "ren_browser"
)
else:
storage_dir = pathlib.Path.home() / ".ren_browser"
return storage_dir
def _ensure_storage_directory(self):
"""Ensure the storage directory exists."""
try:
self._storage_dir.mkdir(parents=True, exist_ok=True)
except (OSError, PermissionError):
import tempfile
self._storage_dir = pathlib.Path(tempfile.gettempdir()) / "ren_browser"
self._storage_dir.mkdir(parents=True, exist_ok=True)
def get_config_path(self) -> pathlib.Path:
"""Get the path to the main configuration file."""
return self._storage_dir / "config"
def get_reticulum_config_path(self) -> pathlib.Path:
"""Get the path to the Reticulum configuration directory."""
# Check for global override from app
try:
from ren_browser.app import RNS_CONFIG_DIR
if RNS_CONFIG_DIR:
return pathlib.Path(RNS_CONFIG_DIR)
except ImportError:
pass
# On Android, use app storage directory instead of ~/.reticulum
if os.name == "posix" and "ANDROID_ROOT" in os.environ:
return self._storage_dir / "reticulum"
# Default to standard RNS config directory
return pathlib.Path.home() / ".reticulum"
def save_config(self, config_content: str) -> bool:
"""Save configuration content to file.
Args:
config_content: Configuration text to save
Returns:
True if successful, False otherwise
"""
try:
# Always save to client storage first (most reliable on mobile)
if self.page and hasattr(self.page, "client_storage"):
self.page.client_storage.set("ren_browser_config", config_content)
# Save to reticulum config directory for RNS to use
reticulum_config_path = self.get_reticulum_config_path() / "config"
reticulum_config_path.parent.mkdir(parents=True, exist_ok=True)
reticulum_config_path.write_text(config_content, encoding="utf-8")
# Also save to local config path as backup
config_path = self.get_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(config_content, encoding="utf-8")
return True
except (OSError, PermissionError, UnicodeEncodeError) as e:
return self._save_config_fallback(config_content, str(e))
def _save_config_fallback(self, config_content: str, error: str) -> bool:
"""Fallback config saving for when primary method fails."""
try:
if self.page and hasattr(self.page, "client_storage"):
self.page.client_storage.set("ren_browser_config", config_content)
self.page.client_storage.set(
"ren_browser_config_error", f"File save failed: {error}"
)
return True
try:
reticulum_config_path = self.get_reticulum_config_path() / "config"
reticulum_config_path.write_text(config_content, encoding="utf-8")
return True
except (OSError, PermissionError):
pass
import tempfile
temp_path = pathlib.Path(tempfile.gettempdir()) / "ren_browser_config.txt"
temp_path.write_text(config_content, encoding="utf-8")
return True
except Exception:
return False
def load_config(self) -> str:
"""Load configuration content from storage.
Returns:
Configuration text, or empty string if not found
"""
# On Android, prioritize client storage first as it's more reliable
if os.name == "posix" and "ANDROID_ROOT" in os.environ:
if self.page and hasattr(self.page, "client_storage"):
stored_config = self.page.client_storage.get("ren_browser_config")
if stored_config:
return stored_config
try:
reticulum_config_path = self.get_reticulum_config_path() / "config"
if reticulum_config_path.exists():
return reticulum_config_path.read_text(encoding="utf-8")
config_path = self.get_config_path()
if config_path.exists():
return config_path.read_text(encoding="utf-8")
# Fallback to client storage for non-Android or if files don't exist
if self.page and hasattr(self.page, "client_storage"):
stored_config = self.page.client_storage.get("ren_browser_config")
if stored_config:
return stored_config
except (OSError, PermissionError, UnicodeDecodeError):
# If file access fails, try client storage as fallback
if self.page and hasattr(self.page, "client_storage"):
stored_config = self.page.client_storage.get("ren_browser_config")
if stored_config:
return stored_config
return ""
def save_bookmarks(self, bookmarks: list) -> bool:
"""Save bookmarks to storage."""
try:
bookmarks_path = self._storage_dir / "bookmarks.json"
with open(bookmarks_path, "w", encoding="utf-8") as f:
json.dump(bookmarks, f, indent=2)
if self.page and hasattr(self.page, "client_storage"):
self.page.client_storage.set(
"ren_browser_bookmarks", json.dumps(bookmarks)
)
return True
except Exception:
return False
def load_bookmarks(self) -> list:
"""Load bookmarks from storage."""
try:
bookmarks_path = self._storage_dir / "bookmarks.json"
if bookmarks_path.exists():
with open(bookmarks_path, "r", encoding="utf-8") as f:
return json.load(f)
if self.page and hasattr(self.page, "client_storage"):
stored_bookmarks = self.page.client_storage.get("ren_browser_bookmarks")
if stored_bookmarks:
return json.loads(stored_bookmarks)
except (OSError, json.JSONDecodeError):
pass
return []
def save_history(self, history: list) -> bool:
"""Save browsing history to storage."""
try:
history_path = self._storage_dir / "history.json"
with open(history_path, "w", encoding="utf-8") as f:
json.dump(history, f, indent=2)
if self.page and hasattr(self.page, "client_storage"):
self.page.client_storage.set("ren_browser_history", json.dumps(history))
return True
except Exception:
return False
def load_history(self) -> list:
"""Load browsing history from storage."""
try:
history_path = self._storage_dir / "history.json"
if history_path.exists():
with open(history_path, "r", encoding="utf-8") as f:
return json.load(f)
if self.page and hasattr(self.page, "client_storage"):
stored_history = self.page.client_storage.get("ren_browser_history")
if stored_history:
return json.loads(stored_history)
except (OSError, json.JSONDecodeError):
pass
return []
def get_storage_info(self) -> Dict[str, Any]:
"""Get information about the storage system."""
return {
"storage_dir": str(self._storage_dir),
"config_path": str(self.get_config_path()),
"reticulum_config_path": str(self.get_reticulum_config_path()),
"storage_dir_exists": self._storage_dir.exists(),
"storage_dir_writable": self._is_writable(self._storage_dir),
"has_client_storage": self.page and hasattr(self.page, "client_storage"),
}
@staticmethod
def _is_writable(path: pathlib.Path) -> bool:
"""Check if a directory is writable."""
try:
test_file = path / ".write_test"
test_file.write_text("test")
test_file.unlink()
return True
except (OSError, PermissionError):
return False
# Global storage instance
_storage_manager: Optional[StorageManager] = None
def get_storage_manager(page: Optional[ft.Page] = None) -> StorageManager:
"""Get the global storage manager instance."""
global _storage_manager
if _storage_manager is None:
_storage_manager = StorageManager(page)
elif page and _storage_manager.page is None:
_storage_manager.page = page
return _storage_manager
def initialize_storage(page: ft.Page) -> StorageManager:
"""Initialize the storage system with a Flet page."""
global _storage_manager
_storage_manager = StorageManager(page)
return _storage_manager
def get_rns_config_directory() -> str:
"""Get the RNS config directory, checking for global override."""
try:
from ren_browser.app import RNS_CONFIG_DIR
if RNS_CONFIG_DIR:
return RNS_CONFIG_DIR
except ImportError:
pass
# Default to standard RNS config directory
return str(pathlib.Path.home() / ".reticulum")

View File

@@ -1,3 +1,9 @@
"""Tab management system for Ren Browser.
Provides tab creation, switching, and content management functionality
for the browser interface.
"""
from types import SimpleNamespace
import flet as ft
@@ -7,17 +13,39 @@ from ren_browser.renderer.plaintext import render_plaintext
class TabsManager:
"""Manages browser tabs and their content.
Handles tab creation, switching, closing, and content rendering.
"""
def __init__(self, page: ft.Page):
"""Initialize the tab manager.
Args:
page: Flet page instance for UI updates.
"""
import ren_browser.app as app_module
self.page = page
self.manager = SimpleNamespace(tabs=[], index=0)
self.tab_bar = ft.Row(spacing=4)
self.content_container = ft.Container(expand=True, bgcolor=ft.Colors.BLACK, padding=ft.padding.all(5))
self.content_container = ft.Container(
expand=True, bgcolor=ft.Colors.BLACK, padding=ft.padding.all(5)
)
default_content = render_micron("Welcome to Ren Browser") if app_module.RENDERER == "micron" else render_plaintext("Welcome to Ren Browser")
default_content = (
render_micron("Welcome to Ren Browser")
if app_module.RENDERER == "micron"
else render_plaintext("Welcome to Ren Browser")
)
self._add_tab_internal("Home", default_content)
self.add_btn = ft.IconButton(ft.Icons.ADD, tooltip="New Tab", on_click=self._on_add_click)
self.close_btn = ft.IconButton(ft.Icons.CLOSE, tooltip="Close Tab", on_click=self._on_close_click)
self.add_btn = ft.IconButton(
ft.Icons.ADD, tooltip="New Tab", on_click=self._on_add_click
)
self.close_btn = ft.IconButton(
ft.Icons.CLOSE, tooltip="Close Tab", on_click=self._on_close_click
)
self.tab_bar.controls.extend([self.add_btn, self.close_btn])
self.select_tab(0)
@@ -27,9 +55,13 @@ class TabsManager:
value=title,
expand=True,
text_style=ft.TextStyle(size=12),
content_padding=ft.padding.only(top=8, bottom=8, left=8, right=8)
content_padding=ft.padding.only(top=8, bottom=8, left=8, right=8),
)
go_btn = ft.IconButton(
ft.Icons.OPEN_IN_BROWSER,
tooltip="Load URL",
on_click=lambda e, i=idx: self._on_tab_go(e, i),
)
go_btn = ft.IconButton(ft.Icons.OPEN_IN_BROWSER, tooltip="Load URL", on_click=lambda e, i=idx: self._on_tab_go(e, i))
content_control = content
tab_content = ft.Column(
expand=True,
@@ -37,13 +69,15 @@ class TabsManager:
content_control,
],
)
self.manager.tabs.append({
self.manager.tabs.append(
{
"title": title,
"url_field": url_field,
"go_btn": go_btn,
"content_control": content_control,
"content": tab_content,
})
}
)
btn = ft.Container(
content=ft.Text(title),
on_click=lambda e, i=idx: self.select_tab(i),
@@ -58,7 +92,12 @@ class TabsManager:
title = f"Tab {len(self.manager.tabs) + 1}"
content_text = f"Content for {title}"
import ren_browser.app as app_module
content = render_micron(content_text) if app_module.RENDERER == "micron" else render_plaintext(content_text)
content = (
render_micron(content_text)
if app_module.RENDERER == "micron"
else render_plaintext(content_text)
)
self._add_tab_internal(title, content)
self.select_tab(len(self.manager.tabs) - 1)
self.page.update()
@@ -76,6 +115,12 @@ class TabsManager:
self.page.update()
def select_tab(self, idx: int):
"""Select and display the tab at the given index.
Args:
idx: Index of the tab to select.
"""
self.manager.index = idx
for i, control in enumerate(self.tab_bar.controls[:-2]):
if i == idx:
@@ -92,7 +137,12 @@ class TabsManager:
return
placeholder_text = f"Loading content for {url}"
import ren_browser.app as app_module
new_control = render_micron(placeholder_text) if app_module.RENDERER == "micron" else render_plaintext(placeholder_text)
new_control = (
render_micron(placeholder_text)
if app_module.RENDERER == "micron"
else render_plaintext(placeholder_text)
)
tab["content_control"] = new_control
tab["content"].controls[0] = new_control
if self.manager.index == idx:

View File

@@ -1,57 +1,125 @@
"""Settings interface for Ren Browser.
Provides configuration management, log viewing, and storage
information display.
"""
import flet as ft
import pathlib
from ren_browser.logs import ERROR_LOGS, RET_LOGS
from ren_browser.storage.storage import get_storage_manager
def open_settings_tab(page: ft.Page, tab_manager):
config_path = pathlib.Path(__file__).resolve().parents[2] / "config" / "config"
"""Open a settings tab with configuration and debugging options.
Args:
page: Flet page instance for UI updates.
tab_manager: Tab manager to add the settings tab to.
"""
storage = get_storage_manager(page)
try:
config_text = config_path.read_text()
config_text = storage.load_config()
except Exception as ex:
config_text = f"Error reading config: {ex}"
config_field = ft.TextField(
label="Reticulum config",
value=config_text,
expand=True,
multiline=True,
)
def on_save_config(ev):
try:
config_path.write_text(config_field.value)
page.snack_bar = ft.SnackBar(ft.Text("Config saved. Please restart the app."), open=True)
success = storage.save_config(config_field.value)
if success:
page.snack_bar = ft.SnackBar(
ft.Text("Config saved successfully. Please restart the app."),
open=True,
)
else:
page.snack_bar = ft.SnackBar(
ft.Text("Error saving config: Storage operation failed"), open=True
)
except Exception as ex:
page.snack_bar = ft.SnackBar(ft.Text(f"Error saving config: {ex}"), open=True)
page.snack_bar = ft.SnackBar(
ft.Text(f"Error saving config: {ex}"), open=True
)
page.update()
save_btn = ft.ElevatedButton("Save and Restart", on_click=on_save_config)
error_text = "\n".join(ERROR_LOGS) or "No errors logged."
error_field = ft.TextField(
label="Error Logs",
value=error_text,
value="",
expand=True,
multiline=True,
read_only=True,
)
ret_text = "\n".join(RET_LOGS) or "No Reticulum logs."
ret_field = ft.TextField(
label="Reticulum logs",
value=ret_text,
value="",
expand=True,
multiline=True,
read_only=True,
)
# Storage information for debugging
storage_info = storage.get_storage_info()
storage_text = "\n".join([f"{key}: {value}" for key, value in storage_info.items()])
storage_field = ft.TextField(
label="Storage Information",
value=storage_text,
expand=True,
multiline=True,
read_only=True,
)
content_placeholder = ft.Container(expand=True)
def show_config(ev):
content_placeholder.content = config_field
page.update()
def show_errors(ev):
error_field.value = "\n".join(ERROR_LOGS) or "No errors logged."
content_placeholder.content = error_field
page.update()
def show_ret_logs(ev):
ret_field.value = "\n".join(RET_LOGS) or "No Reticulum logs."
content_placeholder.content = ret_field
page.update()
def show_storage_info(ev):
storage_info = storage.get_storage_info()
storage_field.value = "\n".join(
[f"{key}: {value}" for key, value in storage_info.items()]
)
content_placeholder.content = storage_field
page.update()
def refresh_current_view(ev):
# Refresh the currently displayed content
if content_placeholder.content == error_field:
show_errors(ev)
elif content_placeholder.content == ret_field:
show_ret_logs(ev)
elif content_placeholder.content == storage_field:
show_storage_info(ev)
elif content_placeholder.content == config_field:
show_config(ev)
btn_config = ft.ElevatedButton("Config", on_click=show_config)
btn_errors = ft.ElevatedButton("Errors", on_click=show_errors)
btn_ret = ft.ElevatedButton("Ret Logs", on_click=show_ret_logs)
button_row = ft.Row(controls=[btn_config, btn_errors, btn_ret])
btn_storage = ft.ElevatedButton("Storage", on_click=show_storage_info)
btn_refresh = ft.ElevatedButton("Refresh", on_click=refresh_current_view)
button_row = ft.Row(
controls=[btn_config, btn_errors, btn_ret, btn_storage, btn_refresh]
)
content_placeholder.content = config_field
settings_content = ft.Column(
expand=True,

View File

@@ -1,3 +1,9 @@
"""Main UI construction for Ren Browser.
Builds the complete browser interface including tabs, navigation,
announce handling, and content rendering.
"""
import flet as ft
from flet import Page
@@ -10,17 +16,24 @@ from ren_browser.tabs.tabs import TabsManager
def build_ui(page: Page):
import ren_browser.app as app_module
"""Build and configure the main browser UI.
Args:
page: Flet page instance to build UI on.
"""
page.theme_mode = ft.ThemeMode.DARK
page.appbar = ft.AppBar()
page.window.maximized = True
page_fetcher = PageFetcher()
announce_list = ft.ListView(expand=True, spacing=1)
def update_announces(ann_list):
announce_list.controls.clear()
for ann in ann_list:
label = ann.display_name or ann.destination_hash
def on_click_ann(e, dest=ann.destination_hash, disp=ann.display_name):
title = disp or "Anonymous"
full_url = f"{dest}:/page/index.mu"
@@ -31,12 +44,14 @@ def build_ui(page: Page):
tab["url_field"].value = full_url
tab_manager.select_tab(idx)
page.update()
def fetch_and_update():
req = PageRequest(destination_hash=dest, page_path="/page/index.mu")
try:
result = page_fetcher.fetch_page(req)
except Exception as ex:
import ren_browser.app as app_module
app_module.log_error(str(ex))
result = f"Error: {ex}"
try:
@@ -52,13 +67,21 @@ def build_ui(page: Page):
if tab_manager.manager.index == idx:
tab_manager.content_container.content = tab["content"]
page.update()
page.run_thread(fetch_and_update)
announce_list.controls.append(ft.TextButton(label, on_click=on_click_ann))
page.update()
AnnounceService(update_callback=update_announces)
page.drawer = ft.NavigationDrawer(
controls=[
ft.Text("Announcements", weight=ft.FontWeight.BOLD, text_align=ft.TextAlign.CENTER, expand=True),
ft.Text(
"Announcements",
weight=ft.FontWeight.BOLD,
text_align=ft.TextAlign.CENTER,
expand=True,
),
ft.Divider(),
announce_list,
],
@@ -66,12 +89,22 @@ def build_ui(page: Page):
page.appbar.leading = ft.IconButton(
ft.Icons.MENU,
tooltip="Toggle sidebar",
on_click=lambda e: (setattr(page.drawer, 'open', not page.drawer.open), page.update()),
on_click=lambda e: (
setattr(page.drawer, "open", not page.drawer.open),
page.update(),
),
)
tab_manager = TabsManager(page)
from ren_browser.ui.settings import open_settings_tab
page.appbar.actions = [ft.IconButton(ft.Icons.SETTINGS, tooltip="Settings", on_click=lambda e: open_settings_tab(page, tab_manager))]
page.appbar.actions = [
ft.IconButton(
ft.Icons.SETTINGS,
tooltip="Settings",
on_click=lambda e: open_settings_tab(page, tab_manager),
)
]
Shortcuts(page, tab_manager)
url_bar = ft.Row(
controls=[
@@ -81,15 +114,19 @@ def build_ui(page: Page):
)
page.appbar.title = url_bar
orig_select_tab = tab_manager.select_tab
def _select_tab_and_update_url(i):
orig_select_tab(i)
tab = tab_manager.manager.tabs[i]
url_bar.controls.clear()
url_bar.controls.extend([tab["url_field"], tab["go_btn"]])
page.update()
tab_manager.select_tab = _select_tab_and_update_url
def _update_content_width(e=None):
tab_manager.content_container.width = page.width
_update_content_width()
page.on_resized = lambda e: (_update_content_width(), page.update())
main_area = ft.Column(

43
tests/README.md Normal file
View File

@@ -0,0 +1,43 @@
# Ren Browser Basic Test Suite
## To-Do
- Security tests
- Performance tests
- Proper RNS support and testing
- Micron Renderer tests (when implemented)
This directory contains comprehensive tests for the Ren Browser application.
## Test Structure
- `unit/` - Unit tests for individual components
- `integration/` - Integration tests for component interactions
- `conftest.py` - Shared test fixtures and configuration
## Running Tests
### All Tests
```bash
poetry run pytest
```
### Unit Tests Only
```bash
poetry run pytest tests/unit/
```
### Integration Tests Only
```bash
poetry run pytest tests/integration/
```
### Specific Test File
```bash
poetry run pytest tests/unit/test_app.py
```
### With Coverage
```bash
poetry run pytest --cov=ren_browser --cov-report=html
```

0
tests/__init__.py Normal file
View File

85
tests/conftest.py Normal file
View File

@@ -0,0 +1,85 @@
from unittest.mock import MagicMock, Mock
import flet as ft
import pytest
@pytest.fixture
def mock_page():
"""Create a mock Flet page for testing."""
page = Mock(spec=ft.Page)
page.add = Mock()
page.update = Mock()
page.run_thread = Mock()
page.controls = []
page.theme_mode = ft.ThemeMode.DARK
page.appbar = Mock()
page.drawer = Mock()
page.window = Mock()
page.width = 1024
page.snack_bar = None
page.on_resized = None
page.on_keyboard_event = None
return page
@pytest.fixture
def mock_rns():
"""Mock RNS module to avoid network dependencies in tests."""
mock_rns = MagicMock()
mock_rns.Reticulum = Mock()
mock_rns.Transport = Mock()
mock_rns.Identity = Mock()
mock_rns.Destination = Mock()
mock_rns.Link = Mock()
mock_rns.log = Mock()
# Mock at the module level for all imports
import sys
sys.modules["RNS"] = mock_rns
yield mock_rns
# Cleanup
if "RNS" in sys.modules:
del sys.modules["RNS"]
@pytest.fixture
def sample_announce_data():
"""Sample announce data for testing."""
return {
"destination_hash": "1234567890abcdef",
"display_name": "Test Node",
"timestamp": 1234567890,
}
@pytest.fixture
def sample_page_request():
"""Sample page request for testing."""
from ren_browser.pages.page_request import PageRequest
return PageRequest(
destination_hash="1234567890abcdef", page_path="/page/index.mu", field_data=None
)
@pytest.fixture
def mock_storage_manager():
"""Mock storage manager for testing."""
mock_storage = Mock()
mock_storage.load_config.return_value = "test config content"
mock_storage.save_config.return_value = True
mock_storage.get_config_path.return_value = Mock()
mock_storage.get_reticulum_config_path.return_value = Mock()
mock_storage.get_storage_info.return_value = {
"storage_dir": "/mock/storage",
"config_path": "/mock/storage/config.txt",
"reticulum_config_path": "/mock/storage/reticulum",
"storage_dir_exists": True,
"storage_dir_writable": True,
"has_client_storage": True,
}
return mock_storage

View File

View File

@@ -0,0 +1,104 @@
from unittest.mock import Mock
import pytest
from ren_browser import app
class TestAppIntegration:
"""Integration tests for the main app functionality."""
@pytest.mark.asyncio
async def test_main_function_structure(self):
"""Test that the main function has the expected structure."""
mock_page = Mock()
mock_page.add = Mock()
mock_page.update = Mock()
mock_page.run_thread = Mock()
mock_page.controls = Mock()
mock_page.controls.clear = Mock()
await app.main(mock_page)
# Verify that the main function sets up the loading screen
mock_page.add.assert_called_once()
mock_page.update.assert_called()
mock_page.run_thread.assert_called_once()
def test_entry_points_exist(self):
"""Test that all expected entry points exist and are callable."""
entry_points = [
"run",
"web",
"android",
"ios",
"run_dev",
"web_dev",
"android_dev",
"ios_dev",
]
for entry_point in entry_points:
assert hasattr(app, entry_point)
assert callable(getattr(app, entry_point))
def test_renderer_global_exists(self):
"""Test that the RENDERER global variable exists."""
assert hasattr(app, "RENDERER")
assert app.RENDERER in ["plaintext", "micron"]
def test_app_module_imports(self):
"""Test that required modules can be imported."""
# Test that the app module imports work
import ren_browser.app
import ren_browser.ui.ui
# Verify key functions exist
assert hasattr(ren_browser.app, "main")
assert hasattr(ren_browser.app, "run")
assert hasattr(ren_browser.ui.ui, "build_ui")
class TestModuleIntegration:
"""Integration tests for module interactions."""
def test_renderer_modules_exist(self):
"""Test that renderer modules can be imported."""
from ren_browser.renderer import micron, plaintext
assert hasattr(plaintext, "render_plaintext")
assert hasattr(micron, "render_micron")
assert callable(plaintext.render_plaintext)
assert callable(micron.render_micron)
def test_data_classes_exist(self):
"""Test that data classes can be imported and used."""
from ren_browser.announces.announces import Announce
from ren_browser.pages.page_request import PageRequest
# Test Announce creation
announce = Announce("hash1", "name1", 1000)
assert announce.destination_hash == "hash1"
# Test PageRequest creation
request = PageRequest("hash2", "/path")
assert request.destination_hash == "hash2"
def test_logs_module_integration(self):
"""Test that logs module integrates correctly."""
from ren_browser import logs
# Test that log functions exist
assert hasattr(logs, "log_error")
assert hasattr(logs, "log_app")
assert hasattr(logs, "log_ret")
# Test that log storage exists
assert hasattr(logs, "APP_LOGS")
assert hasattr(logs, "ERROR_LOGS")
assert hasattr(logs, "RET_LOGS")
# Test that they are lists
assert isinstance(logs.APP_LOGS, list)
assert isinstance(logs.ERROR_LOGS, list)
assert isinstance(logs.RET_LOGS, list)

0
tests/unit/__init__.py Normal file
View File

View File

@@ -0,0 +1,52 @@
from ren_browser.announces.announces import Announce
class TestAnnounce:
"""Test cases for the Announce dataclass."""
def test_announce_creation(self):
"""Test basic Announce creation."""
announce = Announce(
destination_hash="1234567890abcdef",
display_name="Test Node",
timestamp=1234567890,
)
assert announce.destination_hash == "1234567890abcdef"
assert announce.display_name == "Test Node"
assert announce.timestamp == 1234567890
def test_announce_with_none_display_name(self):
"""Test Announce creation with None display name."""
announce = Announce(
destination_hash="1234567890abcdef", display_name=None, timestamp=1234567890
)
assert announce.destination_hash == "1234567890abcdef"
assert announce.display_name is None
assert announce.timestamp == 1234567890
class TestAnnounceService:
"""Test cases for the AnnounceService class.
Note: These tests are simplified due to complex RNS integration.
Full integration tests will be added in the future.
"""
def test_announce_dataclass_functionality(self):
"""Test that the Announce dataclass works correctly."""
# Test that we can create and use Announce objects
announce1 = Announce("hash1", "Node1", 1000)
announce2 = Announce("hash2", None, 2000)
# Test that announces can be stored in lists
announces = [announce1, announce2]
assert len(announces) == 2
assert announces[0].display_name == "Node1"
assert announces[1].display_name is None
# Test that we can filter announces by hash
filtered = [ann for ann in announces if ann.destination_hash == "hash1"]
assert len(filtered) == 1
assert filtered[0].display_name == "Node1"

142
tests/unit/test_app.py Normal file
View File

@@ -0,0 +1,142 @@
from unittest.mock import patch
import flet as ft
import pytest
from ren_browser import app
class TestApp:
"""Test cases for the main app module."""
@pytest.mark.asyncio
async def test_main_initializes_loader(self, mock_page, mock_rns):
"""Test that main function initializes with loading screen."""
with patch("ren_browser.ui.ui.build_ui"):
await app.main(mock_page)
mock_page.add.assert_called_once()
mock_page.update.assert_called()
mock_page.run_thread.assert_called_once()
@pytest.mark.asyncio
async def test_main_function_structure(self, mock_page, mock_rns):
"""Test that main function sets up the expected structure."""
await app.main(mock_page)
# Verify that main function adds content and sets up threading
mock_page.add.assert_called_once()
mock_page.update.assert_called()
mock_page.run_thread.assert_called_once()
# Verify that a function was passed to run_thread
init_function = mock_page.run_thread.call_args[0][0]
assert callable(init_function)
def test_run_with_default_args(self, mock_rns):
"""Test run function with default arguments."""
with patch("sys.argv", ["ren-browser"]), patch("flet.app") as mock_ft_app:
app.run()
mock_ft_app.assert_called_once()
args = mock_ft_app.call_args
assert args[0][0] == app.main
def test_run_with_web_flag(self, mock_rns):
"""Test run function with web flag."""
with (
patch("sys.argv", ["ren-browser", "--web"]),
patch("flet.app") as mock_ft_app,
):
app.run()
mock_ft_app.assert_called_once()
args, kwargs = mock_ft_app.call_args
assert args[0] == app.main
assert kwargs["view"] == ft.AppView.WEB_BROWSER
def test_run_with_web_and_port(self, mock_rns):
"""Test run function with web flag and custom port."""
with (
patch("sys.argv", ["ren-browser", "--web", "--port", "8080"]),
patch("flet.app") as mock_ft_app,
):
app.run()
mock_ft_app.assert_called_once()
args, kwargs = mock_ft_app.call_args
assert args[0] == app.main
assert kwargs["view"] == ft.AppView.WEB_BROWSER
assert kwargs["port"] == 8080
def test_run_with_renderer_flag(self, mock_rns):
"""Test run function with renderer selection."""
with (
patch("sys.argv", ["ren-browser", "--renderer", "micron"]),
patch("flet.app"),
):
app.run()
assert app.RENDERER == "micron"
def test_web_function(self, mock_rns):
"""Test web() entry point function."""
with patch("flet.app") as mock_ft_app:
app.web()
mock_ft_app.assert_called_once_with(app.main, view=ft.AppView.WEB_BROWSER)
def test_android_function(self, mock_rns):
"""Test android() entry point function."""
with patch("flet.app") as mock_ft_app:
app.android()
mock_ft_app.assert_called_once_with(app.main, view=ft.AppView.FLET_APP_WEB)
def test_ios_function(self, mock_rns):
"""Test ios() entry point function."""
with patch("flet.app") as mock_ft_app:
app.ios()
mock_ft_app.assert_called_once_with(app.main, view=ft.AppView.FLET_APP_WEB)
def test_run_dev_function(self, mock_rns):
"""Test run_dev() entry point function."""
with patch("flet.app") as mock_ft_app:
app.run_dev()
mock_ft_app.assert_called_once_with(app.main)
def test_web_dev_function(self, mock_rns):
"""Test web_dev() entry point function."""
with patch("flet.app") as mock_ft_app:
app.web_dev()
mock_ft_app.assert_called_once_with(app.main, view=ft.AppView.WEB_BROWSER)
def test_android_dev_function(self, mock_rns):
"""Test android_dev() entry point function."""
with patch("flet.app") as mock_ft_app:
app.android_dev()
mock_ft_app.assert_called_once_with(app.main, view=ft.AppView.FLET_APP_WEB)
def test_ios_dev_function(self, mock_rns):
"""Test ios_dev() entry point function."""
with patch("flet.app") as mock_ft_app:
app.ios_dev()
mock_ft_app.assert_called_once_with(app.main, view=ft.AppView.FLET_APP_WEB)
def test_global_renderer_setting(self):
"""Test that RENDERER global is properly updated."""
original_renderer = app.RENDERER
with (
patch("sys.argv", ["ren-browser", "--renderer", "micron"]),
patch("flet.app"),
):
app.run()
assert app.RENDERER == "micron"
app.RENDERER = original_renderer

141
tests/unit/test_logs.py Normal file
View File

@@ -0,0 +1,141 @@
import datetime
from unittest.mock import Mock, patch
from ren_browser import logs
class TestLogsModule:
"""Test cases for the logs module."""
def setup_method(self):
"""Reset logs before each test."""
logs.APP_LOGS.clear()
logs.ERROR_LOGS.clear()
logs.RET_LOGS.clear()
def test_initial_state(self):
"""Test that logs start empty."""
assert logs.APP_LOGS == []
assert logs.ERROR_LOGS == []
assert logs.RET_LOGS == []
def test_log_error(self):
"""Test log_error function."""
with patch("datetime.datetime") as mock_datetime:
mock_now = Mock()
mock_now.isoformat.return_value = "2023-01-01T12:00:00"
mock_datetime.now.return_value = mock_now
logs.log_error("Test error message")
assert len(logs.ERROR_LOGS) == 1
assert len(logs.APP_LOGS) == 1
assert logs.ERROR_LOGS[0] == "[2023-01-01T12:00:00] Test error message"
assert logs.APP_LOGS[0] == "[2023-01-01T12:00:00] ERROR: Test error message"
def test_log_app(self):
"""Test log_app function."""
with patch("datetime.datetime") as mock_datetime:
mock_now = Mock()
mock_now.isoformat.return_value = "2023-01-01T12:00:00"
mock_datetime.now.return_value = mock_now
logs.log_app("Test app message")
assert len(logs.APP_LOGS) == 1
assert logs.APP_LOGS[0] == "[2023-01-01T12:00:00] Test app message"
def test_log_ret_with_original_function(self, mock_rns):
"""Test log_ret function calls original RNS.log."""
with patch("datetime.datetime") as mock_datetime:
mock_now = Mock()
mock_now.isoformat.return_value = "2023-01-01T12:00:00"
mock_datetime.now.return_value = mock_now
logs._original_rns_log = Mock(return_value="original_result")
result = logs.log_ret("Test RNS message", "arg1", kwarg1="value1")
assert len(logs.RET_LOGS) == 1
assert logs.RET_LOGS[0] == "[2023-01-01T12:00:00] Test RNS message"
logs._original_rns_log.assert_called_once_with(
"Test RNS message", "arg1", kwarg1="value1"
)
assert result == "original_result"
def test_multiple_log_calls(self):
"""Test multiple log calls accumulate correctly."""
with patch("datetime.datetime") as mock_datetime:
mock_now = Mock()
mock_now.isoformat.return_value = "2023-01-01T12:00:00"
mock_datetime.now.return_value = mock_now
logs.log_error("Error 1")
logs.log_error("Error 2")
logs.log_app("App message")
assert len(logs.ERROR_LOGS) == 2
assert len(logs.APP_LOGS) == 3 # 2 errors + 1 app message
assert logs.ERROR_LOGS[0] == "[2023-01-01T12:00:00] Error 1"
assert logs.ERROR_LOGS[1] == "[2023-01-01T12:00:00] Error 2"
assert logs.APP_LOGS[2] == "[2023-01-01T12:00:00] App message"
def test_timestamp_format(self):
"""Test that timestamps are properly formatted."""
real_datetime = datetime.datetime(2023, 1, 1, 12, 30, 45, 123456)
with patch("datetime.datetime") as mock_datetime:
mock_datetime.now.return_value = real_datetime
logs.log_app("Test message")
expected_timestamp = real_datetime.isoformat()
assert logs.APP_LOGS[0] == f"[{expected_timestamp}] Test message"
def test_rns_log_replacement(self, mock_rns):
"""Test that RNS.log replacement concept works."""
import ren_browser.logs as logs_module
# Test that the log_ret function exists and is callable
assert hasattr(logs_module, "log_ret")
assert callable(logs_module.log_ret)
# Test that we can call the log function
logs_module.log_ret("test message")
# Verify that RET_LOGS was updated
assert len(logs_module.RET_LOGS) > 0
def test_original_rns_log_stored(self, mock_rns):
"""Test that original RNS.log function is stored."""
original_log = Mock()
with patch.object(logs, "_original_rns_log", original_log):
logs.log_ret("test message")
original_log.assert_called_once_with("test message")
def test_empty_message_handling(self):
"""Test handling of empty messages."""
with patch("datetime.datetime") as mock_datetime:
mock_now = Mock()
mock_now.isoformat.return_value = "2023-01-01T12:00:00"
mock_datetime.now.return_value = mock_now
logs.log_error("")
logs.log_app("")
assert logs.ERROR_LOGS[0] == "[2023-01-01T12:00:00] "
assert logs.APP_LOGS[0] == "[2023-01-01T12:00:00] ERROR: "
assert logs.APP_LOGS[1] == "[2023-01-01T12:00:00] "
def test_special_characters_in_messages(self):
"""Test handling of special characters in log messages."""
with patch("datetime.datetime") as mock_datetime:
mock_now = Mock()
mock_now.isoformat.return_value = "2023-01-01T12:00:00"
mock_datetime.now.return_value = mock_now
special_msg = "Message with\nnewlines\tand\ttabs and unicode: 🚀"
logs.log_app(special_msg)
assert logs.APP_LOGS[0] == f"[2023-01-01T12:00:00] {special_msg}"

View File

@@ -0,0 +1,75 @@
from ren_browser.pages.page_request import PageRequest
class TestPageRequest:
"""Test cases for the PageRequest dataclass."""
def test_page_request_creation(self):
"""Test basic PageRequest creation."""
request = PageRequest(
destination_hash="1234567890abcdef", page_path="/page/index.mu"
)
assert request.destination_hash == "1234567890abcdef"
assert request.page_path == "/page/index.mu"
assert request.field_data is None
def test_page_request_with_field_data(self):
"""Test PageRequest creation with field data."""
field_data = {"key": "value", "form_field": "data"}
request = PageRequest(
destination_hash="1234567890abcdef",
page_path="/page/form.mu",
field_data=field_data,
)
assert request.destination_hash == "1234567890abcdef"
assert request.page_path == "/page/form.mu"
assert request.field_data == field_data
def test_page_request_validation(self):
"""Test PageRequest field validation."""
# Test with various path formats
request1 = PageRequest("hash1", "/")
request2 = PageRequest("hash2", "/page/test.mu")
request3 = PageRequest("hash3", "/deep/nested/path/file.mu")
assert request1.page_path == "/"
assert request2.page_path == "/page/test.mu"
assert request3.page_path == "/deep/nested/path/file.mu"
# Test with different hash formats
assert request1.destination_hash == "hash1"
assert len(request1.destination_hash) > 0
# NOTE: PageFetcher tests are complex due to RNS networking integration.
# These will be implemented when the networking layer is more stable.
class TestPageFetcher:
"""Test cases for the PageFetcher class.
Note: These tests are simplified due to complex RNS networking integration.
Full integration tests will be added when the networking layer is stable.
"""
def test_page_fetcher_concepts(self):
"""Test basic concepts that PageFetcher should handle."""
# Test that we can create PageRequest objects for the fetcher
requests = [
PageRequest("hash1", "/index.mu"),
PageRequest("hash2", "/about.mu", {"form": "data"}),
PageRequest("hash3", "/contact.mu"),
]
# Test that requests have the expected structure
assert all(hasattr(req, "destination_hash") for req in requests)
assert all(hasattr(req, "page_path") for req in requests)
assert all(hasattr(req, "field_data") for req in requests)
# Test request with form data
form_request = requests[1]
assert form_request.field_data == {"form": "data"}
# Test requests without form data
simple_requests = [req for req in requests if req.field_data is None]
assert len(simple_requests) == 2

View File

@@ -0,0 +1,128 @@
import flet as ft
from ren_browser.renderer.micron import render_micron
from ren_browser.renderer.plaintext import render_plaintext
class TestPlaintextRenderer:
"""Test cases for the plaintext renderer."""
def test_render_plaintext_basic(self):
"""Test basic plaintext rendering."""
content = "Hello, world!"
result = render_plaintext(content)
assert isinstance(result, ft.Text)
assert result.value == "Hello, world!"
assert result.selectable is True
assert result.font_family == "monospace"
assert result.expand is True
def test_render_plaintext_multiline(self):
"""Test plaintext rendering with multiline content."""
content = "Line 1\nLine 2\nLine 3"
result = render_plaintext(content)
assert isinstance(result, ft.Text)
assert result.value == "Line 1\nLine 2\nLine 3"
assert result.selectable is True
def test_render_plaintext_empty(self):
"""Test plaintext rendering with empty content."""
content = ""
result = render_plaintext(content)
assert isinstance(result, ft.Text)
assert result.value == ""
assert result.selectable is True
def test_render_plaintext_special_chars(self):
"""Test plaintext rendering with special characters."""
content = "Special chars: !@#$%^&*()_+{}|:<>?[]\\;'\",./"
result = render_plaintext(content)
assert isinstance(result, ft.Text)
assert result.value == content
assert result.selectable is True
def test_render_plaintext_unicode(self):
"""Test plaintext rendering with Unicode characters."""
content = "Unicode: 你好 🌍 αβγ"
result = render_plaintext(content)
assert isinstance(result, ft.Text)
assert result.value == content
assert result.selectable is True
class TestMicronRenderer:
"""Test cases for the micron renderer.
Note: The micron renderer is currently a placeholder implementation
that displays raw content without markup processing.
"""
def test_render_micron_basic(self):
"""Test basic micron rendering (currently displays raw content)."""
content = "# Heading\n\nSome content"
result = render_micron(content)
assert isinstance(result, ft.Text)
assert result.value == "# Heading\n\nSome content"
assert result.selectable is True
assert result.font_family == "monospace"
assert result.expand is True
def test_render_micron_empty(self):
"""Test micron rendering with empty content."""
content = ""
result = render_micron(content)
assert isinstance(result, ft.Text)
assert result.value == ""
assert result.selectable is True
def test_render_micron_unicode(self):
"""Test micron rendering with Unicode characters."""
content = "Unicode content: 你好 🌍 αβγ"
result = render_micron(content)
assert isinstance(result, ft.Text)
assert result.value == content
assert result.selectable is True
class TestRendererComparison:
"""Test cases comparing both renderers."""
def test_renderers_return_same_type(self):
"""Test that both renderers return the same control type."""
content = "Test content"
plaintext_result = render_plaintext(content)
micron_result = render_micron(content)
assert type(plaintext_result) is type(micron_result)
assert isinstance(plaintext_result, ft.Text)
assert isinstance(micron_result, ft.Text)
def test_renderers_preserve_content(self):
"""Test that both renderers preserve the original content."""
content = "Test content with\nmultiple lines"
plaintext_result = render_plaintext(content)
micron_result = render_micron(content)
assert plaintext_result.value == content
assert micron_result.value == content
def test_renderers_same_properties(self):
"""Test that both renderers set the same basic properties."""
content = "Test content"
plaintext_result = render_plaintext(content)
micron_result = render_micron(content)
assert plaintext_result.selectable == micron_result.selectable
assert plaintext_result.font_family == micron_result.font_family
assert plaintext_result.expand == micron_result.expand

View File

@@ -0,0 +1,240 @@
from unittest.mock import Mock
import pytest
from ren_browser.controls.shortcuts import Shortcuts
class TestShortcuts:
"""Test cases for the Shortcuts class."""
@pytest.fixture
def mock_tab_manager(self):
"""Create a mock tab manager for testing."""
manager = Mock()
manager.manager.index = 0
manager.manager.tabs = [{"url_field": Mock()}]
manager._on_add_click = Mock()
manager._on_close_click = Mock()
manager.select_tab = Mock()
return manager
@pytest.fixture
def shortcuts(self, mock_page, mock_tab_manager):
"""Create a Shortcuts instance for testing."""
return Shortcuts(mock_page, mock_tab_manager)
def test_shortcuts_init(self, mock_page, mock_tab_manager):
"""Test Shortcuts initialization."""
shortcuts = Shortcuts(mock_page, mock_tab_manager)
assert shortcuts.page == mock_page
assert shortcuts.tab_manager == mock_tab_manager
assert mock_page.on_keyboard_event == shortcuts.on_keyboard
def test_new_tab_shortcut_ctrl_t(self, shortcuts, mock_tab_manager):
"""Test Ctrl+T shortcut for new tab."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "t"
event.shift = False
shortcuts.on_keyboard(event)
mock_tab_manager._on_add_click.assert_called_once_with(None)
shortcuts.page.update.assert_called_once()
def test_new_tab_shortcut_meta_t(self, shortcuts, mock_tab_manager):
"""Test Meta+T shortcut for new tab (macOS)."""
event = Mock()
event.ctrl = False
event.meta = True
event.key = "T"
event.shift = False
shortcuts.on_keyboard(event)
mock_tab_manager._on_add_click.assert_called_once_with(None)
def test_close_tab_shortcut_ctrl_w(self, shortcuts, mock_tab_manager):
"""Test Ctrl+W shortcut for close tab."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "w"
event.shift = False
shortcuts.on_keyboard(event)
mock_tab_manager._on_close_click.assert_called_once_with(None)
shortcuts.page.update.assert_called_once()
def test_focus_url_bar_shortcut_ctrl_l(self, shortcuts, mock_tab_manager):
"""Test Ctrl+L shortcut for focusing URL bar."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "l"
event.shift = False
url_field = Mock()
mock_tab_manager.manager.tabs = [{"url_field": url_field}]
mock_tab_manager.manager.index = 0
shortcuts.on_keyboard(event)
url_field.focus.assert_called_once()
shortcuts.page.update.assert_called_once()
def test_show_announces_drawer_ctrl_a(self, shortcuts):
"""Test Ctrl+A shortcut for showing announces drawer."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "a"
event.shift = False
shortcuts.page.drawer = Mock()
shortcuts.page.drawer.open = False
shortcuts.on_keyboard(event)
assert shortcuts.page.drawer.open is True
shortcuts.page.update.assert_called_once()
def test_cycle_tabs_forward_ctrl_tab(self, shortcuts, mock_tab_manager):
"""Test Ctrl+Tab for cycling tabs forward."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "Tab"
event.shift = False
mock_tab_manager.manager.index = 0
mock_tab_manager.manager.tabs = [Mock(), Mock(), Mock()] # 3 tabs
shortcuts.on_keyboard(event)
mock_tab_manager.select_tab.assert_called_once_with(1)
shortcuts.page.update.assert_called_once()
def test_cycle_tabs_backward_ctrl_shift_tab(self, shortcuts, mock_tab_manager):
"""Test Ctrl+Shift+Tab for cycling tabs backward."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "Tab"
event.shift = True
mock_tab_manager.manager.index = 1
mock_tab_manager.manager.tabs = [Mock(), Mock(), Mock()] # 3 tabs
shortcuts.on_keyboard(event)
mock_tab_manager.select_tab.assert_called_once_with(0)
shortcuts.page.update.assert_called_once()
def test_cycle_tabs_wrap_around_forward(self, shortcuts, mock_tab_manager):
"""Test tab cycling wraps around when going forward from last tab."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "Tab"
event.shift = False
mock_tab_manager.manager.index = 2 # Last tab
mock_tab_manager.manager.tabs = [Mock(), Mock(), Mock()] # 3 tabs
shortcuts.on_keyboard(event)
mock_tab_manager.select_tab.assert_called_once_with(0) # Wrap to first
def test_cycle_tabs_wrap_around_backward(self, shortcuts, mock_tab_manager):
"""Test tab cycling wraps around when going backward from first tab."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "Tab"
event.shift = True
mock_tab_manager.manager.index = 0 # First tab
mock_tab_manager.manager.tabs = [Mock(), Mock(), Mock()] # 3 tabs
shortcuts.on_keyboard(event)
mock_tab_manager.select_tab.assert_called_once_with(2) # Wrap to last
def test_no_ctrl_or_meta_key_returns_early(self, shortcuts, mock_tab_manager):
"""Test that shortcuts without Ctrl or Meta key don't trigger actions."""
event = Mock()
event.ctrl = False
event.meta = False
event.key = "t"
event.shift = False
shortcuts.on_keyboard(event)
mock_tab_manager._on_add_click.assert_not_called()
shortcuts.page.update.assert_not_called()
def test_unknown_key_returns_early(self, shortcuts, mock_tab_manager):
"""Test that unknown key combinations don't trigger actions."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "z" # Unknown shortcut
event.shift = False
shortcuts.on_keyboard(event)
mock_tab_manager._on_add_click.assert_not_called()
shortcuts.page.update.assert_not_called()
def test_case_insensitive_keys(self, shortcuts, mock_tab_manager):
"""Test that shortcuts work with uppercase keys."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "T" # Uppercase
event.shift = False
shortcuts.on_keyboard(event)
mock_tab_manager._on_add_click.assert_called_once_with(None)
def test_multiple_tabs_url_field_access(self, shortcuts, mock_tab_manager):
"""Test URL field access with multiple tabs."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "l"
event.shift = False
url_field1 = Mock()
url_field2 = Mock()
mock_tab_manager.manager.tabs = [
{"url_field": url_field1},
{"url_field": url_field2},
]
mock_tab_manager.manager.index = 1 # Second tab
shortcuts.on_keyboard(event)
url_field1.focus.assert_not_called()
url_field2.focus.assert_called_once()
def test_single_tab_cycling(self, shortcuts, mock_tab_manager):
"""Test tab cycling with only one tab."""
event = Mock()
event.ctrl = True
event.meta = False
event.key = "Tab"
event.shift = False
mock_tab_manager.manager.index = 0
mock_tab_manager.manager.tabs = [Mock()] # Only 1 tab
shortcuts.on_keyboard(event)
mock_tab_manager.select_tab.assert_called_once_with(0) # Stay on same tab

434
tests/unit/test_storage.py Normal file
View File

@@ -0,0 +1,434 @@
import json
import tempfile
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from ren_browser.storage.storage import (
StorageManager,
get_storage_manager,
initialize_storage,
)
class TestStorageManager:
"""Test cases for the StorageManager class."""
def test_storage_manager_init_without_page(self):
"""Test StorageManager initialization without a page."""
with patch(
"ren_browser.storage.storage.StorageManager._get_storage_directory"
) as mock_get_dir:
mock_dir = Path("/mock/storage")
mock_get_dir.return_value = mock_dir
with patch("pathlib.Path.mkdir") as mock_mkdir:
storage = StorageManager()
assert storage.page is None
assert storage._storage_dir == mock_dir
mock_mkdir.assert_called_once_with(parents=True, exist_ok=True)
def test_storage_manager_init_with_page(self):
"""Test StorageManager initialization with a page."""
mock_page = Mock()
with patch(
"ren_browser.storage.storage.StorageManager._get_storage_directory"
) as mock_get_dir:
mock_dir = Path("/mock/storage")
mock_get_dir.return_value = mock_dir
with patch("pathlib.Path.mkdir"):
storage = StorageManager(mock_page)
assert storage.page == mock_page
assert storage._storage_dir == mock_dir
def test_get_storage_directory_desktop(self):
"""Test storage directory detection for desktop platforms."""
with (
patch("os.name", "posix"),
patch.dict(
"os.environ", {"XDG_CONFIG_HOME": "/home/user/.config"}, clear=True
),
patch("pathlib.Path.mkdir"),
):
with patch(
"ren_browser.storage.storage.StorageManager._ensure_storage_directory"
):
storage = StorageManager()
storage._storage_dir = storage._get_storage_directory()
expected_dir = Path("/home/user/.config") / "ren_browser"
assert storage._storage_dir == expected_dir
def test_get_storage_directory_windows(self):
"""Test storage directory detection for Windows."""
# Skip this test on non-Windows systems to avoid path issues
pytest.skip("Windows path test skipped on non-Windows system")
def test_get_storage_directory_android(self):
"""Test storage directory detection for Android."""
with (
patch("os.name", "posix"),
patch.dict("os.environ", {"ANDROID_ROOT": "/system"}, clear=True),
patch("pathlib.Path.mkdir"),
):
with patch(
"ren_browser.storage.storage.StorageManager._ensure_storage_directory"
):
storage = StorageManager()
storage._storage_dir = storage._get_storage_directory()
expected_dir = Path("/data/data/com.ren_browser/files")
assert storage._storage_dir == expected_dir
def test_get_config_path(self):
"""Test getting config file path."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
config_path = storage.get_config_path()
expected_path = Path(temp_dir) / "config"
assert config_path == expected_path
def test_get_reticulum_config_path(self):
"""Test getting Reticulum config directory path."""
storage = StorageManager()
config_path = storage.get_reticulum_config_path()
expected_path = Path.home() / ".reticulum"
assert config_path == expected_path
def test_save_config_success(self):
"""Test successful config saving."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
# Mock the reticulum config path to use temp dir
with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
config_content = "test config content"
result = storage.save_config(config_content)
assert result is True
config_path = storage.get_config_path()
assert config_path.exists()
assert config_path.read_text(encoding="utf-8") == config_content
def test_save_config_with_client_storage(self):
"""Test config saving with client storage."""
mock_page = Mock()
mock_page.client_storage.set = Mock()
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager(mock_page)
storage._storage_dir = Path(temp_dir)
# Mock the reticulum config path to use temp dir
with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
config_content = "test config content"
result = storage.save_config(config_content)
assert result is True
mock_page.client_storage.set.assert_called_with(
"ren_browser_config", config_content
)
def test_save_config_fallback(self):
"""Test config saving fallback when file system fails."""
with tempfile.TemporaryDirectory() as temp_dir:
mock_page = Mock()
mock_page.client_storage.set = Mock()
storage = StorageManager(mock_page)
storage._storage_dir = Path(temp_dir)
# Mock the reticulum config path to use temp dir and cause failure
with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
with patch(
"pathlib.Path.write_text",
side_effect=PermissionError("Access denied"),
):
config_content = "test config content"
result = storage.save_config(config_content)
assert result is True
# Check that the config was set to client storage
mock_page.client_storage.set.assert_any_call(
"ren_browser_config", config_content
)
# Verify that client storage was called at least once
assert mock_page.client_storage.set.call_count >= 1
def test_load_config_from_file(self):
"""Test loading config from file."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
# Mock the reticulum config path to use temp dir
with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
config_content = "test config content"
config_path = storage.get_config_path()
config_path.write_text(config_content, encoding="utf-8")
loaded_config = storage.load_config()
assert loaded_config == config_content
def test_load_config_from_client_storage(self):
"""Test loading config from client storage when file doesn't exist."""
mock_page = Mock()
mock_page.client_storage.get = Mock(return_value="client storage config")
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager(mock_page)
storage._storage_dir = Path(temp_dir)
# Mock the reticulum config path to also be in temp dir
with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
loaded_config = storage.load_config()
assert loaded_config == "client storage config"
mock_page.client_storage.get.assert_called_with("ren_browser_config")
def test_load_config_default(self):
"""Test loading default config when no config exists."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
# Mock the reticulum config path to also be in temp dir
with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
loaded_config = storage.load_config()
assert loaded_config == ""
def test_save_bookmarks(self):
"""Test saving bookmarks."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
bookmarks = [{"name": "Test", "url": "test://example"}]
result = storage.save_bookmarks(bookmarks)
assert result is True
bookmarks_path = storage._storage_dir / "bookmarks.json"
assert bookmarks_path.exists()
with open(bookmarks_path, "r", encoding="utf-8") as f:
loaded_bookmarks = json.load(f)
assert loaded_bookmarks == bookmarks
def test_load_bookmarks(self):
"""Test loading bookmarks."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
bookmarks = [{"name": "Test", "url": "test://example"}]
bookmarks_path = storage._storage_dir / "bookmarks.json"
with open(bookmarks_path, "w", encoding="utf-8") as f:
json.dump(bookmarks, f)
loaded_bookmarks = storage.load_bookmarks()
assert loaded_bookmarks == bookmarks
def test_load_bookmarks_empty(self):
"""Test loading bookmarks when none exist."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
loaded_bookmarks = storage.load_bookmarks()
assert loaded_bookmarks == []
def test_save_history(self):
"""Test saving history."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
history = [{"url": "test://example", "timestamp": 1234567890}]
result = storage.save_history(history)
assert result is True
history_path = storage._storage_dir / "history.json"
assert history_path.exists()
with open(history_path, "r", encoding="utf-8") as f:
loaded_history = json.load(f)
assert loaded_history == history
def test_load_history(self):
"""Test loading history."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
history = [{"url": "test://example", "timestamp": 1234567890}]
history_path = storage._storage_dir / "history.json"
with open(history_path, "w", encoding="utf-8") as f:
json.dump(history, f)
loaded_history = storage.load_history()
assert loaded_history == history
def test_get_storage_info(self):
"""Test getting storage information."""
with tempfile.TemporaryDirectory() as temp_dir:
mock_page = Mock()
mock_page.client_storage = Mock()
storage = StorageManager(mock_page)
storage._storage_dir = Path(temp_dir)
info = storage.get_storage_info()
assert "storage_dir" in info
assert "config_path" in info
assert "reticulum_config_path" in info
assert "storage_dir_exists" in info
assert "storage_dir_writable" in info
assert "has_client_storage" in info
assert info["storage_dir"] == str(Path(temp_dir))
assert info["storage_dir_exists"] is True
assert info["has_client_storage"] is True
def test_storage_directory_fallback(self):
"""Test fallback to temp directory when storage creation fails."""
with patch.object(StorageManager, "_get_storage_directory") as mock_get_dir:
mock_get_dir.return_value = Path("/nonexistent/path")
with patch(
"pathlib.Path.mkdir",
side_effect=[PermissionError("Access denied"), None],
):
with patch("tempfile.gettempdir", return_value="/tmp"):
storage = StorageManager()
expected_fallback = Path("/tmp") / "ren_browser"
assert storage._storage_dir == expected_fallback
class TestStorageGlobalFunctions:
"""Test cases for global storage functions."""
def test_get_storage_manager_singleton(self):
"""Test that get_storage_manager returns the same instance."""
with patch("ren_browser.storage.storage._storage_manager", None):
storage1 = get_storage_manager()
storage2 = get_storage_manager()
assert storage1 is storage2
def test_get_storage_manager_with_page(self):
"""Test get_storage_manager with page parameter."""
mock_page = Mock()
with patch("ren_browser.storage.storage._storage_manager", None):
storage = get_storage_manager(mock_page)
assert storage.page == mock_page
def test_initialize_storage(self):
"""Test initialize_storage function."""
mock_page = Mock()
with patch("ren_browser.storage.storage._storage_manager", None):
storage = initialize_storage(mock_page)
assert storage.page == mock_page
assert get_storage_manager() is storage
class TestStorageManagerEdgeCases:
"""Test edge cases and error scenarios."""
def test_save_config_encoding_error(self):
"""Test config saving with encoding errors."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
# Mock the reticulum config path to use temp dir
with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
# Test with content that might cause encoding issues
with patch(
"pathlib.Path.write_text",
side_effect=UnicodeEncodeError("utf-8", "", 0, 1, "error"),
):
result = storage.save_config("test content")
# Should still succeed due to fallback
assert result is False
def test_load_config_encoding_error(self):
"""Test config loading with encoding errors."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
storage._storage_dir = Path(temp_dir)
# Create a config file with invalid encoding
config_path = storage.get_config_path()
config_path.write_bytes(b"\xff\xfe invalid utf-8")
# Mock the reticulum config path to also be in temp dir
with patch.object(
storage,
"get_reticulum_config_path",
return_value=Path(temp_dir) / "reticulum",
):
# Should return empty string when encoding fails
config = storage.load_config()
assert config == ""
def test_is_writable_permission_denied(self):
"""Test _is_writable when permission is denied."""
storage = StorageManager()
with patch(
"pathlib.Path.write_text", side_effect=PermissionError("Access denied")
):
test_path = Path("/mock/path")
result = storage._is_writable(test_path)
assert result is False
def test_is_writable_success(self):
"""Test _is_writable when directory is writable."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = StorageManager()
test_path = Path(temp_dir)
result = storage._is_writable(test_path)
assert result is True

235
tests/unit/test_tabs.py Normal file
View File

@@ -0,0 +1,235 @@
from types import SimpleNamespace
from unittest.mock import Mock, patch
import flet as ft
import pytest
from ren_browser.tabs.tabs import TabsManager
class TestTabsManager:
"""Test cases for the TabsManager class."""
@pytest.fixture
def tabs_manager(self, mock_page):
"""Create a TabsManager instance for testing."""
with (
patch("ren_browser.app.RENDERER", "plaintext"),
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render,
):
mock_render.return_value = Mock(spec=ft.Text)
return TabsManager(mock_page)
def test_tabs_manager_init(self, mock_page):
"""Test TabsManager initialization."""
with (
patch("ren_browser.app.RENDERER", "plaintext"),
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render,
):
mock_render.return_value = Mock(spec=ft.Text)
manager = TabsManager(mock_page)
assert manager.page == mock_page
assert isinstance(manager.manager, SimpleNamespace)
assert len(manager.manager.tabs) == 1
assert manager.manager.index == 0
assert isinstance(manager.tab_bar, ft.Row)
assert isinstance(manager.content_container, ft.Container)
def test_tabs_manager_init_micron_renderer(self, mock_page):
"""Test TabsManager initialization with micron renderer."""
with patch("ren_browser.app.RENDERER", "micron"):
manager = TabsManager(mock_page)
# Verify that micron renderer was selected and TabsManager was created
assert manager.page == mock_page
assert len(manager.manager.tabs) == 1
def test_add_tab_internal(self, tabs_manager):
"""Test adding a tab internally."""
content = Mock(spec=ft.Text)
tabs_manager._add_tab_internal("Test Tab", content)
assert len(tabs_manager.manager.tabs) == 2
new_tab = tabs_manager.manager.tabs[1]
assert new_tab["title"] == "Test Tab"
assert new_tab["content_control"] == content
def test_on_add_click(self, tabs_manager):
"""Test adding a new tab via button click."""
with (
patch("ren_browser.app.RENDERER", "plaintext"),
patch("ren_browser.renderer.plaintext.render_plaintext") as mock_render,
):
mock_render.return_value = Mock(spec=ft.Text)
initial_count = len(tabs_manager.manager.tabs)
tabs_manager._on_add_click(None)
assert len(tabs_manager.manager.tabs) == initial_count + 1
assert tabs_manager.manager.index == initial_count
tabs_manager.page.update.assert_called()
def test_on_close_click_multiple_tabs(self, tabs_manager):
"""Test closing a tab when multiple tabs exist."""
tabs_manager._add_tab_internal("Tab 2", Mock())
tabs_manager._add_tab_internal("Tab 3", Mock())
tabs_manager.select_tab(1)
initial_count = len(tabs_manager.manager.tabs)
tabs_manager._on_close_click(None)
assert len(tabs_manager.manager.tabs) == initial_count - 1
tabs_manager.page.update.assert_called()
def test_on_close_click_single_tab(self, tabs_manager):
"""Test closing a tab when only one tab exists (should not close)."""
initial_count = len(tabs_manager.manager.tabs)
tabs_manager._on_close_click(None)
assert len(tabs_manager.manager.tabs) == initial_count
def test_select_tab(self, tabs_manager):
"""Test selecting a tab."""
tabs_manager._add_tab_internal("Tab 2", Mock())
tabs_manager.select_tab(1)
assert tabs_manager.manager.index == 1
tabs_manager.page.update.assert_called()
def test_select_tab_updates_background_colors(self, tabs_manager):
"""Test that selecting a tab updates background colors correctly."""
tabs_manager._add_tab_internal("Tab 2", Mock())
tab_controls = tabs_manager.tab_bar.controls[:-2] # Exclude add/close buttons
tabs_manager.select_tab(1)
assert tab_controls[0].bgcolor == ft.Colors.SURFACE_CONTAINER_HIGHEST
assert tab_controls[1].bgcolor == ft.Colors.PRIMARY_CONTAINER
def test_on_tab_go_empty_url(self, tabs_manager):
"""Test tab go with empty URL."""
tab = tabs_manager.manager.tabs[0]
tab["url_field"].value = ""
tabs_manager._on_tab_go(None, 0)
# Should not change anything for empty URL
assert len(tabs_manager.manager.tabs) == 1
def test_on_tab_go_with_url(self, tabs_manager):
"""Test tab go with valid URL."""
tab = tabs_manager.manager.tabs[0]
tab["url_field"].value = "test://example"
tabs_manager._on_tab_go(None, 0)
# Verify that the tab content was updated and page was refreshed
tabs_manager.page.update.assert_called()
def test_on_tab_go_micron_renderer(self, tabs_manager):
"""Test tab go with micron renderer."""
with patch("ren_browser.app.RENDERER", "micron"):
tab = tabs_manager.manager.tabs[0]
tab["url_field"].value = "test://example"
tabs_manager._on_tab_go(None, 0)
# Verify that the page was updated with micron renderer
tabs_manager.page.update.assert_called()
def test_tab_container_properties(self, tabs_manager):
"""Test that tab container has correct properties."""
assert tabs_manager.content_container.expand is True
assert tabs_manager.content_container.bgcolor == ft.Colors.BLACK
assert tabs_manager.content_container.padding == ft.padding.all(5)
def test_tab_bar_controls(self, tabs_manager):
"""Test that tab bar has correct controls."""
controls = tabs_manager.tab_bar.controls
# Should have: home tab, add button, close button
assert len(controls) >= 3
assert isinstance(controls[-2], ft.IconButton) # Add button
assert isinstance(controls[-1], ft.IconButton) # Close button
assert controls[-2].icon == ft.Icons.ADD
assert controls[-1].icon == ft.Icons.CLOSE
def test_tab_content_structure(self, tabs_manager):
"""Test the structure of tab content."""
tab = tabs_manager.manager.tabs[0]
assert "title" in tab
assert "url_field" in tab
assert "go_btn" in tab
assert "content_control" in tab
assert "content" in tab
assert isinstance(tab["url_field"], ft.TextField)
assert isinstance(tab["go_btn"], ft.IconButton)
assert isinstance(tab["content"], ft.Column)
def test_url_field_properties(self, tabs_manager):
"""Test URL field properties."""
tab = tabs_manager.manager.tabs[0]
url_field = tab["url_field"]
assert url_field.expand is True
assert url_field.text_style.size == 12
assert url_field.content_padding is not None
def test_go_button_properties(self, tabs_manager):
"""Test go button properties."""
tab = tabs_manager.manager.tabs[0]
go_btn = tab["go_btn"]
assert go_btn.icon == ft.Icons.OPEN_IN_BROWSER
assert go_btn.tooltip == "Load URL"
def test_tab_click_handlers(self, tabs_manager):
"""Test that tab click handlers are properly set."""
tabs_manager._add_tab_internal("Tab 2", Mock())
tab_controls = tabs_manager.tab_bar.controls[:-2] # Exclude add/close buttons
for i, control in enumerate(tab_controls):
assert control.on_click is not None
def test_multiple_tabs_management(self, tabs_manager):
"""Test management of multiple tabs."""
# Add several tabs
for i in range(3):
tabs_manager._add_tab_internal(f"Tab {i + 2}", Mock())
assert len(tabs_manager.manager.tabs) == 4
# Select different tabs
tabs_manager.select_tab(2)
assert tabs_manager.manager.index == 2
# Close current tab
tabs_manager._on_close_click(None)
assert len(tabs_manager.manager.tabs) == 3
assert tabs_manager.manager.index <= 2
def test_tab_content_update_on_select(self, tabs_manager):
"""Test that content container updates when selecting tabs."""
content1 = Mock()
content2 = Mock()
tabs_manager._add_tab_internal("Tab 2", content1)
tabs_manager._add_tab_internal("Tab 3", content2)
tabs_manager.select_tab(1)
assert (
tabs_manager.content_container.content
== tabs_manager.manager.tabs[1]["content"]
)
tabs_manager.select_tab(2)
assert (
tabs_manager.content_container.content
== tabs_manager.manager.tabs[2]["content"]
)

181
tests/unit/test_ui.py Normal file
View File

@@ -0,0 +1,181 @@
from unittest.mock import Mock, patch
import flet as ft
from ren_browser.ui.settings import open_settings_tab
from ren_browser.ui.ui import build_ui
class TestBuildUI:
"""Test cases for the build_ui function."""
def test_build_ui_basic_setup(self, mock_page):
"""Test that build_ui sets up basic page properties."""
# Mock the page properties we can test without complex dependencies
mock_page.theme_mode = None
mock_page.window = Mock()
mock_page.window.maximized = False
mock_page.appbar = Mock()
# Test basic setup that should always work
mock_page.theme_mode = ft.ThemeMode.DARK
mock_page.window.maximized = True
assert mock_page.theme_mode == ft.ThemeMode.DARK
assert mock_page.window.maximized is True
@patch("ren_browser.announces.announces.AnnounceService")
@patch("ren_browser.pages.page_request.PageFetcher")
@patch("ren_browser.tabs.tabs.TabsManager")
@patch("ren_browser.controls.shortcuts.Shortcuts")
def test_build_ui_appbar_setup(
self, mock_shortcuts, mock_tabs, mock_fetcher, mock_announce_service, mock_page
):
"""Test that build_ui sets up the app bar correctly."""
mock_tab_manager = Mock()
mock_tabs.return_value = mock_tab_manager
mock_tab_manager.manager.tabs = [{"url_field": Mock(), "go_btn": Mock()}]
mock_tab_manager.manager.index = 0
mock_tab_manager.tab_bar = Mock()
mock_tab_manager.content_container = Mock()
build_ui(mock_page)
assert mock_page.appbar is not None
assert mock_page.appbar.leading is not None
assert mock_page.appbar.actions is not None
assert mock_page.appbar.title is not None
@patch("ren_browser.announces.announces.AnnounceService")
@patch("ren_browser.pages.page_request.PageFetcher")
@patch("ren_browser.tabs.tabs.TabsManager")
@patch("ren_browser.controls.shortcuts.Shortcuts")
def test_build_ui_drawer_setup(
self, mock_shortcuts, mock_tabs, mock_fetcher, mock_announce_service, mock_page
):
"""Test that build_ui sets up the drawer correctly."""
mock_tab_manager = Mock()
mock_tabs.return_value = mock_tab_manager
mock_tab_manager.manager.tabs = [{"url_field": Mock(), "go_btn": Mock()}]
mock_tab_manager.manager.index = 0
mock_tab_manager.tab_bar = Mock()
mock_tab_manager.content_container = Mock()
build_ui(mock_page)
assert mock_page.drawer is not None
assert isinstance(mock_page.drawer, ft.NavigationDrawer)
def test_ui_basic_functionality(self, mock_page):
"""Test basic UI functionality without complex mocking."""
# Test that we can create basic UI components
mock_page.theme_mode = ft.ThemeMode.DARK
mock_page.window = Mock()
mock_page.window.maximized = True
mock_page.appbar = Mock()
mock_page.drawer = Mock()
# Verify basic properties can be set
assert mock_page.theme_mode == ft.ThemeMode.DARK
assert mock_page.window.maximized is True
class TestOpenSettingsTab:
"""Test cases for the open_settings_tab function."""
def test_open_settings_tab_basic(self, mock_page):
"""Test opening settings tab with basic functionality."""
mock_tab_manager = Mock()
mock_tab_manager.manager.tabs = []
mock_tab_manager._add_tab_internal = Mock()
mock_tab_manager.select_tab = Mock()
with patch("pathlib.Path.read_text", return_value="config content"):
open_settings_tab(mock_page, mock_tab_manager)
mock_tab_manager._add_tab_internal.assert_called_once()
mock_tab_manager.select_tab.assert_called_once()
mock_page.update.assert_called()
def test_open_settings_tab_config_error(self, mock_page):
"""Test opening settings tab when config file cannot be read."""
mock_tab_manager = Mock()
mock_tab_manager.manager.tabs = []
mock_tab_manager._add_tab_internal = Mock()
mock_tab_manager.select_tab = Mock()
with patch("pathlib.Path.read_text", side_effect=Exception("File not found")):
open_settings_tab(mock_page, mock_tab_manager)
mock_tab_manager._add_tab_internal.assert_called_once()
mock_tab_manager.select_tab.assert_called_once()
# Verify settings tab was opened
args = mock_tab_manager._add_tab_internal.call_args
assert args[0][0] == "Settings"
def test_settings_save_config_success(self, mock_page):
"""Test saving config successfully in settings."""
mock_tab_manager = Mock()
mock_tab_manager.manager.tabs = []
mock_tab_manager._add_tab_internal = Mock()
mock_tab_manager.select_tab = Mock()
with (
patch("pathlib.Path.read_text", return_value="config"),
patch("pathlib.Path.write_text"),
):
open_settings_tab(mock_page, mock_tab_manager)
# Get the settings content that was added
settings_content = mock_tab_manager._add_tab_internal.call_args[0][1]
# Find the save button and simulate click
save_btn = None
for control in settings_content.controls:
if hasattr(control, "controls"):
for sub_control in control.controls:
if (
hasattr(sub_control, "text")
and sub_control.text == "Save and Restart"
):
save_btn = sub_control
break
assert save_btn is not None
def test_settings_save_config_error(self, mock_page, mock_storage_manager):
"""Test saving config with error in settings."""
mock_tab_manager = Mock()
mock_tab_manager.manager.tabs = []
mock_tab_manager._add_tab_internal = Mock()
mock_tab_manager.select_tab = Mock()
with patch(
"ren_browser.ui.settings.get_storage_manager",
return_value=mock_storage_manager,
):
open_settings_tab(mock_page, mock_tab_manager)
settings_content = mock_tab_manager._add_tab_internal.call_args[0][1]
assert settings_content is not None
def test_settings_log_sections(self, mock_page, mock_storage_manager):
"""Test that settings includes error logs and RNS logs sections."""
mock_tab_manager = Mock()
mock_tab_manager.manager.tabs = []
mock_tab_manager._add_tab_internal = Mock()
mock_tab_manager.select_tab = Mock()
with (
patch(
"ren_browser.ui.settings.get_storage_manager",
return_value=mock_storage_manager,
),
patch("ren_browser.logs.ERROR_LOGS", ["Error 1", "Error 2"]),
patch("ren_browser.logs.RET_LOGS", ["RNS log 1", "RNS log 2"]),
):
open_settings_tab(mock_page, mock_tab_manager)
mock_tab_manager._add_tab_internal.assert_called_once()
args = mock_tab_manager._add_tab_internal.call_args
assert args[0][0] == "Settings"