Compare commits
62 Commits
e9c75b998f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
aa0c0a5675
|
|||
|
613b28a751
|
|||
|
a9e9f4e4e3
|
|||
|
61c59814a5
|
|||
|
80d3b69311
|
|||
|
ac09ac1e36
|
|||
|
7cdc7264cd
|
|||
|
94f61b1124
|
|||
|
58bbff8e69
|
|||
|
|
6457084d56 | ||
|
|
3c83a90721 | ||
|
|
c76b80586a | ||
|
b30ade6e1e
|
|||
|
7a5b467490
|
|||
|
6f82068864
|
|||
|
d4672ecb0e
|
|||
|
|
087ac8eda2 | ||
|
|
0a9acaf5da | ||
|
d0fad6a3c9
|
|||
|
468887110c
|
|||
|
32e4950a00
|
|||
|
b16074fa5c
|
|||
|
1bd7c23419
|
|||
|
f4275dd465
|
|||
|
c8b91c4687
|
|||
|
4aaeb2e809
|
|||
|
b6ea9350fa
|
|||
|
29d25cec01
|
|||
|
a634de5462
|
|||
|
1ba1781994
|
|||
|
0aae292f61
|
|||
|
3ef433af0c
|
|||
|
|
9fe33e02d8 | ||
|
2ac91a759d
|
|||
|
2c82bff204
|
|||
|
0889aa883e
|
|||
|
7780dcfc4d
|
|||
|
9ef39ae2b6
|
|||
|
86fb2b2d7c
|
|||
| 9d469f0a12 | |||
|
665a4df322
|
|||
|
3abaccb1e0
|
|||
|
77b025f580
|
|||
|
|
42e2025e54 | ||
|
|
8f84bbce31 | ||
|
3026e7da4e
|
|||
|
3522764c3e
|
|||
|
fd456e5330
|
|||
|
99a963d60c
|
|||
|
0b36e73bce
|
|||
|
4baa2e8684
|
|||
|
4344bbca70
|
|||
|
0a8a290d2d
|
|||
|
92652e8faa
|
|||
|
4f2afaed24
|
|||
|
1751e01e47
|
|||
|
0f74a47aed
|
|||
|
666ec654a0
|
|||
|
0c25cc9fd2
|
|||
|
5de83dbf49
|
|||
| 1821faadf6 | |||
|
17f0a6b0ea
|
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Build AppImage
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
- uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Install required dependencies
|
||||
run: |
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||
|
||||
- name: Checkout repo
|
||||
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Copy fedora.spec
|
||||
run: |
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
name: Build Arch Package
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:943bdad9e9d0d23503f24797b44ce2cc1531bf101e18b3e7fb8c8776190dc45e
|
||||
image: archlinux:base-devel@sha256:9414f5b766a34ebd769608b9ec80e1b4bfd7ea5e86dd49cae06ae308ad6c4221
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
steps:
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
|
||||
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
|
||||
yes | pacman -Scc
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
|
||||
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Upload Arch package
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 0.1.8
|
||||
VERSION: 0.1.9
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
steps:
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
|
||||
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
|
||||
yes | pacman -Scc
|
||||
|
||||
@@ -16,10 +16,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://gitea.com/actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
|
||||
uses: https://gitea.com/actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
fedora: ${{ steps.check.outputs.fedora }}
|
||||
arch: ${{ steps.check.outputs.arch }}
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
- uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
- uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Install required dependencies
|
||||
run: |
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||
|
||||
- name: Checkout repo
|
||||
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Copy fedora-git.spec
|
||||
run: |
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:943bdad9e9d0d23503f24797b44ce2cc1531bf101e18b3e7fb8c8776190dc45e
|
||||
image: archlinux:base-devel@sha256:9414f5b766a34ebd769608b9ec80e1b4bfd7ea5e86dd49cae06ae308ad6c4221
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
|
||||
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Upload Arch package
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
|
||||
@@ -20,10 +20,10 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
- uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
uses: https://gitea.com/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://gitea.com/actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
|
||||
uses: https://gitea.com/actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ on:
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:17c8966ef38fc361e108a550ffe2dcedf73e846f9975a974aea3d48c66b107a6
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:e09f71019881fff15f397560d979736c6c6fd712f790fdb0a75697a324ee965a
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
- uses: https://gitea.com/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
uses: https://gitea.com/actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
32
CHANGELOG.md
@@ -3,6 +3,38 @@
|
||||
Все заметные изменения в этом проекте фиксируются в этом файле.
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.1.9] - 2025-12-08
|
||||
|
||||
### Added
|
||||
- Добавлены основные и расширенные настройки для `.exe`-файлов
|
||||
- Добавлена кнопка обновления сетки без необходимости перезапуска PortProtonQt (F5 на клавиатуре, GUIDE + Select на геймпаде)
|
||||
- Добавлена эмуляция мыши по GUIDE (Xbox или PS) + Start для установки приложений или взаимодействия с инструментами Wine не адаптированные под геймпад (работает только если PortProtonQt вне фокуса)
|
||||
- При сворачивании приложения в трей оно теперь корректно восстанавливается, вместо запуска нового экземпляра
|
||||
- Добавлена поддержка SteamGridDB в качестве дополнительного источника обложек
|
||||
- При добавлении карточки в избранное она автоматически становится первой без необходимости перезапуска
|
||||
|
||||
### Changed
|
||||
- Изменено оформление виртуальной клавиатуры для лучшего соответствия общей теме
|
||||
- Ускорено чтение конфигов за счёт уменьшения количества обращений к файловой системе.
|
||||
- Из стандартной темы удалены неиспользуемые шрифты
|
||||
- Улучшена совместимость с Qt 6.10
|
||||
- Ускорен запуск программы
|
||||
- В диалог редактирования ярылыка добавлен placeholder с уточнением того что в качевстве обложки можно использовать и ссылку, а не только файл
|
||||
- Ссылку на обложку в диалоге редактирования ярлыка теперь можно указывать без протокола вроде http или https
|
||||
|
||||
### Fixed
|
||||
- Добавлено больше проверок на None для избежания вылетов
|
||||
- Улучшена работа с потоками для избежания вылетов
|
||||
- Исправлен запуск PortProton из Flatpak: теперь используется `flatpak run`, а не `start.sh`
|
||||
- Исправлено применение обложки по ссылке например со steamgriddb.com/
|
||||
- Исправлено множественное открытие окон в X11
|
||||
|
||||
### Contributors
|
||||
- @Vector_null
|
||||
- @Dervart
|
||||
|
||||
---
|
||||
|
||||
## [0.1.8] - 2025-10-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -37,7 +37,7 @@ AppDir:
|
||||
id: ru.linux_gaming.PortProtonQt
|
||||
name: PortProtonQt
|
||||
icon: ru.linux_gaming.PortProtonQt
|
||||
version: 0.1.8
|
||||
version: 0.1.9
|
||||
exec: usr/bin/python3
|
||||
exec_args: "-m portprotonqt.app $@"
|
||||
apt:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
pkgname=portprotonqt
|
||||
pkgver=0.1.8
|
||||
pkgver=0.1.9
|
||||
pkgrel=1
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
||||
arch=('any')
|
||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'python-rapidfuzz' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
|
||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
@@ -6,7 +6,7 @@ arch=('any')
|
||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'python-rapidfuzz' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
|
||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
@@ -44,9 +44,10 @@ Requires: python3-tqdm
|
||||
Requires: python3-vdf
|
||||
Requires: python3-pefile
|
||||
Requires: python3-pillow
|
||||
Requires: python3-beautifulsoup4
|
||||
Requires: python3-rapidfuzz
|
||||
Requires: perl-Image-ExifTool
|
||||
Requires: xdg-utils
|
||||
Requires: python3-beautifulsoup4
|
||||
Requires: cabextract
|
||||
Requires: gzip
|
||||
Requires: unzip
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 0.1.8
|
||||
%global pypi_version 0.1.9
|
||||
%global oname PortProtonQt
|
||||
%global _python_no_extras_requires 1
|
||||
|
||||
@@ -41,9 +41,10 @@ Requires: python3-tqdm
|
||||
Requires: python3-vdf
|
||||
Requires: python3-pefile
|
||||
Requires: python3-pillow
|
||||
Requires: python3-beautifulsoup4
|
||||
Requires: python3-rapidfuzz
|
||||
Requires: perl-Image-ExifTool
|
||||
Requires: xdg-utils
|
||||
Requires: python3-beautifulsoup4
|
||||
Requires: cabextract
|
||||
Requires: gzip
|
||||
Requires: unzip
|
||||
|
||||
@@ -1373,7 +1373,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "arena breakout infinite",
|
||||
"status": "Broken"
|
||||
"status": "Denied"
|
||||
},
|
||||
{
|
||||
"normalized_name": "pixel gun 3d pc",
|
||||
@@ -4316,7 +4316,7 @@
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "solo leveling arise",
|
||||
"normalized_name": "solo leveling arise overdrive",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
@@ -4527,10 +4527,6 @@
|
||||
"normalized_name": "project wraith",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "solo leveling arise",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "freedom wars",
|
||||
"status": "Running"
|
||||
@@ -4542,5 +4538,9 @@
|
||||
{
|
||||
"normalized_name": "no more room in hell 2",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "call of duty black ops 7",
|
||||
"status": "Denied"
|
||||
}
|
||||
]
|
||||
13130
data/games_appid.json
@@ -1,4 +1,128 @@
|
||||
[
|
||||
{
|
||||
"normalized_title": "metal gear solid v the phantom pain",
|
||||
"slug": "metal-gear-solid-v-the-phantom-pain"
|
||||
},
|
||||
{
|
||||
"normalized_title": "battlefield bad company 2",
|
||||
"slug": "battlefield-bad-company-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "call of duty black ops",
|
||||
"slug": "call-of-duty-black-ops"
|
||||
},
|
||||
{
|
||||
"normalized_title": "call of duty modern warfare 2 (2009)",
|
||||
"slug": "call-of-duty-modern-warfare-2-2009"
|
||||
},
|
||||
{
|
||||
"normalized_title": "call of duty black ops cold war",
|
||||
"slug": "call-of-duty-black-ops-cold-war"
|
||||
},
|
||||
{
|
||||
"normalized_title": "call of duty infinite warfare",
|
||||
"slug": "call-of-duty-infinite-warfare"
|
||||
},
|
||||
{
|
||||
"normalized_title": "lost planet 2",
|
||||
"slug": "lost-planet-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "lost planet extreme condition colonies",
|
||||
"slug": "lost-planet-extreme-condition-colonies-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "starcraft",
|
||||
"slug": "starcraft-remastered"
|
||||
},
|
||||
{
|
||||
"normalized_title": "the entropy centre",
|
||||
"slug": "the-entropy-centre"
|
||||
},
|
||||
{
|
||||
"normalized_title": "metal gear solid v ground zeroes",
|
||||
"slug": "metal-gear-solid-v-ground-zeroes"
|
||||
},
|
||||
{
|
||||
"normalized_title": "escape from tarkov",
|
||||
"slug": "escape-from-tarkov"
|
||||
},
|
||||
{
|
||||
"normalized_title": "command & conquer generals",
|
||||
"slug": "command-conquer-generals"
|
||||
},
|
||||
{
|
||||
"normalized_title": "command & conquer generals zero hour",
|
||||
"slug": "command-conquer-generals-zero-hour"
|
||||
},
|
||||
{
|
||||
"normalized_title": "absolum",
|
||||
"slug": "absolum"
|
||||
},
|
||||
{
|
||||
"normalized_title": "tom clancy's splinter cell chaos theory",
|
||||
"slug": "tom-clancys-splinter-cell-chaos-theory"
|
||||
},
|
||||
{
|
||||
"normalized_title": "winter burrow",
|
||||
"slug": "winter-burrow"
|
||||
},
|
||||
{
|
||||
"normalized_title": "forager",
|
||||
"slug": "forager"
|
||||
},
|
||||
{
|
||||
"normalized_title": "wall world",
|
||||
"slug": "wall-world"
|
||||
},
|
||||
{
|
||||
"normalized_title": "grand theft auto iv the",
|
||||
"slug": "grand-theft-auto-iv-the-complete-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "voidtrain",
|
||||
"slug": "voidtrain"
|
||||
},
|
||||
{
|
||||
"normalized_title": "jdm japanese drift master",
|
||||
"slug": "jdm-japanese-drift-master"
|
||||
},
|
||||
{
|
||||
"normalized_title": "lego harry potter collection",
|
||||
"slug": "lego-harry-potter-collection"
|
||||
},
|
||||
{
|
||||
"normalized_title": "life is strange season",
|
||||
"slug": "life-is-strange-complete-season"
|
||||
},
|
||||
{
|
||||
"normalized_title": "земский собор [демо]",
|
||||
"slug": "zemskij-sobor-demo"
|
||||
},
|
||||
{
|
||||
"normalized_title": "syberia",
|
||||
"slug": "syberia-remastered"
|
||||
},
|
||||
{
|
||||
"normalized_title": "europa universalis v",
|
||||
"slug": "europa-universalis-v"
|
||||
},
|
||||
{
|
||||
"normalized_title": "no i'm not a human",
|
||||
"slug": "no-im-not-a-human"
|
||||
},
|
||||
{
|
||||
"normalized_title": "dispatch digital deluxe",
|
||||
"slug": "dispatch-digital-deluxe-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "cossacks 3 digital deluxe",
|
||||
"slug": "cossacks-3-digital-deluxe"
|
||||
},
|
||||
{
|
||||
"normalized_title": "battlefield 2",
|
||||
"slug": "battlefield-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "split/second",
|
||||
"slug": "split-second"
|
||||
@@ -11,10 +135,6 @@
|
||||
"normalized_title": "foundation",
|
||||
"slug": "foundation"
|
||||
},
|
||||
{
|
||||
"normalized_title": "земский собор [демо]",
|
||||
"slug": "zemskij-sobor-demo"
|
||||
},
|
||||
{
|
||||
"normalized_title": "crusader kings 3",
|
||||
"slug": "crusader-kings-3"
|
||||
@@ -1411,10 +1531,6 @@
|
||||
"normalized_title": "world of sea battle",
|
||||
"slug": "world-of-sea-battle"
|
||||
},
|
||||
{
|
||||
"normalized_title": "escape from tarkov",
|
||||
"slug": "escape-from-tarkov"
|
||||
},
|
||||
{
|
||||
"normalized_title": "bayonetta",
|
||||
"slug": "bayonetta"
|
||||
@@ -1539,10 +1655,6 @@
|
||||
"normalized_title": "call of duty 2",
|
||||
"slug": "call-of-duty-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "call of duty infinite warfare",
|
||||
"slug": "call-of-duty-infinite-warfare"
|
||||
},
|
||||
{
|
||||
"normalized_title": "call of duty world at war",
|
||||
"slug": "call-of-duty-world-at-war"
|
||||
@@ -1735,10 +1847,6 @@
|
||||
"normalized_title": "elden ring",
|
||||
"slug": "elden-ring"
|
||||
},
|
||||
{
|
||||
"normalized_title": "starcraft",
|
||||
"slug": "starcraft-remastered"
|
||||
},
|
||||
{
|
||||
"normalized_title": "cataclismo",
|
||||
"slug": "cataclismo"
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import re
|
||||
import ast
|
||||
|
||||
# Import the security checker from the main module
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent)) # Add project root to path
|
||||
from portprotonqt.theme_security import ThemeSecurityChecker
|
||||
|
||||
# Запрещенные QSS-свойства
|
||||
FORBIDDEN_PROPERTIES = {
|
||||
@@ -13,57 +16,29 @@ FORBIDDEN_PROPERTIES = {
|
||||
"text-shadow",
|
||||
}
|
||||
|
||||
# Запрещенные модули и функции
|
||||
FORBIDDEN_MODULES = {
|
||||
"os",
|
||||
"subprocess",
|
||||
"shutil",
|
||||
"sys",
|
||||
"socket",
|
||||
"ctypes",
|
||||
"pathlib",
|
||||
"glob",
|
||||
}
|
||||
FORBIDDEN_FUNCTIONS = {
|
||||
"exec",
|
||||
"eval",
|
||||
"open",
|
||||
"__import__",
|
||||
}
|
||||
|
||||
def check_qss_files():
|
||||
has_errors = False
|
||||
for qss_file in Path("portprotonqt/themes").glob("**/*.py"):
|
||||
with open(qss_file, "r") as f:
|
||||
# Check for forbidden QSS properties first
|
||||
with open(qss_file, "r", encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Проверка на запрещённые QSS-свойства
|
||||
for prop in FORBIDDEN_PROPERTIES:
|
||||
if re.search(rf"{prop}\s*:", content, re.IGNORECASE):
|
||||
print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}")
|
||||
has_errors = True
|
||||
|
||||
# Проверка на опасные импорты и функции
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
for node in ast.walk(tree):
|
||||
# Проверка импортов
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
for name in node.names:
|
||||
if name.name in FORBIDDEN_MODULES:
|
||||
print(f"ERROR: Forbidden module '{name.name}' found in file {qss_file}")
|
||||
has_errors = True
|
||||
# Проверка вызовов функций
|
||||
if isinstance(node, ast.Call):
|
||||
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
|
||||
print(f"ERROR: Forbidden function '{node.func.id}' found in file {qss_file}")
|
||||
has_errors = True
|
||||
except SyntaxError as e:
|
||||
print(f"ERROR: Syntax error in file {qss_file}: {e}")
|
||||
for prop in FORBIDDEN_PROPERTIES:
|
||||
if re.search(rf"{prop}\s*:", content, re.IGNORECASE):
|
||||
print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}")
|
||||
has_errors = True
|
||||
|
||||
# Use the imported ThemeSecurityChecker to check for dangerous imports and functions
|
||||
checker = ThemeSecurityChecker()
|
||||
is_safe, errors = checker.check_theme_safety(str(qss_file))
|
||||
|
||||
if not is_safe:
|
||||
for error in errors:
|
||||
print(error)
|
||||
has_errors = True
|
||||
|
||||
return has_errors
|
||||
|
||||
if __name__ == "__main__":
|
||||
if check_qss_files():
|
||||
sys.exit(1)
|
||||
sys.exit(1)
|
||||
@@ -21,9 +21,9 @@ Current translation status:
|
||||
|
||||
| Locale | Progress | Translated |
|
||||
| :----- | -------: | ---------: |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 339 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 339 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 339 of 339 |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 341 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 341 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 341 of 341 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
|
||||
| Локаль | Прогресс | Переведено |
|
||||
| :----- | -------: | ---------: |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 339 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 339 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 339 из 339 |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 341 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 341 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 341 из 341 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -31,17 +31,49 @@ mkdir -p ~/.local/share/PortProtonQT/themes/my_custom_theme
|
||||
|
||||
## 🎨 Style File (`styles.py`)
|
||||
|
||||
Create a `styles.py` in the theme root. It should define variables or functions that return CSS.
|
||||
Create a `styles.py` in the theme root. It should define variables or functions that return QSS (Qt Style Sheets). For better organization, you can split your theme into multiple submodules by creating a subdirectory (e.g., `styles`, `components`, etc.) with separate Python files for different components, and import them in `styles.py`.
|
||||
|
||||
**Example:**
|
||||
**Example of modular structure:**
|
||||
```
|
||||
my_custom_theme/
|
||||
├── styles.py
|
||||
├── metainfo.ini
|
||||
├── fonts/
|
||||
├── images/
|
||||
└── styles/ # This can be named anything (e.g., components, modules, etc.)
|
||||
├── __init__.py # This empty file makes the directory a Python package
|
||||
├── constants.py
|
||||
├── base.py
|
||||
├── game_card.py
|
||||
├── detail_page.py
|
||||
├── settings.py
|
||||
├── winetricks.py
|
||||
└── theme_utils.py
|
||||
```
|
||||
|
||||
**Main styles.py file:**
|
||||
```python
|
||||
def custom_button_style(color1, color2):
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {color1}, stop:1 {color2});
|
||||
}}
|
||||
"""
|
||||
# Import from the theme's submodules using absolute paths relative to the package
|
||||
# Replace 'my_custom_theme' with your actual theme folder name and 'styles' with your subdirectory name
|
||||
from portprotonqt.themes.my_custom_theme.styles.constants import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.base import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.game_card import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.detail_page import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.settings import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.winetricks import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.theme_utils import *
|
||||
```
|
||||
|
||||
**Example submodule (styles/constants.py):**
|
||||
```python
|
||||
# Theme constants
|
||||
font_family = "Play"
|
||||
font_size_a = "16px"
|
||||
font_size_b = "24px"
|
||||
border_radius_a = "10px"
|
||||
color_a = "#409EFF"
|
||||
color_b = "#282a33"
|
||||
# ... other constants
|
||||
```
|
||||
|
||||
---
|
||||
@@ -207,18 +239,52 @@ GAME_CARD_ANIMATION = {
|
||||
|
||||
```ini
|
||||
[Metainfo]
|
||||
name = My Custom Theme
|
||||
name_en = My Custom Theme
|
||||
name_ru = Моя пользовательская тема
|
||||
author = Your Name
|
||||
author_link = https://example.com
|
||||
description = Description of your theme.
|
||||
description_en = Description of your theme.
|
||||
description_ru = Описание вашей темы.
|
||||
```
|
||||
|
||||
### Translation Support
|
||||
|
||||
You must provide translations for your theme's name and description by adding language-specific fields:
|
||||
- `name_en`, `name_ru`, etc. for theme names
|
||||
- `description_en`, `description_ru`, etc. for theme descriptions
|
||||
|
||||
The application will automatically select the appropriate translation based on the user's system language, falling back to English if translations are not available for the user's language.
|
||||
|
||||
---
|
||||
|
||||
## 🖼 Screenshots
|
||||
|
||||
Folder: `images/screenshots/` — place UI screenshots there.
|
||||
|
||||
### Screenshot Translation Support
|
||||
|
||||
You can provide translations for screenshot captions by adding entries to the `[Screenshots]` section in your `metainfo.ini` file:
|
||||
|
||||
```ini
|
||||
[Screenshots]
|
||||
auto_installs_en = Auto-installs
|
||||
auto_installs_ru = Автоустановки
|
||||
library_en = Library
|
||||
library_ru = Библиотека
|
||||
game_card_en = Game Card
|
||||
game_card_ru = Карточка
|
||||
context_menu_en = Context Menu
|
||||
context_menu_ru = Контекстное меню
|
||||
portproton_settings_en = PortProton Settings
|
||||
portproton_settings_ru = Настройки PortProton
|
||||
wine_settings_en = Wine Settings
|
||||
wine_settings_ru = Настройки Wine
|
||||
themes_en = Themes
|
||||
themes_ru = Темы
|
||||
```
|
||||
|
||||
Screenshot files should be named in English (without spaces), and the application will display the appropriate translated caption based on the user's system language, falling back to English if translations are not available.
|
||||
|
||||
---
|
||||
|
||||
## 🔡 Fonts and Icons (optional)
|
||||
|
||||
@@ -31,17 +31,49 @@ mkdir -p ~/.local/share/PortProtonQT/themes/my_custom_theme
|
||||
|
||||
## 🎨 Файл стилей (`styles.py`)
|
||||
|
||||
Создайте `styles.py` в корне темы. В нём определите переменные и/или функции, возвращающие CSS-оформление.
|
||||
Создайте `styles.py` в корне темы. В нём определите переменные и/или функции, возвращающие QSS-оформление (Qt Style Sheets). Для лучшей организации кода, вы можете разделить тему на несколько подмодулей, создав поддиректорию (например, `styles`, `components` и т.д.) с отдельными Python-файлами для разных компонентов, и импортировать их в `styles.py`.
|
||||
|
||||
**Пример функции:**
|
||||
**Пример модульной структуры:**
|
||||
```
|
||||
my_custom_theme/
|
||||
├── styles.py
|
||||
├── metainfo.ini
|
||||
├── fonts/
|
||||
├── images/
|
||||
└── styles/ # Это может быть названо как угодно (например, components, modules и т.д.)
|
||||
├── __init__.py # Этот пустой файл делает директорию Python-пакетом
|
||||
├── constants.py
|
||||
├── base.py
|
||||
├── game_card.py
|
||||
├── detail_page.py
|
||||
├── settings.py
|
||||
├── winetricks.py
|
||||
└── theme_utils.py
|
||||
```
|
||||
|
||||
**Основной файл styles.py:**
|
||||
```python
|
||||
def custom_button_style(color1, color2):
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {color1}, stop:1 {color2});
|
||||
}}
|
||||
"""
|
||||
# Импорт из подмодулей темы с использованием абсолютных путей относительно пакета
|
||||
# Замените 'my_custom_theme' на фактическое имя папки вашей темы и 'styles' на имя вашей поддиректории
|
||||
from portprotonqt.themes.my_custom_theme.styles.constants import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.base import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.game_card import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.detail_page import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.settings import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.winetricks import *
|
||||
from portprotonqt.themes.my_custom_theme.styles.theme_utils import *
|
||||
```
|
||||
|
||||
**Пример подмодуля (styles/constants.py):**
|
||||
```python
|
||||
# Константы темы
|
||||
font_family = "Play"
|
||||
font_size_a = "16px"
|
||||
font_size_b = "24px"
|
||||
border_radius_a = "10px"
|
||||
color_a = "#409EFF"
|
||||
color_b = "#282a33"
|
||||
# ... другие константы
|
||||
```
|
||||
|
||||
---
|
||||
@@ -207,18 +239,52 @@ GAME_CARD_ANIMATION = {
|
||||
|
||||
```ini
|
||||
[Metainfo]
|
||||
name = My Custom Theme
|
||||
name_en = My Custom Theme
|
||||
name_ru = Моя пользовательская тема
|
||||
author = Ваше имя
|
||||
author_link = https://example.com
|
||||
description = Описание вашей темы.
|
||||
description_en = Description of your theme.
|
||||
description_ru = Описание вашей темы.
|
||||
```
|
||||
|
||||
### Поддержка переводов
|
||||
|
||||
Вы должны предоставить переводы для названия и описания вашей темы, добавив поля с указанием языка:
|
||||
- `name_en`, `name_ru` и т.д. для названий тем
|
||||
- `description_en`, `description_ru` и т.д. для описаний тем
|
||||
|
||||
Приложение автоматически выберет соответствующий перевод на основе языка системы пользователя, с откатом к английскому языку, если переводы недоступны для языка пользователя.
|
||||
|
||||
---
|
||||
|
||||
## 🖼 Скриншоты
|
||||
|
||||
Папка: `images/screenshots/` — любые изображения оформления темы.
|
||||
|
||||
### Поддержка перевода скриншотов
|
||||
|
||||
Вы можете предоставить переводы для подписей к скриншотам, добавив записи в секцию `[Screenshots]` в файле `metainfo.ini`:
|
||||
|
||||
```ini
|
||||
[Screenshots]
|
||||
auto_installs_en = Auto-installs
|
||||
auto_installs_ru = Автоустановки
|
||||
library_en = Library
|
||||
library_ru = Библиотека
|
||||
game_card_en = Game Card
|
||||
game_card_ru = Карточка
|
||||
context_menu_en = Context Menu
|
||||
context_menu_ru = Контекстное меню
|
||||
portproton_settings_en = PortProton Settings
|
||||
portproton_settings_ru = Настройки PortProton
|
||||
wine_settings_en = Wine Settings
|
||||
wine_settings_ru = Настройки Wine
|
||||
themes_en = Themes
|
||||
themes_ru = Темы
|
||||
```
|
||||
|
||||
Файлы скриншотов должны быть названы на английском языке (без пробелов), и приложение будет отображать соответствующую переведенную подпись в зависимости от языка системы пользователя, с откатом к английскому языку, если переводы недоступны.
|
||||
|
||||
---
|
||||
|
||||
## 🔡 Шрифты и иконки (опционально)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from typing import Any, cast
|
||||
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstractAnimation, QParallelAnimationGroup, QRect, Qt, QPoint
|
||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
|
||||
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
|
||||
@@ -236,14 +237,31 @@ class DetailPageAnimations:
|
||||
self.main_window = main_window
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
|
||||
self.animations = main_window._animations if hasattr(main_window, '_animations') else {}
|
||||
# Ensure the main window has an animations dict
|
||||
if not hasattr(main_window, '_animations'):
|
||||
main_window._animations = {}
|
||||
self.animations = main_window._animations
|
||||
|
||||
def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
|
||||
"""Animate the detail page based on theme settings."""
|
||||
# Check if the detail page is still valid before proceeding
|
||||
if not detail_page or detail_page.isHidden() or detail_page.parent() is None:
|
||||
logger.warning("Detail page is not valid, skipping enter animation")
|
||||
load_image_and_restore_effect()
|
||||
cleanup_animation()
|
||||
return
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
|
||||
|
||||
if animation_type == "fade":
|
||||
# Check again if page is still valid before starting animation
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during fade setup, skipping animation")
|
||||
load_image_and_restore_effect()
|
||||
cleanup_animation()
|
||||
return
|
||||
|
||||
original_effect = detail_page.graphicsEffect()
|
||||
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=True)
|
||||
opacity_effect.setOpacity(0.0)
|
||||
@@ -252,17 +270,36 @@ class DetailPageAnimations:
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(0.0)
|
||||
animation.setEndValue(0.999)
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
|
||||
def restore_effect():
|
||||
try:
|
||||
detail_page.setGraphicsEffect(original_effect) # type: ignore
|
||||
# Check if page is still valid before restoring effect
|
||||
if detail_page and not detail_page.isHidden():
|
||||
detail_page.setGraphicsEffect(cast(Any, original_effect))
|
||||
except RuntimeError:
|
||||
logger.warning("Original effect already deleted")
|
||||
animation.finished.connect(restore_effect)
|
||||
animation.finished.connect(load_image_and_restore_effect)
|
||||
animation.finished.connect(opacity_effect.deleteLater)
|
||||
|
||||
# Only start animation if page is still valid
|
||||
if detail_page and not detail_page.isHidden():
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
animation.finished.connect(restore_effect)
|
||||
animation.finished.connect(load_image_and_restore_effect)
|
||||
animation.finished.connect(opacity_effect.deleteLater)
|
||||
else:
|
||||
logger.warning("Detail page invalid when starting fade, cleaning up")
|
||||
restore_effect()
|
||||
load_image_and_restore_effect()
|
||||
opacity_effect.deleteLater()
|
||||
cleanup_animation()
|
||||
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
|
||||
# Check again if page is still valid before starting animation
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during slide setup, skipping animation")
|
||||
load_image_and_restore_effect()
|
||||
cleanup_animation()
|
||||
return
|
||||
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
|
||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
|
||||
start_pos = {
|
||||
@@ -277,11 +314,25 @@ class DetailPageAnimations:
|
||||
animation.setStartValue(start_pos)
|
||||
animation.setEndValue(self.main_window.stackedWidget.rect().topLeft())
|
||||
animation.setEasingCurve(easing_curve)
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
animation.finished.connect(cleanup_animation)
|
||||
animation.finished.connect(load_image_and_restore_effect)
|
||||
|
||||
# Only start animation if page is still valid
|
||||
if detail_page and not detail_page.isHidden():
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
animation.finished.connect(cleanup_animation)
|
||||
animation.finished.connect(load_image_and_restore_effect)
|
||||
else:
|
||||
logger.warning("Detail page invalid when starting slide, cleaning up")
|
||||
load_image_and_restore_effect()
|
||||
cleanup_animation()
|
||||
elif animation_type == "bounce":
|
||||
# Check again if page is still valid before starting animation
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during bounce setup, skipping animation")
|
||||
load_image_and_restore_effect()
|
||||
cleanup_animation()
|
||||
return
|
||||
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration", 400)
|
||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
|
||||
detail_page.setWindowOpacity(0.0)
|
||||
@@ -300,14 +351,27 @@ class DetailPageAnimations:
|
||||
group_anim = QParallelAnimationGroup()
|
||||
group_anim.addAnimation(opacity_anim)
|
||||
group_anim.addAnimation(geometry_anim)
|
||||
group_anim.finished.connect(load_image_and_restore_effect)
|
||||
group_anim.finished.connect(cleanup_animation)
|
||||
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = group_anim
|
||||
|
||||
# Only start animation if page is still valid
|
||||
if detail_page and not detail_page.isHidden():
|
||||
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = group_anim
|
||||
group_anim.finished.connect(load_image_and_restore_effect)
|
||||
group_anim.finished.connect(cleanup_animation)
|
||||
else:
|
||||
logger.warning("Detail page invalid when starting bounce, cleaning up")
|
||||
load_image_and_restore_effect()
|
||||
cleanup_animation()
|
||||
|
||||
def animate_detail_page_exit(self, detail_page: QWidget, cleanup_callback: Callable):
|
||||
"""Animate the detail page exit based on theme settings."""
|
||||
try:
|
||||
# Check if the detail page is still valid before proceeding
|
||||
if not detail_page or detail_page.isHidden() or detail_page.parent() is None:
|
||||
logger.warning("Detail page is not valid, skipping exit animation")
|
||||
cleanup_callback()
|
||||
return
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
|
||||
|
||||
# Safely stop and remove any existing animation
|
||||
@@ -326,6 +390,13 @@ class DetailPageAnimations:
|
||||
# Define animation based on type
|
||||
if animation_type == "fade":
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350)
|
||||
|
||||
# Check if page is still valid before accessing properties
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during fade exit setup, skipping animation")
|
||||
cleanup_callback()
|
||||
return
|
||||
|
||||
original_effect = detail_page.graphicsEffect()
|
||||
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=False)
|
||||
opacity_effect.setOpacity(0.999)
|
||||
@@ -334,18 +405,36 @@ class DetailPageAnimations:
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(0.999)
|
||||
animation.setEndValue(0.0)
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
|
||||
def restore_and_cleanup():
|
||||
try:
|
||||
detail_page.setGraphicsEffect(original_effect) # type: ignore
|
||||
# Check if page is still valid before restoring effect
|
||||
if detail_page and not detail_page.isHidden():
|
||||
detail_page.setGraphicsEffect(cast(Any, original_effect))
|
||||
except RuntimeError:
|
||||
logger.debug("Original effect already deleted")
|
||||
cleanup_callback()
|
||||
animation.finished.connect(restore_and_cleanup)
|
||||
animation.finished.connect(opacity_effect.deleteLater)
|
||||
|
||||
# Check if animation is still valid before starting
|
||||
if animation and not detail_page.isHidden():
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
animation.finished.connect(restore_and_cleanup)
|
||||
animation.finished.connect(opacity_effect.deleteLater)
|
||||
else:
|
||||
logger.warning("Animation or detail page invalid when starting fade exit, cleaning up")
|
||||
restore_and_cleanup()
|
||||
opacity_effect.deleteLater()
|
||||
|
||||
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
|
||||
|
||||
# Check if page is still valid before accessing properties
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during slide exit setup, skipping animation")
|
||||
cleanup_callback()
|
||||
return
|
||||
|
||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
|
||||
end_pos = {
|
||||
"slide_left": QPoint(-self.main_window.width(), 0),
|
||||
@@ -353,16 +442,37 @@ class DetailPageAnimations:
|
||||
"slide_up": QPoint(0, self.main_window.height()),
|
||||
"slide_down": QPoint(0, -self.main_window.height())
|
||||
}[animation_type]
|
||||
|
||||
animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(detail_page.pos())
|
||||
animation.setEndValue(end_pos)
|
||||
animation.setEasingCurve(easing_curve)
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
animation.finished.connect(cleanup_callback)
|
||||
|
||||
def slide_cleanup():
|
||||
# Check if page is still valid before cleanup
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.debug("Detail page already cleaned up")
|
||||
cleanup_callback()
|
||||
|
||||
# Check if animation is still valid before starting
|
||||
if animation and not detail_page.isHidden():
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
animation.finished.connect(slide_cleanup)
|
||||
else:
|
||||
logger.warning("Animation or detail page invalid when starting slide exit, cleaning up")
|
||||
slide_cleanup()
|
||||
|
||||
elif animation_type == "bounce":
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration_exit", 400)
|
||||
|
||||
# Check if page is still valid before accessing properties
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during bounce exit setup, skipping animation")
|
||||
cleanup_callback()
|
||||
return
|
||||
|
||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
|
||||
opacity_anim = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity"))
|
||||
opacity_anim.setDuration(duration)
|
||||
@@ -375,13 +485,38 @@ class DetailPageAnimations:
|
||||
geometry_anim.setStartValue(detail_page.geometry())
|
||||
geometry_anim.setEndValue(final_rect)
|
||||
geometry_anim.setEasingCurve(easing_curve)
|
||||
|
||||
# Check if animations are still valid before creating group
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during bounce exit setup, cleaning up")
|
||||
cleanup_callback()
|
||||
return
|
||||
|
||||
group_anim = QParallelAnimationGroup()
|
||||
group_anim.addAnimation(opacity_anim)
|
||||
group_anim.addAnimation(geometry_anim)
|
||||
group_anim.finished.connect(cleanup_callback)
|
||||
|
||||
# Check if group animation is still valid before connecting
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during group animation setup, cleaning up")
|
||||
cleanup_callback()
|
||||
return
|
||||
|
||||
def bounce_cleanup():
|
||||
# Check if page is still valid before cleanup
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.debug("Detail page already cleaned up")
|
||||
cleanup_callback()
|
||||
|
||||
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = group_anim
|
||||
group_anim.finished.connect(bounce_cleanup)
|
||||
except RuntimeError:
|
||||
# Widget was already deleted, which is expected after deleteLater()
|
||||
logger.debug("Detail page already deleted during animation setup")
|
||||
cleanup_callback()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True)
|
||||
self.animations.pop(detail_page, None)
|
||||
if detail_page in self.animations:
|
||||
self.animations.pop(detail_page, None)
|
||||
cleanup_callback()
|
||||
|
||||
@@ -17,7 +17,7 @@ from portprotonqt.cli import parse_args
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
__app_version__ = "0.1.8"
|
||||
__app_version__ = "0.1.9"
|
||||
|
||||
def get_version():
|
||||
try:
|
||||
@@ -34,13 +34,12 @@ def main():
|
||||
os.environ["PROCESS_LOG"] = "1"
|
||||
os.environ["START_FROM_STEAM"] = "1"
|
||||
|
||||
# Get the PortProton start command
|
||||
start_sh = get_portproton_start_command()
|
||||
|
||||
if start_sh is None:
|
||||
return
|
||||
|
||||
subprocess.run(start_sh + ["cli", "--initial"])
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setWindowIcon(QIcon.fromTheme(__app_id__))
|
||||
app.setDesktopFileName(__app_id__)
|
||||
@@ -104,15 +103,14 @@ def main():
|
||||
def restore_window():
|
||||
try:
|
||||
if msg.startswith("show"):
|
||||
if hasattr(window, "restore_from_tray"):
|
||||
window.restore_from_tray() # type: ignore[attr-defined]
|
||||
else:
|
||||
window.showNormal()
|
||||
window.raise_()
|
||||
window.activateWindow()
|
||||
window.setWindowState(
|
||||
window.windowState() & ~Qt.WindowState.WindowMinimized | Qt.WindowState.WindowActive
|
||||
)
|
||||
# Ensure the window is visible and not minimized
|
||||
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
|
||||
window.show()
|
||||
window.raise_()
|
||||
window.activateWindow()
|
||||
|
||||
# Ensure window is in active state for systems with strict focus policies
|
||||
window.setWindowState(window.windowState() | Qt.WindowState.WindowActive)
|
||||
|
||||
if ":fullscreen" in msg:
|
||||
logger.info("Switching to fullscreen via IPC")
|
||||
@@ -145,6 +143,22 @@ def main():
|
||||
save_fullscreen_config(False)
|
||||
window.showNormal()
|
||||
|
||||
# Execute the initial PortProton command after the UI is set up
|
||||
def run_initial_command():
|
||||
nonlocal start_sh
|
||||
if start_sh:
|
||||
try:
|
||||
subprocess.run(start_sh + ["cli", "--initial"], timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Initial PortProton command timed out")
|
||||
except Exception as e:
|
||||
logger.error(f"Error running initial PortProton command: {e}")
|
||||
else:
|
||||
logger.warning("PortProton start command not available, skipping initial command")
|
||||
|
||||
# Run the initial command after the UI is displayed
|
||||
QTimer.singleShot(100, run_initial_command)
|
||||
|
||||
# --- Cleanup ---
|
||||
def cleanup_on_exit():
|
||||
try:
|
||||
|
||||
@@ -3,6 +3,7 @@ import configparser
|
||||
import shutil
|
||||
import subprocess
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.localization import get_theme_translations
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -77,22 +78,6 @@ def invalidate_config_cache(config_file: str = CONFIG_FILE):
|
||||
del _config_last_modified[config_file]
|
||||
logger.debug(f"Config cache invalidated for {config_file}")
|
||||
|
||||
def read_config():
|
||||
"""Reads the configuration file and returns a dictionary of parameters.
|
||||
Example line in config (no sections):
|
||||
detail_level = detailed
|
||||
"""
|
||||
config_dict = {}
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
with open(CONFIG_FILE, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
key, sep, value = line.partition("=")
|
||||
if sep:
|
||||
config_dict[key.strip()] = value.strip()
|
||||
return config_dict
|
||||
|
||||
def read_theme_from_config():
|
||||
"""Reads the theme from the [Appearance] section of the configuration file.
|
||||
@@ -137,8 +122,14 @@ def save_time_config(detail_level):
|
||||
|
||||
def read_file_content(file_path):
|
||||
"""Reads the content of a file and returns it as a string."""
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
try:
|
||||
# Add timeout protection for file operations using a simple approach
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
content = f.read().strip()
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading file {file_path}: {e}")
|
||||
raise # Re-raise the exception to be handled by the caller
|
||||
|
||||
def get_portproton_location():
|
||||
"""Возвращает путь к PortProton каталогу (строку) или None."""
|
||||
@@ -159,6 +150,8 @@ def get_portproton_location():
|
||||
logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults")
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(f"Failed to read PortProton configuration file: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Unexpected error reading PortProton configuration file: {e}")
|
||||
|
||||
default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
|
||||
if os.path.isdir(default_flatpak_dir):
|
||||
@@ -175,18 +168,39 @@ def get_portproton_start_command():
|
||||
if not portproton_path:
|
||||
return None
|
||||
|
||||
# Check if flatpak command exists before trying to run it
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["flatpak", "list"],
|
||||
subprocess.run(
|
||||
["flatpak", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False
|
||||
check=False,
|
||||
timeout=5
|
||||
)
|
||||
if "ru.linux_gaming.PortProton" in result.stdout:
|
||||
logger.info("Detected Flatpak installation")
|
||||
return ["flatpak", "run", "ru.linux_gaming.PortProton"]
|
||||
flatpak_available = True
|
||||
except FileNotFoundError:
|
||||
flatpak_available = False
|
||||
except Exception:
|
||||
pass
|
||||
flatpak_available = False
|
||||
|
||||
if flatpak_available:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["flatpak", "list"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=10
|
||||
)
|
||||
if "ru.linux_gaming.PortProton" in result.stdout:
|
||||
logger.info("Detected Flatpak installation")
|
||||
return ["flatpak", "run", "ru.linux_gaming.PortProton"]
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Flatpak list command timed out")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking flatpak list: {e}")
|
||||
pass
|
||||
|
||||
start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh")
|
||||
if os.path.exists(start_sh_path):
|
||||
@@ -215,13 +229,17 @@ def load_theme_metainfo(theme_name):
|
||||
theme_folder = os.path.join(themes_dir, theme_name)
|
||||
metainfo_file = os.path.join(theme_folder, "metainfo.ini")
|
||||
if os.path.exists(metainfo_file):
|
||||
# Load translated theme name and description
|
||||
theme_translations = get_theme_translations(metainfo_file)
|
||||
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read(metainfo_file, encoding="utf-8")
|
||||
if "Metainfo" in cp:
|
||||
meta["author"] = cp.get("Metainfo", "author", fallback="Unknown")
|
||||
meta["author_link"] = cp.get("Metainfo", "author_link", fallback="")
|
||||
meta["description"] = cp.get("Metainfo", "description", fallback="")
|
||||
meta["name"] = cp.get("Metainfo", "name", fallback=theme_name)
|
||||
# Use translated name and description
|
||||
meta["name"] = theme_translations.get("name", theme_name)
|
||||
meta["description"] = theme_translations.get("description", "")
|
||||
break
|
||||
return meta
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class ContextMenuSignals(QObject):
|
||||
class ContextMenuManager:
|
||||
"""Manages context menu actions for game management in PortProtonQt."""
|
||||
|
||||
def __init__(self, parent, portproton_location, theme, load_games_callback, game_library_manager):
|
||||
def __init__(self, parent, portproton_location, theme, game_library_manager):
|
||||
"""
|
||||
Initialize the ContextMenuManager.
|
||||
|
||||
@@ -44,7 +44,6 @@ class ContextMenuManager:
|
||||
self.portproton_location = portproton_location
|
||||
self.theme = theme
|
||||
self.theme_manager = ThemeManager()
|
||||
self.load_games = load_games_callback
|
||||
self.game_library_manager = game_library_manager
|
||||
self.update_game_grid = game_library_manager.update_game_grid
|
||||
self.legendary_path = os.path.join(
|
||||
@@ -1035,7 +1034,15 @@ Icon={icon_path}
|
||||
)
|
||||
return
|
||||
|
||||
if os.path.isfile(new_cover_path):
|
||||
# Check if new_cover_path is a URL by checking for common image extensions
|
||||
image_extensions = ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp')
|
||||
has_image_extension = any(new_cover_path.lower().endswith(ext) for ext in image_extensions)
|
||||
|
||||
# Consider it a URL if it has image extension and is not a local file
|
||||
is_url = has_image_extension and not os.path.isfile(new_cover_path)
|
||||
|
||||
# Use the downloaded file path if we have a URL and the file was downloaded, otherwise use the local file
|
||||
if os.path.isfile(new_cover_path) or (is_url and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path)):
|
||||
exe_name = os.path.splitext(os.path.basename(new_exe_path))[0]
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME",
|
||||
@@ -1043,16 +1050,25 @@ Icon={icon_path}
|
||||
)
|
||||
custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name)
|
||||
os.makedirs(custom_folder, exist_ok=True)
|
||||
ext = os.path.splitext(new_cover_path)[1].lower()
|
||||
|
||||
# Use the actual cover file path (either from URL download or local file)
|
||||
cover_to_copy = dialog.last_cover_path if is_url and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path) else new_cover_path
|
||||
ext = os.path.splitext(cover_to_copy)[1].lower()
|
||||
if ext in [".png", ".jpg", ".jpeg", ".bmp"]:
|
||||
try:
|
||||
shutil.copyfile(new_cover_path, os.path.join(custom_folder, f"cover{ext}"))
|
||||
shutil.copyfile(cover_to_copy, os.path.join(custom_folder, f"cover{ext}"))
|
||||
except OSError as e:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Failed to copy cover image: {error}").format(error=str(e))
|
||||
)
|
||||
return
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Unsupported image format: {extension}").format(extension=ext)
|
||||
)
|
||||
return
|
||||
|
||||
def add_to_steam(self, game_name, exec_line, cover_path):
|
||||
"""
|
||||
|
||||
878
portprotonqt/detail_pages.py
Normal file
@@ -0,0 +1,878 @@
|
||||
import os
|
||||
import shlex
|
||||
from PySide6.QtWidgets import (QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QLabel, QHBoxLayout, QWidget)
|
||||
from PySide6.QtCore import Qt, QUrl, QTimer, QAbstractAnimation
|
||||
from PySide6.QtGui import QColor, QDesktopServices
|
||||
from portprotonqt.image_utils import load_pixmap_async, round_corners
|
||||
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.howlongtobeat_api import HowLongToBeat
|
||||
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.portproton_api import PortProtonAPI
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.animations import DetailPageAnimations
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DetailPageManager:
|
||||
"""Manages detail pages for games."""
|
||||
|
||||
def __init__(self, main_window):
|
||||
self.main_window = main_window
|
||||
self._detail_page_active = False
|
||||
self._current_detail_page = None
|
||||
self._exit_animation_in_progress = False
|
||||
self._animations = {}
|
||||
self.portproton_api = PortProtonAPI(Downloader(max_workers=4))
|
||||
|
||||
def openGameDetailPage(self, name, description, cover_path=None, appid="", exec_line="", controller_support="",
|
||||
last_launch="", formatted_playtime="", protondb_tier="", game_source="", anticheat_status=""):
|
||||
"""Open detailed game information page showing all game stats, playtime and settings."""
|
||||
detailPage = QWidget()
|
||||
imageLabel = QLabel()
|
||||
imageLabel.setFixedSize(300, 450)
|
||||
self._detail_page_active = True
|
||||
self._current_detail_page = detailPage
|
||||
# Store the source tab index (Library is typically index 0)
|
||||
self._return_to_tab_index = 0 # Library tab
|
||||
|
||||
# Function to load image and restore effect
|
||||
def load_image_and_restore_effect():
|
||||
# Check if detail page still exists and is valid
|
||||
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
|
||||
logger.warning("Detail page is None, hidden, or no longer valid, skipping image load")
|
||||
return
|
||||
|
||||
try:
|
||||
detailPage.setWindowOpacity(1.0)
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page is None, hidden, or no longer valid, skipping opacity set")
|
||||
return
|
||||
|
||||
if cover_path:
|
||||
def on_pixmap_ready(pixmap):
|
||||
# Check if detail page still exists and is valid
|
||||
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
|
||||
logger.warning("Detail page is None, hidden, or no longer valid, skipping pixmap update")
|
||||
return
|
||||
try:
|
||||
rounded = round_corners(pixmap, 10)
|
||||
imageLabel.setPixmap(rounded)
|
||||
logger.debug("Pixmap set for imageLabel")
|
||||
|
||||
def on_palette_ready(palette):
|
||||
# Check if detail page still exists and is valid
|
||||
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
|
||||
logger.warning("Detail page is None, hidden, or no longer valid, skipping palette update")
|
||||
return
|
||||
try:
|
||||
dark_palette = [self.main_window.darkenColor(color, factor=200) for color in palette]
|
||||
stops = ",\n".join(
|
||||
[f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))]
|
||||
)
|
||||
detailPage.setStyleSheet(self.main_window.theme.detail_page_style(stops))
|
||||
detailPage.update()
|
||||
logger.debug("Stylesheet updated with palette")
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping palette stylesheet update")
|
||||
self.main_window.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping pixmap update")
|
||||
load_pixmap_async(cover_path, 300, 450, on_pixmap_ready)
|
||||
else:
|
||||
try:
|
||||
detailPage.setStyleSheet(self.main_window.theme.DETAIL_PAGE_NO_COVER_STYLE)
|
||||
detailPage.update()
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping no-cover stylesheet update")
|
||||
|
||||
def cleanup_animation():
|
||||
if detailPage in self._animations:
|
||||
del self._animations[detailPage]
|
||||
|
||||
mainLayout = QVBoxLayout(detailPage)
|
||||
mainLayout.setContentsMargins(30, 30, 30, 30)
|
||||
mainLayout.setSpacing(20)
|
||||
|
||||
backButton = AutoSizeButton(_("Back"), icon=self.main_window.theme_manager.get_icon("back"))
|
||||
backButton.setFixedWidth(100)
|
||||
backButton.setStyleSheet(self.main_window.theme.ADDGAME_BACK_BUTTON_STYLE)
|
||||
backButton.clicked.connect(lambda: self.goBackDetailPage(detailPage))
|
||||
mainLayout.addWidget(backButton, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
contentFrame = QFrame()
|
||||
contentFrame.setStyleSheet(self.main_window.theme.DETAIL_CONTENT_FRAME_STYLE)
|
||||
contentFrameLayout = QHBoxLayout(contentFrame)
|
||||
contentFrameLayout.setContentsMargins(20, 20, 20, 20)
|
||||
contentFrameLayout.setSpacing(40)
|
||||
mainLayout.addWidget(contentFrame)
|
||||
|
||||
# Cover (at left)
|
||||
coverFrame = QFrame()
|
||||
coverFrame.setFixedSize(300, 450)
|
||||
coverFrame.setStyleSheet(self.main_window.theme.COVER_FRAME_STYLE)
|
||||
shadow = QGraphicsDropShadowEffect(coverFrame)
|
||||
shadow.setBlurRadius(20)
|
||||
shadow.setColor(QColor(0, 0, 0, 200))
|
||||
shadow.setOffset(0, 0)
|
||||
coverFrame.setGraphicsEffect(shadow)
|
||||
coverLayout = QVBoxLayout(coverFrame)
|
||||
coverLayout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
coverLayout.addWidget(imageLabel)
|
||||
|
||||
# Favorite icon
|
||||
favoriteLabelCover = ClickableLabel(coverFrame)
|
||||
favoriteLabelCover.setFixedSize(*self.main_window.theme.favoriteLabelSize)
|
||||
favoriteLabelCover.setStyleSheet(self.main_window.theme.FAVORITE_LABEL_STYLE)
|
||||
favorites = read_favorites()
|
||||
if name in favorites:
|
||||
favoriteLabelCover.setText("★")
|
||||
else:
|
||||
favoriteLabelCover.setText("☆")
|
||||
favoriteLabelCover.clicked.connect(lambda: self.toggleFavoriteInDetailPage(name, favoriteLabelCover))
|
||||
favoriteLabelCover.move(8, 8)
|
||||
favoriteLabelCover.raise_()
|
||||
|
||||
# Add badges (ProtonDB, Steam, PortProton, WeAntiCheatYet)
|
||||
display_filter = read_display_filter()
|
||||
steam_visible = (str(game_source).lower() == "steam" and display_filter in ("all", "favorites"))
|
||||
egs_visible = (str(game_source).lower() == "epic" and display_filter in ("all", "favorites"))
|
||||
portproton_visible = (str(game_source).lower() == "portproton" and display_filter in ("all", "favorites"))
|
||||
right_margin = 8
|
||||
badge_spacing = 5
|
||||
top_y = 10
|
||||
badge_y_positions = []
|
||||
badge_width = int(300 * 2/3)
|
||||
|
||||
# ProtonDB badge
|
||||
protondb_text = GameCard.getProtonDBText(protondb_tier)
|
||||
if protondb_text:
|
||||
icon_filename = GameCard.getProtonDBIconFilename(protondb_tier)
|
||||
icon = self.main_window.theme_manager.get_icon(icon_filename, self.main_window.current_theme_name)
|
||||
protondbLabel = ClickableLabel(
|
||||
protondb_text,
|
||||
icon=icon,
|
||||
parent=coverFrame,
|
||||
icon_size=16,
|
||||
icon_space=3,
|
||||
)
|
||||
protondbLabel.setStyleSheet(self.main_window.theme.get_protondb_badge_style(protondb_tier))
|
||||
protondbLabel.setFixedWidth(badge_width)
|
||||
protondbLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://www.protondb.com/app/{appid}")))
|
||||
protondb_visible = True
|
||||
else:
|
||||
protondbLabel = ClickableLabel("", parent=coverFrame, icon_size=16, icon_space=3)
|
||||
protondbLabel.setFixedWidth(badge_width)
|
||||
protondbLabel.setVisible(False)
|
||||
protondb_visible = False
|
||||
|
||||
# Steam badge
|
||||
steam_icon = self.main_window.theme_manager.get_icon("steam")
|
||||
steamLabel = ClickableLabel(
|
||||
"Steam",
|
||||
icon=steam_icon,
|
||||
parent=coverFrame,
|
||||
icon_size=16,
|
||||
icon_space=5,
|
||||
)
|
||||
steamLabel.setStyleSheet(self.main_window.theme.STEAM_BADGE_STYLE)
|
||||
steamLabel.setFixedWidth(badge_width)
|
||||
steamLabel.setVisible(steam_visible)
|
||||
steamLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://steamcommunity.com/app/{appid}")))
|
||||
|
||||
# Epic Games Store badge
|
||||
egs_icon = self.main_window.theme_manager.get_icon("epic_games")
|
||||
egsLabel = ClickableLabel(
|
||||
"Epic Games",
|
||||
icon=egs_icon,
|
||||
parent=coverFrame,
|
||||
icon_size=16,
|
||||
icon_space=5,
|
||||
change_cursor=False
|
||||
)
|
||||
egsLabel.setStyleSheet(self.main_window.theme.STEAM_BADGE_STYLE)
|
||||
egsLabel.setFixedWidth(badge_width)
|
||||
egsLabel.setVisible(egs_visible)
|
||||
|
||||
# PortProton badge
|
||||
portproton_icon = self.main_window.theme_manager.get_icon("portproton")
|
||||
portprotonLabel = ClickableLabel(
|
||||
"PortProton",
|
||||
icon=portproton_icon,
|
||||
parent=coverFrame,
|
||||
icon_size=16,
|
||||
icon_space=5,
|
||||
)
|
||||
portprotonLabel.setStyleSheet(self.main_window.theme.STEAM_BADGE_STYLE)
|
||||
portprotonLabel.setFixedWidth(badge_width)
|
||||
portprotonLabel.setVisible(portproton_visible)
|
||||
portprotonLabel.clicked.connect(lambda: self.open_portproton_forum_topic(name))
|
||||
|
||||
# WeAntiCheatYet badge
|
||||
anticheat_text = GameCard.getAntiCheatText(anticheat_status)
|
||||
if anticheat_text:
|
||||
icon_filename = GameCard.getAntiCheatIconFilename(anticheat_status)
|
||||
icon = self.main_window.theme_manager.get_icon(icon_filename, self.main_window.current_theme_name)
|
||||
anticheatLabel = ClickableLabel(
|
||||
anticheat_text,
|
||||
icon=icon,
|
||||
parent=coverFrame,
|
||||
icon_size=16,
|
||||
icon_space=3,
|
||||
)
|
||||
anticheatLabel.setStyleSheet(self.main_window.theme.get_anticheat_badge_style(anticheat_status))
|
||||
anticheatLabel.setFixedWidth(badge_width)
|
||||
anticheatLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://areweanticheatyet.com/game/{name.lower().replace(' ', '-')}")))
|
||||
anticheat_visible = True
|
||||
else:
|
||||
anticheatLabel = ClickableLabel("", parent=coverFrame, icon_size=16, icon_space=3)
|
||||
anticheatLabel.setFixedWidth(badge_width)
|
||||
anticheatLabel.setVisible(False)
|
||||
anticheat_visible = False
|
||||
|
||||
# Position badges
|
||||
if steam_visible:
|
||||
steam_x = 300 - badge_width - right_margin
|
||||
steamLabel.move(steam_x, top_y)
|
||||
badge_y_positions.append(top_y + steamLabel.height())
|
||||
if egs_visible:
|
||||
egs_x = 300 - badge_width - right_margin
|
||||
egs_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||
egsLabel.move(egs_x, egs_y)
|
||||
badge_y_positions.append(egs_y + egsLabel.height())
|
||||
if portproton_visible:
|
||||
portproton_x = 300 - badge_width - right_margin
|
||||
portproton_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||
portprotonLabel.move(portproton_x, portproton_y)
|
||||
badge_y_positions.append(portproton_y + portprotonLabel.height())
|
||||
if protondb_visible:
|
||||
protondb_x = 300 - badge_width - right_margin
|
||||
protondb_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||
protondbLabel.move(protondb_x, protondb_y)
|
||||
badge_y_positions.append(protondb_y + protondbLabel.height())
|
||||
if anticheat_visible:
|
||||
anticheat_x = 300 - badge_width - right_margin
|
||||
anticheat_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||
anticheatLabel.move(anticheat_x, anticheat_y)
|
||||
|
||||
anticheatLabel.raise_()
|
||||
protondbLabel.raise_()
|
||||
portprotonLabel.raise_()
|
||||
egsLabel.raise_()
|
||||
steamLabel.raise_()
|
||||
|
||||
contentFrameLayout.addWidget(coverFrame)
|
||||
|
||||
# Game details (at right)
|
||||
detailsWidget = QWidget()
|
||||
detailsWidget.setStyleSheet(self.main_window.theme.DETAILS_WIDGET_STYLE)
|
||||
detailsLayout = QVBoxLayout(detailsWidget)
|
||||
detailsLayout.setContentsMargins(20, 20, 20, 20)
|
||||
detailsLayout.setSpacing(15)
|
||||
|
||||
titleLabel = QLabel(name)
|
||||
titleLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_TITLE_STYLE)
|
||||
detailsLayout.addWidget(titleLabel)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.Shape.HLine)
|
||||
line.setStyleSheet(self.main_window.theme.DETAIL_PAGE_LINE_STYLE)
|
||||
detailsLayout.addWidget(line)
|
||||
|
||||
descLabel = QLabel(description)
|
||||
descLabel.setWordWrap(True)
|
||||
descLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_DESC_STYLE)
|
||||
detailsLayout.addWidget(descLabel)
|
||||
|
||||
# Initialize HowLongToBeat
|
||||
hltb = HowLongToBeat(parent=self.main_window)
|
||||
|
||||
# Create layout for all game info
|
||||
gameInfoLayout = QVBoxLayout()
|
||||
gameInfoLayout.setSpacing(10)
|
||||
|
||||
# First row: Last Launch and Play Time
|
||||
firstRowLayout = QHBoxLayout()
|
||||
firstRowLayout.setSpacing(10)
|
||||
|
||||
# Last Launch
|
||||
lastLaunchTitle = QLabel(_("LAST LAUNCH"))
|
||||
lastLaunchTitle.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||
lastLaunchValue = QLabel(last_launch)
|
||||
lastLaunchValue.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||
firstRowLayout.addWidget(lastLaunchTitle)
|
||||
firstRowLayout.addWidget(lastLaunchValue)
|
||||
firstRowLayout.addSpacing(30)
|
||||
|
||||
# Play Time
|
||||
playTimeTitle = QLabel(_("PLAY TIME"))
|
||||
playTimeTitle.setStyleSheet(self.main_window.theme.PLAY_TIME_TITLE_STYLE)
|
||||
playTimeValue = QLabel(formatted_playtime)
|
||||
playTimeValue.setStyleSheet(self.main_window.theme.PLAY_TIME_VALUE_STYLE)
|
||||
firstRowLayout.addWidget(playTimeTitle)
|
||||
firstRowLayout.addWidget(playTimeValue)
|
||||
|
||||
gameInfoLayout.addLayout(firstRowLayout)
|
||||
|
||||
# Create placeholder for second row (HLTB data)
|
||||
hltbLayout = QHBoxLayout()
|
||||
hltbLayout.setSpacing(10)
|
||||
|
||||
# Completion time (Main Story, Main + Sides, Completionist)
|
||||
def on_hltb_results(results):
|
||||
if not hasattr(self, '_detail_page_active') or not self._detail_page_active:
|
||||
return
|
||||
if not self._current_detail_page or self._current_detail_page.isHidden() or not self._current_detail_page.parent():
|
||||
return
|
||||
# Additional check: make sure the detail page in the stacked widget is still our current detail page
|
||||
if self.main_window.stackedWidget.currentWidget() != self._current_detail_page and self._current_detail_page not in [self.main_window.stackedWidget.widget(i) for i in range(self.main_window.stackedWidget.count())]:
|
||||
return
|
||||
|
||||
if results:
|
||||
game = results[0] # Take first result
|
||||
main_story_time = hltb.format_game_time(game, "main_story")
|
||||
main_extra_time = hltb.format_game_time(game, "main_extra")
|
||||
completionist_time = hltb.format_game_time(game, "completionist")
|
||||
|
||||
# Clear layout before adding new elements
|
||||
def clear_layout(layout):
|
||||
while layout.count():
|
||||
item = layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
sublayout = item.layout()
|
||||
if widget:
|
||||
widget.deleteLater()
|
||||
elif sublayout:
|
||||
clear_layout(sublayout)
|
||||
|
||||
clear_layout(hltbLayout)
|
||||
|
||||
has_data = False
|
||||
|
||||
if main_story_time is not None:
|
||||
try:
|
||||
mainStoryTitle = QLabel(_("MAIN STORY"))
|
||||
mainStoryTitle.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||
mainStoryValue = QLabel(main_story_time)
|
||||
mainStoryValue.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||
hltbLayout.addWidget(mainStoryTitle)
|
||||
hltbLayout.addWidget(mainStoryValue)
|
||||
hltbLayout.addSpacing(30)
|
||||
has_data = True
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping main story time update")
|
||||
|
||||
if main_extra_time is not None:
|
||||
try:
|
||||
mainExtraTitle = QLabel(_("MAIN + SIDES"))
|
||||
mainExtraTitle.setStyleSheet(self.main_window.theme.PLAY_TIME_TITLE_STYLE)
|
||||
mainExtraValue = QLabel(main_extra_time)
|
||||
mainExtraValue.setStyleSheet(self.main_window.theme.PLAY_TIME_VALUE_STYLE)
|
||||
hltbLayout.addWidget(mainExtraTitle)
|
||||
hltbLayout.addWidget(mainExtraValue)
|
||||
hltbLayout.addSpacing(30)
|
||||
has_data = True
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping main extra time update")
|
||||
|
||||
if completionist_time is not None:
|
||||
try:
|
||||
completionistTitle = QLabel(_("COMPLETIONIST"))
|
||||
completionistTitle.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||
completionistValue = QLabel(completionist_time)
|
||||
completionistValue.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||
hltbLayout.addWidget(completionistTitle)
|
||||
hltbLayout.addWidget(completionistValue)
|
||||
has_data = True
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping completionist time update")
|
||||
|
||||
# If there's data, add the layout to the second row
|
||||
if has_data:
|
||||
gameInfoLayout.addLayout(hltbLayout)
|
||||
|
||||
# Connect searchCompleted signal to on_hltb_results
|
||||
hltb.searchCompleted.connect(on_hltb_results)
|
||||
|
||||
# Start search in background thread
|
||||
hltb.search_with_callback(name, case_sensitive=False)
|
||||
|
||||
# Add the game info layout
|
||||
detailsLayout.addLayout(gameInfoLayout)
|
||||
|
||||
if controller_support:
|
||||
cs = controller_support.lower()
|
||||
translated_cs = ""
|
||||
if cs == "full":
|
||||
translated_cs = _("full")
|
||||
elif cs == "partial":
|
||||
translated_cs = _("partial")
|
||||
elif cs == "none":
|
||||
translated_cs = _("none")
|
||||
gamepadSupportLabel = QLabel(_("Gamepad Support: {0}").format(translated_cs))
|
||||
gamepadSupportLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
gamepadSupportLabel.setStyleSheet(self.main_window.theme.GAMEPAD_SUPPORT_VALUE_STYLE)
|
||||
detailsLayout.addWidget(gamepadSupportLabel, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
detailsLayout.addStretch(1)
|
||||
|
||||
# Determine current game ID from exec_line
|
||||
entry_exec_split = shlex.split(exec_line)
|
||||
if not entry_exec_split:
|
||||
return
|
||||
|
||||
if entry_exec_split[0] == "env":
|
||||
file_to_check = entry_exec_split[2] if len(entry_exec_split) >= 3 else None
|
||||
elif entry_exec_split[0] == "flatpak":
|
||||
file_to_check = entry_exec_split[3] if len(entry_exec_split) >= 4 else None
|
||||
else:
|
||||
file_to_check = entry_exec_split[0]
|
||||
current_exe = os.path.basename(file_to_check) if file_to_check else None
|
||||
|
||||
buttons_layout = QHBoxLayout()
|
||||
if self.main_window.target_exe is not None and current_exe == self.main_window.target_exe:
|
||||
playButton = AutoSizeButton(_("Stop"), icon=self.main_window.theme_manager.get_icon("stop"))
|
||||
else:
|
||||
playButton = AutoSizeButton(_("Play"), icon=self.main_window.theme_manager.get_icon("play"))
|
||||
|
||||
playButton.setFixedSize(120, 40)
|
||||
playButton.setStyleSheet(self.main_window.theme.PLAY_BUTTON_STYLE)
|
||||
playButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
playButton.clicked.connect(lambda: self.main_window.toggleGame(exec_line, playButton))
|
||||
buttons_layout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
# Settings button
|
||||
settings_icon = self.main_window.theme_manager.get_icon("settings")
|
||||
settings_button = AutoSizeButton(_("Settings"), icon=settings_icon)
|
||||
settings_button.setFixedSize(120, 40)
|
||||
settings_button.setStyleSheet(self.main_window.theme.PLAY_BUTTON_STYLE)
|
||||
settings_button.clicked.connect(lambda: self.main_window.open_exe_settings(file_to_check))
|
||||
buttons_layout.addWidget(settings_button, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||
buttons_layout.addStretch()
|
||||
detailsLayout.addLayout(buttons_layout)
|
||||
|
||||
contentFrameLayout.addWidget(detailsWidget)
|
||||
mainLayout.addStretch()
|
||||
|
||||
self.main_window.stackedWidget.addWidget(detailPage)
|
||||
self.main_window.stackedWidget.setCurrentWidget(detailPage)
|
||||
self.main_window.currentDetailPage = detailPage
|
||||
self.main_window.current_exec_line = exec_line
|
||||
self.main_window.current_play_button = playButton
|
||||
|
||||
# Animation
|
||||
detail_animations = DetailPageAnimations(self.main_window, self.main_window.theme)
|
||||
detail_animations.animate_detail_page(detailPage, load_image_and_restore_effect, cleanup_animation)
|
||||
|
||||
# Update page reference
|
||||
self.main_window.currentDetailPage = detailPage
|
||||
|
||||
original_load = load_image_and_restore_effect
|
||||
|
||||
def enhanced_load():
|
||||
original_load()
|
||||
QTimer.singleShot(50, try_set_focus)
|
||||
|
||||
def try_set_focus():
|
||||
if not (playButton and not playButton.isHidden()):
|
||||
return
|
||||
|
||||
# Ensure page is active
|
||||
self.main_window.stackedWidget.setCurrentWidget(detailPage)
|
||||
detailPage.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
playButton.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
playButton.update()
|
||||
detailPage.raise_()
|
||||
self.main_window.activateWindow()
|
||||
|
||||
if playButton.hasFocus():
|
||||
logger.debug("Play button successfully received focus")
|
||||
else:
|
||||
logger.debug("Retrying focus...")
|
||||
QTimer.singleShot(20, retry_focus)
|
||||
|
||||
def retry_focus():
|
||||
if not (playButton and not playButton.isHidden() and not playButton.hasFocus()):
|
||||
return
|
||||
|
||||
# Process events to ensure UI state is updated
|
||||
self.main_window.processEvents()
|
||||
self.main_window.activateWindow()
|
||||
self.main_window.stackedWidget.setCurrentWidget(detailPage)
|
||||
detailPage.raise_()
|
||||
playButton.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
playButton.update()
|
||||
|
||||
if not playButton.hasFocus():
|
||||
logger.debug("Final retry...")
|
||||
playButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
playButton.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
self.main_window.processEvents()
|
||||
|
||||
if playButton.hasFocus():
|
||||
logger.debug("Play button received focus after final retry")
|
||||
else:
|
||||
logger.debug("Play button still doesn't have focus")
|
||||
|
||||
detail_animations.animate_detail_page(
|
||||
detailPage,
|
||||
enhanced_load,
|
||||
cleanup_animation
|
||||
)
|
||||
|
||||
def openAutoInstallDetailPage(self, name, description, cover_path=None, exec_line="", game_source=""):
|
||||
"""Open minimal detail page for auto-install games with name, description, cover, and install button."""
|
||||
detailPage = QWidget()
|
||||
imageLabel = QLabel()
|
||||
imageLabel.setFixedSize(300, 450)
|
||||
self._detail_page_active = True
|
||||
self._current_detail_page = detailPage
|
||||
# Store the source tab index (Auto Install is typically index 1)
|
||||
self._return_to_tab_index = 1 # Auto Install tab
|
||||
|
||||
# Try to get the description from downloaded metadata for richer content
|
||||
script_name = ""
|
||||
if exec_line and exec_line.startswith("autoinstall:"):
|
||||
script_name = exec_line[11:].lstrip(':').strip()
|
||||
|
||||
if script_name:
|
||||
# Get localized description based on current UI language
|
||||
# Import locale module to detect current locale
|
||||
import locale
|
||||
try:
|
||||
current_locale = locale.getlocale()[0] or 'en'
|
||||
except Exception:
|
||||
current_locale = 'en'
|
||||
lang_code = 'ru' if current_locale and 'ru' in current_locale.lower() else 'en'
|
||||
|
||||
metadata_description = self.portproton_api.get_autoinstall_description(script_name, lang_code)
|
||||
if metadata_description:
|
||||
description = metadata_description
|
||||
|
||||
# Function to load image and restore effect
|
||||
def load_image_and_restore_effect():
|
||||
# Check if detail page still exists and is valid
|
||||
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
|
||||
logger.warning("Detail page is None, hidden, or no longer valid, skipping image load")
|
||||
return
|
||||
|
||||
try:
|
||||
detailPage.setWindowOpacity(1.0)
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page is None, hidden, or no longer valid, skipping opacity set")
|
||||
return
|
||||
|
||||
if cover_path:
|
||||
def on_pixmap_ready(pixmap):
|
||||
# Check if detail page still exists and is valid
|
||||
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
|
||||
logger.warning("Detail page is None, hidden, or no longer valid, skipping pixmap update")
|
||||
return
|
||||
try:
|
||||
rounded = round_corners(pixmap, 10)
|
||||
imageLabel.setPixmap(rounded)
|
||||
logger.debug("Pixmap set for imageLabel")
|
||||
|
||||
def on_palette_ready(palette):
|
||||
# Check if detail page still exists and is valid
|
||||
if not detailPage or detailPage.isHidden() or detailPage.parent() is None:
|
||||
logger.warning("Detail page is None, hidden, or no longer valid, skipping palette update")
|
||||
return
|
||||
try:
|
||||
dark_palette = [self.main_window.darkenColor(color, factor=200) for color in palette]
|
||||
stops = ",\n".join(
|
||||
[f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))]
|
||||
)
|
||||
detailPage.setStyleSheet(self.main_window.theme.detail_page_style(stops))
|
||||
detailPage.update()
|
||||
logger.debug("Stylesheet updated with palette")
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping palette stylesheet update")
|
||||
self.main_window.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping pixmap update")
|
||||
load_pixmap_async(cover_path, 300, 450, on_pixmap_ready)
|
||||
else:
|
||||
try:
|
||||
detailPage.setStyleSheet(self.main_window.theme.DETAIL_PAGE_NO_COVER_STYLE)
|
||||
detailPage.update()
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping no-cover stylesheet update")
|
||||
|
||||
def cleanup_animation():
|
||||
if detailPage in self._animations:
|
||||
del self._animations[detailPage]
|
||||
|
||||
mainLayout = QVBoxLayout(detailPage)
|
||||
mainLayout.setContentsMargins(30, 30, 30, 30)
|
||||
mainLayout.setSpacing(20)
|
||||
|
||||
backButton = AutoSizeButton(_("Back"), icon=self.main_window.theme_manager.get_icon("back"))
|
||||
backButton.setFixedWidth(100)
|
||||
backButton.setStyleSheet(self.main_window.theme.ADDGAME_BACK_BUTTON_STYLE)
|
||||
backButton.clicked.connect(lambda: self.goBackDetailPage(detailPage))
|
||||
mainLayout.addWidget(backButton, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
contentFrame = QFrame()
|
||||
contentFrame.setStyleSheet(self.main_window.theme.DETAIL_CONTENT_FRAME_STYLE)
|
||||
contentFrameLayout = QHBoxLayout(contentFrame)
|
||||
contentFrameLayout.setContentsMargins(20, 20, 20, 20)
|
||||
contentFrameLayout.setSpacing(40)
|
||||
mainLayout.addWidget(contentFrame)
|
||||
|
||||
# Cover (at left)
|
||||
coverFrame = QFrame()
|
||||
coverFrame.setFixedSize(300, 450)
|
||||
coverFrame.setStyleSheet(self.main_window.theme.COVER_FRAME_STYLE)
|
||||
shadow = QGraphicsDropShadowEffect(coverFrame)
|
||||
shadow.setBlurRadius(20)
|
||||
shadow.setColor(QColor(0, 0, 0, 200))
|
||||
shadow.setOffset(0, 0)
|
||||
coverFrame.setGraphicsEffect(shadow)
|
||||
coverLayout = QVBoxLayout(coverFrame)
|
||||
coverLayout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
coverLayout.addWidget(imageLabel)
|
||||
|
||||
# No favorite icon for auto-install games
|
||||
|
||||
# No badges for auto-install detail page
|
||||
contentFrameLayout.addWidget(coverFrame)
|
||||
|
||||
# Game details (at right) - minimal version without time info
|
||||
detailsWidget = QWidget()
|
||||
detailsWidget.setStyleSheet(self.main_window.theme.DETAILS_WIDGET_STYLE)
|
||||
detailsLayout = QVBoxLayout(detailsWidget)
|
||||
detailsLayout.setContentsMargins(20, 20, 20, 20)
|
||||
detailsLayout.setSpacing(15)
|
||||
|
||||
titleLabel = QLabel(name)
|
||||
titleLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_TITLE_STYLE)
|
||||
detailsLayout.addWidget(titleLabel)
|
||||
|
||||
line = QFrame()
|
||||
line.setFrameShape(QFrame.Shape.HLine)
|
||||
line.setStyleSheet(self.main_window.theme.DETAIL_PAGE_LINE_STYLE)
|
||||
detailsLayout.addWidget(line)
|
||||
|
||||
descLabel = QLabel(description)
|
||||
descLabel.setWordWrap(True)
|
||||
descLabel.setStyleSheet(self.main_window.theme.DETAIL_PAGE_DESC_STYLE)
|
||||
detailsLayout.addWidget(descLabel)
|
||||
|
||||
# No HLTB data, playtime, or launch info for auto install
|
||||
detailsLayout.addStretch(1)
|
||||
|
||||
buttons_layout = QHBoxLayout()
|
||||
|
||||
# The script_name was already extracted at the beginning of the function
|
||||
# Determine if game is already installed based on whether .desktop files exist for the script
|
||||
game_installed = self.is_autoinstall_game_installed(script_name, name) if script_name else False
|
||||
|
||||
install_button_text = _("Reinstall") if game_installed else _("Install")
|
||||
# Use update icon for reinstall, save icon for initial install
|
||||
install_button_icon = self.main_window.theme_manager.get_icon("update" if game_installed else "save")
|
||||
|
||||
installButton = AutoSizeButton(install_button_text, icon=install_button_icon)
|
||||
installButton.setFixedSize(120, 40)
|
||||
installButton.setStyleSheet(self.main_window.theme.PLAY_BUTTON_STYLE)
|
||||
installButton.clicked.connect(lambda: self.main_window.launch_autoinstall(script_name))
|
||||
buttons_layout.addWidget(installButton, alignment=Qt.AlignmentFlag.AlignLeft)
|
||||
buttons_layout.addStretch()
|
||||
detailsLayout.addLayout(buttons_layout)
|
||||
|
||||
contentFrameLayout.addWidget(detailsWidget)
|
||||
mainLayout.addStretch()
|
||||
|
||||
self.main_window.stackedWidget.addWidget(detailPage)
|
||||
self.main_window.stackedWidget.setCurrentWidget(detailPage)
|
||||
self.main_window.currentDetailPage = detailPage
|
||||
|
||||
# Animation
|
||||
detail_animations = DetailPageAnimations(self.main_window, self.main_window.theme)
|
||||
detail_animations.animate_detail_page(detailPage, load_image_and_restore_effect, cleanup_animation)
|
||||
|
||||
# Update page reference
|
||||
self.main_window.currentDetailPage = detailPage
|
||||
|
||||
original_load = load_image_and_restore_effect
|
||||
|
||||
def enhanced_load():
|
||||
original_load()
|
||||
QTimer.singleShot(50, try_set_focus)
|
||||
|
||||
def try_set_focus():
|
||||
if not (installButton and not installButton.isHidden()):
|
||||
return
|
||||
|
||||
# Ensure page is active
|
||||
self.main_window.stackedWidget.setCurrentWidget(detailPage)
|
||||
detailPage.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
installButton.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
installButton.update()
|
||||
detailPage.raise_()
|
||||
self.main_window.activateWindow()
|
||||
|
||||
if installButton.hasFocus():
|
||||
logger.debug("Install button successfully received focus")
|
||||
else:
|
||||
logger.debug("Retrying focus...")
|
||||
QTimer.singleShot(20, retry_focus)
|
||||
|
||||
def retry_focus():
|
||||
if not (installButton and not installButton.isHidden() and not installButton.hasFocus()):
|
||||
return
|
||||
|
||||
# Process events to ensure UI state is updated
|
||||
self.main_window.processEvents()
|
||||
self.main_window.activateWindow()
|
||||
self.main_window.stackedWidget.setCurrentWidget(detailPage)
|
||||
detailPage.raise_()
|
||||
installButton.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
installButton.update()
|
||||
|
||||
if not installButton.hasFocus():
|
||||
logger.debug("Final retry...")
|
||||
installButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
installButton.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
self.main_window.processEvents()
|
||||
|
||||
if installButton.hasFocus():
|
||||
logger.debug("Install button received focus after final retry")
|
||||
else:
|
||||
logger.debug("Install button still doesn't have focus")
|
||||
|
||||
detail_animations.animate_detail_page(
|
||||
detailPage,
|
||||
enhanced_load,
|
||||
cleanup_animation
|
||||
)
|
||||
|
||||
def is_autoinstall_game_installed(self, script_name, game_name):
|
||||
"""Check if an auto-install game is already installed by looking for .desktop files."""
|
||||
if not self.main_window.portproton_location:
|
||||
return False
|
||||
|
||||
# Look for .desktop files that might match this game/script
|
||||
try:
|
||||
desktop_files = os.listdir(self.main_window.portproton_location)
|
||||
for file in desktop_files:
|
||||
if file.endswith('.desktop'):
|
||||
# Check if the desktop file contains references to the script or game name
|
||||
try:
|
||||
with open(os.path.join(self.main_window.portproton_location, file), encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if script_name.lower() in content.lower() or game_name.lower() in content.lower():
|
||||
return True
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
return False
|
||||
|
||||
def toggleFavoriteInDetailPage(self, game_name, label):
|
||||
favorites = read_favorites()
|
||||
if game_name in favorites:
|
||||
favorites.remove(game_name)
|
||||
label.setText("☆")
|
||||
else:
|
||||
favorites.append(game_name)
|
||||
label.setText("★")
|
||||
save_favorites(favorites)
|
||||
self.main_window.game_library_manager.update_game_grid()
|
||||
|
||||
def goBackDetailPage(self, page: QWidget | None) -> None:
|
||||
if page is None or page != self.main_window.stackedWidget.currentWidget() or getattr(self, '_exit_animation_in_progress', False):
|
||||
return
|
||||
self._exit_animation_in_progress = True
|
||||
self._detail_page_active = False
|
||||
self._current_detail_page = None
|
||||
|
||||
def cleanup():
|
||||
"""Helper function to clean up after animation."""
|
||||
try:
|
||||
# Stop and clean up any existing animations for this page
|
||||
if hasattr(self, '_animations') and page in self._animations:
|
||||
try:
|
||||
animation = self._animations[page]
|
||||
if isinstance(animation, QAbstractAnimation):
|
||||
if animation.state() == QAbstractAnimation.State.Running:
|
||||
animation.stop()
|
||||
# Since animation is set to delete when stopped, we don't manually delete it
|
||||
del self._animations[page]
|
||||
except (KeyError, RuntimeError):
|
||||
pass # Animation already deleted or not found
|
||||
|
||||
# Ensure page is still valid before trying to remove it
|
||||
# Check if page is still in the stacked widget by iterating through all widgets
|
||||
page_found = False
|
||||
for i in range(self.main_window.stackedWidget.count()):
|
||||
if self.main_window.stackedWidget.widget(i) is page:
|
||||
page_found = True
|
||||
break
|
||||
|
||||
if page_found:
|
||||
# Remove the detail page widget
|
||||
self.main_window.stackedWidget.removeWidget(page)
|
||||
# Go back to the tab where the detail page was opened from
|
||||
return_tab_index = getattr(self, '_return_to_tab_index', 0) # Default to library tab
|
||||
self.main_window.stackedWidget.setCurrentIndex(return_tab_index)
|
||||
|
||||
# Ensure proper layout update after returning to the tab
|
||||
# This is important when a refresh happened while detail page was open
|
||||
if return_tab_index == 0: # Library tab
|
||||
if hasattr(self.main_window, 'game_library_manager'):
|
||||
QTimer.singleShot(10, lambda: self.main_window.game_library_manager.update_game_grid())
|
||||
elif return_tab_index == 1: # Auto Install tab
|
||||
# Force update of the auto install container layout
|
||||
if hasattr(self.main_window, 'autoInstallContainer'):
|
||||
QTimer.singleShot(10, lambda: self.main_window.autoInstallContainer.updateGeometry())
|
||||
if hasattr(self.main_window, 'autoInstallContainerLayout'):
|
||||
QTimer.singleShot(15, lambda: self.main_window.autoInstallContainerLayout.update())
|
||||
else:
|
||||
logger.debug("Page not found in stacked widget, may have been removed already")
|
||||
|
||||
# Clear references to avoid dangling references
|
||||
if hasattr(self.main_window, 'currentDetailPage'):
|
||||
self.main_window.currentDetailPage = None
|
||||
if hasattr(self.main_window, 'current_exec_line'):
|
||||
self.main_window.current_exec_line = None
|
||||
if hasattr(self.main_window, 'current_play_button'):
|
||||
self.main_window.current_play_button = None
|
||||
|
||||
self._exit_animation_in_progress = False
|
||||
except RuntimeError:
|
||||
# Widget was already deleted, which is expected after deleteLater()
|
||||
logger.debug("Detail page already deleted during cleanup")
|
||||
self._exit_animation_in_progress = False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in cleanup: {e}", exc_info=True)
|
||||
self._exit_animation_in_progress = False
|
||||
|
||||
# Start exit animation
|
||||
try:
|
||||
# Check if the page is still valid before starting animation
|
||||
if page and not page.isHidden() and page.parent() is not None:
|
||||
detail_animations = DetailPageAnimations(self.main_window, self.main_window.theme)
|
||||
detail_animations.animate_detail_page_exit(page, cleanup)
|
||||
else:
|
||||
logger.warning("Detail page not valid, bypassing animation and cleaning up directly")
|
||||
self._exit_animation_in_progress = False
|
||||
cleanup()
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting exit animation: {e}", exc_info=True)
|
||||
self._exit_animation_in_progress = False
|
||||
cleanup() # Fallback to cleanup if animation fails
|
||||
|
||||
def open_portproton_forum_topic(self, name):
|
||||
result = self.portproton_api.get_forum_topic_slug(name)
|
||||
base_url = "https://linux-gaming.ru/"
|
||||
if result.startswith("search?q="):
|
||||
url = QUrl(f"{base_url}{result}")
|
||||
else:
|
||||
url = QUrl(f"{base_url}t/{result}")
|
||||
QDesktopServices.openUrl(url)
|
||||
@@ -10,7 +10,7 @@ from PySide6.QtWidgets import (
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment
|
||||
from icoextract import IconExtractor, IconExtractorError
|
||||
from PIL import Image
|
||||
from portprotonqt.config_utils import get_portproton_location, read_favorite_folders, read_theme_from_config
|
||||
from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command, read_favorite_folders, read_theme_from_config
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
@@ -853,7 +853,6 @@ class AddGameDialog(QDialog):
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit # Локальный импорт
|
||||
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
|
||||
self.edit_mode = edit_mode
|
||||
self.original_name = game_name
|
||||
self.last_exe_path = exe_path # Store last selected exe path
|
||||
self.last_cover_path = cover_path # Store last selected cover path
|
||||
self.downloader = Downloader(max_workers=4) # Initialize Downloader
|
||||
@@ -906,6 +905,7 @@ class AddGameDialog(QDialog):
|
||||
|
||||
self.coverEdit = CustomLineEdit(self, theme=self.theme)
|
||||
self.coverEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
self.coverEdit.setPlaceholderText(_("Enter local path or URL for cover image"))
|
||||
if cover_path:
|
||||
self.coverEdit.setText(cover_path)
|
||||
|
||||
@@ -949,7 +949,12 @@ class AddGameDialog(QDialog):
|
||||
# Подключение сигналов
|
||||
self.select_button.clicked.connect(self.accept)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
self.coverEdit.textChanged.connect(self.updatePreview)
|
||||
# Set up a timer for debounced cover preview updates
|
||||
self.cover_preview_timer = QTimer(self)
|
||||
self.cover_preview_timer.setSingleShot(True)
|
||||
self.cover_preview_timer.timeout.connect(self.updatePreview)
|
||||
|
||||
self.coverEdit.textChanged.connect(self.onCoverTextChanged)
|
||||
self.exeEdit.textChanged.connect(self.updatePreview)
|
||||
|
||||
# Установка одинаковой ширины для кнопок и полей ввода
|
||||
@@ -1083,33 +1088,51 @@ class AddGameDialog(QDialog):
|
||||
|
||||
def handleDownloadedCover(self, file_path):
|
||||
"""Handle the downloaded cover image and update the preview."""
|
||||
if file_path and os.path.isfile(file_path):
|
||||
self.last_cover_path = file_path
|
||||
pixmap = QPixmap(file_path)
|
||||
if not pixmap.isNull():
|
||||
self.coverPreview.setPixmap(pixmap.scaled(250, 250, Qt.AspectRatioMode.KeepAspectRatio))
|
||||
# Check if the dialog or widget has been destroyed before updating
|
||||
if not hasattr(self, 'coverPreview') or self.coverPreview is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if file_path and os.path.isfile(file_path):
|
||||
self.last_cover_path = file_path
|
||||
pixmap = QPixmap(file_path)
|
||||
if not pixmap.isNull():
|
||||
self.coverPreview.setPixmap(pixmap.scaled(250, 250, Qt.AspectRatioMode.KeepAspectRatio))
|
||||
else:
|
||||
self.coverPreview.setText(_("Invalid image"))
|
||||
else:
|
||||
self.coverPreview.setText(_("Invalid image"))
|
||||
else:
|
||||
self.coverPreview.setText(_("Failed to download cover"))
|
||||
logger.warning(f"Failed to download cover to {file_path}")
|
||||
self.coverPreview.setText(_("Failed to download cover"))
|
||||
logger.warning(f"Failed to download cover to {file_path}")
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
def onCoverTextChanged(self):
|
||||
"""Handle cover text changes with debounce."""
|
||||
# Restart the timer to delay the preview update
|
||||
self.cover_preview_timer.start(500) # 500ms delay
|
||||
|
||||
def updatePreview(self):
|
||||
"""Update the cover preview image."""
|
||||
cover_path = self.coverEdit.text().strip()
|
||||
exe_path = self.exeEdit.text().strip()
|
||||
|
||||
# Check if cover_path is a URL
|
||||
url_pattern = r'^https?://[^\s/$.?#].[^\s]*$'
|
||||
if re.match(url_pattern, cover_path):
|
||||
# Check if cover_path is a URL by checking for common image extensions
|
||||
image_extensions = ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp')
|
||||
has_image_extension = any(cover_path.lower().endswith(ext) for ext in image_extensions)
|
||||
|
||||
# Consider it a URL if it has image extension and is not a local file
|
||||
if has_image_extension and not os.path.isfile(cover_path):
|
||||
# Create a temporary file for the downloaded image
|
||||
fd, local_path = tempfile.mkstemp(suffix=".png")
|
||||
os.close(fd)
|
||||
os.unlink(local_path)
|
||||
|
||||
# Start asynchronous download
|
||||
# Add protocol if not present
|
||||
download_url = cover_path if cover_path.startswith(('http://', 'https://')) else f'https://{cover_path}'
|
||||
self.downloader.download_async(
|
||||
url=cover_path,
|
||||
url=download_url,
|
||||
local_path=local_path,
|
||||
timeout=10,
|
||||
callback=self.handleDownloadedCover
|
||||
@@ -1701,8 +1724,10 @@ class ExeSettingsDialog(QDialog):
|
||||
if self.portproton_path is None:
|
||||
logger.error("PortProton location not found")
|
||||
return
|
||||
base_path = os.path.join(self.portproton_path, "data")
|
||||
self.start_sh = [os.path.join(base_path, "scripts", "start.sh")]
|
||||
self.start_sh = get_portproton_start_command()
|
||||
if self.start_sh is None:
|
||||
logger.error("PortProton start command not found")
|
||||
return
|
||||
|
||||
self.dist_options = []
|
||||
self.prefix_options = []
|
||||
@@ -1776,9 +1801,9 @@ class ExeSettingsDialog(QDialog):
|
||||
self.load_current_settings()
|
||||
|
||||
def _get_process_args(self, subcommand_args):
|
||||
"""Get the full arguments for QProcess.start, handling flatpak separator."""
|
||||
if self.start_sh[0] == "flatpak":
|
||||
return self.start_sh[1:] + ["--"] + subcommand_args
|
||||
"""Get the full arguments for QProcess.start, handling flatpak format."""
|
||||
if self.start_sh and self.start_sh[0] == "flatpak":
|
||||
return self.start_sh + subcommand_args
|
||||
else:
|
||||
return self.start_sh + subcommand_args
|
||||
|
||||
@@ -1814,13 +1839,11 @@ class ExeSettingsDialog(QDialog):
|
||||
# Connect tab change to update description hint
|
||||
self.tab_widget.currentChanged.connect(self.on_table_selection_changed)
|
||||
|
||||
# Main settings table
|
||||
# Main settings table with preloader
|
||||
self.settings_table = QTableWidget()
|
||||
self.settings_table.setAlternatingRowColors(True)
|
||||
self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
# self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
|
||||
self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
|
||||
self.settings_table.setColumnCount(3)
|
||||
self.settings_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")])
|
||||
self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
@@ -1831,11 +1854,30 @@ class ExeSettingsDialog(QDialog):
|
||||
self.settings_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.settings_table.setTextElideMode(Qt.TextElideMode.ElideNone)
|
||||
self.settings_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE)
|
||||
self.main_tab_layout.addWidget(self.settings_table)
|
||||
|
||||
# Create preloader for main settings table
|
||||
self.settings_preloader = Preloader()
|
||||
settings_preloader_container = QWidget()
|
||||
settings_preloader_layout = QVBoxLayout(settings_preloader_container)
|
||||
settings_preloader_layout.addStretch()
|
||||
settings_preloader_hlayout = QHBoxLayout()
|
||||
settings_preloader_hlayout.addStretch()
|
||||
settings_preloader_hlayout.addWidget(self.settings_preloader)
|
||||
settings_preloader_hlayout.addStretch()
|
||||
settings_preloader_layout.addLayout(settings_preloader_hlayout)
|
||||
settings_preloader_layout.addStretch()
|
||||
settings_preloader_layout.setContentsMargins(0, 0, 0, 0)
|
||||
settings_preloader_layout.setSpacing(0)
|
||||
|
||||
# Create stacked widget for main settings
|
||||
self.settings_container = QStackedWidget()
|
||||
self.settings_container.addWidget(settings_preloader_container) # Index 0: preloader
|
||||
self.settings_container.addWidget(self.settings_table) # Index 1: table
|
||||
self.main_tab_layout.addWidget(self.settings_container)
|
||||
# Connect selection changed signal for the main table
|
||||
self.settings_table.currentCellChanged.connect(self.on_table_selection_changed)
|
||||
|
||||
# Advanced settings table
|
||||
# Advanced settings table with preloader
|
||||
self.advanced_table = QTableWidget()
|
||||
self.advanced_table.setAlternatingRowColors(True)
|
||||
self.advanced_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -1852,7 +1894,26 @@ class ExeSettingsDialog(QDialog):
|
||||
self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
|
||||
self.advanced_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE)
|
||||
self.advanced_tab_layout.addWidget(self.advanced_table)
|
||||
|
||||
# Create preloader for advanced settings table
|
||||
self.advanced_preloader = Preloader()
|
||||
advanced_preloader_container = QWidget()
|
||||
advanced_preloader_layout = QVBoxLayout(advanced_preloader_container)
|
||||
advanced_preloader_layout.addStretch()
|
||||
advanced_preloader_hlayout = QHBoxLayout()
|
||||
advanced_preloader_hlayout.addStretch()
|
||||
advanced_preloader_hlayout.addWidget(self.advanced_preloader)
|
||||
advanced_preloader_hlayout.addStretch()
|
||||
advanced_preloader_layout.addLayout(advanced_preloader_hlayout)
|
||||
advanced_preloader_layout.addStretch()
|
||||
advanced_preloader_layout.setContentsMargins(0, 0, 0, 0)
|
||||
advanced_preloader_layout.setSpacing(0)
|
||||
|
||||
# Create stacked widget for advanced settings
|
||||
self.advanced_container = QStackedWidget()
|
||||
self.advanced_container.addWidget(advanced_preloader_container) # Index 0: preloader
|
||||
self.advanced_container.addWidget(self.advanced_table) # Index 1: table
|
||||
self.advanced_tab_layout.addWidget(self.advanced_container)
|
||||
# Connect selection changed signal for the advanced table
|
||||
self.advanced_table.currentCellChanged.connect(self.on_table_selection_changed)
|
||||
|
||||
@@ -1890,9 +1951,14 @@ class ExeSettingsDialog(QDialog):
|
||||
|
||||
def load_current_settings(self):
|
||||
"""Load available toggles first, then current settings."""
|
||||
# Show preloaders initially
|
||||
self.settings_container.setCurrentIndex(0) # Show preloader for main settings
|
||||
self.advanced_container.setCurrentIndex(0) # Show preloader for advanced settings
|
||||
|
||||
process = QProcess(self)
|
||||
process.finished.connect(self.on_list_db_finished)
|
||||
process.start(self.start_sh[0], ["cli", "--list-db"])
|
||||
args = self._get_process_args(["cli", "--list-db"])
|
||||
process.start(args[0], args[1:])
|
||||
|
||||
def on_list_db_finished(self, exit_code, exit_status):
|
||||
"""Handle --list-db output and extract available keys and system info."""
|
||||
@@ -1914,6 +1980,9 @@ class ExeSettingsDialog(QDialog):
|
||||
if re.match(r'^[A-Z_0-9]+=[^=]+$', line_stripped) and not line_stripped.startswith('PW_'):
|
||||
# System info
|
||||
k, v = line_stripped.split('=', 1)
|
||||
# Remove surrounding quotes from the value if present
|
||||
if v.startswith('"') and v.endswith('"') and len(v) >= 2:
|
||||
v = v[1:-1]
|
||||
if k.startswith('NUMA_NODE_'):
|
||||
node_id = k[10:]
|
||||
self.numa_nodes[node_id] = v
|
||||
@@ -1939,7 +2008,8 @@ class ExeSettingsDialog(QDialog):
|
||||
# Load current settings
|
||||
process = QProcess(self)
|
||||
process.finished.connect(self.on_show_ppdb_finished)
|
||||
process.start(self.start_sh[0], ["cli", "--show-ppdb", f"{self.exe_path}.ppdb"])
|
||||
args = self._get_process_args(["cli", "--show-ppdb", f"{self.exe_path}"])
|
||||
process.start(args[0], args[1:])
|
||||
|
||||
def on_show_ppdb_finished(self, exit_code, exit_status):
|
||||
"""Handle --show-ppdb output."""
|
||||
@@ -1959,6 +2029,9 @@ class ExeSettingsDialog(QDialog):
|
||||
try:
|
||||
key, val = line_stripped.split('=', 1)
|
||||
if key in self.toggle_settings or key in ADVANCED_SETTING_KEYS:
|
||||
# Remove surrounding quotes from the value if present
|
||||
if val.startswith('"') and val.endswith('"') and len(val) >= 2:
|
||||
val = val[1:-1]
|
||||
self.current_settings[key] = val
|
||||
except ValueError:
|
||||
continue
|
||||
@@ -1979,6 +2052,10 @@ class ExeSettingsDialog(QDialog):
|
||||
self.populate_table()
|
||||
self.populate_advanced()
|
||||
|
||||
# Show the loaded content and hide preloaders
|
||||
self.settings_container.setCurrentIndex(1) # Show main settings table
|
||||
self.advanced_container.setCurrentIndex(1) # Show advanced settings table
|
||||
|
||||
def populate_table(self):
|
||||
"""Populate the table with settings that are available in both lists."""
|
||||
self.settings_table.setRowCount(0)
|
||||
@@ -2001,10 +2078,8 @@ class ExeSettingsDialog(QDialog):
|
||||
current_val = self.current_settings.get(toggle, '0')
|
||||
is_blocked = toggle in self.blocked_keys
|
||||
checkbox = QTableWidgetItem()
|
||||
checkbox.setFlags(checkbox.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
check_state = Qt.CheckState.Checked if current_val == '1' and not is_blocked else Qt.CheckState.Unchecked
|
||||
checkbox.setCheckState(check_state)
|
||||
checkbox.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
if is_blocked:
|
||||
checkbox.setFlags(checkbox.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
|
||||
checkbox.setBackground(QColor(240, 240, 240))
|
||||
@@ -2289,8 +2364,9 @@ class ExeSettingsDialog(QDialog):
|
||||
|
||||
process = QProcess(self)
|
||||
process.finished.connect(self.on_edit_db_finished)
|
||||
args = ["cli", "--edit-db", self.exe_path] + changes
|
||||
process.start(self.start_sh[0], args)
|
||||
process_args = ["cli", "--edit-db", self.exe_path] + changes
|
||||
args = self._get_process_args(process_args)
|
||||
process.start(args[0], args[1:])
|
||||
self.apply_button.setEnabled(False)
|
||||
|
||||
def on_edit_db_finished(self, exit_code, exit_status):
|
||||
@@ -2330,43 +2406,28 @@ class ExeSettingsDialog(QDialog):
|
||||
def show_gamepad_tooltip(self, show=True, text=""):
|
||||
"""Show or hide the gamepad tooltip with the provided text."""
|
||||
if show and text:
|
||||
# First set the text to measure the required size
|
||||
# Set the text to the tooltip
|
||||
self.gamepad_tooltip.setText(text)
|
||||
|
||||
# Calculate appropriate size based on text content
|
||||
# Temporarily set a large size to allow proper text wrapping measurement
|
||||
self.gamepad_tooltip.setFixedSize(500, 300)
|
||||
|
||||
# Use font metrics to calculate the proper size with Qt's wrapping
|
||||
font_metrics = self.gamepad_tooltip.fontMetrics()
|
||||
# Calculate text dimensions - wrap at max width
|
||||
max_width = 500 # Maximum width in pixels
|
||||
text_lines = text.split('\n') # Handle multiline text
|
||||
|
||||
# If text is longer than can fit in a single line at max width, wrap it
|
||||
wrapped_lines = []
|
||||
for line in text_lines:
|
||||
if font_metrics.horizontalAdvance(line) <= max_width:
|
||||
wrapped_lines.append(line)
|
||||
else:
|
||||
# Word wrap the line to fit within max width
|
||||
words = line.split(' ')
|
||||
current_line = ''
|
||||
for word in words:
|
||||
test_line = current_line + ' ' + word if current_line else word
|
||||
if font_metrics.horizontalAdvance(test_line) <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line:
|
||||
wrapped_lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
wrapped_lines.append(current_line)
|
||||
# Calculate the required size using Qt's text wrapping functionality
|
||||
# We'll allow Qt to do the wrapping and measure accordingly
|
||||
# Using the boundingRect with TextWordWrap flag for accurate measurement
|
||||
text_rect = font_metrics.boundingRect(
|
||||
0, 0, max_width - 20, 1000, # Leave space for padding
|
||||
Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextExpandTabs,
|
||||
text
|
||||
)
|
||||
|
||||
# Set the final wrapped text
|
||||
wrapped_text = '\n'.join(wrapped_lines)
|
||||
self.gamepad_tooltip.setText(wrapped_text)
|
||||
|
||||
# Calculate the required size
|
||||
rect = font_metrics.boundingRect(0, 0, max_width, 1000, Qt.TextFlag.TextWordWrap, wrapped_text)
|
||||
required_width = min(max_width, rect.width() + 20) # Add padding
|
||||
required_height = min(300, rect.height() + 16) # Add padding, max height 300
|
||||
# Calculate final dimensions with sufficient padding
|
||||
required_width = min(max_width, text_rect.width() + 25) # Add padding
|
||||
required_height = min(300, text_rect.height() + 25) # Add padding
|
||||
|
||||
# Position the tooltip near the currently focused cell
|
||||
current_table = self.advanced_table if self.tab_widget.currentIndex() == 1 else self.settings_table
|
||||
|
||||
@@ -126,8 +126,6 @@ class Downloader(QObject):
|
||||
self._has_internet = True
|
||||
return self._has_internet
|
||||
|
||||
def reset_internet_check(self):
|
||||
self._has_internet = None
|
||||
|
||||
def _get_url_lock(self, url):
|
||||
with self._global_lock:
|
||||
@@ -247,9 +245,6 @@ class Downloader(QObject):
|
||||
with self._global_lock:
|
||||
self._cache.clear()
|
||||
|
||||
def is_cached(self, url):
|
||||
with self._global_lock:
|
||||
return url in self._cache
|
||||
|
||||
def get_latest_legendary_release(self):
|
||||
"""Get the latest legendary release info from GitHub API."""
|
||||
|
||||
@@ -458,9 +458,13 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
||||
if downloaded_count == total_covers:
|
||||
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
||||
|
||||
def on_steam_apps(steam_data: tuple[list, dict]):
|
||||
def on_steam_apps(steam_data: tuple[list | None, dict | None]):
|
||||
nonlocal steam_appid
|
||||
steam_apps, steam_apps_index = steam_data
|
||||
if not steam_apps or not steam_apps_index:
|
||||
logger.info(f"No Steam data available for EGS game {game_title}, skipping cover download")
|
||||
callback((True, f"Game '{game_title}' added to Steam"))
|
||||
return
|
||||
matching_app = search_app(game_title, steam_apps_index)
|
||||
steam_appid = matching_app.get("appid") if matching_app else None
|
||||
|
||||
@@ -555,49 +559,11 @@ def get_egs_game_description_async(
|
||||
cleaned = re.sub(r'[^a-z0-9 ]', '', title.lower()).strip()
|
||||
return re.sub(r'\s+', '-', cleaned)
|
||||
|
||||
def get_product_slug(namespace: str) -> str:
|
||||
"""Fetches the product slug using the namespace via GraphQL."""
|
||||
search_query = {
|
||||
"query": """
|
||||
query {
|
||||
Catalog {
|
||||
catalogNs(namespace: $namespace) {
|
||||
mappings(pageType: "productHome") {
|
||||
pageSlug
|
||||
pageType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
"variables": {"namespace": namespace}
|
||||
}
|
||||
try:
|
||||
response = requests.post(
|
||||
"https://launcher.store.epicgames.com/graphql",
|
||||
json=search_query,
|
||||
headers=headers,
|
||||
timeout=5
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = orjson.loads(response.content)
|
||||
mappings = data.get("data", {}).get("Catalog", {}).get("catalogNs", {}).get("mappings", [])
|
||||
for mapping in mappings:
|
||||
if mapping.get("pageType") == "productHome":
|
||||
return mapping.get("pageSlug", "")
|
||||
logger.warning("No productHome slug found for namespace %s", namespace)
|
||||
return ""
|
||||
except requests.RequestException as e:
|
||||
logger.warning("Failed to fetch product slug for namespace %s: %s", namespace, str(e))
|
||||
return ""
|
||||
except orjson.JSONDecodeError:
|
||||
logger.warning("Invalid JSON response for namespace %s", namespace)
|
||||
return ""
|
||||
|
||||
def fetch_legacy_description(url: str) -> str:
|
||||
"""Fetches description from the legacy API, handling DNS failures."""
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=5)
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = orjson.loads(response.content)
|
||||
if not isinstance(data, dict):
|
||||
@@ -619,6 +585,9 @@ def get_egs_game_description_async(
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
logger.error("DNS resolution failed for legacy API %s: %s", url, str(e))
|
||||
return ""
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning("Request timeout for legacy API %s", url)
|
||||
return ""
|
||||
except requests.RequestException as e:
|
||||
logger.warning("Failed to fetch legacy API for %s: %s", app_name, str(e))
|
||||
return ""
|
||||
@@ -670,7 +639,7 @@ def get_egs_game_description_async(
|
||||
url = "https://graphql.epicgames.com/graphql"
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=search_query, headers=headers, timeout=5)
|
||||
response = requests.post(url, json=search_query, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = orjson.loads(response.content)
|
||||
if namespace:
|
||||
@@ -689,6 +658,9 @@ def get_egs_game_description_async(
|
||||
for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"])):
|
||||
return element.get("description", ""), element.get("productSlug", "")
|
||||
return "", ""
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning("GraphQL request timeout for %s with locale %s", app_name, locale)
|
||||
return "", ""
|
||||
except requests.RequestException as e:
|
||||
logger.warning("Failed to fetch GraphQL data for %s with locale %s: %s", app_name, locale, str(e))
|
||||
return "", ""
|
||||
@@ -717,6 +689,10 @@ def get_egs_game_description_async(
|
||||
logger.debug("Fetched description from legacy API for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description)
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.error("Skipping legacy API due to DNS resolution failure for %s", app_name)
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning("Legacy API request timed out for %s", app_name)
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error fetching legacy API for %s: %s", app_name, str(e))
|
||||
|
||||
# Step 3: If still no description and no namespace, try GraphQL with title
|
||||
if not description and not namespace:
|
||||
@@ -931,10 +907,14 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
||||
image_folder = os.path.join(os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQt", "images")
|
||||
local_path = os.path.join(image_folder, f"{app_name}.jpg") if cover_url else ""
|
||||
|
||||
def on_steam_apps(steam_data: tuple[list, dict]):
|
||||
def on_steam_apps(steam_data: tuple[list | None, dict | None]):
|
||||
steam_apps, steam_apps_index = steam_data
|
||||
matching_app = search_app(title, steam_apps_index)
|
||||
steam_appid = matching_app.get("appid") if matching_app else None
|
||||
if not steam_apps or not steam_apps_index:
|
||||
logger.info(f"No Steam data available for EGS game {title}, skipping appid lookup")
|
||||
steam_appid = None
|
||||
else:
|
||||
matching_app = search_app(title, steam_apps_index)
|
||||
steam_appid = matching_app.get("appid") if matching_app else None
|
||||
|
||||
def on_protondb_tier(protondb_tier: str):
|
||||
def on_description_fetched(api_description: str):
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from PySide6.QtGui import QPainter, QColor, QDesktopServices
|
||||
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer
|
||||
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
|
||||
from collections.abc import Callable
|
||||
from portprotonqt.image_utils import load_pixmap_async, round_corners
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config
|
||||
@@ -10,8 +9,6 @@ from portprotonqt.custom_widgets import ClickableLabel
|
||||
from portprotonqt.portproton_api import PortProtonAPI
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.animations import GameCardAnimations
|
||||
from typing import cast
|
||||
|
||||
|
||||
class GameCard(QFrame):
|
||||
borderWidthChanged = Signal()
|
||||
@@ -203,13 +200,27 @@ class GameCard(QFrame):
|
||||
self.update_cover_pixmap()
|
||||
|
||||
def update_cover_pixmap(self):
|
||||
# Check if the coverLabel still exists before trying to update it
|
||||
# This prevents the "Internal C++ object already deleted" error when
|
||||
# the widget has been destroyed but the async callback still executes
|
||||
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||
return
|
||||
|
||||
if self.base_pixmap and not self.base_pixmap.isNull():
|
||||
scaled_width = int(self.base_card_width * self._scale)
|
||||
scaled_pixmap = self.base_pixmap.scaled(scaled_width, int(scaled_width * 1.5), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
|
||||
rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale))
|
||||
self.coverLabel.setPixmap(rounded_pixmap)
|
||||
try:
|
||||
self.coverLabel.setPixmap(rounded_pixmap)
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted between the check and the call
|
||||
pass
|
||||
|
||||
def _position_badges(self, current_width):
|
||||
# Check if the card has been destroyed before updating
|
||||
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||
return
|
||||
|
||||
right_margin = int(8 * self._scale)
|
||||
badge_spacing = int(current_width * 0.02)
|
||||
top_y = int(10 * self._scale)
|
||||
@@ -228,16 +239,28 @@ class GameCard(QFrame):
|
||||
if is_visible:
|
||||
badge_x = current_width - badge_width - right_margin
|
||||
badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||
badge.move(int(badge_x), int(badge_y))
|
||||
badge_y_positions.append(badge_y + badge.height())
|
||||
try:
|
||||
badge.move(int(badge_x), int(badge_y))
|
||||
badge_y_positions.append(badge_y + badge.height())
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
self.anticheatLabel.raise_()
|
||||
self.protondbLabel.raise_()
|
||||
self.portprotonLabel.raise_()
|
||||
self.egsLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
try:
|
||||
self.anticheatLabel.raise_()
|
||||
self.protondbLabel.raise_()
|
||||
self.portprotonLabel.raise_()
|
||||
self.egsLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
def update_scale(self):
|
||||
# Check if the card has been destroyed before updating
|
||||
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||
return
|
||||
|
||||
scaled_width = int(self.base_card_width * self._scale)
|
||||
scaled_height = int(self.base_card_width * 1.8 * self._scale)
|
||||
scaled_extra = int(self.base_extra_margin * self._scale)
|
||||
@@ -258,33 +281,53 @@ class GameCard(QFrame):
|
||||
icon_space = int(scaled_width * 0.012)
|
||||
for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]:
|
||||
if label is not None:
|
||||
label.setFixedWidth(badge_width)
|
||||
label.setIconSize(icon_size, icon_space)
|
||||
label.setCardWidth(scaled_width)
|
||||
try:
|
||||
label.setFixedWidth(badge_width)
|
||||
label.setIconSize(icon_size, icon_space)
|
||||
label.setCardWidth(scaled_width)
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
self._position_badges(scaled_width)
|
||||
|
||||
if self.base_font_size is not None:
|
||||
font = self.nameLabel.font()
|
||||
new_font_size = self.base_font_size * self._scale
|
||||
if new_font_size > 0:
|
||||
font.setPointSizeF(new_font_size)
|
||||
self.nameLabel.setFont(font)
|
||||
try:
|
||||
font = self.nameLabel.font()
|
||||
new_font_size = self.base_font_size * self._scale
|
||||
if new_font_size > 0:
|
||||
font.setPointSizeF(new_font_size)
|
||||
self.nameLabel.setFont(font)
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
self.shadow.setBlurRadius(int(20 * self._scale))
|
||||
try:
|
||||
self.shadow.setBlurRadius(int(20 * self._scale))
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
self.updateGeometry()
|
||||
self.update()
|
||||
try:
|
||||
self.updateGeometry()
|
||||
self.update()
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
# Ensure parent layout is updated safely
|
||||
parent = self.parentWidget()
|
||||
if parent:
|
||||
layout = parent.layout()
|
||||
if layout:
|
||||
layout.invalidate()
|
||||
layout.activate()
|
||||
layout.update()
|
||||
parent.updateGeometry()
|
||||
try:
|
||||
parent = self.parentWidget()
|
||||
if parent:
|
||||
layout = parent.layout()
|
||||
if layout:
|
||||
layout.invalidate()
|
||||
layout.activate()
|
||||
layout.update()
|
||||
parent.updateGeometry()
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
def update_card_size(self, new_width: int):
|
||||
self.base_card_width = new_width
|
||||
@@ -292,6 +335,10 @@ class GameCard(QFrame):
|
||||
self.update_scale()
|
||||
|
||||
def update_badge_visibility(self, display_filter: str):
|
||||
# Check if the card has been destroyed before updating
|
||||
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||
return
|
||||
|
||||
self.display_filter = display_filter
|
||||
self.steam_visible = (str(self.game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
|
||||
self.egs_visible = (str(self.game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
|
||||
@@ -299,11 +346,15 @@ class GameCard(QFrame):
|
||||
protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
|
||||
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
|
||||
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.egsLabel.setVisible(self.egs_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.protondbLabel.setVisible(protondb_visible)
|
||||
self.anticheatLabel.setVisible(anticheat_visible)
|
||||
try:
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.egsLabel.setVisible(self.egs_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.protondbLabel.setVisible(protondb_visible)
|
||||
self.anticheatLabel.setVisible(anticheat_visible)
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
return
|
||||
|
||||
scaled_width = int(self.base_card_width * self._scale)
|
||||
self._position_badges(scaled_width)
|
||||
@@ -398,18 +449,33 @@ class GameCard(QFrame):
|
||||
QDesktopServices.openUrl(url)
|
||||
|
||||
def update_favorite_icon(self):
|
||||
if self.is_favorite:
|
||||
self.favoriteLabel.setText("★")
|
||||
else:
|
||||
self.favoriteLabel.setText("☆")
|
||||
self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
|
||||
# Check if the card has been destroyed before updating
|
||||
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||
return
|
||||
|
||||
parent = self.parent()
|
||||
while parent:
|
||||
if hasattr(parent, 'game_library_manager'):
|
||||
QTimer.singleShot(0, parent.game_library_manager.update_game_grid) # type: ignore[attr-defined]
|
||||
break
|
||||
parent = parent.parent()
|
||||
try:
|
||||
if self.is_favorite:
|
||||
self.favoriteLabel.setText("★")
|
||||
else:
|
||||
self.favoriteLabel.setText("☆")
|
||||
self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
return
|
||||
|
||||
try:
|
||||
parent = self.parent()
|
||||
while parent:
|
||||
if hasattr(parent, 'game_library_manager'):
|
||||
# Access using getattr with default to avoid Ruff B009 warning
|
||||
manager = getattr(parent, 'game_library_manager', None)
|
||||
if manager is not None:
|
||||
QTimer.singleShot(0, manager.update_game_grid)
|
||||
break
|
||||
parent = parent.parent()
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
def toggle_favorite(self):
|
||||
favorites = read_favorites()
|
||||
@@ -452,9 +518,9 @@ class GameCard(QFrame):
|
||||
self.update_scale()
|
||||
self.scaleChanged.emit()
|
||||
|
||||
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged))
|
||||
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
|
||||
scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged))
|
||||
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=borderWidthChanged)
|
||||
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=gradientAngleChanged)
|
||||
scale = Property(float, getScale, setScale, None, "", notify=scaleChanged)
|
||||
|
||||
|
||||
def paintEvent(self, event):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Protocol
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.search_utils import SearchOptimizer, ThreadedSearch
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from portprotonqt.custom_widgets import FlowLayout
|
||||
@@ -32,7 +33,6 @@ class MainWindowProtocol(Protocol):
|
||||
|
||||
# Required attributes
|
||||
searchEdit: CustomLineEdit
|
||||
_last_card_width: int
|
||||
card_width: int
|
||||
current_hovered_card: GameCard | None
|
||||
current_focused_card: GameCard | None
|
||||
@@ -56,6 +56,9 @@ class GameLibraryManager:
|
||||
self.pending_deletions = deque()
|
||||
self.is_filtering = False
|
||||
self.dirty = False
|
||||
# Initialize search optimizer
|
||||
self.search_optimizer = SearchOptimizer()
|
||||
self.search_thread: ThreadedSearch | None = None
|
||||
|
||||
def create_games_library_widget(self):
|
||||
"""Creates the games library widget with search, grid, and slider."""
|
||||
@@ -130,7 +133,6 @@ class GameLibraryManager:
|
||||
self.sizeSlider.setToolTip(f"{self.card_width} px")
|
||||
save_card_size(self.card_width)
|
||||
self.main_window.card_width = self.card_width
|
||||
self.main_window._last_card_width = self.card_width
|
||||
for card in self.game_card_cache.values():
|
||||
card.update_card_size(self.card_width)
|
||||
self.update_game_grid()
|
||||
@@ -163,12 +165,18 @@ class GameLibraryManager:
|
||||
|
||||
if is_focused:
|
||||
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
try:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_hovered_card = None
|
||||
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
|
||||
self.main_window.current_focused_card._focused = False
|
||||
self.main_window.current_focused_card.clearFocus()
|
||||
try:
|
||||
self.main_window.current_focused_card._focused = False
|
||||
self.main_window.current_focused_card.clearFocus()
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_focused_card = card
|
||||
else:
|
||||
if self.main_window.current_focused_card == card:
|
||||
@@ -189,11 +197,19 @@ class GameLibraryManager:
|
||||
|
||||
if is_hovered:
|
||||
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
|
||||
self.main_window.current_focused_card._focused = False
|
||||
self.main_window.current_focused_card.clearFocus()
|
||||
try:
|
||||
if self.main_window.current_focused_card:
|
||||
self.main_window.current_focused_card._focused = False
|
||||
self.main_window.current_focused_card.clearFocus()
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
try:
|
||||
if self.main_window.current_hovered_card:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_hovered_card = card
|
||||
else:
|
||||
if self.main_window.current_hovered_card == card:
|
||||
@@ -212,6 +228,10 @@ class GameLibraryManager:
|
||||
if games_list is not None:
|
||||
self.filtered_games = games_list
|
||||
self.dirty = True # Full rebuild only for non-filter
|
||||
else:
|
||||
# When filtering, we want to update with the current filtered_games
|
||||
# which has already been set by _perform_search
|
||||
pass
|
||||
self.is_filtering = is_filter
|
||||
self._pending_update = True
|
||||
|
||||
@@ -222,13 +242,17 @@ class GameLibraryManager:
|
||||
|
||||
def force_update_cards_library(self):
|
||||
if self.gamesListWidget and self.gamesListLayout:
|
||||
# Use singleShot to ensure UI updates happen after all other operations complete
|
||||
# This prevents potential freezing in PySide 6.10.1
|
||||
QTimer.singleShot(0, self._perform_force_update)
|
||||
|
||||
def _perform_force_update(self):
|
||||
"""Perform the actual force update on the layout."""
|
||||
if self.gamesListLayout:
|
||||
self.gamesListLayout.invalidate()
|
||||
if self.gamesListWidget:
|
||||
self.gamesListWidget.adjustSize()
|
||||
self.gamesListWidget.updateGeometry()
|
||||
widget = self.gamesListWidget
|
||||
QTimer.singleShot(0, lambda: (
|
||||
widget.adjustSize(),
|
||||
widget.updateGeometry()
|
||||
))
|
||||
|
||||
def _update_game_grid_immediate(self):
|
||||
"""Updates the game grid with the provided or current game list."""
|
||||
@@ -238,8 +262,9 @@ class GameLibraryManager:
|
||||
search_text = self.main_window.searchEdit.text().strip().lower()
|
||||
|
||||
if self.is_filtering:
|
||||
# Filter mode: do not change layout, only hide/show cards
|
||||
self._apply_filter_visibility(search_text)
|
||||
# Filter mode: use the pre-computed filtered_games from optimized search
|
||||
# This means we already have the exact games to show
|
||||
self._update_search_results(search_text)
|
||||
else:
|
||||
# Full update: sorting, removal/addition, reorganization
|
||||
games_list = self.filtered_games if self.filtered_games else self.games
|
||||
@@ -358,34 +383,74 @@ class GameLibraryManager:
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.update()
|
||||
self.gamesListWidget.updateGeometry()
|
||||
self.main_window._last_card_width = self.card_width
|
||||
|
||||
self.force_update_cards_library()
|
||||
|
||||
self.is_filtering = False # Reset flag in any case
|
||||
|
||||
def _apply_filter_visibility(self, search_text: str):
|
||||
"""Applies visibility to cards based on search, without changing the layout."""
|
||||
visible_count = 0
|
||||
for game_key, card in self.game_card_cache.items():
|
||||
game_name = card.name # Assume GameCard has 'name' attribute
|
||||
should_be_visible = not search_text or search_text in game_name.lower()
|
||||
if card.isVisible() != should_be_visible:
|
||||
card.setVisible(should_be_visible)
|
||||
if should_be_visible:
|
||||
visible_count += 1
|
||||
# Load image only for newly visible cards
|
||||
if game_key in self.pending_images:
|
||||
cover_path, width, height, callback = self.pending_images.pop(game_key)
|
||||
load_pixmap_async(cover_path, width, height, callback)
|
||||
def _update_search_results(self, search_text: str = ""):
|
||||
"""Update the grid with pre-computed search results."""
|
||||
if self.gamesListLayout is None or self.gamesListWidget is None:
|
||||
return
|
||||
|
||||
# Force full relayout after visibility changes
|
||||
# Batch layout updates
|
||||
self.gamesListWidget.setUpdatesEnabled(False)
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.invalidate() # Принудительно инвалидируем для пересчёта
|
||||
self.gamesListLayout.setEnabled(False) # Disable layout during batch
|
||||
|
||||
try:
|
||||
# Create set of keys for current filtered games for fast lookup
|
||||
filtered_keys = {(game[0], game[4]) for game in self.filtered_games} # (name, exec_line)
|
||||
|
||||
# Process existing cards: show cards that are in filtered results, hide others
|
||||
cards_to_hide = []
|
||||
for card_key, card in self.game_card_cache.items():
|
||||
if card_key in filtered_keys:
|
||||
# Card should be visible
|
||||
if not card.isVisible():
|
||||
card.setVisible(True)
|
||||
else:
|
||||
# Card should be hidden
|
||||
if card.isVisible():
|
||||
card.setVisible(False)
|
||||
cards_to_hide.append(card_key)
|
||||
|
||||
# Now add any missing cards that are in filtered results but not in cache
|
||||
cards_to_add = []
|
||||
for game_data in self.filtered_games:
|
||||
game_name = game_data[0]
|
||||
exec_line = game_data[4]
|
||||
game_key = (game_name, exec_line)
|
||||
|
||||
if game_key not in self.game_card_cache:
|
||||
if self.context_menu_manager is None:
|
||||
continue
|
||||
|
||||
card = self._create_game_card(game_data)
|
||||
self.game_card_cache[game_key] = card
|
||||
card.setVisible(True) # New cards should be visible
|
||||
cards_to_add.append((game_key, card))
|
||||
|
||||
# Add new cards to layout
|
||||
for _game_key, card in cards_to_add:
|
||||
self.gamesListLayout.addWidget(card)
|
||||
|
||||
# Remove cards that are no longer needed (if any)
|
||||
# Note: we're not removing them completely as they might be needed later
|
||||
# Instead, we just hide them and they'll be reused if needed
|
||||
|
||||
finally:
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.setEnabled(True)
|
||||
self.gamesListWidget.setUpdatesEnabled(True)
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.update()
|
||||
self.gamesListWidget.updateGeometry()
|
||||
|
||||
self.force_update_cards_library()
|
||||
|
||||
self.gamesListLayout.update()
|
||||
if self.gamesListWidget is not None:
|
||||
self.gamesListWidget.updateGeometry()
|
||||
self.main_window._last_card_width = self.card_width
|
||||
|
||||
# If search is empty, load images for visible ones
|
||||
if not search_text:
|
||||
@@ -398,6 +463,7 @@ class GameLibraryManager:
|
||||
select_callback=self.main_window.openGameDetailPage,
|
||||
theme=self.theme,
|
||||
card_width=self.card_width,
|
||||
parent=self.gamesListWidget,
|
||||
context_menu_manager=self.context_menu_manager
|
||||
)
|
||||
|
||||
@@ -420,6 +486,11 @@ class GameLibraryManager:
|
||||
def _flush_deletions(self):
|
||||
"""Delete pending widgets off the main update cycle."""
|
||||
for card in list(self.pending_deletions):
|
||||
# Clear any references to this card if it's currently focused/hovered
|
||||
if self.main_window.current_focused_card == card:
|
||||
self.main_window.current_focused_card = None
|
||||
if self.main_window.current_hovered_card == card:
|
||||
self.main_window.current_hovered_card = None
|
||||
card.deleteLater()
|
||||
self.pending_deletions.remove(card)
|
||||
|
||||
@@ -427,24 +498,61 @@ class GameLibraryManager:
|
||||
"""Clears all widgets from the layout."""
|
||||
if layout is None:
|
||||
return
|
||||
# Remove all widgets from the layout and clean up caches
|
||||
while layout.count():
|
||||
child = layout.takeAt(0)
|
||||
if child.widget():
|
||||
widget = child.widget()
|
||||
# Clean up cache if widget exists in it
|
||||
for key, card in list(self.game_card_cache.items()):
|
||||
if card == widget:
|
||||
del self.game_card_cache[key]
|
||||
if key in self.pending_images:
|
||||
del self.pending_images[key]
|
||||
break
|
||||
# Always schedule widget for deletion regardless of cache state
|
||||
widget.deleteLater()
|
||||
|
||||
# Also clear the cache completely if needed (in case layout wasn't in sync)
|
||||
self.game_card_cache.clear()
|
||||
self.pending_images.clear()
|
||||
|
||||
def set_games(self, games: list[tuple]):
|
||||
"""Sets the games list and updates the filtered games."""
|
||||
self.games = games
|
||||
self.filtered_games = self.games
|
||||
|
||||
# Build search indices for fast searching
|
||||
self._build_search_indices(games)
|
||||
|
||||
self.dirty = True # Full resort needed
|
||||
self.update_game_grid()
|
||||
|
||||
def _build_search_indices(self, games: list[tuple]):
|
||||
"""Build search indices for fast searching."""
|
||||
# Prepare items for indexing: (search_key, game_data)
|
||||
# We'll index by game name (index 0) and potentially other fields
|
||||
items = []
|
||||
for game in games:
|
||||
# game is a tuple: (name, description, cover, appid, exec_line, controller_support,
|
||||
# last_launch, formatted_playtime, protondb_tier, anticheat_status,
|
||||
# last_played_timestamp, playtime_seconds, game_source)
|
||||
name = str(game[0]).lower() if game[0] else ""
|
||||
description = str(game[1]).lower() if game[1] else ""
|
||||
|
||||
# Create multiple search entries for better matching
|
||||
items.append((name, game)) # Exact name
|
||||
# Add other searchable fields if needed
|
||||
if description:
|
||||
items.append((description, game))
|
||||
|
||||
# Also add individual words from the name for partial matching
|
||||
for word in name.split():
|
||||
if len(word) > 2: # Only index words longer than 2 characters
|
||||
items.append((word, game))
|
||||
|
||||
self.search_optimizer.build_indices(items)
|
||||
|
||||
def add_game_incremental(self, game_data: tuple):
|
||||
"""Add a single game without full reload."""
|
||||
self.games.append(game_data)
|
||||
@@ -468,4 +576,54 @@ class GameLibraryManager:
|
||||
|
||||
def filter_games_delayed(self):
|
||||
"""Filters games based on search text and updates the grid."""
|
||||
self.update_game_grid(is_filter=True)
|
||||
search_text = self.main_window.searchEdit.text().strip().lower()
|
||||
|
||||
if not search_text:
|
||||
# If search is empty, show all games
|
||||
self.filtered_games = self.games
|
||||
self.update_game_grid(is_filter=True)
|
||||
else:
|
||||
# Use the optimized search
|
||||
self._perform_search(search_text)
|
||||
|
||||
def _perform_search(self, search_text: str):
|
||||
"""Perform the actual search using optimized search algorithms."""
|
||||
if not search_text:
|
||||
self.filtered_games = self.games
|
||||
self.update_game_grid(is_filter=True)
|
||||
return
|
||||
|
||||
# Use exact search first
|
||||
exact_result = self.search_optimizer.exact_search(search_text)
|
||||
if exact_result:
|
||||
# If exact match found, show only that game
|
||||
self.filtered_games = [exact_result]
|
||||
self.update_game_grid(is_filter=True)
|
||||
return
|
||||
|
||||
# Try prefix search
|
||||
prefix_results = self.search_optimizer.prefix_search(search_text)
|
||||
if prefix_results:
|
||||
# Get the actual game data from the prefix matches
|
||||
filtered_games = []
|
||||
for _match_text, game_data in prefix_results:
|
||||
if game_data not in filtered_games: # Avoid duplicates
|
||||
filtered_games.append(game_data)
|
||||
self.filtered_games = filtered_games
|
||||
self.update_game_grid(is_filter=True)
|
||||
return
|
||||
|
||||
# Finally, try fuzzy search
|
||||
fuzzy_results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=60.0)
|
||||
if fuzzy_results:
|
||||
# Get the actual game data from the fuzzy matches
|
||||
filtered_games = []
|
||||
for _match_text, game_data, _score in fuzzy_results:
|
||||
if game_data not in filtered_games: # Avoid duplicates
|
||||
filtered_games.append(game_data)
|
||||
self.filtered_games = filtered_games
|
||||
self.update_game_grid(is_filter=True)
|
||||
else:
|
||||
# If no results found, show empty list
|
||||
self.filtered_games = []
|
||||
self.update_game_grid(is_filter=True)
|
||||
|
||||
890
portprotonqt/get_wine_module.py
Normal file
@@ -0,0 +1,890 @@
|
||||
import os
|
||||
import requests
|
||||
import orjson
|
||||
import tarfile
|
||||
import shutil
|
||||
from PySide6.QtWidgets import (QDialog, QTabWidget, QTableWidget,
|
||||
QTableWidgetItem, QVBoxLayout, QWidget, QCheckBox,
|
||||
QPushButton, QHeaderView, QMessageBox,
|
||||
QLabel, QTextEdit, QHBoxLayout, QProgressBar,
|
||||
QFrame, QSizePolicy)
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QMutex, QWaitCondition, QTimer
|
||||
import urllib.parse
|
||||
from portprotonqt.config_utils import read_proxy_config
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.localization import _
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def get_requests_session():
|
||||
"""Create a requests session with proxy support"""
|
||||
session = requests.Session()
|
||||
proxy = read_proxy_config() or {}
|
||||
if proxy:
|
||||
session.proxies.update(proxy)
|
||||
session.verify = True
|
||||
return session
|
||||
|
||||
class DownloadThread(QThread):
|
||||
progress = Signal(int)
|
||||
finished = Signal(str, bool)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, download_url, filename):
|
||||
super().__init__()
|
||||
self.download_url = download_url
|
||||
self.filename = filename
|
||||
self._is_running = True
|
||||
self._mutex = QMutex()
|
||||
self._condition = QWaitCondition()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
session = get_requests_session()
|
||||
response = session.get(self.download_url, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded_size = 0
|
||||
|
||||
with open(self.filename, 'wb') as file:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
self._mutex.lock()
|
||||
if not self._is_running:
|
||||
self._mutex.unlock()
|
||||
# Если загрузка отменена, удаляем частично скачанный файл
|
||||
if os.path.exists(self.filename):
|
||||
os.remove(self.filename)
|
||||
return
|
||||
self._mutex.unlock()
|
||||
|
||||
if chunk:
|
||||
file.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
if total_size > 0:
|
||||
progress = int((downloaded_size / total_size) * 100)
|
||||
self.progress.emit(progress)
|
||||
|
||||
self._mutex.lock()
|
||||
if self._is_running:
|
||||
self._mutex.unlock()
|
||||
self.finished.emit(self.filename, True)
|
||||
else:
|
||||
self._mutex.unlock()
|
||||
# Если загрузка отменена в последний момент, удаляем файл
|
||||
if os.path.exists(self.filename):
|
||||
os.remove(self.filename)
|
||||
|
||||
except Exception as e:
|
||||
self._mutex.lock()
|
||||
if self._is_running:
|
||||
self._mutex.unlock()
|
||||
# Удаляем частично скачанный файл при ошибке
|
||||
if os.path.exists(self.filename):
|
||||
try:
|
||||
os.remove(self.filename)
|
||||
except OSError:
|
||||
pass
|
||||
self.error.emit(str(e))
|
||||
else:
|
||||
self._mutex.unlock()
|
||||
|
||||
def stop(self):
|
||||
"""Безопасная остановка потока"""
|
||||
self._mutex.lock()
|
||||
self._is_running = False
|
||||
self._mutex.unlock()
|
||||
|
||||
if self.isRunning():
|
||||
self.quit()
|
||||
if not self.wait(1000): # Ждем до 1 секунды
|
||||
logger.warning("Thread did not stop gracefully, but continuing...")
|
||||
|
||||
|
||||
class ExtractionThread(QThread):
|
||||
progress = Signal(int)
|
||||
finished = Signal(str, bool) # filename, success
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, archive_path, extract_dir):
|
||||
super().__init__()
|
||||
self.archive_path = archive_path
|
||||
self.extract_dir = extract_dir
|
||||
self._is_running = True
|
||||
self._mutex = QMutex()
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
# Update progress to show extraction is starting
|
||||
self.progress.emit(0)
|
||||
|
||||
# Create dist directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(self.extract_dir), exist_ok=True)
|
||||
|
||||
# Remove existing directory if it exists
|
||||
if os.path.exists(self.extract_dir):
|
||||
shutil.rmtree(self.extract_dir)
|
||||
|
||||
# Extract the archive (only .tar.gz and .tar.xz are supported according to metadata)
|
||||
if self.archive_path.lower().endswith(('.tar.gz', '.tar.xz')):
|
||||
with tarfile.open(self.archive_path, 'r:*') as tar_ref:
|
||||
# Get total number of members for progress tracking (only once)
|
||||
members = tar_ref.getmembers()
|
||||
total_members = len(members)
|
||||
extracted_count = 0
|
||||
|
||||
# Extract to a temporary directory first, then move contents to final destination
|
||||
# to avoid nested directory structures
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Extract with progress tracking
|
||||
last_progress = -1 # Track last emitted progress to avoid too frequent updates
|
||||
for member in members:
|
||||
self._mutex.lock()
|
||||
if not self._is_running:
|
||||
self._mutex.unlock()
|
||||
return
|
||||
self._mutex.unlock()
|
||||
|
||||
tar_ref.extract(member, temp_dir)
|
||||
extracted_count += 1
|
||||
|
||||
# Update progress
|
||||
if total_members > 0:
|
||||
progress = int((extracted_count / total_members) * 100)
|
||||
# Only emit progress if it changed significantly (at least 1%) or at start/end
|
||||
if progress != last_progress:
|
||||
self.progress.emit(progress)
|
||||
last_progress = progress
|
||||
else:
|
||||
# If total_members is 0, emit a default progress to show activity
|
||||
if extracted_count == 1:
|
||||
self.progress.emit(50) # Show partial progress to indicate activity
|
||||
# Process events to ensure UI updates
|
||||
QThread.yieldCurrentThread() # Allow other threads to run
|
||||
|
||||
# Find the actual content directory (often the archive creates a subdirectory)
|
||||
extracted_dirs = os.listdir(temp_dir)
|
||||
if len(extracted_dirs) == 1:
|
||||
# If there's only one directory, move its contents directly to extract_dir
|
||||
content_dir = os.path.join(temp_dir, extracted_dirs[0])
|
||||
if os.path.isdir(content_dir):
|
||||
# Move all contents from content_dir to extract_dir
|
||||
items = os.listdir(content_dir)
|
||||
total_items = len(items)
|
||||
moved_items = 0
|
||||
last_progress = -1 # Track last emitted progress to avoid too frequent updates
|
||||
|
||||
for item in items:
|
||||
self._mutex.lock()
|
||||
if not self._is_running:
|
||||
self._mutex.unlock()
|
||||
return
|
||||
self._mutex.unlock()
|
||||
|
||||
source = os.path.join(content_dir, item)
|
||||
destination = os.path.join(self.extract_dir, item)
|
||||
|
||||
if os.path.isdir(source):
|
||||
shutil.copytree(source, destination)
|
||||
else:
|
||||
shutil.copy2(source, destination)
|
||||
|
||||
moved_items += 1
|
||||
# Update progress during file copying (50% to 100% of extraction)
|
||||
progress = min(100, 50 + int((moved_items / total_items) * 50))
|
||||
# Only emit progress if it changed significantly (at least 1%)
|
||||
if progress != last_progress:
|
||||
self.progress.emit(progress)
|
||||
last_progress = progress
|
||||
# Process events to ensure UI updates
|
||||
QThread.yieldCurrentThread() # Allow other threads to run
|
||||
else:
|
||||
# If it's just files, move them directly
|
||||
items = os.listdir(temp_dir)
|
||||
total_items = len(items)
|
||||
moved_items = 0
|
||||
last_progress = -1 # Track last emitted progress to avoid too frequent updates
|
||||
|
||||
for item in items:
|
||||
self._mutex.lock()
|
||||
if not self._is_running:
|
||||
self._mutex.unlock()
|
||||
return
|
||||
self._mutex.unlock()
|
||||
|
||||
source = os.path.join(temp_dir, item)
|
||||
destination = os.path.join(self.extract_dir, item)
|
||||
|
||||
if os.path.isdir(source):
|
||||
shutil.copytree(source, destination)
|
||||
else:
|
||||
shutil.copy2(source, destination)
|
||||
|
||||
moved_items += 1
|
||||
# Update progress during file copying (50% to 100% of extraction)
|
||||
progress = min(100, 50 + int((moved_items / total_items) * 50))
|
||||
# Only emit progress if it changed significantly (at least 1%)
|
||||
if progress != last_progress:
|
||||
self.progress.emit(progress)
|
||||
last_progress = progress
|
||||
# Process events to ensure UI updates
|
||||
QThread.yieldCurrentThread() # Allow other threads to run
|
||||
else:
|
||||
# If multiple top-level items, extract directly to target
|
||||
# This is a simpler case where we extract directly to the target
|
||||
tar_ref.extractall(self.extract_dir)
|
||||
# Set progress to 100% when extraction is complete
|
||||
self.progress.emit(100)
|
||||
QThread.yieldCurrentThread() # Allow other threads to run
|
||||
else:
|
||||
raise ValueError(f"Unsupported archive format: {self.archive_path}. Only .tar.gz and .tar.xz are supported.")
|
||||
|
||||
self._mutex.lock()
|
||||
if self._is_running:
|
||||
self._mutex.unlock()
|
||||
self.finished.emit(self.archive_path, True)
|
||||
else:
|
||||
self._mutex.unlock()
|
||||
|
||||
except Exception as e:
|
||||
self._mutex.lock()
|
||||
if self._is_running:
|
||||
self._mutex.unlock()
|
||||
self.error.emit(str(e))
|
||||
else:
|
||||
self._mutex.unlock()
|
||||
|
||||
def stop(self):
|
||||
"""Безопасная остановка потока"""
|
||||
self._mutex.lock()
|
||||
self._is_running = False
|
||||
self._mutex.unlock()
|
||||
|
||||
if self.isRunning():
|
||||
self.quit()
|
||||
if not self.wait(1000): # Ждем до 1 секунды
|
||||
logger.warning("Thread did not stop gracefully, but continuing...")
|
||||
|
||||
|
||||
class ProtonManager(QDialog):
|
||||
def __init__(self, parent=None, portproton_location=None):
|
||||
super().__init__(parent)
|
||||
self.selected_assets = {} # {unique_id: asset_data}
|
||||
self.current_download_thread = None
|
||||
self.current_extraction_thread = None
|
||||
self.is_downloading = False
|
||||
self.assets_to_download = []
|
||||
self.current_download_index = 0
|
||||
self.portproton_location = portproton_location
|
||||
self.initUI()
|
||||
self.load_proton_data_from_json()
|
||||
|
||||
def initUI(self):
|
||||
self.setWindowTitle(_('Proton | WINE Download Manager'))
|
||||
self.resize(800, 600)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(5, 5, 5, 5)
|
||||
layout.setSpacing(5)
|
||||
|
||||
# Info label
|
||||
self.info_label = QLabel(_("Loading Proton versions from JSON metadata..."))
|
||||
self.info_label.setMaximumHeight(20)
|
||||
layout.addWidget(self.info_label)
|
||||
|
||||
# Tab widget - основной растягивающийся элемент
|
||||
self.tab_widget = QTabWidget()
|
||||
self.tab_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
layout.addWidget(self.tab_widget, 1)
|
||||
|
||||
# Инфо-блок для показа выбранного (компактный для информации по выбранным закачкам)
|
||||
selection_widget = QWidget()
|
||||
selection_layout = QVBoxLayout(selection_widget)
|
||||
selection_layout.setContentsMargins(0, 2, 0, 2)
|
||||
selection_layout.setSpacing(2)
|
||||
|
||||
selection_label = QLabel(_("Selected assets:"))
|
||||
selection_label.setMaximumHeight(20)
|
||||
selection_layout.addWidget(selection_label)
|
||||
|
||||
self.selection_text = QTextEdit()
|
||||
self.selection_text.setMaximumHeight(80)
|
||||
self.selection_text.setReadOnly(True)
|
||||
self.selection_text.setPlainText(_("No assets selected"))
|
||||
selection_layout.addWidget(self.selection_text)
|
||||
|
||||
layout.addWidget(selection_widget)
|
||||
|
||||
# Область прогресса загрузки
|
||||
self.download_frame = QFrame()
|
||||
self.download_frame.setFrameStyle(QFrame.Shape.StyledPanel)
|
||||
self.download_frame.setVisible(False)
|
||||
self.download_frame.setMaximumHeight(80)
|
||||
|
||||
download_layout = QVBoxLayout(self.download_frame)
|
||||
download_layout.setContentsMargins(10, 5, 10, 5)
|
||||
download_layout.setSpacing(5)
|
||||
|
||||
self.download_info_label = QLabel(_("Downloading: "))
|
||||
download_layout.addWidget(self.download_info_label)
|
||||
|
||||
progress_layout = QHBoxLayout()
|
||||
self.download_progress = QProgressBar()
|
||||
self.download_progress.setMinimum(0)
|
||||
self.download_progress.setMaximum(100)
|
||||
self.cancel_btn = QPushButton(_('Cancel'))
|
||||
self.cancel_btn.clicked.connect(self.cancel_current_download)
|
||||
progress_layout.addWidget(self.download_progress, 4)
|
||||
progress_layout.addWidget(self.cancel_btn, 1)
|
||||
download_layout.addLayout(progress_layout)
|
||||
|
||||
layout.addWidget(self.download_frame)
|
||||
|
||||
# Кнопки управления
|
||||
button_layout = QHBoxLayout()
|
||||
self.download_btn = QPushButton(_('Download Selected'))
|
||||
self.download_btn.clicked.connect(self.download_selected)
|
||||
self.download_btn.setEnabled(False)
|
||||
self.download_btn.setMinimumHeight(40)
|
||||
self.clear_btn = QPushButton(_('Clear All'))
|
||||
self.clear_btn.clicked.connect(self.clear_selection)
|
||||
self.clear_btn.setMinimumHeight(40)
|
||||
button_layout.addWidget(self.download_btn)
|
||||
button_layout.addWidget(self.clear_btn)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def load_proton_data_from_json(self):
|
||||
"""Загружаем данные по Протонам из файла JSON"""
|
||||
json_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata.json"
|
||||
|
||||
try:
|
||||
logger.debug(f"Loading JSON metadata from: {json_url}")
|
||||
session = get_requests_session()
|
||||
response = session.get(json_url, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
metadata = orjson.loads(response.content)
|
||||
logger.info(f"Successfully loaded JSON metadata with {len(metadata)} entries")
|
||||
|
||||
successful_tabs = self.process_metadata(metadata)
|
||||
|
||||
if successful_tabs == 0:
|
||||
self.info_label.setText(_("Error: Could not process any data from JSON."))
|
||||
else:
|
||||
self.info_label.setText(f"Loaded {successful_tabs} Proton/WINE sources from JSON")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Network error loading JSON: {e}")
|
||||
self.info_label.setText(_("Error loading JSON: {0}").format(e))
|
||||
except orjson.JSONDecodeError as e:
|
||||
logger.error(f"JSON parsing error: {e}")
|
||||
self.info_label.setText(_("Error parsing JSON: {0}").format(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading metadata: {e}")
|
||||
self.info_label.setText(_("Error: {0}").format(e))
|
||||
|
||||
def process_metadata(self, metadata):
|
||||
"""Обработка JSON, создание Табов"""
|
||||
successful_tabs = 0
|
||||
|
||||
# Собираем табы в словарь для сортировки
|
||||
tabs_dict = {}
|
||||
|
||||
for source_key, entries in metadata.items():
|
||||
# Пропускаем таб "gdk_proton" (вроде ненужный протон, скипаем)
|
||||
if source_key.lower() == 'gdk_proton':
|
||||
logger.debug(f"Skipping tab: {source_key}")
|
||||
continue
|
||||
|
||||
tabs_dict[source_key] = entries
|
||||
|
||||
# Proton_LG в самое начало кидаем
|
||||
if 'proton_lg' in tabs_dict:
|
||||
if self.create_tab_from_entries('proton_lg', tabs_dict['proton_lg']):
|
||||
successful_tabs += 1
|
||||
del tabs_dict['proton_lg']
|
||||
|
||||
# Остальные табы после Proton_LG
|
||||
for source_key, entries in tabs_dict.items():
|
||||
if self.create_tab_from_entries(source_key, entries):
|
||||
successful_tabs += 1
|
||||
|
||||
return successful_tabs
|
||||
|
||||
def create_tab_from_entries(self, source_name, entries):
|
||||
"""Создаем вкладку с таблицей для источника Proton из записей JSON"""
|
||||
|
||||
try:
|
||||
logger.debug(f"Processing {len(entries)} entries for source: {source_name}")
|
||||
|
||||
tab = QWidget()
|
||||
tab.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setContentsMargins(2, 2, 2, 2)
|
||||
layout.setSpacing(2)
|
||||
|
||||
table = QTableWidget()
|
||||
table.setColumnCount(2) # Только Checkbox и Имя
|
||||
table.setHorizontalHeaderLabels(['', 'Asset Name'])
|
||||
|
||||
header = table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
|
||||
table.setRowCount(len(entries))
|
||||
table.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
|
||||
table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
||||
table.cellClicked.connect(self.on_cell_clicked)
|
||||
|
||||
for row_index, entry in enumerate(entries):
|
||||
self.add_asset_row_from_json(table, row_index, entry, source_name)
|
||||
|
||||
layout.addWidget(table, 1)
|
||||
|
||||
tab_name = (self.get_short_source_name(source_name) or "UNKNOWN").upper() # Название для Таба в верхний регистр
|
||||
self.tab_widget.addTab(tab, tab_name)
|
||||
|
||||
logger.info(f"Successfully created tab for {source_name} with {len(entries)} assets")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating tab for {source_name}: {e}")
|
||||
return False
|
||||
|
||||
def get_short_source_name(self, full_name):
|
||||
"""Получаем короткое имя для вкладки (Таба) из полного имени источника"""
|
||||
if full_name is None:
|
||||
return "UNKNOWN"
|
||||
|
||||
short_names = {
|
||||
'proton_lg': 'PROTON_LG',
|
||||
'proton_ge': 'PROTON_GE',
|
||||
'wine_kron4ek': 'WINE_KRON4EK',
|
||||
'wine_ge': 'WINE_GE',
|
||||
'proton_cachyos': 'PROTON_CACHYOS',
|
||||
'winepak': 'WINEPAK',
|
||||
'proton_sarek': 'PROTON_SAREK',
|
||||
'wine_staging': 'WINE_STAGING',
|
||||
'wine_valve': 'WINE_VALVE',
|
||||
'proton_valve': 'PROTON_VALVE',
|
||||
'proton_em': 'PROTON_EM'
|
||||
}
|
||||
|
||||
return short_names.get(full_name.lower(), full_name.upper())
|
||||
|
||||
def add_asset_row_from_json(self, table, row_index, entry, source_name):
|
||||
"""Добавляем строку для определенной позиции из JSON"""
|
||||
checkbox_widget = QWidget()
|
||||
checkbox_layout = QHBoxLayout(checkbox_widget)
|
||||
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
checkbox_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
checkbox = QCheckBox()
|
||||
|
||||
# Извлекаем имя файла из URL
|
||||
url = entry.get('url', '')
|
||||
filename = entry.get('name', '')
|
||||
|
||||
if url:
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
url_filename = os.path.basename(parsed_url.path)
|
||||
if url_filename:
|
||||
filename = url_filename
|
||||
|
||||
# Извлекаем версию для уникального ID
|
||||
version_from_name = self.extract_version_from_name(filename)
|
||||
|
||||
# Создаем структуру для позиции (элемента)
|
||||
asset_data = {
|
||||
'name': filename, # имя с расширением
|
||||
'browser_download_url': url,
|
||||
}
|
||||
|
||||
# Проверяем, установлен ли уже этот ассет
|
||||
is_installed = self.is_asset_installed(filename, source_name)
|
||||
|
||||
checkbox.stateChanged.connect(lambda state, a=asset_data, v=version_from_name,
|
||||
s=source_name:
|
||||
self.on_asset_toggled_json(state, a, v, s))
|
||||
checkbox_layout.addWidget(checkbox)
|
||||
|
||||
table.setCellWidget(row_index, 0, checkbox_widget)
|
||||
|
||||
# Имя элемента (без расширения для красивого отображения)
|
||||
# Remove .tar.xz and .tar.gz extensions completely
|
||||
display_name = filename
|
||||
if filename.lower().endswith('.tar.xz'):
|
||||
display_name = filename[:-7] # Remove '.tar.xz'
|
||||
elif filename.lower().endswith('.tar.gz'):
|
||||
display_name = filename[:-7] # Remove '.tar.gz'
|
||||
else:
|
||||
# Fallback to removing just the last extension if needed
|
||||
display_name = os.path.splitext(filename)[0]
|
||||
asset_name_item = QTableWidgetItem(display_name)
|
||||
|
||||
# Если ассет уже установлен, делаем его недоступным для выбора
|
||||
if is_installed:
|
||||
checkbox.setEnabled(False)
|
||||
asset_name_item.setFlags(asset_name_item.flags() & ~Qt.ItemFlag.ItemIsEnabled)
|
||||
# Add "(installed)" suffix to indicate it's already installed
|
||||
asset_name_item.setText(f"{display_name} (installed)")
|
||||
|
||||
table.setItem(row_index, 1, asset_name_item)
|
||||
|
||||
# Собираем метаданные в данных элемента
|
||||
unique_id = f"{source_name}_{version_from_name}_{filename}"
|
||||
for col in range(table.columnCount()):
|
||||
item = table.item(row_index, col)
|
||||
if item:
|
||||
item.setData(Qt.ItemDataRole.UserRole, {
|
||||
'asset': asset_data,
|
||||
'unique_id': unique_id,
|
||||
'json_entry': entry,
|
||||
'source_name': source_name,
|
||||
'version': version_from_name
|
||||
})
|
||||
|
||||
def extract_version_from_name(self, name):
|
||||
"""Получаем версию из имени элемента"""
|
||||
if not name:
|
||||
return "N/A"
|
||||
|
||||
# Убираем расширение файла
|
||||
basename = os.path.splitext(name)[0]
|
||||
basename = os.path.splitext(basename)[0] # Для двойных расширений .tar.gz
|
||||
|
||||
# Получаем версию по паттернам
|
||||
if 'GE-Proton' in basename:
|
||||
parts = basename.split('-')
|
||||
if len(parts) >= 2:
|
||||
return '-'.join(parts[:2])
|
||||
elif 'wine-' in basename.lower():
|
||||
parts = basename.split('-')
|
||||
if len(parts) >= 2:
|
||||
return parts[1]
|
||||
elif 'proton-' in basename.lower():
|
||||
parts = basename.split('-')
|
||||
if len(parts) >= 2:
|
||||
return parts[1]
|
||||
|
||||
# Общий случай для всего
|
||||
return basename.split('-')[0] if '-' in basename else basename
|
||||
|
||||
def is_asset_installed(self, asset_filename, source_name):
|
||||
"""Check if asset is already installed in PortProton data/dist"""
|
||||
if not self.portproton_location:
|
||||
return False
|
||||
|
||||
# Determine the directory name without extensions
|
||||
name_without_ext = asset_filename
|
||||
for ext in ['.tar.gz', '.tar.xz']:
|
||||
if name_without_ext.lower().endswith(ext):
|
||||
name_without_ext = name_without_ext[:-len(ext)]
|
||||
break
|
||||
|
||||
# Check if the corresponding directory exists in data/dist
|
||||
dist_path = os.path.join(self.portproton_location, "data", "dist")
|
||||
expected_dir = os.path.join(dist_path, name_without_ext)
|
||||
|
||||
return os.path.exists(expected_dir)
|
||||
|
||||
def on_cell_clicked(self, row):
|
||||
"""Обработка клика по ячейке - переключение флажка при клике по любой ячейке в строке"""
|
||||
tab = self.tab_widget.currentWidget()
|
||||
table = tab.findChild(QTableWidget)
|
||||
if table:
|
||||
checkbox_widget = table.cellWidget(row, 0)
|
||||
if checkbox_widget:
|
||||
checkbox = checkbox_widget.findChild(QCheckBox)
|
||||
if checkbox and checkbox.isEnabled():
|
||||
checkbox.setChecked(not checkbox.isChecked())
|
||||
|
||||
def on_asset_toggled_json(self, state, asset, version, source_name):
|
||||
"""Обработка выбора/отмены выбора элемента из данных JSON"""
|
||||
# Всегда извлекаем имя файла из URL
|
||||
url = asset.get('browser_download_url', '')
|
||||
filename = asset.get('name', '') # Исходное имя из JSON
|
||||
|
||||
# Получаем имя файла из URL (оно всегда с расширением)
|
||||
if url:
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
url_filename = os.path.basename(parsed_url.path)
|
||||
if url_filename: # Если удалось получить имя из URL
|
||||
filename = url_filename
|
||||
|
||||
unique_id = f"{source_name}_{version}_{filename}"
|
||||
|
||||
if state == Qt.CheckState.Checked.value:
|
||||
self.selected_assets[unique_id] = {
|
||||
'source_name': source_name,
|
||||
'version': version,
|
||||
'asset': asset,
|
||||
'asset_name': filename, # Используем имя файла с расширением
|
||||
'download_url': asset['browser_download_url']
|
||||
}
|
||||
else:
|
||||
if unique_id in self.selected_assets:
|
||||
del self.selected_assets[unique_id]
|
||||
|
||||
self.update_selection_display()
|
||||
|
||||
def update_selection_display(self):
|
||||
"""Обновляем отображение выбора"""
|
||||
if self.selected_assets:
|
||||
selection_text = f"Selected {len(self.selected_assets)} assets:\n"
|
||||
|
||||
for i, asset_data in enumerate(self.selected_assets.values(), 1):
|
||||
selection_text += f"{i}. {asset_data['source_name'].upper()} - {asset_data['asset_name']}\n"
|
||||
|
||||
self.selection_text.setPlainText(selection_text)
|
||||
self.download_btn.setEnabled(True)
|
||||
else:
|
||||
self.selection_text.setPlainText(_("No assets selected"))
|
||||
self.download_btn.setEnabled(False)
|
||||
|
||||
def clear_selection(self):
|
||||
"""Очищаем (сбрасываем) всё выбранное"""
|
||||
if self.is_downloading:
|
||||
QMessageBox.warning(self, _("Download in Progress"), _("Cannot clear selection while downloading."))
|
||||
return
|
||||
|
||||
self.selected_assets.clear()
|
||||
|
||||
for tab_index in range(self.tab_widget.count()):
|
||||
tab = self.tab_widget.widget(tab_index)
|
||||
table = tab.findChild(QTableWidget)
|
||||
if table:
|
||||
for row in range(table.rowCount()):
|
||||
checkbox_widget = table.cellWidget(row, 0)
|
||||
if checkbox_widget:
|
||||
checkbox = checkbox_widget.findChild(QCheckBox)
|
||||
if checkbox and checkbox.isEnabled():
|
||||
checkbox.setChecked(False)
|
||||
|
||||
self.update_selection_display()
|
||||
|
||||
def download_selected(self):
|
||||
"""Загружаем все выбранные элементы (протоны)"""
|
||||
if not self.selected_assets:
|
||||
QMessageBox.warning(self, _("No Selection"), _("Please select at least one asset to download."))
|
||||
return
|
||||
|
||||
if self.is_downloading:
|
||||
QMessageBox.warning(self, _("Download in Progress"), _("Please wait for current download to complete."))
|
||||
return
|
||||
|
||||
downloads_dir = "proton_downloads"
|
||||
if not os.path.exists(downloads_dir):
|
||||
os.makedirs(downloads_dir)
|
||||
|
||||
self.assets_to_download = list(self.selected_assets.values())
|
||||
self.current_download_index = 0
|
||||
self.is_downloading = True
|
||||
self.start_next_download()
|
||||
|
||||
def start_next_download(self):
|
||||
"""Запуск загрузки следующего элемента в списке"""
|
||||
if self.current_download_index >= len(self.assets_to_download):
|
||||
# Все загрузки завершены
|
||||
self.download_frame.setVisible(False)
|
||||
self.download_btn.setEnabled(True)
|
||||
self.clear_btn.setEnabled(True)
|
||||
self.is_downloading = False
|
||||
QMessageBox.information(self, _("Download Complete"), _("All selected assets have been downloaded!"))
|
||||
return
|
||||
|
||||
asset_data = self.assets_to_download[self.current_download_index]
|
||||
self.download_asset(asset_data)
|
||||
|
||||
def download_asset(self, asset_data):
|
||||
"""Загрузка конкретного элемента (протона)"""
|
||||
filename = os.path.join("proton_downloads", asset_data['asset_name'])
|
||||
|
||||
download_info = f"{asset_data['source_name'].upper()} - {asset_data['asset_name']}"
|
||||
if len(download_info) > 80:
|
||||
download_info = download_info[:77] + "..."
|
||||
self.download_info_label.setText(f"Downloading: {download_info}")
|
||||
self.download_progress.setValue(0)
|
||||
self.download_frame.setVisible(True)
|
||||
self.download_btn.setEnabled(False)
|
||||
self.clear_btn.setEnabled(False)
|
||||
|
||||
if os.path.exists(filename):
|
||||
reply = QMessageBox.question(self, _("File Exists"),
|
||||
_("File {0} already exists. Overwrite?").format(asset_data['asset_name']),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
if reply == QMessageBox.StandardButton.No:
|
||||
self.current_download_index += 1
|
||||
self.start_next_download()
|
||||
return
|
||||
|
||||
download_url = asset_data.get('download_url', '')
|
||||
if not download_url:
|
||||
QMessageBox.critical(self, _("Download Error"), _("No download URL for {0}").format(asset_data['asset_name']))
|
||||
self.current_download_index += 1
|
||||
self.start_next_download()
|
||||
return
|
||||
|
||||
# Проверка, что предыдущий поток завершен
|
||||
if self.current_download_thread and self.current_download_thread.isRunning():
|
||||
logger.warning("Previous thread still running, waiting...")
|
||||
self.current_download_thread.stop()
|
||||
|
||||
self.current_download_thread = DownloadThread(download_url, filename)
|
||||
|
||||
def update_progress(progress):
|
||||
self.download_progress.setValue(progress)
|
||||
|
||||
def download_finished(filename, success):
|
||||
logger.debug(f"Download finished callback: {filename}, success: {success}")
|
||||
if success:
|
||||
logger.info(f"Successfully downloaded: {filename}")
|
||||
|
||||
# Update progress bar to show extraction phase
|
||||
self.download_info_label.setText(_("Extracting: {0}").format(asset_data['asset_name']))
|
||||
|
||||
# Extract archive to PortProton data/dist directory using a separate thread
|
||||
if self.portproton_location:
|
||||
try:
|
||||
# Determine the name for the extraction directory from the asset name
|
||||
asset_name = asset_data['asset_name']
|
||||
|
||||
# Remove common archive extensions to get the directory name (only .tar.gz and .tar.xz)
|
||||
name_without_ext = asset_name
|
||||
for ext in ['.tar.gz', '.tar.xz']:
|
||||
if name_without_ext.lower().endswith(ext):
|
||||
name_without_ext = name_without_ext[:-len(ext)]
|
||||
break
|
||||
|
||||
# Create the destination directory in PortProton data/dist
|
||||
dist_path = os.path.join(self.portproton_location, "data", "dist")
|
||||
extract_dir = os.path.join(dist_path, name_without_ext)
|
||||
|
||||
# Create and start extraction thread
|
||||
self.current_extraction_thread = ExtractionThread(filename, extract_dir)
|
||||
|
||||
def update_extraction_progress(progress):
|
||||
self.download_progress.setValue(progress)
|
||||
# Update the info label to show current progress percentage during extraction
|
||||
self.download_info_label.setText(_("Extracting: {0} ({1}%)").format(asset_data['asset_name'], progress))
|
||||
|
||||
def extraction_finished(archive_path, success):
|
||||
if success:
|
||||
logger.info(f"Successfully extracted: {archive_path}")
|
||||
else:
|
||||
logger.error(f"Failed to extract: {archive_path}")
|
||||
QMessageBox.critical(self, _("Extraction Error"), _("Failed to extract archive: {0}").format(archive_path))
|
||||
|
||||
if self.current_extraction_thread and self.current_extraction_thread.isRunning():
|
||||
logger.debug("Waiting for extraction thread to finish...")
|
||||
if not self.current_extraction_thread.wait(500):
|
||||
logger.warning("Extraction thread still running, but continuing...")
|
||||
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
|
||||
def extraction_error(error_msg):
|
||||
logger.error(f"Extraction error: {error_msg}")
|
||||
QMessageBox.critical(self, "Extraction Error", f"Failed to extract archive: {error_msg}")
|
||||
|
||||
if self.current_extraction_thread and self.current_extraction_thread.isRunning():
|
||||
logger.debug("Waiting for extraction thread to finish after error...")
|
||||
if not self.current_extraction_thread.wait(500):
|
||||
logger.warning("Extraction thread still running after error, but continuing...")
|
||||
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
|
||||
self.current_extraction_thread.progress.connect(update_extraction_progress)
|
||||
self.current_extraction_thread.finished.connect(extraction_finished)
|
||||
self.current_extraction_thread.error.connect(extraction_error)
|
||||
self.current_extraction_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting extraction thread for {filename}: {e}")
|
||||
QMessageBox.critical(self, "Extraction Error", f"Failed to start extraction: {e}")
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
else:
|
||||
logger.warning("PortProton location not provided, skipping extraction")
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
else:
|
||||
QMessageBox.critical(self, _("Download Failed"),
|
||||
_("Failed to download:\n{0}").format(filename))
|
||||
|
||||
def download_error(error_msg):
|
||||
logger.error(f"Download error: {error_msg}")
|
||||
QMessageBox.critical(self, _("Download Error"), error_msg)
|
||||
|
||||
if self.current_download_thread and self.current_download_thread.isRunning():
|
||||
if not self.current_download_thread.wait(500):
|
||||
logger.warning("Thread still running after error, but continuing...")
|
||||
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
|
||||
self.current_download_thread.progress.connect(update_progress)
|
||||
self.current_download_thread.finished.connect(download_finished)
|
||||
self.current_download_thread.error.connect(download_error)
|
||||
self.current_download_thread.start()
|
||||
|
||||
def cancel_current_download(self):
|
||||
"""Отмена текущей загрузки"""
|
||||
if self.current_download_thread and self.current_download_thread.isRunning():
|
||||
self.current_download_thread.stop()
|
||||
if not self.current_download_thread.wait(1000):
|
||||
logger.warning("Thread did not stop gracefully")
|
||||
|
||||
# Очищаем список загрузок
|
||||
self.assets_to_download = []
|
||||
self.current_download_index = 0
|
||||
self.is_downloading = False
|
||||
|
||||
# Сброс/перезапуск UI
|
||||
self.download_frame.setVisible(False)
|
||||
self.download_btn.setEnabled(True)
|
||||
self.clear_btn.setEnabled(True)
|
||||
|
||||
QMessageBox.information(self, _("Download Cancelled"), _("Download has been cancelled."))
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Проверка, что все потоки останавливаются при закрытии приложения"""
|
||||
logger.debug("Closing ProtonManager dialog...")
|
||||
|
||||
# Stop download thread if running
|
||||
if self.is_downloading and self.current_download_thread and self.current_download_thread.isRunning():
|
||||
logger.debug("Stopping current download thread...")
|
||||
self.current_download_thread.stop()
|
||||
if not self.current_download_thread.wait(2000):
|
||||
logger.warning("Thread did not stop gracefully during close")
|
||||
|
||||
# Stop extraction thread if running
|
||||
if self.current_extraction_thread and self.current_extraction_thread.isRunning():
|
||||
logger.debug("Stopping current extraction thread...")
|
||||
self.current_extraction_thread.stop()
|
||||
if not self.current_extraction_thread.wait(2000):
|
||||
logger.warning("Extraction thread did not stop gracefully during close")
|
||||
|
||||
event.accept()
|
||||
|
||||
|
||||
|
||||
def show_proton_manager(parent=None, portproton_location=None):
|
||||
"""
|
||||
Shows the Proton/WINE manager dialog.
|
||||
|
||||
Args:
|
||||
parent: Parent widget for the dialog
|
||||
portproton_location: Location of PortProton installation
|
||||
|
||||
Returns:
|
||||
ProtonManager dialog instance
|
||||
"""
|
||||
dialog = ProtonManager(parent, portproton_location)
|
||||
dialog.exec() # Use exec() for modal dialog
|
||||
return dialog
|
||||
@@ -199,9 +199,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
||||
painter.end()
|
||||
finish_with(pixmap)
|
||||
|
||||
with queue_lock:
|
||||
image_load_queue.put(process_image)
|
||||
image_executor.submit(lambda: image_load_queue.get()())
|
||||
# Submit the process_image function directly to the executor
|
||||
# This avoids the potential blocking issue with queue.get() in PySide 6.10.1
|
||||
image_executor.submit(process_image)
|
||||
|
||||
def round_corners(pixmap, radius):
|
||||
"""
|
||||
|
||||
@@ -2,11 +2,11 @@ import time
|
||||
import threading
|
||||
import os
|
||||
import math
|
||||
from typing import Protocol, cast
|
||||
from typing import Protocol, cast, Any
|
||||
from evdev import InputDevice, InputEvent, UInput, ecodes, list_devices, ff
|
||||
from enum import Enum
|
||||
from pyudev import Context, Monitor, Device, Devices
|
||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView, QTableWidgetItem
|
||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView, QTableWidgetItem, QSlider
|
||||
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
|
||||
from PySide6.QtGui import QKeyEvent, QMouseEvent
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -35,8 +35,12 @@ class MainWindowProtocol(Protocol):
|
||||
...
|
||||
def on_slider_released(self) -> None:
|
||||
...
|
||||
def on_auto_slider_released(self) -> None:
|
||||
...
|
||||
def isActiveWindow(self) -> bool:
|
||||
...
|
||||
def refreshGames(self) -> None:
|
||||
...
|
||||
stackedWidget: QStackedWidget
|
||||
tabButtons: dict[int, QWidget]
|
||||
gamesListWidget: QWidget
|
||||
@@ -44,6 +48,8 @@ class MainWindowProtocol(Protocol):
|
||||
currentDetailPage: QWidget | None
|
||||
current_exec_line: str | None
|
||||
current_add_game_dialog: AddGameDialog | None
|
||||
game_library_manager: Any # GameLibraryManager - using Any to avoid circular import
|
||||
auto_size_slider: QSlider | None
|
||||
|
||||
# Mapping of actions to evdev button codes, includes Xbox, PlayStation and Nintendo Switch controllers
|
||||
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c
|
||||
@@ -118,22 +124,36 @@ class InputManager(QObject):
|
||||
self.trigger_cooldown = 0.2
|
||||
|
||||
# Mouse emulation attributes
|
||||
self.mouse_emulation_enabled = True # Enable by default as crutch for external apps
|
||||
self.ui = None # UInput for virtual mouse
|
||||
self.mouse_emulation_enabled = True
|
||||
self.ui = None
|
||||
self.stick_x_raw = 0
|
||||
self.stick_y_raw = 0
|
||||
self.deadzone = 8000 # Deadzone for sticks
|
||||
self.max_value = 32767 # Max stick value
|
||||
self.sensitivity = 8.0 # Cursor sensitivity
|
||||
|
||||
# Параметры осей (будут заполнены из ядра)
|
||||
self.center_x = 127 # центр X оси
|
||||
self.center_y = 127 # центр Y оси
|
||||
self.min_value = 0 # минимум осей
|
||||
self.max_value = 255 # максимум осей
|
||||
self.deadzone_value = 15 # мёртвая зона из ядра (flat параметр)
|
||||
|
||||
self.sensitivity = 8.0
|
||||
self.scroll_accumulator = 0.0
|
||||
self.scroll_sensitivity = 0.15 # Scroll sensitivity
|
||||
self.scroll_threshold = 0.2 # Scroll threshold
|
||||
self.scroll_sensitivity = 0.15
|
||||
self.scroll_threshold = 0.2
|
||||
self.last_update = time.time()
|
||||
self.update_interval = 0.016 # ~60 FPS
|
||||
self.emulation_active = False # Flag for external focus (updated in main thread)
|
||||
self.emulation_active = False
|
||||
self.emulation_triggered = False
|
||||
self.start_held = False
|
||||
self.guide_held = False
|
||||
# Variables for key combination handling
|
||||
self.guide_pressed_time = 0
|
||||
self.select_pressed_time = 0
|
||||
self.guide_timer = QTimer(self)
|
||||
self.guide_timer.setSingleShot(True)
|
||||
self.guide_timer.timeout.connect(self._handle_guide_timeout)
|
||||
self.guide_combination_timeout = 0.3 # 300ms timeout for combination
|
||||
self.in_guide_combination_attempt = False # Flag to track if we're in a guide+select combination attempt
|
||||
|
||||
# Focus check timer for emulation flag (runs in main thread)
|
||||
self.focus_check_timer = QTimer(self)
|
||||
@@ -143,7 +163,8 @@ class InputManager(QObject):
|
||||
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
|
||||
|
||||
if self.mouse_emulation_enabled:
|
||||
self.enable_mouse_emulation()
|
||||
# Initialize mouse emulation asynchronously to avoid blocking startup
|
||||
QTimer.singleShot(0, self._async_enable_mouse_emulation)
|
||||
|
||||
# FileExplorer specific attributes
|
||||
self.file_explorer = None
|
||||
@@ -181,6 +202,10 @@ class InputManager(QObject):
|
||||
# Initialize evdev + hotplug
|
||||
self.init_gamepad()
|
||||
|
||||
def _async_enable_mouse_emulation(self):
|
||||
"""Asynchronously enable mouse emulation to avoid blocking startup."""
|
||||
self.enable_mouse_emulation()
|
||||
|
||||
def _update_emulation_flag(self):
|
||||
"""Update emulation_active flag based on Qt app focus (main thread only)."""
|
||||
active = QApplication.activeWindow()
|
||||
@@ -248,7 +273,7 @@ class InputManager(QObject):
|
||||
return
|
||||
current_row = sorted_rows[current_row_idx][1]
|
||||
focused_x = focused.pos().x() + focused.width() / 2
|
||||
current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0) # type: ignore
|
||||
current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0)
|
||||
|
||||
# Add null checks before using current_row_idx and current_col_idx
|
||||
if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows):
|
||||
@@ -1030,15 +1055,27 @@ class InputManager(QObject):
|
||||
self.stick_y_raw = 0
|
||||
self.scroll_accumulator = 0.0
|
||||
|
||||
|
||||
def handle_scroll(self, raw_value):
|
||||
"""Обработка прокрутки с правого стика Y"""
|
||||
if not self.mouse_emulation_enabled or not self.emulation_active or not self.ui:
|
||||
return
|
||||
if abs(raw_value) < self.deadzone:
|
||||
|
||||
# Нормализуем от центра
|
||||
centered_value = raw_value - self.center_y
|
||||
|
||||
if abs(centered_value) < self.deadzone_value:
|
||||
self.scroll_accumulator = 0.0
|
||||
return
|
||||
normalized = raw_value / self.max_value
|
||||
|
||||
# Нормализуем значение (-1.0 до 1.0)
|
||||
range_val = (self.max_value - self.min_value) / 2
|
||||
normalized = centered_value / range_val
|
||||
|
||||
# Накапливаем прокрутку
|
||||
self.scroll_accumulator += normalized * self.scroll_sensitivity
|
||||
|
||||
# Отправляем события прокрутки
|
||||
while abs(self.scroll_accumulator) >= self.scroll_threshold:
|
||||
scroll_step = 1 if self.scroll_accumulator > 0 else -1
|
||||
self.scroll_wheel(-scroll_step)
|
||||
@@ -1048,18 +1085,35 @@ class InputManager(QObject):
|
||||
"""Постоянное обновление позиции мыши на основе состояния стика"""
|
||||
if not self.ui or not self.emulation_active:
|
||||
return
|
||||
x = self.stick_x_raw
|
||||
y = self.stick_y_raw
|
||||
|
||||
# Центрируем значения
|
||||
x = self.stick_x_raw - self.center_x
|
||||
y = self.stick_y_raw - self.center_y
|
||||
|
||||
# Применяем мёртвую зону из ядра
|
||||
magnitude = math.sqrt(x * x + y * y)
|
||||
if magnitude < self.deadzone:
|
||||
|
||||
if magnitude < self.deadzone_value:
|
||||
return
|
||||
norm_x = x / magnitude
|
||||
norm_y = y / magnitude
|
||||
adjusted_magnitude = max(0.0, min(1.0, (magnitude - self.deadzone) / (self.max_value - self.deadzone)))
|
||||
|
||||
if magnitude > 0:
|
||||
norm_x = x / magnitude
|
||||
norm_y = y / magnitude
|
||||
else:
|
||||
return
|
||||
|
||||
# Нормализуем по диапазону оси
|
||||
max_range = (self.max_value - self.min_value) / 2
|
||||
adjusted_magnitude = (magnitude - self.deadzone_value) / (max_range - self.deadzone_value)
|
||||
adjusted_magnitude = max(0.0, min(1.0, adjusted_magnitude))
|
||||
|
||||
# Нелинейная кривая
|
||||
adjusted_magnitude = math.pow(adjusted_magnitude, 1.5)
|
||||
|
||||
speed = adjusted_magnitude * self.sensitivity
|
||||
dx = int(norm_x * speed)
|
||||
dy = int(norm_y * speed)
|
||||
|
||||
if dx != 0 or dy != 0:
|
||||
self.move_mouse(dx, dy)
|
||||
|
||||
@@ -1166,6 +1220,28 @@ class InputManager(QObject):
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping rumble: {e}", exc_info=True)
|
||||
|
||||
def _handle_guide_timeout(self) -> None:
|
||||
if self.guide_held:
|
||||
time_since_guide = time.time() - self.guide_pressed_time
|
||||
time_since_select = time.time() - self.select_pressed_time
|
||||
|
||||
if (self.select_pressed_time > self.guide_pressed_time and
|
||||
time_since_select <= self.guide_combination_timeout and
|
||||
time_since_guide <= self.guide_combination_timeout):
|
||||
logger.debug("Guide + Select combination detected, refreshing game grid")
|
||||
self._parent.refreshGames()
|
||||
else:
|
||||
logger.debug("Guide button pressed alone, opening system overlay")
|
||||
active = QApplication.activeWindow()
|
||||
if not isinstance(active, QDialog):
|
||||
self._parent.openSystemOverlay()
|
||||
|
||||
self.guide_held = False
|
||||
self.in_guide_combination_attempt = False
|
||||
self.guide_pressed_time = 0
|
||||
self.select_pressed_time = 0
|
||||
|
||||
|
||||
@Slot(int, int)
|
||||
def handle_button_slot(self, button_code: int, value: int) -> None:
|
||||
active_window = QApplication.activeWindow()
|
||||
@@ -1200,8 +1276,6 @@ class InputManager(QObject):
|
||||
app = QApplication.instance()
|
||||
active = QApplication.activeWindow()
|
||||
focused = QApplication.focusWidget()
|
||||
popup = QApplication.activePopupWidget()
|
||||
modal_dialog = QApplication.activeModalWidget()
|
||||
if not app or not active:
|
||||
return
|
||||
|
||||
@@ -1230,11 +1304,35 @@ class InputManager(QObject):
|
||||
search_edit.setFocus()
|
||||
return
|
||||
|
||||
# Handle Guide button to open system overlay
|
||||
if button_code in BUTTONS['guide']:
|
||||
if not popup and not isinstance(active, QDialog):
|
||||
self._parent.openSystemOverlay()
|
||||
# Guide + Select combination for refreshing game grid
|
||||
if value == 1:
|
||||
current_time = time.time()
|
||||
|
||||
if button_code in BUTTONS['guide']:
|
||||
self.guide_held = True
|
||||
self.guide_pressed_time = current_time
|
||||
self.in_guide_combination_attempt = True
|
||||
if hasattr(self, 'guide_timer'):
|
||||
self.guide_timer.start(int(self.guide_combination_timeout * 1000))
|
||||
return
|
||||
elif button_code in BUTTONS['menu'] and hasattr(self, 'guide_held') and self.guide_held:
|
||||
self.select_pressed_time = current_time
|
||||
time_since_guide = current_time - self.guide_pressed_time
|
||||
if time_since_guide <= self.guide_combination_timeout:
|
||||
if hasattr(self, 'guide_timer'):
|
||||
self.guide_timer.stop()
|
||||
logger.debug("Guide + Select combination detected, refreshing game grid")
|
||||
self._parent.refreshGames()
|
||||
self.guide_held = False
|
||||
self.in_guide_combination_attempt = False
|
||||
self.guide_pressed_time = 0
|
||||
self.select_pressed_time = 0
|
||||
return
|
||||
else:
|
||||
self.in_guide_combination_attempt = False
|
||||
self.guide_held = False
|
||||
self.guide_pressed_time = 0
|
||||
self.select_pressed_time = 0
|
||||
|
||||
# Handle common UI elements like QMenu and QMessageBox
|
||||
if self._handle_common_ui_elements(button_code):
|
||||
@@ -1301,13 +1399,6 @@ class InputManager(QObject):
|
||||
menu.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
|
||||
# Game launch on detail page
|
||||
if (button_code in BUTTONS['confirm']) and self._parent.currentDetailPage is not None and modal_dialog is None:
|
||||
if self._parent.current_exec_line:
|
||||
self.trigger_rumble()
|
||||
self._parent.toggleGame(self._parent.current_exec_line, None)
|
||||
return
|
||||
|
||||
# Standard navigation
|
||||
if button_code in BUTTONS['confirm']:
|
||||
self._parent.activateFocusedWidget()
|
||||
@@ -1324,20 +1415,38 @@ class InputManager(QObject):
|
||||
idx = (self._parent.stackedWidget.currentIndex() + 1) % len(self._parent.tabButtons)
|
||||
self._parent.switchTab(idx)
|
||||
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
elif button_code in BUTTONS['increase_size'] and self._parent.stackedWidget.currentIndex() == 0:
|
||||
# Increase card size with RT (Xbox) / R2 (PS)
|
||||
size_slider = getattr(self._parent, 'sizeSlider', None)
|
||||
if size_slider:
|
||||
new_value = min(size_slider.value() + 10, size_slider.maximum())
|
||||
size_slider.setValue(new_value)
|
||||
self._parent.on_slider_released()
|
||||
elif button_code in BUTTONS['decrease_size'] and self._parent.stackedWidget.currentIndex() == 0:
|
||||
# Decrease card size with LT (Xbox) / L2 (PS)
|
||||
size_slider = getattr(self._parent, 'sizeSlider', None)
|
||||
if size_slider:
|
||||
new_value = max(size_slider.value() - 10, size_slider.minimum())
|
||||
size_slider.setValue(new_value)
|
||||
self._parent.on_slider_released()
|
||||
elif button_code in BUTTONS['increase_size']:
|
||||
current_tab = self._parent.stackedWidget.currentIndex()
|
||||
if current_tab == 0: # Main games library
|
||||
if hasattr(self._parent, 'game_library_manager') and self._parent.game_library_manager:
|
||||
size_slider = getattr(self._parent.game_library_manager, 'sizeSlider', None)
|
||||
if size_slider:
|
||||
new_value = min(size_slider.value() + 10, size_slider.maximum())
|
||||
size_slider.setValue(new_value)
|
||||
self._parent.on_slider_released()
|
||||
elif current_tab == 1: # Auto-install tab
|
||||
auto_size_slider = getattr(self._parent, 'auto_size_slider', None)
|
||||
if auto_size_slider:
|
||||
new_value = min(auto_size_slider.value() + 10, auto_size_slider.maximum())
|
||||
auto_size_slider.setValue(new_value)
|
||||
if hasattr(self._parent, 'on_auto_slider_released'):
|
||||
self._parent.on_auto_slider_released()
|
||||
elif button_code in BUTTONS['decrease_size']:
|
||||
current_tab = self._parent.stackedWidget.currentIndex()
|
||||
if current_tab == 0: # Main games library
|
||||
if hasattr(self._parent, 'game_library_manager') and self._parent.game_library_manager:
|
||||
size_slider = getattr(self._parent.game_library_manager, 'sizeSlider', None)
|
||||
if size_slider:
|
||||
new_value = max(size_slider.value() - 10, size_slider.minimum())
|
||||
size_slider.setValue(new_value)
|
||||
self._parent.on_slider_released()
|
||||
elif current_tab == 1: # Auto-install tab
|
||||
auto_size_slider = getattr(self._parent, 'auto_size_slider', None)
|
||||
if auto_size_slider:
|
||||
new_value = max(auto_size_slider.value() - 10, auto_size_slider.minimum())
|
||||
auto_size_slider.setValue(new_value)
|
||||
if hasattr(self._parent, 'on_auto_slider_released'):
|
||||
self._parent.on_auto_slider_released()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
|
||||
|
||||
@@ -1607,6 +1716,50 @@ class InputManager(QObject):
|
||||
self._navigate_game_cards(container, current_index, code, value)
|
||||
return
|
||||
|
||||
# Button navigation on detail pages (horizontal layout)
|
||||
if code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y):
|
||||
focused = QApplication.focusWidget()
|
||||
page = self._parent.stackedWidget.currentWidget()
|
||||
|
||||
# Check if we're on a detail page and focused widget is a button
|
||||
if isinstance(focused, AutoSizeButton):
|
||||
# Find all buttons in the same horizontal layout (same parent, same Y position)
|
||||
parent_widget = focused.parentWidget()
|
||||
if parent_widget:
|
||||
# Find all AutoSizeButtons in the parent that are horizontally aligned
|
||||
buttons = parent_widget.findChildren(AutoSizeButton)
|
||||
# Filter buttons that are approximately on the same horizontal level (similar Y positions)
|
||||
y_tolerance = 20 # pixels tolerance for vertical alignment
|
||||
current_y = focused.geometry().y() + focused.geometry().height() // 2
|
||||
aligned_buttons = []
|
||||
for btn in buttons:
|
||||
btn_center_y = btn.geometry().y() + btn.geometry().height() // 2
|
||||
if abs(btn_center_y - current_y) <= y_tolerance:
|
||||
aligned_buttons.append(btn)
|
||||
|
||||
# Sort buttons by x position for left-to-right navigation
|
||||
if len(aligned_buttons) > 1:
|
||||
aligned_buttons.sort(key=lambda b: b.geometry().x() + b.geometry().width() // 2)
|
||||
|
||||
# Find current button index
|
||||
try:
|
||||
current_index = aligned_buttons.index(focused)
|
||||
except ValueError:
|
||||
current_index = -1
|
||||
|
||||
if current_index >= 0:
|
||||
if code == ecodes.ABS_HAT0X: # Horizontal navigation (left/right)
|
||||
if value < 0 and current_index > 0: # Left
|
||||
aligned_buttons[current_index - 1].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
elif value > 0 and current_index < len(aligned_buttons) - 1: # Right
|
||||
aligned_buttons[current_index + 1].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
elif code == ecodes.ABS_HAT0Y: # Vertical navigation (up/down)
|
||||
# For buttons on the same row, up/down should go to other controls
|
||||
# So we'll continue to the next section of code for general navigation
|
||||
pass
|
||||
|
||||
# Vertical navigation in other tabs
|
||||
if code == ecodes.ABS_HAT0Y and value != 0:
|
||||
focused = QApplication.focusWidget()
|
||||
@@ -1772,6 +1925,11 @@ class InputManager(QObject):
|
||||
self._parent.openSystemOverlay()
|
||||
return True
|
||||
|
||||
# Refresh game grid with F5
|
||||
if key == Qt.Key.Key_F5:
|
||||
self._parent.refreshGames()
|
||||
return True
|
||||
|
||||
# Close application with Ctrl+Q
|
||||
if key == Qt.Key.Key_Q and modifiers & Qt.KeyboardModifier.ControlModifier:
|
||||
app.quit()
|
||||
@@ -1803,8 +1961,10 @@ class InputManager(QObject):
|
||||
active_win.show_next()
|
||||
return True # Consume event to prevent tab switching
|
||||
|
||||
# Handle tab switching with Left/Right arrow keys when not in GameCard focus or QLineEdit
|
||||
if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and not isinstance(focused, GameCard | QLineEdit) and not self.file_explorer:
|
||||
# Handle tab switching with Left/Right arrow keys when not in GameCard focus or QLineEdit or QTableWidget or AutoSizeButton
|
||||
if (key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and
|
||||
not isinstance(focused, GameCard | QLineEdit | QTableWidget | AutoSizeButton) and
|
||||
not self.file_explorer):
|
||||
idx = self._parent.stackedWidget.currentIndex()
|
||||
total = len(self._parent.tabButtons)
|
||||
if key == Qt.Key.Key_Left:
|
||||
@@ -1840,12 +2000,6 @@ class InputManager(QObject):
|
||||
self.dpad_moved.emit(dpad_code, dpad_value, now)
|
||||
return True
|
||||
|
||||
# Launch/stop game on detail page
|
||||
if self._parent.currentDetailPage and key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
if self._parent.current_exec_line:
|
||||
self._parent.toggleGame(self._parent.current_exec_line, None)
|
||||
return True
|
||||
|
||||
# Context menu for GameCard
|
||||
if isinstance(focused, GameCard):
|
||||
if key == Qt.Key.Key_F10 and modifiers & Qt.KeyboardModifier.ShiftModifier:
|
||||
@@ -1855,6 +2009,18 @@ class InputManager(QObject):
|
||||
|
||||
# General actions: Activate, Back, Add
|
||||
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
# Special handling for table widgets with checkboxes
|
||||
if isinstance(focused, QTableWidget):
|
||||
current_row = focused.currentRow()
|
||||
current_col = focused.currentColumn()
|
||||
if current_row >= 0 and current_col >= 0:
|
||||
# Check if the cell contains a checkbox
|
||||
item = focused.item(current_row, current_col)
|
||||
if item and (item.flags() & Qt.ItemFlag.ItemIsUserCheckable):
|
||||
# Toggle the checkbox state
|
||||
new_state = Qt.CheckState.Checked if item.checkState() == Qt.CheckState.Unchecked else Qt.CheckState.Unchecked
|
||||
item.setCheckState(new_state)
|
||||
return True
|
||||
self._parent.activateFocusedWidget()
|
||||
return True
|
||||
elif key in (Qt.Key.Key_Escape, Qt.Key.Key_Backspace):
|
||||
@@ -2134,6 +2300,7 @@ class InputManager(QObject):
|
||||
|
||||
if is_joystick == '1':
|
||||
logger.info(f"Found gamepad: {device.name}")
|
||||
self.detect_gamepad_axes(device)
|
||||
return device
|
||||
|
||||
except Exception as e:
|
||||
@@ -2147,6 +2314,34 @@ class InputManager(QObject):
|
||||
logger.error(f"Error finding gamepad: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def detect_gamepad_axes(self, device: InputDevice) -> None:
|
||||
"""Читаем параметры осей из ядра (диапазон и мёртвую зону)"""
|
||||
try:
|
||||
caps = device.capabilities()
|
||||
if ecodes.EV_ABS not in caps:
|
||||
return
|
||||
|
||||
abs_axes = caps[ecodes.EV_ABS]
|
||||
for code, absinfo in cast(Any, abs_axes):
|
||||
if code == ecodes.ABS_X:
|
||||
self.min_value = absinfo.min
|
||||
self.max_value = absinfo.max
|
||||
self.center_x = (absinfo.min + absinfo.max) // 2
|
||||
self.center_y = (absinfo.min + absinfo.max) // 2
|
||||
self.stick_x_raw = self.center_x
|
||||
self.stick_y_raw = self.center_y
|
||||
|
||||
# Берём мёртвую зону из ядра (flat параметр)
|
||||
self.deadzone_value = absinfo.flat if absinfo.flat > 0 else 15
|
||||
|
||||
logger.info(
|
||||
f"Gamepad axes: min={self.min_value}, max={self.max_value}, "
|
||||
f"center={self.center_x}, deadzone={self.deadzone_value}"
|
||||
)
|
||||
break
|
||||
except Exception as ex:
|
||||
logger.error(f"Error detecting gamepad axes: {ex}")
|
||||
|
||||
def monitor_gamepad(self) -> None:
|
||||
try:
|
||||
while self.running:
|
||||
@@ -2180,8 +2375,12 @@ class InputManager(QObject):
|
||||
|
||||
self.button_event.emit(event.code, event.value)
|
||||
# Special handling for menu on press only
|
||||
if event.value == 1 and event.code in BUTTONS['menu'] and not self._is_gamescope_session:
|
||||
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
||||
# Only handle menu button if our main window is currently active
|
||||
if (event.value == 1 and event.code in BUTTONS['menu'] and
|
||||
not self._is_gamescope_session and not self.in_guide_combination_attempt):
|
||||
# Check if our main window is the currently active window
|
||||
if self._parent.isActiveWindow():
|
||||
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
||||
elif event.type == ecodes.EV_ABS:
|
||||
if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}:
|
||||
# Trigger handling for UI
|
||||
@@ -2222,9 +2421,12 @@ class InputManager(QObject):
|
||||
elif event.code == ecodes.ABS_X:
|
||||
self.stick_x_raw = event.value
|
||||
elif event.code == ecodes.ABS_Y:
|
||||
self.stick_y_raw = event.value
|
||||
if event.code not in (ecodes.ABS_GAS, ecodes.ABS_BRAKE):
|
||||
self.stick_y_raw = event.value
|
||||
elif event.code == ecodes.ABS_RY:
|
||||
self.handle_scroll(event.value)
|
||||
elif event.code in (ecodes.ABS_GAS, ecodes.ABS_BRAKE):
|
||||
pass # Триггеры - не обрабатываем
|
||||
elif event.type == ecodes.EV_KEY:
|
||||
if event.code in (ecodes.BTN_SOUTH, ecodes.BTN_A) and event.value == 1:
|
||||
self.click_left()
|
||||
@@ -2242,8 +2444,8 @@ class InputManager(QObject):
|
||||
else:
|
||||
logger.error(f"IOError in gamepad monitoring: {e}")
|
||||
self.gamepad = None
|
||||
self.stick_x_raw = 0
|
||||
self.stick_y_raw = 0
|
||||
self.stick_x_raw = self.center_x
|
||||
self.stick_y_raw = self.center_y
|
||||
self.scroll_accumulator = 0.0
|
||||
self.start_held = False
|
||||
self.guide_held = False
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-11-23 15:42+0500\n"
|
||||
"POT-Creation-Date: 2025-11-30 13:20+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -217,6 +217,10 @@ msgstr ""
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Unsupported image format: {extension}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -320,6 +324,9 @@ msgstr ""
|
||||
msgid "Custom Cover:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Preview:"
|
||||
msgstr ""
|
||||
|
||||
@@ -452,6 +459,9 @@ msgstr ""
|
||||
msgid "Unknown Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Starting PortProton..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
@@ -473,6 +483,9 @@ msgstr ""
|
||||
msgid "Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refresh Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "Installation already in progress."
|
||||
msgstr ""
|
||||
|
||||
@@ -492,9 +505,6 @@ msgstr ""
|
||||
msgid "Installation error."
|
||||
msgstr ""
|
||||
|
||||
msgid "Refresh Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game library refreshed"
|
||||
msgstr ""
|
||||
|
||||
@@ -896,9 +906,6 @@ msgstr ""
|
||||
msgid "Run the application in a terminal"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable startup mode and WINE version selector window"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use system GameMode for performance optimization"
|
||||
msgstr ""
|
||||
|
||||
@@ -971,10 +978,10 @@ msgstr ""
|
||||
msgid "Prefix Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select the Wine prefix to use."
|
||||
msgid "Specify the Wine prefix to run this game with"
|
||||
msgstr ""
|
||||
|
||||
msgid "Latest"
|
||||
msgid "Newest"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stable"
|
||||
@@ -984,31 +991,15 @@ msgid "Vulkan Backend"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Select the rendering backend for translating DirectX → Vulkan/OpenGL:\n"
|
||||
"Select the DirectX → Vulkan/OpenGL backend:\n"
|
||||
"\n"
|
||||
"• Auto – latest DXVK + VKD3D (recommended)\n"
|
||||
" The newest versions from the developers. Give the best compatibility "
|
||||
"and performance in modern games.\n"
|
||||
" Require up-to-date drivers:\n"
|
||||
" – AMD: Mesa 25.0+ or proprietary AMDVLK 2024.Q4+\n"
|
||||
" – NVIDIA: driver 550.54.14 or newer\n"
|
||||
" – Intel: Mesa 24.2+\n"
|
||||
"\n"
|
||||
"• Stable – proven DXVK + VKD3D\n"
|
||||
" Older but extremely well-tested versions. Work on any drivers that "
|
||||
"support Vulkan 1.3+.\n"
|
||||
" The best choice if you have problems with the newest versions.\n"
|
||||
"\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek\n"
|
||||
" Work even on older drivers and video cards that support at least "
|
||||
"Vulkan 1.1.\n"
|
||||
"\n"
|
||||
"• WINED3D – OpenGL translation (fallback)\n"
|
||||
" No DXVK/VKD3D used. DirectX is translated to OpenGL via built-in "
|
||||
"WineD3D.\n"
|
||||
" Works on absolutely any hardware, but performance is significantly "
|
||||
"lower.\n"
|
||||
" Use only as a last resort when nothing else starts."
|
||||
"• Newest – latest DXVK + VKD3D (best compatibility/performance, requires "
|
||||
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||
"• Stable – older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
|
||||
"driver)\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
|
||||
"Vulkan 1.1+)\n"
|
||||
"• WINED3D – OpenGL fallback (lowest performance, use only if others fail)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Windows version"
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-11-23 15:42+0500\n"
|
||||
"POT-Creation-Date: 2025-11-30 13:20+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -217,6 +217,10 @@ msgstr ""
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Unsupported image format: {extension}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -320,6 +324,9 @@ msgstr ""
|
||||
msgid "Custom Cover:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Preview:"
|
||||
msgstr ""
|
||||
|
||||
@@ -452,6 +459,9 @@ msgstr ""
|
||||
msgid "Unknown Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Starting PortProton..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
@@ -473,6 +483,9 @@ msgstr ""
|
||||
msgid "Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refresh Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "Installation already in progress."
|
||||
msgstr ""
|
||||
|
||||
@@ -492,9 +505,6 @@ msgstr ""
|
||||
msgid "Installation error."
|
||||
msgstr ""
|
||||
|
||||
msgid "Refresh Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game library refreshed"
|
||||
msgstr ""
|
||||
|
||||
@@ -896,9 +906,6 @@ msgstr ""
|
||||
msgid "Run the application in a terminal"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable startup mode and WINE version selector window"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use system GameMode for performance optimization"
|
||||
msgstr ""
|
||||
|
||||
@@ -971,10 +978,10 @@ msgstr ""
|
||||
msgid "Prefix Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select the Wine prefix to use."
|
||||
msgid "Specify the Wine prefix to run this game with"
|
||||
msgstr ""
|
||||
|
||||
msgid "Latest"
|
||||
msgid "Newest"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stable"
|
||||
@@ -984,31 +991,15 @@ msgid "Vulkan Backend"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Select the rendering backend for translating DirectX → Vulkan/OpenGL:\n"
|
||||
"Select the DirectX → Vulkan/OpenGL backend:\n"
|
||||
"\n"
|
||||
"• Auto – latest DXVK + VKD3D (recommended)\n"
|
||||
" The newest versions from the developers. Give the best compatibility "
|
||||
"and performance in modern games.\n"
|
||||
" Require up-to-date drivers:\n"
|
||||
" – AMD: Mesa 25.0+ or proprietary AMDVLK 2024.Q4+\n"
|
||||
" – NVIDIA: driver 550.54.14 or newer\n"
|
||||
" – Intel: Mesa 24.2+\n"
|
||||
"\n"
|
||||
"• Stable – proven DXVK + VKD3D\n"
|
||||
" Older but extremely well-tested versions. Work on any drivers that "
|
||||
"support Vulkan 1.3+.\n"
|
||||
" The best choice if you have problems with the newest versions.\n"
|
||||
"\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek\n"
|
||||
" Work even on older drivers and video cards that support at least "
|
||||
"Vulkan 1.1.\n"
|
||||
"\n"
|
||||
"• WINED3D – OpenGL translation (fallback)\n"
|
||||
" No DXVK/VKD3D used. DirectX is translated to OpenGL via built-in "
|
||||
"WineD3D.\n"
|
||||
" Works on absolutely any hardware, but performance is significantly "
|
||||
"lower.\n"
|
||||
" Use only as a last resort when nothing else starts."
|
||||
"• Newest – latest DXVK + VKD3D (best compatibility/performance, requires "
|
||||
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||
"• Stable – older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
|
||||
"driver)\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
|
||||
"Vulkan 1.1+)\n"
|
||||
"• WINED3D – OpenGL fallback (lowest performance, use only if others fail)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Windows version"
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-11-23 15:43+0500\n"
|
||||
"POT-Creation-Date: 2025-11-30 13:20+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -215,6 +215,10 @@ msgstr ""
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Unsupported image format: {extension}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -318,6 +322,9 @@ msgstr ""
|
||||
msgid "Custom Cover:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Preview:"
|
||||
msgstr ""
|
||||
|
||||
@@ -450,6 +457,9 @@ msgstr ""
|
||||
msgid "Unknown Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Starting PortProton..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
@@ -471,6 +481,9 @@ msgstr ""
|
||||
msgid "Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refresh Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "Installation already in progress."
|
||||
msgstr ""
|
||||
|
||||
@@ -490,9 +503,6 @@ msgstr ""
|
||||
msgid "Installation error."
|
||||
msgstr ""
|
||||
|
||||
msgid "Refresh Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game library refreshed"
|
||||
msgstr ""
|
||||
|
||||
@@ -894,9 +904,6 @@ msgstr ""
|
||||
msgid "Run the application in a terminal"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable startup mode and WINE version selector window"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use system GameMode for performance optimization"
|
||||
msgstr ""
|
||||
|
||||
@@ -969,10 +976,10 @@ msgstr ""
|
||||
msgid "Prefix Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select the Wine prefix to use."
|
||||
msgid "Specify the Wine prefix to run this game with"
|
||||
msgstr ""
|
||||
|
||||
msgid "Latest"
|
||||
msgid "Newest"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stable"
|
||||
@@ -982,31 +989,15 @@ msgid "Vulkan Backend"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Select the rendering backend for translating DirectX → Vulkan/OpenGL:\n"
|
||||
"Select the DirectX → Vulkan/OpenGL backend:\n"
|
||||
"\n"
|
||||
"• Auto – latest DXVK + VKD3D (recommended)\n"
|
||||
" The newest versions from the developers. Give the best compatibility "
|
||||
"and performance in modern games.\n"
|
||||
" Require up-to-date drivers:\n"
|
||||
" – AMD: Mesa 25.0+ or proprietary AMDVLK 2024.Q4+\n"
|
||||
" – NVIDIA: driver 550.54.14 or newer\n"
|
||||
" – Intel: Mesa 24.2+\n"
|
||||
"\n"
|
||||
"• Stable – proven DXVK + VKD3D\n"
|
||||
" Older but extremely well-tested versions. Work on any drivers that "
|
||||
"support Vulkan 1.3+.\n"
|
||||
" The best choice if you have problems with the newest versions.\n"
|
||||
"\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek\n"
|
||||
" Work even on older drivers and video cards that support at least "
|
||||
"Vulkan 1.1.\n"
|
||||
"\n"
|
||||
"• WINED3D – OpenGL translation (fallback)\n"
|
||||
" No DXVK/VKD3D used. DirectX is translated to OpenGL via built-in "
|
||||
"WineD3D.\n"
|
||||
" Works on absolutely any hardware, but performance is significantly "
|
||||
"lower.\n"
|
||||
" Use only as a last resort when nothing else starts."
|
||||
"• Newest – latest DXVK + VKD3D (best compatibility/performance, requires "
|
||||
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||
"• Stable – older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
|
||||
"driver)\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
|
||||
"Vulkan 1.1+)\n"
|
||||
"• WINED3D – OpenGL fallback (lowest performance, use only if others fail)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Windows version"
|
||||
|
||||
@@ -9,8 +9,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-11-23 15:42+0500\n"
|
||||
"PO-Revision-Date: 2025-11-23 15:42+0500\n"
|
||||
"POT-Creation-Date: 2025-11-30 13:20+0500\n"
|
||||
"PO-Revision-Date: 2025-11-30 13:18+0500\n"
|
||||
"Last-Translator: \n"
|
||||
"Language: ru_RU\n"
|
||||
"Language-Team: ru_RU <LL@li.org>\n"
|
||||
@@ -222,6 +222,10 @@ msgstr "Не удалось сохранить файл .desktop: {error}"
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Не удалось скопировать обложку: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Unsupported image format: {extension}"
|
||||
msgstr "Неподдерживаемый формат изображения: {extension}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
|
||||
@@ -327,6 +331,9 @@ msgstr "Обзор..."
|
||||
msgid "Custom Cover:"
|
||||
msgstr "Обложка:"
|
||||
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Введите локальный путь или URL обложки"
|
||||
|
||||
msgid "Cover Preview:"
|
||||
msgstr "Предпросмотр обложки:"
|
||||
|
||||
@@ -459,6 +466,9 @@ msgstr "В ожидании"
|
||||
msgid "Unknown Game"
|
||||
msgstr "Неизвестная игра"
|
||||
|
||||
msgid "Starting PortProton..."
|
||||
msgstr "Инициализация PortProton"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Библиотека"
|
||||
|
||||
@@ -480,6 +490,9 @@ msgstr "Назад"
|
||||
msgid "Fullscreen"
|
||||
msgstr "Полный экран"
|
||||
|
||||
msgid "Refresh Grid"
|
||||
msgstr "Обновить"
|
||||
|
||||
msgid "Installation already in progress."
|
||||
msgstr "Установка уже выполняется."
|
||||
|
||||
@@ -499,9 +512,6 @@ msgstr "Установка не удалась."
|
||||
msgid "Installation error."
|
||||
msgstr "Ошибка установки."
|
||||
|
||||
msgid "Refresh Grid"
|
||||
msgstr "Обновить"
|
||||
|
||||
msgid "Game library refreshed"
|
||||
msgstr "Игровая библиотека обновлена"
|
||||
|
||||
@@ -921,9 +931,6 @@ msgstr "Запускать приложение в виртуальном раб
|
||||
msgid "Run the application in a terminal"
|
||||
msgstr "Запускать приложение в терминале"
|
||||
|
||||
msgid "Disable startup mode and WINE version selector window"
|
||||
msgstr "Отключить окно выбора режима запуска и версии WINE"
|
||||
|
||||
msgid "Use system GameMode for performance optimization"
|
||||
msgstr "Использовать системный GameMode для оптимизации производительности"
|
||||
|
||||
@@ -996,68 +1003,40 @@ msgstr "Выбор версии Wine или Proton для использован
|
||||
msgid "Prefix Name"
|
||||
msgstr "Имя префикса"
|
||||
|
||||
msgid "Select the Wine prefix to use."
|
||||
msgstr "Выбор версии Wine для использования."
|
||||
msgid "Specify the Wine prefix to run this game with"
|
||||
msgstr "Укажите префикс Wine для запуска этой игры"
|
||||
|
||||
msgid "Latest"
|
||||
msgstr "Последние"
|
||||
msgid "Newest"
|
||||
msgstr "Новейший"
|
||||
|
||||
msgid "Stable"
|
||||
msgstr "Стабильные"
|
||||
msgstr "Стабильный"
|
||||
|
||||
msgid "Vulkan Backend"
|
||||
msgstr "Vulkan рендеринг"
|
||||
|
||||
msgid ""
|
||||
"Select the rendering backend for translating DirectX → Vulkan/OpenGL:\n"
|
||||
"Select the DirectX → Vulkan/OpenGL backend:\n"
|
||||
"\n"
|
||||
"• Auto – latest DXVK + VKD3D (recommended)\n"
|
||||
" The newest versions from the developers. Give the best compatibility "
|
||||
"and performance in modern games.\n"
|
||||
" Require up-to-date drivers:\n"
|
||||
" – AMD: Mesa 25.0+ or proprietary AMDVLK 2024.Q4+\n"
|
||||
" – NVIDIA: driver 550.54.14 or newer\n"
|
||||
" – Intel: Mesa 24.2+\n"
|
||||
"\n"
|
||||
"• Stable – proven DXVK + VKD3D\n"
|
||||
" Older but extremely well-tested versions. Work on any drivers that "
|
||||
"support Vulkan 1.3+.\n"
|
||||
" The best choice if you have problems with the newest versions.\n"
|
||||
"\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek\n"
|
||||
" Work even on older drivers and video cards that support at least "
|
||||
"Vulkan 1.1.\n"
|
||||
"\n"
|
||||
"• WINED3D – OpenGL translation (fallback)\n"
|
||||
" No DXVK/VKD3D used. DirectX is translated to OpenGL via built-in "
|
||||
"WineD3D.\n"
|
||||
" Works on absolutely any hardware, but performance is significantly "
|
||||
"lower.\n"
|
||||
" Use only as a last resort when nothing else starts."
|
||||
"• Newest – latest DXVK + VKD3D (best compatibility/performance, requires "
|
||||
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||
"• Stable – older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
|
||||
"driver)\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
|
||||
"Vulkan 1.1+)\n"
|
||||
"• WINED3D – OpenGL fallback (lowest performance, use only if others fail)"
|
||||
msgstr ""
|
||||
"Выбор рендеринга для трансляции DirectX → Vulkan/OpenGL:\n"
|
||||
"Выберите бэкэнд DirectX → Vulkan/OpenGL:\n"
|
||||
"\n"
|
||||
"• Авто – последние версии DXVK + VKD3D (рекомендуется)\n"
|
||||
" Новейшие версии от разработчиков. Обеспечивают наилучшую совместимость и"
|
||||
" производительность в современных играх.\n"
|
||||
" Требуются актуальные драйверы:\n"
|
||||
" – AMD: Mesa 25.0+ или проприетарный AMDVLK 2024.Q4+\n"
|
||||
" – NVIDIA: 550.54.14 или новее\n"
|
||||
" – Intel: Mesa 24.2+\n"
|
||||
"\n"
|
||||
"• Стабильный – проверенные версии DXVK + VKD3D\n"
|
||||
" Более старые, но тщательно протестированные версии. Работают с любыми "
|
||||
"драйверами, поддерживающие Vulkan 1.3+.\n"
|
||||
" Лучший выбор, если у вас возникли проблемы с последними версиями.\n"
|
||||
"\n"
|
||||
"• Sarek – экспериментальная версия DXVK-Sarek + VKD3D-Sarek\n"
|
||||
"Работает даже на старых драйверах и видеокартах, поддерживающих как "
|
||||
"минимум Vulkan 1.1.\n"
|
||||
"• WINED3D – трансляция OpenGL (для видеокарт без поддержки Vulkan)\n"
|
||||
"DXVK/VKD3D не используется. DirectX транслируется в OpenGL через "
|
||||
"встроенную WineD3D.Работает абсолютно на любом оборудовании, но "
|
||||
"производительность значительно снижается.Используйте только в крайнем "
|
||||
"случае, когда ничего другое не запускается."
|
||||
"• Новейший — последние версии DXVK + VKD3D (наилучшая "
|
||||
"совместимость/производительность, требует современных драйверов: AMD Mesa"
|
||||
" 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||
"• Стабильный — более старая, хорошо протестированная версия DXVK + VKD3D "
|
||||
"(работает с любыми драйверами Vulkan 1.3+)\n"
|
||||
"• Sarek — экспериментальная версия DXVK-Sarek + VKD3D-Sarek (поддерживает"
|
||||
" более старые драйверы, Vulkan 1.1+)\n"
|
||||
"• WINED3D — резервный вариант OpenGL (наименьшая производительность, "
|
||||
"используйте только в случае сбоя других вариантов)"
|
||||
|
||||
msgid "Windows version"
|
||||
msgstr "Версия Windows"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import gettext
|
||||
import configparser
|
||||
from pathlib import Path
|
||||
import locale
|
||||
import os
|
||||
@@ -102,3 +103,97 @@ def read_metadata_translations(metadata_file, language_code):
|
||||
translations['description'] = line[len('description='):].strip()
|
||||
|
||||
return translations
|
||||
|
||||
def get_screenshot_caption(base_filename, metainfo_file, language_code=None):
|
||||
"""
|
||||
Возвращает перевод названия скриншота на основе языка пользователя.
|
||||
|
||||
Args:
|
||||
base_filename: Имя файла без расширения
|
||||
metainfo_file: Путь к файлу metainfo.ini
|
||||
language_code: Код языка (если None, будет определен автоматически)
|
||||
|
||||
Returns:
|
||||
Переведенное название скриншота
|
||||
"""
|
||||
if language_code is None:
|
||||
system_locale = get_system_locale()
|
||||
language_code = system_locale.split('_')[0] if '_' in system_locale else system_locale
|
||||
|
||||
# Загружаем переводы из metainfo.ini
|
||||
screenshot_translations = {}
|
||||
if metainfo_file and os.path.exists(metainfo_file):
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read(metainfo_file, encoding="utf-8")
|
||||
if "Screenshots" in cp:
|
||||
for key in cp.options("Screenshots"):
|
||||
screenshot_translations[key] = cp.get("Screenshots", key)
|
||||
|
||||
# Ищем перевод в формате: base_filename_languagecode
|
||||
caption = base_filename # По умолчанию используем базовое имя файла
|
||||
|
||||
if screenshot_translations:
|
||||
# Попробуем перевод для конкретного языка (например, "library_ru")
|
||||
lang_specific_key = f"{base_filename}_{language_code}"
|
||||
# Попробуем английский перевод (например, "library_en")
|
||||
english_key = f"{base_filename}_en"
|
||||
|
||||
if lang_specific_key in screenshot_translations:
|
||||
caption = screenshot_translations[lang_specific_key]
|
||||
elif english_key in screenshot_translations:
|
||||
caption = screenshot_translations[english_key]
|
||||
elif base_filename in screenshot_translations:
|
||||
caption = screenshot_translations[base_filename] # fallback to untranslated key
|
||||
|
||||
return caption
|
||||
|
||||
def get_theme_translations(metainfo_file, language_code=None):
|
||||
"""
|
||||
Возвращает переводы названия и описания темы на основе языка пользователя.
|
||||
|
||||
Args:
|
||||
metainfo_file: Путь к файлу metainfo.ini
|
||||
language_code: Код языка (если None, будет определен автоматически)
|
||||
|
||||
Returns:
|
||||
Словарь с полями 'name' и 'description' с переведенными значениями
|
||||
"""
|
||||
if language_code is None:
|
||||
system_locale = get_system_locale()
|
||||
language_code = system_locale.split('_')[0] if '_' in system_locale else system_locale
|
||||
|
||||
# Загружаем переводы из metainfo.ini
|
||||
translations = {'name': '', 'description': ''}
|
||||
|
||||
if metainfo_file and os.path.exists(metainfo_file):
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read(metainfo_file, encoding="utf-8")
|
||||
|
||||
if "Metainfo" in cp:
|
||||
# Попробуем перевод названия для конкретного языка (например, "name_ru")
|
||||
lang_specific_name_key = f"name_{language_code}"
|
||||
# Попробуем английский перевод названия (например, "name_en")
|
||||
english_name_key = "name_en"
|
||||
|
||||
# Ищем перевод названия
|
||||
if cp.has_option("Metainfo", lang_specific_name_key):
|
||||
translations['name'] = cp.get("Metainfo", lang_specific_name_key)
|
||||
elif cp.has_option("Metainfo", english_name_key):
|
||||
translations['name'] = cp.get("Metainfo", english_name_key)
|
||||
elif cp.has_option("Metainfo", "name"):
|
||||
translations['name'] = cp.get("Metainfo", "name")
|
||||
|
||||
# Попробуем перевод описания для конкретного языка (например, "description_ru")
|
||||
lang_specific_desc_key = f"description_{language_code}"
|
||||
# Попробуем английский перевод описания (например, "description_en")
|
||||
english_desc_key = "description_en"
|
||||
|
||||
# Ищем перевод описания
|
||||
if cp.has_option("Metainfo", lang_specific_desc_key):
|
||||
translations['description'] = cp.get("Metainfo", lang_specific_desc_key)
|
||||
elif cp.has_option("Metainfo", english_desc_key):
|
||||
translations['description'] = cp.get("Metainfo", english_desc_key)
|
||||
elif cp.has_option("Metainfo", "description"):
|
||||
translations['description'] = cp.get("Metainfo", "description")
|
||||
|
||||
return translations
|
||||
|
||||
@@ -124,8 +124,12 @@ class PortProtonAPI:
|
||||
)
|
||||
break
|
||||
|
||||
if self._check_file_exists(metadata_url, timeout):
|
||||
local_metadata_path = os.path.join(game_dir, "metadata.txt")
|
||||
# Check if metadata already exists locally before attempting download
|
||||
local_metadata_path = os.path.join(game_dir, "metadata.txt")
|
||||
if os.path.exists(local_metadata_path):
|
||||
logger.debug(f"Metadata already exists locally for {exe_name}: {local_metadata_path}")
|
||||
results["metadata"] = local_metadata_path
|
||||
elif self._check_file_exists(metadata_url, timeout):
|
||||
pending_downloads += 1
|
||||
self.downloader.download_async(
|
||||
metadata_url,
|
||||
@@ -152,9 +156,17 @@ class PortProtonAPI:
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
cover_url = f"{self.base_url}/{exe_name}/cover.png"
|
||||
local_cover_path = os.path.join(user_game_folder, "cover.png")
|
||||
|
||||
# Check if the cover already exists locally before attempting download
|
||||
if os.path.exists(local_cover_path):
|
||||
logger.debug(f"Async autoinstall cover already exists locally for {exe_name}: {local_cover_path}")
|
||||
if callback:
|
||||
callback(local_cover_path)
|
||||
return
|
||||
|
||||
cover_url = f"{self.base_url}/{exe_name}/cover.png"
|
||||
|
||||
def on_cover_downloaded(local_path: str | None):
|
||||
if local_path:
|
||||
logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}")
|
||||
@@ -175,6 +187,102 @@ class PortProtonAPI:
|
||||
if callback:
|
||||
callback(None)
|
||||
|
||||
def download_autoinstall_metadata_async(self, exe_name: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None) -> None:
|
||||
"""Download autoinstall metadata.txt file."""
|
||||
xdg_data_home = os.getenv("XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||||
autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
|
||||
user_game_folder = os.path.join(autoinstall_root, exe_name)
|
||||
|
||||
if not os.path.isdir(user_game_folder):
|
||||
try:
|
||||
os.makedirs(user_game_folder, exist_ok=True)
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
local_metadata_path = os.path.join(user_game_folder, "metadata.txt")
|
||||
|
||||
# Check if the file already exists locally before attempting download
|
||||
if os.path.exists(local_metadata_path):
|
||||
logger.debug(f"Async autoinstall metadata already exists locally for {exe_name}: {local_metadata_path}")
|
||||
if callback:
|
||||
callback(local_metadata_path)
|
||||
return
|
||||
|
||||
metadata_url = f"{self.base_url}/{exe_name}/metadata.txt"
|
||||
|
||||
def on_metadata_downloaded(local_path: str | None):
|
||||
if local_path:
|
||||
logger.info(f"Async autoinstall metadata downloaded for {exe_name}: {local_path}")
|
||||
else:
|
||||
logger.debug(f"No autoinstall metadata downloaded for {exe_name}")
|
||||
if callback:
|
||||
callback(local_path)
|
||||
|
||||
if self._check_file_exists(metadata_url, timeout):
|
||||
self.downloader.download_async(
|
||||
metadata_url,
|
||||
local_metadata_path,
|
||||
timeout=timeout,
|
||||
callback=on_metadata_downloaded
|
||||
)
|
||||
else:
|
||||
logger.debug(f"No autoinstall metadata found for {exe_name}")
|
||||
if callback:
|
||||
callback(None)
|
||||
|
||||
def get_autoinstall_description(self, exe_name: str, lang_code: str = "en") -> str | None:
|
||||
"""Read description from downloaded metadata.txt file for autoinstall game.
|
||||
|
||||
Args:
|
||||
exe_name: The executable name/script name
|
||||
lang_code: Language code ("en" or "ru" for description)
|
||||
|
||||
Returns:
|
||||
Description string or None if not found
|
||||
"""
|
||||
xdg_data_home = os.getenv("XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||||
autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
|
||||
metadata_path = os.path.join(autoinstall_root, exe_name, "metadata.txt")
|
||||
|
||||
if not os.path.exists(metadata_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(metadata_path, encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse the metadata content to extract description
|
||||
# Format: description_en=... or description_ru=...
|
||||
if lang_code == "ru":
|
||||
pattern = r'^description_ru=(.*)$'
|
||||
else:
|
||||
pattern = r'^description_en=(.*)$'
|
||||
|
||||
import re
|
||||
match = re.search(pattern, content, re.MULTILINE)
|
||||
if match:
|
||||
description = match.group(1).strip()
|
||||
# Handle potential quoted strings
|
||||
if description.startswith('"') and description.endswith('"'):
|
||||
description = description[1:-1]
|
||||
return description
|
||||
else:
|
||||
# Try fallback to the other language if the requested one is not found
|
||||
fallback_lang = "ru" if lang_code == "en" else "en"
|
||||
fallback_pattern = rf'^description_{fallback_lang}=(.*)$'
|
||||
fallback_match = re.search(fallback_pattern, content, re.MULTILINE)
|
||||
if fallback_match:
|
||||
description = fallback_match.group(1).strip()
|
||||
if description.startswith('"') and description.endswith('"'):
|
||||
description = description[1:-1]
|
||||
return description
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading metadata for {exe_name}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def parse_autoinstall_script(self, file_path: str) -> tuple[str | None, str | None]:
|
||||
"""Extract display_name from # name comment and exe_name from autoinstall bash script."""
|
||||
try:
|
||||
@@ -254,6 +362,8 @@ class PortProtonAPI:
|
||||
try:
|
||||
mod_time = os.path.getmtime(cache_file)
|
||||
if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION:
|
||||
# Add timeout protection for file operations
|
||||
start_time = time.time()
|
||||
with open(cache_file, "rb") as f:
|
||||
data = orjson.loads(f.read())
|
||||
# Check signature
|
||||
@@ -261,6 +371,10 @@ class PortProtonAPI:
|
||||
current_signature = self._compute_scripts_signature(
|
||||
os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
|
||||
)
|
||||
# Check for timeout during signature computation
|
||||
if time.time() - start_time > 3: # 3 second timeout
|
||||
logger.warning("Cache loading took too long, skipping cache")
|
||||
return None
|
||||
if cached_signature != current_signature:
|
||||
logger.info("Scripts signature mismatch; invalidating cache")
|
||||
return None
|
||||
@@ -287,21 +401,26 @@ class PortProtonAPI:
|
||||
|
||||
def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None:
|
||||
"""Start loading auto-install games in a background thread. Returns the thread for management."""
|
||||
# Check cache first (sync, fast)
|
||||
cached_games = self._load_autoinstall_cache()
|
||||
if cached_games is not None:
|
||||
# Emit via callback immediately if cached
|
||||
QThread.msleep(0) # Yield to Qt event loop
|
||||
callback(cached_games)
|
||||
return None # No thread needed
|
||||
|
||||
# No cache: Start background thread
|
||||
class AutoinstallWorker(QThread):
|
||||
finished = Signal(list)
|
||||
api: "PortProtonAPI"
|
||||
portproton_location: str | None
|
||||
|
||||
def run(self):
|
||||
import time
|
||||
# Check cache in this background thread, not in main thread
|
||||
start_time = time.time()
|
||||
cached_games = self.api._load_autoinstall_cache()
|
||||
# If cache loading took too long (>2 seconds), skip cache and load directly
|
||||
if time.time() - start_time > 2:
|
||||
logger.warning("Cache loading took too long, proceeding without cache")
|
||||
cached_games = None
|
||||
|
||||
if cached_games is not None:
|
||||
self.finished.emit(cached_games)
|
||||
return
|
||||
|
||||
# No cache: Load games from scratch
|
||||
games = []
|
||||
auto_dir = os.path.join(
|
||||
self.portproton_location or "", "data", "scripts", "pw_autoinstall"
|
||||
@@ -351,8 +470,23 @@ class PortProtonAPI:
|
||||
if not cover_path:
|
||||
logger.debug(f"No local cover found for autoinstall {exe_name}")
|
||||
|
||||
# Try to get the description from metadata file
|
||||
description = ""
|
||||
# Look for metadata in the expected location
|
||||
try:
|
||||
import locale
|
||||
current_locale = locale.getlocale()[0] or 'en'
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
current_locale = 'en'
|
||||
lang_code = 'ru' if current_locale and 'ru' in current_locale.lower() else 'en'
|
||||
|
||||
# Try to read description from downloaded metadata
|
||||
metadata_description = self.api.get_autoinstall_description(exe_name, lang_code)
|
||||
if metadata_description:
|
||||
description = metadata_description
|
||||
|
||||
game_tuple = (
|
||||
display_name, "", cover_path, "", f"autoinstall:{script_name}",
|
||||
display_name, description, cover_path, "", f"autoinstall:{script_name}",
|
||||
"", "Never", "0h 0m", "", "", 0, 0, "autoinstall", exe_name
|
||||
)
|
||||
games.append(game_tuple)
|
||||
|
||||
240
portprotonqt/search_utils.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Utility module for search optimizations including Trie, hash tables, and fuzzy matching.
|
||||
"""
|
||||
from typing import Any
|
||||
from rapidfuzz import fuzz
|
||||
from threading import Lock
|
||||
from portprotonqt.logger import get_logger
|
||||
from PySide6.QtCore import QThread, Signal, QObject
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class TrieNode:
|
||||
"""Node in the Trie data structure."""
|
||||
def __init__(self):
|
||||
self.children = {}
|
||||
self.is_end_word = False
|
||||
self.payload = None # Store the original data in leaf nodes
|
||||
|
||||
class Trie:
|
||||
"""Trie data structure for efficient prefix-based searching."""
|
||||
def __init__(self):
|
||||
self.root = TrieNode()
|
||||
self._lock = Lock() # Thread safety for concurrent access
|
||||
|
||||
def insert(self, key: str, payload: Any):
|
||||
"""Insert a key with payload into the Trie."""
|
||||
with self._lock:
|
||||
node = self.root
|
||||
for char in key.lower():
|
||||
if char not in node.children:
|
||||
node.children[char] = TrieNode()
|
||||
node = node.children[char]
|
||||
node.is_end_word = True
|
||||
node.payload = payload
|
||||
|
||||
def search_prefix(self, prefix: str) -> list[tuple[str, Any]]:
|
||||
"""Find all entries with the given prefix."""
|
||||
with self._lock:
|
||||
node = self.root
|
||||
for char in prefix.lower():
|
||||
if char not in node.children:
|
||||
return []
|
||||
node = node.children[char]
|
||||
|
||||
results = []
|
||||
self._collect_all(node, prefix.lower(), results)
|
||||
return results
|
||||
|
||||
def _collect_all(self, node: TrieNode, current_prefix: str, results: list[tuple[str, Any]]):
|
||||
"""Collect all entries from the current node."""
|
||||
if node.is_end_word:
|
||||
results.append((current_prefix, node.payload))
|
||||
|
||||
for char, child_node in node.children.items():
|
||||
self._collect_all(child_node, current_prefix + char, results)
|
||||
|
||||
class FuzzySearchIndex:
|
||||
"""Index for fuzzy string matching with rapidfuzz."""
|
||||
def __init__(self, items: list[tuple[str, Any]] | None = None):
|
||||
self.items: list[tuple[str, Any]] = items or []
|
||||
self.normalized_items: list[tuple[str, Any]] = []
|
||||
self._lock = Lock()
|
||||
self._build_normalized_index()
|
||||
|
||||
def _build_normalized_index(self):
|
||||
"""Build a normalized index for fuzzy matching."""
|
||||
with self._lock:
|
||||
self.normalized_items = [(self._normalize(item[0]), item[1]) for item in self.items]
|
||||
|
||||
def _normalize(self, s: str) -> str:
|
||||
"""Normalize string for fuzzy matching."""
|
||||
s = s.lower()
|
||||
for ch in ["™", "®"]:
|
||||
s = s.replace(ch, "")
|
||||
for ch in ["-", ":", ","]:
|
||||
s = s.replace(ch, " ")
|
||||
s = " ".join(s.split())
|
||||
for suffix in ["bin", "app"]:
|
||||
if s.endswith(suffix):
|
||||
s = s[:-len(suffix)].strip()
|
||||
keywords_to_remove = {"ultimate", "edition", "definitive", "complete", "remastered"}
|
||||
words = s.split()
|
||||
filtered_words = [word for word in words if word not in keywords_to_remove]
|
||||
return " ".join(filtered_words)
|
||||
|
||||
def fuzzy_search(self, query: str, limit: int = 5, min_score: float = 60.0) -> list[tuple[str, Any, float]]:
|
||||
"""Perform fuzzy search using rapidfuzz."""
|
||||
with self._lock:
|
||||
if not query or not self.normalized_items:
|
||||
return []
|
||||
|
||||
query_normalized = self._normalize(query)
|
||||
results = []
|
||||
|
||||
for i, (item_text, item_data) in enumerate(self.normalized_items):
|
||||
score = fuzz.ratio(query_normalized, item_text)
|
||||
if score >= min_score:
|
||||
results.append((self.items[i][0], item_data, score))
|
||||
|
||||
# Sort by score descending
|
||||
results.sort(key=lambda x: x[2], reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
class SearchOptimizer:
|
||||
"""Main search optimization class combining multiple approaches."""
|
||||
def __init__(self):
|
||||
self.hash_index: dict[str, Any] = {}
|
||||
self.trie_index = Trie()
|
||||
self.fuzzy_index = None
|
||||
self._lock = Lock()
|
||||
|
||||
def build_indices(self, items: list[tuple[str, Any]]):
|
||||
"""Build all search indices from items."""
|
||||
with self._lock:
|
||||
self.hash_index = {item[0].lower(): item[1] for item in items}
|
||||
self.trie_index = Trie()
|
||||
for key, value in self.hash_index.items():
|
||||
self.trie_index.insert(key, value)
|
||||
self.fuzzy_index = FuzzySearchIndex(items)
|
||||
|
||||
def exact_search(self, key: str) -> Any | None:
|
||||
"""Perform exact hash-based lookup."""
|
||||
with self._lock:
|
||||
return self.hash_index.get(key.lower())
|
||||
|
||||
def prefix_search(self, prefix: str) -> list[tuple[str, Any]]:
|
||||
"""Perform prefix search using Trie."""
|
||||
with self._lock:
|
||||
return self.trie_index.search_prefix(prefix)
|
||||
|
||||
def fuzzy_search(self, query: str, limit: int = 5, min_score: float = 60.0) -> list[tuple[str, Any, float]]:
|
||||
"""Perform fuzzy search."""
|
||||
if self.fuzzy_index:
|
||||
return self.fuzzy_index.fuzzy_search(query, limit, min_score)
|
||||
return []
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Threaded search implementation using QThread for performance optimization
|
||||
|
||||
|
||||
class ThreadedSearchWorker(QObject):
|
||||
"""
|
||||
A threaded worker for performing search operations without blocking the UI.
|
||||
"""
|
||||
search_started = Signal()
|
||||
search_finished = Signal(list)
|
||||
search_error = Signal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.search_optimizer = SearchOptimizer()
|
||||
self.games_data = []
|
||||
|
||||
def set_games_data(self, games_data: list):
|
||||
"""Set the games data to be searched."""
|
||||
self.games_data = games_data
|
||||
# Build indices from the games data (name, description, etc.)
|
||||
items = [(game[0], game) for game in games_data] # game[0] is the name
|
||||
self.search_optimizer.build_indices(items)
|
||||
|
||||
def execute_search(self, search_text: str, search_type: str = "auto"):
|
||||
"""
|
||||
Execute search in a separate thread.
|
||||
|
||||
Args:
|
||||
search_text: Text to search for
|
||||
search_type: Type of search ("exact", "prefix", "fuzzy", "auto")
|
||||
"""
|
||||
try:
|
||||
self.search_started.emit()
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
results = []
|
||||
|
||||
if search_type == "exact" or (search_type == "auto" and len(search_text) > 2):
|
||||
exact_result = self.search_optimizer.exact_search(search_text)
|
||||
if exact_result:
|
||||
results = [exact_result]
|
||||
elif search_type == "prefix":
|
||||
results = self.search_optimizer.prefix_search(search_text)
|
||||
elif search_type == "fuzzy" or search_type == "auto":
|
||||
results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=50.0)
|
||||
else:
|
||||
# Auto-detect search type based on input
|
||||
if len(search_text) < 3:
|
||||
results = self.search_optimizer.prefix_search(search_text)
|
||||
else:
|
||||
# Try exact first, then fuzzy
|
||||
exact_result = self.search_optimizer.exact_search(search_text)
|
||||
if exact_result:
|
||||
results = [exact_result]
|
||||
else:
|
||||
results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=50.0)
|
||||
|
||||
end_time = time.time()
|
||||
print(f"Search completed in {end_time - start_time:.4f} seconds")
|
||||
|
||||
self.search_finished.emit(results)
|
||||
except Exception as e:
|
||||
self.search_error.emit(str(e))
|
||||
|
||||
|
||||
class ThreadedSearch(QThread):
|
||||
"""
|
||||
QThread implementation for running search operations in the background.
|
||||
"""
|
||||
search_started = Signal()
|
||||
search_finished = Signal(list)
|
||||
search_error = Signal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.worker = ThreadedSearchWorker()
|
||||
self.search_text = ""
|
||||
self.search_type = "auto"
|
||||
self.games_data = []
|
||||
|
||||
# Connect worker signals to thread signals
|
||||
self.worker.search_started.connect(self.search_started)
|
||||
self.worker.search_finished.connect(self.search_finished)
|
||||
self.worker.search_error.connect(self.search_error)
|
||||
|
||||
|
||||
def set_games_data(self, games_data: list):
|
||||
"""Set the games data to be searched."""
|
||||
self.games_data = games_data
|
||||
self.worker.set_games_data(games_data)
|
||||
|
||||
def run(self):
|
||||
"""Run the search operation in the thread."""
|
||||
self.worker.execute_search(self.search_text, self.search_type)
|
||||
@@ -20,7 +20,6 @@ def get_toggle_settings():
|
||||
'PW_HIDE_NVIDIA_GPU': _("Disguise all NVIDIA GPU features"),
|
||||
'PW_VIRTUAL_DESKTOP': _("Run the application in WINE virtual desktop"),
|
||||
'PW_USE_TERMINAL': _("Run the application in a terminal"),
|
||||
'PW_GUI_DISABLED_CS': _("Disable startup mode and WINE version selector window"),
|
||||
'PW_USE_GAMEMODE': _("Use system GameMode for performance optimization"),
|
||||
'PW_USE_D3D_EXTRAS': _("Enable forced use of third-party DirectX libraries"),
|
||||
'PW_FIX_VIDEO_IN_GAME': _("Fix pink-tinted video playback in some games"),
|
||||
@@ -70,7 +69,7 @@ def get_advanced_settings(disabled_text, logical_core_options, locale_options,
|
||||
advanced_settings.append({
|
||||
'key': 'PW_PREFIX_NAME',
|
||||
'name': _("Prefix Name"),
|
||||
'description': _("Select the Wine prefix to use."),
|
||||
'description': _("Specify the Wine prefix to run this game with"),
|
||||
'type': 'combo',
|
||||
'options': prefix_options,
|
||||
'default': 'DEFAULT'
|
||||
@@ -78,7 +77,7 @@ def get_advanced_settings(disabled_text, logical_core_options, locale_options,
|
||||
|
||||
# 3. Vulkan Backend
|
||||
vulkan_options = [
|
||||
_("Latest"), # → 6
|
||||
_("Newest"), # → 6
|
||||
_("Stable"), # → 2
|
||||
("Sarek"), # → 1
|
||||
("WINED3D – OpenGL") # → 0
|
||||
@@ -96,22 +95,11 @@ def get_advanced_settings(disabled_text, logical_core_options, locale_options,
|
||||
'key': 'PW_VULKAN_USE',
|
||||
'name': _("Vulkan Backend"),
|
||||
'description': _(
|
||||
"Select the rendering backend for translating DirectX → Vulkan/OpenGL:\n\n"
|
||||
"• Auto – latest DXVK + VKD3D (recommended)\n"
|
||||
" The newest versions from the developers. Give the best compatibility and performance in modern games.\n"
|
||||
" Require up-to-date drivers:\n"
|
||||
" – AMD: Mesa 25.0+ or proprietary AMDVLK 2024.Q4+\n"
|
||||
" – NVIDIA: driver 550.54.14 or newer\n"
|
||||
" – Intel: Mesa 24.2+\n\n"
|
||||
"• Stable – proven DXVK + VKD3D\n"
|
||||
" Older but extremely well-tested versions. Work on any drivers that support Vulkan 1.3+.\n"
|
||||
" The best choice if you have problems with the newest versions.\n\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek\n"
|
||||
" Work even on older drivers and video cards that support at least Vulkan 1.1.\n\n"
|
||||
"• WINED3D – OpenGL translation (fallback)\n"
|
||||
" No DXVK/VKD3D used. DirectX is translated to OpenGL via built-in WineD3D.\n"
|
||||
" Works on absolutely any hardware, but performance is significantly lower.\n"
|
||||
" Use only as a last resort when nothing else starts."
|
||||
"Select the DirectX → Vulkan/OpenGL backend:\n\n"
|
||||
"• Newest – latest DXVK + VKD3D (best compatibility/performance, requires modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||
"• Stable – older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ driver)\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, Vulkan 1.1+)\n"
|
||||
"• WINED3D – OpenGL fallback (lowest performance, use only if others fail)"
|
||||
),
|
||||
'type': 'combo',
|
||||
'options': vulkan_options,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import functools
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
@@ -262,21 +261,58 @@ def remove_duplicates(candidates):
|
||||
"""
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
# Simple TTL cache for exiftool data with max entries to control memory usage
|
||||
_EXIFTOOL_CACHE = {}
|
||||
_CACHE_MAX_ENTRIES = 64 # Limit cache size to control memory
|
||||
_CACHE_TTL = 300 # 5 minutes TTL
|
||||
|
||||
def get_exiftool_data(game_exe):
|
||||
"""Retrieves metadata using exiftool."""
|
||||
"""Retrieves metadata using exiftool with TTL-based caching."""
|
||||
import time
|
||||
|
||||
current_time = time.time()
|
||||
|
||||
# Clean up expired entries periodically
|
||||
if len(_EXIFTOOL_CACHE) > _CACHE_MAX_ENTRIES // 2: # Clean when half full
|
||||
# Remove expired entries
|
||||
expired_keys = [
|
||||
key for key, (data, timestamp) in _EXIFTOOL_CACHE.items()
|
||||
if current_time - timestamp > _CACHE_TTL
|
||||
]
|
||||
for key in expired_keys:
|
||||
del _EXIFTOOL_CACHE[key]
|
||||
|
||||
# Check cache first
|
||||
if game_exe in _EXIFTOOL_CACHE:
|
||||
data, timestamp = _EXIFTOOL_CACHE[game_exe]
|
||||
if current_time - timestamp <= _CACHE_TTL:
|
||||
return data
|
||||
else:
|
||||
# Entry expired, remove it
|
||||
del _EXIFTOOL_CACHE[game_exe]
|
||||
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["exiftool", "-j", game_exe],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False
|
||||
check=False,
|
||||
timeout=10 # Add timeout to prevent hanging
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}")
|
||||
return {}
|
||||
meta_data_list = orjson.loads(proc.stdout.encode("utf-8"))
|
||||
return meta_data_list[0] if meta_data_list else {}
|
||||
result = meta_data_list[0] if meta_data_list else {}
|
||||
|
||||
# Add to cache if we have a reasonable result
|
||||
if result and len(_EXIFTOOL_CACHE) < _CACHE_MAX_ENTRIES:
|
||||
_EXIFTOOL_CACHE[game_exe] = (result, current_time)
|
||||
|
||||
return result
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"exiftool timed out for {game_exe}")
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in get_exiftool_data for {game_exe}: {e}")
|
||||
return {}
|
||||
@@ -323,6 +359,17 @@ def load_steam_apps_async(callback: Callable[[list], None]):
|
||||
logger.info("Deleted archive: %s", cache_tar)
|
||||
# Delete all cached app detail files (steam_app_*.json)
|
||||
delete_cached_app_files(cache_dir, "steam_app_*.json")
|
||||
|
||||
# Build the new index in the background and atomically update the cache
|
||||
new_index = build_index(data) if isinstance(data, list) else {}
|
||||
current_time = time.time()
|
||||
|
||||
# Atomically update the cache
|
||||
with _STEAM_APPS_LOCK:
|
||||
_STEAM_APPS_CACHE['data'] = data if isinstance(data, list) else []
|
||||
_STEAM_APPS_CACHE['index'] = new_index
|
||||
_STEAM_APPS_CACHE['timestamp'] = current_time
|
||||
|
||||
steam_apps = data if isinstance(data, list) else []
|
||||
logger.info("Loaded %d apps from archive", len(steam_apps))
|
||||
callback(steam_apps)
|
||||
@@ -373,25 +420,31 @@ def build_index(steam_apps):
|
||||
return steam_apps_index
|
||||
logger.info("Building Steam apps index")
|
||||
for app in steam_apps:
|
||||
normalized = app["normalized_name"]
|
||||
steam_apps_index[normalized] = app
|
||||
normalized = app.get("normalized_name", "")
|
||||
if normalized: # Only add if normalized_name exists
|
||||
steam_apps_index[normalized] = app
|
||||
return steam_apps_index
|
||||
|
||||
def search_app(candidate, steam_apps_index):
|
||||
"""
|
||||
Searches for an application by candidate: tries exact match first, then substring match.
|
||||
Searches for an application by candidate: tries exact match first, then partial match.
|
||||
"""
|
||||
candidate_norm = normalize_name(candidate)
|
||||
logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm)
|
||||
|
||||
# Exact match first (O(1) lookup)
|
||||
if candidate_norm in steam_apps_index:
|
||||
logger.info("Found exact match: '%s'", candidate_norm)
|
||||
return steam_apps_index[candidate_norm]
|
||||
|
||||
# If no exact match, try partial matching
|
||||
for name_norm, app in steam_apps_index.items():
|
||||
if candidate_norm in name_norm:
|
||||
ratio = len(candidate_norm) / len(name_norm)
|
||||
if ratio > 0.8:
|
||||
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f)", candidate_norm, name_norm, ratio)
|
||||
return app
|
||||
|
||||
logger.info("No app found for candidate '%s'", candidate_norm)
|
||||
return None
|
||||
|
||||
@@ -420,7 +473,7 @@ def fetch_sgdb_cover(game_name: str) -> str:
|
||||
try:
|
||||
encoded = urllib.parse.quote(game_name)
|
||||
url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
|
||||
resp = requests.get(url, timeout=5)
|
||||
resp = requests.get(url, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code)
|
||||
return ""
|
||||
@@ -431,17 +484,30 @@ def fetch_sgdb_cover(game_name: str) -> str:
|
||||
if text:
|
||||
logger.info("Fetched SGDB cover for %s: %s", game_name, text)
|
||||
return text
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"SGDB request timed out for {game_name}")
|
||||
return ""
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"SGDB request error for {game_name}: {e}")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch SGDB cover for %s: %s", game_name, e)
|
||||
return ""
|
||||
logger.warning(f"Unexpected error while fetching SGDB cover for {game_name}: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
def check_url_exists(url: str) -> bool:
|
||||
"""Check whether a URL returns HTTP 200."""
|
||||
try:
|
||||
r = requests.head(url, timeout=3)
|
||||
r = requests.head(url, timeout=5)
|
||||
return r.status_code == 200
|
||||
except Exception:
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"URL check timed out for: {url}")
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"Request error when checking URL {url}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Unexpected error when checking URL {url}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -518,6 +584,16 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
|
||||
if os.path.exists(cache_tar):
|
||||
os.remove(cache_tar)
|
||||
logger.info("Deleted archive: %s", cache_tar)
|
||||
# Build the new index in the background and atomically update the cache
|
||||
new_index = build_weanticheatyet_index(data) if isinstance(data, list) else {}
|
||||
current_time = time.time()
|
||||
|
||||
# Atomically update the cache
|
||||
with _ANTICHEAT_LOCK:
|
||||
_ANTICHEAT_CACHE['data'] = data if isinstance(data, list) else []
|
||||
_ANTICHEAT_CACHE['index'] = new_index
|
||||
_ANTICHEAT_CACHE['timestamp'] = current_time
|
||||
|
||||
anti_cheat_data = data or []
|
||||
logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data))
|
||||
callback(anti_cheat_data)
|
||||
@@ -564,17 +640,25 @@ def build_weanticheatyet_index(anti_cheat_data):
|
||||
return anti_cheat_index
|
||||
logger.info("Building WeAntiCheatYet data index")
|
||||
for entry in anti_cheat_data:
|
||||
normalized = entry["normalized_name"]
|
||||
anti_cheat_index[normalized] = entry
|
||||
normalized = entry.get("normalized_name", "")
|
||||
if normalized: # Only add if normalized_name exists
|
||||
anti_cheat_index[normalized] = entry
|
||||
return anti_cheat_index
|
||||
|
||||
def search_anticheat_status(candidate, anti_cheat_index):
|
||||
"""
|
||||
Searches for anti-cheat status by candidate: tries exact match first, then partial match.
|
||||
"""
|
||||
candidate_norm = normalize_name(candidate)
|
||||
logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm)
|
||||
|
||||
# Exact match first (O(1) lookup)
|
||||
if candidate_norm in anti_cheat_index:
|
||||
status = anti_cheat_index[candidate_norm]["status"]
|
||||
logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status)
|
||||
return status
|
||||
|
||||
# If no exact match, try partial matching
|
||||
for name_norm, entry in anti_cheat_index.items():
|
||||
if candidate_norm in name_norm:
|
||||
ratio = len(candidate_norm) / len(name_norm)
|
||||
@@ -582,20 +666,122 @@ def search_anticheat_status(candidate, anti_cheat_index):
|
||||
status = entry["status"]
|
||||
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%s'", candidate_norm, name_norm, ratio, status)
|
||||
return status
|
||||
|
||||
logger.info("No anti-cheat status found for candidate '%s'", candidate_norm)
|
||||
return ""
|
||||
|
||||
# Cache for WeAntiCheatYet data with timestamp for expiration
|
||||
_ANTICHEAT_CACHE = {
|
||||
'data': None,
|
||||
'index': None,
|
||||
'timestamp': 0
|
||||
}
|
||||
_ANTICHEAT_LOCK = threading.RLock() # Use RLock to allow reentrant calls
|
||||
|
||||
# Use a class to track loading state instead of dynamic function attributes
|
||||
class AntiCheatDataLoader:
|
||||
def __init__(self):
|
||||
self._loading = False
|
||||
self._pending_callbacks = []
|
||||
|
||||
def get_anticheat_data_and_index_async(self, callback: Callable[[tuple[list | None, dict | None]], None]):
|
||||
"""
|
||||
Asynchronously loads and caches anti-cheat data and their index.
|
||||
Calls the callback with (anti_cheat_data, anti_cheat_index).
|
||||
Implements proper cache expiration and thread safety with single index building.
|
||||
"""
|
||||
cache_duration = CACHE_DURATION
|
||||
current_time = time.time()
|
||||
|
||||
with _ANTICHEAT_LOCK:
|
||||
# Check if we have valid cached data
|
||||
if (_ANTICHEAT_CACHE['data'] is not None and
|
||||
_ANTICHEAT_CACHE['index'] is not None and
|
||||
current_time - _ANTICHEAT_CACHE['timestamp'] < cache_duration):
|
||||
callback((_ANTICHEAT_CACHE['data'], _ANTICHEAT_CACHE['index']))
|
||||
return
|
||||
|
||||
# Check if there's already a loading operation in progress
|
||||
if self._loading:
|
||||
# Add this callback to the pending list to be called when loading completes
|
||||
self._pending_callbacks.append(callback)
|
||||
return
|
||||
|
||||
# Mark that loading is in progress
|
||||
self._loading = True
|
||||
self._pending_callbacks = []
|
||||
|
||||
def on_anticheat_data(anti_cheat_data: list):
|
||||
current_time = time.time()
|
||||
with _ANTICHEAT_LOCK:
|
||||
# Only update cache if data is valid
|
||||
if anti_cheat_data:
|
||||
_ANTICHEAT_CACHE['data'] = anti_cheat_data
|
||||
_ANTICHEAT_CACHE['index'] = build_weanticheatyet_index(anti_cheat_data)
|
||||
_ANTICHEAT_CACHE['timestamp'] = current_time
|
||||
cached_data = (_ANTICHEAT_CACHE['data'], _ANTICHEAT_CACHE['index'])
|
||||
else:
|
||||
# If loading failed, clear the cache to force reload on next attempt
|
||||
_ANTICHEAT_CACHE['data'] = None
|
||||
_ANTICHEAT_CACHE['index'] = None
|
||||
_ANTICHEAT_CACHE['timestamp'] = 0
|
||||
cached_data = (None, None)
|
||||
|
||||
# Mark loading as complete
|
||||
self._loading = False
|
||||
pending_callbacks = self._pending_callbacks
|
||||
self._pending_callbacks = []
|
||||
|
||||
# Call the original callback
|
||||
callback(cached_data)
|
||||
# Call any pending callbacks that accumulated during loading
|
||||
for pending_callback in pending_callbacks:
|
||||
pending_callback(cached_data)
|
||||
|
||||
load_weanticheatyet_data_async(on_anticheat_data)
|
||||
|
||||
# Create a global instance for the anti-cheat data loader
|
||||
_anticheat_loader = AntiCheatDataLoader()
|
||||
|
||||
def get_anticheat_data_and_index_async(callback: Callable[[tuple[list | None, dict | None]], None]):
|
||||
"""
|
||||
Asynchronously loads and caches anti-cheat data and their index.
|
||||
Calls the callback with (anti_cheat_data, anti_cheat_index).
|
||||
Implements proper cache expiration and thread safety with single index building.
|
||||
"""
|
||||
_anticheat_loader.get_anticheat_data_and_index_async(callback)
|
||||
|
||||
def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]):
|
||||
"""
|
||||
Asynchronously retrieves WeAntiCheatYet status for a game by name.
|
||||
Calls the callback with the status string or empty string if not found.
|
||||
"""
|
||||
def on_anticheat_data(anti_cheat_data: list):
|
||||
anti_cheat_index = build_weanticheatyet_index(anti_cheat_data)
|
||||
status = search_anticheat_status(game_name, anti_cheat_index)
|
||||
def on_anticheat_data_and_index(data_and_index: tuple[list | None, dict | None]):
|
||||
anti_cheat_data, anti_cheat_index = data_and_index
|
||||
if anti_cheat_data and anti_cheat_index:
|
||||
status = search_anticheat_status(game_name, anti_cheat_index)
|
||||
else:
|
||||
status = ""
|
||||
callback(status)
|
||||
|
||||
load_weanticheatyet_data_async(on_anticheat_data)
|
||||
get_anticheat_data_and_index_async(on_anticheat_data_and_index)
|
||||
|
||||
def clear_steam_api_caches():
|
||||
"""Clears all cached data to force reload from files."""
|
||||
global _STEAM_APPS_CACHE, _ANTICHEAT_CACHE
|
||||
with _STEAM_APPS_LOCK:
|
||||
_STEAM_APPS_CACHE = {
|
||||
'data': None,
|
||||
'index': None,
|
||||
'timestamp': 0
|
||||
}
|
||||
with _ANTICHEAT_LOCK:
|
||||
_ANTICHEAT_CACHE = {
|
||||
'data': None,
|
||||
'index': None,
|
||||
'timestamp': 0
|
||||
}
|
||||
logger.info("Cleared Steam API caches")
|
||||
|
||||
def load_protondb_status(appid):
|
||||
"""Loads cached ProtonDB data for a game by appid if not outdated."""
|
||||
@@ -747,9 +933,30 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
|
||||
candidates_ordered = sorted(candidates, key=lambda s: len(s.split()), reverse=True)
|
||||
logger.info("Sorted candidates: %s", candidates_ordered)
|
||||
|
||||
def on_steam_apps(steam_apps: list):
|
||||
steam_apps_index = build_index(steam_apps)
|
||||
def on_steam_apps_and_index(data_and_index: tuple[list | None, dict | None]):
|
||||
steam_apps, steam_apps_index = data_and_index
|
||||
matching_app = None
|
||||
if not steam_apps or not steam_apps_index:
|
||||
# Handle case where data loading failed
|
||||
game_name = desktop_name or exe_name
|
||||
cover = fetch_sgdb_cover(game_name) or ""
|
||||
logger.info("Using SGDB cover for non-Steam game due to data loading failure: %s", game_name)
|
||||
|
||||
def on_anticheat_status(anticheat_status: str):
|
||||
callback({
|
||||
"appid": "",
|
||||
"name": decode_text(game_name),
|
||||
"description": "",
|
||||
"cover": cover,
|
||||
"controller_support": "",
|
||||
"protondb_tier": "",
|
||||
"steam_game": "false",
|
||||
"anticheat_status": anticheat_status
|
||||
})
|
||||
|
||||
get_weanticheatyet_status_async(game_name, on_anticheat_status)
|
||||
return
|
||||
|
||||
for candidate in candidates_ordered:
|
||||
if not candidate:
|
||||
continue
|
||||
@@ -826,31 +1033,88 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
|
||||
|
||||
fetch_app_info_async(appid, on_app_info)
|
||||
|
||||
load_steam_apps_async(on_steam_apps)
|
||||
get_steam_apps_and_index_async(on_steam_apps_and_index)
|
||||
|
||||
_STEAM_APPS = None
|
||||
_STEAM_APPS_INDEX = None
|
||||
_STEAM_APPS_LOCK = threading.Lock()
|
||||
# Cache for Steam apps data with timestamp for expiration
|
||||
_STEAM_APPS_CACHE = {
|
||||
'data': None,
|
||||
'index': None,
|
||||
'timestamp': 0
|
||||
}
|
||||
_STEAM_APPS_LOCK = threading.RLock() # Use RLock to allow reentrant calls
|
||||
|
||||
def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None]):
|
||||
# Use a class to track loading state instead of dynamic function attributes
|
||||
class SteamAppsLoader:
|
||||
def __init__(self):
|
||||
self._loading = False
|
||||
self._pending_callbacks = []
|
||||
|
||||
def get_steam_apps_and_index_async(self, callback: Callable[[tuple[list | None, dict | None]], None]):
|
||||
"""
|
||||
Asynchronously loads and caches Steam apps and their index.
|
||||
Calls the callback with (steam_apps, steam_apps_index).
|
||||
Implements proper cache expiration and thread safety with single index building.
|
||||
"""
|
||||
cache_duration = CACHE_DURATION
|
||||
current_time = time.time()
|
||||
|
||||
with _STEAM_APPS_LOCK:
|
||||
# Check if we have valid cached data
|
||||
if (_STEAM_APPS_CACHE['data'] is not None and
|
||||
_STEAM_APPS_CACHE['index'] is not None and
|
||||
current_time - _STEAM_APPS_CACHE['timestamp'] < cache_duration):
|
||||
callback((_STEAM_APPS_CACHE['data'], _STEAM_APPS_CACHE['index']))
|
||||
return
|
||||
|
||||
# Check if there's already a loading operation in progress
|
||||
if self._loading:
|
||||
# Add this callback to the pending list to be called when loading completes
|
||||
self._pending_callbacks.append(callback)
|
||||
return
|
||||
|
||||
# Mark that loading is in progress
|
||||
self._loading = True
|
||||
self._pending_callbacks = []
|
||||
|
||||
def on_steam_apps(steam_apps: list):
|
||||
current_time = time.time()
|
||||
with _STEAM_APPS_LOCK:
|
||||
# Only update cache if data is valid
|
||||
if steam_apps:
|
||||
_STEAM_APPS_CACHE['data'] = steam_apps
|
||||
_STEAM_APPS_CACHE['index'] = build_index(steam_apps)
|
||||
_STEAM_APPS_CACHE['timestamp'] = current_time
|
||||
cached_data = (_STEAM_APPS_CACHE['data'], _STEAM_APPS_CACHE['index'])
|
||||
else:
|
||||
# If loading failed, clear the cache to force reload on next attempt
|
||||
_STEAM_APPS_CACHE['data'] = None
|
||||
_STEAM_APPS_CACHE['index'] = None
|
||||
_STEAM_APPS_CACHE['timestamp'] = 0
|
||||
cached_data = (None, None)
|
||||
|
||||
# Mark loading as complete
|
||||
self._loading = False
|
||||
pending_callbacks = self._pending_callbacks
|
||||
self._pending_callbacks = []
|
||||
|
||||
# Call the original callback
|
||||
callback(cached_data)
|
||||
# Call any pending callbacks that accumulated during loading
|
||||
for pending_callback in pending_callbacks:
|
||||
pending_callback(cached_data)
|
||||
|
||||
load_steam_apps_async(on_steam_apps)
|
||||
|
||||
# Create a global instance for the Steam apps loader
|
||||
_steam_apps_loader = SteamAppsLoader()
|
||||
|
||||
def get_steam_apps_and_index_async(callback: Callable[[tuple[list | None, dict | None]], None]):
|
||||
"""
|
||||
Asynchronously loads and caches Steam apps and their index.
|
||||
Calls the callback with (steam_apps, steam_apps_index).
|
||||
Implements proper cache expiration and thread safety with single index building.
|
||||
"""
|
||||
global _STEAM_APPS, _STEAM_APPS_INDEX
|
||||
with _STEAM_APPS_LOCK:
|
||||
if _STEAM_APPS is not None and _STEAM_APPS_INDEX is not None:
|
||||
callback((_STEAM_APPS, _STEAM_APPS_INDEX))
|
||||
return
|
||||
|
||||
def on_steam_apps(steam_apps: list):
|
||||
global _STEAM_APPS, _STEAM_APPS_INDEX
|
||||
with _STEAM_APPS_LOCK:
|
||||
_STEAM_APPS = steam_apps
|
||||
_STEAM_APPS_INDEX = build_index(steam_apps)
|
||||
callback((_STEAM_APPS, _STEAM_APPS_INDEX))
|
||||
|
||||
load_steam_apps_async(on_steam_apps)
|
||||
_steam_apps_loader.get_steam_apps_and_index_async(callback)
|
||||
|
||||
def enable_steam_cef() -> tuple[bool, str]:
|
||||
"""
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import ast
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_security import check_theme_safety, is_safe_image_file
|
||||
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
|
||||
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
|
||||
from portprotonqt.localization import get_screenshot_caption
|
||||
|
||||
# Icon caching for performance optimization
|
||||
_icon_cache = {}
|
||||
@@ -18,57 +19,6 @@ THEMES_DIRS = [
|
||||
]
|
||||
_loaded_theme = None
|
||||
|
||||
# Запрещенные модули и функции
|
||||
FORBIDDEN_MODULES = {
|
||||
"os",
|
||||
"subprocess",
|
||||
"shutil",
|
||||
"sys",
|
||||
"socket",
|
||||
"ctypes",
|
||||
"pathlib",
|
||||
"glob",
|
||||
}
|
||||
FORBIDDEN_FUNCTIONS = {
|
||||
"exec",
|
||||
"eval",
|
||||
"open",
|
||||
"__import__",
|
||||
}
|
||||
|
||||
def check_theme_safety(theme_file: str) -> bool:
|
||||
"""
|
||||
Проверяет файл темы на наличие запрещённых модулей и функций.
|
||||
Возвращает True, если файл безопасен, иначе False.
|
||||
"""
|
||||
has_errors = False
|
||||
try:
|
||||
with open(theme_file) as f:
|
||||
content = f.read()
|
||||
|
||||
# Проверка на опасные импорты и функции
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
for node in ast.walk(tree):
|
||||
# Проверка импортов
|
||||
if isinstance(node, ast.Import | ast.ImportFrom):
|
||||
for name in node.names:
|
||||
if name.name in FORBIDDEN_MODULES:
|
||||
logger.error(f"Forbidden module '{name.name}' found in file {theme_file}")
|
||||
has_errors = True
|
||||
# Проверка вызовов функций
|
||||
if isinstance(node, ast.Call):
|
||||
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
|
||||
logger.error(f"Forbidden function '{node.func.id}' found in file {theme_file}")
|
||||
has_errors = True
|
||||
except SyntaxError as e:
|
||||
logger.error(f"Syntax error in file {theme_file}: {e}")
|
||||
has_errors = True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check theme safety for {theme_file}: {e}")
|
||||
has_errors = True
|
||||
|
||||
return not has_errors
|
||||
|
||||
def list_themes():
|
||||
"""
|
||||
@@ -86,20 +36,36 @@ def list_themes():
|
||||
def load_theme_screenshots(theme_name):
|
||||
"""
|
||||
Загружает все скриншоты из папки "screenshots", расположенной в папке темы.
|
||||
Возвращает список кортежей (pixmap, filename).
|
||||
Возвращает список кортежей (pixmap, caption), где caption - это перевод названия скриншота.
|
||||
Если папка отсутствует или пуста, возвращается пустой список.
|
||||
"""
|
||||
screenshots = []
|
||||
|
||||
# Find the metainfo file for the theme
|
||||
metainfo_file = None
|
||||
for themes_dir in THEMES_DIRS:
|
||||
theme_folder = os.path.join(themes_dir, theme_name)
|
||||
temp_metainfo_file = os.path.join(theme_folder, "metainfo.ini")
|
||||
if os.path.exists(temp_metainfo_file):
|
||||
metainfo_file = temp_metainfo_file
|
||||
break
|
||||
|
||||
for themes_dir in THEMES_DIRS:
|
||||
theme_folder = os.path.join(themes_dir, theme_name)
|
||||
screenshots_folder = os.path.join(theme_folder, "images", "screenshots")
|
||||
if os.path.exists(screenshots_folder) and os.path.isdir(screenshots_folder):
|
||||
for file in os.listdir(screenshots_folder):
|
||||
screenshot_path = os.path.join(screenshots_folder, file)
|
||||
if os.path.isfile(screenshot_path):
|
||||
if os.path.isfile(screenshot_path) and is_safe_image_file(screenshot_path):
|
||||
pixmap = QPixmap(screenshot_path)
|
||||
if not pixmap.isNull():
|
||||
screenshots.append((pixmap, file))
|
||||
# Get the base filename without extension
|
||||
base_filename = os.path.splitext(file)[0]
|
||||
|
||||
# Get translated caption using localization function
|
||||
caption = get_screenshot_caption(base_filename, metainfo_file)
|
||||
|
||||
screenshots.append((pixmap, caption))
|
||||
return screenshots
|
||||
|
||||
def load_theme_fonts(theme_name):
|
||||
@@ -111,34 +77,65 @@ def load_theme_fonts(theme_name):
|
||||
logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
|
||||
return
|
||||
|
||||
QFontDatabase.removeAllApplicationFonts()
|
||||
fonts_folder = None
|
||||
if theme_name == "standart":
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
fonts_folder = os.path.join(base_dir, "themes", "standart", "fonts")
|
||||
else:
|
||||
for themes_dir in THEMES_DIRS:
|
||||
theme_folder = os.path.join(themes_dir, theme_name)
|
||||
possible_fonts_folder = os.path.join(theme_folder, "fonts")
|
||||
if os.path.exists(possible_fonts_folder):
|
||||
fonts_folder = possible_fonts_folder
|
||||
break
|
||||
def load_fonts_delayed():
|
||||
global _loaded_theme
|
||||
try:
|
||||
# Only remove fonts if this is a theme change (not initial load)
|
||||
current_loaded_theme = _loaded_theme # Capture the current value
|
||||
if current_loaded_theme is not None and current_loaded_theme != theme_name:
|
||||
# Run font removal in the GUI thread with delay
|
||||
QFontDatabase.removeAllApplicationFonts()
|
||||
|
||||
if not fonts_folder or not os.path.exists(fonts_folder):
|
||||
logger.error(f"Fonts folder not found for theme '{theme_name}'")
|
||||
return
|
||||
import time
|
||||
import os
|
||||
start_time = time.time()
|
||||
timeout = 3 # Reduced timeout to 3 seconds for faster loading
|
||||
|
||||
for filename in os.listdir(fonts_folder):
|
||||
if filename.lower().endswith((".ttf", ".otf")):
|
||||
font_path = os.path.join(fonts_folder, filename)
|
||||
font_id = QFontDatabase.addApplicationFont(font_path)
|
||||
if font_id != -1:
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
logger.info(f"Font {filename} successfully loaded: {families}")
|
||||
fonts_folder = None
|
||||
if theme_name == "standart":
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
fonts_folder = os.path.join(base_dir, "themes", "standart", "fonts")
|
||||
else:
|
||||
logger.error(f"Error loading font: {filename}")
|
||||
for themes_dir in THEMES_DIRS:
|
||||
theme_folder = os.path.join(themes_dir, theme_name)
|
||||
possible_fonts_folder = os.path.join(theme_folder, "fonts")
|
||||
if os.path.exists(possible_fonts_folder):
|
||||
fonts_folder = possible_fonts_folder
|
||||
break
|
||||
|
||||
_loaded_theme = theme_name
|
||||
if not fonts_folder or not os.path.exists(fonts_folder):
|
||||
logger.error(f"Fonts folder not found for theme '{theme_name}'")
|
||||
return
|
||||
|
||||
font_files = []
|
||||
for filename in os.listdir(fonts_folder):
|
||||
if filename.lower().endswith((".ttf", ".otf")):
|
||||
font_files.append(filename)
|
||||
|
||||
# Limit number of fonts loaded to prevent too much blocking
|
||||
font_files = font_files[:10] # Only load first 10 fonts to prevent too much blocking
|
||||
|
||||
for filename in font_files:
|
||||
if time.time() - start_time > timeout:
|
||||
logger.warning(f"Font loading timed out for theme '{theme_name}' after loading {len(font_files)} fonts")
|
||||
break
|
||||
|
||||
font_path = os.path.join(fonts_folder, filename)
|
||||
font_id = QFontDatabase.addApplicationFont(font_path)
|
||||
if font_id != -1:
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
logger.info(f"Font {filename} successfully loaded: {families}")
|
||||
else:
|
||||
logger.error(f"Error loading font: {filename}")
|
||||
|
||||
# Update the global variable in the main thread
|
||||
_loaded_theme = theme_name
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading fonts for theme '{theme_name}': {e}")
|
||||
|
||||
# Use QTimer to delay font loading until after the UI is rendered
|
||||
from PySide6.QtCore import QTimer
|
||||
QTimer.singleShot(100, load_fonts_delayed) # Delay font loading by 100ms
|
||||
|
||||
class ThemeWrapper:
|
||||
"""
|
||||
@@ -257,14 +254,14 @@ class ThemeManager:
|
||||
# Если передано имя с расширением, проверяем только этот файл
|
||||
if has_extension:
|
||||
candidate = os.path.join(icons_folder, str(base_name))
|
||||
if os.path.exists(candidate):
|
||||
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||
icon_path = candidate
|
||||
break
|
||||
else:
|
||||
# Проверяем все поддерживаемые расширения
|
||||
for ext in supported_extensions:
|
||||
candidate = os.path.join(icons_folder, str(base_name) + str(ext))
|
||||
if os.path.exists(candidate):
|
||||
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||
icon_path = candidate
|
||||
break
|
||||
if icon_path:
|
||||
@@ -278,12 +275,12 @@ class ThemeManager:
|
||||
# Аналогично проверяем в стандартной теме
|
||||
if has_extension:
|
||||
icon_path = os.path.join(standard_icons_folder, base_name)
|
||||
if not os.path.exists(icon_path):
|
||||
if not os.path.exists(icon_path) or not is_safe_image_file(icon_path):
|
||||
icon_path = None
|
||||
else:
|
||||
for ext in supported_extensions:
|
||||
candidate = os.path.join(standard_icons_folder, base_name + ext)
|
||||
if os.path.exists(candidate):
|
||||
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||
icon_path = candidate
|
||||
break
|
||||
|
||||
@@ -326,13 +323,13 @@ class ThemeManager:
|
||||
|
||||
if has_extension:
|
||||
candidate = os.path.join(images_folder, str(base_name))
|
||||
if os.path.exists(candidate):
|
||||
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||
image_path = candidate
|
||||
break
|
||||
else:
|
||||
for ext in supported_extensions:
|
||||
candidate = os.path.join(images_folder, str(base_name) + str(ext))
|
||||
if os.path.exists(candidate):
|
||||
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||
image_path = candidate
|
||||
break
|
||||
if image_path:
|
||||
@@ -345,12 +342,12 @@ class ThemeManager:
|
||||
|
||||
if has_extension:
|
||||
image_path = os.path.join(standard_images_folder, base_name)
|
||||
if not os.path.exists(image_path):
|
||||
if not os.path.exists(image_path) or not is_safe_image_file(image_path):
|
||||
image_path = None
|
||||
else:
|
||||
for ext in supported_extensions:
|
||||
candidate = os.path.join(standard_images_folder, base_name + ext)
|
||||
if os.path.exists(candidate):
|
||||
if os.path.exists(candidate) and is_safe_image_file(candidate):
|
||||
image_path = candidate
|
||||
break
|
||||
|
||||
|
||||
444
portprotonqt/theme_security.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""
|
||||
Theme security module for PortProtonQt.
|
||||
Provides enhanced security checks for theme files to prevent malicious code execution.
|
||||
"""
|
||||
import ast
|
||||
import os
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ThemeSecurityChecker:
|
||||
"""
|
||||
Enhanced security checker for theme files.
|
||||
Identifies and blocks various attack vectors in theme Python files.
|
||||
"""
|
||||
|
||||
# Basic forbidden modules that could allow dangerous operations
|
||||
FORBIDDEN_MODULES = {
|
||||
# File system operations
|
||||
"os", "shutil", "pathlib", "glob", "tempfile", "filecmp", "fileinput",
|
||||
"linecache", "io", "mmap", "fnmatch", "difflib",
|
||||
|
||||
# Process and system operations
|
||||
"subprocess", "sys", "ctypes", "cffi", "platform", "resource", "signal",
|
||||
"multiprocessing", "concurrent", "threading", "asyncio", "select",
|
||||
"selectors", "queue", "sched", "contextvars",
|
||||
|
||||
# Network operations
|
||||
"socket", "urllib", "urllib2", "urllib.request", "urllib.parse",
|
||||
"urllib.error", "urllib.robotparser", "http", "http.client",
|
||||
"http.cookies", "http.cookiejar", "ftplib", "telnetlib", "smtplib",
|
||||
"poplib", "imaplib", "nntplib", "socketserver", "xmlrpc", "xmlrpc.client",
|
||||
"xmlrpc.server", "ipaddress", "webbrowser", "ssl", "uuid",
|
||||
|
||||
# Code execution and dynamic imports
|
||||
"code", "codeop", "compileall", "py_compile", "runpy", "zipimport",
|
||||
"pkgutil", "pkg_resources", "importlib", "importlib.util",
|
||||
"importlib.import_module", "importlib.resources", "importlib.metadata",
|
||||
"builtins", "exec", "eval", "__import__", "compile", "execfile",
|
||||
"imp", "importlib.machinery", "importlib.abc", "importlib.load_module",
|
||||
"importlib.reload", "imp.load_source", "imp.load_compiled", "imp.find_module",
|
||||
"imp.get_suffixes", "imp.init_builtin", "imp.init_frozen", "imp.is_builtin",
|
||||
"imp.is_frozen", "imp.lock_held", "imp.lock", "imp.reload", "imp.load_module",
|
||||
|
||||
# Data serialization and code execution
|
||||
"pickle", "marshal", "shelve", "json", "yaml", "configparser", "binascii", "base64",
|
||||
|
||||
# Databases and storage
|
||||
"sqlite3", "dbapi2", "sqlite_web", "dataset", "records", "tinydb",
|
||||
|
||||
# Cryptography and security
|
||||
"hashlib", "hmac", "secrets", "crypt", "cryptography",
|
||||
|
||||
# External libraries that could be dangerous
|
||||
"requests", "aiohttp", "selenium", "paramiko", "fabric", "docker",
|
||||
"boto", "boto3", "pymongo", "pymysql", "psycopg2", "redis", "pika",
|
||||
"kafka", "celery", "rq", "playwright", "mechanize", "scrapy",
|
||||
"beautifulsoup4", "lxml", "html5lib", "pyautogui",
|
||||
"keyboard", "mouse", "pynput", "psutil", "wmi", "pywin32",
|
||||
|
||||
# GUI and UI libraries that could be used for malicious purposes
|
||||
"tkinter", "PyQt4", "PyQt5", "PyQt6", "PySide", "PySide2", "PySide6",
|
||||
"kivy", "kivymd", "wx", "wxPython", "pygame", "flask", "django",
|
||||
"fastapi", "tornado", "bottle", "cherrypy", "falcon", "sanic",
|
||||
|
||||
|
||||
}
|
||||
|
||||
# Forbidden functions that could allow dangerous operations
|
||||
FORBIDDEN_FUNCTIONS = {
|
||||
# Code execution
|
||||
"exec", "eval", "compile", "execfile", "__import__",
|
||||
|
||||
# Import-related functions that allow dynamic imports
|
||||
"importlib.import_module", "importlib.util", "importlib.resources",
|
||||
"importlib.metadata", "builtins.__import__", "builtins.eval",
|
||||
"builtins.exec", "builtins.compile", "builtins.open",
|
||||
|
||||
# File system operations
|
||||
"open", "file", "os.open", "os.fdopen", "io.open", "tempfile.mktemp",
|
||||
"tempfile.mkdtemp", "tempfile.NamedTemporaryFile", "tempfile.SpooledTemporaryFile",
|
||||
|
||||
# System operations
|
||||
"os.system", "os.popen", "os.spawnl", "os.spawnle", "os.spawnlp",
|
||||
"os.spawnlpe", "os.spawnv", "os.spawnve", "os.spawnvp", "os.spawnvpe",
|
||||
"os.startfile", "os.execv", "os.execve", "os.execl", "os.execle", "os.execlp",
|
||||
"os.execlpe", "subprocess.run", "subprocess.call",
|
||||
"subprocess.check_call", "subprocess.check_output", "subprocess.Popen",
|
||||
|
||||
# Network operations
|
||||
"socket.socket", "socket.create_connection", "urllib.request.urlopen",
|
||||
"urllib.request.Request", "requests.get", "requests.post", "requests.put",
|
||||
"requests.delete", "requests.patch", "requests.head", "requests.options",
|
||||
"aiohttp.ClientSession", "http.client.HTTPConnection", "http.client.HTTPSConnection",
|
||||
|
||||
# Reflection and introspection that could be dangerous
|
||||
"getattr", "setattr", "hasattr", "delattr", "globals", "locals", "vars",
|
||||
"dir", "type", "id", "object", "issubclass", "isinstance", "callable",
|
||||
"iter", "next", "reversed", "slice", "sorted", "filter", "map", "reduce",
|
||||
|
||||
# Input functions
|
||||
"input", "raw_input",
|
||||
|
||||
# Built-in functions that could be dangerous in certain contexts
|
||||
"breakpoint", "quit", "exit", "copyright", "credits", "license", "help",
|
||||
|
||||
# Dynamic attribute access that could be dangerous
|
||||
"operator.attrgetter", "operator.itemgetter", "operator.methodcaller",
|
||||
|
||||
"apply", "buffer", "coerce", "intern", "long", "unichr",
|
||||
"unicode", "xrange", "cmp", "reload", "basestring",
|
||||
}
|
||||
|
||||
# Forbidden attributes that could be dangerous
|
||||
FORBIDDEN_ATTRIBUTES = {
|
||||
# Special methods and attributes that could be used for code execution
|
||||
"__class__", "__dict__", "__module__", "__subclasses__", "__bases__",
|
||||
"__mro__", "__call__", "__func__", "__self__", "__code__", "__closure__",
|
||||
"__globals__", "__name__", "__file__", "__path__", "__package__",
|
||||
"__loader__", "__spec__", "__builtins__", "__import__", "__new__",
|
||||
"__init__", "__del__", "__repr__", "__str__", "__bytes__", "__format__",
|
||||
"__lt__", "__le__", "__eq__", "__ne__", "__gt__", "__ge__", "__hash__",
|
||||
"__bool__", "__dir__", "__delattr__", "__getattribute__",
|
||||
"__setattr__", "__delete__", "__set__", "__get__", "__set_name__",
|
||||
"__prepare__", "__init_subclass__", "__instancecheck__", "__subclasscheck__",
|
||||
"__subclasshook__", "__class_getitem__", "__annotations__", "__weakref__",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.has_errors = False
|
||||
self.errors = []
|
||||
|
||||
def check_theme_safety(self, theme_file: str) -> tuple[bool, list[str]]:
|
||||
"""
|
||||
Enhanced security check for theme files.
|
||||
Returns (is_safe, list_of_errors).
|
||||
"""
|
||||
self.has_errors = False
|
||||
self.errors = []
|
||||
|
||||
try:
|
||||
with open(theme_file, encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for syntax errors first
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
except SyntaxError as e:
|
||||
self.errors.append(f"Syntax error in file {theme_file}: {e}")
|
||||
self.has_errors = True
|
||||
return not self.has_errors, self.errors
|
||||
|
||||
# Walk through the AST and check for dangerous patterns
|
||||
for node in ast.walk(tree):
|
||||
self._check_node_safety(node, theme_file)
|
||||
|
||||
except Exception as e:
|
||||
self.errors.append(f"Failed to check theme safety for {theme_file}: {e}")
|
||||
self.has_errors = True
|
||||
|
||||
return not self.has_errors, self.errors
|
||||
|
||||
def _check_node_safety(self, node, theme_file: str):
|
||||
"""Check individual AST nodes for security issues."""
|
||||
# Check for forbidden imports
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
for alias in node.names:
|
||||
module_name = alias.name
|
||||
# Handle from ... import ... cases
|
||||
if isinstance(node, ast.ImportFrom) and node.module:
|
||||
module_name = node.module
|
||||
|
||||
# Check if the module is in the forbidden list
|
||||
if module_name in self.FORBIDDEN_MODULES:
|
||||
error_msg = f"Forbidden module '{module_name}' found in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
# Also check submodules (e.g., "os.path" should trigger on "os")
|
||||
for forbidden_module in self.FORBIDDEN_MODULES:
|
||||
if module_name.startswith(forbidden_module + "."):
|
||||
error_msg = f"Forbidden submodule '{module_name}' found in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
break
|
||||
|
||||
# Check for forbidden function calls
|
||||
elif isinstance(node, ast.Call):
|
||||
# Check for direct function calls (e.g., eval(), exec())
|
||||
if isinstance(node.func, ast.Name):
|
||||
if node.func.id in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{node.func.id}' found in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for method calls (e.g., os.system(), requests.get())
|
||||
elif isinstance(node.func, ast.Attribute):
|
||||
# Get the full function path (e.g., "os.system")
|
||||
full_func_name = self._get_attribute_path(node.func)
|
||||
if full_func_name in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{full_func_name}' found in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
# Check just the attribute name
|
||||
elif node.func.attr in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden method '{node.func.attr}' called in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for import expressions that might be used dynamically
|
||||
elif isinstance(node, ast.Expr):
|
||||
# Check if the expression is a call to an import-related function
|
||||
if isinstance(node.value, ast.Call):
|
||||
if isinstance(node.value.func, ast.Name):
|
||||
if node.value.func.id in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{node.value.func.id}' found in expression in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
elif isinstance(node.value.func, ast.Attribute):
|
||||
full_func_name = self._get_attribute_path(node.value.func)
|
||||
if full_func_name in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{full_func_name}' found in expression in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for forbidden attributes
|
||||
elif isinstance(node, ast.Attribute):
|
||||
if node.attr in self.FORBIDDEN_ATTRIBUTES:
|
||||
error_msg = f"Forbidden attribute access '{node.attr}' found in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for dangerous expressions (like accessing builtins)
|
||||
elif isinstance(node, ast.Name):
|
||||
if node.id in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{node.id}' found in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for potentially dangerous f-strings that might execute code
|
||||
elif isinstance(node, ast.FormattedValue):
|
||||
# Check if the format value contains dangerous expressions
|
||||
if hasattr(node, 'value'):
|
||||
# Recursively check the value for dangerous patterns
|
||||
if isinstance(node.value, ast.Call):
|
||||
func_name = self._get_attribute_path(node.value.func)
|
||||
if func_name in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{func_name}' found in f-string in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
elif isinstance(node.value, ast.Attribute) and node.value.attr in self.FORBIDDEN_ATTRIBUTES:
|
||||
error_msg = f"Forbidden attribute access '{node.value.attr}' found in f-string in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
elif isinstance(node.value, ast.Name) and node.value.id in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{node.value.id}' found in f-string in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
# Recursively check nested expressions in f-strings
|
||||
elif isinstance(node.value, (ast.BinOp, ast.UnaryOp, ast.BoolOp)):
|
||||
# Check for complex expressions that might contain dangerous operations
|
||||
self._check_node_safety(node.value, theme_file)
|
||||
# Check for nested function calls that might be dangerous
|
||||
elif isinstance(node.value, ast.Subscript):
|
||||
# Check if we're accessing something potentially dangerous
|
||||
if hasattr(node.value, 'value') and isinstance(node.value.value, ast.Call):
|
||||
func_name = self._get_attribute_path(node.value.value.func)
|
||||
if func_name in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Forbidden function '{func_name}' found in f-string subscript in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for string concatenation attacks (e.g., "im" + "port", "exec", etc.)
|
||||
elif isinstance(node, ast.BinOp):
|
||||
# Check for string concatenations that might be used to obfuscate dangerous code
|
||||
if isinstance(node.op, ast.Add): # String concatenation with +
|
||||
left_val = self._get_constant_value(node.left)
|
||||
right_val = self._get_constant_value(node.right)
|
||||
|
||||
if left_val is not None and right_val is not None:
|
||||
concatenated = str(left_val) + str(right_val)
|
||||
# Check if concatenated string forms a dangerous module/function name
|
||||
if concatenated in self.FORBIDDEN_MODULES or concatenated in self.FORBIDDEN_FUNCTIONS:
|
||||
error_msg = f"Potential string concatenation attack detected: '{concatenated}' in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
# Also check if it's a substring of forbidden items
|
||||
for forbidden_module in self.FORBIDDEN_MODULES:
|
||||
if concatenated in forbidden_module or forbidden_module in concatenated:
|
||||
error_msg = f"Potential string concatenation attack detected: '{concatenated}' matches forbidden module '{forbidden_module}' in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
for forbidden_func in self.FORBIDDEN_FUNCTIONS:
|
||||
if concatenated in forbidden_func or forbidden_func in concatenated:
|
||||
error_msg = f"Potential string concatenation attack detected: '{concatenated}' matches forbidden function '{forbidden_func}' in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for common obfuscation techniques
|
||||
elif isinstance(node, ast.Call):
|
||||
if isinstance(node.func, ast.Name) and node.func.id in ['eval', 'exec']:
|
||||
# Check if eval/exec is being called with obfuscated content
|
||||
if len(node.args) > 0:
|
||||
first_arg = node.args[0]
|
||||
arg_value = self._get_constant_value(first_arg)
|
||||
if arg_value:
|
||||
# Check if eval/exec argument contains dangerous content
|
||||
for forbidden_func in self.FORBIDDEN_FUNCTIONS:
|
||||
if forbidden_func in str(arg_value):
|
||||
error_msg = f"Potential obfuscated code execution detected: '{forbidden_func}' found in eval/exec argument in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
|
||||
# Check for character code arrays (another obfuscation method)
|
||||
elif isinstance(node, ast.List) or isinstance(node, ast.Tuple):
|
||||
# Check if it's a list of character codes that might be converted to dangerous strings
|
||||
if all(isinstance(elt, (ast.Num, ast.Constant)) and isinstance(self._get_constant_value(elt), int) for elt in node.elts):
|
||||
# This might be an array of ASCII codes
|
||||
try:
|
||||
char_codes = [self._get_constant_value(elt) for elt in node.elts if self._get_constant_value(elt) is not None]
|
||||
# Filter to only include actual integers for character codes
|
||||
int_char_codes = [code for code in char_codes if isinstance(code, int)]
|
||||
if int_char_codes and all(isinstance(code, int) and 32 <= code <= 126 for code in int_char_codes): # Printable ASCII range
|
||||
decoded_str = ''.join(chr(code) for code in int_char_codes)
|
||||
# Check if decoded string contains dangerous content
|
||||
for forbidden_module in self.FORBIDDEN_MODULES:
|
||||
if forbidden_module in decoded_str:
|
||||
error_msg = f"Potential character code obfuscation detected: '{forbidden_module}' found in decoded array in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
for forbidden_func in self.FORBIDDEN_FUNCTIONS:
|
||||
if forbidden_func in decoded_str:
|
||||
error_msg = f"Potential character code obfuscation detected: '{forbidden_func}' found in decoded array in file {theme_file}"
|
||||
self.errors.append(error_msg)
|
||||
self.has_errors = True
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
# If conversion fails, continue
|
||||
pass
|
||||
|
||||
def _get_attribute_path(self, attr_node):
|
||||
"""Extract the full attribute path from an AST node (e.g., 'os.path.join')."""
|
||||
if isinstance(attr_node, ast.Name):
|
||||
return attr_node.id
|
||||
elif isinstance(attr_node, ast.Attribute):
|
||||
parent_path = self._get_attribute_path(attr_node.value)
|
||||
return f"{parent_path}.{attr_node.attr}"
|
||||
return ""
|
||||
|
||||
def _get_constant_value(self, node):
|
||||
"""Extract the constant value from an AST node if it's a constant."""
|
||||
if isinstance(node, ast.Str): # Python < 3.8
|
||||
return node.s
|
||||
elif isinstance(node, ast.Constant): # Python 3.8+
|
||||
return node.value
|
||||
elif isinstance(node, ast.Num): # Python < 3.8 for numbers
|
||||
return node.n
|
||||
elif isinstance(node, ast.Bytes): # For bytes
|
||||
return node.s
|
||||
return None
|
||||
|
||||
|
||||
def check_theme_safety(theme_file: str) -> bool:
|
||||
"""
|
||||
Convenience function to check theme safety.
|
||||
Returns True if the theme is safe, False otherwise.
|
||||
"""
|
||||
checker = ThemeSecurityChecker()
|
||||
is_safe, errors = checker.check_theme_safety(theme_file)
|
||||
|
||||
for error in errors:
|
||||
logger.error(error)
|
||||
|
||||
return is_safe
|
||||
|
||||
|
||||
def is_safe_image_file(file_path: str) -> bool:
|
||||
"""
|
||||
Check if an image file is safe to load by verifying its extension and basic file properties.
|
||||
This helps prevent loading malicious files that might be disguised as images.
|
||||
"""
|
||||
|
||||
# Check file extension first
|
||||
safe_extensions = {'.png', '.jpg', '.jpeg', '.svg', '.bmp', '.gif', '.webp', '.ico'}
|
||||
_, ext = os.path.splitext(file_path.lower())
|
||||
|
||||
if ext not in safe_extensions:
|
||||
logger.warning(f"Unsafe image file extension for {file_path}: {ext}")
|
||||
return False
|
||||
|
||||
# Check file size (prevent loading extremely large files)
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
# Limit to 50MB to prevent memory exhaustion attacks
|
||||
if file_size > 50 * 1024 * 1024: # 50MB
|
||||
logger.warning(f"Image file too large ({file_size} bytes): {file_path}")
|
||||
return False
|
||||
except OSError:
|
||||
logger.error(f"Could not get file size for {file_path}")
|
||||
return False
|
||||
|
||||
# For security, we can also check the file's magic bytes (first few bytes)
|
||||
# to ensure it's actually an image file and not a disguised executable
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
header = f.read(32) # Read first 32 bytes
|
||||
|
||||
# Check for common image file signatures (magic bytes)
|
||||
if ext == '.png':
|
||||
# PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
||||
if not header.startswith(b'\x89PNG\r\n\x1a\n'):
|
||||
logger.warning(f"File {file_path} does not have PNG signature")
|
||||
return False
|
||||
elif ext in ['.jpg', '.jpeg']:
|
||||
# JPEG signature: FF D8 FF
|
||||
if not header.startswith(b'\xff\xd8\xff'):
|
||||
logger.warning(f"File {file_path} does not have JPEG signature")
|
||||
return False
|
||||
elif ext == '.gif':
|
||||
# GIF signature: 47 49 46 38 (GIF8)
|
||||
if not header.startswith(b'GIF8'):
|
||||
logger.warning(f"File {file_path} does not have GIF signature")
|
||||
return False
|
||||
elif ext == '.bmp':
|
||||
# BMP signature: 42 4D (BM)
|
||||
if not header.startswith(b'BM'):
|
||||
logger.warning(f"File {file_path} does not have BMP signature")
|
||||
return False
|
||||
# SVG is text-based, so we just check if it contains XML-like structure
|
||||
elif ext == '.svg':
|
||||
try:
|
||||
header_str = header.decode('utf-8', errors='ignore')
|
||||
# Basic check for SVG XML structure
|
||||
if not ('<svg' in header_str or '<?xml' in header_str):
|
||||
logger.warning(f"File {file_path} does not appear to be a valid SVG")
|
||||
return False
|
||||
except UnicodeDecodeError:
|
||||
logger.warning(f"SVG file {file_path} contains invalid UTF-8")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking image file signature for {file_path}: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
1
portprotonqt/themes/standart/images/key_+.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m24 9.185c-2.0302 0-3.7027 1.6725-3.7027 3.7027v7.4096h-7.4096c-2.0302 0-3.7027 1.6725-3.7027 3.7027s1.6725 3.7027 3.7027 3.7027h7.4096v7.4096c0 2.0302 1.6725 3.7027 3.7027 3.7027s3.7027-1.6725 3.7027-3.7027v-7.4096h7.4096c2.0302 0 3.7027-1.6725 3.7027-3.7027s-1.6725-3.7027-3.7027-3.7027h-7.4096v-7.4096c0-2.0302-1.6725-3.7027-3.7027-3.7027zm0 2.9613c0.41396 0 0.74137 0.32742 0.74137 0.74137v10.371h10.371c0.41396 0 0.74137 0.32742 0.74137 0.74137s-0.32742 0.74137-0.74137 0.74137h-10.371v10.371c0 0.41396-0.32742 0.74137-0.74137 0.74137s-0.74137-0.32742-0.74137-0.74137v-10.371h-10.371c-0.41396 0-0.74137-0.32742-0.74137-0.74137s0.32742-0.74137 0.74137-0.74137h10.371v-10.371c0-0.41396 0.32742-0.74137 0.74137-0.74137z" fill="#3f424d" stop-color="#000000" stroke-width="1.0662"/><path d="m24 11.494c1.1462 0 2.0844 0.93819 2.0844 2.0844v8.3375h8.3375c1.1462 0 2.0844 0.93819 2.0844 2.0844s-0.93819 2.0844-2.0844 2.0844h-8.3375v8.3375c0 1.1462-0.93819 2.0844-2.0844 2.0844s-2.0844-0.93819-2.0844-2.0844v-8.3375h-8.3375c-1.1462 0-2.0844-0.93819-2.0844-2.0844s0.93819-2.0844 2.0844-2.0844h8.3375v-8.3375c0-1.1462 0.93819-2.0844 2.0844-2.0844z" fill="#fff" stop-color="#000000" stroke-width="0"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
portprotonqt/themes/standart/images/key_f5.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m15.222 18.538h8.5002v1.8684h-5.8744v2.6763h5.3189v1.8684h-5.3189v4.511h-2.6258zm13.6 11.092q-1.1614 0-3.4842-0.18515v-2.0367q2.0872 0.38714 3.1476 0.38714 0.92576 0 1.3466-0.20198 0.4208-0.21882 0.4208-0.67328v-1.6327q0-0.45446-0.30298-0.63962-0.30298-0.20198-0.99309-0.20198h-3.3664v-5.908h6.9011v1.8852h-4.4436v2.2218h1.7169q0.97626 0 1.902 0.3703 0.50496 0.21882 0.80794 0.67328t0.30298 1.0604v2.2892q0 0.65645-0.25248 1.1782-0.25248 0.50496-0.63962 0.77427-0.33664 0.25248-0.90893 0.40397-0.55546 0.15149-1.0772 0.20198-0.60595 0.03366-1.0772 0.03366z" fill="#3f424d" stroke-width="0" aria-label="F5"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
portprotonqt/themes/standart/images/ps_ps.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m20.375 10.836v21.898l4.4513 1.4106v-18.447c0-1.1417 0.63761-1.8498 1.4528-1.5795 1.2195 0.33732 1.2205 1.7172 1.2205 2.2383v7.34c0.69194 0.30705 1.3479 0.46456 1.9511 0.46456 0.90817 0 1.6684-0.36594 2.2003-1.06 0.57517-0.75032 0.87844-1.8745 0.87844-3.2519 0-3.9851-1.3499-5.7267-5.562-7.1711-1.3341-0.45192-4.3372-1.3851-6.5925-1.8413zm-1.6344 13.688-6.8797 2.441c-0.02379 0.0087-1.7358 0.57835-2.724 1.3092-0.35029 0.25948-0.50605 0.57737-0.44766 0.89955 0.11028 0.60112 0.89455 1.164 2.099 1.5035 2.5191 0.83248 5.1622 1.0445 7.7159 0.62504l0.23228-0.03801v-1.9131l-2.0863 0.75596c-0.96438 0.34597-2.458 0.42428-3.2646 0.16049-0.5795-0.19028-0.70734-0.49524-0.70951-0.71795-0.0043-0.26596 0.17441-0.64535 1.022-0.95023l5.0426-1.8033zm13.269 1.1529c-0.59973-0.0075-1.1869 0.016-1.7442 0.07603-2.1515 0.23785-3.7118 0.78326-3.7291 0.78975l-0.09714 0.0338v2.3692l5.0299-1.7695c0.96439-0.34597 2.4538-0.42428 3.2604-0.16048 0.5795 0.19028 0.70734 0.49523 0.70951 0.71795 0.0021 0.26596-0.17234 0.64329-1.0178 0.94601l-7.9819 2.8465v2.2594l10.706-3.8432c0.01297-0.0043 1.421-0.53074 1.968-1.2205 0.19245-0.24218 0.25306-0.4904 0.17737-0.73907-0.09298-0.30488-0.49051-0.89192-2.0863-1.3979-1.4887-0.56436-3.3954-0.88519-5.1946-0.908z" fill="#3f424d" stroke-width="2.1623"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
1
portprotonqt/themes/standart/images/xbox_xbox.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m22.671 37.279c-2.0532-0.1967-4.1317-0.93396-5.9172-2.099-1.4962-0.97578-1.8338-1.3776-1.8338-2.1783 0-1.6086 1.7688-4.4266 4.7953-7.6383 1.7183-1.8246 4.1122-3.962 4.3709-3.9044 0.50369 0.11245 4.5276 4.0376 6.0343 5.8857 2.3821 2.9211 3.4769 5.3138 2.9205 6.3804-0.42284 0.81067-3.0472 2.3955-4.9749 3.0042-1.5891 0.50183-3.6761 0.71433-5.395 0.54984zm-9.772-5.9498c-1.2434-1.9076-1.8716-3.7854-2.1746-6.5015-0.10037-0.89679-0.06505-1.4095 0.22706-3.2507 0.36336-2.2923 1.6697-4.947 3.2402-6.5795 0.66849-0.69389 0.72796-0.71247 1.5433-0.43678 0.98817 0.33455 2.0445 1.0644 3.6832 2.5463l0.95719 0.8655-0.52351 0.64123c-2.4249 2.9769-4.9842 7.1991-5.9476 9.8105-0.52351 1.4188-0.73416 2.8437-0.50802 3.4369 0.15179 0.40084 0.01239 0.25153-0.49873-0.53095zm21.824 0.32433c0.12298-0.59972-0.03253-1.7006-0.39651-2.8115-0.78837-2.4054-3.4242-6.88-5.8445-9.9226l-0.76204-0.95781 0.82461-0.75677c1.0761-0.98817 1.8233-1.5798 2.63-2.0826 0.63596-0.39651 1.5451-0.74748 1.9361-0.74748 0.24069 0 1.0892 0.88285 1.7738 1.8431 1.0607 1.4869 1.8407 3.2929 2.2359 5.1701 0.25556 1.2143 0.27694 3.8102 0.0412 5.0214-0.19516 0.99375-0.60405 2.2818-1.0006 3.1556-0.30048 0.65455-1.0408 1.9262-1.3661 2.34-0.16728 0.21281-0.16728 0.2125-0.07435-0.24658zm-11.832-17.733c-1.117-0.56688-2.84-1.1756-3.7916-1.3398-0.33331-0.05731-0.90236-0.08983-1.2639-0.07125-0.78558 0.03965-0.75058-0.0012 0.50895-0.59631 1.047-0.4947 1.9206-0.78558 3.107-1.0346 1.3336-0.28034 3.8412-0.28344 5.1537-0.0068 1.4172 0.29893 3.0866 0.92002 4.027 1.4993l0.28003 0.17161-0.64123-0.03222c-1.275-0.06443-3.133 0.45072-5.128 1.4209-0.60158 0.29304-1.1245 0.52661-1.1629 0.52042-0.0381-0.0074-0.52847-0.24627-1.0904-0.53126z" fill="#3f424d" stroke-width=".30977"/></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -1,5 +1,23 @@
|
||||
[Metainfo]
|
||||
author = Dervart
|
||||
author_link =
|
||||
description = Стандартная тема PortProtonQt (тёмный вариант)
|
||||
name = Clean Dark
|
||||
name_en = Clean Dark
|
||||
name_ru = Чистая темная
|
||||
description_en = Standard PortProtonQt theme (dark variant)
|
||||
description_ru = Стандартная тема PortProtonQt (тёмный вариант)
|
||||
|
||||
[Screenshots]
|
||||
auto_installs_en = Auto-installs
|
||||
auto_installs_ru = Автоустановки
|
||||
library_en = Library
|
||||
library_ru = Библиотека
|
||||
game_card_en = Game Card
|
||||
game_card_ru = Карточка
|
||||
context_menu_en = Context Menu
|
||||
context_menu_ru = Контекстное меню
|
||||
portproton_settings_en = PortProton Settings
|
||||
portproton_settings_ru = Настройки PortProton
|
||||
wine_settings_en = Wine Settings
|
||||
wine_settings_ru = Настройки Wine
|
||||
themes_en = Themes
|
||||
themes_ru = Темы
|
||||
|
||||
0
portprotonqt/themes/standart/styles/__init__.py
Normal file
378
portprotonqt/themes/standart/styles/base.py
Normal file
@@ -0,0 +1,378 @@
|
||||
from .constants import *
|
||||
|
||||
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН), ЛЭЙБЛОВ, КНОПОК
|
||||
MAIN_WINDOW_STYLE = f"""
|
||||
QWidget {{
|
||||
background: {color_b};
|
||||
}}
|
||||
QLabel {{
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QPushButton {{
|
||||
background: {color_c};
|
||||
border: {border_c} rgba(255, 255, 255, 0.01);
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
font-size: {font_size_a};
|
||||
font-family: '{font_family}';
|
||||
padding: 8px 16px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background: {color_a};
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {color_b};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ПРОГРЕСС-БАРА
|
||||
PROGRESS_BAR_STYLE = f"""
|
||||
QProgressBar {{
|
||||
color: {color_f};
|
||||
background-color: {color_c};
|
||||
text-align: center;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background-color: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ СТАТУС-БАРА
|
||||
STATUS_BAR_STYLE = f"""
|
||||
QStatusBar {{
|
||||
color: {color_f};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
|
||||
MAIN_WINDOW_HEADER_STYLE = f"""
|
||||
QFrame {{
|
||||
background: {color_h};
|
||||
border: 10px solid {color_g};
|
||||
border-bottom: 0px solid {color_g};
|
||||
border-top-left-radius: 30px;
|
||||
border-top-right-radius: 30px;
|
||||
border: none;
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
|
||||
NAV_WIDGET_STYLE = f"""
|
||||
QWidget {{
|
||||
background: {color_h};
|
||||
border: {border_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ КНОПОК ВКЛАДОК НАВИГАЦИИ
|
||||
NAV_BUTTON_STYLE = f"""
|
||||
NavLabel {{
|
||||
background: rgba(0,0,0,0);
|
||||
padding: 12px 3px;
|
||||
margin: 10px 0 10px 10px;
|
||||
color: #7f7f7f;
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
text-transform: uppercase;
|
||||
border: {color_a};
|
||||
border-radius: {border_radius_b};
|
||||
}}
|
||||
NavLabel[checked = true] {{
|
||||
background: rgba(0,0,0,0);
|
||||
color: {color_a};
|
||||
font-weight: normal;
|
||||
text-decoration: underline;
|
||||
border-radius: {border_radius_b};
|
||||
}}
|
||||
NavLabel:hover {{
|
||||
background: none;
|
||||
color: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ПОЛЯ ПОИСКА
|
||||
SEARCH_EDIT_STYLE = f"""
|
||||
QLineEdit {{
|
||||
background-color: rgba(30, 30, 30, 0.50);
|
||||
border: {border_b} rgba(255, 255, 255, 0.5);
|
||||
border-radius: {border_radius_a};
|
||||
padding: 7px 14px;
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QLineEdit:focus {{
|
||||
border: {border_b} {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# ОТКЛЮЧАЕМ РАМКУ У QScrollArea
|
||||
SCROLL_AREA_STYLE = f"""
|
||||
QWidget {{
|
||||
background: {color_h};
|
||||
}}
|
||||
QScrollBar:vertical {{
|
||||
width: 10px;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: #bebebe;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::add-line:vertical {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::sub-line:vertical {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {{
|
||||
border: {border_a};
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}}
|
||||
|
||||
QScrollBar:horizontal {{
|
||||
height: 10px;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
background: #bebebe;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::add-line:horizontal {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::sub-line:horizontal {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal {{
|
||||
border: {border_a};
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}}
|
||||
"""
|
||||
|
||||
# SLIDER_SIZE_STYLE
|
||||
SLIDER_SIZE_STYLE= f"""
|
||||
QWidget {{
|
||||
background: {color_h};
|
||||
height: 25px;
|
||||
}}
|
||||
QSlider::groove:horizontal {{
|
||||
border: {border_a};
|
||||
border-radius: 3px;
|
||||
height: 6px; /* the groove expands to the size of the slider by default. by giving it a height, it has a fixed size */
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
margin: 6px 0;
|
||||
}}
|
||||
QSlider::handle:horizontal {{
|
||||
background: #bebebe;
|
||||
border: {border_a};
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -6px 0; /* handle is placed by default on the contents rect of the groove. Expand outside the groove */
|
||||
border-radius: 9px;
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОБЛАСТИ ДЛЯ КАРТОЧЕК ИГР (QWidget)
|
||||
LIST_WIDGET_STYLE = """
|
||||
QWidget {
|
||||
background: none;
|
||||
border: {border_a} {color_g};
|
||||
border-radius: 25px;
|
||||
}
|
||||
"""
|
||||
|
||||
# ЗАГОЛОВОК "БИБЛИОТЕКА" НА ВКЛАДКЕ
|
||||
INSTALLED_TAB_TITLE_STYLE = f"""
|
||||
QLabel {{
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_b};
|
||||
color: {color_f};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ КНОПОК "СОХРАНЕНИЯ, ПРИМЕНЕНИЯ И Т.Д."
|
||||
ACTION_BUTTON_STYLE = f"""
|
||||
QPushButton {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_g};
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
font-size: {font_size_a};
|
||||
font-family: '{font_family}';
|
||||
padding: 8px 16px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background: {color_a};
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {color_b};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОВЕРЛЕЯ
|
||||
OVERLAY_WINDOW_STYLE = f"background: {color_b};"
|
||||
OVERLAY_BUTTON_STYLE = f"""
|
||||
QPushButton {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_g};
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
font-size: {font_size_a};
|
||||
font-family: '{font_family}';
|
||||
padding: 8px 16px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background: {color_a};
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {color_b};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# ТЕКСТОВЫЕ СТИЛИ: ЗАГОЛОВКИ И ОСНОВНОЙ КОНТЕНТ
|
||||
TAB_TITLE_STYLE = f"font-family: '{font_family}'; font-size: {font_size_b}; color: {color_f}; background-color: none;"
|
||||
CONTENT_STYLE = f"""
|
||||
QLabel {{
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
color: {color_f};
|
||||
background-color: none;
|
||||
border-bottom: {border_b} rgba(255, 255, 255, 0.2);
|
||||
padding-bottom: 15px;
|
||||
}}
|
||||
"""
|
||||
|
||||
PREVIEW_WIDGET_STYLE = f"""
|
||||
QWidget {{
|
||||
margin-top: 3px;
|
||||
background-color: {color_c};
|
||||
border-radius: {border_radius_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОСНОВНЫХ СТРАНИЦ
|
||||
# LIBRARY_WIDGET_STYLE
|
||||
LIBRARY_WIDGET_STYLE= """
|
||||
QWidget {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(112,20,132,1),
|
||||
stop:1 rgba(50,134,182,1));
|
||||
border-radius: 0px;
|
||||
}
|
||||
"""
|
||||
|
||||
# CONTAINER_STYLE
|
||||
CONTAINER_STYLE= """
|
||||
QWidget {
|
||||
background-color: none;
|
||||
}
|
||||
"""
|
||||
|
||||
# OTHER_PAGES_WIDGET_STYLE
|
||||
OTHER_PAGES_WIDGET_STYLE= f"""
|
||||
QWidget {{
|
||||
background: {color_d};
|
||||
border-radius: 0px;
|
||||
}}
|
||||
"""
|
||||
|
||||
# CAROUSEL_WIDGET_STYLE
|
||||
CAROUSEL_WIDGET_STYLE= f"""
|
||||
QWidget {{
|
||||
background: {color_c};
|
||||
border-radius: 0px;
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛИ ДЛЯ ВКЛАДКИ НАСТРОЕК PORTPROTON
|
||||
# PARAMS_TITLE_STYLE
|
||||
PARAMS_TITLE_STYLE = f"color: {color_f}; font-family: '{font_family}'; font-size: {font_size_a}; padding: 10px; background: {color_h};"
|
||||
|
||||
PROXY_INPUT_STYLE = f"""
|
||||
QLineEdit {{
|
||||
background: {color_b};
|
||||
border: {border_c} rgba(255, 255, 255, 0.01);
|
||||
border-radius: {border_radius_a};
|
||||
height: 34px;
|
||||
padding-left: 12px;
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QLineEdit:hover {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QLineEdit:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_e};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛИ ДЛЯ QMessageBox (ОКНА СООБЩЕНИЙ)
|
||||
MESSAGE_BOX_STYLE = f"""
|
||||
QMessageBox {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(40, 40, 40, 0.95),
|
||||
stop:1 rgba(25, 25, 25, 0.95));
|
||||
border: {border_b} rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
}}
|
||||
QMessageBox QLabel {{
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QMessageBox QPushButton {{
|
||||
background: rgba(30, 30, 30, 0.6);
|
||||
border: {border_b} rgba(255, 255, 255, 0.2);
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
padding: 8px 20px;
|
||||
min-width: 80px;
|
||||
}}
|
||||
QMessageBox QPushButton:hover {{
|
||||
background: #09bec8;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}}
|
||||
QMessageBox QPushButton:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background: {color_e};
|
||||
}}
|
||||
"""
|
||||
|
||||
# Favorite Star
|
||||
FAVORITE_LABEL_STYLE = f"color: gold; font-size: 32px; background: {color_h};"
|
||||
176
portprotonqt/themes/standart/styles/constants.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config_utils import read_theme_from_config
|
||||
|
||||
theme_manager = ThemeManager()
|
||||
current_theme_name = read_theme_from_config()
|
||||
|
||||
# КОНСТАНТЫ
|
||||
favoriteLabelSize = 48, 48
|
||||
|
||||
# VARS
|
||||
font_family = "Play"
|
||||
font_size_a = "16px"
|
||||
font_size_b = "24px"
|
||||
border_a = "0px solid"
|
||||
border_b = "1px solid"
|
||||
border_c = "2px solid"
|
||||
border_radius_a = "10px"
|
||||
border_radius_b = "15px"
|
||||
color_a = "#409EFF"
|
||||
color_b = "#282a33"
|
||||
color_c = "#3f424d"
|
||||
color_d = "#32343d"
|
||||
color_e = "#404554"
|
||||
color_f = "#ffffff"
|
||||
color_g = "rgba(0, 0, 0, 0)"
|
||||
color_h = "transparent"
|
||||
color_i = "rgba(40, 42, 51, 0.9)"
|
||||
|
||||
GAME_CARD_ANIMATION = {
|
||||
# Тип анимации при входе и выходе на детальную страницу
|
||||
# Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
|
||||
# Определяет, как детальная страница появляется и исчезает
|
||||
"detail_page_animation_type": "fade",
|
||||
|
||||
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса)
|
||||
# Влияет на толщину рамки вокруг карточки, когда она не выделена
|
||||
# Значение в пикселях
|
||||
"default_border_width": 2,
|
||||
|
||||
# Ширина обводки при наведении курсора
|
||||
# Увеличивает толщину рамки, когда курсор находится над карточкой
|
||||
# Значение в пикселях
|
||||
"hover_border_width": 8,
|
||||
|
||||
# Ширина обводки при фокусе (например, при выборе с клавиатуры)
|
||||
# Увеличивает толщину рамки, когда карточка в фокусе
|
||||
# Значение в пикселях
|
||||
"focus_border_width": 12,
|
||||
|
||||
# Минимальная ширина обводки во время пульсирующей анимации
|
||||
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания")
|
||||
# Значение в пикселях
|
||||
"pulse_min_border_width": 8,
|
||||
|
||||
# Максимальная ширина обводки во время пульсирующей анимации
|
||||
# Определяет максимальную толщину рамки при пульсации
|
||||
# Значение в пикселях
|
||||
"pulse_max_border_width": 10,
|
||||
|
||||
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе)
|
||||
# Влияет на скорость перехода от одной ширины обводки к другой
|
||||
# Значение в миллисекундах
|
||||
"thickness_anim_duration": 300,
|
||||
|
||||
# Длительность одного цикла пульсирующей анимации
|
||||
# Определяет, как быстро рамка "пульсирует" между min и max значениями
|
||||
# Значение в миллисекундах
|
||||
"pulse_anim_duration": 800,
|
||||
|
||||
# Длительность анимации вращения градиента
|
||||
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки
|
||||
# Значение в миллисекундах
|
||||
"gradient_anim_duration": 3000,
|
||||
|
||||
# Начальный угол градиента (в градусах)
|
||||
# Определяет начальную точку вращения градиента при старте анимации
|
||||
"gradient_start_angle": 360,
|
||||
|
||||
# Конечный угол градиента (в градусах)
|
||||
# Определяет конечную точку вращения градиента
|
||||
# Значение 0 означает полный поворот на 360 градусов
|
||||
"gradient_end_angle": 0,
|
||||
|
||||
# Тип анимации для карточки при наведении или фокусе
|
||||
# Возможные значения: "gradient", "scale"
|
||||
# "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
|
||||
"card_animation_type": "gradient",
|
||||
|
||||
# Масштаб карточки в состоянии покоя
|
||||
# Определяет базовый размер карточки (1.0 = 100% от исходного размера)
|
||||
# Значение в долях (например, 1.0 для нормального размера)
|
||||
"default_scale": 1.0,
|
||||
|
||||
# Масштаб карточки при наведении курсора
|
||||
# Увеличивает размер карточки при наведении
|
||||
# Значение в долях (например, 1.1 = 110% от исходного размера)
|
||||
"hover_scale": 1.1,
|
||||
|
||||
# Масштаб карточки при фокусе (например, при выборе с клавиатуры)
|
||||
# Увеличивает размер карточки при фокусе
|
||||
# Значение в долях (например, 1.05 = 105% от исходного размера)
|
||||
"focus_scale": 1.05,
|
||||
|
||||
# Длительность анимации масштабирования
|
||||
# Влияет на скорость изменения размера карточки при наведении или фокусе
|
||||
# Значение в миллисекундах
|
||||
"scale_anim_duration": 200,
|
||||
|
||||
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе)
|
||||
# Влияет на "чувство" анимации (например, плавное ускорение или замедление)
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad")
|
||||
"thickness_easing_curve": "OutBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса)
|
||||
# Влияет на "чувство" возврата к исходной ширине обводки
|
||||
"thickness_easing_curve_out": "InBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе)
|
||||
# Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
"scale_easing_curve": "OutBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
|
||||
# Влияет на "чувство" возврата к исходному масштабу
|
||||
"scale_easing_curve_out": "InBack",
|
||||
|
||||
# Цвета градиента для анимированной обводки
|
||||
# Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex
|
||||
# Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
|
||||
"gradient_colors": [
|
||||
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
|
||||
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
|
||||
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
|
||||
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
|
||||
],
|
||||
|
||||
# Длительность анимации fade при входе на детальную страницу
|
||||
# Влияет на скорость появления страницы при fade-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_fade_duration": 350,
|
||||
|
||||
# Длительность анимации slide при входе на детальную страницу
|
||||
# Влияет на скорость скольжения страницы при slide-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_slide_duration": 500,
|
||||
|
||||
# Длительность анимации bounce при входе на детальную страницу
|
||||
# Влияет на скорость "прыжка" страницы при bounce-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_bounce_duration": 400,
|
||||
|
||||
# Длительность анимации fade при выходе из детальной страницы
|
||||
# Влияет на скорость исчезновения страницы при fade-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_fade_duration_exit": 350,
|
||||
|
||||
# Длительность анимации slide при выходе из детальной страницы
|
||||
# Влияет на скорость скольжения страницы при slide-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_slide_duration_exit": 500,
|
||||
|
||||
# Длительность анимации bounce при выходе из детальной страницы
|
||||
# Влияет на скорость "сжатия" страницы при bounce-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_bounce_duration_exit": 400,
|
||||
|
||||
# Тип кривой сглаживания для анимации при входе на детальную страницу
|
||||
# Применяется к slide и bounce анимациям, влияет на "чувство" движения
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
"detail_page_easing_curve": "OutCubic",
|
||||
|
||||
# Тип кривой сглаживания для анимации при выходе из детальной страницы
|
||||
# Применяется к slide и bounce анимациям, влияет на "чувство" движения
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
"detail_page_easing_curve_exit": "InCubic"
|
||||
}
|
||||
115
portprotonqt/themes/standart/styles/detail_page.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from .constants import *
|
||||
|
||||
# ФОН ДЛЯ ДЕТАЛЬНОЙ СТРАНИЦЫ, ЕСЛИ ОБЛОЖКА НЕ ЗАГРУЖЕНА
|
||||
DETAIL_PAGE_NO_COVER_STYLE = f"background: rgba(20,20,20,0.95); border-radius: {border_radius_b};"
|
||||
|
||||
# СТИЛЬ КНОПКИ "ДОБАВИТЬ ИГРУ" И "НАЗАД" НА ДЕТАЛЬНОЙ СТРАНИЦЕ И БИБЛИОТЕКИ
|
||||
ADDGAME_BACK_BUTTON_STYLE = f"""
|
||||
QPushButton {{
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: {border_b} rgba(255, 255, 255, 0.5);
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
font-size: {font_size_a};
|
||||
font-family: '{font_family}';
|
||||
padding: 8px 16px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background: {color_a};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
# ОСНОВНОЙ ФРЕЙМ ДЕТАЛЕЙ ИГРЫ
|
||||
DETAIL_CONTENT_FRAME_STYLE = f"""
|
||||
QFrame {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(20, 20, 20, 0.40),
|
||||
stop:1 rgba(20, 20, 20, 0.35));
|
||||
border: {border_a} {color_g};
|
||||
border-radius: {border_radius_b};
|
||||
}}
|
||||
"""
|
||||
|
||||
# ФРЕЙМ ПОД ОБЛОЖКОЙ
|
||||
COVER_FRAME_STYLE = f"""
|
||||
QFrame {{
|
||||
background: rgba(30, 30, 30, 0.80);
|
||||
border-radius: {border_radius_b};
|
||||
border: {border_a} {color_g};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СКРУГЛЕНИЕ LABEL ПОД ОБЛОЖКУ
|
||||
COVER_LABEL_STYLE = f"border-radius: {border_radius_b};"
|
||||
|
||||
# ВИДЖЕТ ДЕТАЛЕЙ (ТЕКСТ, ОПИСАНИЕ)
|
||||
DETAILS_WIDGET_STYLE = f"background: rgba(20,20,20,0.40); border-radius: {border_radius_b}; padding: 10px;"
|
||||
|
||||
# НАЗВАНИЕ (ЗАГОЛОВОК) НА ДЕТАЛЬНОЙ СТРАНИЦЕ
|
||||
DETAIL_PAGE_TITLE_STYLE = f"font-family: '{font_family}'; font-size: 32px; color: #007AFF;"
|
||||
|
||||
# ЛИНИЯ-РАЗДЕЛИТЕЛЬ
|
||||
DETAIL_PAGE_LINE_STYLE = "color: rgba(255,255,255,0.12); margin: 10px 0;"
|
||||
|
||||
# ТЕКСТ ОПИСАНИЯ
|
||||
DETAIL_PAGE_DESC_STYLE = f"font-family: '{font_family}'; font-size: {font_size_a}; color: {color_f}; line-height: 1.5;"
|
||||
|
||||
# СТИЛЬ КНОПКИ "ИГРАТЬ"
|
||||
PLAY_BUTTON_STYLE = f"""
|
||||
QPushButton {{
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: {border_b} rgba(255, 255, 255, 0.5);
|
||||
border-radius: {border_radius_a};
|
||||
font-size: 18px;
|
||||
color: {color_f};
|
||||
font-weight: bold;
|
||||
font-family: '{font_family}';
|
||||
padding: 8px 16px;
|
||||
min-width: 120px;
|
||||
min-height: 40px;
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background: {color_a};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {color_a};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
background: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
ADDGAME_INPUT_STYLE = f"""
|
||||
QLineEdit {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_g};
|
||||
border-radius: {border_radius_a};
|
||||
height: 34px;
|
||||
padding-left: 12px;
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QLineEdit:hover {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QLineEdit:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_e};
|
||||
}}
|
||||
"""
|
||||
|
||||
# ФУНКЦИЯ ДЛЯ ДИНАМИЧЕСКОГО ГРАДИЕНТА (ДЕТАЛИ ИГР)
|
||||
# Функции из этой темы срабатывает всегда вне зависимости от выбранной темы, функции из других тем работают только в этих темах
|
||||
def detail_page_style(stops):
|
||||
return f"""
|
||||
QWidget {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||||
{stops});
|
||||
border-radius: {border_radius_b};
|
||||
}}
|
||||
"""
|
||||
87
portprotonqt/themes/standart/styles/game_card.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from .constants import *
|
||||
|
||||
# СТИЛЬ КАРТОЧКИ ИГРЫ (GAMECARD)
|
||||
GAME_CARD_WINDOW_STYLE = f"""
|
||||
QFrame {{
|
||||
border-radius: 20px;
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: {border_a} {color_g};
|
||||
}}
|
||||
"""
|
||||
|
||||
# НАЗВАНИЕ В КАРТОЧКЕ (QLabel)
|
||||
GAME_CARD_NAME_LABEL_STYLE = f"""
|
||||
QLabel {{
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
font-weight: bold;
|
||||
background-color: {color_g};
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
padding: 14px, 7px, 3px, 7px;
|
||||
qproperty-wordWrap: true;
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛИ БЕЙДЖА PROTONDB НА КАРТОЧКЕ
|
||||
def get_protondb_badge_style(tier):
|
||||
tier = tier.lower()
|
||||
tier_colors = {
|
||||
"platinum": {"background": "rgba(255,255,255,0.9)", "color": "black"},
|
||||
"gold": {"background": "rgba(253,185,49,0.7)", "color": "black"},
|
||||
"silver": {"background": "rgba(169,169,169,0.8)", "color": "black"},
|
||||
"bronze": {"background": "rgba(205,133,63,0.7)", "color": "black"},
|
||||
"borked": {"background": "rgba(255,0,0,0.7)", "color": "black"},
|
||||
"pending": {"background": "rgba(160,82,45,0.7)", "color": "black"}
|
||||
}
|
||||
colors = tier_colors.get(tier, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
|
||||
return f"""
|
||||
qproperty-alignment: AlignCenter;
|
||||
background-color: {colors["background"]};
|
||||
color: {colors["color"]};
|
||||
border-radius: 5px;
|
||||
font-family: '{font_family}';
|
||||
font-weight: bold;
|
||||
"""
|
||||
|
||||
# СТИЛИ БЕЙДЖА WEANTICHEATYET
|
||||
def get_anticheat_badge_style(status):
|
||||
status = status.lower()
|
||||
status_colors = {
|
||||
"supported": {"background": "rgba(102, 168, 15, 0.7)", "color": "black"},
|
||||
"running": {"background": "rgba(25, 113, 194, 0.7)", "color": "black"},
|
||||
"planned": {"background": "rgba(156, 54, 181, 0.7)", "color": "black"},
|
||||
"broken": {"background": "rgba(232, 89, 12, 0.7)", "color": "black"},
|
||||
"denied": {"background": "rgba(224, 49, 49, 0.7)", "color": "black"}
|
||||
}
|
||||
colors = status_colors.get(status, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
|
||||
return f"""
|
||||
qproperty-alignment: AlignCenter;
|
||||
background-color: {colors["background"]};
|
||||
color: {colors["color"]};
|
||||
font-size: {font_size_a};
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
"""
|
||||
|
||||
# СТИЛИ БЕЙДЖА STEAM
|
||||
STEAM_BADGE_STYLE= f"""
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
font-family: '{font_family}';
|
||||
font-weight: bold;
|
||||
"""
|
||||
|
||||
# ДОПОЛНИТЕЛЬНЫЕ СТИЛИ ИНФОРМАЦИИ НА СТРАНИЦЕ ИГР
|
||||
LAST_LAUNCH_TITLE_STYLE = f"font-family: '{font_family}'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
|
||||
LAST_LAUNCH_VALUE_STYLE = f"font-family: '{font_family}'; font-size: 13px; color: {color_f}; font-weight: 600; letter-spacing: 0.75px;"
|
||||
PLAY_TIME_TITLE_STYLE = f"font-family: '{font_family}'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
|
||||
PLAY_TIME_VALUE_STYLE = f"font-family: '{font_family}'; font-size: 13px; color: {color_f}; font-weight: 600; letter-spacing: 0.75px;"
|
||||
GAMEPAD_SUPPORT_VALUE_STYLE = f"""
|
||||
font-family: '{font_family}'; font-size: {font_size_a}; color: #00ff00;
|
||||
font-weight: bold; background: {color_g};
|
||||
border-radius: 5px; padding: 4px 8px;
|
||||
"""
|
||||
111
portprotonqt/themes/standart/styles/settings.py
Normal file
@@ -0,0 +1,111 @@
|
||||
from .constants import *
|
||||
|
||||
SETTINGS_COMBO_STYLE = f"""
|
||||
QComboBox {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_g};
|
||||
border-radius: {border_radius_a};
|
||||
height: 34px;
|
||||
padding-left: 12px;
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
min-width: 120px;
|
||||
combobox-popup: 0;
|
||||
}}
|
||||
QComboBox:on {{
|
||||
background: {color_b};
|
||||
border: {border_c} {color_a};
|
||||
border-bottom-style: none;
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}}
|
||||
QComboBox:hover {{
|
||||
border: {border_c} {color_a};
|
||||
background: {color_a};
|
||||
}}
|
||||
/* Состояние фокуса */
|
||||
QComboBox:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_a};
|
||||
}}
|
||||
QComboBox::drop-down {{
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: center right;
|
||||
border-left: {border_b} rgba(255, 255, 255, 0.05);
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox::down-arrow {{
|
||||
image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)});
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox::down-arrow:on {{
|
||||
image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)});
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
/* Список при открытом комбобоксе */
|
||||
QComboBox QAbstractItemView {{
|
||||
outline: none;
|
||||
border: {border_c} {color_a};
|
||||
border-top-style: none;
|
||||
border-top-left-radius: 0px;
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}}
|
||||
QListView {{
|
||||
background: {color_c};
|
||||
}}
|
||||
QListView::item {{
|
||||
padding: 7px 7px 7px 12px;
|
||||
margin: 3px;
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QListView::item:hover {{
|
||||
background: {color_b};
|
||||
}}
|
||||
QListView::item:selected {{
|
||||
background: {color_b};
|
||||
}}
|
||||
/* Выделение в списке при фокусе на элементе */
|
||||
QListView::item:focus {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
"""
|
||||
|
||||
SETTINGS_CHECKBOX_STYLE = f"""
|
||||
QCheckBox {{
|
||||
height: 34px;
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QCheckBox::indicator {{
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: {border_c} {color_g};
|
||||
border-radius: {border_radius_a};
|
||||
background: {color_b};
|
||||
}}
|
||||
QCheckBox::indicator:hover {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QCheckBox::indicator:focus {{
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QCheckBox::indicator:checked {{
|
||||
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
"""
|
||||
84
portprotonqt/themes/standart/styles/theme_utils.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from .constants import *
|
||||
|
||||
CONTEXT_MENU_STYLE = f"""
|
||||
QMenu {{
|
||||
background: {color_b};
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
padding: 5px;
|
||||
min-width: 150px;
|
||||
}}
|
||||
QMenu::icon {{
|
||||
margin-left: 15px;
|
||||
}}
|
||||
QMenu::item {{
|
||||
padding: 10px 20px 10px 10px;
|
||||
background: {color_h};
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QMenu::item:selected {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QMenu::item:disabled {{
|
||||
color: #7f7f7f;
|
||||
}}
|
||||
QMenu::item:hover {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QMenu::item:focus {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
border: {border_b} rgba(255, 255, 255, 0.3);
|
||||
border-radius: {border_radius_a};
|
||||
}}
|
||||
QMenu::separator {{
|
||||
height: 1px;
|
||||
background-color: #7f7f7f;
|
||||
margin: 3px 6px;
|
||||
}}
|
||||
"""
|
||||
|
||||
VIRTUAL_KEYBOARD_STYLE = f"""
|
||||
QWidget {{
|
||||
background: {color_i};
|
||||
}}
|
||||
QPushButton {{
|
||||
font-size: 14px;
|
||||
border: {border_a} {color_h};
|
||||
border-radius: {border_radius_a};
|
||||
min-width: 30px;
|
||||
min-height: 30px;
|
||||
padding: 5px;
|
||||
background-color: {color_c};
|
||||
color: {color_f};
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background-color: {color_a};
|
||||
border: {border_b} {color_a};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
border: {border_b} {color_a};
|
||||
background-color: {color_a};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background-color: {color_c};
|
||||
border: {border_a} {color_h};
|
||||
}}
|
||||
QPushButton[checked="true"] {{
|
||||
background-color: {color_a};
|
||||
color: {color_f};
|
||||
border: {border_a} {color_h};
|
||||
}}
|
||||
QPushButton[checked="true"]:focus {{
|
||||
border: {border_b} {color_f};
|
||||
}}
|
||||
"""
|
||||
|
||||
# СТИЛИ ПОЛНОЭКРАНОГО ПРЕВЬЮ СКРИНШОТОВ ТЕМЫ
|
||||
PREV_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
|
||||
NEXT_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
|
||||
CAPTION_LABEL_STYLE=f"color: white; font-size: {font_size_a};"
|
||||
317
portprotonqt/themes/standart/styles/winetricks.py
Normal file
@@ -0,0 +1,317 @@
|
||||
from .constants import *
|
||||
|
||||
WINETRICKS_TAB_STYLE = f"""
|
||||
QTabWidget::pane {{
|
||||
border-top: 1px solid {color_c};
|
||||
background: {color_h};
|
||||
}}
|
||||
QTabBar::tab {{
|
||||
background: {color_c};
|
||||
color: {color_f};
|
||||
padding: 8px 16px;
|
||||
border-top-left-radius: {border_radius_a};
|
||||
border-top-right-radius: {border_radius_a};
|
||||
margin-right: 2px;
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QTabBar::tab:hover {{
|
||||
background: {color_a};
|
||||
}}
|
||||
"""
|
||||
|
||||
WINETRICKS_TABBLE_STYLE = f"""
|
||||
QComboBox {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_g};
|
||||
border-radius: {border_radius_a};
|
||||
padding-left: 12px;
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
min-width: 120px;
|
||||
combobox-popup: 0;
|
||||
}}
|
||||
QComboBox:on {{
|
||||
background: {color_b};
|
||||
border: {border_c} {color_a};
|
||||
border-bottom-style: none;
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}}
|
||||
QComboBox:hover {{
|
||||
border: {border_c} {color_a};
|
||||
background: {color_a};
|
||||
}}
|
||||
/* Состояние фокуса */
|
||||
QComboBox:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_a};
|
||||
}}
|
||||
QComboBox:disabled {{
|
||||
background: #2a2c35;
|
||||
border: {border_c} #2a2c35;
|
||||
color: #777a84;
|
||||
}}
|
||||
QComboBox::drop-down {{
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: center right;
|
||||
border-left: {border_b} rgba(255, 255, 255, 0.05);
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox::down-arrow {{
|
||||
image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)});
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox::down-arrow:on {{
|
||||
image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)});
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
/* Список при открытом комбобоксе */
|
||||
QComboBox QAbstractItemView {{
|
||||
outline: none;
|
||||
background: {color_c};
|
||||
border: {border_c} {color_a};
|
||||
border-top-style: none;
|
||||
border-top-left-radius: 0px;
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}}
|
||||
QListView {{
|
||||
background: {color_c};
|
||||
}}
|
||||
QListView::item {{
|
||||
padding: 7px 7px 7px 12px;
|
||||
margin: 3px;
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QListView::item:hover {{
|
||||
background: {color_b};
|
||||
}}
|
||||
QListView::item:selected {{
|
||||
background: {color_b};
|
||||
}}
|
||||
/* Выделение в списке при фокусе на элементе */
|
||||
QListView::item:focus {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QLineEdit {{
|
||||
background: {color_c};
|
||||
border: {border_c} rgba(255, 255, 255, 0.01);
|
||||
border-radius: {border_radius_a};
|
||||
height: 34px;
|
||||
padding-left: 12px;
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QLineEdit:hover {{
|
||||
background: {color_c};
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QLineEdit:focus {{
|
||||
border: {border_c} {color_a};
|
||||
background-color: {color_e};
|
||||
}}
|
||||
QTableWidget {{
|
||||
background: {color_h};
|
||||
color: {color_f};
|
||||
gridline-color: {color_h};
|
||||
alternate-background-color: {color_d};
|
||||
border: {border_a};
|
||||
border-radius: {border_radius_a};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QHeaderView::section {{
|
||||
background: {color_d};
|
||||
color: {color_f};
|
||||
padding: 5px;
|
||||
border: {border_a};
|
||||
font-weight: bold;
|
||||
}}
|
||||
QTableWidget::item {{
|
||||
padding: 8px;
|
||||
border-bottom: {border_a } {color_c};
|
||||
height: 36px;
|
||||
}}
|
||||
QTableWidget::item:selected,
|
||||
QTableWidget::item:focus,
|
||||
QTableWidget::item:selected:focus {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
selection-background-color: {color_a};
|
||||
}}
|
||||
QTableWidget::item:hover {{
|
||||
background: {color_h};
|
||||
}}
|
||||
QTableWidget::indicator {{
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: {border_c} {color_h};
|
||||
border-radius: {border_radius_a};
|
||||
background: {color_b};
|
||||
}}
|
||||
QTableWidget::indicator:unchecked {{
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
image: none;
|
||||
}}
|
||||
QTableWidget::indicator:checked {{
|
||||
background: {color_b};
|
||||
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QTableWidget::indicator:hover {{
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QTableWidget::indicator:focus {{
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
QScrollBar:vertical {{
|
||||
width: 10px;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: #bebebe;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::add-line:vertical {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::sub-line:vertical {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {{
|
||||
border: {border_a};
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar:horizontal {{
|
||||
height: 10px;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
background: #bebebe;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::add-line:horizontal {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::sub-line:horizontal {{
|
||||
border: {border_a};
|
||||
background: none;
|
||||
}}
|
||||
QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal {{
|
||||
border: {border_a};
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}}
|
||||
"""
|
||||
|
||||
WINETRICKS_LOG_STYLE = f"""
|
||||
QTextEdit {{
|
||||
background: {color_c};
|
||||
border: {border_a};
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
padding: 5px;
|
||||
}}
|
||||
"""
|
||||
|
||||
FILE_EXPLORER_STYLE = f"""
|
||||
QListView {{
|
||||
font-size: {font_size_a};
|
||||
font-family: {font_family};
|
||||
background: {color_c};
|
||||
alternate-background-color: {color_c};
|
||||
color: {color_f};
|
||||
border-top-left-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
}}
|
||||
QListView::item {{
|
||||
padding: 8px;
|
||||
margin: 0px 5px;
|
||||
}}
|
||||
QListView::item:alternate {{
|
||||
margin: 0px 5px;
|
||||
background: {color_d};
|
||||
}}
|
||||
QListView::item:selected {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
border-radius: {border_radius_a};
|
||||
}}
|
||||
QListView::item:hover {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
border-radius: {border_radius_a};
|
||||
}}
|
||||
QListView::item:focus {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
border-radius: {border_radius_a};
|
||||
}}
|
||||
QScrollBar:vertical {{
|
||||
width: 10px;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
background: {color_c};
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: #bebebe;
|
||||
border: {border_a};
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::add-line:vertical {{
|
||||
border: {border_a};
|
||||
background: {color_c};
|
||||
border-bottom-right-radius: 5px;
|
||||
}}
|
||||
QScrollBar::sub-line:vertical {{
|
||||
border: {border_a};
|
||||
background: {color_c};
|
||||
border-top-right-radius: 5px;
|
||||
}}
|
||||
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {{
|
||||
border: {border_a};
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}}
|
||||
"""
|
||||
|
||||
FILE_EXPLORER_PATH_LABEL_STYLE = f"""
|
||||
QLabel {{
|
||||
color: {color_a};
|
||||
font-size: {font_size_a};
|
||||
font-family: {font_family};
|
||||
}}
|
||||
"""
|
||||
@@ -72,8 +72,6 @@ class TrayManager:
|
||||
self.tray_icon.setContextMenu(self.tray_menu)
|
||||
self.tray_icon.show()
|
||||
|
||||
self.main_window.is_exiting = False
|
||||
|
||||
self.click_count = 0
|
||||
self.click_timer = QTimer()
|
||||
self.click_timer.setSingleShot(True)
|
||||
@@ -231,7 +229,6 @@ class TrayManager:
|
||||
executable = sys.executable
|
||||
args = sys.argv
|
||||
|
||||
self.main_window.is_exiting = True
|
||||
QApplication.quit()
|
||||
|
||||
subprocess.Popen([executable] + args)
|
||||
@@ -241,11 +238,9 @@ class TrayManager:
|
||||
save_theme_to_config("standart")
|
||||
executable = sys.executable
|
||||
args = sys.argv
|
||||
self.main_window.is_exiting = True
|
||||
QApplication.quit()
|
||||
subprocess.Popen([executable] + args)
|
||||
|
||||
def force_exit(self):
|
||||
self.main_window.is_exiting = True
|
||||
self.main_window.close()
|
||||
sys.exit(0)
|
||||
|
||||
@@ -66,8 +66,11 @@ class VirtualKeyboard(QFrame):
|
||||
if not self.current_input_widget or not isinstance(self.current_input_widget, QLineEdit):
|
||||
return
|
||||
|
||||
# Просто устанавливаем курсор на нужную позицию без выделения
|
||||
self.current_input_widget.setCursorPosition(self.current_input_widget.cursorPosition())
|
||||
try:
|
||||
# Просто устанавливаем курсор на нужную позицию без выделения
|
||||
self.current_input_widget.setCursorPosition(self.current_input_widget.cursorPosition())
|
||||
except RuntimeError:
|
||||
self.current_input_widget = None
|
||||
|
||||
def initUI(self):
|
||||
layout = QVBoxLayout()
|
||||
@@ -166,8 +169,10 @@ class VirtualKeyboard(QFrame):
|
||||
# Очищаем предыдущие кнопки
|
||||
while self.keyboard_layout.count():
|
||||
item = self.keyboard_layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
if item:
|
||||
widget = item.widget()
|
||||
if widget:
|
||||
widget.deleteLater()
|
||||
|
||||
fixed_w = self.button_width
|
||||
fixed_h = self.button_height
|
||||
@@ -280,37 +285,51 @@ class VirtualKeyboard(QFrame):
|
||||
if coords:
|
||||
row, col = coords
|
||||
item = self.keyboard_layout.itemAtPosition(row, col)
|
||||
if item and item.widget():
|
||||
item.widget().setFocus()
|
||||
if item:
|
||||
widget = item.widget()
|
||||
if widget:
|
||||
widget.setFocus()
|
||||
|
||||
def up_key(self):
|
||||
"""Перемещает курсор в QLineEdit вверх/в начало, если клавиатура видима"""
|
||||
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
|
||||
self.current_input_widget.setCursorPosition(0)
|
||||
self.current_input_widget.setFocus()
|
||||
try:
|
||||
self.current_input_widget.setCursorPosition(0)
|
||||
self.current_input_widget.setFocus()
|
||||
except RuntimeError:
|
||||
self.current_input_widget = None
|
||||
|
||||
def down_key(self):
|
||||
"""Перемещает курсор в QLineEdit вниз/в конец, если клавиатура видима"""
|
||||
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
|
||||
self.current_input_widget.setCursorPosition(len(self.current_input_widget.text()))
|
||||
self.current_input_widget.setFocus()
|
||||
try:
|
||||
self.current_input_widget.setCursorPosition(len(self.current_input_widget.text()))
|
||||
self.current_input_widget.setFocus()
|
||||
except RuntimeError:
|
||||
self.current_input_widget = None
|
||||
|
||||
def left_key(self):
|
||||
"""Перемещает курсор в QLineEdit влево, если клавиатура видима"""
|
||||
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
|
||||
pos = self.current_input_widget.cursorPosition()
|
||||
if pos > 0:
|
||||
self.current_input_widget.setCursorPosition(pos - 1)
|
||||
self.current_input_widget.setFocus()
|
||||
try:
|
||||
pos = self.current_input_widget.cursorPosition()
|
||||
if pos > 0:
|
||||
self.current_input_widget.setCursorPosition(pos - 1)
|
||||
self.current_input_widget.setFocus()
|
||||
except RuntimeError:
|
||||
self.current_input_widget = None
|
||||
|
||||
def right_key(self):
|
||||
"""Перемещает курсор в QLineEdit вправо, если клавиатура видима"""
|
||||
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
|
||||
pos = self.current_input_widget.cursorPosition()
|
||||
text_len = len(self.current_input_widget.text())
|
||||
if pos < text_len:
|
||||
self.current_input_widget.setCursorPosition(pos + 1)
|
||||
self.current_input_widget.setFocus()
|
||||
try:
|
||||
pos = self.current_input_widget.cursorPosition()
|
||||
text_len = len(self.current_input_widget.text())
|
||||
if pos < text_len:
|
||||
self.current_input_widget.setCursorPosition(pos + 1)
|
||||
self.current_input_widget.setFocus()
|
||||
except RuntimeError:
|
||||
self.current_input_widget = None
|
||||
|
||||
def move_focus_up(self):
|
||||
"""Перемещает фокус по кнопкам клавиатуры вверх с фиксированной скоростью"""
|
||||
@@ -366,34 +385,41 @@ class VirtualKeyboard(QFrame):
|
||||
self.on_shift_click(not self.shift_pressed)
|
||||
self.highlight_cursor_position()
|
||||
elif self.current_input_widget is not None:
|
||||
# Сохраняем текущую кнопку с фокусом
|
||||
focused_button = self.focusWidget()
|
||||
key_to_restore = None
|
||||
if isinstance(focused_button, QPushButton) and focused_button in self.buttons.values():
|
||||
key_to_restore = next((k for k, btn in self.buttons.items() if btn == focused_button), None)
|
||||
try:
|
||||
# Сохраняем текущую кнопку с фокусом
|
||||
focused_button = self.focusWidget()
|
||||
key_to_restore = None
|
||||
if isinstance(focused_button, QPushButton) and focused_button in self.buttons.values():
|
||||
key_to_restore = next((k for k, btn in self.buttons.items() if btn == focused_button), None)
|
||||
|
||||
key = "&" if key == "&&" else key
|
||||
cursor_pos = self.current_input_widget.cursorPosition()
|
||||
text = self.current_input_widget.text()
|
||||
new_text = text[:cursor_pos] + key + text[cursor_pos:]
|
||||
self.current_input_widget.setText(new_text)
|
||||
self.current_input_widget.setCursorPosition(cursor_pos + len(key))
|
||||
self.keyPressed.emit(key)
|
||||
self.highlight_cursor_position()
|
||||
key = "&" if key == "&&" else key
|
||||
cursor_pos = self.current_input_widget.cursorPosition()
|
||||
text = self.current_input_widget.text()
|
||||
new_text = text[:cursor_pos] + key + text[cursor_pos:]
|
||||
self.current_input_widget.setText(new_text)
|
||||
self.current_input_widget.setCursorPosition(cursor_pos + len(key))
|
||||
self.keyPressed.emit(key)
|
||||
self.highlight_cursor_position()
|
||||
|
||||
# Если был нажат SHIFT, но не CapsLock, отключаем его после ввода символа
|
||||
if self.shift_pressed and not self.caps_lock:
|
||||
self.shift_pressed = False
|
||||
self.update_keyboard()
|
||||
if key_to_restore and key_to_restore in self.buttons:
|
||||
self.buttons[key_to_restore].setFocus()
|
||||
# Если был нажат SHIFT, но не CapsLock, отключаем его после ввода символа
|
||||
if self.shift_pressed and not self.caps_lock:
|
||||
self.shift_pressed = False
|
||||
self.update_keyboard()
|
||||
if key_to_restore and key_to_restore in self.buttons:
|
||||
self.buttons[key_to_restore].setFocus()
|
||||
except RuntimeError:
|
||||
self.current_input_widget = None
|
||||
|
||||
def on_tab_click(self):
|
||||
if self.current_input_widget is not None:
|
||||
self.current_input_widget.insert('\t')
|
||||
self.keyPressed.emit('Tab')
|
||||
self.current_input_widget.setFocus()
|
||||
self.highlight_cursor_position()
|
||||
try:
|
||||
self.current_input_widget.insert('\t')
|
||||
self.keyPressed.emit('Tab')
|
||||
if self.current_input_widget:
|
||||
self.current_input_widget.setFocus()
|
||||
self.highlight_cursor_position()
|
||||
except RuntimeError:
|
||||
self.current_input_widget = None
|
||||
|
||||
def on_caps_click(self):
|
||||
"""Включаем/выключаем CapsLock"""
|
||||
@@ -412,15 +438,18 @@ class VirtualKeyboard(QFrame):
|
||||
def on_backspace_click(self):
|
||||
"""Обработка одного нажатия Backspace"""
|
||||
if self.current_input_widget is not None:
|
||||
cursor_pos = self.current_input_widget.cursorPosition()
|
||||
text = self.current_input_widget.text()
|
||||
try:
|
||||
cursor_pos = self.current_input_widget.cursorPosition()
|
||||
text = self.current_input_widget.text()
|
||||
|
||||
if cursor_pos > 0:
|
||||
new_text = text[:cursor_pos - 1] + text[cursor_pos:]
|
||||
self.current_input_widget.setText(new_text)
|
||||
self.current_input_widget.setCursorPosition(cursor_pos - 1)
|
||||
self.keyPressed.emit('Backspace')
|
||||
self.highlight_cursor_position()
|
||||
if cursor_pos > 0:
|
||||
new_text = text[:cursor_pos - 1] + text[cursor_pos:]
|
||||
self.current_input_widget.setText(new_text)
|
||||
self.current_input_widget.setCursorPosition(cursor_pos - 1)
|
||||
self.keyPressed.emit('Backspace')
|
||||
self.highlight_cursor_position()
|
||||
except RuntimeError:
|
||||
self.current_input_widget = None
|
||||
|
||||
def on_backspace_pressed(self):
|
||||
"""Обработка зажатого Backspace"""
|
||||
@@ -444,15 +473,21 @@ class VirtualKeyboard(QFrame):
|
||||
# TODO: тут подумать, как обрабатывать нажатие.
|
||||
# Пока болванка перехода на новую строку, в QlineEdit работает как нажатие пробела
|
||||
if self.current_input_widget is not None:
|
||||
self.current_input_widget.insert('\n')
|
||||
self.keyPressed.emit('Enter')
|
||||
try:
|
||||
self.current_input_widget.insert('\n')
|
||||
self.keyPressed.emit('Enter')
|
||||
except RuntimeError:
|
||||
self.current_input_widget = None
|
||||
|
||||
def on_clear_click(self):
|
||||
"""Чистим строку от введённого текста"""
|
||||
if self.current_input_widget is not None:
|
||||
self.current_input_widget.clear()
|
||||
self.keyPressed.emit('Clear')
|
||||
self.highlight_cursor_position()
|
||||
try:
|
||||
self.current_input_widget.clear()
|
||||
self.keyPressed.emit('Clear')
|
||||
self.highlight_cursor_position()
|
||||
except RuntimeError:
|
||||
self.current_input_widget = None
|
||||
|
||||
def on_lang_click(self):
|
||||
"""Переключение раскладки"""
|
||||
@@ -478,8 +513,11 @@ class VirtualKeyboard(QFrame):
|
||||
def show_for_widget(self, widget):
|
||||
self.current_input_widget = widget
|
||||
if widget:
|
||||
widget.setFocus()
|
||||
self.highlight_cursor_position()
|
||||
try:
|
||||
widget.setFocus()
|
||||
self.highlight_cursor_position()
|
||||
except RuntimeError:
|
||||
self.current_input_widget = None
|
||||
|
||||
# Позиционирование клавиатуры внизу родительского виджета
|
||||
if self._parent and isinstance(self._parent, QWidget):
|
||||
@@ -530,8 +568,9 @@ class VirtualKeyboard(QFrame):
|
||||
search_col = current_col + col_span
|
||||
while search_col < num_cols:
|
||||
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
|
||||
if item and item.widget() and item.widget().isEnabled():
|
||||
next_button = cast(QPushButton, item.widget())
|
||||
widget = item.widget() if item else None
|
||||
if widget and widget.isEnabled():
|
||||
next_button = cast(QPushButton, widget)
|
||||
next_button.setFocus()
|
||||
found = True
|
||||
break
|
||||
@@ -544,8 +583,9 @@ class VirtualKeyboard(QFrame):
|
||||
# Ищем первую кнопку в этой строке
|
||||
while search_col < num_cols:
|
||||
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
|
||||
if item and item.widget() and item.widget().isEnabled():
|
||||
next_button = cast(QPushButton, item.widget())
|
||||
widget = item.widget() if item else None
|
||||
if widget and widget.isEnabled():
|
||||
next_button = cast(QPushButton, widget)
|
||||
next_button.setFocus()
|
||||
found = True
|
||||
break
|
||||
@@ -557,8 +597,9 @@ class VirtualKeyboard(QFrame):
|
||||
search_col = current_col - 1
|
||||
while search_col >= 0:
|
||||
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
|
||||
if item and item.widget() and item.widget().isEnabled():
|
||||
next_button = cast(QPushButton, item.widget())
|
||||
widget = item.widget() if item else None
|
||||
if widget and widget.isEnabled():
|
||||
next_button = cast(QPushButton, widget)
|
||||
next_button.setFocus()
|
||||
found = True
|
||||
break
|
||||
@@ -571,8 +612,9 @@ class VirtualKeyboard(QFrame):
|
||||
# Ищем последнюю кнопку в этой строке
|
||||
while search_col >= 0:
|
||||
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
|
||||
if item and item.widget() and item.widget().isEnabled():
|
||||
next_button = cast(QPushButton, item.widget())
|
||||
widget = item.widget() if item else None
|
||||
if widget and widget.isEnabled():
|
||||
next_button = cast(QPushButton, widget)
|
||||
next_button.setFocus()
|
||||
found = True
|
||||
break
|
||||
@@ -584,8 +626,9 @@ class VirtualKeyboard(QFrame):
|
||||
search_row = current_row + row_span
|
||||
while search_row < num_rows:
|
||||
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
|
||||
if item and item.widget() and item.widget().isEnabled():
|
||||
next_button = cast(QPushButton, item.widget())
|
||||
widget = item.widget() if item else None
|
||||
if widget and widget.isEnabled():
|
||||
next_button = cast(QPushButton, widget)
|
||||
next_button.setFocus()
|
||||
found = True
|
||||
break
|
||||
@@ -598,8 +641,9 @@ class VirtualKeyboard(QFrame):
|
||||
# Ищем первую кнопку в этом столбце
|
||||
while search_row < num_rows:
|
||||
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
|
||||
if item and item.widget() and item.widget().isEnabled():
|
||||
next_button = cast(QPushButton, item.widget())
|
||||
widget = item.widget() if item else None
|
||||
if widget and widget.isEnabled():
|
||||
next_button = cast(QPushButton, widget)
|
||||
next_button.setFocus()
|
||||
found = True
|
||||
break
|
||||
@@ -611,8 +655,9 @@ class VirtualKeyboard(QFrame):
|
||||
search_row = current_row - 1
|
||||
while search_row >= 0:
|
||||
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
|
||||
if item and item.widget() and item.widget().isEnabled():
|
||||
next_button = cast(QPushButton, item.widget())
|
||||
widget = item.widget() if item else None
|
||||
if widget and widget.isEnabled():
|
||||
next_button = cast(QPushButton, widget)
|
||||
next_button.setFocus()
|
||||
found = True
|
||||
break
|
||||
@@ -625,8 +670,9 @@ class VirtualKeyboard(QFrame):
|
||||
# Ищем последнюю кнопку в этом столбце
|
||||
while search_row >= 0:
|
||||
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
|
||||
if item and item.widget() and item.widget().isEnabled():
|
||||
next_button = cast(QPushButton, item.widget())
|
||||
widget = item.widget() if item else None
|
||||
if widget and widget.isEnabled():
|
||||
next_button = cast(QPushButton, widget)
|
||||
next_button.setFocus()
|
||||
found = True
|
||||
break
|
||||
@@ -637,6 +683,7 @@ class VirtualKeyboard(QFrame):
|
||||
for row in range(self.keyboard_layout.rowCount()):
|
||||
for col in range(self.keyboard_layout.columnCount()):
|
||||
item = self.keyboard_layout.itemAtPosition(row, col)
|
||||
if item and item.widget() and item.widget().isEnabled():
|
||||
return cast(QPushButton, item.widget())
|
||||
widget = item.widget() if item else None
|
||||
if widget and widget.isEnabled():
|
||||
return cast(QPushButton, widget)
|
||||
return None
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portprotonqt"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
|
||||
readme = "README.md"
|
||||
license = { text = "GPL-3.0" }
|
||||
@@ -27,15 +27,16 @@ classifiers = [
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"babel>=2.17.0",
|
||||
"beautifulsoup4>=4.14.2",
|
||||
"beautifulsoup4>=4.14.3",
|
||||
"evdev>=1.9.2",
|
||||
"icoextract>=0.2.0",
|
||||
"numpy>=2.2.4",
|
||||
"orjson>=3.11.3",
|
||||
"orjson>=3.11.5",
|
||||
"pillow>=12.0.0",
|
||||
"psutil>=7.1.0",
|
||||
"pyside6==6.9.1",
|
||||
"pyudev>=0.24.3",
|
||||
"psutil>=7.1.3",
|
||||
"pyside6>=6.10.1",
|
||||
"pyudev>=0.24.4",
|
||||
"rapidfuzz>=3.14.3",
|
||||
"requests>=2.32.5",
|
||||
"tqdm>=4.67.1",
|
||||
"vdf>=3.4",
|
||||
@@ -103,7 +104,7 @@ ignore = [
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pre-commit>=4.3.0",
|
||||
"pre-commit>=4.5.1",
|
||||
"pyaspeller>=2.0.2",
|
||||
"pyright>=1.1.406",
|
||||
"pyright>=1.1.407",
|
||||
]
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"groupName": "Python dependencies"
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["numpy", "setuptools", "python", "pyside6"],
|
||||
"matchPackageNames": ["numpy", "setuptools", "python"],
|
||||
"enabled": false,
|
||||
"description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)"
|
||||
},
|
||||
|
||||
701
uv.lock
generated
@@ -17,97 +17,122 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "beautifulsoup4"
|
||||
version = "4.14.2"
|
||||
version = "4.14.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "soupsieve" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.10.5"
|
||||
version = "2025.11.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfgv"
|
||||
version = "3.4.0"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.3"
|
||||
version = "3.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -136,11 +161,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/63/fe/a17c106a1f4061ce8
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.19.1"
|
||||
version = "3.20.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -163,20 +188,20 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.9.1"
|
||||
version = "1.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -246,163 +271,165 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.3.3"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.11'",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/19/95b3d357407220ed24c139018d2518fab0a61a948e68286a25f1a4d049ff/numpy-2.3.3.tar.gz", hash = "sha256:ddc7c39727ba62b80dfdbedf400d1c10ddfa8eefbd7ec8dcb118be8b56d31029", size = 20576648, upload-time = "2025-09-09T16:54:12.543Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/7a/6a3d14e205d292b738db449d0de649b373a59edb0d0b4493821d0a3e8718/numpy-2.4.0.tar.gz", hash = "sha256:6e504f7b16118198f138ef31ba24d985b124c2c469fe8467007cf30fd992f934", size = 20685720, upload-time = "2025-12-20T16:18:19.023Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/45/e80d203ef6b267aa29b22714fb558930b27960a0c5ce3c19c999232bb3eb/numpy-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ffc4f5caba7dfcbe944ed674b7eef683c7e94874046454bb79ed7ee0236f59d", size = 21259253, upload-time = "2025-09-09T15:56:02.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/18/cf2c648fccf339e59302e00e5f2bc87725a3ce1992f30f3f78c9044d7c43/numpy-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e7e946c7170858a0295f79a60214424caac2ffdb0063d4d79cb681f9aa0aa569", size = 14450980, upload-time = "2025-09-09T15:56:05.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/fb/9af1082bec870188c42a1c239839915b74a5099c392389ff04215dcee812/numpy-2.3.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:cd4260f64bc794c3390a63bf0728220dd1a68170c169088a1e0dfa2fde1be12f", size = 5379709, upload-time = "2025-09-09T15:56:07.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/0f/bfd7abca52bcbf9a4a65abc83fe18ef01ccdeb37bfb28bbd6ad613447c79/numpy-2.3.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:f0ddb4b96a87b6728df9362135e764eac3cfa674499943ebc44ce96c478ab125", size = 6913923, upload-time = "2025-09-09T15:56:09.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/55/d69adad255e87ab7afda1caf93ca997859092afeb697703e2f010f7c2e55/numpy-2.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afd07d377f478344ec6ca2b8d4ca08ae8bd44706763d1efb56397de606393f48", size = 14589591, upload-time = "2025-09-09T15:56:11.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/a2/010b0e27ddeacab7839957d7a8f00e91206e0c2c47abbb5f35a2630e5387/numpy-2.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc92a5dedcc53857249ca51ef29f5e5f2f8c513e22cfb90faeb20343b8c6f7a6", size = 16938714, upload-time = "2025-09-09T15:56:14.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/6b/12ce8ede632c7126eb2762b9e15e18e204b81725b81f35176eac14dc5b82/numpy-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7af05ed4dc19f308e1d9fc759f36f21921eb7bbfc82843eeec6b2a2863a0aefa", size = 16370592, upload-time = "2025-09-09T15:56:17.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/35/aba8568b2593067bb6a8fe4c52babb23b4c3b9c80e1b49dff03a09925e4a/numpy-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:433bf137e338677cebdd5beac0199ac84712ad9d630b74eceeb759eaa45ddf30", size = 18884474, upload-time = "2025-09-09T15:56:20.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/fa/7f43ba10c77575e8be7b0138d107e4f44ca4a1ef322cd16980ea3e8b8222/numpy-2.3.3-cp311-cp311-win32.whl", hash = "sha256:eb63d443d7b4ffd1e873f8155260d7f58e7e4b095961b01c91062935c2491e57", size = 6599794, upload-time = "2025-09-09T15:56:23.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/a2/a4f78cb2241fe5664a22a10332f2be886dcdea8784c9f6a01c272da9b426/numpy-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:ec9d249840f6a565f58d8f913bccac2444235025bbb13e9a4681783572ee3caa", size = 13088104, upload-time = "2025-09-09T15:56:25.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/64/e424e975adbd38282ebcd4891661965b78783de893b381cbc4832fb9beb2/numpy-2.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:74c2a948d02f88c11a3c075d9733f1ae67d97c6bdb97f2bb542f980458b257e7", size = 10460772, upload-time = "2025-09-09T15:56:27.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/5d/bb7fc075b762c96329147799e1bcc9176ab07ca6375ea976c475482ad5b3/numpy-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cfdd09f9c84a1a934cde1eec2267f0a43a7cd44b2cca4ff95b7c0d14d144b0bf", size = 20957014, upload-time = "2025-09-09T15:56:29.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/0e/c6211bb92af26517acd52125a237a92afe9c3124c6a68d3b9f81b62a0568/numpy-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb32e3cf0f762aee47ad1ddc6672988f7f27045b0783c887190545baba73aa25", size = 14185220, upload-time = "2025-09-09T15:56:32.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/f2/07bb754eb2ede9073f4054f7c0286b0d9d2e23982e090a80d478b26d35ca/numpy-2.3.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:396b254daeb0a57b1fe0ecb5e3cff6fa79a380fa97c8f7781a6d08cd429418fe", size = 5113918, upload-time = "2025-09-09T15:56:34.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/0a/afa51697e9fb74642f231ea36aca80fa17c8fb89f7a82abd5174023c3960/numpy-2.3.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:067e3d7159a5d8f8a0b46ee11148fc35ca9b21f61e3c49fbd0a027450e65a33b", size = 6647922, upload-time = "2025-09-09T15:56:36.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/f5/122d9cdb3f51c520d150fef6e87df9279e33d19a9611a87c0d2cf78a89f4/numpy-2.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c02d0629d25d426585fb2e45a66154081b9fa677bc92a881ff1d216bc9919a8", size = 14281991, upload-time = "2025-09-09T15:56:40.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/64/7de3c91e821a2debf77c92962ea3fe6ac2bc45d0778c1cbe15d4fce2fd94/numpy-2.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9192da52b9745f7f0766531dcfa978b7763916f158bb63bdb8a1eca0068ab20", size = 16641643, upload-time = "2025-09-09T15:56:43.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/e4/961a5fa681502cd0d68907818b69f67542695b74e3ceaa513918103b7e80/numpy-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cd7de500a5b66319db419dc3c345244404a164beae0d0937283b907d8152e6ea", size = 16056787, upload-time = "2025-09-09T15:56:46.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/26/92c912b966e47fbbdf2ad556cb17e3a3088e2e1292b9833be1dfa5361a1a/numpy-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:93d4962d8f82af58f0b2eb85daaf1b3ca23fe0a85d0be8f1f2b7bb46034e56d7", size = 18579598, upload-time = "2025-09-09T15:56:49.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/b6/fc8f82cb3520768718834f310c37d96380d9dc61bfdaf05fe5c0b7653e01/numpy-2.3.3-cp312-cp312-win32.whl", hash = "sha256:5534ed6b92f9b7dca6c0a19d6df12d41c68b991cef051d108f6dbff3babc4ebf", size = 6320800, upload-time = "2025-09-09T15:56:52.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/ee/de999f2625b80d043d6d2d628c07d0d5555a677a3cf78fdf868d409b8766/numpy-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:497d7cad08e7092dba36e3d296fe4c97708c93daf26643a1ae4b03f6294d30eb", size = 12786615, upload-time = "2025-09-09T15:56:54.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/6e/b479032f8a43559c383acb20816644f5f91c88f633d9271ee84f3b3a996c/numpy-2.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:ca0309a18d4dfea6fc6262a66d06c26cfe4640c3926ceec90e57791a82b6eee5", size = 10195936, upload-time = "2025-09-09T15:56:56.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/b9/984c2b1ee61a8b803bf63582b4ac4242cf76e2dbd663efeafcb620cc0ccb/numpy-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f5415fb78995644253370985342cd03572ef8620b934da27d77377a2285955bf", size = 20949588, upload-time = "2025-09-09T15:56:59.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/e4/07970e3bed0b1384d22af1e9912527ecbeb47d3b26e9b6a3bced068b3bea/numpy-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d00de139a3324e26ed5b95870ce63be7ec7352171bc69a4cf1f157a48e3eb6b7", size = 14177802, upload-time = "2025-09-09T15:57:01.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/c7/477a83887f9de61f1203bad89cf208b7c19cc9fef0cebef65d5a1a0619f2/numpy-2.3.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9dc13c6a5829610cc07422bc74d3ac083bd8323f14e2827d992f9e52e22cd6a6", size = 5106537, upload-time = "2025-09-09T15:57:03.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/47/93b953bd5866a6f6986344d045a207d3f1cfbad99db29f534ea9cee5108c/numpy-2.3.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d79715d95f1894771eb4e60fb23f065663b2298f7d22945d66877aadf33d00c7", size = 6640743, upload-time = "2025-09-09T15:57:07.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/83/377f84aaeb800b64c0ef4de58b08769e782edcefa4fea712910b6f0afd3c/numpy-2.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:952cfd0748514ea7c3afc729a0fc639e61655ce4c55ab9acfab14bda4f402b4c", size = 14278881, upload-time = "2025-09-09T15:57:11.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/a5/bf3db6e66c4b160d6ea10b534c381a1955dfab34cb1017ea93aa33c70ed3/numpy-2.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b83648633d46f77039c29078751f80da65aa64d5622a3cd62aaef9d835b6c93", size = 16636301, upload-time = "2025-09-09T15:57:14.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/59/1287924242eb4fa3f9b3a2c30400f2e17eb2707020d1c5e3086fe7330717/numpy-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b001bae8cea1c7dfdb2ae2b017ed0a6f2102d7a70059df1e338e307a4c78a8ae", size = 16053645, upload-time = "2025-09-09T15:57:16.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/93/b3d47ed882027c35e94ac2320c37e452a549f582a5e801f2d34b56973c97/numpy-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8e9aced64054739037d42fb84c54dd38b81ee238816c948c8f3ed134665dcd86", size = 18578179, upload-time = "2025-09-09T15:57:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/d9/487a2bccbf7cc9d4bfc5f0f197761a5ef27ba870f1e3bbb9afc4bbe3fcc2/numpy-2.3.3-cp313-cp313-win32.whl", hash = "sha256:9591e1221db3f37751e6442850429b3aabf7026d3b05542d102944ca7f00c8a8", size = 6312250, upload-time = "2025-09-09T15:57:21.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/b5/263ebbbbcede85028f30047eab3d58028d7ebe389d6493fc95ae66c636ab/numpy-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f0dadeb302887f07431910f67a14d57209ed91130be0adea2f9793f1a4f817cf", size = 12783269, upload-time = "2025-09-09T15:57:23.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/75/67b8ca554bbeaaeb3fac2e8bce46967a5a06544c9108ec0cf5cece559b6c/numpy-2.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:3c7cf302ac6e0b76a64c4aecf1a09e51abd9b01fc7feee80f6c43e3ab1b1dbc5", size = 10195314, upload-time = "2025-09-09T15:57:25.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/d0/0d1ddec56b162042ddfafeeb293bac672de9b0cfd688383590090963720a/numpy-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:eda59e44957d272846bb407aad19f89dc6f58fecf3504bd144f4c5cf81a7eacc", size = 21048025, upload-time = "2025-09-09T15:57:27.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/9e/1996ca6b6d00415b6acbdd3c42f7f03ea256e2c3f158f80bd7436a8a19f3/numpy-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:823d04112bc85ef5c4fda73ba24e6096c8f869931405a80aa8b0e604510a26bc", size = 14301053, upload-time = "2025-09-09T15:57:30.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/24/43da09aa764c68694b76e84b3d3f0c44cb7c18cdc1ba80e48b0ac1d2cd39/numpy-2.3.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:40051003e03db4041aa325da2a0971ba41cf65714e65d296397cc0e32de6018b", size = 5229444, upload-time = "2025-09-09T15:57:32.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/14/50ffb0f22f7218ef8af28dd089f79f68289a7a05a208db9a2c5dcbe123c1/numpy-2.3.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6ee9086235dd6ab7ae75aba5662f582a81ced49f0f1c6de4260a78d8f2d91a19", size = 6738039, upload-time = "2025-09-09T15:57:34.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/52/af46ac0795e09657d45a7f4db961917314377edecf66db0e39fa7ab5c3d3/numpy-2.3.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94fcaa68757c3e2e668ddadeaa86ab05499a70725811e582b6a9858dd472fb30", size = 14352314, upload-time = "2025-09-09T15:57:36.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/b1/dc226b4c90eb9f07a3fff95c2f0db3268e2e54e5cce97c4ac91518aee71b/numpy-2.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da1a74b90e7483d6ce5244053399a614b1d6b7bc30a60d2f570e5071f8959d3e", size = 16701722, upload-time = "2025-09-09T15:57:38.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/9d/9d8d358f2eb5eced14dba99f110d83b5cd9a4460895230f3b396ad19a323/numpy-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2990adf06d1ecee3b3dcbb4977dfab6e9f09807598d647f04d385d29e7a3c3d3", size = 16132755, upload-time = "2025-09-09T15:57:41.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/27/b3922660c45513f9377b3fb42240bec63f203c71416093476ec9aa0719dc/numpy-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ed635ff692483b8e3f0fcaa8e7eb8a75ee71aa6d975388224f70821421800cea", size = 18651560, upload-time = "2025-09-09T15:57:43.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/8e/3ab61a730bdbbc201bb245a71102aa609f0008b9ed15255500a99cd7f780/numpy-2.3.3-cp313-cp313t-win32.whl", hash = "sha256:a333b4ed33d8dc2b373cc955ca57babc00cd6f9009991d9edc5ddbc1bac36bcd", size = 6442776, upload-time = "2025-09-09T15:57:45.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/3a/e22b766b11f6030dc2decdeff5c2fb1610768055603f9f3be88b6d192fb2/numpy-2.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:4384a169c4d8f97195980815d6fcad04933a7e1ab3b530921c3fef7a1c63426d", size = 12927281, upload-time = "2025-09-09T15:57:47.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/42/c2e2bc48c5e9b2a83423f99733950fbefd86f165b468a3d85d52b30bf782/numpy-2.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:75370986cc0bc66f4ce5110ad35aae6d182cc4ce6433c40ad151f53690130bf1", size = 10265275, upload-time = "2025-09-09T15:57:49.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/01/342ad585ad82419b99bcf7cebe99e61da6bedb89e213c5fd71acc467faee/numpy-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cd052f1fa6a78dee696b58a914b7229ecfa41f0a6d96dc663c1220a55e137593", size = 20951527, upload-time = "2025-09-09T15:57:52.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/d8/204e0d73fc1b7a9ee80ab1fe1983dd33a4d64a4e30a05364b0208e9a241a/numpy-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:414a97499480067d305fcac9716c29cf4d0d76db6ebf0bf3cbce666677f12652", size = 14186159, upload-time = "2025-09-09T15:57:54.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/af/f11c916d08f3a18fb8ba81ab72b5b74a6e42ead4c2846d270eb19845bf74/numpy-2.3.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:50a5fe69f135f88a2be9b6ca0481a68a136f6febe1916e4920e12f1a34e708a7", size = 5114624, upload-time = "2025-09-09T15:57:56.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/11/0ed919c8381ac9d2ffacd63fd1f0c34d27e99cab650f0eb6f110e6ae4858/numpy-2.3.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:b912f2ed2b67a129e6a601e9d93d4fa37bef67e54cac442a2f588a54afe5c67a", size = 6642627, upload-time = "2025-09-09T15:57:58.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/83/deb5f77cb0f7ba6cb52b91ed388b47f8f3c2e9930d4665c600408d9b90b9/numpy-2.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e318ee0596d76d4cb3d78535dc005fa60e5ea348cd131a51e99d0bdbe0b54fe", size = 14296926, upload-time = "2025-09-09T15:58:00.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/cc/70e59dcb84f2b005d4f306310ff0a892518cc0c8000a33d0e6faf7ca8d80/numpy-2.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce020080e4a52426202bdb6f7691c65bb55e49f261f31a8f506c9f6bc7450421", size = 16638958, upload-time = "2025-09-09T15:58:02.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/5a/b2ab6c18b4257e099587d5b7f903317bd7115333ad8d4ec4874278eafa61/numpy-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6687dc183aa55dae4a705b35f9c0f8cb178bcaa2f029b241ac5356221d5c021", size = 16071920, upload-time = "2025-09-09T15:58:05.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/f1/8b3fdc44324a259298520dd82147ff648979bed085feeacc1250ef1656c0/numpy-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8f3b1080782469fdc1718c4ed1d22549b5fb12af0d57d35e992158a772a37cf", size = 18577076, upload-time = "2025-09-09T15:58:07.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/a1/b87a284fb15a42e9274e7fcea0dad259d12ddbf07c1595b26883151ca3b4/numpy-2.3.3-cp314-cp314-win32.whl", hash = "sha256:cb248499b0bc3be66ebd6578b83e5acacf1d6cb2a77f2248ce0e40fbec5a76d0", size = 6366952, upload-time = "2025-09-09T15:58:10.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/5f/1816f4d08f3b8f66576d8433a66f8fa35a5acfb3bbd0bf6c31183b003f3d/numpy-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:691808c2b26b0f002a032c73255d0bd89751425f379f7bcd22d140db593a96e8", size = 12919322, upload-time = "2025-09-09T15:58:12.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/de/072420342e46a8ea41c324a555fa90fcc11637583fb8df722936aed1736d/numpy-2.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:9ad12e976ca7b10f1774b03615a2a4bab8addce37ecc77394d8e986927dc0dfe", size = 10478630, upload-time = "2025-09-09T15:58:14.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/df/ee2f1c0a9de7347f14da5dd3cd3c3b034d1b8607ccb6883d7dd5c035d631/numpy-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9cc48e09feb11e1db00b320e9d30a4151f7369afb96bd0e48d942d09da3a0d00", size = 21047987, upload-time = "2025-09-09T15:58:16.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/92/9453bdc5a4e9e69cf4358463f25e8260e2ffc126d52e10038b9077815989/numpy-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:901bf6123879b7f251d3631967fd574690734236075082078e0571977c6a8e6a", size = 14301076, upload-time = "2025-09-09T15:58:20.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/77/1447b9eb500f028bb44253105bd67534af60499588a5149a94f18f2ca917/numpy-2.3.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:7f025652034199c301049296b59fa7d52c7e625017cae4c75d8662e377bf487d", size = 5229491, upload-time = "2025-09-09T15:58:22.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/f9/d72221b6ca205f9736cb4b2ce3b002f6e45cd67cd6a6d1c8af11a2f0b649/numpy-2.3.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:533ca5f6d325c80b6007d4d7fb1984c303553534191024ec6a524a4c92a5935a", size = 6737913, upload-time = "2025-09-09T15:58:24.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/5f/d12834711962ad9c46af72f79bb31e73e416ee49d17f4c797f72c96b6ca5/numpy-2.3.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0edd58682a399824633b66885d699d7de982800053acf20be1eaa46d92009c54", size = 14352811, upload-time = "2025-09-09T15:58:26.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/0d/fdbec6629d97fd1bebed56cd742884e4eead593611bbe1abc3eb40d304b2/numpy-2.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:367ad5d8fbec5d9296d18478804a530f1191e24ab4d75ab408346ae88045d25e", size = 16702689, upload-time = "2025-09-09T15:58:28.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/09/0a35196dc5575adde1eb97ddfbc3e1687a814f905377621d18ca9bc2b7dd/numpy-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8f6ac61a217437946a1fa48d24c47c91a0c4f725237871117dea264982128097", size = 16133855, upload-time = "2025-09-09T15:58:31.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/ca/c9de3ea397d576f1b6753eaa906d4cdef1bf97589a6d9825a349b4729cc2/numpy-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:179a42101b845a816d464b6fe9a845dfaf308fdfc7925387195570789bb2c970", size = 18652520, upload-time = "2025-09-09T15:58:33.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/c2/e5ed830e08cd0196351db55db82f65bc0ab05da6ef2b72a836dcf1936d2f/numpy-2.3.3-cp314-cp314t-win32.whl", hash = "sha256:1250c5d3d2562ec4174bce2e3a1523041595f9b651065e4a4473f5f48a6bc8a5", size = 6515371, upload-time = "2025-09-09T15:58:36.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c7/b0f6b5b67f6788a0725f744496badbb604d226bf233ba716683ebb47b570/numpy-2.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b37a0b2e5935409daebe82c1e42274d30d9dd355852529eab91dab8dcca7419f", size = 13112576, upload-time = "2025-09-09T15:58:37.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/b9/33bba5ff6fb679aa0b1f8a07e853f002a6b04b9394db3069a1270a7784ca/numpy-2.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:78c9f6560dc7e6b3990e32df7ea1a50bbd0e2a111e05209963f5ddcab7073b0b", size = 10545953, upload-time = "2025-09-09T15:58:40.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/f2/7e0a37cfced2644c9563c529f29fa28acbd0960dde32ece683aafa6f4949/numpy-2.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1e02c7159791cd481e1e6d5ddd766b62a4d5acf8df4d4d1afe35ee9c5c33a41e", size = 21131019, upload-time = "2025-09-09T15:58:42.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/7e/3291f505297ed63831135a6cc0f474da0c868a1f31b0dd9a9f03a7a0d2ed/numpy-2.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:dca2d0fc80b3893ae72197b39f69d55a3cd8b17ea1b50aa4c62de82419936150", size = 14376288, upload-time = "2025-09-09T15:58:45.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/4b/ae02e985bdeee73d7b5abdefeb98aef1207e96d4c0621ee0cf228ddfac3c/numpy-2.3.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:99683cbe0658f8271b333a1b1b4bb3173750ad59c0c61f5bbdc5b318918fffe3", size = 5305425, upload-time = "2025-09-09T15:58:48.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/eb/9df215d6d7250db32007941500dc51c48190be25f2401d5b2b564e467247/numpy-2.3.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d9d537a39cc9de668e5cd0e25affb17aec17b577c6b3ae8a3d866b479fbe88d0", size = 6819053, upload-time = "2025-09-09T15:58:50.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/62/208293d7d6b2a8998a4a1f23ac758648c3c32182d4ce4346062018362e29/numpy-2.3.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8596ba2f8af5f93b01d97563832686d20206d303024777f6dfc2e7c7c3f1850e", size = 14420354, upload-time = "2025-09-09T15:58:52.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/0c/8e86e0ff7072e14a71b4c6af63175e40d1e7e933ce9b9e9f765a95b4e0c3/numpy-2.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1ec5615b05369925bd1125f27df33f3b6c8bc10d788d5999ecd8769a1fa04db", size = 16760413, upload-time = "2025-09-09T15:58:55.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/11/0cc63f9f321ccf63886ac203336777140011fb669e739da36d8db3c53b98/numpy-2.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2e267c7da5bf7309670523896df97f93f6e469fb931161f483cd6882b3b1a5dc", size = 12971844, upload-time = "2025-09-09T15:58:57.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/7e/7bae7cbcc2f8132271967aa03e03954fc1e48aa1f3bf32b29ca95fbef352/numpy-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:316b2f2584682318539f0bcaca5a496ce9ca78c88066579ebd11fd06f8e4741e", size = 16940166, upload-time = "2025-12-20T16:15:43.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/27/6c13f5b46776d6246ec884ac5817452672156a506d08a1f2abb39961930a/numpy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2718c1de8504121714234b6f8241d0019450353276c88b9453c9c3d92e101db", size = 12641781, upload-time = "2025-12-20T16:15:45.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1c/83b4998d4860d15283241d9e5215f28b40ac31f497c04b12fa7f428ff370/numpy-2.4.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:21555da4ec4a0c942520ead42c3b0dc9477441e085c42b0fbdd6a084869a6f6b", size = 5470247, upload-time = "2025-12-20T16:15:47.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/08/cbce72c835d937795571b0464b52069f869c9e78b0c076d416c5269d2718/numpy-2.4.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:413aa561266a4be2d06cd2b9665e89d9f54c543f418773076a76adcf2af08bc7", size = 6799807, upload-time = "2025-12-20T16:15:49.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/be/2e647961cd8c980591d75cdcd9e8f647d69fbe05e2a25613dc0a2ea5fb1a/numpy-2.4.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0feafc9e03128074689183031181fac0897ff169692d8492066e949041096548", size = 14701992, upload-time = "2025-12-20T16:15:51.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/fb/e1652fb8b6fd91ce6ed429143fe2e01ce714711e03e5b762615e7b36172c/numpy-2.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8fdfed3deaf1928fb7667d96e0567cdf58c2b370ea2ee7e586aa383ec2cb346", size = 16646871, upload-time = "2025-12-20T16:15:54.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/23/d841207e63c4322842f7cd042ae981cffe715c73376dcad8235fb31debf1/numpy-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06a922a469cae9a57100864caf4f8a97a1026513793969f8ba5b63137a35d25", size = 16487190, upload-time = "2025-12-20T16:15:56.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/a0/6a842c8421ebfdec0a230e65f61e0dabda6edbef443d999d79b87c273965/numpy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:927ccf5cd17c48f801f4ed43a7e5673a2724bd2171460be3e3894e6e332ef83a", size = 18580762, upload-time = "2025-12-20T16:15:58.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/d1/c79e0046641186f2134dde05e6181825b911f8bdcef31b19ddd16e232847/numpy-2.4.0-cp311-cp311-win32.whl", hash = "sha256:882567b7ae57c1b1a0250208cc21a7976d8cbcc49d5a322e607e6f09c9e0bd53", size = 6233359, upload-time = "2025-12-20T16:16:00.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/f0/74965001d231f28184d6305b8cdc1b6fcd4bf23033f6cb039cfe76c9fca7/numpy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:8b986403023c8f3bf8f487c2e6186afda156174d31c175f747d8934dfddf3479", size = 12601132, upload-time = "2025-12-20T16:16:02.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/32/55408d0f46dfebce38017f5bd931affa7256ad6beac1a92a012e1fbc67a7/numpy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:3f3096405acc48887458bbf9f6814d43785ac7ba2a57ea6442b581dedbc60ce6", size = 10573977, upload-time = "2025-12-20T16:16:04.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ff/f6400ffec95de41c74b8e73df32e3fff1830633193a7b1e409be7fb1bb8c/numpy-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a8b6bb8369abefb8bd1801b054ad50e02b3275c8614dc6e5b0373c305291037", size = 16653117, upload-time = "2025-12-20T16:16:06.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/28/6c23e97450035072e8d830a3c411bf1abd1f42c611ff9d29e3d8f55c6252/numpy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e284ca13d5a8367e43734148622caf0b261b275673823593e3e3634a6490f83", size = 12369711, upload-time = "2025-12-20T16:16:08.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/af/acbef97b630ab1bb45e6a7d01d1452e4251aa88ce680ac36e56c272120ec/numpy-2.4.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:49ff32b09f5aa0cd30a20c2b39db3e669c845589f2b7fc910365210887e39344", size = 5198355, upload-time = "2025-12-20T16:16:10.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/c8/4e0d436b66b826f2e53330adaa6311f5cac9871a5b5c31ad773b27f25a74/numpy-2.4.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:36cbfb13c152b1c7c184ddac43765db8ad672567e7bafff2cc755a09917ed2e6", size = 6545298, upload-time = "2025-12-20T16:16:12.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/27/e1f5d144ab54eac34875e79037011d511ac57b21b220063310cb96c80fbc/numpy-2.4.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35ddc8f4914466e6fc954c76527aa91aa763682a4f6d73249ef20b418fe6effb", size = 14398387, upload-time = "2025-12-20T16:16:14.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/64/4cb909dd5ab09a9a5d086eff9586e69e827b88a5585517386879474f4cf7/numpy-2.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc578891de1db95b2a35001b695451767b580bb45753717498213c5ff3c41d63", size = 16363091, upload-time = "2025-12-20T16:16:17.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/9c/8efe24577523ec6809261859737cf117b0eb6fdb655abdfdc81b2e468ce4/numpy-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98e81648e0b36e325ab67e46b5400a7a6d4a22b8a7c8e8bbfe20e7db7906bf95", size = 16176394, upload-time = "2025-12-20T16:16:19.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/f0/1687441ece7b47a62e45a1f82015352c240765c707928edd8aef875d5951/numpy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d57b5046c120561ba8fa8e4030fbb8b822f3063910fa901ffadf16e2b7128ad6", size = 18287378, upload-time = "2025-12-20T16:16:22.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/6f/f868765d44e6fc466467ed810ba9d8d6db1add7d4a748abfa2a4c99a3194/numpy-2.4.0-cp312-cp312-win32.whl", hash = "sha256:92190db305a6f48734d3982f2c60fa30d6b5ee9bff10f2887b930d7b40119f4c", size = 5955432, upload-time = "2025-12-20T16:16:25.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/b5/94c1e79fcbab38d1ca15e13777477b2914dd2d559b410f96949d6637b085/numpy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:680060061adb2d74ce352628cb798cfdec399068aa7f07ba9fb818b2b3305f98", size = 12306201, upload-time = "2025-12-20T16:16:26.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/09/c39dadf0b13bb0768cd29d6a3aaff1fb7c6905ac40e9aaeca26b1c086e06/numpy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:39699233bc72dd482da1415dcb06076e32f60eddc796a796c5fb6c5efce94667", size = 10308234, upload-time = "2025-12-20T16:16:29.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/0d/853fd96372eda07c824d24adf02e8bc92bb3731b43a9b2a39161c3667cc4/numpy-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a152d86a3ae00ba5f47b3acf3b827509fd0b6cb7d3259665e63dafbad22a75ea", size = 16649088, upload-time = "2025-12-20T16:16:31.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/37/cc636f1f2a9f585434e20a3e6e63422f70bfe4f7f6698e941db52ea1ac9a/numpy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39b19251dec4de8ff8496cd0806cbe27bf0684f765abb1f4809554de93785f2d", size = 12364065, upload-time = "2025-12-20T16:16:33.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/69/0b78f37ca3690969beee54103ce5f6021709134e8020767e93ba691a72f1/numpy-2.4.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:009bd0ea12d3c784b6639a8457537016ce5172109e585338e11334f6a7bb88ee", size = 5192640, upload-time = "2025-12-20T16:16:35.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/2a/08569f8252abf590294dbb09a430543ec8f8cc710383abfb3e75cc73aeda/numpy-2.4.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5fe44e277225fd3dff6882d86d3d447205d43532c3627313d17e754fb3905a0e", size = 6541556, upload-time = "2025-12-20T16:16:37.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e9/a949885a4e177493d61519377952186b6cbfdf1d6002764c664ba28349b5/numpy-2.4.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f935c4493eda9069851058fa0d9e39dbf6286be690066509305e52912714dbb2", size = 14396562, upload-time = "2025-12-20T16:16:38.953Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/98/9d4ad53b0e9ef901c2ef1d550d2136f5ac42d3fd2988390a6def32e23e48/numpy-2.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cfa5f29a695cb7438965e6c3e8d06e0416060cf0d709c1b1c1653a939bf5c2a", size = 16351719, upload-time = "2025-12-20T16:16:41.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/de/5f3711a38341d6e8dd619f6353251a0cdd07f3d6d101a8fd46f4ef87f895/numpy-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba0cb30acd3ef11c94dc27fbfba68940652492bc107075e7ffe23057f9425681", size = 16176053, upload-time = "2025-12-20T16:16:44.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/5b/2a3753dc43916501b4183532e7ace862e13211042bceafa253afb5c71272/numpy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60e8c196cd82cbbd4f130b5290007e13e6de3eca79f0d4d38014769d96a7c475", size = 18277859, upload-time = "2025-12-20T16:16:47.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/c5/a18bcdd07a941db3076ef489d036ab16d2bfc2eae0cf27e5a26e29189434/numpy-2.4.0-cp313-cp313-win32.whl", hash = "sha256:5f48cb3e88fbc294dc90e215d86fbaf1c852c63dbdb6c3a3e63f45c4b57f7344", size = 5953849, upload-time = "2025-12-20T16:16:49.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/f1/719010ff8061da6e8a26e1980cf090412d4f5f8060b31f0c45d77dd67a01/numpy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:a899699294f28f7be8992853c0c60741f16ff199205e2e6cdca155762cbaa59d", size = 12302840, upload-time = "2025-12-20T16:16:51.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5a/b3d259083ed8b4d335270c76966cb6cf14a5d1b69e1a608994ac57a659e6/numpy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9198f447e1dc5647d07c9a6bbe2063cc0132728cc7175b39dbc796da5b54920d", size = 10308509, upload-time = "2025-12-20T16:16:53.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/01/95edcffd1bb6c0633df4e808130545c4f07383ab629ac7e316fb44fff677/numpy-2.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74623f2ab5cc3f7c886add4f735d1031a1d2be4a4ae63c0546cfd74e7a31ddf6", size = 12491815, upload-time = "2025-12-20T16:16:55.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/ea/5644b8baa92cc1c7163b4b4458c8679852733fa74ca49c942cfa82ded4e0/numpy-2.4.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:0804a8e4ab070d1d35496e65ffd3cf8114c136a2b81f61dfab0de4b218aacfd5", size = 5320321, upload-time = "2025-12-20T16:16:57.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/4e/e10938106d70bc21319bd6a86ae726da37edc802ce35a3a71ecdf1fdfe7f/numpy-2.4.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:02a2038eb27f9443a8b266a66911e926566b5a6ffd1a689b588f7f35b81e7dc3", size = 6641635, upload-time = "2025-12-20T16:16:59.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/8d/a8828e3eaf5c0b4ab116924df82f24ce3416fa38d0674d8f708ddc6c8aac/numpy-2.4.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1889b3a3f47a7b5bee16bc25a2145bd7cb91897f815ce3499db64c7458b6d91d", size = 14456053, upload-time = "2025-12-20T16:17:01.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/a1/17d97609d87d4520aa5ae2dcfb32305654550ac6a35effb946d303e594ce/numpy-2.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85eef4cb5625c47ee6425c58a3502555e10f45ee973da878ac8248ad58c136f3", size = 16401702, upload-time = "2025-12-20T16:17:04.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/32/0f13c1b2d22bea1118356b8b963195446f3af124ed7a5adfa8fdecb1b6ca/numpy-2.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6dc8b7e2f4eb184b37655195f421836cfae6f58197b67e3ffc501f1333d993fa", size = 16242493, upload-time = "2025-12-20T16:17:06.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/23/48f21e3d309fbc137c068a1475358cbd3a901b3987dcfc97a029ab3068e2/numpy-2.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:44aba2f0cafd287871a495fb3163408b0bd25bbce135c6f621534a07f4f7875c", size = 18324222, upload-time = "2025-12-20T16:17:09.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/52/41f3d71296a3dcaa4f456aaa3c6fc8e745b43d0552b6bde56571bb4b4a0f/numpy-2.4.0-cp313-cp313t-win32.whl", hash = "sha256:20c115517513831860c573996e395707aa9fb691eb179200125c250e895fcd93", size = 6076216, upload-time = "2025-12-20T16:17:11.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/ff/46fbfe60ab0710d2a2b16995f708750307d30eccbb4c38371ea9e986866e/numpy-2.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b48e35f4ab6f6a7597c46e301126ceba4c44cd3280e3750f85db48b082624fa4", size = 12444263, upload-time = "2025-12-20T16:17:13.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/e3/9189ab319c01d2ed556c932ccf55064c5d75bb5850d1df7a482ce0badead/numpy-2.4.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4d1cfce39e511069b11e67cd0bd78ceff31443b7c9e5c04db73c7a19f572967c", size = 10378265, upload-time = "2025-12-20T16:17:15.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/ed/52eac27de39d5e5a6c9aadabe672bc06f55e24a3d9010cd1183948055d76/numpy-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c95eb6db2884917d86cde0b4d4cf31adf485c8ec36bf8696dd66fa70de96f36b", size = 16647476, upload-time = "2025-12-20T16:17:17.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/c0/990ce1b7fcd4e09aeaa574e2a0a839589e4b08b2ca68070f1acb1fea6736/numpy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:65167da969cd1ec3a1df31cb221ca3a19a8aaa25370ecb17d428415e93c1935e", size = 12374563, upload-time = "2025-12-20T16:17:20.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/7c/8c5e389c6ae8f5fd2277a988600d79e9625db3fff011a2d87ac80b881a4c/numpy-2.4.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3de19cfecd1465d0dcf8a5b5ea8b3155b42ed0b639dba4b71e323d74f2a3be5e", size = 5203107, upload-time = "2025-12-20T16:17:22.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/94/ca5b3bd6a8a70a5eec9a0b8dd7f980c1eff4b8a54970a9a7fef248ef564f/numpy-2.4.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6c05483c3136ac4c91b4e81903cb53a8707d316f488124d0398499a4f8e8ef51", size = 6538067, upload-time = "2025-12-20T16:17:24.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/43/993eb7bb5be6761dde2b3a3a594d689cec83398e3f58f4758010f3b85727/numpy-2.4.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36667db4d6c1cea79c8930ab72fadfb4060feb4bfe724141cd4bd064d2e5f8ce", size = 14411926, upload-time = "2025-12-20T16:17:25.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/75/d4c43b61de473912496317a854dac54f1efec3eeb158438da6884b70bb90/numpy-2.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a818668b674047fd88c4cddada7ab8f1c298812783e8328e956b78dc4807f9f", size = 16354295, upload-time = "2025-12-20T16:17:28.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/0a/b54615b47ee8736a6461a4bb6749128dd3435c5a759d5663f11f0e9af4ac/numpy-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ee32359fb7543b7b7bd0b2f46294db27e29e7bbdf70541e81b190836cd83ded", size = 16190242, upload-time = "2025-12-20T16:17:30.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/ce/ea207769aacad6246525ec6c6bbd66a2bf56c72443dc10e2f90feed29290/numpy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e493962256a38f58283de033d8af176c5c91c084ea30f15834f7545451c42059", size = 18280875, upload-time = "2025-12-20T16:17:33.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/ef/ec409437aa962ea372ed601c519a2b141701683ff028f894b7466f0ab42b/numpy-2.4.0-cp314-cp314-win32.whl", hash = "sha256:6bbaebf0d11567fa8926215ae731e1d58e6ec28a8a25235b8a47405d301332db", size = 6002530, upload-time = "2025-12-20T16:17:35.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/4a/5cb94c787a3ed1ac65e1271b968686521169a7b3ec0b6544bb3ca32960b0/numpy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d857f55e7fdf7c38ab96c4558c95b97d1c685be6b05c249f5fdafcbd6f9899e", size = 12435890, upload-time = "2025-12-20T16:17:37.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/a0/04b89db963af9de1104975e2544f30de89adbf75b9e75f7dd2599be12c79/numpy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:bb50ce5fb202a26fd5404620e7ef820ad1ab3558b444cb0b55beb7ef66cd2d63", size = 10591892, upload-time = "2025-12-20T16:17:39.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/e5/d74b5ccf6712c06c7a545025a6a71bfa03bdc7e0568b405b0d655232fd92/numpy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:355354388cba60f2132df297e2d53053d4063f79077b67b481d21276d61fc4df", size = 12494312, upload-time = "2025-12-20T16:17:41.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/08/3ca9cc2ddf54dfee7ae9a6479c071092a228c68aef08252aa08dac2af002/numpy-2.4.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:1d8f9fde5f6dc1b6fc34df8162f3b3079365468703fee7f31d4e0cc8c63baed9", size = 5322862, upload-time = "2025-12-20T16:17:44.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/74/0bb63a68394c0c1e52670cfff2e309afa41edbe11b3327d9af29e4383f34/numpy-2.4.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e0434aa22c821f44eeb4c650b81c7fbdd8c0122c6c4b5a576a76d5a35625ecd9", size = 6644986, upload-time = "2025-12-20T16:17:46.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/8f/9264d9bdbcf8236af2823623fe2f3981d740fc3461e2787e231d97c38c28/numpy-2.4.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40483b2f2d3ba7aad426443767ff5632ec3156ef09742b96913787d13c336471", size = 14457958, upload-time = "2025-12-20T16:17:48.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d9/f9a69ae564bbc7236a35aa883319364ef5fd41f72aa320cc1cbe66148fe2/numpy-2.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6a7664ddd9746e20b7325351fe1a8408d0a2bf9c63b5e898290ddc8f09544", size = 16398394, upload-time = "2025-12-20T16:17:50.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/c7/39241501408dde7f885d241a98caba5421061a2c6d2b2197ac5e3aa842d8/numpy-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ecb0019d44f4cdb50b676c5d0cb4b1eae8e15d1ed3d3e6639f986fc92b2ec52c", size = 16241044, upload-time = "2025-12-20T16:17:52.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/95/cae7effd90e065a95e59fe710eeee05d7328ed169776dfdd9f789e032125/numpy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0ffd9e2e4441c96a9c91ec1783285d80bf835b677853fc2770a89d50c1e48ac", size = 18321772, upload-time = "2025-12-20T16:17:54.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/df/3c6c279accd2bfb968a76298e5b276310bd55d243df4fa8ac5816d79347d/numpy-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:77f0d13fa87036d7553bf81f0e1fe3ce68d14c9976c9851744e4d3e91127e95f", size = 6148320, upload-time = "2025-12-20T16:17:57.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/8d/f23033cce252e7a75cae853d17f582e86534c46404dea1c8ee094a9d6d84/numpy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b1f5b45829ac1848893f0ddf5cb326110604d6df96cdc255b0bf9edd154104d4", size = 12623460, upload-time = "2025-12-20T16:17:58.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/4f/1f8475907d1a7c4ef9020edf7f39ea2422ec896849245f00688e4b268a71/numpy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:23a3e9d1a6f360267e8fbb38ba5db355a6a7e9be71d7fce7ab3125e88bb646c8", size = 10661799, upload-time = "2025-12-20T16:18:01.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/ef/088e7c7342f300aaf3ee5f2c821c4b9996a1bef2aaf6a49cc8ab4883758e/numpy-2.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b54c83f1c0c0f1d748dca0af516062b8829d53d1f0c402be24b4257a9c48ada6", size = 16819003, upload-time = "2025-12-20T16:18:03.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/ce/a53017b5443b4b84517182d463fc7bcc2adb4faa8b20813f8e5f5aeb5faa/numpy-2.4.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:aabb081ca0ec5d39591fc33018cd4b3f96e1a2dd6756282029986d00a785fba4", size = 12567105, upload-time = "2025-12-20T16:18:05.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/58/5ff91b161f2ec650c88a626c3905d938c89aaadabd0431e6d9c1330c83e2/numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:8eafe7c36c8430b7794edeab3087dec7bf31d634d92f2af9949434b9d1964cba", size = 5395590, upload-time = "2025-12-20T16:18:08.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/4e/f1a084106df8c2df8132fc437e56987308e0524836aa7733721c8429d4fe/numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2f585f52b2baf07ff3356158d9268ea095e221371f1074fadea2f42544d58b4d", size = 6709947, upload-time = "2025-12-20T16:18:09.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/09/3d8aeb809c0332c3f642da812ac2e3d74fc9252b3021f8c30c82e99e3f3d/numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ed06d0fe9cae27d8fb5f400c63ccee72370599c75e683a6358dd3a4fb50aaf", size = 14535119, upload-time = "2025-12-20T16:18:12.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7f/68f0fc43a2cbdc6bb239160c754d87c922f60fbaa0fa3cd3d312b8a7f5ee/numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57c540ed8fb1f05cb997c6761cd56db72395b0d6985e90571ff660452ade4f98", size = 16475815, upload-time = "2025-12-20T16:18:14.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/73/edeacba3167b1ca66d51b1a5a14697c2c40098b5ffa01811c67b1785a5ab/numpy-2.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a39fb973a726e63223287adc6dafe444ce75af952d711e400f3bf2b36ef55a7b", size = 12489376, upload-time = "2025-12-20T16:18:16.524Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.11.3"
|
||||
version = "3.11.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/4d/8df5f83256a809c22c4d6792ce8d43bb503be0fb7a8e4da9025754b09658/orjson-3.11.3.tar.gz", hash = "sha256:1c0603b1d2ffcd43a411d64797a19556ef76958aef1c182f22dc30860152a98a", size = 5482394, upload-time = "2025-08-26T17:46:43.171Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/64/4a3cef001c6cd9c64256348d4c13a7b09b857e3e1cbb5185917df67d8ced/orjson-3.11.3-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:29cb1f1b008d936803e2da3d7cba726fc47232c45df531b29edf0b232dd737e7", size = 238600, upload-time = "2025-08-26T17:44:36.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/ce/0c8c87f54f79d051485903dc46226c4d3220b691a151769156054df4562b/orjson-3.11.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97dceed87ed9139884a55db8722428e27bd8452817fbf1869c58b49fecab1120", size = 123526, upload-time = "2025-08-26T17:44:39.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/d0/249497e861f2d438f45b3ab7b7b361484237414945169aa285608f9f7019/orjson-3.11.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58533f9e8266cb0ac298e259ed7b4d42ed3fa0b78ce76860626164de49e0d467", size = 128075, upload-time = "2025-08-26T17:44:40.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/64/00485702f640a0fd56144042a1ea196469f4a3ae93681871564bf74fa996/orjson-3.11.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c212cfdd90512fe722fa9bd620de4d46cda691415be86b2e02243242ae81873", size = 130483, upload-time = "2025-08-26T17:44:41.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/81/110d68dba3909171bf3f05619ad0cf187b430e64045ae4e0aa7ccfe25b15/orjson-3.11.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff835b5d3e67d9207343effb03760c00335f8b5285bfceefd4dc967b0e48f6a", size = 132539, upload-time = "2025-08-26T17:44:43.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/92/dba25c22b0ddfafa1e6516a780a00abac28d49f49e7202eb433a53c3e94e/orjson-3.11.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5aa4682912a450c2db89cbd92d356fef47e115dffba07992555542f344d301b", size = 135390, upload-time = "2025-08-26T17:44:44.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/1d/ca2230fd55edbd87b58a43a19032d63a4b180389a97520cc62c535b726f9/orjson-3.11.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7d18dd34ea2e860553a579df02041845dee0af8985dff7f8661306f95504ddf", size = 132966, upload-time = "2025-08-26T17:44:45.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/b9/96bbc8ed3e47e52b487d504bd6861798977445fbc410da6e87e302dc632d/orjson-3.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8b11701bc43be92ea42bd454910437b355dfb63696c06fe953ffb40b5f763b4", size = 131349, upload-time = "2025-08-26T17:44:46.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/3c/418fbd93d94b0df71cddf96b7fe5894d64a5d890b453ac365120daec30f7/orjson-3.11.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:90368277087d4af32d38bd55f9da2ff466d25325bf6167c8f382d8ee40cb2bbc", size = 404087, upload-time = "2025-08-26T17:44:48.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/a9/2bfd58817d736c2f63608dec0c34857339d423eeed30099b126562822191/orjson-3.11.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fd7ff459fb393358d3a155d25b275c60b07a2c83dcd7ea962b1923f5a1134569", size = 146067, upload-time = "2025-08-26T17:44:49.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/ba/29023771f334096f564e48d82ed855a0ed3320389d6748a9c949e25be734/orjson-3.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f8d902867b699bcd09c176a280b1acdab57f924489033e53d0afe79817da37e6", size = 135506, upload-time = "2025-08-26T17:44:50.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/62/b5a1eca83f54cb3aa11a9645b8a22f08d97dbd13f27f83aae7c6666a0a05/orjson-3.11.3-cp310-cp310-win32.whl", hash = "sha256:bb93562146120bb51e6b154962d3dadc678ed0fce96513fa6bc06599bb6f6edc", size = 136352, upload-time = "2025-08-26T17:44:51.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/c0/7ebfaa327d9a9ed982adc0d9420dbce9a3fec45b60ab32c6308f731333fa/orjson-3.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:976c6f1975032cc327161c65d4194c549f2589d88b105a5e3499429a54479770", size = 131539, upload-time = "2025-08-26T17:44:52.974Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/8b/360674cd817faef32e49276187922a946468579fcaf37afdfb6c07046e92/orjson-3.11.3-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9d2ae0cc6aeb669633e0124531f342a17d8e97ea999e42f12a5ad4adaa304c5f", size = 238238, upload-time = "2025-08-26T17:44:54.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/3d/5fa9ea4b34c1a13be7d9046ba98d06e6feb1d8853718992954ab59d16625/orjson-3.11.3-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:ba21dbb2493e9c653eaffdc38819b004b7b1b246fb77bfc93dc016fe664eac91", size = 127713, upload-time = "2025-08-26T17:44:55.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/5f/e18367823925e00b1feec867ff5f040055892fc474bf5f7875649ecfa586/orjson-3.11.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00f1a271e56d511d1569937c0447d7dce5a99a33ea0dec76673706360a051904", size = 123241, upload-time = "2025-08-26T17:44:57.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/bd/3c66b91c4564759cf9f473251ac1650e446c7ba92a7c0f9f56ed54f9f0e6/orjson-3.11.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b67e71e47caa6680d1b6f075a396d04fa6ca8ca09aafb428731da9b3ea32a5a6", size = 127895, upload-time = "2025-08-26T17:44:58.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/b5/dc8dcd609db4766e2967a85f63296c59d4722b39503e5b0bf7fd340d387f/orjson-3.11.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7d012ebddffcce8c85734a6d9e5f08180cd3857c5f5a3ac70185b43775d043d", size = 130303, upload-time = "2025-08-26T17:44:59.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/c2/d58ec5fd1270b2aa44c862171891adc2e1241bd7dab26c8f46eb97c6c6f1/orjson-3.11.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd759f75d6b8d1b62012b7f5ef9461d03c804f94d539a5515b454ba3a6588038", size = 132366, upload-time = "2025-08-26T17:45:00.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/87/0ef7e22eb8dd1ef940bfe3b9e441db519e692d62ed1aae365406a16d23d0/orjson-3.11.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6890ace0809627b0dff19cfad92d69d0fa3f089d3e359a2a532507bb6ba34efb", size = 135180, upload-time = "2025-08-26T17:45:02.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/6a/e5bf7b70883f374710ad74faf99bacfc4b5b5a7797c1d5e130350e0e28a3/orjson-3.11.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9d4a5e041ae435b815e568537755773d05dac031fee6a57b4ba70897a44d9d2", size = 132741, upload-time = "2025-08-26T17:45:03.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/0c/4577fd860b6386ffaa56440e792af01c7882b56d2766f55384b5b0e9d39b/orjson-3.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d68bf97a771836687107abfca089743885fb664b90138d8761cce61d5625d55", size = 131104, upload-time = "2025-08-26T17:45:04.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/4b/83e92b2d67e86d1c33f2ea9411742a714a26de63641b082bdbf3d8e481af/orjson-3.11.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bfc27516ec46f4520b18ef645864cee168d2a027dbf32c5537cb1f3e3c22dac1", size = 403887, upload-time = "2025-08-26T17:45:06.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/e5/9eea6a14e9b5ceb4a271a1fd2e1dec5f2f686755c0fab6673dc6ff3433f4/orjson-3.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f66b001332a017d7945e177e282a40b6997056394e3ed7ddb41fb1813b83e824", size = 145855, upload-time = "2025-08-26T17:45:08.338Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/78/8d4f5ad0c80ba9bf8ac4d0fc71f93a7d0dc0844989e645e2074af376c307/orjson-3.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:212e67806525d2561efbfe9e799633b17eb668b8964abed6b5319b2f1cfbae1f", size = 135361, upload-time = "2025-08-26T17:45:09.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5f/16386970370178d7a9b438517ea3d704efcf163d286422bae3b37b88dbb5/orjson-3.11.3-cp311-cp311-win32.whl", hash = "sha256:6e8e0c3b85575a32f2ffa59de455f85ce002b8bdc0662d6b9c2ed6d80ab5d204", size = 136190, upload-time = "2025-08-26T17:45:10.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/60/db16c6f7a41dd8ac9fb651f66701ff2aeb499ad9ebc15853a26c7c152448/orjson-3.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:6be2f1b5d3dc99a5ce5ce162fc741c22ba9f3443d3dd586e6a1211b7bc87bc7b", size = 131389, upload-time = "2025-08-26T17:45:12.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/2a/bb811ad336667041dea9b8565c7c9faf2f59b47eb5ab680315eea612ef2e/orjson-3.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:fafb1a99d740523d964b15c8db4eabbfc86ff29f84898262bf6e3e4c9e97e43e", size = 126120, upload-time = "2025-08-26T17:45:13.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/b0/a7edab2a00cdcb2688e1c943401cb3236323e7bfd2839815c6131a3742f4/orjson-3.11.3-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8c752089db84333e36d754c4baf19c0e1437012242048439c7e80eb0e6426e3b", size = 238259, upload-time = "2025-08-26T17:45:15.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/c6/ff4865a9cc398a07a83342713b5932e4dc3cb4bf4bc04e8f83dedfc0d736/orjson-3.11.3-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:9b8761b6cf04a856eb544acdd82fc594b978f12ac3602d6374a7edb9d86fd2c2", size = 127633, upload-time = "2025-08-26T17:45:16.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/e6/e00bea2d9472f44fe8794f523e548ce0ad51eb9693cf538a753a27b8bda4/orjson-3.11.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b13974dc8ac6ba22feaa867fc19135a3e01a134b4f7c9c28162fed4d615008a", size = 123061, upload-time = "2025-08-26T17:45:17.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/31/9fbb78b8e1eb3ac605467cb846e1c08d0588506028b37f4ee21f978a51d4/orjson-3.11.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f83abab5bacb76d9c821fd5c07728ff224ed0e52d7a71b7b3de822f3df04e15c", size = 127956, upload-time = "2025-08-26T17:45:19.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/88/b0604c22af1eed9f98d709a96302006915cfd724a7ebd27d6dd11c22d80b/orjson-3.11.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6fbaf48a744b94091a56c62897b27c31ee2da93d826aa5b207131a1e13d4064", size = 130790, upload-time = "2025-08-26T17:45:20.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/9d/1c1238ae9fffbfed51ba1e507731b3faaf6b846126a47e9649222b0fd06f/orjson-3.11.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc779b4f4bba2847d0d2940081a7b6f7b5877e05408ffbb74fa1faf4a136c424", size = 132385, upload-time = "2025-08-26T17:45:22.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/b5/c06f1b090a1c875f337e21dd71943bc9d84087f7cdf8c6e9086902c34e42/orjson-3.11.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd4b909ce4c50faa2192da6bb684d9848d4510b736b0611b6ab4020ea6fd2d23", size = 135305, upload-time = "2025-08-26T17:45:23.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/26/5f028c7d81ad2ebbf84414ba6d6c9cac03f22f5cd0d01eb40fb2d6a06b07/orjson-3.11.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:524b765ad888dc5518bbce12c77c2e83dee1ed6b0992c1790cc5fb49bb4b6667", size = 132875, upload-time = "2025-08-26T17:45:25.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/d4/b8df70d9cfb56e385bf39b4e915298f9ae6c61454c8154a0f5fd7efcd42e/orjson-3.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:84fd82870b97ae3cdcea9d8746e592b6d40e1e4d4527835fc520c588d2ded04f", size = 130940, upload-time = "2025-08-26T17:45:27.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5e/afe6a052ebc1a4741c792dd96e9f65bf3939d2094e8b356503b68d48f9f5/orjson-3.11.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fbecb9709111be913ae6879b07bafd4b0785b44c1eb5cac8ac76da048b3885a1", size = 403852, upload-time = "2025-08-26T17:45:28.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/90/7bbabafeb2ce65915e9247f14a56b29c9334003536009ef5b122783fe67e/orjson-3.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9dba358d55aee552bd868de348f4736ca5a4086d9a62e2bfbbeeb5629fe8b0cc", size = 146293, upload-time = "2025-08-26T17:45:29.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/b3/2d703946447da8b093350570644a663df69448c9d9330e5f1d9cce997f20/orjson-3.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eabcf2e84f1d7105f84580e03012270c7e97ecb1fb1618bda395061b2a84a049", size = 135470, upload-time = "2025-08-26T17:45:31.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/70/b14dcfae7aff0e379b0119c8a812f8396678919c431efccc8e8a0263e4d9/orjson-3.11.3-cp312-cp312-win32.whl", hash = "sha256:3782d2c60b8116772aea8d9b7905221437fdf53e7277282e8d8b07c220f96cca", size = 136248, upload-time = "2025-08-26T17:45:32.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/b8/9e3127d65de7fff243f7f3e53f59a531bf6bb295ebe5db024c2503cc0726/orjson-3.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:79b44319268af2eaa3e315b92298de9a0067ade6e6003ddaef72f8e0bedb94f1", size = 131437, upload-time = "2025-08-26T17:45:34.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/92/a946e737d4d8a7fd84a606aba96220043dcc7d6988b9e7551f7f6d5ba5ad/orjson-3.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:0e92a4e83341ef79d835ca21b8bd13e27c859e4e9e4d7b63defc6e58462a3710", size = 125978, upload-time = "2025-08-26T17:45:36.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/79/8932b27293ad35919571f77cb3693b5906cf14f206ef17546052a241fdf6/orjson-3.11.3-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:af40c6612fd2a4b00de648aa26d18186cd1322330bd3a3cc52f87c699e995810", size = 238127, upload-time = "2025-08-26T17:45:38.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/82/cb93cd8cf132cd7643b30b6c5a56a26c4e780c7a145db6f83de977b540ce/orjson-3.11.3-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:9f1587f26c235894c09e8b5b7636a38091a9e6e7fe4531937534749c04face43", size = 127494, upload-time = "2025-08-26T17:45:39.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/b8/2d9eb181a9b6bb71463a78882bcac1027fd29cf62c38a40cc02fc11d3495/orjson-3.11.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61dcdad16da5bb486d7227a37a2e789c429397793a6955227cedbd7252eb5a27", size = 123017, upload-time = "2025-08-26T17:45:40.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/14/a0e971e72d03b509190232356d54c0f34507a05050bd026b8db2bf2c192c/orjson-3.11.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11c6d71478e2cbea0a709e8a06365fa63da81da6498a53e4c4f065881d21ae8f", size = 127898, upload-time = "2025-08-26T17:45:42.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/af/dc74536722b03d65e17042cc30ae586161093e5b1f29bccda24765a6ae47/orjson-3.11.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff94112e0098470b665cb0ed06efb187154b63649403b8d5e9aedeb482b4548c", size = 130742, upload-time = "2025-08-26T17:45:43.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/e6/7a3b63b6677bce089fe939353cda24a7679825c43a24e49f757805fc0d8a/orjson-3.11.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae8b756575aaa2a855a75192f356bbda11a89169830e1439cfb1a3e1a6dde7be", size = 132377, upload-time = "2025-08-26T17:45:45.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/cd/ce2ab93e2e7eaf518f0fd15e3068b8c43216c8a44ed82ac2b79ce5cef72d/orjson-3.11.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9416cc19a349c167ef76135b2fe40d03cea93680428efee8771f3e9fb66079d", size = 135313, upload-time = "2025-08-26T17:45:46.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b822caf5b9752bc6f246eb08124c3d12bf2175b66ab74bac2ef3bbf9221ce1b2", size = 132908, upload-time = "2025-08-26T17:45:48.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/92/8f5182d7bc2a1bed46ed960b61a39af8389f0ad476120cd99e67182bfb6d/orjson-3.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:414f71e3bdd5573893bf5ecdf35c32b213ed20aa15536fe2f588f946c318824f", size = 130905, upload-time = "2025-08-26T17:45:49.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/60/c41ca753ce9ffe3d0f67b9b4c093bdd6e5fdb1bc53064f992f66bb99954d/orjson-3.11.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:828e3149ad8815dc14468f36ab2a4b819237c155ee1370341b91ea4c8672d2ee", size = 403812, upload-time = "2025-08-26T17:45:51.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/13/e4a4f16d71ce1868860db59092e78782c67082a8f1dc06a3788aef2b41bc/orjson-3.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac9e05f25627ffc714c21f8dfe3a579445a5c392a9c8ae7ba1d0e9fb5333f56e", size = 146277, upload-time = "2025-08-26T17:45:52.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/8b/bafb7f0afef9344754a3a0597a12442f1b85a048b82108ef2c956f53babd/orjson-3.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e44fbe4000bd321d9f3b648ae46e0196d21577cf66ae684a96ff90b1f7c93633", size = 135418, upload-time = "2025-08-26T17:45:54.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d4/bae8e4f26afb2c23bea69d2f6d566132584d1c3a5fe89ee8c17b718cab67/orjson-3.11.3-cp313-cp313-win32.whl", hash = "sha256:2039b7847ba3eec1f5886e75e6763a16e18c68a63efc4b029ddf994821e2e66b", size = 136216, upload-time = "2025-08-26T17:45:57.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/76/224985d9f127e121c8cad882cea55f0ebe39f97925de040b75ccd4b33999/orjson-3.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:29be5ac4164aa8bdcba5fa0700a3c9c316b411d8ed9d39ef8a882541bd452fae", size = 131362, upload-time = "2025-08-26T17:45:58.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/cf/0dce7a0be94bd36d1346be5067ed65ded6adb795fdbe3abd234c8d576d01/orjson-3.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:18bd1435cb1f2857ceb59cfb7de6f92593ef7b831ccd1b9bfb28ca530e539dce", size = 125989, upload-time = "2025-08-26T17:45:59.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/77/d3b1fef1fc6aaeed4cbf3be2b480114035f4df8fa1a99d2dac1d40d6e924/orjson-3.11.3-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cf4b81227ec86935568c7edd78352a92e97af8da7bd70bdfdaa0d2e0011a1ab4", size = 238115, upload-time = "2025-08-26T17:46:01.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/6d/468d21d49bb12f900052edcfbf52c292022d0a323d7828dc6376e6319703/orjson-3.11.3-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:bc8bc85b81b6ac9fc4dae393a8c159b817f4c2c9dee5d12b773bddb3b95fc07e", size = 127493, upload-time = "2025-08-26T17:46:03.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/46/1e2588700d354aacdf9e12cc2d98131fb8ac6f31ca65997bef3863edb8ff/orjson-3.11.3-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:88dcfc514cfd1b0de038443c7b3e6a9797ffb1b3674ef1fd14f701a13397f82d", size = 122998, upload-time = "2025-08-26T17:46:04.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/94/11137c9b6adb3779f1b34fd98be51608a14b430dbc02c6d41134fbba484c/orjson-3.11.3-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d61cd543d69715d5fc0a690c7c6f8dcc307bc23abef9738957981885f5f38229", size = 132915, upload-time = "2025-08-26T17:46:06.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/61/dccedcf9e9bcaac09fdabe9eaee0311ca92115699500efbd31950d878833/orjson-3.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2b7b153ed90ababadbef5c3eb39549f9476890d339cf47af563aea7e07db2451", size = 130907, upload-time = "2025-08-26T17:46:07.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/fd/0e935539aa7b08b3ca0f817d73034f7eb506792aae5ecc3b7c6e679cdf5f/orjson-3.11.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7909ae2460f5f494fecbcd10613beafe40381fd0316e35d6acb5f3a05bfda167", size = 403852, upload-time = "2025-08-26T17:46:08.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/2b/50ae1a5505cd1043379132fdb2adb8a05f37b3e1ebffe94a5073321966fd/orjson-3.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2030c01cbf77bc67bee7eef1e7e31ecf28649353987775e3583062c752da0077", size = 146309, upload-time = "2025-08-26T17:46:10.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/1d/a473c158e380ef6f32753b5f39a69028b25ec5be331c2049a2201bde2e19/orjson-3.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0169ebd1cbd94b26c7a7ad282cf5c2744fce054133f959e02eb5265deae1872", size = 135424, upload-time = "2025-08-26T17:46:12.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/09/17d9d2b60592890ff7382e591aa1d9afb202a266b180c3d4049b1ec70e4a/orjson-3.11.3-cp314-cp314-win32.whl", hash = "sha256:0c6d7328c200c349e3a4c6d8c83e0a5ad029bdc2d417f234152bf34842d0fc8d", size = 136266, upload-time = "2025-08-26T17:46:13.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/58/358f6846410a6b4958b74734727e582ed971e13d335d6c7ce3e47730493e/orjson-3.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:317bbe2c069bbc757b1a2e4105b64aacd3bc78279b66a6b9e51e846e4809f804", size = 131351, upload-time = "2025-08-26T17:46:15.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/01/d6b274a0635be0468d4dbd9cafe80c47105937a0d42434e805e67cd2ed8b/orjson-3.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:e8f6a7a27d7b7bec81bd5924163e9af03d49bbb63013f107b48eb5d16db711bc", size = 125985, upload-time = "2025-08-26T17:46:16.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/19/b22cf9dad4db20c8737041046054cbd4f38bb5a2d0e4bb60487832ce3d76/orjson-3.11.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:df9eadb2a6386d5ea2bfd81309c505e125cfc9ba2b1b99a97e60985b0b3665d1", size = 245719, upload-time = "2025-12-06T15:53:43.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/2e/b136dd6bf30ef5143fbe76a4c142828b55ccc618be490201e9073ad954a1/orjson-3.11.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc70da619744467d8f1f49a8cadae5ec7bbe054e5232d95f92ed8737f8c5870", size = 132467, upload-time = "2025-12-06T15:53:45.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/fc/ae99bfc1e1887d20a0268f0e2686eb5b13d0ea7bbe01de2b566febcd2130/orjson-3.11.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073aab025294c2f6fc0807201c76fdaed86f8fc4be52c440fb78fbb759a1ac09", size = 130702, upload-time = "2025-12-06T15:53:46.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/43/ef7912144097765997170aca59249725c3ab8ef6079f93f9d708dd058df5/orjson-3.11.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:835f26fa24ba0bb8c53ae2a9328d1706135b74ec653ed933869b74b6909e63fd", size = 135907, upload-time = "2025-12-06T15:53:48.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/da/24d50e2d7f4092ddd4d784e37a3fa41f22ce8ed97abc9edd222901a96e74/orjson-3.11.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667c132f1f3651c14522a119e4dd631fad98761fa960c55e8e7430bb2a1ba4ac", size = 139935, upload-time = "2025-12-06T15:53:49.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/4a/b4cb6fcbfff5b95a3a019a8648255a0fac9b221fbf6b6e72be8df2361feb/orjson-3.11.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42e8961196af655bb5e63ce6c60d25e8798cd4dfbc04f4203457fa3869322c2e", size = 137541, upload-time = "2025-12-06T15:53:51.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/99/a11bd129f18c2377c27b2846a9d9be04acec981f770d711ba0aaea563984/orjson-3.11.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75412ca06e20904c19170f8a24486c4e6c7887dea591ba18a1ab572f1300ee9f", size = 139031, upload-time = "2025-12-06T15:53:52.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/29/d7b77d7911574733a036bb3e8ad7053ceb2b7d6ea42208b9dbc55b23b9ed/orjson-3.11.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6af8680328c69e15324b5af3ae38abbfcf9cbec37b5346ebfd52339c3d7e8a18", size = 141622, upload-time = "2025-12-06T15:53:53.606Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/41/332db96c1de76b2feda4f453e91c27202cd092835936ce2b70828212f726/orjson-3.11.5-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a86fe4ff4ea523eac8f4b57fdac319faf037d3c1be12405e6a7e86b3fbc4756a", size = 413800, upload-time = "2025-12-06T15:53:54.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/e1/5a0d148dd1f89ad2f9651df67835b209ab7fcb1118658cf353425d7563e9/orjson-3.11.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e607b49b1a106ee2086633167033afbd63f76f2999e9236f638b06b112b24ea7", size = 151198, upload-time = "2025-12-06T15:53:56.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/96/8db67430d317a01ae5cf7971914f6775affdcfe99f5bff9ef3da32492ecc/orjson-3.11.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7339f41c244d0eea251637727f016b3d20050636695bc78345cce9029b189401", size = 141984, upload-time = "2025-12-06T15:53:57.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/49/40d21e1aa1ac569e521069228bb29c9b5a350344ccf922a0227d93c2ed44/orjson-3.11.5-cp310-cp310-win32.whl", hash = "sha256:8be318da8413cdbbce77b8c5fac8d13f6eb0f0db41b30bb598631412619572e8", size = 135272, upload-time = "2025-12-06T15:53:59.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/7e/d0e31e78be0c100e08be64f48d2850b23bcb4d4c70d114f4e43b39f6895a/orjson-3.11.5-cp310-cp310-win_amd64.whl", hash = "sha256:b9f86d69ae822cabc2a0f6c099b43e8733dda788405cba2665595b7e8dd8d167", size = 133360, upload-time = "2025-12-06T15:54:01.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/68/6b3659daec3a81aed5ab47700adb1a577c76a5452d35b91c88efee89987f/orjson-3.11.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9c8494625ad60a923af6b2b0bd74107146efe9b55099e20d7740d995f338fcd8", size = 245318, upload-time = "2025-12-06T15:54:02.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/00/92db122261425f61803ccf0830699ea5567439d966cbc35856fe711bfe6b/orjson-3.11.5-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:7bb2ce0b82bc9fd1168a513ddae7a857994b780b2945a8c51db4ab1c4b751ebc", size = 129491, upload-time = "2025-12-06T15:54:03.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/4f/ffdcb18356518809d944e1e1f77589845c278a1ebbb5a8297dfefcc4b4cb/orjson-3.11.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67394d3becd50b954c4ecd24ac90b5051ee7c903d167459f93e77fc6f5b4c968", size = 132167, upload-time = "2025-12-06T15:54:04.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c6/0a8caff96f4503f4f7dd44e40e90f4d14acf80d3b7a97cb88747bb712d3e/orjson-3.11.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:298d2451f375e5f17b897794bcc3e7b821c0f32b4788b9bcae47ada24d7f3cf7", size = 130516, upload-time = "2025-12-06T15:54:06.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/63/43d4dc9bd9954bff7052f700fdb501067f6fb134a003ddcea2a0bb3854ed/orjson-3.11.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa5e4244063db8e1d87e0f54c3f7522f14b2dc937e65d5241ef0076a096409fd", size = 135695, upload-time = "2025-12-06T15:54:07.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/6f/27e2e76d110919cb7fcb72b26166ee676480a701bcf8fc53ac5d0edce32f/orjson-3.11.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1db2088b490761976c1b2e956d5d4e6409f3732e9d79cfa69f876c5248d1baf9", size = 139664, upload-time = "2025-12-06T15:54:08.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/f8/5966153a5f1be49b5fbb8ca619a529fde7bc71aa0a376f2bb83fed248bcd/orjson-3.11.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2ed66358f32c24e10ceea518e16eb3549e34f33a9d51f99ce23b0251776a1ef", size = 137289, upload-time = "2025-12-06T15:54:09.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/34/8acb12ff0299385c8bbcbb19fbe40030f23f15a6de57a9c587ebf71483fb/orjson-3.11.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2021afda46c1ed64d74b555065dbd4c2558d510d8cec5ea6a53001b3e5e82a9", size = 138784, upload-time = "2025-12-06T15:54:11.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/27/910421ea6e34a527f73d8f4ee7bdffa48357ff79c7b8d6eb6f7b82dd1176/orjson-3.11.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b42ffbed9128e547a1647a3e50bc88ab28ae9daa61713962e0d3dd35e820c125", size = 141322, upload-time = "2025-12-06T15:54:12.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/a3/4b703edd1a05555d4bb1753d6ce44e1a05b7a6d7c164d5b332c795c63d70/orjson-3.11.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8d5f16195bb671a5dd3d1dbea758918bada8f6cc27de72bd64adfbd748770814", size = 413612, upload-time = "2025-12-06T15:54:13.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/36/034177f11d7eeea16d3d2c42a1883b0373978e08bc9dad387f5074c786d8/orjson-3.11.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c0e5d9f7a0227df2927d343a6e3859bebf9208b427c79bd31949abcc2fa32fa5", size = 150993, upload-time = "2025-12-06T15:54:15.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/2f/ea8b24ee046a50a7d141c0227c4496b1180b215e728e3b640684f0ea448d/orjson-3.11.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:23d04c4543e78f724c4dfe656b3791b5f98e4c9253e13b2636f1af5d90e4a880", size = 141774, upload-time = "2025-12-06T15:54:16.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/12/cc440554bf8200eb23348a5744a575a342497b65261cd65ef3b28332510a/orjson-3.11.5-cp311-cp311-win32.whl", hash = "sha256:c404603df4865f8e0afe981aa3c4b62b406e6d06049564d58934860b62b7f91d", size = 135109, upload-time = "2025-12-06T15:54:17.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/83/e0c5aa06ba73a6760134b169f11fb970caa1525fa4461f94d76e692299d9/orjson-3.11.5-cp311-cp311-win_amd64.whl", hash = "sha256:9645ef655735a74da4990c24ffbd6894828fbfa117bc97c1edd98c282ecb52e1", size = 133193, upload-time = "2025-12-06T15:54:19.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/35/5b77eaebc60d735e832c5b1a20b155667645d123f09d471db0a78280fb49/orjson-3.11.5-cp311-cp311-win_arm64.whl", hash = "sha256:1cbf2735722623fcdee8e712cbaaab9e372bbcb0c7924ad711b261c2eccf4a5c", size = 126830, upload-time = "2025-12-06T15:54:20.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/43/61a77040ce59f1569edf38f0b9faadc90c8cf7e9bec2e0df51d0132c6bb7/orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629", size = 245271, upload-time = "2025-12-06T15:54:40.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/f9/0f79be617388227866d50edd2fd320cb8fb94dc1501184bb1620981a0aba/orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3", size = 129422, upload-time = "2025-12-06T15:54:42.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/42/f1bf1549b432d4a78bfa95735b79b5dac75b65b5bb815bba86ad406ead0a/orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39", size = 132060, upload-time = "2025-12-06T15:54:43.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/49/825aa6b929f1a6ed244c78acd7b22c1481fd7e5fda047dc8bf4c1a807eb6/orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f", size = 130391, upload-time = "2025-12-06T15:54:45.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/ec/de55391858b49e16e1aa8f0bbbb7e5997b7345d8e984a2dec3746d13065b/orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51", size = 135964, upload-time = "2025-12-06T15:54:46.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/40/820bc63121d2d28818556a2d0a09384a9f0262407cf9fa305e091a8048df/orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8", size = 139817, upload-time = "2025-12-06T15:54:48.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/c7/3a445ca9a84a0d59d26365fd8898ff52bdfcdcb825bcc6519830371d2364/orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706", size = 137336, upload-time = "2025-12-06T15:54:49.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/b3/dc0d3771f2e5d1f13368f56b339c6782f955c6a20b50465a91acb79fe961/orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f", size = 138993, upload-time = "2025-12-06T15:54:50.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a2/65267e959de6abe23444659b6e19c888f242bf7725ff927e2292776f6b89/orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863", size = 141070, upload-time = "2025-12-06T15:54:52.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/c9/da44a321b288727a322c6ab17e1754195708786a04f4f9d2220a5076a649/orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228", size = 413505, upload-time = "2025-12-06T15:54:53.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/17/68dc14fa7000eefb3d4d6d7326a190c99bb65e319f02747ef3ebf2452f12/orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2", size = 151342, upload-time = "2025-12-06T15:54:55.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c5/ccee774b67225bed630a57478529fc026eda33d94fe4c0eac8fe58d4aa52/orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05", size = 141823, upload-time = "2025-12-06T15:54:56.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/80/5d00e4155d0cd7390ae2087130637671da713959bb558db9bac5e6f6b042/orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef", size = 135236, upload-time = "2025-12-06T15:54:57.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/fe/792cc06a84808dbdc20ac6eab6811c53091b42f8e51ecebf14b540e9cfe4/orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583", size = 133167, upload-time = "2025-12-06T15:54:58.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/2c/d158bd8b50e3b1cfdcf406a7e463f6ffe3f0d167b99634717acdaf5e299f/orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287", size = 126712, upload-time = "2025-12-06T15:54:59.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/60/77d7b839e317ead7bb225d55bb50f7ea75f47afc489c81199befc5435b50/orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0", size = 245252, upload-time = "2025-12-06T15:55:01.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/aa/d4639163b400f8044cef0fb9aa51b0337be0da3a27187a20d1166e742370/orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81", size = 129419, upload-time = "2025-12-06T15:55:02.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/94/9eabf94f2e11c671111139edf5ec410d2f21e6feee717804f7e8872d883f/orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f", size = 132050, upload-time = "2025-12-06T15:55:03.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/c8/ca10f5c5322f341ea9a9f1097e140be17a88f88d1cfdd29df522970d9744/orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e", size = 130370, upload-time = "2025-12-06T15:55:05.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/d4/e96824476d361ee2edd5c6290ceb8d7edf88d81148a6ce172fc00278ca7f/orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7", size = 136012, upload-time = "2025-12-06T15:55:06.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/8e/9bc3423308c425c588903f2d103cfcfe2539e07a25d6522900645a6f257f/orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb", size = 139809, upload-time = "2025-12-06T15:55:07.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/3c/b404e94e0b02a232b957c54643ce68d0268dacb67ac33ffdee24008c8b27/orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4", size = 137332, upload-time = "2025-12-06T15:55:08.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/30/cc2d69d5ce0ad9b84811cdf4a0cd5362ac27205a921da524ff42f26d65e0/orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad", size = 138983, upload-time = "2025-12-06T15:55:10.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/87/de3223944a3e297d4707d2fe3b1ffb71437550e165eaf0ca8bbe43ccbcb1/orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829", size = 141069, upload-time = "2025-12-06T15:55:11.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/81d5087ae74be33bcae3ff2d80f5ccaa4a8fedc6d39bf65a427a95b8977f/orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac", size = 413491, upload-time = "2025-12-06T15:55:13.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/6f/f6058c21e2fc1efaf918986dbc2da5cd38044f1a2d4b7b91ad17c4acf786/orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d", size = 151375, upload-time = "2025-12-06T15:55:14.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/92/c6921f17d45e110892899a7a563a925b2273d929959ce2ad89e2525b885b/orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439", size = 141850, upload-time = "2025-12-06T15:55:15.94Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/86/cdecb0140a05e1a477b81f24739da93b25070ee01ce7f7242f44a6437594/orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499", size = 135278, upload-time = "2025-12-06T15:55:17.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/97/b638d69b1e947d24f6109216997e38922d54dcdcdb1b11c18d7efd2d3c59/orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310", size = 133170, upload-time = "2025-12-06T15:55:18.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/dd/f4fff4a6fe601b4f8f3ba3aa6da8ac33d17d124491a3b804c662a70e1636/orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5", size = 126713, upload-time = "2025-12-06T15:55:19.738Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -514,16 +541,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.4.0"
|
||||
version = "4.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portprotonqt"
|
||||
version = "0.1.8"
|
||||
version = "0.1.9"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "babel" },
|
||||
@@ -531,12 +558,13 @@ dependencies = [
|
||||
{ name = "evdev" },
|
||||
{ name = "icoextract" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "numpy", version = "2.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "orjson" },
|
||||
{ name = "pillow" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pyside6" },
|
||||
{ name = "pyudev" },
|
||||
{ name = "rapidfuzz" },
|
||||
{ name = "requests" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "vdf" },
|
||||
@@ -553,15 +581,16 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "babel", specifier = ">=2.17.0" },
|
||||
{ name = "beautifulsoup4", specifier = ">=4.14.2" },
|
||||
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
|
||||
{ name = "evdev", specifier = ">=1.9.2" },
|
||||
{ name = "icoextract", specifier = ">=0.2.0" },
|
||||
{ name = "numpy", specifier = ">=2.2.4" },
|
||||
{ name = "orjson", specifier = ">=3.11.3" },
|
||||
{ name = "orjson", specifier = ">=3.11.5" },
|
||||
{ name = "pillow", specifier = ">=12.0.0" },
|
||||
{ name = "psutil", specifier = ">=7.1.0" },
|
||||
{ name = "pyside6", specifier = "==6.9.1" },
|
||||
{ name = "pyudev", specifier = ">=0.24.3" },
|
||||
{ name = "psutil", specifier = ">=7.1.3" },
|
||||
{ name = "pyside6", specifier = ">=6.10.1" },
|
||||
{ name = "pyudev", specifier = ">=0.24.4" },
|
||||
{ name = "rapidfuzz", specifier = ">=3.14.3" },
|
||||
{ name = "requests", specifier = ">=2.32.5" },
|
||||
{ name = "tqdm", specifier = ">=4.67.1" },
|
||||
{ name = "vdf", specifier = ">=3.4" },
|
||||
@@ -570,14 +599,14 @@ requires-dist = [
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pre-commit", specifier = ">=4.3.0" },
|
||||
{ name = "pre-commit", specifier = ">=4.5.1" },
|
||||
{ name = "pyaspeller", specifier = ">=2.0.2" },
|
||||
{ name = "pyright", specifier = ">=1.1.406" },
|
||||
{ name = "pyright", specifier = ">=1.1.407" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "4.3.0"
|
||||
version = "4.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cfgv" },
|
||||
@@ -586,25 +615,35 @@ dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "virtualenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.1.0"
|
||||
version = "7.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/31/4723d756b59344b643542936e37a31d1d3204bcdc42a7daa8ee9eb06fb50/psutil-7.1.0.tar.gz", hash = "sha256:655708b3c069387c8b77b072fc429a57d0e214221d01c0a772df7dfedcb3bcd2", size = 497660, upload-time = "2025-09-17T20:14:52.902Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/62/ce4051019ee20ce0ed74432dd73a5bb087a6704284a470bb8adff69a0932/psutil-7.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76168cef4397494250e9f4e73eb3752b146de1dd950040b29186d0cce1d5ca13", size = 245242, upload-time = "2025-09-17T20:14:56.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/61/f76959fba841bf5b61123fbf4b650886dc4094c6858008b5bf73d9057216/psutil-7.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:5d007560c8c372efdff9e4579c2846d71de737e4605f611437255e81efcca2c5", size = 246682, upload-time = "2025-09-17T20:14:58.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/7a/37c99d2e77ec30d63398ffa6a660450b8a62517cabe44b3e9bae97696e8d/psutil-7.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22e4454970b32472ce7deaa45d045b34d3648ce478e26a04c7e858a0a6e75ff3", size = 287994, upload-time = "2025-09-17T20:14:59.901Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/de/04c8c61232f7244aa0a4b9a9fbd63a89d5aeaf94b2fc9d1d16e2faa5cbb0/psutil-7.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c70e113920d51e89f212dd7be06219a9b88014e63a4cec69b684c327bc474e3", size = 291163, upload-time = "2025-09-17T20:15:01.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/58/c4f976234bf6d4737bc8c02a81192f045c307b72cf39c9e5c5a2d78927f6/psutil-7.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d4a113425c037300de3ac8b331637293da9be9713855c4fc9d2d97436d7259d", size = 293625, upload-time = "2025-09-17T20:15:04.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/87/157c8e7959ec39ced1b11cc93c730c4fb7f9d408569a6c59dbd92ceb35db/psutil-7.1.0-cp37-abi3-win32.whl", hash = "sha256:09ad740870c8d219ed8daae0ad3b726d3bf9a028a198e7f3080f6a1888b99bca", size = 244812, upload-time = "2025-09-17T20:15:07.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e9/b44c4f697276a7a95b8e94d0e320a7bf7f3318521b23de69035540b39838/psutil-7.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:57f5e987c36d3146c0dd2528cd42151cf96cd359b9d67cfff836995cc5df9a3d", size = 247965, upload-time = "2025-09-17T20:15:09.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971, upload-time = "2025-09-17T20:15:12.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -621,20 +660,20 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyright"
|
||||
version = "1.1.406"
|
||||
version = "1.1.407"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodeenv" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/16/6b4fbdd1fef59a0292cbb99f790b44983e390321eccbc5921b4d161da5d1/pyright-1.1.406.tar.gz", hash = "sha256:c4872bc58c9643dac09e8a2e74d472c62036910b3bd37a32813989ef7576ea2c", size = 4113151, upload-time = "2025-10-02T01:04:45.488Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/a2/e309afbb459f50507103793aaef85ca4348b66814c86bc73908bdeb66d12/pyright-1.1.406-py3-none-any.whl", hash = "sha256:1d81fb43c2407bf566e97e57abb01c811973fdb21b2df8df59f870f688bdca71", size = 5980982, upload-time = "2025-10-02T01:04:43.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyside6"
|
||||
version = "6.9.1"
|
||||
version = "6.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyside6-addons" },
|
||||
@@ -642,51 +681,51 @@ dependencies = [
|
||||
{ name = "shiboken6" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/91/8e9c7f7e90431297de9856e90a156ade9420977e26d87996909c63f30bd2/PySide6-6.9.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:f843ef39970a2f79757810fffd7b8e93ac42a3de9ea62f2a03648cde57648aed", size = 558097, upload-time = "2025-06-03T13:20:03.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ff/04d1b6b30edd24d761cc30d964860f997bdf37d06620694bf9aab35eec3a/PySide6-6.9.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:db44ac08b8f7ac1b421bc1c6a44200d03f08d80dc7b3f68dfdb1684f30f41c17", size = 558239, upload-time = "2025-06-03T13:20:06.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/b4/ca076c55c11a8e473363e05aa82c5c03dd7ba8f17b77cc9311ce17213193/PySide6-6.9.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:531a6e67c429b045674d57fe9864b711eb59e4cded753c2640982e368fd468d1", size = 558239, upload-time = "2025-06-03T13:20:08.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/ff/95c941f53b0faebc27dbe361d8e971b77f504b9cf36f8f5d750fd82cd6fc/PySide6-6.9.1-cp39-abi3-win_amd64.whl", hash = "sha256:c82dbb7d32bbdd465e01059174f71bddc97de152ab71bded3f1907c40f9a5f16", size = 564571, upload-time = "2025-06-03T13:20:10.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/ef/0aa5e910fa4e9770db6b45c23e360a52313922e0ca71fc060a57db613de1/PySide6-6.9.1-cp39-abi3-win_arm64.whl", hash = "sha256:1525d63dc6dc425b8c2dc5bc01a8cb1d67530401449f3a3490c09a14c095b9f9", size = 401793, upload-time = "2025-06-03T13:20:12.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/22/f82cfcd1158be502c5741fe67c3fa853f3c1edbd3ac2c2250769dd9722d1/pyside6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:d0e70dd0e126d01986f357c2a555722f9462cf8a942bf2ce180baf69f468e516", size = 558169, upload-time = "2025-11-20T10:09:08.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/eb/54afe242a25d1c33b04ecd8321a549d9efb7b89eef7690eed92e98ba1dc9/pyside6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4053bf51ba2c2cb20e1005edd469997976a02cec009f7c46356a0b65c137f1fa", size = 557818, upload-time = "2025-11-20T10:09:10.132Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/af/5706b1b33587dc2f3dfa3a5000424befba35e4f2d5889284eebbde37138b/pyside6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:7d3ca20a40139ca5324a7864f1d91cdf2ff237e11bd16354a42670f2a4eeb13c", size = 558358, upload-time = "2025-11-20T10:09:11.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/41/3f48d724ecc8e42cea8a8442aa9b5a86d394b85093275990038fd1020039/pyside6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9f89ff994f774420eaa38cec6422fddd5356611d8481774820befd6f3bb84c9e", size = 564424, upload-time = "2025-11-20T10:09:12.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/30/395411473b433875a82f6b5fdd0cb28f19a0e345bcaac9fbc039400d7072/pyside6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:9c5c1d94387d1a32a6fae25348097918ef413b87dfa3767c46f737c6d48ae437", size = 548866, upload-time = "2025-11-20T10:09:14.174Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyside6-addons"
|
||||
version = "6.9.1"
|
||||
version = "6.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyside6-essentials" },
|
||||
{ name = "shiboken6" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/e2/39b9e04335d7ac782b6459bf7abec90c36b8efaac5a88ef818e972c59387/PySide6_Addons-6.9.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:7be0708fa89715c282541fca47e2ba97c0c8d2886e0236ef994b2dd8f52aacdd", size = 316212438, upload-time = "2025-06-03T13:06:15.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/6f/691d7039a6f7943522a770b713ecd85fa169688dfdd65ddd4db1699d01b6/PySide6_Addons-6.9.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:da7869b02e3599d26546fad582db4656060786bc5ec8ece5ec9ee8aa8b42371c", size = 166690468, upload-time = "2025-06-03T13:06:34.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/08/a264db09ad35819643d910cd4c73a86f72f23b7092f8ebc7e51dcca53a86/PySide6_Addons-6.9.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:53fd08c8152b6ba8c435458afd189835ba905793a5077a2bb0b1b11222b375d4", size = 162466096, upload-time = "2025-06-03T13:08:58.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/be/a849402f7e73d137b5ae8b4370a49b0cf0e0c02f028b845782cb743e4995/PySide6_Addons-6.9.1-cp39-abi3-win_amd64.whl", hash = "sha256:cd93a3a5e3886cd958f3a5acc7c061c24f10a394ce9f4ce657ac394544ca7ec2", size = 143150906, upload-time = "2025-06-03T13:09:12.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/f1/1bb6b5859aff4e2b3f5ef789b9cee200811a9f469f04d9aa7425e816622b/PySide6_Addons-6.9.1-cp39-abi3-win_arm64.whl", hash = "sha256:4f589631bdceb518080ae9c9fa288e64f092cd5bebe25adc8ad89e8eadd4db29", size = 26938762, upload-time = "2025-06-03T13:09:20.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/f9/b72a2578d7dbef7741bb90b5756b4ef9c99a5b40148ea53ce7f048573fe9/pyside6_addons-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:4d2b82bbf9b861134845803837011e5f9ac7d33661b216805273cf0c6d0f8e82", size = 322639446, upload-time = "2025-11-20T09:54:50.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/3b/3ed951c570a15570706a89d39bfd4eaaffdf16d5c2dca17e82fc3ec8aaa6/pyside6_addons-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:330c229b58d30083a7b99ed22e118eb4f4126408429816a4044ccd0438ae81b4", size = 170678293, upload-time = "2025-11-20T09:56:40.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/77/4c780b204d0bf3323a75c184e349d063e208db44c993f1214aa4745d6f47/pyside6_addons-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:56864b5fecd6924187a2d0f7e98d968ed72b6cc267caa5b294cd7e88fff4e54c", size = 166365011, upload-time = "2025-11-20T09:57:20.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/14/58239776499e6b279fa6ca2e0d47209531454b99f6bd2ad7c96f11109416/pyside6_addons-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:b6e249d15407dd33d6a2ffabd9dc6d7a8ab8c95d05f16a71dad4d07781c76341", size = 164864664, upload-time = "2025-11-20T09:57:54.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/cd/1b74108671ba4b1ebb2661330665c4898b089e9c87f7ba69fe2438f3d1b6/pyside6_addons-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:0de303c0447326cdc6c8be5ab066ef581e2d0baf22560c9362d41b8304fdf2db", size = 34191225, upload-time = "2025-11-20T09:58:04.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyside6-essentials"
|
||||
version = "6.9.1"
|
||||
version = "6.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "shiboken6" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/59/714874db9ef3bbbbda654fd3223248969bea02ec1a5bfdd1c941c4e97749/PySide6_Essentials-6.9.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:ed43435a70e018e1c22efcaf34a9430b83cfcad716dba661b03de21c13322fab", size = 132957077, upload-time = "2025-06-03T13:11:52.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/6a/ea0db68d40a1c487fd255634896f4e37b6560e3ef1f57ca5139bf6509b1f/PySide6_Essentials-6.9.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:e5da48883f006c6206ef85874db74ddebcdf69b0281bd4f1642b1c5ac1d54aea", size = 96416183, upload-time = "2025-06-03T13:12:48.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/2f/4243630d1733522638c4967d36018c38719d8b84f5246bf3d4c010e0aa9d/PySide6_Essentials-6.9.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:e46a2801c9c6098025515fd0af6c594b9e9c951842f68b8f6f3da9858b9b26c2", size = 94171343, upload-time = "2025-06-03T13:12:59.426Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/a9/a8e0209ba9116f2c2db990cfb79f2edbd5a3a428013be2df1f1cddd660a9/PySide6_Essentials-6.9.1-cp39-abi3-win_amd64.whl", hash = "sha256:ad1ac94011492dba33051bc33db1c76a7d6f815a81c01422cb6220273b369145", size = 72435676, upload-time = "2025-06-03T13:13:08.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/e4/23268c57e775a1a4d2843d288a9583a47f2e4b3977a9ae93cb9ded1a4ea5/PySide6_Essentials-6.9.1-cp39-abi3-win_arm64.whl", hash = "sha256:35c2c2bb4a88db74d11e638cf917524ff35785883f10b439ead07960a5733aa4", size = 49483707, upload-time = "2025-06-03T13:13:16.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/b0/c43209fecef79912e9b1c70a1b5172b1edf76caebcc885c58c60a09613b0/pyside6_essentials-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:cd224aff3bb26ff1fca32c050e1c4d0bd9f951a96219d40d5f3d0128485b0bbe", size = 105461499, upload-time = "2025-11-20T09:59:23.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/8e/b69ba7fa0c701f3f4136b50460441697ec49ee6ea35c229eb2a5ee4b5952/pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e9ccbfb58c03911a0bce1f2198605b02d4b5ca6276bfc0cbcf7c6f6393ffb856", size = 76764617, upload-time = "2025-11-20T09:59:38.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/83/569d27f4b6c6b9377150fe1a3745d64d02614021bea233636bc936a23423/pyside6_essentials-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:ec8617c9b143b0c19ba1cc5a7e98c538e4143795480cb152aee47802c18dc5d2", size = 75850373, upload-time = "2025-11-20T09:59:56.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/64/a8df6333de8ccbf3a320e1346ca30d0f314840aff5e3db9b4b66bf38e26c/pyside6_essentials-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:9555a48e8f0acf63fc6a23c250808db841b28a66ed6ad89ee0e4df7628752674", size = 74491180, upload-time = "2025-11-20T10:00:11.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/da/65cc6c6a870d4ea908c59b2f0f9e2cf3bfc6c0710ebf278ed72f69865e4e/pyside6_essentials-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:4d1d248644f1778f8ddae5da714ca0f5a150a5e6f602af2765a7d21b876da05c", size = 55190458, upload-time = "2025-11-20T10:00:26.226Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyudev"
|
||||
version = "0.24.3"
|
||||
version = "0.24.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/5c/6cc034da13830e3da123ccf9a30910bc868fa16670362f004e4b788d0df1/pyudev-0.24.3.tar.gz", hash = "sha256:2e945427a21674893bb97632401db62139d91cea1ee96137cc7b07ad22198fc7", size = 55970, upload-time = "2024-05-10T18:24:04.599Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/1d/8bdbf651de1002e8b58fbe817bee22b1e8bfcdd24341d42c3238ce9a75f4/pyudev-0.24.4.tar.gz", hash = "sha256:e788bb983700b1a84efc2e88862b0a51af2a995d5b86bc9997546505cf7b36bc", size = 56135, upload-time = "2025-10-08T17:26:58.661Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/3b/c37870f68ceb067707ca7b04db364a1478fcd40c6194007fb6e492ff9a92/pyudev-0.24.3-py3-none-any.whl", hash = "sha256:e8246f0a014fe370119ba2bc781bfbe62c0298d0d6b39c94e83102a8a3f56960", size = 62677, upload-time = "2024-05-10T18:24:02.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/51/3dc0cd6498b24dea3cdeaed648568e3ca7454d41334d840b114156d7479f/pyudev-0.24.4-py3-none-any.whl", hash = "sha256:b3b6b01c68e6fc628428cc45ff3fe6c277afbb5d96507f14473ddb4a6b959e00", size = 62784, upload-time = "2025-10-08T17:26:57.664Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -753,6 +792,96 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rapidfuzz"
|
||||
version = "3.14.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d3/28/9d808fe62375b9aab5ba92fa9b29371297b067c2790b2d7cda648b1e2f8d/rapidfuzz-3.14.3.tar.gz", hash = "sha256:2491937177868bc4b1e469087601d53f925e8d270ccc21e07404b4b5814b7b5f", size = 57863900, upload-time = "2025-11-01T11:54:52.321Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/d1/0efa42a602ed466d3ca1c462eed5d62015c3fd2a402199e2c4b87aa5aa25/rapidfuzz-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9fcd4d751a4fffa17aed1dde41647923c72c74af02459ad1222e3b0022da3a1", size = 1952376, upload-time = "2025-11-01T11:52:29.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/00/37a169bb28b23850a164e6624b1eb299e1ad73c9e7c218ee15744e68d628/rapidfuzz-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ad73afb688b36864a8d9b7344a9cf6da186c471e5790cbf541a635ee0f457f2", size = 1390903, upload-time = "2025-11-01T11:52:31.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/91/b37207cbbdb6eaafac3da3f55ea85287b27745cb416e75e15769b7d8abe8/rapidfuzz-3.14.3-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5fb2d978a601820d2cfd111e2c221a9a7bfdf84b41a3ccbb96ceef29f2f1ac7", size = 1385655, upload-time = "2025-11-01T11:52:32.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/bb/ca53e518acf43430be61f23b9c5987bd1e01e74fcb7a9ee63e00f597aefb/rapidfuzz-3.14.3-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d83b8b712fa37e06d59f29a4b49e2e9e8635e908fbc21552fe4d1163db9d2a1", size = 3164708, upload-time = "2025-11-01T11:52:34.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/e1/7667bf2db3e52adb13cb933dd4a6a2efc66045d26fa150fc0feb64c26d61/rapidfuzz-3.14.3-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:dc8c07801df5206b81ed6bd6c35cb520cf9b6c64b9b0d19d699f8633dc942897", size = 1221106, upload-time = "2025-11-01T11:52:36.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/8a/84d9f2d46a2c8eb2ccae81747c4901fa10fe4010aade2d57ce7b4b8e02ec/rapidfuzz-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c71ce6d4231e5ef2e33caa952bfe671cb9fd42e2afb11952df9fad41d5c821f9", size = 2406048, upload-time = "2025-11-01T11:52:37.936Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a9/a0b7b7a1b81a020c034eb67c8e23b7e49f920004e295378de3046b0d99e1/rapidfuzz-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:0e38828d1381a0cceb8a4831212b2f673d46f5129a1897b0451c883eaf4a1747", size = 2527020, upload-time = "2025-11-01T11:52:39.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/bc/416df7d108b99b4942ba04dd4cf73c45c3aadb3ef003d95cad78b1d12eb9/rapidfuzz-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da2a007434323904719158e50f3076a4dadb176ce43df28ed14610c773cc9825", size = 4273958, upload-time = "2025-11-01T11:52:41.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/d0/b81e041c17cd475002114e0ab8800e4305e60837882cb376a621e520d70f/rapidfuzz-3.14.3-cp310-cp310-win32.whl", hash = "sha256:fce3152f94afcfd12f3dd8cf51e48fa606e3cb56719bccebe3b401f43d0714f9", size = 1725043, upload-time = "2025-11-01T11:52:42.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/6b/64ad573337d81d64bc78a6a1df53a72a71d54d43d276ce0662c2e95a1f35/rapidfuzz-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:37d3c653af15cd88592633e942f5407cb4c64184efab163c40fcebad05f25141", size = 1542273, upload-time = "2025-11-01T11:52:44.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/5e/faf76e259bc15808bc0b86028f510215c3d755b6c3a3911113079485e561/rapidfuzz-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:cc594bbcd3c62f647dfac66800f307beaee56b22aaba1c005e9c4c40ed733923", size = 814875, upload-time = "2025-11-01T11:52:45.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/25/5b0a33ad3332ee1213068c66f7c14e9e221be90bab434f0cb4defa9d6660/rapidfuzz-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dea2d113e260a5da0c4003e0a5e9fdf24a9dc2bb9eaa43abd030a1e46ce7837d", size = 1953885, upload-time = "2025-11-01T11:52:47.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/ab/f1181f500c32c8fcf7c966f5920c7e56b9b1d03193386d19c956505c312d/rapidfuzz-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e6c31a4aa68cfa75d7eede8b0ed24b9e458447db604c2db53f358be9843d81d3", size = 1390200, upload-time = "2025-11-01T11:52:49.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/2a/0f2de974ececad873865c6bb3ea3ad07c976ac293d5025b2d73325aac1d4/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02821366d928e68ddcb567fed8723dad7ea3a979fada6283e6914d5858674850", size = 1389319, upload-time = "2025-11-01T11:52:51.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/69/309d8f3a0bb3031fd9b667174cc4af56000645298af7c2931be5c3d14bb4/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe8df315ab4e6db4e1be72c5170f8e66021acde22cd2f9d04d2058a9fd8162e", size = 3178495, upload-time = "2025-11-01T11:52:53.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/b7/f9c44a99269ea5bf6fd6a40b84e858414b6e241288b9f2b74af470d222b1/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:769f31c60cd79420188fcdb3c823227fc4a6deb35cafec9d14045c7f6743acae", size = 1228443, upload-time = "2025-11-01T11:52:54.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/0a/3b3137abac7f19c9220e14cd7ce993e35071a7655e7ef697785a3edfea1a/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:54fa03062124e73086dae66a3451c553c1e20a39c077fd704dc7154092c34c63", size = 2411998, upload-time = "2025-11-01T11:52:56.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/b6/983805a844d44670eaae63831024cdc97ada4e9c62abc6b20703e81e7f9b/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:834d1e818005ed0d4ae38f6b87b86fad9b0a74085467ece0727d20e15077c094", size = 2530120, upload-time = "2025-11-01T11:52:58.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/cc/2c97beb2b1be2d7595d805682472f1b1b844111027d5ad89b65e16bdbaaa/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:948b00e8476a91f510dd1ec07272efc7d78c275d83b630455559671d4e33b678", size = 4283129, upload-time = "2025-11-01T11:53:00.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/03/2f0e5e94941045aefe7eafab72320e61285c07b752df9884ce88d6b8b835/rapidfuzz-3.14.3-cp311-cp311-win32.whl", hash = "sha256:43d0305c36f504232f18ea04e55f2059bb89f169d3119c4ea96a0e15b59e2a91", size = 1724224, upload-time = "2025-11-01T11:53:02.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/99/5fa23e204435803875daefda73fd61baeabc3c36b8fc0e34c1705aab8c7b/rapidfuzz-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:ef6bf930b947bd0735c550683939a032090f1d688dfd8861d6b45307b96fd5c5", size = 1544259, upload-time = "2025-11-01T11:53:03.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/35/d657b85fcc615a42661b98ac90ce8e95bd32af474603a105643963749886/rapidfuzz-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:f3eb0ff3b75d6fdccd40b55e7414bb859a1cda77c52762c9c82b85569f5088e7", size = 814734, upload-time = "2025-11-01T11:53:05.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/8e/3c215e860b458cfbedb3ed73bc72e98eb7e0ed72f6b48099604a7a3260c2/rapidfuzz-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:685c93ea961d135893b5984a5a9851637d23767feabe414ec974f43babbd8226", size = 1945306, upload-time = "2025-11-01T11:53:06.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/d9/31b33512015c899f4a6e6af64df8dfe8acddf4c8b40a4b3e0e6e1bcd00e5/rapidfuzz-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fa7c8f26f009f8c673fbfb443792f0cf8cf50c4e18121ff1e285b5e08a94fbdb", size = 1390788, upload-time = "2025-11-01T11:53:08.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/67/2ee6f8de6e2081ccd560a571d9c9063184fe467f484a17fa90311a7f4a2e/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57f878330c8d361b2ce76cebb8e3e1dc827293b6abf404e67d53260d27b5d941", size = 1374580, upload-time = "2025-11-01T11:53:10.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c5f545f454871e6af05753a0172849c82feaf0f521c5ca62ba09e1b382d6382", size = 3154947, upload-time = "2025-11-01T11:53:12.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/cf/9f49831085a16384695f9fb096b99662f589e30b89b4a589a1ebc1a19d34/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:07aa0b5d8863e3151e05026a28e0d924accf0a7a3b605da978f0359bb804df43", size = 1223872, upload-time = "2025-11-01T11:53:13.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/0f/41ee8034e744b871c2e071ef0d360686f5ccfe5659f4fd96c3ec406b3c8b/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73b07566bc7e010e7b5bd490fb04bb312e820970180df6b5655e9e6224c137db", size = 2392512, upload-time = "2025-11-01T11:53:15.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/86/280038b6b0c2ccec54fb957c732ad6b41cc1fd03b288d76545b9cf98343f/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6de00eb84c71476af7d3110cf25d8fe7c792d7f5fa86764ef0b4ca97e78ca3ed", size = 2521398, upload-time = "2025-11-01T11:53:17.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/7b/05c26f939607dca0006505e3216248ae2de631e39ef94dd63dbbf0860021/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d7843a1abf0091773a530636fdd2a49a41bcae22f9910b86b4f903e76ddc82dc", size = 4259416, upload-time = "2025-11-01T11:53:19.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/eb/9e3af4103d91788f81111af1b54a28de347cdbed8eaa6c91d5e98a889aab/rapidfuzz-3.14.3-cp312-cp312-win32.whl", hash = "sha256:dea97ac3ca18cd3ba8f3d04b5c1fe4aa60e58e8d9b7793d3bd595fdb04128d7a", size = 1709527, upload-time = "2025-11-01T11:53:20.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/63/d06ecce90e2cf1747e29aeab9f823d21e5877a4c51b79720b2d3be7848f8/rapidfuzz-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:b5100fd6bcee4d27f28f4e0a1c6b5127bc8ba7c2a9959cad9eab0bf4a7ab3329", size = 1538989, upload-time = "2025-11-01T11:53:22.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/6d/beee32dcda64af8128aab3ace2ccb33d797ed58c434c6419eea015fec779/rapidfuzz-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:4e49c9e992bc5fc873bd0fff7ef16a4405130ec42f2ce3d2b735ba5d3d4eb70f", size = 811161, upload-time = "2025-11-01T11:53:23.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/4f/0d94d09646853bd26978cb3a7541b6233c5760687777fa97da8de0d9a6ac/rapidfuzz-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbcb726064b12f356bf10fffdb6db4b6dce5390b23627c08652b3f6e49aa56ae", size = 1939646, upload-time = "2025-11-01T11:53:25.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/eb/f96aefc00f3bbdbab9c0657363ea8437a207d7545ac1c3789673e05d80bd/rapidfuzz-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1704fc70d214294e554a2421b473779bcdeef715881c5e927dc0f11e1692a0ff", size = 1385512, upload-time = "2025-11-01T11:53:27.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/34/71c4f7749c12ee223dba90017a5947e8f03731a7cc9f489b662a8e9e643d/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc65e72790ddfd310c2c8912b45106e3800fefe160b0c2ef4d6b6fec4e826457", size = 1373571, upload-time = "2025-11-01T11:53:29.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e38c1305cffae8472572a0584d4ffc2f130865586a81038ca3965301f7c97c", size = 3156759, upload-time = "2025-11-01T11:53:30.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/d5/b41eeb4930501cc899d5a9a7b5c9a33d85a670200d7e81658626dcc0ecc0/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:e195a77d06c03c98b3fc06b8a28576ba824392ce40de8c708f96ce04849a052e", size = 1222067, upload-time = "2025-11-01T11:53:32.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/7d/6d9abb4ffd1027c6ed837b425834f3bed8344472eb3a503ab55b3407c721/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b7ef2f4b8583a744338a18f12c69693c194fb6777c0e9ada98cd4d9e8f09d10", size = 2394775, upload-time = "2025-11-01T11:53:34.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/ce/4f3ab4c401c5a55364da1ffff8cc879fc97b4e5f4fa96033827da491a973/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a2135b138bcdcb4c3742d417f215ac2d8c2b87bde15b0feede231ae95f09ec41", size = 2526123, upload-time = "2025-11-01T11:53:35.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/4b/54f804975376a328f57293bd817c12c9036171d15cf7292032e3f5820b2d/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33a325ed0e8e1aa20c3e75f8ab057a7b248fdea7843c2a19ade0008906c14af0", size = 4262874, upload-time = "2025-11-01T11:53:37.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/b6/958db27d8a29a50ee6edd45d33debd3ce732e7209183a72f57544cd5fe22/rapidfuzz-3.14.3-cp313-cp313-win32.whl", hash = "sha256:8383b6d0d92f6cd008f3c9216535be215a064b2cc890398a678b56e6d280cb63", size = 1707972, upload-time = "2025-11-01T11:53:39.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/75/fde1f334b0cec15b5946d9f84d73250fbfcc73c236b4bc1b25129d90876b/rapidfuzz-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:e6b5e3036976f0fde888687d91be86d81f9ac5f7b02e218913c38285b756be6c", size = 1537011, upload-time = "2025-11-01T11:53:40.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/d7/d83fe001ce599dc7ead57ba1debf923dc961b6bdce522b741e6b8c82f55c/rapidfuzz-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:7ba009977601d8b0828bfac9a110b195b3e4e79b350dcfa48c11269a9f1918a0", size = 810744, upload-time = "2025-11-01T11:53:42.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/13/a486369e63ff3c1a58444d16b15c5feb943edd0e6c28a1d7d67cb8946b8f/rapidfuzz-3.14.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0a28add871425c2fe94358c6300bbeb0bc2ed828ca003420ac6825408f5a424", size = 1967702, upload-time = "2025-11-01T11:53:44.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/82/efad25e260b7810f01d6b69122685e355bed78c94a12784bac4e0beb2afb/rapidfuzz-3.14.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010e12e2411a4854b0434f920e72b717c43f8ec48d57e7affe5c42ecfa05dd0e", size = 1410702, upload-time = "2025-11-01T11:53:46.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/1a/34c977b860cde91082eae4a97ae503f43e0d84d4af301d857679b66f9869/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cfc3d57abd83c734d1714ec39c88a34dd69c85474918ebc21296f1e61eb5ca8", size = 1382337, upload-time = "2025-11-01T11:53:47.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/74/f50ea0e24a5880a9159e8fd256b84d8f4634c2f6b4f98028bdd31891d907/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89acb8cbb52904f763e5ac238083b9fc193bed8d1f03c80568b20e4cef43a519", size = 3165563, upload-time = "2025-11-01T11:53:49.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/7a/e744359404d7737049c26099423fc54bcbf303de5d870d07d2fb1410f567/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_31_armv7l.whl", hash = "sha256:7d9af908c2f371bfb9c985bd134e295038e3031e666e4b2ade1e7cb7f5af2f1a", size = 1214727, upload-time = "2025-11-01T11:53:50.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/2e/87adfe14ce75768ec6c2b8acd0e05e85e84be4be5e3d283cdae360afc4fe/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1f1925619627f8798f8c3a391d81071336942e5fe8467bc3c567f982e7ce2897", size = 2403349, upload-time = "2025-11-01T11:53:52.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/17/6c0b2b2bff9c8b12e12624c07aa22e922b0c72a490f180fa9183d1ef2c75/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:152555187360978119e98ce3e8263d70dd0c40c7541193fc302e9b7125cf8f58", size = 2507596, upload-time = "2025-11-01T11:53:53.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d1/87852a7cbe4da7b962174c749a47433881a63a817d04f3e385ea9babcd9e/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52619d25a09546b8db078981ca88939d72caa6b8701edd8b22e16482a38e799f", size = 4273595, upload-time = "2025-11-01T11:53:55.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ab/1d0354b7d1771a28fa7fe089bc23acec2bdd3756efa2419f463e3ed80e16/rapidfuzz-3.14.3-cp313-cp313t-win32.whl", hash = "sha256:489ce98a895c98cad284f0a47960c3e264c724cb4cfd47a1430fa091c0c25204", size = 1757773, upload-time = "2025-11-01T11:53:57.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/0c/71ef356adc29e2bdf74cd284317b34a16b80258fa0e7e242dd92cc1e6d10/rapidfuzz-3.14.3-cp313-cp313t-win_amd64.whl", hash = "sha256:656e52b054d5b5c2524169240e50cfa080b04b1c613c5f90a2465e84888d6f15", size = 1576797, upload-time = "2025-11-01T11:53:59.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/d2/0e64fc27bb08d4304aa3d11154eb5480bcf5d62d60140a7ee984dc07468a/rapidfuzz-3.14.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c7e40c0a0af02ad6e57e89f62bef8604f55a04ecae90b0ceeda591bbf5923317", size = 829940, upload-time = "2025-11-01T11:54:01.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/6f/1b88aaeade83abc5418788f9e6b01efefcd1a69d65ded37d89cd1662be41/rapidfuzz-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:442125473b247227d3f2de807a11da6c08ccf536572d1be943f8e262bae7e4ea", size = 1942086, upload-time = "2025-11-01T11:54:02.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/2c/b23861347436cb10f46c2bd425489ec462790faaa360a54a7ede5f78de88/rapidfuzz-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ec0c8c0c3d4f97ced46b2e191e883f8c82dbbf6d5ebc1842366d7eff13cd5a6", size = 1386993, upload-time = "2025-11-01T11:54:04.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/86/5d72e2c060aa1fbdc1f7362d938f6b237dff91f5b9fc5dd7cc297e112250/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2dc37bc20272f388b8c3a4eba4febc6e77e50a8f450c472def4751e7678f55e4", size = 1379126, upload-time = "2025-11-01T11:54:05.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/bc/ef2cee3e4d8b3fc22705ff519f0d487eecc756abdc7c25d53686689d6cf2/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee362e7e79bae940a5e2b3f6d09c6554db6a4e301cc68343886c08be99844f1", size = 3159304, upload-time = "2025-11-01T11:54:07.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/36/dc5f2f62bbc7bc90be1f75eeaf49ed9502094bb19290dfb4747317b17f12/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:4b39921df948388a863f0e267edf2c36302983459b021ab928d4b801cbe6a421", size = 1218207, upload-time = "2025-11-01T11:54:09.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/7e/8f4be75c1bc62f47edf2bbbe2370ee482fae655ebcc4718ac3827ead3904/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:beda6aa9bc44d1d81242e7b291b446be352d3451f8217fcb068fc2933927d53b", size = 2401245, upload-time = "2025-11-01T11:54:11.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/38/f7c92759e1bb188dd05b80d11c630ba59b8d7856657baf454ff56059c2ab/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6a014ba09657abfcfeed64b7d09407acb29af436d7fc075b23a298a7e4a6b41c", size = 2518308, upload-time = "2025-11-01T11:54:13.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/ac/85820f70fed5ecb5f1d9a55f1e1e2090ef62985ef41db289b5ac5ec56e28/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32eeafa3abce138bb725550c0e228fc7eaeec7059aa8093d9cbbec2b58c2371a", size = 4265011, upload-time = "2025-11-01T11:54:15.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/a9/616930721ea9835c918af7cde22bff17f9db3639b0c1a7f96684be7f5630/rapidfuzz-3.14.3-cp314-cp314-win32.whl", hash = "sha256:adb44d996fc610c7da8c5048775b21db60dd63b1548f078e95858c05c86876a3", size = 1742245, upload-time = "2025-11-01T11:54:17.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/8a/f2fa5e9635b1ccafda4accf0e38246003f69982d7c81f2faa150014525a4/rapidfuzz-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:f3d15d8527e2b293e38ce6e437631af0708df29eafd7c9fc48210854c94472f9", size = 1584856, upload-time = "2025-11-01T11:54:18.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/97/09e20663917678a6d60d8e0e29796db175b1165e2079830430342d5298be/rapidfuzz-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:576e4b9012a67e0bf54fccb69a7b6c94d4e86a9540a62f1a5144977359133583", size = 833490, upload-time = "2025-11-01T11:54:20.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/1b/6b6084576ba87bf21877c77218a0c97ba98cb285b0c02eaaee3acd7c4513/rapidfuzz-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cec3c0da88562727dd5a5a364bd9efeb535400ff0bfb1443156dd139a1dd7b50", size = 1968658, upload-time = "2025-11-01T11:54:22.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/c0/fb02a0db80d95704b0a6469cc394e8c38501abf7e1c0b2afe3261d1510c2/rapidfuzz-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d1fa009f8b1100e4880868137e7bf0501422898f7674f2adcd85d5a67f041296", size = 1410742, upload-time = "2025-11-01T11:54:23.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/72/3fbf12819fc6afc8ec75a45204013b40979d068971e535a7f3512b05e765/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b86daa7419b5e8b180690efd1fdbac43ff19230803282521c5b5a9c83977655", size = 1382810, upload-time = "2025-11-01T11:54:25.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/18/0f1991d59bb7eee28922a00f79d83eafa8c7bfb4e8edebf4af2a160e7196/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7bd1816db05d6c5ffb3a4df0a2b7b56fb8c81ef584d08e37058afa217da91b1", size = 3166349, upload-time = "2025-11-01T11:54:27.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f0/baa958b1989c8f88c78bbb329e969440cf330b5a01a982669986495bb980/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:33da4bbaf44e9755b0ce192597f3bde7372fe2e381ab305f41b707a95ac57aa7", size = 1214994, upload-time = "2025-11-01T11:54:28.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/a0/cd12ec71f9b2519a3954febc5740291cceabc64c87bc6433afcb36259f3b/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3fecce764cf5a991ee2195a844196da840aba72029b2612f95ac68a8b74946bf", size = 2403919, upload-time = "2025-11-01T11:54:30.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ce/019bd2176c1644098eced4f0595cb4b3ef52e4941ac9a5854f209d0a6e16/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ecd7453e02cf072258c3a6b8e930230d789d5d46cc849503729f9ce475d0e785", size = 2508346, upload-time = "2025-11-01T11:54:32.048Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/f8/be16c68e2c9e6c4f23e8f4adbb7bccc9483200087ed28ff76c5312da9b14/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea188aa00e9bcae8c8411f006a5f2f06c4607a02f24eab0d8dc58566aa911f35", size = 4274105, upload-time = "2025-11-01T11:54:33.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/d1/5ab148e03f7e6ec8cd220ccf7af74d3aaa4de26dd96df58936beb7cba820/rapidfuzz-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:7ccbf68100c170e9a0581accbe9291850936711548c6688ce3bfb897b8c589ad", size = 1793465, upload-time = "2025-11-01T11:54:35.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/97/433b2d98e97abd9fff1c470a109b311669f44cdec8d0d5aa250aceaed1fb/rapidfuzz-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9ec02e62ae765a318d6de38df609c57fc6dacc65c0ed1fd489036834fd8a620c", size = 1623491, upload-time = "2025-11-01T11:54:38.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/f6/e2176eb94f94892441bce3ddc514c179facb65db245e7ce3356965595b19/rapidfuzz-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e805e52322ae29aa945baf7168b6c898120fbc16d2b8f940b658a5e9e3999253", size = 851487, upload-time = "2025-11-01T11:54:40.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/33/b5bd6475c7c27164b5becc9b0e3eb978f1e3640fea590dd3dced6006ee83/rapidfuzz-3.14.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7cf174b52cb3ef5d49e45d0a1133b7e7d0ecf770ed01f97ae9962c5c91d97d23", size = 1888499, upload-time = "2025-11-01T11:54:42.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/d2/89d65d4db4bb931beade9121bc71ad916b5fa9396e807d11b33731494e8e/rapidfuzz-3.14.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:442cba39957a008dfc5bdef21a9c3f4379e30ffb4e41b8555dbaf4887eca9300", size = 1336747, upload-time = "2025-11-01T11:54:43.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/33/cd87d92b23f0b06e8914a61cea6850c6d495ca027f669fab7a379041827a/rapidfuzz-3.14.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1faa0f8f76ba75fd7b142c984947c280ef6558b5067af2ae9b8729b0a0f99ede", size = 1352187, upload-time = "2025-11-01T11:54:45.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/20/9d30b4a1ab26aac22fff17d21dec7e9089ccddfe25151d0a8bb57001dc3d/rapidfuzz-3.14.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e6eefec45625c634926a9fd46c9e4f31118ac8f3156fff9494422cee45207e6", size = 3101472, upload-time = "2025-11-01T11:54:47.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/ad/fa2d3e5c29a04ead7eaa731c7cd1f30f9ec3c77b3a578fdf90280797cbcb/rapidfuzz-3.14.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56fefb4382bb12250f164250240b9dd7772e41c5c8ae976fd598a32292449cc5", size = 1511361, upload-time = "2025-11-01T11:54:49.057Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
@@ -770,23 +899,23 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "shiboken6"
|
||||
version = "6.9.1"
|
||||
version = "6.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/98/34d4d25b79055959b171420d47fcc10121aefcbb261c91d5491252830e31/shiboken6-6.9.1-cp39-abi3-macosx_12_0_universal2.whl", hash = "sha256:40e92afc88da06b5100c56b761e59837ff282166e9531268f3d910b6128e621e", size = 406159, upload-time = "2025-06-03T13:16:45.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/07/53b2532ecd42ff925feb06b7bb16917f5f99f9c3470f0815c256789d818b/shiboken6-6.9.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:efcdfa8655d34aaf8d7a0c7724def3440bd46db02f5ad3b1785db5f6ccb0a8ff", size = 206756, upload-time = "2025-06-03T13:16:46.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/b0/75b86ee3f7b044e6a87fbe7abefd1948ca4ae5fcde8321f4986a1d9eaa5e/shiboken6-6.9.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:efcf75d48a29ae072d0bf54b3cd5a59ae91bb6b3ab7459e17c769355486c2e0b", size = 203233, upload-time = "2025-06-03T13:16:48.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/56/00af281275aab4c79e22e0ea65feede0a5c6da3b84e86b21a4a0071e0744/shiboken6-6.9.1-cp39-abi3-win_amd64.whl", hash = "sha256:209ccf02c135bd70321143dcbc5023ae0c056aa4850a845955dd2f9b2ff280a9", size = 1153587, upload-time = "2025-06-03T13:16:50.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/ce/6ccd382fbe1a96926c5514afa6f2c42da3a9a8482e61f8dfc6068a9ca64f/shiboken6-6.9.1-cp39-abi3-win_arm64.whl", hash = "sha256:2a39997ce275ced7853defc89d3a1f19a11c90991ac6eef3435a69bb0b7ff1de", size = 1831623, upload-time = "2025-06-03T13:16:52.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/8b/e5db743d505ceea3efc4cd9634a3bee22a3e2bf6e07cefd28c9b9edabcc6/shiboken6-6.10.1-cp39-abi3-macosx_13_0_universal2.whl", hash = "sha256:9f2990f5b61b0b68ecadcd896ab4441f2cb097eef7797ecc40584107d9850d71", size = 478483, upload-time = "2025-11-20T10:08:52.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/ba/b50c1a44b3c4643f482afbf1a0ea58f393827307100389ce29404f9ad3b0/shiboken6-6.10.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4221a52dfb81f24a0d20cc4f8981cb6edd810d5a9fb28287ce10d342573a0e4", size = 271993, upload-time = "2025-11-20T10:08:54.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/b8/939c24ebd662b0aa5c945443d0973145b3fb7079f0196274ef7bb4b98f73/shiboken6-6.10.1-cp39-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:c095b00f4d6bf578c0b2464bb4e264b351a99345374478570f69e2e679a2a1d0", size = 268691, upload-time = "2025-11-20T10:08:55.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/a6/8c65ee0fa5e172ebcca03246b1bc3bd96cdaf1d60537316648536b7072a5/shiboken6-6.10.1-cp39-abi3-win_amd64.whl", hash = "sha256:c1601d3cda1fa32779b141663873741b54e797cb0328458d7466281f117b0a4e", size = 1234704, upload-time = "2025-11-20T10:08:57.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/6a/c0fea2f2ac7d9d96618c98156500683a4d1f93fea0e8c5a2bc39913d7ef1/shiboken6-6.10.1-cp39-abi3-win_arm64.whl", hash = "sha256:5cf800917008587b551005a45add2d485cca66f5f7ecd5b320e9954e40448cc9", size = 1795567, upload-time = "2025-11-20T10:08:59.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8"
|
||||
version = "2.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/23/adf3796d740536d63a6fbda113d07e60c734b6ed5d3058d1e47fc0495e47/soupsieve-2.8.1.tar.gz", hash = "sha256:4cf733bc50fa805f5df4b8ef4740fc0e0fa6218cf3006269afd3f9d6d80fd350", size = 117856, upload-time = "2025-12-18T13:50:34.655Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -812,11 +941,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.5.0"
|
||||
version = "2.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -830,7 +959,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "20.34.0"
|
||||
version = "20.35.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "distlib" },
|
||||
@@ -838,9 +967,9 @@ dependencies = [
|
||||
{ name = "platformdirs" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||