diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5f1ff3790..80de32e0e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,85 +1,171 @@ -name: Build with pyinstaller +name: Build TiLiA on: workflow_dispatch: push: tags: - - 'v*' + - "v*" jobs: build: - name: Build with pyinstaller + name: Build with Nuitka + continue-on-error: true runs-on: ${{ matrix.os }} + permissions: + contents: read + outputs: + linux-build: ${{ steps.set-path.outputs.path }} strategy: matrix: - include: - - os: windows-latest - CMD_BUILD: pyinstaller tilia.spec - OUT_FILE_OS: win - OUT_FILE_EXTENSION: .exe - ASSET_MIME: application/vnd.microsoft.portable-executable - - - os: macos-13 - CMD_BUILD: > - pyinstaller tilia.spec && - cd dist && - mv tilia-$APP_VERSION-mac.app tilia-$APP_VERSION-mac-intel.app && - zip -r9 tilia-$APP_VERSION-mac-intel.zip tilia-$APP_VERSION-mac-intel.app - OUT_FILE_OS: mac-intel - OUT_FILE_EXTENSION: .zip - ASSET_MIME: application/zip - - - os: macos-14 - CMD_BUILD: > - pyinstaller tilia.spec && - cd dist && - mv tilia-$APP_VERSION-mac.app tilia-$APP_VERSION-mac-silicon.app && - zip -r9 tilia-$APP_VERSION-mac-silicon.zip tilia-$APP_VERSION-mac-silicon.app - OUT_FILE_OS: mac-silicon - OUT_FILE_EXTENSION: .zip - ASSET_MIME: application/zip - - - os: ubuntu-latest - CMD_BUILD: > - pyinstaller tilia.spec - OUT_FILE_OS: linux - OUT_FILE_EXTENSION: - ASSET_MIME: application/octet-stream + os: [ + macos-latest, + macos-15-intel, + ubuntu-22.04, + windows-latest + ] + env: + QT_DEBUG_PLUGINS: 1 + QT_QPA_PLATFORM: offscreen steps: - - uses: actions/checkout@v4 - - - name: Extract package version - id: extract_version - shell: pwsh - run: | - $version = (Select-String '^version =' setup.cfg).Line -split ' = ' | Select-Object -Last 1 - echo "APP_VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append - - - name: Set output file name - shell: pwsh - run: | - $out_file_name = "tilia-${{env.APP_VERSION}}-${{matrix.OUT_FILE_OS}}${{matrix.OUT_FILE_EXTENSION}}" - echo "OUT_FILE_NAME=$out_file_name" | Out-File -FilePath $env:GITHUB_ENV -Append + - uses: actions/checkout@v6 - name: Set up Python 3.11 - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.11 - cache: 'pip' + cache: "pip" + + - name: Setup additional Linux dependencies + if: runner.os == 'Linux' + run: | + chmod +x ./scripts/linux-setup.sh + ./scripts/linux-setup.sh + + - name: Remove problematic brew libs (see Nuitka/Nuitka#2853) + if: runner.os == 'macOS' && runner.arch != 'ARM64' + run: | + brew remove --force --ignore-dependencies openssl@3 + brew cleanup openssl@3 - name: Install dependencies run: | - python -m pip install --upgrade pip setuptools wheel - pip install -e . - pip install pyinstaller + python -m pip install --upgrade pip + pip install -e . --group build - name: Build executable + id: build-exe + shell: bash + run: | + echo "python scripts/deploy.py ${{github.ref_name}} ${{matrix.os}}" | bash + + - name: Test executable [mac] + if: runner.os == 'macOS' + shell: bash + run: | + brew install coreutils + echo "Checking dependencies" + # test build starts up - should see the output of qt_debug_plugins. the command will kill itself in 10s if everything works. something went wrong if time taken for this command is less than 10s. + # then zip everything up and delete the original non-zipped file. + echo "Starting GUI..." + start=$(date +%s) + echo "gtimeout 10 ${{ steps.build-exe.outputs.out-filepath }}/Contents/MacOS/${{ steps.build-exe.outputs.out-filename }}" | bash || true + if (($(date +%s) - $start < 10)); then + echo "::error::Process terminated early! Potential errors in build." + exit 1 + fi + echo "\n\nStarting CLI..." + echo "gtimeout 10 ${{ steps.build-exe.outputs.out-filepath }}/Contents/MacOS/${{ steps.build-exe.outputs.out-filename }} --user-interface=cli" | bash || true + + - name: Test executable [Windows] + if: runner.os == 'Windows' shell: bash - run: ${{matrix.CMD_BUILD}} + run: | + echo "Checking dependencies" + # test build starts up - should see the output of qt_debug_plugins. the command will kill itself in 10s if everything works. something went wrong if time taken for this command is not 10s. + echo "Starting GUI..." + start=$(date +%s) + echo "timeout 10 ${{ steps.build-exe.outputs.out-filepath }}" | bash || true + if (($(date +%s) - $start < 10)); then + echo "::error::Process terminated early! Potential errors in build." + exit 1 + fi + echo "\n\nStarting CLI..." + echo "timeout 10 ${{ steps.build-exe.outputs.out-filepath }} --user-interface=cli" | bash || true + + - name: Test executable [Linux] + id: set-path + if: runner.os == 'Linux' + shell: bash + run: | + echo "path=${{ steps.build-exe.outputs.out-filename }}" >> "$GITHUB_OUTPUT" + echo "Checking dependencies" + # test build starts up - should see the output of qt_debug_plugins. the command will kill itself in 10s if everything works. something went wrong if time taken for this command is not 10s. + echo "Starting GUI..." + start=$(date +%s) + echo "timeout 10 ${{ steps.build-exe.outputs.out-filepath }}" | bash || true + if (($(date +%s) - $start < 10)); then + echo "::error::Process terminated early! Potential errors in build." + exit 1 + fi + echo "\n\nStarting CLI..." + echo "timeout 10 ${{ steps.build-exe.outputs.out-filepath }} --user-interface=cli" | bash || true - name: Upload executable - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.build-exe.outputs.out-filename }} + path: build/*/exe + retention-days: 1 + + deploy: + name: Create release + continue-on-error: true + needs: build + runs-on: "ubuntu-latest" + permissions: + contents: write + env: + QT_DEBUG_PLUGINS: 1 + QT_QPA_PLATFORM: offscreen + steps: + - uses: actions/checkout@v6 + + - name: Download executable + uses: actions/download-artifact@v8 + with: + path: build/ + pattern: 'TiLiA-v*' + merge-multiple: true + + - name: Test Linux still works in a clean environment + continue-on-error: true + id: test-linux + run: | + if [ -e build/ubuntu/exe/${{ needs.build.outputs.linux-build }} ]; + then + chmod ugo+x build/ubuntu/exe/${{ needs.build.outputs.linux-build }} + echo "Starting GUI..." + start=$(date +%s) + echo "timeout 10 build/ubuntu/exe/${{ needs.build.outputs.linux-build }}" | bash || true + if (($(date +%s) - $start < 10)); + then + echo "Linux started in a clean environment!"; + else + echo "Linux didn't work... Check libraries with previous step?"; + fi; + echo "Starting CLI..." + echo "timeout 10 build/ubuntu/exe/${{ needs.build.outputs.linux-build }} --user-interface=cli" | bash || true + else + echo "file not found!"; + fi + ls build -ltraR |egrep -v '\.$|\.\.|\.:|\.\/|total|^d' | sed '/^$/d' || true + + - name: Upload + uses: "softprops/action-gh-release@v2" with: - name: ${{env.OUT_FILE_NAME}} - path: dist/${{env.OUT_FILE_NAME}} + tag_name: ${{github.ref_name}} + make_latest: ${{github.event_name == 'push'}} + generate_release_notes: true + files: | + build/*/exe/TiLiA-v* diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index e69a3fc6e..1c95dcb4e 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -24,8 +24,7 @@ jobs: strategy: fail-fast: false matrix: - # TODO: test for 3.13 - python-version: ['3.10', '3.11', '3.12'] + python-version: ['3.10', '3.11', '3.12', '3.13'] os: [macos-latest, ubuntu-latest, windows-latest] include: - os: ubuntu-latest @@ -34,27 +33,31 @@ jobs: path: ~/Library/Caches/pip - os: windows-latest path: ~\AppData\Local\pip\Cache + exclude: + - os: windows-latest + python-version: '3.13' # no pyside support timeout-minutes: 30 env: - DISPLAY: ":99.0" - QT_SELECT: "qt6" + QT_QPA_PLATFORM: offscreen steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - name: Setup xvfb (Linux) + - name: Setup additional Linux dependencies if: runner.os == 'Linux' run: | - sudo apt-get update - sudo apt-get install -y xvfb libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xinput0 libxcb-xfixes0 libxcb-shape0 libglib2.0-0 libgl1-mesa-dev libpulse-dev - sudo apt-get install '^libxcb.*-dev' libx11-xcb-dev libglu1-mesa-dev libxrender-dev libxi-dev libxkbcommon-dev libxkbcommon-x11-dev - sudo /usr/bin/Xvfb $DISPLAY -screen 0 1280x1024x24 & + chmod +x ./scripts/linux-setup.sh + ./scripts/linux-setup.sh - - uses: actions/cache@v4 + - name: Install timeout + if: runner.os == 'macOS' + run: brew install coreutils + + - uses: actions/cache@v5 with: path: ${{ matrix.path }} key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} @@ -64,15 +67,15 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip wheel - pip install -e .[testing,ci-tests] + pip install -e . --group testing --group ci-tests - name: List installed packages run: | pip freeze - - name: Lint with flake8 + - name: Lint with ruff run: | - flake8 + ruff check continue-on-error: true - name: Test with pytest @@ -82,3 +85,12 @@ jobs: - name: Generate Coverage Report run: | coverage report -m + + - name: Check Program + shell: bash + run: | + export QT_DEBUG_PLUGINS=1 + echo "Starting GUI..." + timeout 10 tilia || true + echo "Starting CLI..." + timeout 10 tilia -i=cli || true diff --git a/.gitignore b/.gitignore index 29a3b0892..57b553bff 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,7 @@ /.pytest_cache/ /build/ /dist/ -/tilia/installers/win/tilia* /tilia.egg-info/ /venv/ __pycache__/ -pytest.ini tilia/dev/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6237874a0..c70117428 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,20 +1,21 @@ repos: -- repo: https://github.com/psf/black + - repo: https://github.com/psf/black rev: 22.12.0 hooks: - - id: black + - id: black -- repo: https://github.com/pre-commit/pre-commit-hooks + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-added-large-files - args: ['--maxkb=1000'] + args: ["--maxkb=1000"] - id: debug-statements - id: check-yaml -- repo: https://github.com/pycqa/flake8 - rev: 7.1.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.0 hooks: - - id: flake8 + - id: ruff-check + args: [ --fix ] diff --git a/.tilia.env b/.tilia.env new file mode 100644 index 000000000..ffbaed9d8 --- /dev/null +++ b/.tilia.env @@ -0,0 +1,3 @@ +LOG_REQUESTS = 1 +EXCLUDE_FROM_LOG = TIMELINE_VIEW_LEFT_BUTTON_DRAG;PLAYER_CURRENT_TIME_CHANGED;APP_STATE_RECORD +ENVIRONMENT='dev' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a0335265..f38a04887 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,4 +28,11 @@ If you find a bug, [check](https://github.com/TimeLineAnnotator/desktop/issues) # Suggesting features or enhancements We are very much looking for ideas of new features for TiLiA. If you would like to suggest one, [check](https://github.com/TimeLineAnnotator/desktop/issues) to see if your feature has already been requested. If not, [open a new one](https://github.com/TimeLineAnnotator/desktop/issues/new) detailing your suggestion. -You need Python 3.11 to build and test TiLiA. You will also need to have ffmpeg installed to use the export and convert audio features. +You need Python >=3.10 to build and test TiLiA. You will also need to have ffmpeg installed to use the export and convert audio features. + +# Developing for TiLiA +For a better development experience, we recommend the installation of a few more packages: +``` +pip install --group dev --group testing +pre-commit install +``` diff --git a/README.md b/README.md index f19f9dbcd..105121ea5 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,15 @@ drawing

-TiLiA (TimeLine Annotator) is a GUI for producing and displaying complex annotations with video and audio files. It is a full-featured but easy-to-use set of tools for researchers and enthusiasts to better analyze their media of interest without needing to rely on textual representations (like music scores). It is written in Python, using the PyQt library for its GUI. +TiLiA (TimeLine Annotator) is a GUI for producing and displaying complex annotations with video and audio files. It is a full-featured, easy-to-use set of tools for researchers and enthusiasts to better analyze their media of interest without needing to rely on textual representations (like music scores). It is written in Python, using the PySide library for its GUI. -TiLiA allows users to annotate media files primarily through timelines of various types. Each one provides different tools and enables specific annotations and visualizations. Currently, there are six types of timelines, but many more are planned. +TiLiA allows users to annotate media files primarily through timelines of various types. Each one provides different tools and enables specific annotations and visualizations. Currently, there are seven types of timelines, but many more are planned.

TiLiA desktop interface

-Here are some examples TiLiA visualizations: +Here are some examples of TiLiA visualizations: - Formal analysis of the Piano Sonata in D Major, K.284: - [First movement](https://tilia-app.com/viewer/135/) @@ -20,12 +20,12 @@ Here are some examples TiLiA visualizations: ## Current features -- 6 kinds of timelines +- 7 kinds of timelines - AudioWave: visualize audio files through bars that represent changes in amplitude - - Beat: beat and measure markers with support to numbering + - Beat: beat and measure markers with support for numbering - Harmony: Roman numeral and chord symbol labels using a specialized font, including proper display of inversion numerals, quality symbols and applied chords - - Hierarchy: nested and leveled units organized in arbitrally complex hierarchical structures - - Marker: simple, labeled markers to indicate discrete events + - Hierarchy: nested and levelled units organized in arbitrarily complex hierarchical structures + - Marker: simple, labelled markers to indicate discrete events - PDF: visualize PDF files synced to playback - Score: visualize music scores in custom, to-scale notation or conventional engraving - Controlling playback by clicking on timeline units @@ -41,73 +41,78 @@ Here are some examples TiLiA visualizations: - Command-line interface (see the CLI [help page](docs/cli-tutorial.md) for more information) ## How to install and use -TiLiA has releases for Windows, macOS and Linux. Instructions to download and run are [at the website](https://tilia-app.com/help/introduction/). +TiLiA has releases for Windows, macOS and Linux. Instructions to download and run are [on our website](https://tilia-app.com/help/introduction/). -Tutorials on how to use TiLiA can be found at the [website](https://tilia-app.com/help/) or in video [here](https://www.youtube.com/@tilia-app). +Tutorials on how to use TiLiA can be found on our [website](https://tilia-app.com/help/) or in these [videos](https://www.youtube.com/@tilia-app). ## Build or run from source -TiLiA can be also run and build from source. +TiLiA can also be run and built from source. ### Prerequisites Before you start, you will need: -- Python 3.11 or later. You can get it at the [Python website](https://www.python.org/downloads/). -- `pip` to install dependencies. +- Python 3.10 - 3.12. Download from the [Python website](https://www.python.org/downloads/). (Support for Python 3.13 is variable due to the dependencies used.) +-
+ Other pre-requisites -### Note for Linux users + | Package | Installation | Notes | + | --- | --- | --- | + | `pip` | `python -m ensurepip --upgrade` | `pip` should come with your Python installation | + | `git` | Download [here](https://git-scm.com/install) | Not necessary for a direct download from this repository | +
+ +### Note to Linux users Users have reported dependency issues when running TiLiA on Linux (see [#370](https://github.com/TimeLineAnnotator/desktop/issues/370) and [#371](https://github.com/TimeLineAnnotator/desktop/issues/371)). -That is probably due to `PyInstaller` not being completely compatible with `PyQt`. -We are working to fix that with with a new build process using `pyside6-deploy` instead. +Due to system variations between Linux distributions, some additional system dependencies may be required. Visit our [help page](https://tilia-app.com/help/installation#troubleshooting-linux) for more information. ### Running from source -Clone TiLiA with: +- Clone TiLiA with: ``` git clone https://github.com/TimeLineAnnotator/desktop.git tilia-desktop ``` -Change directory to the cloned repository: +- Change directory to the cloned repository: ``` cd tilia-desktop ``` -Note: We recommend using a clean [virtual environment](https://docs.python.org/3/library/venv.html) for the next steps. -Failure to do so is likely to cause issues with dependencies. +> Note: We recommend using a clean [virtual environment](https://docs.python.org/3/library/venv.html) for the next steps. +Failure to do so may cause dependency issues. -Install python dependencies with: +- Install TiLiA and its dependencies with: ``` pip install -e . ``` -On Linux, some additional Qt dependencies are required: -``` -sudo apt install libnss3 libasound libxkbfile1 libpulse0 -``` -To run TiLiA from source, use: +- To run TiLiA from source, run: ``` -python -m tilia.main +tilia ``` -TiLiA also offers a CLI mode, which can be run with: +- TiLiA also offers a CLI mode, which can be run with: ``` -python -m tilia.main --user-interface cli +tilia --user-interface cli ``` +> Note: The CLI is currently only available when run from source, and not in the compiled executable. ### Building from source -TiLiA uses [PyInstaller](https://pyinstaller.org/en/stable/) to build binaries. -Note that the binaries will be for the platform you are building on, as `PyInstaller` supports no cross-compilation. +TiLiA uses [Nuitka](https://nuitka.net/) to build binaries. +Note that the binaries will be for the platform you are building on, as `Nuitka` supports no cross-compilation. -After cloning tilia and installing the dependencies (see above), install `PyInstaller` with: +- After cloning TiLiA, install TiLiA's run and build dependencies with: ``` -pip install pyinstaller +pip install -e . --group build ``` -To build a stand-alone executable, run PyInstaller with the settings in `tilia.spec`: +- To build a stand-alone executable, run the script: ``` -pyinstaller tilia.spec +python scripts/deploy.py [ref_name] [os_type] ``` -The executable will be created in `dist` folder inside the project directory. +> Note: `ref_name` and `os_type` are arbitrary strings that do not affect the build outcome. + +The executable will be found in the `build/[os_type]/exe` folder in the project directory. ## Planned features diff --git a/TESTING.md b/TESTING.md index 8593df908..b371ef7f5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1,4 +1,6 @@ The test suite is written in pytest. Below are some things to keep in my mind when writing tests. For examples of good and thorough tests, see `tests\ui\timelines\test_marker_timeline_ui.py`. Older modules should be refactored at some point to follow the guidelines below. +## Pre-requisites +`pip install --group testing` ## How to simulate interaction with the UI? - The `user_actions` fixture can be used to trigger actions on the UI. This is equivalent to pressing buttons on the UI. We should also check that the actions are available in the UI where we expect them. - The `tilia_state` fixture can be used to make certain changes to state simulating user input (e.g. `tilia_state.current_time = 10`). diff --git a/build.py b/build.py deleted file mode 100644 index d87a35cb2..000000000 --- a/build.py +++ /dev/null @@ -1,59 +0,0 @@ -import subprocess - -import tilia.constants - -from pathlib import Path - -PATH_TO_INNO = r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" -PATH_TO_INSTALL_SCRIPT_TEMPLATE = Path("tilia", "installers", "win", "template.iss") -PATH_TO_INSTALL_SCRIPT = Path( - "tilia", "installers", "win", f"tilia_{tilia.constants.VERSION}.iss" -) - - -def confirm_version_number_update(): - answer = input( - f"Did you remember to update the version number (current version number is {tilia.constants.VERSION})? y/n " - ) - - if answer.lower() == "y": - return True - else: - return False - - -def build(): - p = subprocess.Popen("pyinstaller tilia.spec -y") - p.wait() - - -def make_installer(): - create_install_script() - - p = subprocess.Popen(f"{PATH_TO_INNO} {PATH_TO_INSTALL_SCRIPT.resolve()}") - p.wait() - - -def create_install_script() -> None: - with open(PATH_TO_INSTALL_SCRIPT_TEMPLATE, "r") as f: - template = f.read() - - install_script = template.replace("$VERSION$", tilia.constants.VERSION) - install_script = install_script.replace( - "$SOURCE_PATH$", Path("").resolve().__str__() - ) - - with open(PATH_TO_INSTALL_SCRIPT, "w") as f: - f.write(install_script) - - -def main() -> None: - if not confirm_version_number_update(): - return - - # build() - make_installer() - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index 255236759..07a377ff1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,124 @@ [build-system] -requires = ["setuptools>=78.1.1", "wheel"] +requires = [ + "Nuitka", + "setuptools", +] build-backend = "setuptools.build_meta" -[tool.pytest.ini_options] -env = ['QT_QPA_PLATFORM=offscreen', 'ENVIRONMENT=test'] +[project] +name = "TiLiA" +version = "0.5.15.0" +description = "A GUI for creating and visualizing annotations over audio and video files." +readme = "README.md" +requires-python = ">=3.10,<3.14" +dependencies = [ + "colorama~=0.4.6", + "httpx~=0.28.1", + "isodate~=0.7.2", + "lxml>=5.3.0", + "music21>=9.1", + "numpy~=1.26.4", + "platformdirs>=4.0.0", + "prettytable>=3.7.0", + "python-dotenv>=1.0.1", + "pypdf>=6.0.0", + "PySide6>=6.8.0", + "PySide6_Addons>=6.8.0", + "PySide6_Essentials>=6.8.0", + "sentry-sdk>=2.21.0", + "soundfile~=0.13.1", + "TiLiA", + "tomli; python_version < '3.11'" +] + +[[project.authors]] +name = "Felipe Defensor et al." +email = "tilia@tilia-app.com" + +[project.scripts] +tilia = "tilia.__main__:main" + +[project.urls] +Homepage = "https://tilia-app.com" +Repository = "https://github.com/TimeLineAnnotator/desktop" + +[dependency-groups] +build = [ + "build", + "ImageIO; sys_platform == 'darwin'", + "Nuitka >= 4.0", + "ordered-set==4.1.0", + "pyyaml", + "setuptools<82.0.0; python_version >= '3.12'", # setuptools>82.0.0 no longer provides pkg_resources + "wheel==0.38.4", + "zstandard==0.20.0" +] +ci-tests = [ + "coverage>=7.0.0", + "coverage-badge>=1.0.0", + "ruff>=0.15.0", + "pytest-cov>=6.0.0", +] +dev = [ + "icecream>=2.1.0", + "pre-commit", +] +testing = [ + "pytest>=8.0.0", + "pytest-env>=1.1.5", +] [tool.coverage.run] source = ["tilia"] omit = ["tilia/dev/*"] + +[tool.nuitka] +assume-yes-for-downloads = true +enable-plugins = ["pyside6"] +include-qt-plugins = ["multimedia"] +nofollow-import-to = ["*.tests", "*.test"] +noinclude-qt-translations = true +onefile-tempdir-spec = "{CACHE_DIR}/{PRODUCT}/v{VERSION}" +remove-output = true +report = "compilation-report.xml" +user-package-configuration-file = "tilia.nuitka-package.config.yml" +deployment = true +mode = "app" + +[tool.pytest.ini_options] +env = [ + "ENVIRONMENT=test", + "QT_QPA_PLATFORM=offscreen", +] + +[tool.ruff] +exclude = ["tilia/dev"] +line-length = 88 +lint.ignore = ["E203","E501"] +lint.per-file-ignores = {"__init__.py"=["F401"]} +target-version = "py310" + +[tool.setuptools.packages.find] +where = ["."] +exclude = [ + "build", "build.*", + "dist", + "docs", "docs.*", + "paper", "paper.*", + "scripts", + "tests", "tests.*", + "venv.*", "venv" +] + +[tool.setuptools.package-data] +tilia = [ + "../.tilia.env", + "../tilia.nuitka-package.config.yml", # because we build from sdist + "../LICENSE", + "ui/img/*", + "ui/fonts/*", + "media/player/youtube.html", + "media/player/youtube.css", + "parsers/score/svg_maker.html", + "parsers/score/timewise_to_partwise.xsl", +] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 6b0d12491..000000000 --- a/requirements.txt +++ /dev/null @@ -1,22 +0,0 @@ -platformdirs>=4.0.0 -prettytable>=3.7.0 -lxml>=5.3.0 -music21>=9.1 -numpy~=1.26.4 -PyQt6==6.6.0 -PyQt6-Qt6==6.6.0 -PyQt6-sip==13.6.0 -PyQt6-WebEngine==6.6.0 -PyQt6-WebEngine-Qt6==6.6.0 -python-dotenv>=1.0.1 -pypdf>=6.0.0 -httpx~=0.28.1 -isodate~=0.7.2 -soundfile~=0.13.1 -colorama~=0.4.6 - -pytest>=8.0.0 -pytest-env>=1.1.5 -icecream~=2.1.3 -colorama~=0.4.6 -sentry-sdk>=2.21.0 diff --git a/scripts/deploy.py b/scripts/deploy.py new file mode 100644 index 000000000..10b0461d3 --- /dev/null +++ b/scripts/deploy.py @@ -0,0 +1,279 @@ +""" +Script to build TiLiA with Nuitka. +(a more flexible alternative to building with only pyside-deploy or Nuitka) +pyside-deploy has very limited Nuitka-specific options and Nuitka requires a specific file structure to build. +Hence this pyside-deploy-inspired script. + +- Run `python scripts/deploy.py [ref_name] [os_type]` +- Creates sdist to create metadata file from information in pyproject and filter out unnecessary files +- Then builds executable, which will be found in: + build/exe/[os_type]/TiLiA-[tilia version in pyproject](-[ref_name, if not the same as tilia version])-[os_type] +""" + +from colorama import Fore +import dotenv +from enum import Enum +from lxml import etree +from nuitka.distutils.DistutilsCommands import build as n_build +import os +from pathlib import Path +from subprocess import check_call +import sys +import tarfile +import traceback + + +ref_name = "" +build_os = "" +buildlib = Path(__file__).parents[1] / "build" +toml_file = Path(__file__).parents[1] / "pyproject.toml" +pkg_cfg = "tilia.nuitka-package.config.yml" +outdir = Path() +out_filename = "" + +if not toml_file.exists(): + options = {} +else: + if sys.version_info >= (3, 11): + from tomllib import load + else: + from tomli import load + + with open(toml_file, "rb") as f: + options = load(f) + + +class P(Enum): + CMD = Fore.BLUE + ERROR = Fore.RED + OK = Fore.GREEN + + +def _print(text: list[str | list[str]], p_type: P | None = None): + if not text: + return + formatted_text = "\n".join([t.__str__() for t in text]) + if p_type: + formatted_text = p_type.value + formatted_text + Fore.RESET + sys.stdout.write(formatted_text + "\n") + + +def _handle_inputs(): + assert len(sys.argv) == 3, "Incorrect number of inputs" + global ref_name, build_os, outdir + ref_name = sys.argv[1] + # in a git action runner, sys.argv[2] could look like someOS-latest, someOS-22.02, etc, where someOS is probably macos, ubuntu or windows. + # we save build_os as the runner os stripped of "latest" and any digits: just someOS. + if "macos" in sys.argv[2] and "intel" not in sys.argv[2]: + build_os = "macos-silicon" + # to identify the difference between macos-silicon and macos-intel. currently uses the images macos-latest and macos-15-intel (which are silicon and intel respectively.) + else: + build_os = "-".join( + [ + x + for x in sys.argv[2].split("-") + if not x.replace(".", "", 1).isdigit() and x != "latest" + ] + ) + outdir = buildlib / build_os + + +def _get_nuitka_toml() -> list[str]: + toml_cmds = [] + for option, value in options.get("tool", {}).get("nuitka", {}).items(): + toml_cmds.extend(n_build._parseOptionsEntry(option, value)) + return toml_cmds + + +def _set_out_filename(name: str, version: str): + def _clean_version(v: str) -> list[str]: + return v.strip("v ").split(".") + + global out_filename + if _clean_version(version) == _clean_version(ref_name): + out_filename = f"{name}-v{version}-{build_os}" + else: + out_filename = f"{name}-v{version}-{ref_name}-{build_os}" + + +def _get_exe_cmd() -> list[str]: + name = options.get("project", {}).get("name", "TiLiA") + version = options.get("project", {}).get("version", "0") + _set_out_filename(name, version) + icon_path = Path(__file__).parents[1] / "tilia" / "ui" / "img" / "main_icon.ico" + exe_args = [ + sys.executable, + "-m", + "nuitka", + f"--output-dir={outdir}/exe", + f"--product-name={name}", + f"--file-version={version}", + f"--output-filename={out_filename}", + f"--macos-app-icon={icon_path}", + "--macos-app-mode=gui", + f"--macos-app-version={version}", + "--windows-console-mode=attach", + f"--windows-icon-from-ico={icon_path}", + f"--linux-icon={icon_path}", + ] + + return exe_args + + +def _get_sdist() -> Path: + for f in outdir.iterdir(): + if "".join(f.suffixes[-2:]) == ".tar.gz": + return f + raise Exception(f"Could not find sdist in {outdir}:", [*outdir.iterdir()]) + + +def _get_main_file() -> Path: + main = _create_lib() + _update_yml() + return main + + +def _create_lib() -> Path: + sdist = _get_sdist() + base = ".".join(sdist.name.split(".")[:-2]) + tilia = f"{base}/tilia" + lib = outdir / "Lib" + + ext_data = [ + f"{base}/{e[3:]}" + for e in options.get("tool", {}) + .get("setuptools", {}) + .get("package-data", {}) + .get("tilia", []) + if e.split("/")[0] == ".." + ] + + with tarfile.open(sdist) as f: + main = f"{tilia}/__main__.py" + assert main in f.getnames(), f"Could not locate {main}" + f.extractall( + lib, + filter=lambda x, _: ( + x + if x.name.startswith(tilia) + or x.name.startswith(f"{base}/TiLiA.egg-info") + or x.name in ext_data + else None + ), + ) + + os.chdir(lib / base) + return lib / tilia + + +def _update_yml(): + if not Path(pkg_cfg).exists(): + return + + import yaml + + with open(pkg_cfg) as f: + yml = yaml.safe_load(f) + + yml.append( + { + "module-name": "tilia", + "data-files": [ + { + "patterns": [ + e + for e in options.get("tool", {}) + .get("setuptools", {}) + .get("package-data", {}) + .get("tilia", []) + if pkg_cfg not in e + ] + }, + {"include-metadata": ["TiLiA"]}, + ], + } + ) + + with open(pkg_cfg, "w") as f: + yaml.dump(yml, f) + + +def _build_sdist(): + sdist_cmd = [ + sys.executable, + "-m", + "build", + "--no-isolation", + "--verbose", + "--sdist", + f"--outdir={outdir.as_posix()}", + ] + + _print(["Building sdist with command:", sdist_cmd], P.CMD) + check_call(sdist_cmd) + + +def _build_exe(): + main_file = _get_main_file() + exe_cmd = _get_exe_cmd() + exe_cmd.extend(_get_nuitka_toml()) + exe_cmd.append(main_file.as_posix()) + + _print(["Building exe with command:", exe_cmd], P.CMD) + check_call(exe_cmd) + _print(["Build complete!"], P.OK) + _print(["Compilation report:"]) + _print( + [ + etree.tostring( + etree.parse(main_file.parent / "compilation-report.xml"), + pretty_print=True, + encoding=str, + ) + ] + ) + + +def build(): + _handle_inputs() + old_env_var = dotenv.dotenv_values(".tilia.env").get("ENVIRONMENT", "") + dotenv.set_key(".tilia.env", "ENVIRONMENT", "prod") + if buildlib.exists(): + _print(["Cleaning build folder..."], P.ERROR) + for root, dirs, files in os.walk(buildlib, False): + r = Path(root) + _print([f"\t~{r}"]) + for f in files: + os.unlink(r / f) + for d in dirs: + os.rmdir(r / d) + os.rmdir(buildlib) + + old_dir = os.getcwd() + try: + _build_sdist() + _build_exe() + if os.environ.get("GITHUB_OUTPUT"): + if "mac" in build_os: + os.rename( + outdir / "exe" / "tilia.app", + outdir / "exe" / (out_filename + ".app"), + ) + out_filepath = outdir / "exe" / (out_filename + ".app") + else: + out_filepath = outdir / "exe" / out_filename + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"out-filepath={out_filepath.as_posix()}\n") + f.write(f"out-filename={out_filename}\n") + os.chdir(old_dir) + dotenv.set_key(".tilia.env", "ENVIRONMENT", old_env_var) + except Exception as e: + _print(["Build failed!", e.__str__()], P.ERROR) + _print([traceback.format_exc()]) + os.chdir(old_dir) + dotenv.set_key(".tilia.env", "ENVIRONMENT", old_env_var) + raise SystemExit(1) from e + + +if __name__ == "__main__": + build() diff --git a/scripts/linux-setup.sh b/scripts/linux-setup.sh new file mode 100644 index 000000000..4effae2be --- /dev/null +++ b/scripts/linux-setup.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +echo "Setup Linux build environment" +trap 'echo Setup failed; exit 1' ERR + +df -h . + +########################################################################## +# GET DEPENDENCIES +########################################################################## + +apt_packages=( + coreutils + curl + gawk + git + lcov + libasound2-dev + libcups2-dev + libfontconfig1-dev + libfreetype6-dev + libgcrypt20-dev + libgl1-mesa-dev + libglib2.0-dev + libjack-dev + libnss3-dev + libportmidi-dev + libpulse-dev + librsvg2-dev + libsndfile1-dev + libssl-dev + libtool + make + p7zip-full + sed + unzip + wget + ) + +apt_packages_runtime=( + # Alphabetical order please! + libdbus-1-3 + libegl1-mesa-dev + libgles2-mesa-dev + libodbc2 + libpq-dev + libssl-dev + libxcomposite-dev + libxcursor-dev + libxi-dev + libxkbcommon-x11-0 + libxrandr2 + libxtst-dev + libdrm-dev + libxcb-cursor-dev + libxcb-icccm4 + libxcb-image0 + libxcb-keysyms1 + libxcb-randr0 + libxcb-render-util0 + libxcb-xinerama0 + libxcb-xkb-dev + libxkbcommon-dev + libopengl-dev + libvulkan-dev + ) + +apt_packages_ffmpeg=( + ffmpeg + libavcodec-dev + libavformat-dev + libswscale-dev + ) + +apt_packages_pw_deps=( + libdbus-1-dev + libudev-dev + ) + +sudo apt-get update +sudo apt-get install -y --no-install-recommends \ + "${apt_packages[@]}" \ + "${apt_packages_runtime[@]}" \ + "${apt_packages_ffmpeg[@]}" \ + "${apt_packages_pw_deps[@]}" + +########################################################################## +# PIPEWIRE +########################################################################## + +sudo apt-get install pipewire-media-session- wireplumber + +systemctl --user --now enable wireplumber.service diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index dc55445e6..000000000 --- a/setup.cfg +++ /dev/null @@ -1,46 +0,0 @@ -[metadata] -name = TiLiA -version = 0.5.15 -description = A GUI for creating and visualizing annotations over audio and video files. -author = Felipe Defensor et al. - -[options] -packages = tilia -install_requires = - lxml>=5.3.0 - platformdirs>=4.0.0 - prettytable>=3.7.0 - music21>=9.1 - numpy~=1.26.4 - PyQt6==6.6.0 - PyQt6-Qt6==6.6.0 - PyQt6-sip==13.6.0 - PyQt6-WebEngine==6.6.0 - PyQt6-WebEngine-Qt6==6.6.0 - python-dotenv>=1.0.1 - pypdf~=4.2.0 - sentry-sdk>=2.21.0 - soundfile~=0.13.1 - httpx~=0.28.1 - isodate~=0.7.2 - colorama~=0.4.6 -python_requires = >=3.10 -zip_safe = no - -[options.extras_require] -testing = - pytest>=8.0.0 - pytest-env>=1.1.5 - colorama>=0.4.6 - icecream>=2.1.0 -ci-tests = - coverage>=7.0.0 - coverage-badge>=1.0.0 - flake8>=7.0.0 - pytest-cov>=6.0.0 - -[flake8] -max-line-length = 88 -per-file-ignores = __init__.py: F401 -extend-ignore = E203,E501 -exclude = tilia/dev diff --git a/setup.py b/setup.py deleted file mode 100644 index 7f1a1763c..000000000 --- a/setup.py +++ /dev/null @@ -1,4 +0,0 @@ -from setuptools import setup - -if __name__ == "__main__": - setup() diff --git a/tests/conftest.py b/tests/conftest.py index 24ab48ce5..b145a8503 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,16 +3,16 @@ from pathlib import Path from typing import Literal -import dotenv import pytest -from PyQt6.QtCore import QSettings -from PyQt6.QtWidgets import QApplication +from PySide6.QtCore import QSettings +from PySide6.QtWidgets import QApplication from colorama import Fore, Style +import tilia.utils # noqa: F401 + import tilia.constants as constants_module import tilia.log as logging_module import tilia.settings as settings_module -from tilia.dirs import PROJECT_ROOT from tilia.media.player.base import MediaTimeChangeReason from tilia.app import App from tilia.boot import setup_logic @@ -51,9 +51,6 @@ "tests.timelines.score.fixtures", ] -dotenv_path = PROJECT_ROOT / ".env" -success = dotenv.load_dotenv(dotenv_path) - class TiliaErrors: def __init__(self): @@ -199,7 +196,7 @@ def resources() -> Path: @pytest.fixture(scope="module") def use_test_settings(qapplication): settings_module.settings._settings = QSettings( - constants_module.APP_NAME, "DesktopTests" + constants_module.APP_NAME, "DesktopTests", None ) settings_module.settings._check_all_default_settings_present() settings_module.settings.set("general", "prioritise_performance", True) diff --git a/tests/mock.py b/tests/mock.py index af1589495..a884c1cba 100644 --- a/tests/mock.py +++ b/tests/mock.py @@ -2,7 +2,7 @@ from typing import Any, Sequence from unittest.mock import patch, Mock -from PyQt6.QtWidgets import QFileDialog, QMessageBox, QInputDialog +from PySide6.QtWidgets import QFileDialog, QMessageBox, QInputDialog from tilia.requests import Get, Post, serve, server, stop_serving from tilia.requests import post as post_original @@ -51,8 +51,8 @@ def __exit__(self, exc_type, exc_val, exc_tb): def _callback(self, *_, **__): try: return_value = self.return_values[self.return_count] - except IndexError: - raise IndexError("Not enough return values to serve") + except IndexError as e: + raise IndexError("Not enough return values to serve") from e self.return_count += 1 return return_value @@ -142,6 +142,6 @@ def patch_ask_for_string_dialog(success: bool | list[bool], string: str | list[s else: raise ValueError("input must be a string if success is a bool.") - return_values = list(zip(string, success)) - with (patch.object(QInputDialog, "getText", side_effect=return_values),): + return_values = list(zip(string, success, strict=True)) + with patch.object(QInputDialog, "getText", side_effect=return_values): yield diff --git a/tests/parsers/csv/test_harmony_from_csv.py b/tests/parsers/csv/test_harmony_from_csv.py index efe814ddc..e090779e8 100644 --- a/tests/parsers/csv/test_harmony_from_csv.py +++ b/tests/parsers/csv/test_harmony_from_csv.py @@ -82,7 +82,7 @@ def test_fails_without_a_required_column(self, required_attr, harmony_tl): success, errors = call_patched_import_by_time_func(harmony_tl, data) assert_in_errors(required_attr, errors) - def test_returns_error_for_invalid_rows_and_processess_valid_rows(self, harmony_tl): + def test_returns_error_for_invalid_rows_and_processes_valid_rows(self, harmony_tl): data = "\n".join( [ "time,harmony_or_key,symbol", diff --git a/tests/parsers/csv/test_markers_from_csv.py b/tests/parsers/csv/test_markers_from_csv.py index 722d8a4a9..9a12d13d3 100644 --- a/tests/parsers/csv/test_markers_from_csv.py +++ b/tests/parsers/csv/test_markers_from_csv.py @@ -3,7 +3,7 @@ from typing import Literal from unittest.mock import patch, mock_open -from PyQt6.QtWidgets import QFileDialog +from PySide6.QtWidgets import QFileDialog from tests.utils import undoable from tilia.requests import Post, post diff --git a/tests/parsers/score/test_score_from_musicxml.py b/tests/parsers/score/test_score_from_musicxml.py index b63fc4ed6..f4cae4fdf 100644 --- a/tests/parsers/score/test_score_from_musicxml.py +++ b/tests/parsers/score/test_score_from_musicxml.py @@ -1,6 +1,6 @@ from unittest.mock import patch -from PyQt6.QtWidgets import QMessageBox +from PySide6.QtWidgets import QMessageBox from tilia.parsers.score.musicxml import notes_from_musicXML from tilia.timelines.component_kinds import ComponentKind diff --git a/tests/player/test_player.py b/tests/player/test_player.py index 1e16c61fd..5c2e39e13 100644 --- a/tests/player/test_player.py +++ b/tests/player/test_player.py @@ -11,7 +11,7 @@ def conservative_player_stop(tilia): """ Increases player.SLEEP_AFTER_STOP to 5 seconds if on CI. Avoids freezes when setting URL after stop. Workaround for running tests on CI. - Proper hadling of player status changes would be a more robust solution. + Proper handling of player status changes would be a more robust solution. """ original_sleep_after_stop = tilia.player.SLEEP_AFTER_STOP diff --git a/tests/test_app.py b/tests/test_app.py index b8dab9b21..a1be29f61 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -28,7 +28,7 @@ class TestLogger: def test_sentry_not_logging(self): # TODO: make this test run first in batch testing. - # enabling sentry during tests will fill inbox up unneccesarily + # enabling sentry during tests will fill inbox up unnecessarily assert "tilia.log" in tilia.log.sentry_sdk.integrations.logging._IGNORED_LOGGERS @@ -493,7 +493,7 @@ def test_open_without_saving_changes(self, tilia, tls, marker_tlui, tmp_path): assert len(tls) == 2 # assert load was successful assert len(list(contents["timelines"][str(prev_tl_id)]["components"])) == 0 - def test_open_canceling_should_save_changes_dialog( + def test_open_cancelling_should_save_changes_dialog( self, tilia, tls, marker_tlui, tmp_path ): previous_path = tmp_path / "previous.tla" @@ -584,7 +584,7 @@ def test_all_windows_are_closed(self, tilia, qtui): with Serve(Get.FROM_USER_SHOULD_SAVE_CHANGES, (True, False)): commands.execute("file.new") - # this doesn't actaully check if windows are closed + # this doesn't actually check if windows are closed # it checks if app._windows[kind] is None. # Those should be equivalent, if everything is working as it should assert not any(qtui.is_window_open(k) for k in WindowKind) @@ -645,7 +645,7 @@ def test_moving_files(self, tla, media, tilia, qtui, tmp_path): new_media = old_media.rename(new_folder / media) # open file at new folder - with (patch_file_dialog(True, [str(new_tla)])): + with patch_file_dialog(True, [str(new_tla)]): commands.execute("file.open") assert tilia.player.media_path == str(new_media) diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 000000000..ccee02720 --- /dev/null +++ b/tests/test_constants.py @@ -0,0 +1,24 @@ +import importlib.metadata +import sys +from unittest.mock import patch + + +def test_tilia_metadata_not_found(): + # If tilia.constants was already imported, we must remove it from sys.modules + # to ensure the module-level code runs again with our mocks. + if "tilia.constants" in sys.modules: + del sys.modules["tilia.constants"] + + # 1. Mock Path.exists to return False so it skips the pyproject.toml logic + with patch("pathlib.Path.exists", return_value=False), patch( + "importlib.metadata.metadata", + side_effect=importlib.metadata.PackageNotFoundError, + ): + + import tilia.constants as constants + + assert constants.APP_NAME == "" + assert constants.VERSION == "0.0.0" + assert constants.YEAR == "2022-2026" + assert constants.AUTHOR == "" + assert constants.EMAIL == "" diff --git a/tests/timelines/base/test_validators.py b/tests/timelines/base/test_validators.py index f38af83a1..3a41e574c 100644 --- a/tests/timelines/base/test_validators.py +++ b/tests/timelines/base/test_validators.py @@ -1,4 +1,4 @@ -from PyQt6.QtGui import QColor +from PySide6.QtGui import QColor from tilia.timelines.base.validators import validate_color diff --git a/tests/ui/cli/test_load_media.py b/tests/ui/cli/test_load_media.py index ca1cb9b3d..949cb8912 100644 --- a/tests/ui/cli/test_load_media.py +++ b/tests/ui/cli/test_load_media.py @@ -92,7 +92,9 @@ def test_with_timelines_scale_not_provided_answer_yes_but_dont_confirm_crop( assert marker_tl[0].get_data("time") == 50 * EXAMPLE_MEDIA_SCALE_FACTOR -def test_with_timelines_scale_not_provied_answer_crop(cli, tilia_state, marker_tl): +def test_with_timelines_scale_not_provided_answer_crop( + cli, tilia_state, marker_tl +): for time in [5, 50]: marker_tl.create_marker(time) diff --git a/tests/ui/dialogs/test_crash.py b/tests/ui/dialogs/test_crash.py index e75c680a5..f1261247d 100644 --- a/tests/ui/dialogs/test_crash.py +++ b/tests/ui/dialogs/test_crash.py @@ -40,7 +40,7 @@ def test_crash_support_empty_fields(self): set_user.assert_not_called() - def test_crash_support_remmeber(self): + def test_crash_support_remember(self): crash_support_dialog = CrashSupportDialog(None) crash_support_dialog.email_field.setText("an invalid email") crash_support_dialog.name_field.setText("John Doe") @@ -51,7 +51,7 @@ def test_crash_support_remmeber(self): set_user.assert_called_once_with("", "John Doe") - def test_crash_support_not_remmeber(self): + def test_crash_support_not_remember(self): crash_support_dialog = CrashSupportDialog(None) crash_support_dialog.email_field.setText("an invalid email") crash_support_dialog.name_field.setText("John Doe") diff --git a/tests/ui/timelines/harmony/test_harmony_ui.py b/tests/ui/timelines/harmony/test_harmony_ui.py index bb371e70b..a1861db81 100644 --- a/tests/ui/timelines/harmony/test_harmony_ui.py +++ b/tests/ui/timelines/harmony/test_harmony_ui.py @@ -79,7 +79,7 @@ def test_paste_single_into_element(self, tlui): click_harmony_ui(tlui[1]) commands.execute("timeline.component.paste") assert len(tlui) == 2 - for attr, value in attributes_to_copy.items(): + for attr in attributes_to_copy.keys(): assert tlui[1].get_data(attr) == attributes_to_copy[attr] def test_paste_multiple_into_element(self, tlui): @@ -117,5 +117,5 @@ def test_paste_multiple_into_element(self, tlui): click_harmony_ui(target_hui) commands.execute("timeline.component.paste") assert len(tlui) == 6 - for attr, value in attributes_to_copy.items(): + for attr in attributes_to_copy.keys(): assert target_hui.get_data(attr) == attributes_to_copy[attr] diff --git a/tests/ui/timelines/harmony/test_mode_ui.py b/tests/ui/timelines/harmony/test_mode_ui.py index 0fbadcd51..651898711 100644 --- a/tests/ui/timelines/harmony/test_mode_ui.py +++ b/tests/ui/timelines/harmony/test_mode_ui.py @@ -71,7 +71,7 @@ def test_paste_single_into_element(self, tlui): click_mode_ui(tlui[1]) commands.execute("timeline.component.paste") assert len(tlui) == 2 - for attr, value in attributes_to_copy.items(): + for attr in attributes_to_copy.keys(): assert tlui[1].get_data(attr) == attributes_to_copy[attr] def test_paste_multiple_into_element(self, tlui): @@ -101,5 +101,5 @@ def test_paste_multiple_into_element(self, tlui): click_mode_ui(target_mui) commands.execute("timeline.component.paste") assert len(tlui) == 6 - for attr, value in attributes_to_copy.items(): + for attr in attributes_to_copy.keys(): assert target_mui.get_data(attr) == attributes_to_copy[attr] diff --git a/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py b/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py index ca11860e8..2c48a54b8 100644 --- a/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py +++ b/tests/ui/timelines/hierarchy/test_hierarchy_timeline_ui.py @@ -1,5 +1,5 @@ import pytest -from PyQt6.QtGui import QColor +from PySide6.QtGui import QColor from tests.mock import Serve, patch_yes_or_no_dialog from tilia.requests import Post, Get, post diff --git a/tests/ui/timelines/hierarchy/test_hierarchy_ui.py b/tests/ui/timelines/hierarchy/test_hierarchy_ui.py index 2c3f29e71..57c16e2ab 100644 --- a/tests/ui/timelines/hierarchy/test_hierarchy_ui.py +++ b/tests/ui/timelines/hierarchy/test_hierarchy_ui.py @@ -30,7 +30,7 @@ def test_full_name_no_label(self, tlui): assert ( tlui[0].full_name - == "tl" + HierarchyUI.FULL_NAME_SEPARATOR + HierarchyUI.NAME_WHEN_UNLABELED + == "tl" + HierarchyUI.FULL_NAME_SEPARATOR + HierarchyUI.NAME_WHEN_UNLABELLED ) def test_full_name_with_parent(self, tlui): diff --git a/tests/ui/timelines/interact.py b/tests/ui/timelines/interact.py index 09ed9cbe3..7e7ee6724 100644 --- a/tests/ui/timelines/interact.py +++ b/tests/ui/timelines/interact.py @@ -1,8 +1,8 @@ from typing import Literal -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QGraphicsView, QGraphicsItem, QApplication -from PyQt6.QtTest import QTest +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QGraphicsView, QGraphicsItem, QApplication +from PySide6.QtTest import QTest from tilia.requests import Post, post, get, Get from tilia.ui.coords import time_x_converter @@ -92,8 +92,8 @@ def press_key(key: str, modifier: Qt.KeyboardModifier | None = None): if len(key) > 1: try: key = getattr(Qt.Key, f"Key_{key}") - except AttributeError: - raise ValueError(f"Unknown key: {key}") + except AttributeError as e: + raise ValueError(f"Unknown key: {key}") from e QTest.keyClick( get_focused_widget(), key, modifier=modifier or Qt.KeyboardModifier.NoModifier diff --git a/tests/ui/timelines/marker/test_marker_timeline_ui.py b/tests/ui/timelines/marker/test_marker_timeline_ui.py index 17faa1b15..b8160fc31 100644 --- a/tests/ui/timelines/marker/test_marker_timeline_ui.py +++ b/tests/ui/timelines/marker/test_marker_timeline_ui.py @@ -1,8 +1,8 @@ from unittest.mock import patch -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QColor -from PyQt6.QtWidgets import QColorDialog, QInputDialog +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QColorDialog, QInputDialog from tests.mock import Serve, patch_ask_for_string_dialog, patch_yes_or_no_dialog from tests.ui.test_qtui import get_toolbars_of_class diff --git a/tests/ui/timelines/marker/test_marker_ui.py b/tests/ui/timelines/marker/test_marker_ui.py index ea649e284..8cbe1eeed 100644 --- a/tests/ui/timelines/marker/test_marker_ui.py +++ b/tests/ui/timelines/marker/test_marker_ui.py @@ -1,7 +1,7 @@ from unittest.mock import patch, Mock -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QColor +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor from tests.mock import PatchPost from tilia.requests import Post diff --git a/tests/ui/timelines/score/test_musicxml.py b/tests/ui/timelines/score/test_musicxml.py index 1fefb500f..39cddace4 100644 --- a/tests/ui/timelines/score/test_musicxml.py +++ b/tests/ui/timelines/score/test_musicxml.py @@ -13,7 +13,7 @@ def setup_valid_beats(self, beat_tl): beat_tl.recalculate_measures() - def test_user_accpets(self, qtui, score_tlui, beat_tl, tmp_path, tilia_state): + def test_user_accepts(self, qtui, score_tlui, beat_tl, tmp_path, tilia_state): self.setup_valid_beats(beat_tl) with patch_yes_or_no_dialog(True): diff --git a/tests/ui/timelines/score/test_score_timeline_ui.py b/tests/ui/timelines/score/test_score_timeline_ui.py index 9351c7517..e3b477c2f 100644 --- a/tests/ui/timelines/score/test_score_timeline_ui.py +++ b/tests/ui/timelines/score/test_score_timeline_ui.py @@ -151,7 +151,7 @@ def test_missing_staff_deletes_timeline(qtui, tls, tilia_errors, tmp_path): "staff_index": 0, "color": None, "comments": "", - "" "display_accidental": False, + "display_accidental": False, "kind": "NOTE", "hash": "", }, @@ -235,7 +235,7 @@ def test_symbol_staff_collision(qtui, tmp_path): json.dumps(file_data_with_symbols), encoding="utf-8" ) - with (patch_file_dialog(True, [tmp_file_with_symbols])): + with patch_file_dialog(True, [tmp_file_with_symbols]): commands.execute("file.open") score = get(Get.TIMELINE_UI_BY_ATTR, "TIMELINE_KIND", TimelineKind.SCORE_TIMELINE) diff --git a/tests/ui/timelines/test_timelineui_collection.py b/tests/ui/timelines/test_timelineui_collection.py index 9896ed1f2..8edb37889 100644 --- a/tests/ui/timelines/test_timelineui_collection.py +++ b/tests/ui/timelines/test_timelineui_collection.py @@ -222,7 +222,7 @@ def test_by_page_is_triggered(self, tluis): move_to_x_mock.assert_called() center_on_time_mock.assert_not_called() - def test_by_page_is_not_triggered_when_not_over_treshold(self, tluis): + def test_by_page_is_not_triggered_when_not_over_threshold(self, tluis): self._set_auto_scroll(ScrollType.BY_PAGE) with patch.object(tluis.view, "move_to_x") as move_to_x_mock: post(Post.PLAYER_CURRENT_TIME_CHANGED, 10, MediaTimeChangeReason.PLAYBACK) diff --git a/tests/ui/windows/test_manage_timelines.py b/tests/ui/windows/test_manage_timelines.py index 2ee34c3a8..3c246718c 100644 --- a/tests/ui/windows/test_manage_timelines.py +++ b/tests/ui/windows/test_manage_timelines.py @@ -1,8 +1,9 @@ +from contextlib import contextmanager from typing import Literal import pytest -from PyQt6.QtCore import Qt -from PyQt6.QtTest import QTest +from PySide6.QtCore import Qt +from PySide6.QtTest import QTest from tests.mock import Serve from tilia.requests import Get, get @@ -14,23 +15,33 @@ def assert_order_is_correct(tls: Timelines, expected: list[Timeline]): # assert timeline order - for tl, expected in zip(sorted(tls), expected): - assert tl == expected + for tl, e in zip(sorted(tls), expected, strict=True): + assert tl == e # assert list widget order for i, tl in enumerate(expected): tlui = get(Get.TIMELINE_UI, tl.id) - assert ManageTimelines().list_widget.item(i).timeline_ui == tlui + with manage_timelines() as mt: + assert mt.list_widget.item(i).timeline_ui == tlui + + +@contextmanager +def manage_timelines(): + """Context manager for the ManageTimelines window.""" + mt = ManageTimelines() + try: + yield mt + finally: + mt.close() class TestChangeTimelineVisibility: @staticmethod def toggle_timeline_is_visible(row: int = 0): """Toggles timeline visibility using the Manage Timelines window.""" - mt = ManageTimelines() - mt.list_widget.setCurrentRow(row) - QTest.mouseClick(mt.checkbox, Qt.MouseButton.LeftButton) - mt.close() + with manage_timelines() as mt: + mt.list_widget.setCurrentRow(row) + QTest.mouseClick(mt.checkbox, Qt.MouseButton.LeftButton) def test_hide(self, marker_tlui): commands.execute("timeline.set_is_visible", marker_tlui, True) @@ -64,17 +75,16 @@ def setup_timelines(self, tluis, tls): @staticmethod def click_set_ordinal_button(button: Literal["up", "down"], row: int): """Toggles timeline visibility using the ManageTimelines window.""" - mt = ManageTimelines() - mt.list_widget.setCurrentRow(row) - if button == "up": - button = mt.up_button - elif button == "down": - button = mt.down_button - else: - assert False, "Invalid button value." - - QTest.mouseClick(button, Qt.MouseButton.LeftButton) - mt.close() + with manage_timelines() as mt: + mt.list_widget.setCurrentRow(row) + if button == "up": + button = mt.up_button + elif button == "down": + button = mt.down_button + else: + assert False, "Invalid button value." + + QTest.mouseClick(button, Qt.MouseButton.LeftButton) def test_increase_ordinal(self, tls, setup_timelines): tl0, tl1, tl2 = setup_timelines @@ -141,3 +151,13 @@ def test_decrease_ordinal_with_last_selected_does_nothing( self.click_set_ordinal_button("down", 2) assert_order_is_correct(tls, [tl0, tl1, tl2]) + + +class TesttimelinesChangeWhileOpen: + def test_timeline_is_deleted(self, tluis): + commands.execute("timelines.add.marker", name="") + with manage_timelines() as mt: + mt.list_widget.setCurrentRow(0) + commands.execute("timeline.delete", tluis[0], confirm=False) + + # Much more could be tested here. diff --git a/tests/utils.py b/tests/utils.py index e5c29edff..9ff041d82 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,7 +5,7 @@ from pprint import pformat from typing import Callable -from PyQt6.QtWidgets import QMenu +from PySide6.QtWidgets import QMenu from tilia.requests import get, Get, Post, post from tests.mock import patch_file_dialog diff --git a/tilia.nuitka-package.config.yml b/tilia.nuitka-package.config.yml new file mode 100644 index 000000000..6c5353d8c --- /dev/null +++ b/tilia.nuitka-package.config.yml @@ -0,0 +1,59 @@ +# see https://nuitka.net/user-documentation/nuitka-package-config.html for help on this file. +# yamllint disable rule:line-length +# yamllint disable rule:indentation +# yamllint disable rule:comments-indentation +# yamllint disable rule:comments +# too many spelling things, spell-checker: disable +--- +- module-name: "music21" # mostly regex matching entire refrences to tests and deleting. + anti-bloat: + - description: "remove tests" + replacements_plain: + "'mainTest',": "" + "'test',": "" + "from music21.test.testRunner import mainTest": "" + "from music21 import test": "" + - description: "remove module tests" + global_replacements_re: + '(?m)class Test([^(]*)\(unittest.TestCase\):[\d\D]*?(?=^if|^# -|^class|^_[A-Z]|\Z)': "" + "if __name__ == '__main__':([\\d|\\D]*)": "" + "from music21.(.*) import test(.*)": "" + global_replacements_plain: + "import unittest": "" + +- module-name: "executing._pytest_utils" + anti-bloat: + - description: "remove pytest reference" + change_function: + "is_pytest_compatible": "'(lambda : False)'" + when: "not use_pytest" + +- module-name: "sentry_sdk.integrations" + implicit-imports: + - depends: + - "sentry_sdk.integrations.argv" + - "sentry_sdk.integrations.atexit" + - "sentry_sdk.integrations.dedupe" + - "sentry_sdk.integrations.excepthook" + - "sentry_sdk.integrations.logging" + - "sentry_sdk.integrations.modules" + - "sentry_sdk.integrations.stdlib" + - "sentry_sdk.integrations.threading" + anti-bloat: + - description: "fix import of unused integrations" + replacements_plain: + "auto_enabling_integrations=_AUTO_ENABLING_INTEGRATIONS,": "auto_enabling_integrations=[]," + +- module-name: "tilia.utils" + anti-bloat: + - description: "slightly hacky way of forcing prod if env file is not found" + replacements_plain: + 'os.environ["ENVIRONMENT"] = "dev"': 'os.environ["ENVIRONMENT"] = "prod"' + +- module-name: "tilia.boot" + anti-bloat: + - description: "CLI doesn't currently work in the executable" + replacements_re: + 'elif interface == "cli":(\n|.)*?return CLI\(\)': '' + replacements_plain: + 'choices=["qt", "cli"]': 'choices=["qt"], help="CLI option is currently not available in the exe. Run from source instead."' diff --git a/tilia.spec b/tilia.spec deleted file mode 100644 index 41456aa98..000000000 --- a/tilia.spec +++ /dev/null @@ -1,101 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- -import argparse -import platform -import dotenv - -from pathlib import Path - -from tilia.constants import APP_NAME, VERSION - -# Parse build options -parser = argparse.ArgumentParser() -parser.add_argument("--debug", action="store_true") -options = parser.parse_args() - -options = parser.parse_args() - -# Set platform suffix -if platform.system() == 'Windows': - platform_suffix = 'win' -elif platform.system() == 'Darwin': - platform_suffix = 'mac' -elif platform.system() == 'Linux': - platform_suffix = 'linux' -else: - platform_suffix = platform.system() - -# Set enviroment to production -dotenv.set_key(".env", "ENVIRONMENT", "prod") - -# Build executable -a = Analysis( - ["./tilia/main.py"], - pathex=[], - binaries=None, - datas=[ - ("./README.md", "."), - ("./LICENSE", "."), - ("./.env", "."), - ("./setup.cfg", "."), - ("./tilia/ui/img", "./tilia/ui/img/"), - ("./tilia/ui/fonts", "./tilia/ui/fonts/"), - ("./tilia/media/player/youtube.html", "./tilia/media/player/"), - ("./tilia/media/player/youtube.css", "./tilia/media/player/"), - ("./tilia/parsers/score/svg_maker.html", "./tilia/parsers/score/"), - ("./tilia/parsers/score/timewise_to_partwise.xsl", "./tilia/parsers/score/"), - ], - hiddenimports=[], - hookspath=None, - runtime_hooks=None, - excludes=None, -) - -pyz = PYZ(a.pure) - -icon_path = Path("tilia", "ui", "img", "main_icon.ico").resolve().__str__() -executable_basename = f"{APP_NAME.lower()}-{VERSION}-{platform_suffix}" - -if options.debug: - exe = EXE( - pyz, - a.scripts, - name=executable_basename, - console=True, - embed_manifest=True, - exclude_binaries=True, - icon=icon_path, - ) - - coll = COLLECT(exe, a.datas, a.binaries, name="TiLiA") -else: - exe = EXE( - pyz, - a.scripts, - a.datas, - a.binaries, - name=executable_basename, - console=False, - embed_manifest=True, - icon=icon_path, - ) - app = BUNDLE( - exe, - name=f"{executable_basename}.app", - icon=icon_path, - version=VERSION, - info_plist={ - 'NSPrincipalClass': 'NSApplication', - 'NSAppleScriptEnabled': False, - 'CFBundleDocumentTypes': [ - { - 'CFBundleTypeName': 'My File Format', - 'CFBundleTypeIconFile': 'MyFileIcon.icns', - 'LSItemContentTypes': ['com.example.myformat'], - 'LSHandlerRank': 'Owner' - } - ] - }, - ) - -# Reset enviroment -dotenv.set_key(".env", "ENVIRONMENT", "dev") diff --git a/tilia/__main__.py b/tilia/__main__.py new file mode 100644 index 000000000..e769ab26c --- /dev/null +++ b/tilia/__main__.py @@ -0,0 +1,72 @@ +import sys +from pathlib import Path +import platform +import subprocess +import traceback +import webbrowser + +sys.path[0] = Path(__file__).parents[1].__str__() + + +def deps_debug(exc: ImportError): + from tilia.constants import EMAIL, GITHUB_URL, WEBSITE_URL # noqa: E402 + + def _raise_deps_error(exc: ImportError, message: list[str]): + raise RuntimeError("\n".join(message)) from exc + + distro = platform.freedesktop_os_release().get("ID_LIKE", "").split()[0] + link = f"{WEBSITE_URL}/help/installation?distro={distro}#troubleshooting-linux" + root_path = Path( + [*traceback.walk_tb(exc.__traceback__)][0][0].f_code.co_filename + ).parent + lib_path = root_path / "PySide6/qt-plugins/platforms/libqxcb.so" + + if not lib_path.exists(): + if "__compiled__" not in globals(): + msg = [ + "Could not locate the necessary libraries to run TiLiA. libqxcb.so file not found.", + "Did you forget to install python dependencies?", + ] + else: + msg = [ + "Could not locate the necessary libraries to run TiLiA. libqxcb.so file not found.", + f"Open an issue on our repo at <{GITHUB_URL}> or contact us at <{EMAIL}> for help.\n" + "Dumping all files in tree for debug...", + subprocess.getoutput(f"ls {root_path.as_posix()} -ltraR"), + ] + _raise_deps_error(exc, msg) + + deps = subprocess.getoutput(f"ldd {lib_path.as_posix()}") + if "=> not found" in deps: + missing_deps = [] + for line in deps.splitlines(): + if "=> not found" in line: + dep = line.strip().rstrip(" => not found") + missing_deps.append(dep) + + if missing_deps: + deps = f"Missing libraries:\n{missing_deps}" + + msg = [ + "TiLiA could not start due to missing system dependencies.", + f"Visit <{link}> for help on installation.", + "Install the necessary dependencies then restart.\n", + deps, + ] + webbrowser.open(link) + _raise_deps_error(exc, msg) + + +def main(): + try: + from tilia.boot import boot # noqa: E402 + + boot() + except ImportError as exc: + if sys.platform != "linux": + raise exc + deps_debug(exc) + + +if __name__ == "__main__": + main() diff --git a/tilia/app.py b/tilia/app.py index 8467aa803..2792234e4 100644 --- a/tilia/app.py +++ b/tilia/app.py @@ -169,7 +169,7 @@ def on_open(self, path: Path | str | None = None) -> None: def update_recent_files(self): try: geometry, window_state = get(Get.WINDOW_GEOMETRY), get(Get.WINDOW_STATE) - except tilia.exceptions.NoReplyToRequest: + except NoReplyToRequest: geometry, window_state = None, None settings.update_recent_files( @@ -257,7 +257,7 @@ def on_restore_state(self, state: dict) -> None: def recover_to_state(self, state: dict) -> None: """ - Clears the app and attemps to restore the given state. + Clears the app and attempts to restore the given state. Unlike `on_restore_state` this will crash if an error occurs during the restoration. This is meant to be used after an exception occurred, so if the diff --git a/tilia/boot.py b/tilia/boot.py index 5fbf2afe0..9c70ae093 100644 --- a/tilia/boot.py +++ b/tilia/boot.py @@ -3,25 +3,23 @@ import sys import traceback -import dotenv -from PyQt6.QtWidgets import QApplication +from PySide6.QtWidgets import QApplication +import tilia.utils # noqa: F401 from tilia.app import App from tilia.clipboard import Clipboard -from tilia.dirs import PROJECT_ROOT, setup_dirs +from tilia.dirs import setup_dirs from tilia.file.file_manager import FileManager from tilia.file.autosave import AutoSaver from tilia.log import logger from tilia.media.player import QtAudioPlayer -from tilia.ui.cli.ui import CLI -from tilia.ui.qtui import QtUI, TiliaMainWindow from tilia.undo_manager import UndoManager app = None ui = None -def handle_expection(type, value, tb): +def handle_exception(type, value, tb): exc_message = "".join(traceback.format_exception(type, value, tb)) if ui: ui.show_crash_dialog(exc_message) @@ -34,11 +32,8 @@ def handle_expection(type, value, tb): def boot(): - sys.excepthook = handle_expection - dotenv_path = PROJECT_ROOT / ".env" - success = dotenv.load_dotenv(dotenv_path) - if not success: - print(f"Could not load environment variables from {dotenv_path}") + sys.excepthook = handle_exception + args = setup_parser() setup_dirs() logger.setup() @@ -94,9 +89,14 @@ def setup_logic(autosaver=True): def setup_ui(q_application: QApplication, interface: str): if interface == "qt": + from tilia.ui.qtui import QtUI, TiliaMainWindow + mw = TiliaMainWindow() return QtUI(q_application, mw) + elif interface == "cli": + from tilia.ui.cli.ui import CLI + return CLI() diff --git a/tilia/constants.py b/tilia/constants.py index 37b00702e..550b09ab4 100644 --- a/tilia/constants.py +++ b/tilia/constants.py @@ -1,30 +1,56 @@ -import configparser +from importlib import metadata from pathlib import Path +import re -setupcfg = configparser.ConfigParser() -setupcfg.read(Path(__file__).parent.parent / "setup.cfg") +if (toml := Path(__file__).parent.parent / "pyproject.toml").exists(): + import sys -if setupcfg.has_section("metadata"): - APP_NAME = setupcfg["metadata"]["name"] - AUTHOR = setupcfg["metadata"]["author"] - VERSION = setupcfg["metadata"]["version"] + if sys.version_info >= (3, 11): + from tomllib import load + else: + from tomli import load + + with open(toml, "rb") as f: + setupcfg = load(f).get("project", {}) + AUTHOR = setupcfg.get("authors", [{"name": ""}])[0]["name"] + EMAIL = setupcfg.get("authors", [{"email": ""}])[0]["email"] else: - APP_NAME = "TiLiA" - AUTHOR = "" - VERSION = "beta" + try: + setupcfg = metadata.metadata("TiLiA").json.copy() + + AUTHOR = re.search(r'"(.*?)"', setupcfg.get("author_email", "")).group(1) + EMAIL = re.search(r"<(.*?)>", setupcfg.get("author_email", "")).group(1) + if "urls" not in setupcfg: + setupcfg["urls"] = {} + for url in setupcfg.get("project_url", {}): + k, _, v = url.partition(", ") + setupcfg["urls"][k] = v + setupcfg["description"] = setupcfg.get("summary", "") + except metadata.PackageNotFoundError: + setupcfg = {} + AUTHOR = "" + EMAIL = "" + +APP_NAME = setupcfg.get("name", "") +VERSION = setupcfg.get("version", "0.0.0") -YEAR = "2022-2025" +YEAR = "2022-2026" FILE_EXTENSION = "tla" -EMAIL_URL = "mailto:tilia@tilia-app.com" -GITHUB_URL = "https://github.com/TimeLineAnnotator/desktop" -WEBSITE_URL = "https://tilia-app.com" +EMAIL_URL = "mailto:" + EMAIL + +GITHUB_URL = setupcfg.get("urls", {}).get("Repository", "") +WEBSITE_URL = setupcfg.get("urls", {}).get("Homepage", "") YOUTUBE_URL_REGEX = r"^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube(-nocookie)?\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$" NOTICE = f""" -{APP_NAME}, {setupcfg["metadata"]["description"] if AUTHOR else ""} +{APP_NAME}, {setupcfg.get("description", "") if AUTHOR else ""} Copyright © {YEAR} {AUTHOR} This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. """ + +with open(Path(__file__).parents[1] / "LICENSE", encoding="utf-8") as f: + LICENSE_TEXT = f.read() +LICENSE = re.split("How to Apply These Terms to Your New Programs", LICENSE_TEXT)[0] diff --git a/tilia/dirs.py b/tilia/dirs.py index f2f2650fe..762002b93 100644 --- a/tilia/dirs.py +++ b/tilia/dirs.py @@ -12,10 +12,7 @@ _USER_DATA_DIR = Path( platformdirs.user_data_dir(tilia.constants.APP_NAME, roaming=True) ) -data_path = _SITE_DATA_DIR -PROJECT_ROOT = Path(tilia.__file__).parents[1] -TILIA_DIR = Path(tilia.__file__).parent -IMG_DIR = Path(TILIA_DIR, "ui", "img") +IMG_DIR = Path(__file__).parent / "ui" / "img" def setup_data_dir() -> Path: @@ -40,7 +37,9 @@ def setup_logs_path(data_dir): def setup_dirs() -> None: - os.chdir(os.path.dirname(__file__)) + # if not in prod, set directory to root of tilia + if os.environ.get("ENVIRONMENT") != "prod": + os.chdir(os.path.dirname(__file__)) data_dir = setup_data_dir() diff --git a/tilia/errors.py b/tilia/errors.py index 0373df743..3d04c7dd6 100644 --- a/tilia/errors.py +++ b/tilia/errors.py @@ -24,7 +24,7 @@ class Error(NamedTuple): CSV_IMPORT_FAILED = Error("CSV import failed", "Import failed:\n{}") CSV_IMPORT_SUCCESS_ERRORS = Error( "CSV import", - "Import was successful, but some components may not have been imported.\nThe following errors occured:\n{}", + "Import was successful, but some components may not have been imported.\nThe following errors occurred:\n{}", ) CREATE_TIMELINE_WITHOUT_MEDIA = Error( "Create timeline error", "Cannot create timeline with no media loaded." @@ -75,7 +75,7 @@ class Error(NamedTuple): COMPONENTS_COPY_ERROR = Error("Copy components error", "{}") COMPONENTS_LOAD_ERROR = Error( "Load components error", - "Some components were not loaded. The following errors occured:\n{}", + "Some components were not loaded. The following errors occurred:\n{}", ) COMPONENTS_PASTE_ERROR = Error("Paste components error", "{}") FILE_NOT_FOUND = Error( diff --git a/tilia/file/file_manager.py b/tilia/file/file_manager.py index 3a0132202..1d72d8fef 100644 --- a/tilia/file/file_manager.py +++ b/tilia/file/file_manager.py @@ -2,8 +2,11 @@ from pathlib import Path import json -from tilia.exceptions import MediaMetadataFieldNotFound, MediaMetadataFieldAlreadyExists -import tilia.exceptions +from tilia.exceptions import ( + MediaMetadataFieldAlreadyExists, + MediaMetadataFieldNotFound, + NoReplyToRequest, +) from tilia.file.common import are_tilia_data_equal, write_tilia_file_to_disk from tilia.requests import listen, Post, Get, serve, get, post from tilia.file.tilia_file import TiliaFile, validate_tla_data @@ -219,7 +222,7 @@ def save(self, data: dict, path: Path | str): try: geometry, window_state = get(Get.WINDOW_GEOMETRY), get(Get.WINDOW_STATE) - except tilia.exceptions.NoReplyToRequest: + except NoReplyToRequest: geometry, window_state = None, None settings.update_recent_files(path, geometry, window_state) diff --git a/tilia/log.py b/tilia/log.py index 198371585..b02c78589 100644 --- a/tilia/log.py +++ b/tilia/log.py @@ -41,7 +41,7 @@ def __init__(self): self._dump_count = count() def setup(self): - match (env := os.environ.get("ENVIRONMENT", "prod")): + match env := os.environ.get("ENVIRONMENT"): case "test": self.disabled = True self.setup_sentry(env) diff --git a/tilia/main.py b/tilia/main.py deleted file mode 100644 index 61175e8aa..000000000 --- a/tilia/main.py +++ /dev/null @@ -1,9 +0,0 @@ -from tilia.boot import boot - - -def main() -> None: - boot() - - -if __name__ == "__main__": - main() diff --git a/tilia/media/player/base.py b/tilia/media/player/base.py index b3bf688b0..c4ee053a9 100644 --- a/tilia/media/player/base.py +++ b/tilia/media/player/base.py @@ -5,7 +5,7 @@ from enum import Enum, auto from pathlib import Path -from PyQt6.QtCore import QTimer +from PySide6.QtCore import QTimer import tilia.errors from tilia.media import exporter diff --git a/tilia/media/player/qtplayer.py b/tilia/media/player/qtplayer.py index 2d986628c..c158af543 100644 --- a/tilia/media/player/qtplayer.py +++ b/tilia/media/player/qtplayer.py @@ -1,9 +1,7 @@ from __future__ import annotations -import time - -from PyQt6.QtCore import QUrl -from PyQt6.QtMultimedia import ( +from PySide6.QtCore import QUrl, QEventLoop, SignalInstance, QTimer +from PySide6.QtMultimedia import ( QMediaPlayer, QAudioOutput, QAudio, @@ -17,6 +15,42 @@ from tilia.ui.player import PlayerStatus +def wait_for_signal(signal: SignalInstance, value): + """ + Many Qt functions run on threads, and this wrapper makes sure that after starting a process, the right signal is emitted before continuing the TiLiA process. + See _engine_stop of QtPlayer for an example implementation. + + :param signal: The signal to watch. + :type signal: SignalInstance + :param value: The "right" output value that signal should emit before continuing. (eg. on stopping player, playbackStateChanged emits StoppedState when player has been successfully stopped. Only then can we continue the rest of the update process.) + """ + + def signal_wrapper(func): + timer = QTimer(singleShot=True, interval=100) + loop = QEventLoop() + success = False + + def value_checker(signal_value): + if signal_value == value: + nonlocal success + success = True + loop.quit() + + def check_signal(*args, **kwargs): + nonlocal success + if not func(*args, **kwargs): + return False + signal.connect(value_checker) + timer.timeout.connect(loop.quit) + timer.start() + loop.exec() + return timer.isActive() and success + + return check_signal + + return signal_wrapper + + class QtPlayer(Player): MEDIA_TYPE = "" @@ -48,9 +82,15 @@ def on_audio_outputs_changed(self) -> None: self.audio_output.setDevice(QAudioDevice()) def _engine_load_media(self, media_path: str) -> bool: - self._engine_stop() # Must be _engine_stop() instead of player.stop() to avoid freeze. - self.player.setSource(QUrl.fromLocalFile(media_path)) - return True + @wait_for_signal( + self.player.mediaStatusChanged, QMediaPlayer.MediaStatus.LoadedMedia + ) + def load_media(media_path): + self._engine_stop() # Must be _engine_stop() instead of player.stop() to avoid freeze. + self.player.setSource(QUrl.fromLocalFile(media_path)) + return True + + return load_media(media_path) def _engine_get_current_time(self): return self.player.position() / 1000 @@ -68,12 +108,14 @@ def _engine_unpause(self) -> None: self.player.play() def _engine_stop(self): - self.player.stop() - # Sleeping avoids freeze if about to change player URL. Not sure why the freeze happens. - # Waiting for self.player.mediaStatus() == MediaPlayer.MediaStatus.Stopped also does not work. - # I have tested different sleep times and 0.01 seems to prevent freezes reliably - # while still having no perceptible impact on test performance. - time.sleep(0.01) + @wait_for_signal( + self.player.playbackStateChanged, QMediaPlayer.PlaybackState.StoppedState + ) + def stop(): + self.player.stop() + return True + + return stop() def _engine_unload_media(self): self._engine_stop() # Must be _engine_stop() instead of player.stop() to avoid freeze. diff --git a/tilia/media/player/qtvideo.py b/tilia/media/player/qtvideo.py index 08017396a..3c3c252ea 100644 --- a/tilia/media/player/qtvideo.py +++ b/tilia/media/player/qtvideo.py @@ -1,7 +1,7 @@ from __future__ import annotations -from PyQt6.QtMultimediaWidgets import QVideoWidget -from PyQt6.QtWidgets import QSizePolicy +from PySide6.QtMultimediaWidgets import QVideoWidget +from PySide6.QtWidgets import QSizePolicy from .qtplayer import QtPlayer from tilia.ui.windows.view_window import ViewWindow diff --git a/tilia/media/player/youtube.py b/tilia/media/player/youtube.py index a467cf1d4..0cd2bd20b 100644 --- a/tilia/media/player/youtube.py +++ b/tilia/media/player/youtube.py @@ -2,16 +2,16 @@ from enum import Enum from pathlib import Path -from PyQt6.QtCore import QUrl, pyqtSlot, QObject, QTimer, QByteArray -from PyQt6.QtWebChannel import QWebChannel +from PySide6.QtCore import QUrl, Slot, QObject, QTimer, QByteArray +from PySide6.QtWebChannel import QWebChannel import tilia.constants import tilia.errors from tilia.media.player import Player -from PyQt6.QtWebEngineWidgets import QWebEngineView -from PyQt6.QtWebEngineCore import QWebEngineSettings, QWebEngineUrlRequestInterceptor +from PySide6.QtWebEngineWidgets import QWebEngineView +from PySide6.QtWebEngineCore import QWebEngineSettings, QWebEngineUrlRequestInterceptor from tilia.media.player.base import MediaTimeChangeReason from tilia.requests import Post, post @@ -38,11 +38,11 @@ def __init__( self.player_toolbar_enabled = False self.display_error = display_error - @pyqtSlot("float") + @Slot("float") def on_new_time(self, time): self.set_current_time(time) - @pyqtSlot("int") + @Slot("int") def on_player_state_change(self, state): if state == self.State.UNSTARTED.value: post(Post.PLAYER_UPDATE_CONTROLS, PlayerStatus.WAITING_FOR_YOUTUBE) @@ -56,11 +56,11 @@ def on_player_state_change(self, state): else: self.set_is_playing(False) - @pyqtSlot("float") + @Slot("float") def on_set_playback_rate(self, playback_rate: float): self.set_playback_rate(playback_rate) - @pyqtSlot(str) + @Slot(str) def on_error(self, message: str) -> None: self.display_error(message) @@ -118,7 +118,7 @@ def load_media( initial_duration: float = 0.0, ): """ - Returns True if media loading has *started* succesfully, False otherwise. + Returns True if media loading has *started* successfully, False otherwise. Loading is asynchronous, and self.on_media_load_done will be called when it is completed. If initial_duration is provided, it will be available when returning. diff --git a/tilia/parsers/csv/beat.py b/tilia/parsers/csv/beat.py index 594c70974..3eb2bd821 100644 --- a/tilia/parsers/csv/beat.py +++ b/tilia/parsers/csv/beat.py @@ -55,7 +55,7 @@ def beats_from_csv( measures_to_force_display = [] def check_params() -> bool: - for param, parser in zip(params, parsers): + for param, parser in zip(params, parsers, strict=True): if param in params_to_indices: try: index = params_to_indices[param] diff --git a/tilia/parsers/csv/common.py b/tilia/parsers/csv/common.py index f04ff9528..08ee6ba6d 100644 --- a/tilia/parsers/csv/common.py +++ b/tilia/parsers/csv/common.py @@ -73,8 +73,8 @@ def _get_attr_data( def _parse_measure_fraction(value: str): try: value = float(value) - except ValueError: - raise ValueError("APPEND:Must be a number between 0 and 1.") + except ValueError as e: + raise ValueError("APPEND:Must be a number between 0 and 1.") from e if not 0 <= value <= 1: raise ValueError("APPEND:Must be a number between 0 and 1.") diff --git a/tilia/parsers/csv/hierarchy.py b/tilia/parsers/csv/hierarchy.py index e3ee5a431..ea5632dca 100644 --- a/tilia/parsers/csv/hierarchy.py +++ b/tilia/parsers/csv/hierarchy.py @@ -41,7 +41,7 @@ def import_by_time( "formal_type", "formal_function", ] - parsers = [float, float, int, float, float, str, str, str] + parsers = [float, float, int, float, float, str, str, str, str, str] params_to_indices = get_params_indices(params, next(reader)) for attr in ["start", "end", "level"]: @@ -61,7 +61,7 @@ def import_by_time( errors.append(f"'{value}' is not a valid {attr.replace('_', ' ')}") continue - for param, parser in zip(params, parsers): + for param, parser in zip(params, parsers, strict=True): if param in params_to_indices: index = params_to_indices[param] constructor_args[param] = parser(row[index]) diff --git a/tilia/parsers/csv/marker.py b/tilia/parsers/csv/marker.py index 95ec0e0ac..1a96e3a1b 100644 --- a/tilia/parsers/csv/marker.py +++ b/tilia/parsers/csv/marker.py @@ -50,7 +50,7 @@ def import_by_time( parsers = [float, str, str] constructor_kwargs = {} - for param, parser in zip(params, parsers): + for param, parser in zip(params, parsers, strict=True): if param in params_to_indices: index = params_to_indices[param] constructor_kwargs[param] = parser(row[index]) @@ -124,7 +124,7 @@ def import_by_measure( parsers = [str, str] constructor_kwargs = {} - for param, parser in zip(params, parsers): + for param, parser in zip(params, parsers, strict=True): if param in params_to_indices: index = params_to_indices[param] constructor_kwargs[param] = parser(row[index]) diff --git a/tilia/parsers/score/musicxml.py b/tilia/parsers/score/musicxml.py index 2769226a2..a78236734 100644 --- a/tilia/parsers/score/musicxml.py +++ b/tilia/parsers/score/musicxml.py @@ -148,11 +148,11 @@ def _parse_attributes(part: etree._Element, part_id: str): ): match prev_note.tag: case "backup": - if not prev_note.find("duration") is None: + if prev_note.find("duration") is not None: # grace notes have no duration cur_div -= int(prev_note.find("duration").text) case _: - if not prev_note.find("duration") is None: + if prev_note.find("duration") is not None: # grace notes have no duration cur_div += int(prev_note.find("duration").text) diff --git a/tilia/parsers/score/musicxml_to_svg.py b/tilia/parsers/score/musicxml_to_svg.py index 21289b173..0a565f8e3 100644 --- a/tilia/parsers/score/musicxml_to_svg.py +++ b/tilia/parsers/score/musicxml_to_svg.py @@ -4,14 +4,14 @@ from pathlib import Path from re import sub -from PyQt6.QtCore import ( - pyqtSlot, +from PySide6.QtCore import ( + Slot, QObject, QUrl, ) -from PyQt6.QtWebChannel import QWebChannel -from PyQt6.QtWebEngineCore import QWebEngineSettings -from PyQt6.QtWebEngineWidgets import QWebEngineView +from PySide6.QtWebChannel import QWebChannel +from PySide6.QtWebEngineCore import QWebEngineSettings +from PySide6.QtWebEngineWidgets import QWebEngineView from tilia.requests import ( get, Get, @@ -26,11 +26,11 @@ def __init__(self, page, on_svg_loaded, display_error) -> None: self.on_svg_loaded = on_svg_loaded self.display_error = display_error - @pyqtSlot(str) + @Slot(str) def set_svg(self, svg: str) -> None: self.on_svg_loaded(svg) - @pyqtSlot(str) + @Slot(str) def on_error(self, message: str) -> None: self.display_error(message) diff --git a/tilia/parsers/score/timewise_to_partwise.xsl b/tilia/parsers/score/timewise_to_partwise.xsl index f0e0925e4..b61adf97f 100644 --- a/tilia/parsers/score/timewise_to_partwise.xsl +++ b/tilia/parsers/score/timewise_to_partwise.xsl @@ -52,7 +52,7 @@ diff --git a/tilia/requests/get.py b/tilia/requests/get.py index ce3bc1e30..6d3fb9eb5 100644 --- a/tilia/requests/get.py +++ b/tilia/requests/get.py @@ -80,8 +80,8 @@ def get(request: Get, *args, **kwargs) -> Any: try: return _requests_to_callbacks[request](*args, **kwargs) - except KeyError: - raise NoReplyToRequest(f"{request} has no repliers attached.") + except KeyError as e: + raise NoReplyToRequest(f"{request} has no repliers attached.") from e except Exception as exc: raise Exception( f"Exception when processing {request} with {args=}, {kwargs=}" @@ -110,12 +110,12 @@ def server(request: Get) -> tuple[Any | None, Callable | None]: def stop_serving(replier: Any, request: Get) -> None: """ - Detaches a calback from a request. + Detaches a callback from a request. """ try: _requests_to_callbacks.pop(request) - except KeyError: - raise NoCallbackAttached() + except KeyError as e: + raise NoCallbackAttached() from e _servers_to_requests[replier].remove(request) if not _servers_to_requests[replier]: diff --git a/tilia/requests/post.py b/tilia/requests/post.py index 85a85a1a0..bf70f9715 100644 --- a/tilia/requests/post.py +++ b/tilia/requests/post.py @@ -104,17 +104,14 @@ class Post(Enum): ] = weakref.WeakKeyDictionary() -def _get_posts_excluded_from_log() -> list[Post]: - result = [] - for name in os.environ.get("EXCLUDE_FROM_LOG", "").split(";"): - result.append(Post[name]) - return result +EXCLUDED_POSTS = [ + Post[post] for post in os.environ.get("EXCLUDE_FROM_LOG", "").split(";") +] +LOG_REQUESTS = os.environ.get("LOG_REQUESTS", 0) def _log_post(post, *args, **kwargs): - log_message = ( - f"{post.name:<40} {str((args, kwargs)):<100} {list(_posts_to_listeners[post])}" - ) + log_message = f"{post.name:<40} {str((args, kwargs)):<100} {list(_posts_to_listeners.get(post, ''))}" if post is Post.DISPLAY_ERROR: logger.warning(log_message) return @@ -124,14 +121,14 @@ def _log_post(post, *args, **kwargs): def post(post: Post, *args, **kwargs) -> None: - if os.environ.get("LOG_REQUESTS", 0) and post not in _get_posts_excluded_from_log(): + if LOG_REQUESTS and post not in EXCLUDED_POSTS: _log_post(post, args, kwargs) # Returning a result is an experimental feature. # This can be very useful to check if the request was successful. # Should be used only when a single listener is expected. # If there are multiple listeners, the result of the last listener is returned. result = None - for listener, callback in _posts_to_listeners[post].copy().items(): + for callback in _posts_to_listeners[post].copy().values(): result = callback(*args, **kwargs) return result diff --git a/tilia/settings.py b/tilia/settings.py index 9c143b224..386d22331 100644 --- a/tilia/settings.py +++ b/tilia/settings.py @@ -1,4 +1,4 @@ -from PyQt6.QtCore import QSettings, QObject +from PySide6.QtCore import QSettings, QObject from pathlib import Path import tilia.constants @@ -6,7 +6,6 @@ class SettingsManager(QObject): - DEFAULT_SETTINGS = { "general": { "auto-scroll": ScrollType.OFF, @@ -90,7 +89,9 @@ class SettingsManager(QObject): } def __init__(self): - self._settings = QSettings(tilia.constants.APP_NAME, "Desktop Settings") + self._settings = QSettings( + tilia.constants.APP_NAME, application="Desktop Settings", parent=None + ) self._files_updated_callbacks = set() self._cache = {} self._check_all_default_settings_present() @@ -119,7 +120,10 @@ def link_file_update(self, updating_function) -> None: def _get(self, group_name: str, setting: str, in_default=True): key = self._get_key(group_name, setting, in_default) - value = self._settings.value(key, None) + try: + value = self._settings.value(key, None) + except EOFError: # happens when the group in self._settings is not initiated, but setting a value solves this. + value = None if not value or not isinstance( value, type(self.DEFAULT_SETTINGS[group_name][setting]) ): @@ -151,8 +155,8 @@ def set(self, group_name: str, setting: str, value): try: self._cache[group_name][setting] = value self._set(group_name, setting, value) - except AttributeError: - raise AttributeError(f"{group_name}.{setting} not found in cache.") + except AttributeError as e: + raise AttributeError(f"{group_name}.{setting} not found in cache.") from e @staticmethod def _get_key(group_name: str, setting: str, in_default: bool) -> str: diff --git a/tilia/timelines/base/component/base.py b/tilia/timelines/base/component/base.py index 6dc6d30b9..6e1b8e4f2 100644 --- a/tilia/timelines/base/component/base.py +++ b/tilia/timelines/base/component/base.py @@ -1,6 +1,5 @@ from __future__ import annotations -from abc import ABC from typing import Any, Callable from tilia.exceptions import SetComponentDataError, GetComponentDataError @@ -10,7 +9,7 @@ from tilia.utils import get_tilia_class_string -class TimelineComponent(ABC): +class TimelineComponent: SERIALIZABLE = [] ORDERING_ATTRS = tuple() @@ -54,10 +53,10 @@ def validate_set_data(self, attr, value): ) try: return self.validators[attr](value) - except KeyError: + except KeyError as e: raise KeyError( f"{self} has no validator for attribute {attr}. Can't set to '{value}'." - ) + ) from e def set_data(self, attr: str, value: Any): if not self.validate_set_data(attr, value): @@ -71,11 +70,11 @@ def set_data(self, attr: str, value: Any): def get_data(self, attr: str): try: return getattr(self, attr) - except AttributeError: + except AttributeError as e: raise GetComponentDataError( "AttributeError while getting data from component." f"Does {type(self)} have a {attr} attribute?" - ) + ) from e @classmethod def validate_creation(cls, *args, **kwargs) -> tuple[bool, str]: @@ -83,7 +82,7 @@ def validate_creation(cls, *args, **kwargs) -> tuple[bool, str]: @staticmethod def compose_validators( - validators: list[Callable[[], tuple[bool, str]]] + validators: list[Callable[[], tuple[bool, str]]], ) -> tuple[bool, str]: """Calls validators in order and returns (False, reason) if any fails. Returns (True, '') if all succeed.""" for validator in validators: diff --git a/tilia/timelines/base/timeline.py b/tilia/timelines/base/timeline.py index d96f649b3..2d4b65a83 100644 --- a/tilia/timelines/base/timeline.py +++ b/tilia/timelines/base/timeline.py @@ -164,10 +164,10 @@ def validate_set_data(self, attr, value): ) try: return self.validators[attr](value) - except KeyError: + except KeyError as e: raise KeyError( f"{self} has no validator for attribute {attr}. Can't set to '{value}'." - ) + ) from e def set_data(self, attr: str, value: Any): if not self.validate_set_data(attr, value): @@ -272,7 +272,7 @@ def _get_base_state(self) -> dict: string_to_hash += str(value) + "|" string_to_hash += str(value) + "|" - state["hash"] = hash_function(f'{state["kind"]}|{string_to_hash}') + state["hash"] = hash_function(f"{state['kind']}|{string_to_hash}") return state @@ -329,7 +329,7 @@ def associate_to_timeline(self, timeline: Timeline): @staticmethod def _compose_validators( - validators: list[Callable[[], tuple[bool, str]]] + validators: list[Callable[[], tuple[bool, str]]], ) -> tuple[bool, str]: """Calls validators in order and returns (False, reason) if any fails.""" for validator in validators: @@ -470,11 +470,11 @@ def _remove_from_components_set(self, component: TC) -> None: try: self._components.remove(component) self.id_to_component.pop(component.id) - except KeyError: + except KeyError as e: raise KeyError( f"Can't remove component '{component}' from {self}: not in" " self.components." - ) + ) from e def update_component_order(self, component: TC): self._components.remove(component) diff --git a/tilia/timelines/base/validators.py b/tilia/timelines/base/validators.py index 5edf87d8a..fc36e080a 100644 --- a/tilia/timelines/base/validators.py +++ b/tilia/timelines/base/validators.py @@ -1,7 +1,7 @@ import math from typing import Any -from PyQt6.QtGui import QColor +from PySide6.QtGui import QColor def validate_time(value): diff --git a/tilia/timelines/beat/timeline.py b/tilia/timelines/beat/timeline.py index a954058b9..fa13f3f6b 100644 --- a/tilia/timelines/beat/timeline.py +++ b/tilia/timelines/beat/timeline.py @@ -577,7 +577,7 @@ def get_beat_index(self, beat: Beat) -> int: return self.components.index(beat) def propagate_measure_number_change(self, start_index: int): - for j, measure in enumerate(self.measure_numbers[start_index + 1 :]): + for j in range(len(self.measure_numbers[start_index + 1 :])): propagate_index = j + start_index + 1 if propagate_index in self.measures_to_force_display: break diff --git a/tilia/timelines/collection/collection.py b/tilia/timelines/collection/collection.py index 36cb62cf1..962f13dc3 100644 --- a/tilia/timelines/collection/collection.py +++ b/tilia/timelines/collection/collection.py @@ -86,10 +86,10 @@ def _validate_timeline_kind(kind: TlKind | str): if isinstance(kind, str): try: kind = TlKind(kind) - except ValueError: + except ValueError as e: raise TimelineValidationError( f"Can't create timeline: invalid timeline kind '{kind}'" - ) + ) from e if not isinstance(kind, TlKind): raise TimelineValidationError( f"Can't create timeline: invalid timeline kind '{kind}'" @@ -204,11 +204,11 @@ def _remove_from_timelines(self, timeline: Timeline) -> None: for tl in self: if tl.ordinal > timeline.ordinal: tl.ordinal -= 1 - except ValueError: + except ValueError as e: raise ValueError( f"Can't remove timeline '{timeline}' from {self}: not in" " self._timelines." - ) + ) from e def get_export_data(self): return [ diff --git a/tilia/timelines/harmony/components/harmony.py b/tilia/timelines/harmony/components/harmony.py index 8b3de430a..182925e39 100644 --- a/tilia/timelines/harmony/components/harmony.py +++ b/tilia/timelines/harmony/components/harmony.py @@ -103,9 +103,11 @@ def __repr__(self): @classmethod def from_string( - cls, time: float, string: str, key: music21.key.Key = music21.key.Key("C") + cls, time: float, string: str, key: music21.key.Key | str = "C major" ): - music21_object, object_type = _get_music21_object_from_text(string, key) + music21_object, object_type = _get_music21_object_from_text( + string, key.__str__() + ) if not string: return None @@ -159,9 +161,10 @@ def _replace_special_abbreviations(text): def _get_music21_object_from_text( text: str, key: str -) -> tuple[music21.harmony.ChordSymbol | music21.roman.RomanNumeral, str] | tuple[ - None, None -]: +) -> ( + tuple[music21.harmony.ChordSymbol | music21.roman.RomanNumeral, str] + | tuple[None, None] +): text, prefixed_accidental = _extract_prefixed_accidental(text) text = _format_postfix_accidental(text) text = _replace_special_abbreviations(text) diff --git a/tilia/timelines/hierarchy/timeline.py b/tilia/timelines/hierarchy/timeline.py index 68797c1b9..d4422f16d 100644 --- a/tilia/timelines/hierarchy/timeline.py +++ b/tilia/timelines/hierarchy/timeline.py @@ -95,7 +95,7 @@ def get_boundary_conflicts(self) -> list[tuple[Hierarchy, Hierarchy]]: and hrc1.end == hrc2.end and hrc1.level == hrc2.level ): - # if hierachies have same times and level, there's a conflict + # if hierarchies have same times and level, there's a conflict conflicts.append((hrc1, hrc2)) return conflicts diff --git a/tilia/timelines/slider/timeline.py b/tilia/timelines/slider/timeline.py index 49fd61560..02c5a296e 100644 --- a/tilia/timelines/slider/timeline.py +++ b/tilia/timelines/slider/timeline.py @@ -32,7 +32,7 @@ def is_empty(self): return True def _validate_delete_components(self, component: TimelineComponent): - """Nothing to do. Must impement abstract method.""" + """Nothing to do. Must implement abstract method.""" def get_state(self) -> dict: result = {} diff --git a/tilia/ui/actions.py b/tilia/ui/actions.py new file mode 100644 index 000000000..e69de29bb diff --git a/tilia/ui/cli/generate_scripts.py b/tilia/ui/cli/generate_scripts.py index 04210bbee..6e5b5f3c7 100644 --- a/tilia/ui/cli/generate_scripts.py +++ b/tilia/ui/cli/generate_scripts.py @@ -136,12 +136,12 @@ def _get_script_for_folder( if not (media_data or "set_media_length.txt" in filenames): print( - f'{"No suitable media found in " + folder_name + ".":<100}{"Folder skipped.":>14}' + f"{'No suitable media found in ' + folder_name + '.':<100}{'Folder skipped.':>14}" ) return if len(media_data) > 1: print( - f'{"Multiple media files found in " + folder_name + ".":<100}{"Folder skipped.":>14}' + f"{'Multiple media files found in ' + folder_name + '.':<100}{'Folder skipped.':>14}" ) return @@ -151,7 +151,7 @@ def _get_script_for_folder( if "set_media_length.txt" in filenames: to_write.append( - f'metadata set-media-length {open(os.path.join(folder_name, "set_media_length.txt"), "r").read()}\n' + f"metadata set-media-length {open(os.path.join(folder_name, 'set_media_length.txt'), 'r').read()}\n" ) filenames.remove("set_media_length.txt") @@ -173,7 +173,7 @@ def _get_script_for_folder( reference_beat = args.ref_name elif reference_beat and args.ref_name: print( - f'{"Multiple beat timelines found. Using " + reference_beat + " as reference instead of " + args.ref_name + ".":<100}{os.path.join(folder_name, file):>20}' + f"{'Multiple beat timelines found. Using ' + reference_beat + ' as reference instead of ' + args.ref_name + '.':<100}{os.path.join(folder_name, file):>20}" ) except ValueError as e: @@ -218,7 +218,7 @@ def get_scripts(directory: str | Path) -> list[Path]: List of paths to individual scripts (str): The absolute path to each script. """ saved_scripts = [] - for folder_name, sub_folders, filenames in os.walk(directory): + for folder_name, _, filenames in os.walk(directory): saved_scripts += filter(None, [_get_script_for_folder(folder_name, filenames)]) return saved_scripts diff --git a/tilia/ui/cli/player.py b/tilia/ui/cli/player.py index fc5e9fa1e..1b8a86730 100644 --- a/tilia/ui/cli/player.py +++ b/tilia/ui/cli/player.py @@ -162,7 +162,6 @@ def get_youtube_duration(self, id: str) -> tuple[bool, str | float]: return False, self.DECODE_ERROR_MESSAGE try: - duration = response_json["items"][0]["contentDetails"]["duration"] except IndexError: return False, self.VIDEO_NOT_FOUND_MESSAGE.format(id) diff --git a/tilia/ui/cli/timelines/imp.py b/tilia/ui/cli/timelines/imp.py index 8ca8e1fb5..f9ce5a5d0 100644 --- a/tilia/ui/cli/timelines/imp.py +++ b/tilia/ui/cli/timelines/imp.py @@ -14,7 +14,7 @@ def setup_parser(subparsers): # Import command import_parser = subparsers.add_parser( - "import", help="Import data from a file into a " "timeline" + "import", help="Import data from a file into a timeline" ) import_parser.set_defaults(func=import_timeline) diff --git a/tilia/ui/cli/timelines/list.py b/tilia/ui/cli/timelines/list.py index dc8d7ba8c..1f140e417 100644 --- a/tilia/ui/cli/timelines/list.py +++ b/tilia/ui/cli/timelines/list.py @@ -4,7 +4,7 @@ def pprint_tlkind(kind: TlKind) -> str: - return kind.value.strip("_TIMELINE").capitalize() + return kind.value.replace("_TIMELINE", "").capitalize() def setup_parser(subparser): diff --git a/tilia/ui/cli/ui.py b/tilia/ui/cli/ui.py index e4bcd6762..b5c33d358 100644 --- a/tilia/ui/cli/ui.py +++ b/tilia/ui/cli/ui.py @@ -111,7 +111,7 @@ def parse_and_run(self, cmd): def run(self, cmd: str) -> bool: """ Parses the commands entered by the user. - Return True if an uncaught exception ocurred. + Return True if an uncaught exception occurred. The exception is stored in self.exception. """ try: @@ -150,6 +150,10 @@ def get_player_class(media_type: str): def show_crash_dialog(exc_message) -> None: post(Post.DISPLAY_ERROR, "CLI has crashed", "Error: " + exc_message) + @staticmethod + def exit(code: int): + raise SystemExit(code) + def on_ask_yes_or_no(title: str, prompt: str) -> bool: return ask_yes_or_no(f"{title}: {prompt}") diff --git a/tilia/ui/color.py b/tilia/ui/color.py index 13af1443c..9f4b5702d 100644 --- a/tilia/ui/color.py +++ b/tilia/ui/color.py @@ -1,4 +1,4 @@ -from PyQt6.QtGui import QColor +from PySide6.QtGui import QColor def get_tinted_color(color: str, factor: int) -> str: diff --git a/tilia/ui/commands.py b/tilia/ui/commands.py index b95f6215e..9e5e0a48f 100644 --- a/tilia/ui/commands.py +++ b/tilia/ui/commands.py @@ -37,8 +37,8 @@ import tilia.errors from tilia.dirs import IMG_DIR -from PyQt6.QtWidgets import QMainWindow, QWidget -from PyQt6.QtGui import QAction, QKeySequence, QIcon +from PySide6.QtWidgets import QMainWindow, QWidget +from PySide6.QtGui import QAction, QKeySequence, QIcon class CommandQAction(QAction): diff --git a/tilia/ui/dialogs/add_timeline_without_media.py b/tilia/ui/dialogs/add_timeline_without_media.py index c93c627a9..de79665af 100644 --- a/tilia/ui/dialogs/add_timeline_without_media.py +++ b/tilia/ui/dialogs/add_timeline_without_media.py @@ -1,7 +1,7 @@ from enum import Enum -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QRadioButton, diff --git a/tilia/ui/dialogs/basic.py b/tilia/ui/dialogs/basic.py index 8549f5ba4..eef71ba19 100644 --- a/tilia/ui/dialogs/basic.py +++ b/tilia/ui/dialogs/basic.py @@ -1,5 +1,5 @@ -from PyQt6.QtGui import QColor -from PyQt6.QtWidgets import QColorDialog, QInputDialog, QMessageBox +from PySide6.QtGui import QColor +from PySide6.QtWidgets import QColorDialog, QInputDialog, QMessageBox def ask_for_color( diff --git a/tilia/ui/dialogs/by_time_or_by_measure.py b/tilia/ui/dialogs/by_time_or_by_measure.py index b95f2d4e8..388b1ff1a 100644 --- a/tilia/ui/dialogs/by_time_or_by_measure.py +++ b/tilia/ui/dialogs/by_time_or_by_measure.py @@ -1,5 +1,5 @@ -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( QDialog, QLabel, QRadioButton, diff --git a/tilia/ui/dialogs/choose.py b/tilia/ui/dialogs/choose.py index d141eb20d..b7fa97cf1 100644 --- a/tilia/ui/dialogs/choose.py +++ b/tilia/ui/dialogs/choose.py @@ -1,6 +1,6 @@ from typing import Any -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QDialog, QMainWindow, QLabel, diff --git a/tilia/ui/dialogs/crash.py b/tilia/ui/dialogs/crash.py index 69f61fc6b..3446c205b 100644 --- a/tilia/ui/dialogs/crash.py +++ b/tilia/ui/dialogs/crash.py @@ -1,6 +1,6 @@ -from PyQt6.QtCore import Qt, QRegularExpression -from PyQt6.QtGui import QRegularExpressionValidator -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Qt, QRegularExpression +from PySide6.QtGui import QRegularExpressionValidator +from PySide6.QtWidgets import ( QDialog, QFormLayout, QVBoxLayout, diff --git a/tilia/ui/dialogs/file.py b/tilia/ui/dialogs/file.py index 649f75f04..3e59fa975 100644 --- a/tilia/ui/dialogs/file.py +++ b/tilia/ui/dialogs/file.py @@ -1,9 +1,9 @@ from pathlib import Path from typing import Sequence -from PyQt6 import QtCore -from PyQt6.QtCore import QDir -from PyQt6.QtWidgets import QFileDialog +from PySide6 import QtCore +from PySide6.QtCore import QDir +from PySide6.QtWidgets import QFileDialog from tilia.media import constants as media_constants from tilia.constants import APP_NAME, FILE_EXTENSION diff --git a/tilia/ui/dialogs/harmony_params.py b/tilia/ui/dialogs/harmony_params.py index 4edc69d01..c0e38577b 100644 --- a/tilia/ui/dialogs/harmony_params.py +++ b/tilia/ui/dialogs/harmony_params.py @@ -1,5 +1,5 @@ import music21 -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QDialog, QComboBox, QGridLayout, diff --git a/tilia/ui/dialogs/mode_params.py b/tilia/ui/dialogs/mode_params.py index 4d64671d4..3b895b703 100644 --- a/tilia/ui/dialogs/mode_params.py +++ b/tilia/ui/dialogs/mode_params.py @@ -1,4 +1,4 @@ -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QDialog, QComboBox, QGridLayout, diff --git a/tilia/ui/dialogs/resize_rect.py b/tilia/ui/dialogs/resize_rect.py index 45a2d2b8c..7f5c74559 100644 --- a/tilia/ui/dialogs/resize_rect.py +++ b/tilia/ui/dialogs/resize_rect.py @@ -1,4 +1,4 @@ -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QDialog, QDialogButtonBox, QDoubleSpinBox, diff --git a/tilia/ui/menubar.py b/tilia/ui/menubar.py index 478456359..e995c1694 100644 --- a/tilia/ui/menubar.py +++ b/tilia/ui/menubar.py @@ -1,4 +1,4 @@ -from PyQt6.QtWidgets import QMainWindow +from PySide6.QtWidgets import QMainWindow from tilia.ui.menus import FileMenu, EditMenu, TimelinesMenu, ViewMenu, HelpMenu diff --git a/tilia/ui/menus.py b/tilia/ui/menus.py index 0a1ede5f4..df34ce205 100644 --- a/tilia/ui/menus.py +++ b/tilia/ui/menus.py @@ -3,8 +3,8 @@ from typing import TypeAlias from enum import Enum, auto -from PyQt6.QtWidgets import QMenu -from PyQt6.QtGui import QAction +from PySide6.QtWidgets import QMenu +from PySide6.QtGui import QAction from tilia.timelines.timeline_kinds import get_timeline_name, TimelineKind from tilia.ui import commands diff --git a/tilia/ui/options_toolbar.py b/tilia/ui/options_toolbar.py index 41a6716a1..f1542c2c4 100644 --- a/tilia/ui/options_toolbar.py +++ b/tilia/ui/options_toolbar.py @@ -1,5 +1,5 @@ -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QToolBar, QComboBox, QLabel +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QToolBar, QComboBox, QLabel from tilia.settings import settings from tilia.ui.enums import ScrollType diff --git a/tilia/ui/player.py b/tilia/ui/player.py index f8faa2e77..8cb65ac2a 100644 --- a/tilia/ui/player.py +++ b/tilia/ui/player.py @@ -1,11 +1,11 @@ -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QDoubleSpinBox, QLabel, QSlider, QToolBar, ) -from PyQt6.QtGui import QIcon, QAction, QPixmap -from PyQt6.QtCore import Qt +from PySide6.QtGui import QIcon, QAction, QPixmap +from PySide6.QtCore import Qt from tilia.dirs import IMG_DIR from tilia.ui import commands diff --git a/tilia/ui/qtui.py b/tilia/ui/qtui.py index a39b0047e..bd610d7f1 100644 --- a/tilia/ui/qtui.py +++ b/tilia/ui/qtui.py @@ -6,10 +6,10 @@ from typing import Optional -from PyQt6 import QtGui -from PyQt6.QtCore import QKeyCombination, Qt, qInstallMessageHandler, QUrl, QtMsgType -from PyQt6.QtGui import QIcon, QFontDatabase, QDesktopServices, QPainter, QPixmap -from PyQt6.QtWidgets import ( +from PySide6 import QtGui +from PySide6.QtCore import QKeyCombination, Qt, qInstallMessageHandler, QUrl, QtMsgType +from PySide6.QtGui import QIcon, QFontDatabase, QDesktopServices, QPainter, QPixmap +from PySide6.QtWidgets import ( QMainWindow, QApplication, QToolBar, @@ -313,7 +313,7 @@ def launch(self): return self.q_application.exec() def exit(self, code: int): - # Code = 0 means a succesful run, code = 1 means an unhandled exception. + # Code = 0 means a successful run, code = 1 means an unhandled exception. self.q_application.exit(code) def get_window_geometry(self): @@ -416,7 +416,7 @@ def on_media_load_youtube(): def on_clear_ui(self): """Closes all UI windows.""" - for kind, window in self._windows.items(): + for window in self._windows.values(): if window is not None: window.close() self.main_window.setFocus() diff --git a/tilia/ui/smooth_scroll.py b/tilia/ui/smooth_scroll.py index 78f7756dd..64b853378 100644 --- a/tilia/ui/smooth_scroll.py +++ b/tilia/ui/smooth_scroll.py @@ -3,7 +3,7 @@ # - apply smoothing curve to input - currently linear from typing import Any, Callable -from PyQt6.QtCore import QVariantAnimation, QVariant +from PySide6.QtCore import QVariantAnimation from tilia.settings import settings @@ -13,7 +13,7 @@ def setup_smooth(self): self.animation.setDuration(125) -def smooth(self: Any, args_getter: Callable[[], QVariant]): +def smooth(self: Any, args_getter: Callable[[], object]): """ Function Wrapper Smooths changes made by `args_setter` by inputting smaller changes over time. @@ -26,15 +26,15 @@ def smooth(self: Any, args_getter: Callable[[], QVariant]): `args_getter` and `args_setter` must refer to the same variables in `args_setpoint` in the same order. """ - def wrapper(args_setter: Callable[[QVariant], None]) -> Callable: - def wrapped_setter(args_setpoint: QVariant) -> None: + def wrapper(args_setter: Callable[[object], None]) -> Callable: + def wrapped_setter(args_setpoint: object) -> None: if self.animation.state() is QVariantAnimation.State.Running: self.animation.pause() self.animation.setStartValue(args_getter()) self.animation.setEndValue(args_setpoint) self.animation.start() - def timeout(value: QVariant) -> None: + def timeout(value: object) -> None: args_setter(value) if settings.get("general", "prioritise_performance") is True: diff --git a/tilia/ui/timelines/audiowave/element.py b/tilia/ui/timelines/audiowave/element.py index 949158e6a..1404bea95 100644 --- a/tilia/ui/timelines/audiowave/element.py +++ b/tilia/ui/timelines/audiowave/element.py @@ -1,8 +1,8 @@ from __future__ import annotations -from PyQt6.QtCore import Qt, QPointF, QLineF -from PyQt6.QtGui import QPen, QColor -from PyQt6.QtWidgets import QGraphicsLineItem +from PySide6.QtCore import Qt, QPointF, QLineF +from PySide6.QtGui import QPen, QColor +from PySide6.QtWidgets import QGraphicsLineItem from tilia.requests import Post, post, get, Get from ..cursors import CursorMixIn diff --git a/tilia/ui/timelines/base/element.py b/tilia/ui/timelines/base/element.py index 59c54181c..4cb4b5a34 100644 --- a/tilia/ui/timelines/base/element.py +++ b/tilia/ui/timelines/base/element.py @@ -4,12 +4,12 @@ from typing import Optional, Any, Callable -from PyQt6.QtCore import QPoint +from PySide6.QtCore import QPoint from tilia.ui.timelines.base.context_menus import TimelineUIElementContextMenu from tilia.ui.coords import time_x_converter -from PyQt6.QtWidgets import QGraphicsScene +from PySide6.QtWidgets import QGraphicsScene from tilia.requests import stop_listening_to_all @@ -64,7 +64,7 @@ def is_selected(self): return self in self.timeline_ui.selected_elements @abstractmethod - def child_items(self): + def child_items(self) -> list[Any]: ... def selection_triggers(self): @@ -86,12 +86,18 @@ def on_right_click(self, x, y, _): menu = self.CONTEXT_MENU_CLASS(self) menu.exec(QPoint(x, y)) + @abstractmethod def on_select(self): ... + @abstractmethod def on_deselect(self): ... + @abstractmethod + def update_position(self): + ... + def delete(self): for item in self.child_items(): if item.parentItem(): diff --git a/tilia/ui/timelines/base/element_manager.py b/tilia/ui/timelines/base/element_manager.py index 302aaf528..4a331b27e 100644 --- a/tilia/ui/timelines/base/element_manager.py +++ b/tilia/ui/timelines/base/element_manager.py @@ -3,7 +3,7 @@ import bisect from typing import Any, Callable, TYPE_CHECKING, TypeVar, Generic, Iterable -from PyQt6.QtWidgets import QGraphicsItem +from PySide6.QtWidgets import QGraphicsItem from tilia.timelines.component_kinds import ComponentKind from tilia.ui.timelines.element_kinds import get_element_class_by_kind @@ -70,10 +70,10 @@ def _remove_from_elements_set(self, element: TE) -> None: try: self._elements.remove(element) del self.id_to_element[element.id] - except ValueError: + except ValueError as e: raise ValueError( f"Can't remove element '{element}' from {self}: not in self._elements." - ) + ) from e def get_element(self, id: int) -> TE: return self.id_to_element[id] @@ -173,11 +173,11 @@ def _add_to_selected_elements_set(self, element: TE) -> None: def _remove_from_selected_elements_set(self, element: TE) -> None: try: self._selected_elements.remove(element) - except ValueError: + except ValueError as e: raise ValueError( f"Can't remove element '{element}' from selected objects of {self}: not" " in self._selected_elements." - ) + ) from e def get_selected_elements(self) -> list[TE]: return self._selected_elements diff --git a/tilia/ui/timelines/base/timeline.py b/tilia/ui/timelines/base/timeline.py index 0606fd2d5..8d011197f 100644 --- a/tilia/ui/timelines/base/timeline.py +++ b/tilia/ui/timelines/base/timeline.py @@ -12,8 +12,8 @@ Callable, ) -from PyQt6.QtCore import Qt, QPoint -from PyQt6.QtWidgets import QGraphicsItem +from PySide6.QtCore import Qt, QPoint +from PySide6.QtWidgets import QGraphicsItem from tilia.timelines.component_kinds import ComponentKind from .context_menus import TimelineUIContextMenu @@ -538,7 +538,7 @@ def delete_element(self, element: T): self.element_manager.delete_element(element) def validate_copy(self, elements: list[T]) -> None: - """Can be overwritten by subclsses""" + """Can be overwritten by subclasses""" def validate_paste( self, paste_data: dict, elements_to_receive_paste: list[T] diff --git a/tilia/ui/timelines/beat/dialogs.py b/tilia/ui/timelines/beat/dialogs.py index 06c9a422f..c6a28192e 100644 --- a/tilia/ui/timelines/beat/dialogs.py +++ b/tilia/ui/timelines/beat/dialogs.py @@ -21,7 +21,7 @@ def validate_result(): return True, list(map(int, result)) -def ask_beat_timeline_fill_method() -> ( - tuple[bool, None | tuple[BeatTimeline, BeatTimeline.FillMethod, float]] -): +def ask_beat_timeline_fill_method() -> tuple[ + bool, None | tuple[BeatTimeline, BeatTimeline.FillMethod, float] +]: return FillBeatTimeline.select() diff --git a/tilia/ui/timelines/beat/element.py b/tilia/ui/timelines/beat/element.py index 9a5e6b5ab..164d93213 100644 --- a/tilia/ui/timelines/beat/element.py +++ b/tilia/ui/timelines/beat/element.py @@ -1,9 +1,9 @@ from __future__ import annotations from typing import TYPE_CHECKING, Callable, Any -from PyQt6.QtCore import Qt, QLineF, QPointF -from PyQt6.QtGui import QPen, QColor, QFont -from PyQt6.QtWidgets import QGraphicsScene, QGraphicsLineItem, QGraphicsTextItem +from PySide6.QtCore import Qt, QLineF, QPointF +from PySide6.QtGui import QPen, QColor, QFont +from PySide6.QtWidgets import QGraphicsScene, QGraphicsLineItem, QGraphicsTextItem from tilia.requests import Post, post, Get, get from .context_menu import BeatContextMenu @@ -42,7 +42,8 @@ class BeatUI(TimelineUIElement): FIELD_NAMES_TO_ATTRIBUTES: dict[ str, str - ] = {} # only needed if attrs will be set by Inspect + ] = {} + # only needed if attrs will be set by Inspect DEFAULT_COPY_ATTRIBUTES = CopyAttributes( by_element_value=[], @@ -114,7 +115,7 @@ def seek_time(self): return self.time def child_items(self): - return self.body, self.label + return [self.body, self.label] def update_time(self): self.update_position() @@ -132,16 +133,16 @@ def update_label(self): self.label.set_position(self.x, self.label_y) def selection_triggers(self): - return self.body, self.label + return [self.body, self.label] def left_click_triggers(self): - return (self.body,) + return [self.body] def on_left_click(self, _) -> None: self.setup_drag() def double_left_click_triggers(self): - return self.body, self.label + return [self.body, self.label] def on_double_left_click(self, _) -> None: if self.drag_manager: @@ -149,9 +150,8 @@ def on_double_left_click(self, _) -> None: self.drag_manager = None post(Post.PLAYER_SEEK, self.seek_time) - @property def right_click_triggers(self): - return self.body, self.label + return [self.body, self.label] def setup_drag(self): self.drag_manager = DragManager( @@ -265,7 +265,7 @@ def set_position(self, x, y): def set_text(self, value: str): if not value: - # Settting plain text to empty string + # Setting plain text to empty string # keeps the graphics item interactable, # so we need to hide it, instead. self.setVisible(False) diff --git a/tilia/ui/timelines/beat/timeline.py b/tilia/ui/timelines/beat/timeline.py index cba78b600..c895e3186 100644 --- a/tilia/ui/timelines/beat/timeline.py +++ b/tilia/ui/timelines/beat/timeline.py @@ -116,7 +116,7 @@ def on_set_measure_number(self, elements: list[BeatUI] | None = None): Get.FROM_USER_INT, "Change measure number", "Insert measure number", - min=0, + minValue=0, ) if not accepted: return False @@ -143,7 +143,7 @@ def on_set_amount_in_measure(self, elements: list[BeatUI] | None = None): Get.FROM_USER_INT, "Change beats in measure", "Insert amount of beats in measure", - min=1, + minValue=1, ) if not accepted: return False @@ -275,7 +275,7 @@ def update_measure_numbers(self): # State is being restored and # beats in measure has not been # updated yet. This is a dangerous - # workaroung, as it might conceal + # workaround, as it might conceal # other exceptions. Let's fix this ASAP. continue diff --git a/tilia/ui/timelines/collection/collection.py b/tilia/ui/timelines/collection/collection.py index ded463510..9dfa0a9ac 100644 --- a/tilia/ui/timelines/collection/collection.py +++ b/tilia/ui/timelines/collection/collection.py @@ -5,9 +5,8 @@ from enum import Enum, auto from typing import Any, Optional, Callable, cast -from PyQt6.QtCore import Qt, QPoint -from PyQt6.QtWidgets import ( - QGraphicsView, +from PySide6.QtCore import Qt, QPoint +from PySide6.QtWidgets import ( QMainWindow, QGraphicsItem, QGraphicsScene, @@ -74,7 +73,7 @@ def __init__( kind: None for kind in TimelineKind if kind != TlKind.SLIDER_TIMELINE } - self._timeline_uis = set() + self._timeline_uis: set[TimelineUI] = set() self._select_order = [] self._timeline_uis_to_playback_line_ids = {} self.sb_items_to_selected_items = {} @@ -386,7 +385,7 @@ def _handle_media_not_loaded() -> bool: "Set duration", "Insert duration (s)", value=60, - min=1, + minValue=1, ) if not success: return False @@ -569,11 +568,11 @@ def _add_to_timeline_uis_set(self, timeline_ui: TimelineUI) -> None: def _remove_from_timeline_uis_set(self, timeline_ui: TimelineUI) -> None: try: self._timeline_uis.remove(timeline_ui) - except ValueError: + except ValueError as e: raise ValueError( f"Can't remove timeline ui '{timeline_ui}' from {self}: not in" " self.timeline_uis." - ) + ) from e def _add_to_timeline_ui_select_order(self, tl_ui: TimelineUI) -> None: self._select_order.insert(0, tl_ui) @@ -581,17 +580,17 @@ def _add_to_timeline_ui_select_order(self, tl_ui: TimelineUI) -> None: def _remove_from_timeline_ui_select_order(self, tl_ui: TimelineUI) -> None: try: self._select_order.remove(tl_ui) - except ValueError: + except ValueError as e: raise ValueError( f"Can't remove timeline ui '{tl_ui}' from select order: not in select" " order." - ) + ) from e def _send_to_top_of_select_order(self, tl_ui: TimelineUI): self._select_order.remove(tl_ui) self._select_order.insert(0, tl_ui) - def add_timeline_view_to_scene(self, view: QGraphicsView, ordinal: int) -> None: + def add_timeline_view_to_scene(self, view: TimelineView, ordinal: int) -> None: view.proxy = self.scene.addWidget(view) y = sum(tlui.get_data("height") for tlui in sorted(self)[: ordinal - 1]) view.move(0, y) @@ -646,7 +645,7 @@ def update_timeline_ui_ordinal(self): @staticmethod def update_timeline_times(tlui: TimelineUI): if tlui.TIMELINE_KIND == TlKind.SLIDER_TIMELINE: - tlui: SliderTimelineUI + tlui = cast(SliderTimelineUI, tlui) tlui.update_items_position() else: tlui.element_manager.update_time_on_elements() @@ -693,7 +692,7 @@ def _get_timeline_ui_by_view(self, view): def _on_timeline_ui_right_click( self, - view: QGraphicsView, + view: TimelineView, x: int, y: int, item: Optional[QGraphicsItem], @@ -704,8 +703,7 @@ def _on_timeline_ui_right_click( if not timeline_ui: raise ValueError( - f"Can't process left click: no timeline with view '{view}' on" - f" {self}" + f"Can't process left click: no timeline with view '{view}' on {self}" ) if ( @@ -719,7 +717,7 @@ def _on_timeline_ui_right_click( def _on_timeline_ui_left_click( self, - view: QGraphicsView, + view: TimelineView, x: int, y: int, item: Optional[QGraphicsItem], @@ -916,7 +914,10 @@ def on_hierarchy_merge_split(self, new_units: list, old_units: list): self._update_loop_elements() def on_harmony_timeline_components_deserialized(self, id): - self.get_timeline_ui(id).on_timeline_components_deserialized() # noqa + from ..harmony import HarmonyTimelineUI + + timeline_ui = cast(HarmonyTimelineUI, self.get_timeline_ui(id)) + timeline_ui.on_timeline_components_deserialized() def on_beat_timeline_components_deserialized(self, id: int): from tilia.ui.timelines.beat import BeatTimelineUI @@ -1122,8 +1123,8 @@ def filter_for_pasting(_) -> list[TimelineUI]: try: return selector_to_func[selector](get_by_kinds(kinds)) - except KeyError: - raise NotImplementedError(f"Can't select with {selector=}") + except KeyError as e: + raise NotImplementedError(f"Can't select with {selector=}") from e @command_callback def on_timeline_ordinal_permute(self, tlui1: TimelineUI, tlui2: TimelineUI): @@ -1131,14 +1132,12 @@ def on_timeline_ordinal_permute(self, tlui1: TimelineUI, tlui2: TimelineUI): @staticmethod @command_callback - def on_timeline_delete(timeline_ui: TimelineUI): - confirmed = get( + def on_timeline_delete(timeline_ui: TimelineUI, confirm: bool = True) -> bool: + if confirm and not get( Get.FROM_USER_YES_OR_NO, "Delete timeline", "Are you sure you want to delete the selected timeline? This can be undone later.", - ) - - if not confirmed: + ): return False get(Get.TIMELINE_COLLECTION).delete_timeline(timeline_ui.timeline) @@ -1195,7 +1194,7 @@ def on_timeline_set_height( "Change timeline height", "Insert new timeline height", value=timeline_ui.get_data("height"), - min=10, + minValue=10, ) if not accepted: return False diff --git a/tilia/ui/timelines/collection/import_.py b/tilia/ui/timelines/collection/import_.py index 06c1089b2..eac6aa614 100644 --- a/tilia/ui/timelines/collection/import_.py +++ b/tilia/ui/timelines/collection/import_.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING import tilia.errors -import tilia.parsers from tilia.requests import get, Get from tilia.timelines.timeline_kinds import TimelineKind as TlKind from tilia.ui.dialogs.by_time_or_by_measure import ByTimeOrByMeasure @@ -47,7 +46,7 @@ def _on_import_to_timeline( success, path = get( Get.FROM_USER_FILE_PATH, "Import components", - ["musicXML files (*.musicxml *.mxl, *.xml)"], + ["musicXML files (*.musicxml *.mxl *.xml)"], ) else: diff --git a/tilia/ui/timelines/collection/requests/args.py b/tilia/ui/timelines/collection/requests/args.py new file mode 100644 index 000000000..e69de29bb diff --git a/tilia/ui/timelines/collection/scene.py b/tilia/ui/timelines/collection/scene.py index 7d14d73fb..921cd8f8a 100644 --- a/tilia/ui/timelines/collection/scene.py +++ b/tilia/ui/timelines/collection/scene.py @@ -1,6 +1,6 @@ from __future__ import annotations -from PyQt6.QtWidgets import QGraphicsScene +from PySide6.QtWidgets import QGraphicsScene class TimelineUIsScene(QGraphicsScene): diff --git a/tilia/ui/timelines/collection/view.py b/tilia/ui/timelines/collection/view.py index d0d4d9de4..6e7387649 100644 --- a/tilia/ui/timelines/collection/view.py +++ b/tilia/ui/timelines/collection/view.py @@ -1,7 +1,7 @@ from __future__ import annotations -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QGraphicsView, QAbstractSlider +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QGraphicsView, QAbstractSlider from tilia.ui import commands from tilia.ui.smooth_scroll import setup_smooth, smooth diff --git a/tilia/ui/timelines/copy_paste.py b/tilia/ui/timelines/copy_paste.py index 48fc104bc..a25886d2d 100644 --- a/tilia/ui/timelines/copy_paste.py +++ b/tilia/ui/timelines/copy_paste.py @@ -8,10 +8,10 @@ def get_copy_data_from_elements( - elements: list[tuple[TimelineUIElement, CopyAttributes]] + elements: list[tuple[TimelineUIElement, CopyAttributes]], ) -> list[dict]: copy_data = [] - for element, kind, copy_attrs in elements: + for element, copy_attrs in elements: copy_data.append(get_copy_data_from_element(element, copy_attrs)) return copy_data diff --git a/tilia/ui/timelines/cursors.py b/tilia/ui/timelines/cursors.py index 95949187a..f66d417e1 100644 --- a/tilia/ui/timelines/cursors.py +++ b/tilia/ui/timelines/cursors.py @@ -1,4 +1,4 @@ -from PyQt6.QtGui import QGuiApplication +from PySide6.QtGui import QGuiApplication # noinspection PyUnresolvedReferences diff --git a/tilia/ui/timelines/harmony/elements/harmony.py b/tilia/ui/timelines/harmony/elements/harmony.py index ec5bf195d..f496c521d 100644 --- a/tilia/ui/timelines/harmony/elements/harmony.py +++ b/tilia/ui/timelines/harmony/elements/harmony.py @@ -1,9 +1,9 @@ from __future__ import annotations import music21 -from PyQt6.QtCore import QPointF -from PyQt6.QtGui import QFont, QColor -from PyQt6.QtWidgets import QGraphicsItem, QGraphicsTextItem +from PySide6.QtCore import QPointF +from PySide6.QtGui import QFont, QColor +from PySide6.QtWidgets import QGraphicsItem, QGraphicsTextItem from music21.roman import RomanNumeral from . import harmony_attrs diff --git a/tilia/ui/timelines/harmony/elements/mode.py b/tilia/ui/timelines/harmony/elements/mode.py index 05df9a375..89076aa4a 100644 --- a/tilia/ui/timelines/harmony/elements/mode.py +++ b/tilia/ui/timelines/harmony/elements/mode.py @@ -1,7 +1,7 @@ import music21 -from PyQt6.QtCore import QPointF -from PyQt6.QtGui import QFont, QColor -from PyQt6.QtWidgets import QGraphicsItem, QGraphicsTextItem +from PySide6.QtCore import QPointF +from PySide6.QtGui import QFont, QColor +from PySide6.QtWidgets import QGraphicsItem, QGraphicsTextItem from tilia.requests import get, Get, post, Post from tilia.ui.coords import time_x_converter diff --git a/tilia/ui/timelines/harmony/level_label.py b/tilia/ui/timelines/harmony/level_label.py index 0f87c6ba1..4506a8874 100644 --- a/tilia/ui/timelines/harmony/level_label.py +++ b/tilia/ui/timelines/harmony/level_label.py @@ -1,6 +1,6 @@ -from PyQt6.QtCore import QPointF -from PyQt6.QtGui import QFont, QColor -from PyQt6.QtWidgets import QGraphicsTextItem +from PySide6.QtCore import QPointF +from PySide6.QtGui import QFont, QColor +from PySide6.QtWidgets import QGraphicsTextItem class LevelLabel(QGraphicsTextItem): diff --git a/tilia/ui/timelines/hierarchy/element.py b/tilia/ui/timelines/hierarchy/element.py index c7f1f62e7..20f851e2e 100644 --- a/tilia/ui/timelines/hierarchy/element.py +++ b/tilia/ui/timelines/hierarchy/element.py @@ -3,9 +3,9 @@ from enum import Enum from typing import Literal -from PyQt6.QtCore import Qt, QRectF, QPointF -from PyQt6.QtGui import QColor, QPen, QFont, QFontMetrics, QPixmap -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Qt, QRectF, QPointF +from PySide6.QtGui import QColor, QPen, QFont, QFontMetrics, QPixmap +from PySide6.QtWidgets import ( QGraphicsPixmapItem, QGraphicsRectItem, QGraphicsTextItem, @@ -76,7 +76,7 @@ class HierarchyUI(TimelineUIElement): support_by_component_value=["start", "pre_start", "end", "level"], ) - NAME_WHEN_UNLABELED = "Unnamed" + NAME_WHEN_UNLABELLED = "Unnamed" FULL_NAME_SEPARATOR = "-" UPDATE_TRIGGERS = [ @@ -188,12 +188,12 @@ def get_cropped_label(self, start_x, end_x, label): @property def full_name(self) -> str: - partial_name = self.get_data("label") or self.NAME_WHEN_UNLABELED + partial_name = self.get_data("label") or self.NAME_WHEN_UNLABELLED next_parent = self.get_data("parent") while next_parent: - parent_name = next_parent.get_data("label") or self.NAME_WHEN_UNLABELED + parent_name = next_parent.get_data("label") or self.NAME_WHEN_UNLABELLED partial_name = parent_name + self.FULL_NAME_SEPARATOR + partial_name next_parent = next_parent.parent @@ -454,8 +454,8 @@ def extremity_to_handle( HierarchyUI.Extremity.PRE_START: self.pre_start_handle, HierarchyUI.Extremity.POST_END: self.post_end_handle, }[extremity] - except KeyError: - raise ValueError("Unrecognized extremity") + except KeyError as e: + raise ValueError("Unrecognized extremity") from e @staticmethod def frame_to_body_extremity( @@ -468,8 +468,8 @@ def frame_to_body_extremity( HierarchyUI.Extremity.PRE_START: HierarchyUI.Extremity.START, HierarchyUI.Extremity.POST_END: HierarchyUI.Extremity.END, }[extremity] - except KeyError: - raise ValueError("Unrecognized extremity") + except KeyError as e: + raise ValueError("Unrecognized extremity") from e def handle_to_extremity(self, handle: HierarchyBodyHandle | HierarchyFrameHandle): try: @@ -479,8 +479,8 @@ def handle_to_extremity(self, handle: HierarchyBodyHandle | HierarchyFrameHandle self.pre_start_handle: HierarchyUI.Extremity.PRE_START, self.post_end_handle: HierarchyUI.Extremity.POST_END, }[handle] - except KeyError: - raise ValueError(f"{handle} if not a handle of {self}") + except KeyError as e: + raise ValueError(f"{handle} if not a handle of {self}") from e @staticmethod def extremity_to_x(extremity: HierarchyUI.Extremity, start_x, end_x): @@ -501,7 +501,7 @@ def _setup_handle(self, extremity: HierarchyUI.Extremity): ) def selection_triggers(self): - return self.body, self.label, self.comments_icon + return [self.body, self.label, self.comments_icon] def left_click_triggers(self): triggers = [ @@ -531,7 +531,7 @@ def on_double_left_click(self, _) -> None: post(Post.PLAYER_SEEK, self.seek_time) def right_click_triggers(self): - return self.body, self.label, self.comments_icon + return [self.body, self.label, self.comments_icon] def on_select(self) -> None: self.body.on_select() @@ -554,7 +554,7 @@ def on_deselect(self) -> None: def selected_ascendants(self) -> list[HierarchyUI]: """Returns hierarchies in the same branch that - are both selected and higher-leveled than self""" + are both selected and higher-levelled than self""" uis_at_start = self.timeline_ui.get_elements_by_attr("start_x", self.start_x) selected_uis = self.timeline_ui.selected_elements @@ -566,7 +566,7 @@ def selected_ascendants(self) -> list[HierarchyUI]: def selected_descendants(self) -> list[HierarchyUI]: """Returns hierarchies in the same branch that are both - selected and lower-leveled than self""" + selected and lower-levelled than self""" uis_at_start = self.timeline_ui.get_elements_by_attr("start_x", self.start_x) selected_uis = self.timeline_ui.selected_elements diff --git a/tilia/ui/timelines/hierarchy/handles.py b/tilia/ui/timelines/hierarchy/handles.py index 033fe3c6f..36495f97a 100644 --- a/tilia/ui/timelines/hierarchy/handles.py +++ b/tilia/ui/timelines/hierarchy/handles.py @@ -1,8 +1,8 @@ from __future__ import annotations -from PyQt6.QtCore import Qt, QRectF, QPointF, QLineF -from PyQt6.QtGui import QColor, QPen -from PyQt6.QtWidgets import QGraphicsRectItem, QGraphicsLineItem, QGraphicsItemGroup +from PySide6.QtCore import Qt, QRectF, QPointF, QLineF +from PySide6.QtGui import QColor, QPen +from PySide6.QtWidgets import QGraphicsRectItem, QGraphicsLineItem, QGraphicsItemGroup from tilia.ui.timelines.cursors import CursorMixIn diff --git a/tilia/ui/timelines/hierarchy/key_press_manager.py b/tilia/ui/timelines/hierarchy/key_press_manager.py index f0f7a34b1..30e7c544d 100644 --- a/tilia/ui/timelines/hierarchy/key_press_manager.py +++ b/tilia/ui/timelines/hierarchy/key_press_manager.py @@ -41,10 +41,12 @@ def on_vertical_arrow_press(self, direction: str): def on_horizontal_arrow_press(self, side: str): def _get_next_element_in_same_level(elm): - is_later_at_same_level = ( - lambda h: h.tl_component.start > elm.tl_component.start - and h.tl_component.level == elm.tl_component.level - ) + def is_later_at_same_level(h): + return ( + h.tl_component.start > elm.tl_component.start + and h.tl_component.level == elm.tl_component.level + ) + later_elements = self.element_manager.get_elements_by_condition( is_later_at_same_level ) @@ -54,10 +56,12 @@ def _get_next_element_in_same_level(elm): return None def _get_previous_element_in_same_level(elm): - is_earlier_at_same_level = ( - lambda h: h.tl_component.start < elm.tl_component.start - and h.tl_component.level == elm.tl_component.level - ) + def is_earlier_at_same_level(h): + return ( + h.tl_component.start < elm.tl_component.start + and h.tl_component.level == elm.tl_component.level + ) + earlier_elements = self.element_manager.get_elements_by_condition( is_earlier_at_same_level ) diff --git a/tilia/ui/timelines/hierarchy/timeline.py b/tilia/ui/timelines/hierarchy/timeline.py index 7a9210b88..2cad39416 100644 --- a/tilia/ui/timelines/hierarchy/timeline.py +++ b/tilia/ui/timelines/hierarchy/timeline.py @@ -230,10 +230,12 @@ def paste_with_children_into_elements( self, elements: list[HierarchyUI], data: list[dict] ): def get_descendants(parent: HierarchyUI): - is_in_branch = ( - lambda e: e.tl_component.start >= parent.tl_component.start - and e.tl_component.end <= parent.tl_component.end - ) + def is_in_branch(e): + return ( + e.tl_component.start >= parent.tl_component.start + and e.tl_component.end <= parent.tl_component.end + ) + elements_in_branch = self.element_manager.get_elements_by_condition( is_in_branch ) diff --git a/tilia/ui/timelines/marker/element.py b/tilia/ui/timelines/marker/element.py index 6871c7856..578e7be1e 100644 --- a/tilia/ui/timelines/marker/element.py +++ b/tilia/ui/timelines/marker/element.py @@ -1,8 +1,8 @@ from __future__ import annotations -from PyQt6.QtCore import QPointF, Qt -from PyQt6.QtGui import QPolygonF, QPen, QColor, QFont -from PyQt6.QtWidgets import ( +from PySide6.QtCore import QPointF, Qt +from PySide6.QtGui import QPolygonF, QPen, QColor, QFont +from PySide6.QtWidgets import ( QGraphicsItem, QGraphicsPolygonItem, QGraphicsTextItem, diff --git a/tilia/ui/timelines/pdf/element.py b/tilia/ui/timelines/pdf/element.py index 0a1c8c739..759d5e503 100644 --- a/tilia/ui/timelines/pdf/element.py +++ b/tilia/ui/timelines/pdf/element.py @@ -1,14 +1,14 @@ from __future__ import annotations -from PyQt6.QtCore import QPointF, Qt -from PyQt6.QtGui import ( +from PySide6.QtCore import QPointF, Qt +from PySide6.QtGui import ( QPen, QColor, QFont, QPixmap, QFontMetrics, ) -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QGraphicsItem, QGraphicsTextItem, QGraphicsPixmapItem, diff --git a/tilia/ui/timelines/pdf/timeline.py b/tilia/ui/timelines/pdf/timeline.py index 896f24e9f..d9245cf0e 100644 --- a/tilia/ui/timelines/pdf/timeline.py +++ b/tilia/ui/timelines/pdf/timeline.py @@ -3,9 +3,9 @@ import copy from typing import TYPE_CHECKING -from PyQt6.QtCore import QPointF -from PyQt6.QtPdf import QPdfDocument -from PyQt6.QtPdfWidgets import QPdfView +from PySide6.QtCore import QPointF +from PySide6.QtPdf import QPdfDocument +from PySide6.QtPdfWidgets import QPdfView import tilia.errors from tilia.media.player.base import MediaTimeChangeReason @@ -99,7 +99,7 @@ def page_total(self): # prevents it from capping the # number at a value that might be lower than # the page total in a PDF loaded in the future. - # Can't use math.inf because PyQt requires an int. + # Can't use math.inf because pyside requires an int. return 99999999 return self.timeline.get_data("page_total") diff --git a/tilia/ui/timelines/scene.py b/tilia/ui/timelines/scene.py index b80c1bca5..7601dbeab 100644 --- a/tilia/ui/timelines/scene.py +++ b/tilia/ui/timelines/scene.py @@ -1,6 +1,6 @@ -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QGraphicsScene, QGraphicsRectItem -from PyQt6.QtGui import QColor, QPen, QBrush, QFont, QFontMetrics +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QGraphicsScene, QGraphicsRectItem +from PySide6.QtGui import QColor, QPen, QBrush, QFont, QFontMetrics from tilia.settings import settings from tilia.requests import Get, get, listen, Post diff --git a/tilia/ui/timelines/score/element/barline.py b/tilia/ui/timelines/score/element/barline.py index a3bdcb2cd..f277087ee 100644 --- a/tilia/ui/timelines/score/element/barline.py +++ b/tilia/ui/timelines/score/element/barline.py @@ -1,5 +1,5 @@ -from PyQt6.QtGui import QPen, QColor -from PyQt6.QtWidgets import QGraphicsLineItem +from PySide6.QtGui import QPen, QColor +from PySide6.QtWidgets import QGraphicsLineItem from tilia.ui.coords import time_x_converter from tilia.ui.timelines.base.element import TimelineUIElement @@ -12,6 +12,7 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.body = None + @property def x(self): return time_x_converter.get_x_by_time(self.get_data("time")) @@ -23,7 +24,7 @@ def child_items(self): def get_body_args(self): return ( - self.x(), + self.x, self.timeline_ui.staff_y_cache.values(), ) @@ -41,6 +42,12 @@ def on_components_deserialized(self): def selection_triggers(self): return [] + def on_deselect(self): + return + + def on_select(self): + return + class BarLineBody: def __init__(self, x, ys: list[tuple[float, float]]): @@ -56,5 +63,5 @@ def _setup_lines(self, x: float, ys: list[tuple[float, float]]): self.set_position(x, ys) def set_position(self, x: float, ys: list[tuple[float, float]]): - for line, (y0, y1) in zip(self.lines, ys): + for line, (y0, y1) in zip(self.lines, ys, strict=True): line.setLine(x, y0, x, y1) diff --git a/tilia/ui/timelines/score/element/clef.py b/tilia/ui/timelines/score/element/clef.py index 377350d44..f671df1f8 100644 --- a/tilia/ui/timelines/score/element/clef.py +++ b/tilia/ui/timelines/score/element/clef.py @@ -1,8 +1,8 @@ from pathlib import Path -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QPixmap -from PyQt6.QtWidgets import QGraphicsPixmapItem +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QGraphicsPixmapItem from tilia.dirs import IMG_DIR from tilia.timelines.score.components import Clef @@ -61,6 +61,12 @@ def selection_triggers(self): def shorthand(self) -> Clef.Shorthand | None: return self.tl_component.shorthand() + def on_deselect(self): + return + + def on_select(self): + return + class ClefBody(QGraphicsPixmapItem): def __init__(self, x: float, y: float, height: float, path: Path): diff --git a/tilia/ui/timelines/score/element/key_signature.py b/tilia/ui/timelines/score/element/key_signature.py index 2d7577aef..ff6520d6a 100644 --- a/tilia/ui/timelines/score/element/key_signature.py +++ b/tilia/ui/timelines/score/element/key_signature.py @@ -1,8 +1,8 @@ from __future__ import annotations -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QPixmap -from PyQt6.QtWidgets import QGraphicsPixmapItem +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QGraphicsPixmapItem from tilia.dirs import IMG_DIR from tilia.timelines.score.components import Clef @@ -19,7 +19,7 @@ def __init__(self, *args, **kwargs): @staticmethod def _clef_shorthand_to_icon_path_string(shorthand: Clef.Shorthand | None) -> str: if not shorthand: - # Key signature not implmemented + # Key signature not implemented # for custom clefs. Using "treble" # just to prevent a crash return "treble" @@ -84,6 +84,12 @@ def on_components_deserialized(self): self.scene.removeItem(self.body) self._setup_body() + def on_deselect(self): + return + + def on_select(self): + return + class KeySignatureBody(QGraphicsPixmapItem): def __init__(self, x: float, y: float, height: int, path: str): diff --git a/tilia/ui/timelines/score/element/note/accidental.py b/tilia/ui/timelines/score/element/note/accidental.py index e6b9c4fb5..0f79297a4 100644 --- a/tilia/ui/timelines/score/element/note/accidental.py +++ b/tilia/ui/timelines/score/element/note/accidental.py @@ -1,8 +1,8 @@ from pathlib import Path -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QPixmap -from PyQt6.QtWidgets import QGraphicsPixmapItem +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QGraphicsPixmapItem class NoteAccidental(QGraphicsPixmapItem): diff --git a/tilia/ui/timelines/score/element/note/body.py b/tilia/ui/timelines/score/element/note/body.py index 056944f29..03c7da390 100644 --- a/tilia/ui/timelines/score/element/note/body.py +++ b/tilia/ui/timelines/score/element/note/body.py @@ -1,8 +1,8 @@ from __future__ import annotations -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QColor, QPen -from PyQt6.QtWidgets import QGraphicsRectItem +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QPen +from PySide6.QtWidgets import QGraphicsRectItem from tilia.timelines.score.components import Note from tilia.ui.color import get_tinted_color, get_untinted_color diff --git a/tilia/ui/timelines/score/element/note/ledger_line.py b/tilia/ui/timelines/score/element/note/ledger_line.py index cb093d7ad..4b60a594f 100644 --- a/tilia/ui/timelines/score/element/note/ledger_line.py +++ b/tilia/ui/timelines/score/element/note/ledger_line.py @@ -1,8 +1,8 @@ from enum import Enum -from PyQt6.QtCore import QLineF -from PyQt6.QtGui import QPen, QColor -from PyQt6.QtWidgets import QGraphicsLineItem +from PySide6.QtCore import QLineF +from PySide6.QtGui import QPen, QColor +from PySide6.QtWidgets import QGraphicsLineItem class NoteLedgerLines: diff --git a/tilia/ui/timelines/score/element/note/ui.py b/tilia/ui/timelines/score/element/note/ui.py index 3b9f624d6..684dc21cc 100644 --- a/tilia/ui/timelines/score/element/note/ui.py +++ b/tilia/ui/timelines/score/element/note/ui.py @@ -1,7 +1,7 @@ import math from pathlib import Path -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QGraphicsItem, ) @@ -242,20 +242,20 @@ def get_accidental_height(accidental: int, scale_factor: float) -> int: def get_accidental_scale_factor(self): """ Scales accidental according to amw = average measure width. - If amw < visibility_treshold, returns 0, indicating accidentals should be hidden. - If visibility_treshold < amw < max_size_treshold, scales proportionally with min_scale as a minimum. - If amw > max_size_treshold, returns 1, indicating accidentals should be fully visible. + If amw < visibility_threshold, returns 0, indicating accidentals should be hidden. + If visibility_threshold < amw < max_size_threshold, scales proportionally with min_scale as a minimum. + If amw > max_size_threshold, returns 1, indicating accidentals should be fully visible. """ - visibility_treshold = 30 - max_size_treshold = 180 + visibility_threshold = 30 + max_size_threshold = 180 min_scale = 0.5 average_measure_width = self.timeline_ui.average_measure_width() if not average_measure_width: return 1 - if average_measure_width < visibility_treshold: + if average_measure_width < visibility_threshold: return 0 return min( - 1, min_scale + (average_measure_width / max_size_treshold * min_scale) + 1, min_scale + (average_measure_width / max_size_threshold * min_scale) ) def update_color(self): diff --git a/tilia/ui/timelines/score/element/staff.py b/tilia/ui/timelines/score/element/staff.py index 856500456..05d31bcf5 100644 --- a/tilia/ui/timelines/score/element/staff.py +++ b/tilia/ui/timelines/score/element/staff.py @@ -1,6 +1,6 @@ -from PyQt6.QtCore import QLineF -from PyQt6.QtGui import QColor, QPen -from PyQt6.QtWidgets import QGraphicsLineItem +from PySide6.QtCore import QLineF +from PySide6.QtGui import QColor, QPen +from PySide6.QtWidgets import QGraphicsLineItem from tilia.requests import get, Get from tilia.timelines.component_kinds import ComponentKind @@ -51,6 +51,12 @@ def child_items(self): def selection_triggers(self): return [] + def on_deselect(self): + return + + def on_select(self): + return + class StaffLines: COLOR = "gray" diff --git a/tilia/ui/timelines/score/element/time_signature.py b/tilia/ui/timelines/score/element/time_signature.py index 7a5c187fc..0443f6fa5 100644 --- a/tilia/ui/timelines/score/element/time_signature.py +++ b/tilia/ui/timelines/score/element/time_signature.py @@ -1,6 +1,6 @@ -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QPixmap -from PyQt6.QtWidgets import QGraphicsItem, QGraphicsPixmapItem +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QGraphicsItem, QGraphicsPixmapItem from tilia.ui.coords import time_x_converter from tilia.ui.timelines.score.element.with_collision import ( @@ -68,6 +68,12 @@ def on_components_deserialized(self): def selection_triggers(self): return [] + def on_deselect(self): + return + + def on_select(self): + return + class TimeSignatureBody(QGraphicsItem): def __init__( @@ -96,7 +102,7 @@ def get_scaled_pixmap(self, digit: int | str, height: int): def set_numerator_items(self, numerator: int, height: int): self.numerator_items = [] for i, digit in enumerate(str(numerator)): - item = QGraphicsPixmapItem(self.get_scaled_pixmap(digit, height), self) + item = NumberPixmap(self.get_scaled_pixmap(digit, height), self) item.digit = int(digit) item.setPos(i * item.pixmap().width(), 0) self.numerator_items.append(item) @@ -104,7 +110,7 @@ def set_numerator_items(self, numerator: int, height: int): def set_denominator_items(self, denominator: int, height: int): self.denominator_items = [] for i, digit in enumerate(str(denominator)): - item = QGraphicsPixmapItem(self.get_scaled_pixmap(digit, height), self) + item = NumberPixmap(self.get_scaled_pixmap(digit, height), self) item.digit = int(digit) item.setPos(i * item.pixmap().width(), item.pixmap().height()) self.denominator_items.append(item) @@ -145,5 +151,6 @@ def boundingRect(self): bounding_rect = bounding_rect.united(item.boundingRect()) return bounding_rect - def paint(self, painter, option, widget): - ... + +class NumberPixmap(QGraphicsPixmapItem): + digit = 0 diff --git a/tilia/ui/timelines/score/timeline.py b/tilia/ui/timelines/score/timeline.py index bf0151e5a..301fc2943 100644 --- a/tilia/ui/timelines/score/timeline.py +++ b/tilia/ui/timelines/score/timeline.py @@ -3,9 +3,9 @@ import math from typing import Callable, Any, Iterable -from PyQt6.QtCore import Qt, QRectF, QPointF -from PyQt6.QtGui import QPixmap, QColor -from PyQt6.QtWidgets import QGraphicsRectItem +from PySide6.QtCore import Qt, QRectF, QPointF +from PySide6.QtGui import QPixmap, QColor +from PySide6.QtWidgets import QGraphicsRectItem from tilia.dirs import IMG_DIR import tilia.errors @@ -448,8 +448,8 @@ def _validate_staff_numbers(self) -> bool: def average_measure_width(self) -> float: if self._measure_count == 0: return 0 - x0 = self.first_bar_line.x() - x1 = self.last_bar_line.x() + x0 = self.first_bar_line.x + x1 = self.last_bar_line.x return (x1 - x0) / self._measure_count def on_audio_time_change(self, time: float, _) -> None: diff --git a/tilia/ui/timelines/selection_box.py b/tilia/ui/timelines/selection_box.py index 296a2d960..8549fbd17 100644 --- a/tilia/ui/timelines/selection_box.py +++ b/tilia/ui/timelines/selection_box.py @@ -1,6 +1,6 @@ -from PyQt6.QtCore import QRectF, QPointF, Qt -from PyQt6.QtGui import QColor, QPen -from PyQt6.QtWidgets import QGraphicsRectItem +from PySide6.QtCore import QRectF, QPointF, Qt +from PySide6.QtGui import QColor, QPen +from PySide6.QtWidgets import QGraphicsRectItem from tilia.requests import Post, stop_listening_to_all, post diff --git a/tilia/ui/timelines/slider/timeline.py b/tilia/ui/timelines/slider/timeline.py index 19ef75802..65afbebcc 100644 --- a/tilia/ui/timelines/slider/timeline.py +++ b/tilia/ui/timelines/slider/timeline.py @@ -1,9 +1,9 @@ from __future__ import annotations from typing import TYPE_CHECKING -from PyQt6.QtCore import QRectF, QLineF, Qt -from PyQt6.QtGui import QPen, QColor, QBrush -from PyQt6.QtWidgets import ( +from PySide6.QtCore import QRectF, QLineF, Qt +from PySide6.QtGui import QPen, QColor, QBrush +from PySide6.QtWidgets import ( QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsDropShadowEffect, diff --git a/tilia/ui/timelines/toolbar.py b/tilia/ui/timelines/toolbar.py index 00232bb87..e92efd09a 100644 --- a/tilia/ui/timelines/toolbar.py +++ b/tilia/ui/timelines/toolbar.py @@ -1,5 +1,5 @@ -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QToolBar +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QToolBar from tilia.ui import commands diff --git a/tilia/ui/timelines/view.py b/tilia/ui/timelines/view.py index 9ad1581f8..a0393b0e6 100644 --- a/tilia/ui/timelines/view.py +++ b/tilia/ui/timelines/view.py @@ -1,14 +1,20 @@ from typing import Optional -from PyQt6.QtCore import Qt -from PyQt6.QtGui import ( +from PySide6.QtCore import Qt +from PySide6.QtGui import ( QPainter, QMouseEvent, QGuiApplication, QColor, QBrush, ) -from PyQt6.QtWidgets import QGraphicsView, QGraphicsScene, QSizePolicy, QFrame +from PySide6.QtWidgets import ( + QFrame, + QGraphicsProxyWidget, + QGraphicsScene, + QGraphicsView, + QSizePolicy, +) from tilia.settings import settings from tilia.requests import post, Post, Get, get, listen @@ -39,7 +45,7 @@ def __init__(self, scene: QGraphicsScene): ) self.dragging = False - self.proxy = None # will be set by TimelineUIs + self.proxy = QGraphicsProxyWidget() # will be set by TimelineUIs def on_settings_updated(self, updated_settings): if "general" in updated_settings: diff --git a/tilia/ui/windows/about.py b/tilia/ui/windows/about.py index f715874f6..dd4e28aeb 100644 --- a/tilia/ui/windows/about.py +++ b/tilia/ui/windows/about.py @@ -1,6 +1,5 @@ -from pathlib import Path -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( QDialog, QFrame, QLabel, @@ -8,7 +7,7 @@ QScrollArea, QVBoxLayout, ) -from re import split, sub +from re import sub import tilia.constants @@ -84,14 +83,9 @@ def __init__(self, parent): notice = QLabel(tilia.constants.NOTICE) notice.setWordWrap(True) - with open( - Path(__file__).parent.parent.parent.parent / "LICENSE", encoding="utf-8" - ) as license_file: - license_text = split( - "How to Apply These Terms to Your New Programs", license_file.read() - )[0] - - formatted_text = f'
{license_text}
' + formatted_text = ( + f'
{tilia.constants.LICENSE}
' + ) text_with_links = sub( "]+)>", lambda y: f'{y[0][1:-1]}', diff --git a/tilia/ui/windows/beat_pattern.py b/tilia/ui/windows/beat_pattern.py index 1ed9566f8..edf495b8c 100644 --- a/tilia/ui/windows/beat_pattern.py +++ b/tilia/ui/windows/beat_pattern.py @@ -1,4 +1,4 @@ -from PyQt6.QtWidgets import QInputDialog +from PySide6.QtWidgets import QInputDialog import tilia.ui.strings diff --git a/tilia/ui/windows/fill_beat_timeline.py b/tilia/ui/windows/fill_beat_timeline.py index 330f63a7b..65511eae9 100644 --- a/tilia/ui/windows/fill_beat_timeline.py +++ b/tilia/ui/windows/fill_beat_timeline.py @@ -1,6 +1,6 @@ import sys -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import ( +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( QButtonGroup, QGridLayout, QRadioButton, diff --git a/tilia/ui/windows/inspect.py b/tilia/ui/windows/inspect.py index 0ba467f31..4cec20757 100644 --- a/tilia/ui/windows/inspect.py +++ b/tilia/ui/windows/inspect.py @@ -5,7 +5,7 @@ from typing import Any, Callable, cast -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QDockWidget, QFormLayout, QLabel, @@ -19,7 +19,7 @@ QFrame, ) -from PyQt6.QtCore import Qt, QKeyCombination +from PySide6.QtCore import Qt, QKeyCombination from tilia.requests import Post, listen, stop_listening_to_all, post, Get, get from tilia.utils import get_tilia_class_string @@ -49,6 +49,7 @@ class Inspect(QDockWidget): def __init__(self, main_window) -> None: super().__init__(main_window) + self.setObjectName("inspector") self.setWindowTitle("Inspector") self.setMinimumWidth(250) self.setFeatures( diff --git a/tilia/ui/windows/manage_timelines.py b/tilia/ui/windows/manage_timelines.py index 321befe52..3840e3608 100644 --- a/tilia/ui/windows/manage_timelines.py +++ b/tilia/ui/windows/manage_timelines.py @@ -1,9 +1,9 @@ from typing import Optional import typing -from PyQt6 import QtGui -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import ( +from PySide6 import QtGui +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( QDialog, QHBoxLayout, QVBoxLayout, diff --git a/tilia/ui/windows/metadata.py b/tilia/ui/windows/metadata.py index d721c7b44..7eff335e3 100644 --- a/tilia/ui/windows/metadata.py +++ b/tilia/ui/windows/metadata.py @@ -1,6 +1,6 @@ import functools -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QDialog, QFormLayout, QLabel, diff --git a/tilia/ui/windows/metadata_edit_fields.py b/tilia/ui/windows/metadata_edit_fields.py index a4df3a08a..9159c1b03 100644 --- a/tilia/ui/windows/metadata_edit_fields.py +++ b/tilia/ui/windows/metadata_edit_fields.py @@ -1,6 +1,6 @@ import functools -from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QDialogButtonBox +from PySide6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QDialogButtonBox from tilia.requests import Get, get diff --git a/tilia/ui/windows/metadata_edit_notes.py b/tilia/ui/windows/metadata_edit_notes.py index b10d590a0..d67764bb0 100644 --- a/tilia/ui/windows/metadata_edit_notes.py +++ b/tilia/ui/windows/metadata_edit_notes.py @@ -1,4 +1,4 @@ -from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QDialogButtonBox +from PySide6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QDialogButtonBox from tilia.requests import Get, get diff --git a/tilia/ui/windows/settings.py b/tilia/ui/windows/settings.py index 368729070..afb0d81e9 100644 --- a/tilia/ui/windows/settings.py +++ b/tilia/ui/windows/settings.py @@ -1,4 +1,4 @@ -from PyQt6.QtWidgets import ( +from PySide6.QtWidgets import ( QCheckBox, QColorDialog, QComboBox, @@ -15,7 +15,7 @@ QVBoxLayout, QWidget, ) -from PyQt6.QtGui import QColor +from PySide6.QtGui import QColor from tilia.settings import settings from tilia.requests import post, Post @@ -195,7 +195,7 @@ def pretty_label(input_string: str): ) -def select_color_button(value, text=None): +def select_color_button(value, text=""): def select_color(old_color): new_color = QColorDialog.getColor( QColor(old_color), @@ -212,7 +212,7 @@ def set_color(color): ) def get_value(): - return button.styleSheet().lstrip("background-color: ").split(";")[0] + return button.styleSheet().replace("background-color: ", "").split(";")[0] button = QPushButton() button.setText(text) @@ -231,7 +231,7 @@ def combobox(options: list, current_value: str): return combobox -def get_widget_for_value(value, text=None) -> QWidget: +def get_widget_for_value(value, text="") -> QWidget: match value: case bool(): checkbox = QCheckBox() @@ -284,7 +284,15 @@ def get_widget_for_value(value, text=None) -> QWidget: raise NotImplementedError -def get_value_for_widget(widget: QWidget): +def get_value_for_widget( + widget: QSpinBox + | QTextEdit + | QWidget + | QCheckBox + | QPushButton + | QComboBox + | QLineEdit, +): match widget.objectName(): case "int": return widget.value() @@ -304,7 +312,7 @@ def get_value_for_widget(widget: QWidget): return widget.isChecked() case "color": - return widget.styleSheet().lstrip("background-color: ").split(";")[0] + return widget.styleSheet().replace("background-color: ", "").split(";")[0] case "combobox": text = widget.currentText().lower() diff --git a/tilia/ui/windows/svg_viewer.py b/tilia/ui/windows/svg_viewer.py index f08f01f16..594a673c3 100644 --- a/tilia/ui/windows/svg_viewer.py +++ b/tilia/ui/windows/svg_viewer.py @@ -6,14 +6,14 @@ from bisect import bisect from typing import Callable -from PyQt6.QtCore import ( +from PySide6.QtCore import ( Qt, QKeyCombination, QPointF, ) -from PyQt6.QtGui import QFont -from PyQt6.QtSvgWidgets import QGraphicsSvgItem -from PyQt6.QtWidgets import ( +from PySide6.QtGui import QFont +from PySide6.QtSvgWidgets import QGraphicsSvgItem +from PySide6.QtWidgets import ( QFrame, QGraphicsView, QGraphicsItem, @@ -24,7 +24,7 @@ QVBoxLayout, QWidget, ) -from PyQt6.QtSvg import QSvgRenderer +from PySide6.QtSvg import QSvgRenderer from tilia.ui import commands from tilia.ui.commands import get_qaction @@ -408,9 +408,8 @@ def annotation_font_inc(self) -> None: def _get_time_from_scene_x(self, xs: dict[int, float]) -> dict[int, list[float]]: output = {} beat_pos = {} - beats, x_pos = list(self.beat_x_position.keys()), list( - self.beat_x_position.values() - ) + beats = list(self.beat_x_position.keys()) + x_pos = list(self.beat_x_position.values()) for key, x in xs.items(): x = round(x, 3) if x in x_pos: @@ -476,9 +475,8 @@ def _get_scene_x_from_time(self, time: float) -> float: beat = beat_tl.get_metric_fraction_by_time(time) if x := self.beat_x_position.get(beat): return x - beats, x_pos = list(self.beat_x_position.keys()), list( - self.beat_x_position.values() - ) + beats = list(self.beat_x_position.keys()) + x_pos = list(self.beat_x_position.values()) idx = bisect(beats, beat) if idx == 0: return x_pos[0] diff --git a/tilia/ui/windows/view_window.py b/tilia/ui/windows/view_window.py index 1b7da4458..68268a349 100644 --- a/tilia/ui/windows/view_window.py +++ b/tilia/ui/windows/view_window.py @@ -1,7 +1,7 @@ from typing import TypeVar, Generic -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QDockWidget, QDialog, QWidget +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QDockWidget, QDialog, QWidget from tilia.requests import get, Get, post, Post, listen from tilia.ui.enums import WindowState diff --git a/tilia/utils.py b/tilia/utils.py index 0753f03e2..5b0766d1d 100644 --- a/tilia/utils.py +++ b/tilia/utils.py @@ -21,3 +21,26 @@ def open_with_os(path: Path) -> None: subprocess.Popen(["open", path.resolve()], shell=True) else: raise OSError(f"Unsupported platform: {sys.platform}") + + +def load_dotenv() -> None: + root = Path(__file__).parents[1] + dotenv_paths = [root / fn for fn in os.listdir(root) if fn.endswith(".env")] + + if dotenv_paths: + import dotenv + + for p in dotenv_paths: + with open(p) as f: + dotenv.load_dotenv(stream=f) + + else: # setup some basic values + os.environ["LOG_REQUESTS"] = "1" + os.environ[ + "EXCLUDE_FROM_LOG" + ] = "TIMELINE_VIEW_LEFT_BUTTON_DRAG;PLAYER_CURRENT_TIME_CHANGED;APP_RECORD_STATE" + if not os.environ.get("ENVIRONMENT"): + os.environ["ENVIRONMENT"] = "dev" + + +load_dotenv()