From 2f2f297f1d7d6a0bb70d2262c683a40c7030829b Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 18 Mar 2024 11:53:59 -0400 Subject: [PATCH 001/226] build: Initial Commit --- .cruft.json | 21 ++++ .github/ISSUE_TEMPLATE.md | 15 +++ .github/TEST_FAIL_TEMPLATE.md | 12 +++ .github/dependabot.yml | 10 ++ .github/workflows/ci.yml | 100 +++++++++++++++++++ .github_changelog_generator | 6 ++ .pre-commit-config.yaml | 38 ++++++++ LICENSE | 2 +- README.md | 54 ++--------- pyproject.toml | 161 +++++++++++++++++++++++++++++++ src/micromanager_gui/__init__.py | 10 ++ src/micromanager_gui/py.typed | 0 tests/test_micromanager_gui.py | 2 + 13 files changed, 383 insertions(+), 48 deletions(-) create mode 100644 .cruft.json create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/TEST_FAIL_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github_changelog_generator create mode 100644 .pre-commit-config.yaml create mode 100644 pyproject.toml create mode 100644 src/micromanager_gui/__init__.py create mode 100644 src/micromanager_gui/py.typed create mode 100644 tests/test_micromanager_gui.py diff --git a/.cruft.json b/.cruft.json new file mode 100644 index 00000000..d0b23875 --- /dev/null +++ b/.cruft.json @@ -0,0 +1,21 @@ +{ + "template": "https://github.com/fdrgsp/pyrepo-cookiecutter", + "commit": "b88c1cdc74cacc806c32985fe8692110857c0cba", + "checkout": null, + "context": { + "cookiecutter": { + "full_name": "Federico Gasparoli", + "email": "federico.gasparoli@gmail.com", + "github_username": "fdrgsp", + "project_name": "micromanager-gui", + "project_slug": "micromanager_gui", + "project_short_description": "A Micro-Manager GUI based on pymmcore-widgets and pymmcore-plus.", + "pypi_username": "fdrgsp", + "_copy_without_render": [ + ".github/workflows/*" + ], + "_template": "https://github.com/fdrgsp/pyrepo-cookiecutter" + } + }, + "directory": null +} diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000..df562e54 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,15 @@ +* micromanager-gui version: +* Python version: +* Operating System: + +### Description + +Describe what you were trying to get done. +Tell us what happened, what went wrong, and what you expected to happen. + +### What I Did + +``` +Paste the command(s) you ran and the output. +If there was a crash, please include the traceback here. +``` diff --git a/.github/TEST_FAIL_TEMPLATE.md b/.github/TEST_FAIL_TEMPLATE.md new file mode 100644 index 00000000..35129728 --- /dev/null +++ b/.github/TEST_FAIL_TEMPLATE.md @@ -0,0 +1,12 @@ +--- +title: "{{ env.TITLE }}" +labels: [bug] +--- +The {{ workflow }} workflow failed on {{ date | date("YYYY-MM-DD HH:mm") }} UTC + +The most recent failing test was on {{ env.PLATFORM }} py{{ env.PYTHON }} +with commit: {{ sha }} + +Full run: https://github.com/{{ repo }}/actions/runs/{{ env.RUN_ID }} + +(This post will be updated if another test fails, as long as this issue remains open.) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..96505a93 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "ci(dependabot):" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..608ef379 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,100 @@ +name: CI + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + workflow_dispatch: + schedule: + - cron: "0 0 * * 0" # every week (for --pre release tests) + +jobs: + check-manifest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: pipx run check-manifest + + test: + name: ${{ matrix.platform }} (${{ matrix.python-version }}) + runs-on: ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + python-version: ['3.8', '3.9', '3.10', '3.11'] + platform: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache-dependency-path: "pyproject.toml" + cache: "pip" + + # if running a cron job, we add the --pre flag to test against pre-releases + - name: Install dependencies + run: | + python -m pip install -U pip + python -m pip install .[test] ${{ github.event_name == 'schedule' && '--pre' || '' }} + + - name: Test + run: pytest --color=yes --cov --cov-report=xml --cov-report=term-missing + + # If something goes wrong, we can open an issue in the repo + - name: Report --pre Failures + if: failure() && github.event_name == 'schedule' + uses: JasonEtco/create-an-issue@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLATFORM: ${{ matrix.platform }} + PYTHON: ${{ matrix.python-version }} + RUN_ID: ${{ github.run_id }} + TITLE: '[test-bot] pip install --pre is failing' + with: + filename: .github/TEST_FAIL_TEMPLATE.md + update_existing: true + + - name: Coverage + uses: codecov/codecov-action@v3 + + deploy: + name: Deploy + needs: test + if: success() && startsWith(github.ref, 'refs/tags/') && github.event_name != 'schedule' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + + - name: install + run: | + pip install -U pip build twine + python -m build + twine check dist/* + + - name: Build and publish + run: twine upload dist/* + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} + + - uses: softprops/action-gh-release@v1 + with: + generate_release_notes: true diff --git a/.github_changelog_generator b/.github_changelog_generator new file mode 100644 index 00000000..d0bd3917 --- /dev/null +++ b/.github_changelog_generator @@ -0,0 +1,6 @@ +user=fdrgsp +project=micromanager-gui +issues=false +exclude-labels=duplicate,question,invalid,wontfix,hide +add-sections={"tests":{"prefix":"**Tests & CI:**","labels":["tests"]}, "documentation":{"prefix":"**Documentation:**", "labels":["documentation"]}} +exclude-tags-regex=.*rc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..f840a42b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +ci: + autoupdate_schedule: monthly + autofix_commit_msg: "style(pre-commit.ci): auto fixes [...]" + autoupdate_commit_msg: "ci(pre-commit.ci): autoupdate" + +default_install_hook_types: [pre-commit, commit-msg] + +repos: + # - repo: https://github.com/compilerla/conventional-pre-commit + # rev: v2.1.1 + # hooks: + # - id: conventional-pre-commit + # stages: [commit-msg] + + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.0.252 + hooks: + - id: ruff + args: [--fix] + + - repo: https://github.com/psf/black + rev: 23.1.0 + hooks: + - id: black + + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.12.1 + hooks: + - id: validate-pyproject + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.0.1 + hooks: + - id: mypy + files: "^src/" + # # you have to add the things you want to type check against here + # additional_dependencies: + # - numpy diff --git a/LICENSE b/LICENSE index 4dcc2ec7..62d3dad2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2024, Talley Lambert +Copyright (c) 2024, pymmcore-plus contributors Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index 6914a1c5..19609ca4 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,9 @@ -# pymmcore-gui - -This is a stub repo for discussing a unified effort towards a GUI application for [`pymmcore-plus`](https://github.com/pymmcore-plus/pymmcore-plus) & [`pymmcore-widgets`](https://github.com/pymmcore-plus/pymmcore-widgets) - -## Goals (and non-goals) of unification: - -**Goals** - -- Provide a napari-independent GUI for controlling micro-manager via pymmcore-plus (i.e. pure python micro-manager control). We'd like to have a primary application that we can point interested parties to (rather than having to describe all the related efforts and explain how to compose pymmcore-widgets directly). -- Avoid duplicate efforts. While independent related projects are excellent in that they allow rapid exploration and experimentation, we'd like to be able to share the results of these efforts. In some ways that is done via pymmcore-widgets, but all of the application level stuff (persistence of settings, complex layouts, coordination of data saving, viewing & processing) is explicitly not part of pymmcore widgets. -- Establish patterns for persistence and application state. - -**Non-Goals** - -- Working on a shared application is *not* meant to discourage independent experimentation and repositories. (One of the real strengths in doing this all in python is the ease of creating custom widgets and GUIs!). One possible pattern would be forks & branches off of a main central repository. - -## Purpose of this repo - -For now, this serves as place to store TODO issues and discussion items. Please open an issue if you are interested, (even just to say hi! 🙂) - - -## Existing Efforts - -### napari-micromanager - -napari-micromanager - -An initial effort towards a pure python micro-manager gui based on the pymmcore-plus ecosystem was [napari-micromanager](https://github.com/pymmcore-plus/napari-micromanager). It uses [napari](https://github.com/napari/napari) as the primary viewer, and [pymmcore-widgets](https://github.com/pymmcore-plus/pymmcore-widgets) for most of the UI related to micro-manager functionality. It still works and will continue to be maintained for the foreseable future, but we are also interested in exploring options that do not depend on napari. - -One candidate to replace the viewing functionality provided by napari is [`ndv`](https://github.com/pyapp-kit/ndv), a slim multi-dimensional viewer with minimal dependencies. Two experimental efforts exist to build a micro-manager gui using ndv - -### micromanager-gui - -Screenshot 2024-06-03 at 11 49 45 PM - -[micromanager-gui](https://github.com/fdrgsp/micromanager-gui) is a standalone application written by Federico Gasparoli ([@fdrgsp](https://github.com/fdrgsp)), and currently lives in federico's personal org while we experiment with it. - -### pymmcore-plus-sandbox - -Screenshot 2024-10-13 at 2 50 57 PM - - -[`pymmcore-plus-sandbox`](https://github.com/gselzer/pymmcore-plus-sandbox) is another experimental standalone GUI written by Gabe Selzer ([@gselzer](https://github.com/gselzer) with input from [@marktsuchida](https://github.com/marktsuchida). One initial goal here is to create a main window that looks very similar to the java based MMStudio (which would make it familiar to existing users of the java ecosystem). - -### LEB-EPFL - -Willi Stepp ([@wl-stepp](https://github.com/wl-stepp)) has been an active contributor to pymmcore-widgets and uses some of these widgets in his event-driven microscopy controllers. +# micromanager-gui +[![License](https://img.shields.io/pypi/l/micromanager-gui.svg?color=green)](https://github.com/fdrgsp/micromanager-gui/raw/main/LICENSE) +[![PyPI](https://img.shields.io/pypi/v/micromanager-gui.svg?color=green)](https://pypi.org/project/micromanager-gui) +[![Python Version](https://img.shields.io/pypi/pyversions/micromanager-gui.svg?color=green)](https://python.org) +[![CI](https://github.com/fdrgsp/micromanager-gui/actions/workflows/ci.yml/badge.svg)](https://github.com/fdrgsp/micromanager-gui/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/fdrgsp/micromanager-gui/branch/main/graph/badge.svg)](https://codecov.io/gh/fdrgsp/micromanager-gui) +A Micro-Manager GUI based on pymmcore-widgets and pymmcore-plus. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..fbf69ee3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,161 @@ +# https://peps.python.org/pep-0517/ +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +# https://peps.python.org/pep-0621/ +[project] +name = "micromanager-gui" +description = "A Micro-Manager GUI based on pymmcore-widgets and pymmcore-plus." +readme = "README.md" +requires-python = ">=3.8" +license = { text = "BSD 3-Clause License" } +authors = [ + { email = "federico.gasparoli@gmail.com", name = "Federico Gasparoli" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: BSD License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Typing :: Typed", +] +dynamic = ["version"] +dependencies = [] + +# extras +# https://peps.python.org/pep-0621/#dependencies-optional-dependencies +[project.optional-dependencies] +test = ["pytest>=6.0", "pytest-cov"] +dev = [ + "black", + "ipython", + "mypy", + "pdbpp", + "pre-commit", + "pytest-cov", + "pytest", + "rich", + "ruff", +] + +[project.urls] +homepage = "https://github.com/fdrgsp/micromanager-gui" +repository = "https://github.com/fdrgsp/micromanager-gui" + +# same as console_scripts entry point +# [project.scripts] +# spam-cli = "spam:main_cli" + +# Entry points +# https://peps.python.org/pep-0621/#entry-points +# [project.entry-points."spam.magical"] +# tomatoes = "spam:main_tomatoes" + +# https://hatch.pypa.io/latest/config/metadata/ +[tool.hatch.version] +source = "vcs" + +# https://hatch.pypa.io/latest/config/build/#file-selection +# [tool.hatch.build.targets.sdist] +# include = ["/src", "/tests"] + +[tool.hatch.build.targets.wheel] +only-include = ["src"] +sources = ["src"] + +# https://github.com/charliermarsh/ruff +[tool.ruff] +line-length = 88 +target-version = "py38" +src = ["src"] +# https://beta.ruff.rs/docs/rules/ +select = [ + "E", # style errors + "W", # style warnings + "F", # flakes + "D", # pydocstyle + "I", # isort + "UP", # pyupgrade + "C4", # flake8-comprehensions + "B", # flake8-bugbear + "A001", # flake8-builtins + "RUF", # ruff-specific rules +] +# I do this to get numpy-style docstrings AND retain +# D417 (Missing argument descriptions in the docstring) +# otherwise, see: +# https://beta.ruff.rs/docs/faq/#does-ruff-support-numpy-or-google-style-docstrings +# https://github.com/charliermarsh/ruff/issues/2606 +ignore = [ + "D100", # Missing docstring in public module + "D107", # Missing docstring in __init__ + "D203", # 1 blank line required before class docstring + "D212", # Multi-line docstring summary should start at the first line + "D213", # Multi-line docstring summary should start at the second line + "D401", # First line should be in imperative mood + "D413", # Missing blank line after last section + "D416", # Section name should end with a colon +] + +[tool.ruff.per-file-ignores] +"tests/*.py" = ["D", "S"] + +# https://docs.pytest.org/en/6.2.x/customize.html +[tool.pytest.ini_options] +minversion = "6.0" +testpaths = ["tests"] +filterwarnings = ["error"] + +# https://mypy.readthedocs.io/en/stable/config_file.html +[tool.mypy] +files = "src/**/" +strict = true +disallow_any_generics = false +disallow_subclassing_any = false +show_error_codes = true +pretty = true + +# # module specific overrides +# [[tool.mypy.overrides]] +# module = ["numpy.*",] +# ignore_errors = true + + +# https://coverage.readthedocs.io/en/6.4/config.html +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "@overload", + "except ImportError", + "\\.\\.\\.", + "raise NotImplementedError()", +] +[tool.coverage.run] +source = ["micromanager_gui"] + +# https://github.com/mgedmin/check-manifest#configuration +[tool.check-manifest] +ignore = [ + ".github_changelog_generator", + ".pre-commit-config.yaml", + ".ruff_cache/**/*", + "tests/**/*", +] + +# # for things that require compilation +# # https://cibuildwheel.readthedocs.io/en/stable/options/ +# [tool.cibuildwheel] +# # Skip 32-bit builds & PyPy wheels on all platforms +# skip = ["*-manylinux_i686", "*-musllinux_i686", "*-win32", "pp*"] +# test-extras = ["test"] +# test-command = "pytest {project}/tests -v" +# test-skip = "*-musllinux*" + +# [tool.cibuildwheel.environment] +# HATCH_BUILD_HOOKS_ENABLE = "1" diff --git a/src/micromanager_gui/__init__.py b/src/micromanager_gui/__init__.py new file mode 100644 index 00000000..04158a26 --- /dev/null +++ b/src/micromanager_gui/__init__.py @@ -0,0 +1,10 @@ +"""A Micro-Manager GUI based on pymmcore-widgets and pymmcore-plus.""" +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("micromanager-gui") +except PackageNotFoundError: + __version__ = "uninstalled" + +__author__ = "Federico Gasparoli" +__email__ = "federico.gasparoli@gmail.com" diff --git a/src/micromanager_gui/py.typed b/src/micromanager_gui/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py new file mode 100644 index 00000000..363b3e20 --- /dev/null +++ b/tests/test_micromanager_gui.py @@ -0,0 +1,2 @@ +def test_something(): + pass From f15a058c23f08f49de3cf2338d396072bd70444e Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 18 Mar 2024 11:54:30 -0400 Subject: [PATCH 002/226] chore: update pre-commit --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f840a42b..aff3cc73 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,23 +13,23 @@ repos: # stages: [commit-msg] - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.252 + rev: v0.3.3 hooks: - id: ruff args: [--fix] - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 24.3.0 hooks: - id: black - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.12.1 + rev: v0.16 hooks: - id: validate-pyproject - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.0.1 + rev: v1.9.0 hooks: - id: mypy files: "^src/" From cda2182b251d19416eb77e1303e2d7b2531000c6 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Mar 2024 12:26:47 -0400 Subject: [PATCH 003/226] feat: add mainwindow, preview and viewer --- examples/gui.py | 12 + pyproject.toml | 38 +- src/micromanager_gui/__init__.py | 6 + src/micromanager_gui/__main__.py | 0 src/micromanager_gui/_core_link.py | 125 +++++ src/micromanager_gui/_main_window.py | 107 +++++ src/micromanager_gui/_toolbar.py | 205 ++++++++ src/micromanager_gui/_util.py | 101 ++++ src/micromanager_gui/_widgets/_camera_roi.py | 18 + .../_widgets/_config_wizard.py | 45 ++ .../_widgets/_group_and_preset.py | 32 ++ .../_widgets/_mda/_mda_viewer.py | 441 ++++++++++++++++++ .../_widgets/_mda/_sliders.py | 110 +++++ .../_widgets/_pixel_configurations.py | 44 ++ src/micromanager_gui/_widgets/_preview.py | 179 +++++++ .../_widgets/_shutters_toolbar.py | 58 +++ .../_widgets/_snap_and_live.py | 44 ++ .../_widgets/_stage_control.py | 108 +++++ 18 files changed, 1655 insertions(+), 18 deletions(-) create mode 100644 examples/gui.py create mode 100644 src/micromanager_gui/__main__.py create mode 100644 src/micromanager_gui/_core_link.py create mode 100644 src/micromanager_gui/_main_window.py create mode 100644 src/micromanager_gui/_toolbar.py create mode 100644 src/micromanager_gui/_util.py create mode 100644 src/micromanager_gui/_widgets/_camera_roi.py create mode 100644 src/micromanager_gui/_widgets/_config_wizard.py create mode 100644 src/micromanager_gui/_widgets/_group_and_preset.py create mode 100644 src/micromanager_gui/_widgets/_mda/_mda_viewer.py create mode 100644 src/micromanager_gui/_widgets/_mda/_sliders.py create mode 100644 src/micromanager_gui/_widgets/_pixel_configurations.py create mode 100644 src/micromanager_gui/_widgets/_preview.py create mode 100644 src/micromanager_gui/_widgets/_shutters_toolbar.py create mode 100644 src/micromanager_gui/_widgets/_snap_and_live.py create mode 100644 src/micromanager_gui/_widgets/_stage_control.py diff --git a/examples/gui.py b/examples/gui.py new file mode 100644 index 00000000..174b57c7 --- /dev/null +++ b/examples/gui.py @@ -0,0 +1,12 @@ +from pymmcore_plus import CMMCorePlus +from qtpy.QtWidgets import QApplication + +from micromanager_gui import MicroManagerGUI + +mmc = CMMCorePlus.instance() +mmc.loadSystemConfiguration() +gui = MicroManagerGUI() +gui.show() + +app = QApplication.instance() +app.setStyle("Fusion") diff --git a/pyproject.toml b/pyproject.toml index fbf69ee3..3607f6e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,12 @@ classifiers = [ "Typing :: Typed", ] dynamic = ["version"] -dependencies = [] +dependencies = [ + "pymmcore-widgets >= 0.7.1", + "pymmcore-plus >= 0.9.4", + "qtpy", + "vispy" +] # extras # https://peps.python.org/pep-0621/#dependencies-optional-dependencies @@ -68,42 +73,39 @@ source = "vcs" only-include = ["src"] sources = ["src"] -# https://github.com/charliermarsh/ruff +# https://beta.ruff.rs/docs/rules/ [tool.ruff] line-length = 88 target-version = "py38" -src = ["src"] -# https://beta.ruff.rs/docs/rules/ +src = ["src", "tests"] +[tool.ruff.lint] +pydocstyle = { convention = "numpy" } select = [ "E", # style errors - "W", # style warnings "F", # flakes + "W", # warnings "D", # pydocstyle + "D417", # Missing argument descriptions in Docstrings "I", # isort "UP", # pyupgrade "C4", # flake8-comprehensions "B", # flake8-bugbear "A001", # flake8-builtins "RUF", # ruff-specific rules + "TID", # tidy + "TCH", # typecheck + # "SLF", # private-access ] -# I do this to get numpy-style docstrings AND retain -# D417 (Missing argument descriptions in the docstring) -# otherwise, see: -# https://beta.ruff.rs/docs/faq/#does-ruff-support-numpy-or-google-style-docstrings -# https://github.com/charliermarsh/ruff/issues/2606 ignore = [ "D100", # Missing docstring in public module - "D107", # Missing docstring in __init__ - "D203", # 1 blank line required before class docstring - "D212", # Multi-line docstring summary should start at the first line - "D213", # Multi-line docstring summary should start at the second line "D401", # First line should be in imperative mood - "D413", # Missing blank line after last section - "D416", # Section name should end with a colon ] -[tool.ruff.per-file-ignores] -"tests/*.py" = ["D", "S"] +[tool.ruff.lint.per-file-ignores] +"tests/*.py" = ["D", "SLF"] + +[tool.ruff.format] +docstring-code-format = true # https://docs.pytest.org/en/6.2.x/customize.html [tool.pytest.ini_options] diff --git a/src/micromanager_gui/__init__.py b/src/micromanager_gui/__init__.py index 04158a26..2323c913 100644 --- a/src/micromanager_gui/__init__.py +++ b/src/micromanager_gui/__init__.py @@ -1,4 +1,5 @@ """A Micro-Manager GUI based on pymmcore-widgets and pymmcore-plus.""" + from importlib.metadata import PackageNotFoundError, version try: @@ -8,3 +9,8 @@ __author__ = "Federico Gasparoli" __email__ = "federico.gasparoli@gmail.com" + + +from ._main_window import MicroManagerGUI + +__all__ = ["MicroManagerGUI"] diff --git a/src/micromanager_gui/__main__.py b/src/micromanager_gui/__main__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py new file mode 100644 index 00000000..e96ee38f --- /dev/null +++ b/src/micromanager_gui/_core_link.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from pymmcore_plus import CMMCorePlus +from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY +from qtpy.QtCore import QObject, Qt + +from ._widgets._mda._mda_viewer import MDAViewer +from ._widgets._preview import Preview + +DIALOG = Qt.WindowType.Dialog + +if TYPE_CHECKING: + import useq + + from ._main_window import MicroManagerGUI + + +class _CoreLink(QObject): + def __init__(self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None): + super().__init__(parent) + + self._mmc = mmcore or CMMCorePlus.instance() + + self._main_window = parent + self._canvas_size = (parent.size().height(), parent.size().height()) + + self._is_mda_running: bool = False + + # to keep track of the viewers + self._current_viewer: MDAViewer | None = None + self._viewers: list[MDAViewer] = [] + + # preview widget + self._preview = Preview(parent, mmcore=self._mmc, canvas_size=self._canvas_size) + self._preview.setWindowFlags(DIALOG) + self._preview.hide() + + # core connections + ev = self._mmc.events + ev.imageSnapped.connect(self._show_preview) + ev.continuousSequenceAcquisitionStarted.connect(self._show_preview) + self._mmc.mda.events.sequenceStarted.connect(self._on_sequence_started) + self._mmc.mda.events.sequenceFinished.connect(self._on_sequence_finished) + + self.destroyed.connect(self._disconnect) + + def _disconnect(self) -> None: + """Disconnect signals.""" + ev = self._mmc.events + ev.imageSnapped.disconnect(self._show_preview) + ev.continuousSequenceAcquisitionStarted.disconnect(self._show_preview) + self._mmc.mda.events.sequenceStarted.disconnect(self._on_sequence_started) + self._mmc.mda.events.sequenceFinished.disconnect(self._on_sequence_finished) + + def _show_preview(self) -> None: + """Show the preview widget.""" + # do not show if MDA is running + if self._is_mda_running: + return + # show if hidden, raise if visible + if self._preview.isHidden(): + self._preview.resize(self._preview.sizeHint() / 2) + self._preview.show() + else: + self._preview.raise_() + + def _setup_viewer(self, sequence: useq.MDASequence) -> None: + self._current_viewer = MDAViewer( + self._main_window, mmcore=self._mmc, canvas_size=self._canvas_size + ) + + # rename the viewer if there is a save_name in the metadata or add a digit + save_meta = cast(dict, sequence.metadata.get(PYMMCW_METADATA_KEY, {})) + save_name = save_meta.get("save_name") + save_name = ( + save_name + if save_name is not None + else f"MDA Viewer {len(self._viewers) + 1}" + ) + self._current_viewer.setWindowTitle(save_name) + + # call it manually indted in _connect_viewer because this signal has been + # emitted already + self._current_viewer.sequenceStarted(sequence) + + # connect the signals + self._connect_viewer(self._current_viewer) + + # set the dialog window flags and show + self._current_viewer.setWindowFlags(DIALOG) + self._current_viewer.resize(self._current_viewer.sizeHint() / 2) + self._current_viewer.show() + + # store the viewer + self._viewers.append(self._current_viewer) + + def _connect_viewer(self, viewer: MDAViewer) -> None: + self._mmc.mda.events.sequenceFinished.connect(viewer.sequenceFinished) + self._mmc.mda.events.frameReady.connect(viewer.frameReady) + + def _disconnect_viewer(self, viewer: MDAViewer) -> None: + """Disconnect the signals.""" + self._mmc.mda.events.sequenceFinished.disconnect(viewer.sequenceFinished) + self._mmc.mda.events.frameReady.disconnect(viewer.frameReady) + + def _on_sequence_started(self, sequence: useq.MDASequence) -> None: + """Show the MDAViewer when the MDA sequence starts.""" + self._is_mda_running = True + self._preview.hide() + + # pause until the viewer is ready + self._mmc.mda.toggle_pause() + # setup the viewer + self._setup_viewer(sequence) + # resume the sequence + self._mmc.mda.toggle_pause() + + def _on_sequence_finished(self, sequence: useq.MDASequence) -> None: + """Hide the MDAViewer when the MDA sequence finishes.""" + self._is_mda_running = False + if self._current_viewer is None: + return + self._disconnect_viewer(self._current_viewer) diff --git a/src/micromanager_gui/_main_window.py b/src/micromanager_gui/_main_window.py new file mode 100644 index 00000000..2921de7b --- /dev/null +++ b/src/micromanager_gui/_main_window.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from pymmcore_plus import CMMCorePlus +from pymmcore_widgets.hcwizard.intro_page import SRC_CONFIG +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QAction, + QMainWindow, + QMenuBar, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from ._core_link import _CoreLink +from ._toolbar import MainToolBar +from ._util import ( + load_sys_config_dialog, + save_sys_config_dialog, +) +from ._widgets._config_wizard import HardwareConfigWizard + +FLAGS = Qt.WindowType.Dialog +DEFAULT = "Experiment" +ALLOWED_AREAS = ( + Qt.DockWidgetArea.LeftDockWidgetArea + | Qt.DockWidgetArea.RightDockWidgetArea + # | Qt.DockWidgetArea.BottomDockWidgetArea +) + + +class MicroManagerGUI(QMainWindow): + def __init__( + self, parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent) + + self._mmc = mmcore or CMMCorePlus.instance() + + self.setWindowTitle("Micro-Manager GUI") + + # extend size to fill the screen + self.showMaximized() + + # add menu + self._add_menu() + + # add toolbar + self._toolbar = MainToolBar(self) + self.contextMenuEvent = self._toolbar.contextMenuEvent + + # add central widget + central_widget = QWidget() + central_widget.setLayout(QVBoxLayout()) + self.setCentralWidget(central_widget) + + # set tabbed dockwidgets tabs to the top + self.setTabPosition( + Qt.DockWidgetArea.AllDockWidgetAreas, QTabWidget.TabPosition.North + ) + + # link to the core + self._core_link = _CoreLink(self, mmcore=self._mmc) + + self._wizard: HardwareConfigWizard | None = None + + def _add_menu(self) -> None: + + menubar = QMenuBar(self) + + # main Micro-Manager menu + mm_menu = menubar.addMenu("Micro-Manager") + + # Configurations Sub-Menu + configurations_menu = mm_menu.addMenu("System Configurations") + # save cfg + self.act_save_configuration = QAction("Save Configuration", self) + self.act_save_configuration.triggered.connect(self._save_cfg) + configurations_menu.addAction(self.act_save_configuration) + # load cfg + self.act_load_configuration = QAction("Load Configuration", self) + self.act_load_configuration.triggered.connect(self._load_cfg) + configurations_menu.addAction(self.act_load_configuration) + # cfg wizard + self.act_cfg_wizard = QAction("Hardware Configuration Wizard", self) + self.act_cfg_wizard.triggered.connect(self._show_config_wizard) + configurations_menu.addAction(self.act_cfg_wizard) + + def _save_cfg(self) -> None: + """Save the current Micro-Manager system configuration.""" + save_sys_config_dialog(parent=self, mmcore=self._mmc) + + def _load_cfg(self) -> None: + """Load a Micro-Manager system configuration.""" + load_sys_config_dialog(parent=self, mmcore=self._mmc) + + def _show_config_wizard(self) -> None: + """Show the Micro-Manager Hardware Configuration Wizard.""" + if self._wizard is None: + self._wizard = HardwareConfigWizard(parent=self) + + if self._wizard.isVisible(): + self._wizard.raise_() + else: + current_cfg = self._mmc.systemConfigurationFile() or "" + self._wizard.setField(SRC_CONFIG, current_cfg) + self._wizard.show() diff --git a/src/micromanager_gui/_toolbar.py b/src/micromanager_gui/_toolbar.py new file mode 100644 index 00000000..5e2c7475 --- /dev/null +++ b/src/micromanager_gui/_toolbar.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from fonticon_mdi6 import MDI6 +from pymmcore_widgets import MDAWidget, PropertyBrowser +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import ( + QDockWidget, + QPushButton, + QScrollArea, + QSizePolicy, + QToolBar, + QWidget, +) +from superqt.fonticon import icon + +from ._widgets._camera_roi import _CameraRoiWidget +from ._widgets._group_and_preset import _GroupsAndPresets +from ._widgets._pixel_configurations import _PixelConfigurationWidget +from ._widgets._shutters_toolbar import _ShuttersToolbar +from ._widgets._snap_and_live import Live, Snap +from ._widgets._stage_control import _StagesControlWidget + +if TYPE_CHECKING: + from ._main_window import MicroManagerGUI + + +BTN_SIZE = (60, 40) +ALLOWED_AREAS = ( + Qt.DockWidgetArea.LeftDockWidgetArea + | Qt.DockWidgetArea.RightDockWidgetArea + # | Qt.DockWidgetArea.BottomDockWidgetArea +) + + +# fmt: off +# key: (widget, window name, icon) +WIDGETS: dict[str, tuple[type[QWidget], str, str | None]] = { + "Shutters": (_ShuttersToolbar, "Shutters Control", MDI6.hexagon_slice_6), + "Camera ROI": (_CameraRoiWidget, "Camera ROI", MDI6.crop), + "Property Browser": (PropertyBrowser, "Device Property Browser", MDI6.table_large), + "Group Presets": (_GroupsAndPresets, "Group & Presets Table", MDI6.table_large_plus), # noqa: E501 + "Stages": (_StagesControlWidget, "Stages Control", MDI6.arrow_all), + "Pixel": (_PixelConfigurationWidget, "Pixel Configuration Table", None), + "MDA": (MDAWidget, "Multi-Dimensional Acquisition", None), +} +# fmt: on + + +class ScrollableDockWidget(QDockWidget): + """A QDockWidget with a QScrollArea.""" + + def __init__(self, title: str, parent: QWidget | None = None, *, widget: QWidget): + super().__init__(title, parent) + # set allowed dock areas + self.setAllowedAreas(ALLOWED_AREAS) + + # create the scroll area and set it as the widget of the QDockwidget + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + super().setWidget(self.scroll_area) + + # set the widget to the scroll area + self.scroll_area.setWidget(widget) + # resize the dock widget to the size hint of the widget + self.resize(widget.minimumSizeHint()) + + +class MainToolBar(QToolBar): + """A QToolBar containing QPushButtons for pymmcore-widgets.""" + + def __init__(self, parent: MicroManagerGUI) -> None: + super().__init__(parent) + + self._main_window = parent + + self.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) + + # snap and live toolbar + self._snap_live_toolbar = QToolBar("Snap/Live Toolbar", self) + self._snap_live_toolbar.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) + self._main_window.addToolBar( + Qt.ToolBarArea.TopToolBarArea, self._snap_live_toolbar + ) + self._snap_button = Snap() + self._live_button = Live() + self._snap_live_toolbar.addWidget(self._snap_button) + self._snap_live_toolbar.addWidget(self._live_button) + + # widgets toolbar + self._widgets_toolbar = _WidgetsToolBar( + self, main_window=self._main_window, main_toolbar=self + ) + self._widgets_toolbar.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) + self._main_window.addToolBar( + Qt.ToolBarArea.TopToolBarArea, self._widgets_toolbar + ) + + # shutters toolbar + self._shutter_toolbar = _ShuttersToolbar(self) + self._main_window.addToolBar( + Qt.ToolBarArea.TopToolBarArea, self._shutter_toolbar + ) + + def contextMenuEvent(self, event: Any) -> None: + """Remove all actions from the context menu but the shutter toolbar.""" + menu = self._main_window.createPopupMenu() + for action in menu.actions(): + if action.text() == "Shutters ToolBar": + continue + menu.removeAction(action) + menu.exec_(event.globalPos()) + + +class _WidgetsToolBar(QToolBar): + """A QToolBar containing QPushButtons for pymmcore-widgets. + + e.g. Property Browser, MDAWidget, StagesWidget, ... + + The QPushButton.whatsThis() property is used to store the key that + will be used by the `_show_widget` method. + """ + + def __init__( + self, + parent: QWidget | None = None, + *, + main_window: MicroManagerGUI, + main_toolbar: MainToolBar, + ) -> None: + super().__init__("Widgets ToolBar", parent) + + self._main_window = main_window + self._main_toolbar = main_toolbar + + # keep track of the created widgets + self._widgets: dict[str, ScrollableDockWidget] = {} + + self.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) + + for key in WIDGETS: + _, windows_name, btn_icon = WIDGETS[key] + btn = QPushButton() + btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + btn.setToolTip(windows_name) + btn.setWhatsThis(key) + btn.setIcon(icon(btn_icon)) if btn_icon else btn.setText(key) + btn.setFixedSize(*BTN_SIZE) + btn.setIconSize(QSize(25, 25)) + if key == "Shutters": + btn.clicked.connect(self._show_shutters_toolbar) + else: + btn.clicked.connect(self._show_widget) + self.addWidget(btn) + + def _show_shutters_toolbar(self) -> None: + """Show or raise the shutters toolbar.""" + if self._main_toolbar._shutter_toolbar is None: + return + if self._main_toolbar._shutter_toolbar.isVisible(): + self._main_toolbar._shutter_toolbar.hide() + else: + self._main_toolbar._shutter_toolbar.show() + + def _show_widget(self) -> None: + """Show or raise a widget.""" + # using QPushButton.whatsThis() property to get the key. + btn = cast(QPushButton, self.sender()) + key = btn.whatsThis() + + if key in self._widgets: + # already exists + wdg = self._widgets[key] + wdg.show() + wdg.raise_() + return + + wdg = self._create_widget(key) + wdg.show() + + def _create_widget(self, key: str) -> ScrollableDockWidget: + """Create a widget for the first time.""" + try: + wdg_cls = WIDGETS[key][0] + except KeyError as e: + raise KeyError( + "Not a recognized widget key. " + f"Must be one of {list(WIDGETS)} " + " or the `whatsThis` property of a `sender` `QPushButton`." + ) from e + + wdg = wdg_cls(parent=self, mmcore=self._main_window._mmc) + + windows_title = WIDGETS[key][1] + dock = ScrollableDockWidget(windows_title, self, widget=wdg) + self._main_window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock) + dock.setFloating(True) + self._widgets[key] = dock + + # if the widget is an MDAWidget, connect the onRunClicked signal + # if isinstance(wdg, _MDAWidget): + # wdg.onRunClicked.connect(self._on_mda_run) + + return dock diff --git a/src/micromanager_gui/_util.py b/src/micromanager_gui/_util.py new file mode 100644 index 00000000..e71862e6 --- /dev/null +++ b/src/micromanager_gui/_util.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from pathlib import Path +from typing import ContextManager, cast +from warnings import warn + +from platformdirs import user_config_dir +from pymmcore_plus import CMMCorePlus +from pymmcore_plus.core.events import CMMCoreSignaler, PCoreSignaler +from qtpy.QtWidgets import ( + QFileDialog, + QWidget, +) +from superqt.utils import signals_blocked + +PLATE_FROM_CALIBRATION = "custom_from_calibration" +USER_DIR = Path(user_config_dir("napari_micromanager")) +USER_CONFIGS_PATHS = USER_DIR / "system_configurations.json" + + +def block_core(mmcore_events: CMMCoreSignaler | PCoreSignaler) -> ContextManager: + """Block core signals.""" + if isinstance(mmcore_events, CMMCoreSignaler): + return mmcore_events.blocked() # type: ignore + elif isinstance(mmcore_events, PCoreSignaler): + return signals_blocked(mmcore_events) # type: ignore + else: + raise ValueError("Unknown core signaler.") + + +def add_path_to_config_json(path: Path | str) -> None: + """Update the stystem configurations json file with the new path.""" + import json + + if not USER_CONFIGS_PATHS.exists(): + return + + if isinstance(path, Path): + path = str(path) + + # Read the existing data + try: + with open(USER_CONFIGS_PATHS) as f: + data = json.load(f) + except json.JSONDecodeError: + data = {"paths": []} + + # Append the new path. using insert so we leave the empty string at the end + paths = cast(list, data.get("paths", [])) + if path in paths: + paths.remove(path) + paths.insert(0, path) + + # Write the data back to the file + with open(USER_CONFIGS_PATHS, "w") as f: + json.dump({"paths": paths}, f) + + +def save_sys_config_dialog( + parent: QWidget | None = None, mmcore: CMMCorePlus | None = None +) -> None: + """Open file dialog to save a config file. + + The file will be also saved in the USER_CONFIGS_PATHS jason file if it doesn't + yet exist. + """ + (filename, _) = QFileDialog.getSaveFileName( + parent, "Save Micro-Manager Configuration." + ) + if filename: + filename = filename if str(filename).endswith(".cfg") else f"{filename}.cfg" + mmcore = mmcore or CMMCorePlus.instance() + mmcore.saveSystemConfiguration(filename) + add_path_to_config_json(filename) + + +def load_sys_config_dialog( + parent: QWidget | None = None, mmcore: CMMCorePlus | None = None +) -> None: + """Open file dialog to select a config file. + + The loaded file will be also saved in the USER_CONFIGS_PATHS jason file if it + doesn't yet exist. + """ + (filename, _) = QFileDialog.getOpenFileName( + parent, "Select a Micro-Manager configuration file", "", "cfg(*.cfg)" + ) + if filename: + add_path_to_config_json(filename) + mmcore = mmcore or CMMCorePlus.instance() + mmcore.loadSystemConfiguration(filename) + + +def load_sys_config(config: Path | str, mmcore: CMMCorePlus | None = None) -> None: + """Load a system configuration with a warning if the file is not found.""" + mmcore = mmcore or CMMCorePlus.instance() + try: + mmcore.loadSystemConfiguration(config) + except FileNotFoundError: + # don't crash if the user passed an invalid config + warn(f"Config file {config} not found. Nothing loaded.", stacklevel=2) diff --git a/src/micromanager_gui/_widgets/_camera_roi.py b/src/micromanager_gui/_widgets/_camera_roi.py new file mode 100644 index 00000000..2489c5bc --- /dev/null +++ b/src/micromanager_gui/_widgets/_camera_roi.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pymmcore_widgets import CameraRoiWidget + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from qtpy.QtWidgets import QWidget + + +class _CameraRoiWidget(CameraRoiWidget): + """A subclass of CameraRoiWidget that sets a fixed height.""" + + def __init__(self, parent: QWidget, *, mmcore: CMMCorePlus | None = None): + super().__init__(parent=parent, mmcore=mmcore) + + self.setFixedHeight(self.minimumSizeHint().height()) diff --git a/src/micromanager_gui/_widgets/_config_wizard.py b/src/micromanager_gui/_widgets/_config_wizard.py new file mode 100644 index 00000000..832c7ee2 --- /dev/null +++ b/src/micromanager_gui/_widgets/_config_wizard.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pymmcore_widgets import ConfigWizard +from pymmcore_widgets.hcwizard.finish_page import DEST_CONFIG + +from micromanager_gui._util import ( + add_path_to_config_json, + load_sys_config, +) + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from qtpy.QtWidgets import QWidget + + +class HardwareConfigWizard(ConfigWizard): + """A wizard to create a new Micro-Manager hardware configuration file. + + Subclassing to load the newly created configuration file and to add it to the + USER_CONFIGS_PATHS json file. + """ + + def __init__( + self, + config_file: str = "", + core: CMMCorePlus | None = None, + parent: QWidget | None = None, + ): + super().__init__(config_file, core, parent) + + self.setWindowTitle("Micro-Manager Hardware Configuration Wizard") + + def accept(self) -> None: + """Accept the wizard and save the configuration to a file. + + Overriding to add the new configuration file to the USER_CONFIGS_PATHS json file + and to load it. + """ + super().accept() + dest = self.field(DEST_CONFIG) + # add the path to the USER_CONFIGS_PATHS list + add_path_to_config_json(dest) + load_sys_config(dest) diff --git a/src/micromanager_gui/_widgets/_group_and_preset.py b/src/micromanager_gui/_widgets/_group_and_preset.py new file mode 100644 index 00000000..d397d96f --- /dev/null +++ b/src/micromanager_gui/_widgets/_group_and_preset.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pymmcore_widgets import GroupPresetTableWidget + +from micromanager_gui._util import load_sys_config_dialog, save_sys_config_dialog + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from qtpy.QtWidgets import QWidget + + +class _GroupsAndPresets(GroupPresetTableWidget): + """Subclass of GroupPresetTableWidget. + + Overwrite the save and load methods to store the saved or loaded configuration in + the USER_CONFIGS_PATHS json config file. + """ + + def __init__( + self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent=parent, mmcore=mmcore) + + def _save_cfg(self) -> None: + """Open file dialog to save the current configuration.""" + save_sys_config_dialog(parent=self, mmcore=self._mmc) + + def _load_cfg(self) -> None: + """Open file dialog to select a config file.""" + load_sys_config_dialog(parent=self, mmcore=self._mmc) diff --git a/src/micromanager_gui/_widgets/_mda/_mda_viewer.py b/src/micromanager_gui/_widgets/_mda/_mda_viewer.py new file mode 100644 index 00000000..13165560 --- /dev/null +++ b/src/micromanager_gui/_widgets/_mda/_mda_viewer.py @@ -0,0 +1,441 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, MutableMapping, cast + +import zarr +from fonticon_mdi6 import MDI6 +from pymmcore_plus import CMMCorePlus +from pymmcore_plus.mda.handlers import OMEZarrWriter +from pymmcore_plus.mda.handlers._ome_zarr_writer import POS_PREFIX +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import ( + QCheckBox, + QFileDialog, + QGroupBox, + QHBoxLayout, + QPushButton, + QVBoxLayout, + QWidget, +) +from superqt import QLabeledDoubleRangeSlider +from superqt.fonticon import icon +from superqt.utils import signals_blocked + +from ._sliders import _AxisSlider + +if TYPE_CHECKING: + import os + from typing import Literal + + import numpy as np + import useq + from fsspec import FSMap + +BTN_SIZE = (60, 40) + + +class MDAViewer(OMEZarrWriter, QWidget): + """A Widget that displays an MDA sequence. + + Parameters + ---------- + parent : QWidget | None + Optional parent widget. By default, None. + mmcore : CMMCorePlus | None + Optional [`pymmcore_plus.CMMCorePlus`][] micromanager core. + By default, None. If not specified, the widget will use the active + (or create a new) + [`CMMCorePlus.instance`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance]. + store : MutableMapping | str | os.PathLike | FSMap | None = None + The store to use for the zarr group. By default, None. + """ + + def __init__( + self, + parent: QWidget | None = None, + mmcore: CMMCorePlus | None = None, + store: MutableMapping | str | os.PathLike | FSMap | None = None, + canvas_size: tuple[int, int] | None = None, + *args: Any, + **kwargs: Any, + ): + try: + from vispy import scene + except ImportError as e: + raise ImportError( + "vispy is required for ImagePreview. " + "Please run `pip install pymmcore-widgets[image]`" + ) from e + + super().__init__(store, *args, **kwargs) + QWidget.__init__(self, parent) + + self.setWindowTitle("MDA Viewer") + + self._mmc = mmcore or CMMCorePlus.instance() + self._canvas_size = canvas_size + + # buttons groupbox + btn_wdg = QGroupBox() + btn_wdg_layout = QHBoxLayout(btn_wdg) + btn_wdg_layout.setContentsMargins(10, 0, 10, 0) + # auto contrast checkbox + self._auto = QCheckBox("Auto") + self._auto.setChecked(True) + self._auto.setToolTip("Auto Contrast") + self._auto.setFixedSize(*BTN_SIZE) + self._auto.toggled.connect(self._clims_auto) + # LUT slider + self._lut_slider = QLabeledDoubleRangeSlider() + self._lut_slider.setDecimals(0) + self._lut_slider.valueChanged.connect(self._on_range_changed) + # reset view button + self._reset_view = QPushButton() + self._reset_view.clicked.connect(self._reset) + self._reset_view.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._reset_view.setToolTip("Reset View") + self._reset_view.setIcon(icon(MDI6.home_outline)) + self._reset_view.setIconSize(QSize(25, 25)) + self._reset_view.setFixedSize(*BTN_SIZE) + # save button + self._save = QPushButton() + self._save.clicked.connect(self._on_save) + self._save.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._save.setToolTip("Save as Zarr") + self._save.setIcon(icon(MDI6.content_save_outline)) + self._save.setIconSize(QSize(25, 25)) + self._save.setFixedSize(*BTN_SIZE) + + # add to layout + btn_wdg_layout.addWidget(self._lut_slider) + btn_wdg_layout.addWidget(self._auto) + btn_wdg_layout.addWidget(self._reset_view) + btn_wdg_layout.addWidget(self._save) + + # # connect core signals + self._mmc.events.systemConfigurationLoaded.connect(self._on_sys_cfg_loaded) + + self._mda_running: bool = False + + self._sliders: dict[str, _AxisSlider] | None = None + + self._imcls = scene.visuals.Image + self._clims: tuple[float, float] | Literal["auto"] = "auto" + self._cmap: str = "grays" + + self._canvas = scene.SceneCanvas( + keys="interactive", size=(512, 512), parent=self + ) + self.view = self._canvas.central_widget.add_view(camera="panzoom") + self.view.camera.aspect = 1 + + self.image: scene.visuals.Image | None = None + + self._sliders_widget = QGroupBox() + self._sliders_layout = QVBoxLayout(self._sliders_widget) + self._sliders_layout.setContentsMargins(10, 0, 10, 0) + self._sliders_layout.setSpacing(5) + + self.setLayout(QVBoxLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + self.layout().setSpacing(5) + self.layout().addWidget(self._canvas.native) + self.layout().addWidget(self._sliders_widget) + self.layout().addWidget(btn_wdg) + + self.destroyed.connect(self._disconnect) + + if bit := self._mmc.getImageBitDepth(): + with signals_blocked(self._lut_slider): + self._lut_slider.setRange(0, 2**bit - 1) + self._lut_slider.setValue((0, 2**bit - 1)) + + self._on_sys_cfg_loaded() + + def _on_sys_cfg_loaded(self) -> None: + """Set the canvas size to half of the image size.""" + self._canvas.size = self._canvas_size or ( + int(self._mmc.getImageWidth() / 2), + int(self._mmc.getImageHeight() / 2), + ) + + def _disconnect(self) -> None: + """Disconnect the signals.""" + self._mmc.events.systemConfigurationLoaded.disconnect(self._on_sys_cfg_loaded) + + def sequenceStarted(self, sequence: useq.MDASequence) -> None: + # this method is called be in `_CoreLink` when the MDA sequence starts + self._mda_running = True + super().sequenceStarted(sequence) + + def sequenceFinished(self, sequence: useq.MDASequence) -> None: + super().sequenceFinished(sequence) + if not self._sliders: + self._sliders_widget.hide() + self._mda_running = False + self._disconnect() + + def frameReady(self, image: np.ndarray, event: useq.MDAEvent, meta: dict) -> None: + """Update the image and sliders when a new frame is ready.""" + super().frameReady(image, event, meta) + # update the image in the viewer + self._update_image(image) + # get the position key to select which array to use + key = f"{POS_PREFIX}{event.index.get(POS_PREFIX, 0)}" + # get the data array + data = self.position_arrays[key] + # get the index keys from zarr attrs and remove 'x' and 'y' + index_keys = cast(list[str], data.attrs["_ARRAY_DIMENSIONS"][:-2]) + + if self._sliders is not None: + self._update_sliders_position(event, index_keys) + + if self._sliders is None: + # if self._sliders is None, create the sliders + self._initialize_sliders(data, index_keys, event) + else: + # create any missing slider if the shape of the data has changed (e.g. if + # the shape of the position differs from any of the previous) + self._update_sliders(data, index_keys) + + def _update_image(self, image: np.ndarray) -> None: + """Update the image in the viewer.""" + clim = (image.min(), image.max()) if self._clims == "auto" else self._clims + if self.image is None: + # first time we see this position, create the image + self.image = self._imcls( + image, cmap=self._cmap, clim=clim, parent=self.view.scene + ) + self.view.camera.set_range(margin=0) + else: + # we have seen this position before, update the image + self.image.set_data(image) + self.image.clim = clim + + # update the LUT slider to match the new image + with signals_blocked(self._lut_slider): + if isinstance(clim, tuple): + self._lut_slider.setValue(clim) + else: + self._lut_slider.setValue((image.min(), image.max())) + + def _update_sliders_position( + self, event: useq.MDAEvent, index_keys: list[str] + ) -> None: + """Update the sliders to match the new position.""" + if self._sliders is None: + return + + # move the position sliders to the current position + if POS_PREFIX in self._sliders: + self._update_slider_range_and_value(POS_PREFIX, event) + self._enable_sliders(index_keys) + # move all the other sliders to the current position + for key in index_keys: + if key in self._sliders: + self._update_slider_range_and_value(key, event) + + def _update_slider_range_and_value(self, key: str, event: useq.MDAEvent) -> None: + """Update the sliders to match the new dimensions.""" + if self._sliders is None: + return + index = event.index.get(key, 0) + # set the range of the slider + self._sliders[key].setRange(0, index) + # set the value of the slider + # block signals to avoid triggering "_on_slider_value_changed" + self._sliders[key].blockSignals(True) + self._sliders[key].setValue(index) + self._sliders[key].blockSignals(False) + + def _enable_sliders(self, dims: list[str]) -> None: + """Enable only sliders with the keys that are in the data attrs.""" + # useful when we have a jagged array + if self._sliders is None: + return + for sl in self._sliders: + if sl == POS_PREFIX: + continue + self._sliders[sl].setEnabled(sl in dims) + + def _initialize_sliders( + self, data: np.ndarray, index_keys: list[str], event: useq.MDAEvent + ) -> None: + """Create the sliders for the first time.""" + if event.sequence is None: + return + + self._sliders = {} + + # create position slider if there is more than one position. + # the OMEZarrDatastore divides the data into positions using the POS_PREFIX + # so we can use that to create the position sliders + if POS_PREFIX not in self._sliders and len(event.sequence.stage_positions) > 1: + self._create_and_add_slider(POS_PREFIX, 1) + + if POS_PREFIX in index_keys: + index_keys.remove(POS_PREFIX) + + # create sliders for any other axis + self._create_sliders_for_dimensions(data, index_keys) + + def _create_and_add_slider(self, key: str, range_end: int) -> None: + """Create a slider for the given key and add it to the _sliders_layout.""" + slider = self._create_axis_slider(key, range_end) + if slider is not None: + self._sliders_layout.addWidget(slider) + + def _create_axis_slider(self, key: str, range_end: int) -> _AxisSlider | None: + """Create a slider for the given key.""" + if self._sliders is None: + return None + slider = _AxisSlider(key, parent=self) + slider.valueChanged.connect(self._on_slider_value_changed) + slider.setRange(0, range_end) + self._sliders[key] = slider + return slider + + def _create_sliders_for_dimensions( + self, data: np.ndarray, index_keys: list[str] + ) -> None: + """Create a slider for each index key if the corresponding shape is > 1.""" + if self._sliders is None: + return + for idx, sh in enumerate(data.shape[:-2]): + if sh > 1 and index_keys[idx] not in self._sliders: + self._create_and_add_slider(index_keys[idx], data.shape[idx] - 1) + + def _update_sliders(self, data: np.ndarray, index_keys: list[str]) -> None: + """Update the sliders to match the new dimensions.""" + if self._sliders is None: + return + + if POS_PREFIX in index_keys: + index_keys.remove(POS_PREFIX) + + # create a slider if the key is not yet in the sliders + if any(k not in self._sliders for k in index_keys): + self._create_sliders_for_dimensions(data, index_keys) + + def _on_slider_value_changed(self, value: int) -> None: + """Update the shown image when the slider value changes.""" + if self._sliders is None or self.image is None: + return + + # get the position slider + pos_slider = self._sliders.get(POS_PREFIX, None) + + # get the position key to select which array to use + key = "p0" if pos_slider is None else f"p{pos_slider.value()}" + + # get the data array + data = self.position_arrays[key] + + # get the index keys from zarr attrs and remove 'x' and 'y' + dims = data.attrs["_ARRAY_DIMENSIONS"][:-2] + + # disable sliders that are not in the data attrs (if we have multiple positions) + sender = cast(_AxisSlider, self.sender()) + if sender.axis == POS_PREFIX: + self._enable_sliders(dims) + if not self._mda_running: + # update the sliders range to match the data array + # e.g. if a pos has a 2x1 grid and another has a 2x2 grid, when moving + # the pos slider the range of the g slider should change + self._set_slider_range(dims, data) + + # get the index values from the sliders + index = tuple( + self._sliders[dim].value() + for dim in dims + if dim in self._sliders and dim != POS_PREFIX + ) + + # get the image from the data array + # squeeze the data to remove any dimensions of size 1 + image = data[index].squeeze() + + # display the image in the viewer + self.image.set_data(image) + clim = (image.min(), image.max()) if self._clims == "auto" else self._clims + self.image.clim = clim + + def _set_slider_range(self, dims: list[str], data: np.ndarray) -> None: + """Set the range of the sliders to match the data.""" + if self._sliders is None: + return + for dim in dims: + if dim in self._sliders: + self._sliders[dim].setRange(0, data.shape[dims.index(dim)] - 1) + + def _reset(self) -> None: + """Reset the preview.""" + x = (0, self._mmc.getImageWidth()) if self._mmc.getImageWidth() else None + y = (0, self._mmc.getImageHeight()) if self._mmc.getImageHeight() else None + self.view.camera.set_range(x, y, margin=0) + + def _on_range_changed(self, range: tuple[float, float]) -> None: + """Update the LUT range.""" + self.clims = range + self._auto.setChecked(False) + + def _clims_auto(self, state: bool) -> None: + """Set the LUT range to auto.""" + self.clims = "auto" if state else self._lut_slider.value() + + if self.image is None: + return + + image = self.image._data + with signals_blocked(self._lut_slider): + self._lut_slider.setValue((image.min(), image.max())) + + @property + def clims(self) -> tuple[float, float] | Literal["auto"]: + """Get the contrast limits of the image.""" + return self._clims + + @clims.setter + def clims(self, clims: tuple[float, float] | Literal["auto"] = "auto") -> None: + """Set the contrast limits of the image. + + Parameters + ---------- + clims : tuple[float, float], or "auto" + The contrast limits to set. + """ + if self.image is not None: + self.image.clim = clims + + self._clims = clims + + @property + def cmap(self) -> str: + """Get the colormap (lookup table) of the image.""" + return self._cmap + + @cmap.setter + def cmap(self, cmap: str = "grays") -> None: + """Set the colormap (lookup table) of the image. + + Parameters + ---------- + cmap : str + The colormap to use. + """ + if self.image is not None: + self.image.cmap = cmap + + self._cmap = cmap + + def _on_save(self) -> None: + """Save the data as a tif or zarr.""" + save_path, _ = QFileDialog.getSaveFileName( + self, + "Saving directory and filename.", + "", + "ZARR (*.zarr);", + ) + if save_path: + dir_store = zarr.DirectoryStore(save_path) + zarr.copy_store(self._group.attrs.store, dir_store) diff --git a/src/micromanager_gui/_widgets/_mda/_sliders.py b/src/micromanager_gui/_widgets/_mda/_sliders.py new file mode 100644 index 00000000..211754a7 --- /dev/null +++ b/src/micromanager_gui/_widgets/_mda/_sliders.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from typing import Any, cast + +from fonticon_mdi6 import MDI6 +from qtpy import QtCore +from qtpy.QtWidgets import ( + QBoxLayout, + QLabel, + QMenu, + QPushButton, + QSizePolicy, + QSpinBox, + QWidget, + QWidgetAction, +) +from superqt import QLabeledSlider +from superqt.fonticon import icon + +FIXED = QSizePolicy.Policy.Fixed +ICON_SIZE = (24, 24) + + +class _AxisSlider(QLabeledSlider): + def __init__( + self, + axis: str = "", + orientation: QtCore.Qt.Orientation = QtCore.Qt.Orientation.Horizontal, + parent: QWidget | None = None, + ) -> None: + super().__init__(orientation, parent) + self.axis = axis + name_label = QLabel(axis.lower()) + name_label.setSizePolicy(FIXED, FIXED) + name_label.setFixedWidth(20) + + self._play_btn = QPushButton(icon(MDI6.play), "", self) + self._play_btn.setMaximumWidth(self._play_btn.sizeHint().height()) + self._play_btn.setCheckable(True) + self._play_btn.toggled.connect(self._on_play_toggled) + # Enable the custom context menu for the play button + self._play_btn.setContextMenuPolicy( + QtCore.Qt.ContextMenuPolicy.CustomContextMenu + ) + self._play_btn.customContextMenuRequested.connect(self._showContextMenu) + + self._timer_id: int | None = None + + self._interval: int = 10 + self._interval_spin = QSpinBox() + self._interval_spin.setSuffix(" fps") + self._interval_spin.setRange(1, 1000) + self._interval_spin.setValue(self._interval) + self._interval_spin.valueChanged.connect( + lambda val: setattr(self, "_interval", val) + ) + + self._length_label = QLabel() + self.rangeChanged.connect(self._on_range_changed) + + layout = cast(QBoxLayout, self.layout()) + layout.setContentsMargins(10, 0, 0, 0) + layout.insertWidget(0, self._play_btn, 0, QtCore.Qt.AlignmentFlag.AlignRight) + layout.insertWidget(0, name_label, 0, QtCore.Qt.AlignmentFlag.AlignVCenter) + layout.addWidget(self._length_label, 0, QtCore.Qt.AlignmentFlag.AlignVCenter) + + self.installEventFilter(self) + self.setPageStep(1) + self.last_val = 0 + + def _on_play_toggled(self, state: bool) -> None: + if state: + self._play_btn.setIcon(icon(MDI6.pause)) + self._timer_id = self.startTimer(int(1000 / self._interval)) # ms + elif self._timer_id is not None: + self._play_btn.setIcon(icon(MDI6.play)) + self.killTimer(self._timer_id) + self._timer_id = None + + def _showContextMenu(self, position: QtCore.QPoint) -> None: + """Context menu to change the interval of the play button.""" + # toggle off the play button + self._play_btn.setChecked(False) + # create context menu + context_menu = QMenu(self) + # create a QWidgetAction and set its default widget to the QSpinBox + spin_box_action = QWidgetAction(self) + spin_box_action.setDefaultWidget(self._interval_spin) + # add the QWidgetAction to the menu + context_menu.addAction(spin_box_action) + # show the context menu + context_menu.exec_(self._play_btn.mapToGlobal(position)) + + def timerEvent(self, e: QtCore.QTimerEvent) -> None: + """Move the slider to the next value when play is toggled.""" + self.setValue( + self.minimum() + + (self.value() - self.minimum() + 1) + % (self.maximum() - self.minimum() + 1) + ) + + def _on_range_changed(self, min_: int, max_: int) -> None: + self._length_label.setText(f"/ {max_}") + + def eventFilter(self, source: QtCore.QObject, event: QtCore.QEvent) -> Any: + if event.type() == QtCore.QEvent.Type.Paint and self.underMouse(): + if self.value() != self.last_val: + self.sliderMoved.emit(self.value()) + self.last_val = self.value() + return super().eventFilter(source, event) diff --git a/src/micromanager_gui/_widgets/_pixel_configurations.py b/src/micromanager_gui/_widgets/_pixel_configurations.py new file mode 100644 index 00000000..b2549afc --- /dev/null +++ b/src/micromanager_gui/_widgets/_pixel_configurations.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from pymmcore_plus.model import PixelSizeGroup +from pymmcore_widgets import PixelConfigurationWidget +from qtpy.QtWidgets import QHBoxLayout, QPushButton, QWidget + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + + +class _PixelConfigurationWidget(PixelConfigurationWidget): + """A Subclass of PixelConfigurationWidget. + + Remove cancel button and hide the parent widget since this will become a + dock widget. + """ + + def __init__(self, parent: QWidget, *, mmcore: CMMCorePlus | None = None): + super().__init__(parent=parent, mmcore=mmcore) + self._parent = parent + + # hide cancel button + btns_layout = cast(QHBoxLayout, self.layout().children()[0]) + cancel_btn = cast(QPushButton, btns_layout.itemAt(1).widget()) + cancel_btn.hide() + + # remove close() method from _on_apply + def _on_apply(self) -> None: + """Update the pixel configuration.""" + # check if there are errors in the pixel configurations + if self._check_for_errors(): + return + + # delete all the pixel size configurations + for resolutionID in self._mmc.getAvailablePixelSizeConfigs(): + self._mmc.deletePixelSizeConfig(resolutionID) + + # create the new pixel size configurations + px_groups = PixelSizeGroup(presets=self._value_to_dict(self.value())) + px_groups.apply_to_core(self._mmc) + + self._parent.hide() diff --git a/src/micromanager_gui/_widgets/_preview.py b/src/micromanager_gui/_widgets/_preview.py new file mode 100644 index 00000000..0a665081 --- /dev/null +++ b/src/micromanager_gui/_widgets/_preview.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +# import tifffile +from fonticon_mdi6 import MDI6 +from pymmcore_plus import CMMCorePlus +from pymmcore_widgets import ImagePreview +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import ( + QCheckBox, + # QFileDialog, + QGroupBox, + QHBoxLayout, + QPushButton, + QVBoxLayout, + QWidget, +) +from superqt import QLabeledDoubleRangeSlider +from superqt.fonticon import icon +from superqt.utils import signals_blocked + +from ._snap_and_live import Live, Snap + +if TYPE_CHECKING: + import numpy as np + from qtpy.QtGui import QCloseEvent + +BTN_SIZE = (60, 40) + + +class _ImagePreview(ImagePreview): + """Subclass of ImagePreview. + + This subclass updates the LUT slider when the image is updated. + """ + + def __init__( + self, + parent: QWidget | None = None, + *, + mmcore: CMMCorePlus | None = None, + preview_widget: Preview, + use_with_mda: bool = False, + ): + super().__init__(parent=parent, mmcore=mmcore, use_with_mda=use_with_mda) + + self._preview_wdg = preview_widget + + def _update_image(self, image: np.ndarray) -> None: + super()._update_image(image) + + if self.image is None: + return + + with signals_blocked(self._preview_wdg._lut_slider): + self._preview_wdg._lut_slider.setValue(self.image.clim) + + +class Preview(QWidget): + """A widget containing an ImagePreview and buttons for image preview.""" + + def __init__( + self, + parent: QWidget | None = None, + *, + mmcore: CMMCorePlus | None = None, + canvas_size: tuple[int, int] | None = None, + ): + super().__init__(parent) + self.setWindowTitle("Image Preview") + + self._mmc = mmcore or CMMCorePlus.instance() + self._canvas_size = canvas_size + + main_layout = QVBoxLayout() + self.setLayout(main_layout) + + # preview + self._image_preview = _ImagePreview(self, mmcore=self._mmc, preview_widget=self) + main_layout.addWidget(self._image_preview) + + # buttons + btn_wdg = QGroupBox() + btn_wdg_layout = QHBoxLayout(btn_wdg) + btn_wdg_layout.setContentsMargins(0, 0, 0, 0) + # auto contrast checkbox + self._auto = QCheckBox("Auto") + self._auto.setChecked(True) + self._auto.setToolTip("Auto Contrast") + self._auto.setFixedSize(*BTN_SIZE) + self._auto.toggled.connect(self._clims_auto) + # LUT slider + self._lut_slider = QLabeledDoubleRangeSlider() + self._lut_slider.setDecimals(0) + self._lut_slider.valueChanged.connect(self._on_range_changed) + # snap and live buttons + self._snap = Snap(mmcore=self._mmc) + self._snap.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._live = Live(mmcore=self._mmc) + self._live.setFocusPolicy(Qt.FocusPolicy.NoFocus) + # reset view button + self._reset_view = QPushButton() + self._reset_view.clicked.connect(self._reset) + self._reset_view.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._reset_view.setToolTip("Reset View") + self._reset_view.setIcon(icon(MDI6.home_outline)) + self._reset_view.setIconSize(QSize(25, 25)) + self._reset_view.setFixedSize(*BTN_SIZE) + # save button + # self._save = QPushButton() + # self._save.clicked.connect(self._on_save) + # self._save.setFocusPolicy(Qt.FocusPolicy.NoFocus) + # self._save.setToolTip("Save Image") + # self._save.setIcon(icon(MDI6.content_save_outline)) + # self._save.setIconSize(QSize(25, 25)) + # self._save.setFixedSize(*BTN_SIZE) + + btn_wdg_layout.addWidget(self._lut_slider) + btn_wdg_layout.addWidget(self._auto) + btn_wdg_layout.addWidget(self._snap) + btn_wdg_layout.addWidget(self._live) + btn_wdg_layout.addWidget(self._reset_view) + # btn_wdg_layout.addWidget(self._save) + main_layout.addWidget(btn_wdg) + + self._reset() + self._on_sys_cfg_loaded() + + def _on_sys_cfg_loaded(self) -> None: + """Update the LUT slider range and the canvas size.""" + # update the LUT slider range + if bit := self._mmc.getImageBitDepth(): + with signals_blocked(self._lut_slider): + self._lut_slider.setRange(0, 2**bit - 1) + self._lut_slider.setValue((0, 2**bit - 1)) + + # set the canvas size to half of the image size + self._image_preview._canvas.size = self._canvas_size or ( + int(self._mmc.getImageWidth()), + int(self._mmc.getImageHeight()), + ) + + def _reset(self) -> None: + """Reset the preview.""" + x = (0, self._mmc.getImageWidth()) if self._mmc.getImageWidth() else None + y = (0, self._mmc.getImageHeight()) if self._mmc.getImageHeight() else None + self._image_preview.view.camera.set_range(x, y, margin=0) + + def _on_range_changed(self, range: tuple[float, float]) -> None: + """Update the LUT range.""" + self._image_preview.clims = range + self._auto.setChecked(False) + + def _clims_auto(self, state: bool) -> None: + """Set the LUT range to auto.""" + self._image_preview.clims = "auto" if state else self._lut_slider.value() + if self._image_preview.image is not None: + data = self._image_preview.image._data + with signals_blocked(self._lut_slider): + self._lut_slider.setValue((data.min(), data.max())) + + # def _on_save(self) -> None: + # """Save the image as tif.""" + # # TODO: add metadata + # if self._image_preview.image is None: + # return + # path, _ = QFileDialog.getSaveFileName( + # self, "Save Image", "", "TIFF (*.tif *.tiff)" + # ) + # if not path: + # return + # tifffile.imwrite(path, self._image_preview.image._data, imagej=True) + + def closeEvent(self, event: QCloseEvent | None) -> None: + # stop live acquisition if running + if self._mmc.isSequenceRunning(): + self._mmc.stopSequenceAcquisition() + super().closeEvent(event) diff --git a/src/micromanager_gui/_widgets/_shutters_toolbar.py b/src/micromanager_gui/_widgets/_shutters_toolbar.py new file mode 100644 index 00000000..f4ac4250 --- /dev/null +++ b/src/micromanager_gui/_widgets/_shutters_toolbar.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from pymmcore_plus import CMMCorePlus, DeviceType +from pymmcore_widgets import ShuttersWidget +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QToolBar, QWidget + + +class _ShuttersToolbar(QToolBar): + """A QToolBar for the loased Shutters.""" + + def __init__( + self, parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__("Shutters ToolBar", parent) + + self.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) + + self._mmc = mmcore or CMMCorePlus.instance() + self._mmc.events.systemConfigurationLoaded.connect(self._on_cfg_loaded) + self._on_cfg_loaded() + + def _on_cfg_loaded(self) -> None: + self._clear() + + if not self._mmc.getLoadedDevicesOfType(DeviceType.ShutterDevice): + # FIXME: + # ShuttersWidget has not been tested with an empty device label... + # it raises all sorts of errors. + # if we want to have a "placeholder" widget, it needs more testing. + + # empty_shutter = ShuttersWidget("") + # self.layout().addWidget(empty_shutter) + return + + shutters_devs = list(self._mmc.getLoadedDevicesOfType(DeviceType.ShutterDevice)) + for d in shutters_devs: + props = self._mmc.getDevicePropertyNames(d) + if bool([x for x in props if "Physical Shutter" in x]): + shutters_devs.remove(d) + shutters_devs.insert(0, d) + + for idx, shutter in enumerate(shutters_devs): + if idx == len(shutters_devs) - 1: + s = ShuttersWidget(shutter) + else: + s = ShuttersWidget(shutter, autoshutter=False) + s.button_text_open = shutter + s.button_text_closed = shutter + s.icon_color_open = () + s.icon_color_closed = () + self.addWidget(s) + + def _clear(self) -> None: + """Delete toolbar action.""" + while self.actions(): + action = self.actions()[0] + self.removeAction(action) diff --git a/src/micromanager_gui/_widgets/_snap_and_live.py b/src/micromanager_gui/_widgets/_snap_and_live.py new file mode 100644 index 00000000..ce00e42a --- /dev/null +++ b/src/micromanager_gui/_widgets/_snap_and_live.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fonticon_mdi6 import MDI6 +from pymmcore_widgets import ( + LiveButton, + SnapButton, +) +from superqt.fonticon import icon + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from qtpy.QtWidgets import QWidget + +BTN_SIZE = (60, 40) + + +class Snap(SnapButton): + """A SnapButton.""" + + def __init__( + self, parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent=parent, mmcore=mmcore) + self.setToolTip("Snap Image") + self.setIcon(icon(MDI6.camera_outline)) + self.setText("") + self.setFixedSize(*BTN_SIZE) + + +class Live(LiveButton): + """A LiveButton.""" + + def __init__( + self, parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent=parent, mmcore=mmcore) + self.setToolTip("Live Mode") + self.button_text_on = "" + self.button_text_off = "" + self.icon_color_on = () + self.icon_color_off = "#C33" + self.setFixedSize(*BTN_SIZE) diff --git a/src/micromanager_gui/_widgets/_stage_control.py b/src/micromanager_gui/_widgets/_stage_control.py new file mode 100644 index 00000000..a3352532 --- /dev/null +++ b/src/micromanager_gui/_widgets/_stage_control.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from typing import cast + +from pymmcore_plus import CMMCorePlus, DeviceType +from pymmcore_widgets import StageWidget +from qtpy.QtCore import QMimeData, Qt +from qtpy.QtGui import QDrag, QDragEnterEvent, QDropEvent, QMouseEvent +from qtpy.QtWidgets import QGroupBox, QHBoxLayout, QSizePolicy, QWidget + +STAGE_DEVICES = {DeviceType.Stage, DeviceType.XYStage} + + +class _StagesControlWidget(QWidget): + """A widget to control all the XY and Z loaded stages.""" + + def __init__( + self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent=parent) + + self.setAcceptDrops(True) + self.setLayout(QHBoxLayout()) + self.layout().setContentsMargins(5, 5, 5, 5) + self.layout().setSpacing(5) + + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + self._mmc = CMMCorePlus.instance() + self._on_cfg_loaded() + self._mmc.events.systemConfigurationLoaded.connect(self._on_cfg_loaded) + + def _on_cfg_loaded(self) -> None: + self._clear() + sizepolicy = QSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + stage_dev_list = list(self._mmc.getLoadedDevicesOfType(DeviceType.XYStage)) + stage_dev_list.extend(iter(self._mmc.getLoadedDevicesOfType(DeviceType.Stage))) + for stage_dev in stage_dev_list: + if self._mmc.getDeviceType(stage_dev) is DeviceType.XYStage: + bx = _DragGroupBox("XY Control") + elif self._mmc.getDeviceType(stage_dev) is DeviceType.Stage: + bx = _DragGroupBox("Z Control") + else: + continue + bx.setLayout(QHBoxLayout()) + bx.setSizePolicy(sizepolicy) + bx.layout().addWidget(StageWidget(device=stage_dev)) + self.layout().addWidget(bx) + self.resize(self.sizeHint()) + + def _clear(self) -> None: + for i in reversed(range(self.layout().count())): + if item := self.layout().takeAt(i): + if wdg := item.widget(): + wdg.setParent(QWidget()) + wdg.deleteLater() + + def dragEnterEvent(self, event: QDragEnterEvent) -> None: + event.accept() + + def dropEvent(self, event: QDropEvent) -> None: + pos = event.pos() + + wdgs: list[tuple[int, _DragGroupBox, int, int]] = [] + zones: list[tuple[int, int]] = [] + for i in range(self.layout().count()): + wdg = cast(_DragGroupBox, self.layout().itemAt(i).widget()) + wdgs.append((i, wdg, wdg.x(), wdg.x() + wdg.width())) + zones.append((wdg.x(), wdg.x() + wdg.width())) + + for idx, w, _, _ in wdgs: + if not w.start_pos: + continue + + try: + curr_idx = next( + ( + i + for i, z in enumerate(zones) + if pos.x() >= z[0] and pos.x() <= z[1] + ) + ) + except StopIteration: + break + + if curr_idx == idx: + w.start_pos = 0 + break + cast(QHBoxLayout, self.layout()).insertWidget(curr_idx, w) + w.start_pos = 0 + break + event.accept() + + +class _DragGroupBox(QGroupBox): + def __init__(self, name: str, start_pos: int = 0) -> None: + super().__init__() + self._name = name + self.start_pos = start_pos + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + drag = QDrag(self) + mime = QMimeData() + drag.setMimeData(mime) + self.start_pos = event.pos().x() + drag.exec_(Qt.DropAction.MoveAction) From 5dd37a60681bc3801af312d518cacd544987a27f Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Mar 2024 14:00:06 -0400 Subject: [PATCH 004/226] feat: restore layout at startup --- src/micromanager_gui/_main_window.py | 3 + src/micromanager_gui/_toolbar.py | 146 +++++++++++++++--- .../_widgets/_shutters_toolbar.py | 2 + 3 files changed, 131 insertions(+), 20 deletions(-) diff --git a/src/micromanager_gui/_main_window.py b/src/micromanager_gui/_main_window.py index 2921de7b..1518bf69 100644 --- a/src/micromanager_gui/_main_window.py +++ b/src/micromanager_gui/_main_window.py @@ -64,6 +64,9 @@ def __init__( self._wizard: HardwareConfigWizard | None = None + # load latest layout + self._toolbar._widgets_toolbar._load_layout() + def _add_menu(self) -> None: menubar = QMenuBar(self) diff --git a/src/micromanager_gui/_toolbar.py b/src/micromanager_gui/_toolbar.py index 5e2c7475..f258597a 100644 --- a/src/micromanager_gui/_toolbar.py +++ b/src/micromanager_gui/_toolbar.py @@ -1,10 +1,14 @@ from __future__ import annotations +import base64 +import json +from pathlib import Path from typing import TYPE_CHECKING, Any, cast from fonticon_mdi6 import MDI6 +from platformdirs import user_data_dir from pymmcore_widgets import MDAWidget, PropertyBrowser -from qtpy.QtCore import QSize, Qt +from qtpy.QtCore import QByteArray, QSize, Qt from qtpy.QtWidgets import ( QDockWidget, QPushButton, @@ -26,6 +30,10 @@ from ._main_window import MicroManagerGUI +# Path to the user data directory to store the layout +USER_DATA_DIR = Path(user_data_dir(appname="micromanager_gui")) +USER_LAYOUT_PATH = USER_DATA_DIR / "micromanager_gui_layout.json" + BTN_SIZE = (60, 40) ALLOWED_AREAS = ( Qt.DockWidgetArea.LeftDockWidgetArea @@ -51,8 +59,18 @@ class ScrollableDockWidget(QDockWidget): """A QDockWidget with a QScrollArea.""" - def __init__(self, title: str, parent: QWidget | None = None, *, widget: QWidget): + def __init__( + self, + title: str, + parent: QWidget | None = None, + *, + widget: QWidget, + objectName: str, + ): super().__init__(title, parent) + # set the object name + self.setObjectName(objectName) + # set allowed dock areas self.setAllowedAreas(ALLOWED_AREAS) @@ -78,21 +96,15 @@ def __init__(self, parent: MicroManagerGUI) -> None: self.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) # snap and live toolbar - self._snap_live_toolbar = QToolBar("Snap/Live Toolbar", self) - self._snap_live_toolbar.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) + self._snap_live_toolbar = SnapLiveToolBar(self) self._main_window.addToolBar( Qt.ToolBarArea.TopToolBarArea, self._snap_live_toolbar ) - self._snap_button = Snap() - self._live_button = Live() - self._snap_live_toolbar.addWidget(self._snap_button) - self._snap_live_toolbar.addWidget(self._live_button) # widgets toolbar self._widgets_toolbar = _WidgetsToolBar( self, main_window=self._main_window, main_toolbar=self ) - self._widgets_toolbar.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) self._main_window.addToolBar( Qt.ToolBarArea.TopToolBarArea, self._widgets_toolbar ) @@ -113,6 +125,23 @@ def contextMenuEvent(self, event: Any) -> None: menu.exec_(event.globalPos()) +class SnapLiveToolBar(QToolBar): + """A QToolBar containing QPushButtons for snap and live.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__("Snap/Live Toolbar", parent) + + self.setObjectName("Snap/Live ToolBar") + + self.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) + + self._snap_button = Snap() + self._live_button = Live() + + self.addWidget(self._snap_button) + self.addWidget(self._live_button) + + class _WidgetsToolBar(QToolBar): """A QToolBar containing QPushButtons for pymmcore-widgets. @@ -131,14 +160,16 @@ def __init__( ) -> None: super().__init__("Widgets ToolBar", parent) + self.setObjectName("Widgets ToolBar") + + self.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) + self._main_window = main_window self._main_toolbar = main_toolbar # keep track of the created widgets self._widgets: dict[str, ScrollableDockWidget] = {} - self.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) - for key in WIDGETS: _, windows_name, btn_icon = WIDGETS[key] btn = QPushButton() @@ -163,11 +194,12 @@ def _show_shutters_toolbar(self) -> None: else: self._main_toolbar._shutter_toolbar.show() - def _show_widget(self) -> None: + def _show_widget(self, key: str = "") -> None: """Show or raise a widget.""" - # using QPushButton.whatsThis() property to get the key. - btn = cast(QPushButton, self.sender()) - key = btn.whatsThis() + if not key: + # using QPushButton.whatsThis() property to get the key. + btn = cast(QPushButton, self.sender()) + key = btn.whatsThis() if key in self._widgets: # already exists @@ -193,13 +225,87 @@ def _create_widget(self, key: str) -> ScrollableDockWidget: wdg = wdg_cls(parent=self, mmcore=self._main_window._mmc) windows_title = WIDGETS[key][1] - dock = ScrollableDockWidget(windows_title, self, widget=wdg) + dock = ScrollableDockWidget(windows_title, self, widget=wdg, objectName=key) + self._connect_dock_widget(dock) self._main_window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock) dock.setFloating(True) self._widgets[key] = dock - # if the widget is an MDAWidget, connect the onRunClicked signal - # if isinstance(wdg, _MDAWidget): - # wdg.onRunClicked.connect(self._on_mda_run) - return dock + + def _connect_dock_widget(self, dock_wdg: QDockWidget) -> None: + """Connect the dock widget to the main window.""" + dock_wdg.visibilityChanged.connect(self._save_layout) + dock_wdg.topLevelChanged.connect(self._save_layout) + dock_wdg.dockLocationChanged.connect(self._save_layout) + + def _save_layout(self) -> None: + """Save the napa-micromanager layout to a json file. + + The json file has two keys: + - "layout_state" where the state of napari main window is stored using the + saveState() method. The state is base64 encoded to be able to save it to the + json file. + - "pymmcore_widgets" where the names of the docked pymmcore_widgets are stored. + + IMPORTANT: The "pymmcore_widgets" key is crucial in our layout saving process. + It stores the names of all active pymmcore_widgets at the time of saving. Before + restoring the layout, we must recreate these widgets. If not, they won't be + included in the restored layout. + """ + # get the names of the pymmcore_widgets that are part of the layout + pymmcore_wdgs: list[str] = [] + for dock_wdg in self._main_window.findChildren(ScrollableDockWidget): + wdg_name = dock_wdg.objectName() + if wdg_name in WIDGETS: + pymmcore_wdgs.append(wdg_name) + + # get the state of the napari main window as bytes + state_bytes = self._main_window.saveState().data() + + # Create dictionary with widget names and layout state. The layout state is + # base64 encoded to be able to save it to a json file. + data = { + "pymmcore_widgets": pymmcore_wdgs, + "layout_state": base64.b64encode(state_bytes).decode(), + } + + # if the user layout path does not exist, create it + if not USER_LAYOUT_PATH.exists(): + USER_DATA_DIR.mkdir(parents=True, exist_ok=True) + + try: + with open(USER_LAYOUT_PATH, "w") as json_file: + json.dump(data, json_file) + except Exception as e: + print(f"Was not able to save layout to file. Error: {e}") + + def _load_layout(self) -> None: + """Load the napari-micromanager layout from a json file.""" + if not USER_LAYOUT_PATH.exists(): + return + + try: + with open(USER_LAYOUT_PATH) as f: + data = json.load(f) + + # get the layout state bytes + state_bytes = data.get("layout_state") + + if state_bytes is None: + return + + # add pymmcore_widgets to the main window + pymmcore_wdgs = data.get("pymmcore_widgets", []) + for wdg_name in pymmcore_wdgs: + if wdg_name in WIDGETS: + self._show_widget(wdg_name) + + # Convert base64 encoded string back to bytes + state_bytes = base64.b64decode(state_bytes) + + # restore the layout state + self._main_window.restoreState(QByteArray(state_bytes)) + + except Exception as e: + print(f"Was not able to load layout from file. Error: {e}") diff --git a/src/micromanager_gui/_widgets/_shutters_toolbar.py b/src/micromanager_gui/_widgets/_shutters_toolbar.py index f4ac4250..b59671d2 100644 --- a/src/micromanager_gui/_widgets/_shutters_toolbar.py +++ b/src/micromanager_gui/_widgets/_shutters_toolbar.py @@ -14,6 +14,8 @@ def __init__( ) -> None: super().__init__("Shutters ToolBar", parent) + self.setObjectName("Shutters ToolBar") + self.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) self._mmc = mmcore or CMMCorePlus.instance() From 6c141a3dc1101717f8cd9d2ca67eadcf490f6fd8 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Mar 2024 14:05:22 -0400 Subject: [PATCH 005/226] feat: close all viewers when the main window is closed --- src/micromanager_gui/_main_window.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/micromanager_gui/_main_window.py b/src/micromanager_gui/_main_window.py index 1518bf69..cd18cc58 100644 --- a/src/micromanager_gui/_main_window.py +++ b/src/micromanager_gui/_main_window.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from pymmcore_plus import CMMCorePlus from pymmcore_widgets.hcwizard.intro_page import SRC_CONFIG from qtpy.QtCore import Qt @@ -20,6 +22,9 @@ ) from ._widgets._config_wizard import HardwareConfigWizard +if TYPE_CHECKING: + from qtpy.QtGui import QCloseEvent + FLAGS = Qt.WindowType.Dialog DEFAULT = "Experiment" ALLOWED_AREAS = ( @@ -108,3 +113,9 @@ def _show_config_wizard(self) -> None: current_cfg = self._mmc.systemConfigurationFile() or "" self._wizard.setField(SRC_CONFIG, current_cfg) self._wizard.show() + + def closeEvent(self, event: QCloseEvent) -> None: + # close all viewers + for viewer in self._core_link._viewers: + viewer.close() + super().closeEvent(event) From ba341702e5b1048cd55c27ebf379ef7f7c829a97 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Mar 2024 14:09:54 -0400 Subject: [PATCH 006/226] feat: add __main__ --- src/micromanager_gui/__main__.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/micromanager_gui/__main__.py b/src/micromanager_gui/__main__.py index e69de29b..c34500f9 100644 --- a/src/micromanager_gui/__main__.py +++ b/src/micromanager_gui/__main__.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from qtpy.QtWidgets import QApplication + +from micromanager_gui import MicroManagerGUI + + +def main() -> None: + """Run the Micro-Manager GUI.""" + app = QApplication([]) + win = MicroManagerGUI() + win.show() + app.exec_() + + +if __name__ == "__main__": + main() From 2d4f5de245ecc7e41e2bb34490ad242b205b0bc2 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Mar 2024 14:30:14 -0400 Subject: [PATCH 007/226] feat: handle system configs at startup --- src/micromanager_gui/__main__.py | 22 +- src/micromanager_gui/_init_system_config.py | 256 ++++++++++++++++++++ src/micromanager_gui/_main_window.py | 23 +- src/micromanager_gui/_toolbar.py | 13 +- src/micromanager_gui/_util.py | 4 +- 5 files changed, 302 insertions(+), 16 deletions(-) create mode 100644 src/micromanager_gui/_init_system_config.py diff --git a/src/micromanager_gui/__main__.py b/src/micromanager_gui/__main__.py index c34500f9..821f5839 100644 --- a/src/micromanager_gui/__main__.py +++ b/src/micromanager_gui/__main__.py @@ -1,14 +1,32 @@ from __future__ import annotations +import argparse +import sys +from typing import Sequence + from qtpy.QtWidgets import QApplication from micromanager_gui import MicroManagerGUI -def main() -> None: +def main(args: Sequence[str] | None = None) -> None: """Run the Micro-Manager GUI.""" + if args is None: + args = sys.argv[1:] + + parser = argparse.ArgumentParser(description="Enter string") + parser.add_argument( + "-c", + "--config", + type=str, + default=None, + help="Config file to load", + nargs="?", + ) + parsed_args = parser.parse_args(args) + app = QApplication([]) - win = MicroManagerGUI() + win = MicroManagerGUI(config=parsed_args.config) win.show() app.exec_() diff --git a/src/micromanager_gui/_init_system_config.py b/src/micromanager_gui/_init_system_config.py new file mode 100644 index 00000000..292b609d --- /dev/null +++ b/src/micromanager_gui/_init_system_config.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import cast +from warnings import warn + +from pymmcore_plus import CMMCorePlus, find_micromanager +from pymmcore_widgets import ConfigWizard +from pymmcore_widgets.hcwizard.finish_page import DEST_CONFIG +from qtpy.QtCore import QObject +from qtpy.QtWidgets import ( + QComboBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QGridLayout, + QLabel, + QPushButton, + QSizePolicy, + QWidget, +) + +from micromanager_gui._util import ( + USER_CONFIGS_PATHS, + USER_DIR, + add_path_to_config_json, + load_sys_config, +) + +FIXED = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) +NEW = "New Hardware Configuration" + + +class InitializeSystemConfigurations(QObject): + def __init__( + self, + parent: QObject | None = None, + config: Path | str | None = None, + mmcore: CMMCorePlus | None = None, + ) -> None: + super().__init__(parent) + + self._mmc = mmcore or CMMCorePlus.instance() + + self._initialize() + + # if a config is provided, load it + if config is not None: + # add the config to the system configurations json and set it as the + # current configuration path. + add_path_to_config_json(config) + load_sys_config(config) + # if no config is provided, show a dialog to select one or to create a new one + else: + self._startup_dialog = StartupConfigurationsDialog( + parent=self.parent(), config=config, mmcore=self._mmc + ) + self._startup_dialog.show() + + def _initialize(self) -> None: + """Create or update the list of Micro-Manager hardware configurations paths. + + This method is called everytime napari-micromanager is loaded and it updates (or + create if does not yet exists) the list of Micro-Manager configurations paths + saved in the USER_CONFIGS_PATHS as a json file. + """ + # create USER_CONFIGS_PATHS if it doesn't exist + if not USER_CONFIGS_PATHS.exists(): + USER_DIR.mkdir(parents=True, exist_ok=True) + with open(USER_CONFIGS_PATHS, "w") as f: + json.dump({"paths": []}, f) + + # get the paths from the json file + configs_paths = self._get_config_paths() + + # write the data back to the file + with open(USER_CONFIGS_PATHS, "w") as f: + json.dump({"paths": configs_paths}, f) + + def _get_config_paths(self) -> list[str]: + """Return the paths from the json file. + + If a file stored in the json file doesn't exist, it is removed from the list. + + The method also adds all the .cfg files in the MicroManager folder to the list + if they are not already there. + """ + try: + with open(USER_CONFIGS_PATHS) as f: + data = json.load(f) + + # get path list from json file + paths = cast(list, data.get("paths", [])) + + # remove any path that doesn't exist + for path in paths: + if not Path(path).exists(): + paths.remove(path) + + # get all the .cfg files in the MicroManager folder + cfg_files = self._get_micromanager_cfg_files() + + # add all the .cfg files to the list if they are not already there + for cfg in reversed(cfg_files): + if str(cfg) not in paths: + # using insert so we leave the empty string at the end + paths.insert(0, str(cfg)) + + except json.JSONDecodeError: + paths = [] + warn("Error reading the json file.", stacklevel=2) + + return paths + + def _get_micromanager_cfg_files(self) -> list[Path]: + """Return all the .cfg files from all the MicroManager folders.""" + mm: list = find_micromanager(False) + cfg_files: list[Path] = [] + for mm_dir in mm: + cfg_files.extend(Path(mm_dir).glob("*.cfg")) + + return cfg_files + + +class StartupConfigurationsDialog(QDialog): + """A dialog to select the Micro-Manager Hardware configuration files at startup.""" + + def __init__( + self, + parent: QWidget | None = None, + *, + config: Path | str | None = None, + mmcore: CMMCorePlus | None = None, + ) -> None: + super().__init__(parent) + self.setWindowTitle("Micro-Manager Hardware System Configurations") + + self._mmc = mmcore or CMMCorePlus.instance() + self._config = config + + # label + cfg_lbl = QLabel("Configuration file:") + cfg_lbl.setSizePolicy(FIXED) + + # combo box + self.cfg_combo = QComboBox() + # `AdjustToMinimumContents` is not available in all qtpy backends so using + # `AdjustToMinimumContentsLengthWithIcon` instead + self.cfg_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + + # browse button + self.browse_btn = QPushButton("...") + self.browse_btn.setSizePolicy(FIXED) + self.browse_btn.clicked.connect(self._on_browse_clicked) + + # Create OK and Cancel buttons + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + # add widgets to layout + wdg_layout = QGridLayout(self) + wdg_layout.addWidget(cfg_lbl, 0, 0) + wdg_layout.addWidget(self.cfg_combo, 0, 1) + wdg_layout.addWidget(self.browse_btn, 0, 2) + wdg_layout.addWidget(button_box, 2, 0, 1, 3) + + self._initialize() + + def accept(self) -> None: + super().accept() + config = self.cfg_combo.currentText() + # if current text is not at index 0, update the json file and insert it at the + # first position so it will be shown as the fort option in the combo box next + # time the dialog is shown. + if config != self.cfg_combo.itemText(0): + add_path_to_config_json(config) + + # if the user selected NEW, show the config wizard + if config == NEW: + self._cfg_wizard = HardwareConfigWizard(parent=self) + self._cfg_wizard.show() + else: + load_sys_config(config) + + def _initialize(self) -> None: + """Initialize the dialog with the Micro-Manager configuration files.""" + # return if the json file doesn't exist + if not USER_CONFIGS_PATHS.exists(): + return + + # Read the existing data + try: + with open(USER_CONFIGS_PATHS) as f: + configs_paths = json.load(f) + except json.JSONDecodeError: + configs_paths = {"paths": []} + + configs_paths = cast(list, configs_paths.get("paths", [])) + # add the paths to the combo box + self.cfg_combo.addItems([*configs_paths, NEW]) + + # resize the widget so its width is not too small + self.resize(600, self.minimumSizeHint().height()) + + def _on_browse_clicked(self) -> None: + """Open a file dialog to select a file. + + If a file path is provided, it is added to the USER_CONFIGS_PATHS json file and + to the combo box. + """ + path, _ = QFileDialog.getOpenFileName( + self, "Open file", "", "MicroManager files (*.cfg)" + ) + if path: + # using insert so we leave the empty string at the end + self.cfg_combo.insertItem(0, path) + self.cfg_combo.setCurrentText(path) + # add the config to the system configurations json and set it as the + # current configuration path. + add_path_to_config_json(path) + + +class HardwareConfigWizard(ConfigWizard): + """A wizard to create a new Micro-Manager hardware configuration file. + + Subclassing to load the newly created configuration file and to add it to the + USER_CONFIGS_PATHS json file. + """ + + def __init__( + self, + config_file: str = "", + core: CMMCorePlus | None = None, + parent: QWidget | None = None, + ): + super().__init__(config_file, core, parent) + + self.setWindowTitle("Micro-Manager Hardware Configuration Wizard") + + def accept(self) -> None: + """Accept the wizard and save the configuration to a file. + + Overriding to add the new configuration file to the USER_CONFIGS_PATHS json file + and to load it. + """ + super().accept() + dest = self.field(DEST_CONFIG) + # add the path to the USER_CONFIGS_PATHS list + add_path_to_config_json(dest) + load_sys_config(dest) diff --git a/src/micromanager_gui/_main_window.py b/src/micromanager_gui/_main_window.py index cd18cc58..2f092b17 100644 --- a/src/micromanager_gui/_main_window.py +++ b/src/micromanager_gui/_main_window.py @@ -15,11 +15,9 @@ ) from ._core_link import _CoreLink +from ._init_system_config import InitializeSystemConfigurations from ._toolbar import MainToolBar -from ._util import ( - load_sys_config_dialog, - save_sys_config_dialog, -) +from ._util import load_sys_config_dialog, save_sys_config_dialog from ._widgets._config_wizard import HardwareConfigWizard if TYPE_CHECKING: @@ -36,7 +34,11 @@ class MicroManagerGUI(QMainWindow): def __init__( - self, parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None + self, + parent: QWidget | None = None, + *, + mmcore: CMMCorePlus | None = None, + config: str | None = None, ) -> None: super().__init__(parent) @@ -72,6 +74,17 @@ def __init__( # load latest layout self._toolbar._widgets_toolbar._load_layout() + # handle the system configurations at startup. + # with this we create/updatethe list of the Micro-Manager hardware system + # configurations files path stored as a json file in the user's configuration + # file directory (USER_CONFIGS_PATHS). + # a dialog will be also displayed if no system configuration file is + # provided to either select one from the list of available ones or to create + # a new one. + self._init_cfg = InitializeSystemConfigurations( + parent=self, config=config, mmcore=self._mmc + ) + def _add_menu(self) -> None: menubar = QMenuBar(self) diff --git a/src/micromanager_gui/_toolbar.py b/src/micromanager_gui/_toolbar.py index f258597a..ad023abb 100644 --- a/src/micromanager_gui/_toolbar.py +++ b/src/micromanager_gui/_toolbar.py @@ -2,11 +2,9 @@ import base64 import json -from pathlib import Path from typing import TYPE_CHECKING, Any, cast from fonticon_mdi6 import MDI6 -from platformdirs import user_data_dir from pymmcore_widgets import MDAWidget, PropertyBrowser from qtpy.QtCore import QByteArray, QSize, Qt from qtpy.QtWidgets import ( @@ -19,6 +17,11 @@ ) from superqt.fonticon import icon +from micromanager_gui._util import ( + USER_DIR, + USER_LAYOUT_PATH, +) + from ._widgets._camera_roi import _CameraRoiWidget from ._widgets._group_and_preset import _GroupsAndPresets from ._widgets._pixel_configurations import _PixelConfigurationWidget @@ -30,10 +33,6 @@ from ._main_window import MicroManagerGUI -# Path to the user data directory to store the layout -USER_DATA_DIR = Path(user_data_dir(appname="micromanager_gui")) -USER_LAYOUT_PATH = USER_DATA_DIR / "micromanager_gui_layout.json" - BTN_SIZE = (60, 40) ALLOWED_AREAS = ( Qt.DockWidgetArea.LeftDockWidgetArea @@ -272,7 +271,7 @@ def _save_layout(self) -> None: # if the user layout path does not exist, create it if not USER_LAYOUT_PATH.exists(): - USER_DATA_DIR.mkdir(parents=True, exist_ok=True) + USER_DIR.mkdir(parents=True, exist_ok=True) try: with open(USER_LAYOUT_PATH, "w") as json_file: diff --git a/src/micromanager_gui/_util.py b/src/micromanager_gui/_util.py index e71862e6..7e092caa 100644 --- a/src/micromanager_gui/_util.py +++ b/src/micromanager_gui/_util.py @@ -13,8 +13,8 @@ ) from superqt.utils import signals_blocked -PLATE_FROM_CALIBRATION = "custom_from_calibration" -USER_DIR = Path(user_config_dir("napari_micromanager")) +USER_DIR = Path(user_config_dir("micromanager_gui")) +USER_LAYOUT_PATH = USER_DIR / "micromanager_gui_layout.json" USER_CONFIGS_PATHS = USER_DIR / "system_configurations.json" From 20dafaf00c70404631a806f37803676c4b22c715 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Mar 2024 14:46:04 -0400 Subject: [PATCH 008/226] docs: readme --- README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 19609ca4..d53146cf 100644 --- a/README.md +++ b/README.md @@ -6,4 +6,29 @@ [![CI](https://github.com/fdrgsp/micromanager-gui/actions/workflows/ci.yml/badge.svg)](https://github.com/fdrgsp/micromanager-gui/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/fdrgsp/micromanager-gui/branch/main/graph/badge.svg)](https://codecov.io/gh/fdrgsp/micromanager-gui) -A Micro-Manager GUI based on pymmcore-widgets and pymmcore-plus. +A Micro-Manager GUI based on [pymmcore-widgets](https://pymmcore-plus.github.io/pymmcore-widgets/) and [pymmcore-plus](https://pymmcore-plus.github.io/pymmcore-plus/). + + +## Installation + +```bash +pip install git+https://github.com/fdrgsp/micromanager-gui +``` + +### Installing PyQt or PySide + +Since `micromanager-gui` relies on either the [PyQt](https://riverbankcomputing.com/software/pyqt/) or [PySide](https://www.qt.io/qt-for-python) libraries, you also **need** to install one of these packages. You can use any of the available versions of these libraries: [PyQt5](https://pypi.org/project/PyQt5/), [PyQt6](https://pypi.org/project/PyQt6/), [PySide2](https://pypi.org/project/PySide2/) or [PySide6](https://pypi.org/project/PySide6/). For example, to install [PyQt6](https://riverbankcomputing.com/software/pyqt/download), you can use: + +```sh +pip install PyQt6 +``` + +### Installing Micro-Manager + +You also need to install the `Micro-Manager` device adapters and C++ core provided by [mmCoreAndDevices](https://github.com/micro-manager/mmCoreAndDevices#mmcoreanddevices). This can be done by following the steps described in the `pymmcore-plus` [documentation page](https://pymmcore-plus.github.io/pymmcore-plus/install/#installing-micro-manager-device-adapters). + +## To run the GUI + +```bash +python -m micromanager_gui +``` From 6b6fe2ff9a2a0869a8669c8c178b807ec999a7bc Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Tue, 19 Mar 2024 14:46:36 -0400 Subject: [PATCH 009/226] docs: Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d53146cf..e7d5ab5a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A Micro-Manager GUI based on [pymmcore-widgets](https://pymmcore-plus.github.io/pymmcore-widgets/) and [pymmcore-plus](https://pymmcore-plus.github.io/pymmcore-plus/). +Screenshot 2024-03-19 at 2 42 00 PM + ## Installation From c3b1f7b97eb50a27bcb137af695619ecda8ffde7 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Mar 2024 14:51:35 -0400 Subject: [PATCH 010/226] fix: add zarr to dependencies --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3607f6e7..de8f4c06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,8 @@ dependencies = [ "pymmcore-widgets >= 0.7.1", "pymmcore-plus >= 0.9.4", "qtpy", - "vispy" + "vispy", + "zarr" ] # extras From d822293af4d63030bf26197457559f6d33273fbe Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Mar 2024 15:21:53 -0400 Subject: [PATCH 011/226] fix: _canvas_size --- src/micromanager_gui/_core_link.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index e96ee38f..ccb3541b 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -5,6 +5,7 @@ from pymmcore_plus import CMMCorePlus from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY from qtpy.QtCore import QObject, Qt +from qtpy.QtGui import QScreen from ._widgets._mda._mda_viewer import MDAViewer from ._widgets._preview import Preview @@ -24,7 +25,10 @@ def __init__(self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None self._mmc = mmcore or CMMCorePlus.instance() self._main_window = parent - self._canvas_size = (parent.size().height(), parent.size().height()) + + # set max canvas size to the screen height + screen_height = QScreen().geometry().height() + self._canvas_size = (screen_height, screen_height) self._is_mda_running: bool = False From 51c10a83dc2d194459eda665f8edbfcbfe264372 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Mar 2024 15:29:57 -0400 Subject: [PATCH 012/226] fix: _canvas_size --- src/micromanager_gui/_core_link.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index ccb3541b..08412be8 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -5,7 +5,7 @@ from pymmcore_plus import CMMCorePlus from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY from qtpy.QtCore import QObject, Qt -from qtpy.QtGui import QScreen +from qtpy.QtWidgets import QApplication from ._widgets._mda._mda_viewer import MDAViewer from ._widgets._preview import Preview @@ -27,7 +27,8 @@ def __init__(self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None self._main_window = parent # set max canvas size to the screen height - screen_height = QScreen().geometry().height() + app = QApplication.instance() + screen_height = app.primaryScreen().geometry().height() self._canvas_size = (screen_height, screen_height) self._is_mda_running: bool = False From 542abcb6bb7c42c886edd599adebdda9028b90a3 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Mar 2024 15:50:03 -0400 Subject: [PATCH 013/226] fix: _lut_slider --- src/micromanager_gui/_widgets/_preview.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/micromanager_gui/_widgets/_preview.py b/src/micromanager_gui/_widgets/_preview.py index 0a665081..ee7b753b 100644 --- a/src/micromanager_gui/_widgets/_preview.py +++ b/src/micromanager_gui/_widgets/_preview.py @@ -123,9 +123,17 @@ def __init__( btn_wdg_layout.addWidget(self._reset_view) # btn_wdg_layout.addWidget(self._save) main_layout.addWidget(btn_wdg) + + # connections + self._mmc.events.systemConfigurationLoaded.connect(self._on_sys_cfg_loaded) + + self.destroyed.connect(self._disconnect) self._reset() self._on_sys_cfg_loaded() + + def _disconnect(self) -> None: + self._mmc.events.systemConfigurationLoaded.disconnect(self._on_sys_cfg_loaded) def _on_sys_cfg_loaded(self) -> None: """Update the LUT slider range and the canvas size.""" From 55d9c6e8ec20bed5a47f7bba67525caa87d13271 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Mar 2024 16:08:24 -0400 Subject: [PATCH 014/226] fix: spaces --- src/micromanager_gui/_widgets/_preview.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/micromanager_gui/_widgets/_preview.py b/src/micromanager_gui/_widgets/_preview.py index ee7b753b..c1003512 100644 --- a/src/micromanager_gui/_widgets/_preview.py +++ b/src/micromanager_gui/_widgets/_preview.py @@ -123,7 +123,7 @@ def __init__( btn_wdg_layout.addWidget(self._reset_view) # btn_wdg_layout.addWidget(self._save) main_layout.addWidget(btn_wdg) - + # connections self._mmc.events.systemConfigurationLoaded.connect(self._on_sys_cfg_loaded) @@ -131,7 +131,7 @@ def __init__( self._reset() self._on_sys_cfg_loaded() - + def _disconnect(self) -> None: self._mmc.events.systemConfigurationLoaded.disconnect(self._on_sys_cfg_loaded) From 1ea2f55d81c6a280fbf7b086d70b0c951da86106 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Mar 2024 15:57:00 +0000 Subject: [PATCH 015/226] ci(dependabot): bump softprops/action-gh-release from 1 to 2 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 608ef379..8a26a99c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,6 +95,6 @@ jobs: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} - - uses: softprops/action-gh-release@v1 + - uses: softprops/action-gh-release@v2 with: generate_release_notes: true From 52ba2c564f7c7e61c855c5d97b3984a0c526df0b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Mar 2024 15:57:03 +0000 Subject: [PATCH 016/226] ci(dependabot): bump actions/checkout from 3 to 4 Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a26a99c..ad7abb26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: check-manifest: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: pipx run check-manifest test: @@ -32,7 +32,7 @@ jobs: platform: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 @@ -74,7 +74,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 From 42c55d3db1de26a3e3bde62009e5ddea10f3b355 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Mar 2024 15:57:03 +0000 Subject: [PATCH 017/226] ci(dependabot): bump actions/setup-python from 4 to 5 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad7abb26..c21b8456 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache-dependency-path: "pyproject.toml" @@ -79,7 +79,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.x" From 5fee5ec6ae66f7b69348540fa285e5b306f80644 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Mar 2024 15:57:08 +0000 Subject: [PATCH 018/226] ci(dependabot): bump codecov/codecov-action from 3 to 4 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3 to 4. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v3...v4) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c21b8456..28692e06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: update_existing: true - name: Coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 deploy: name: Deploy From d7b5176ad8089a9a951eff2dd691fa4d50bfe645 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Mar 2024 16:15:36 -0400 Subject: [PATCH 019/226] fix: update dependancies --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index de8f4c06..96e28254 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,8 @@ dependencies = [ "pymmcore-plus >= 0.9.4", "qtpy", "vispy", - "zarr" + "zarr", + "tifffile" ] # extras From 7b4318c4425b79ccc06e465cee2be214cc51bf3c Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Mar 2024 16:18:01 -0400 Subject: [PATCH 020/226] fix: docstring --- src/micromanager_gui/_widgets/_mda/_mda_viewer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/micromanager_gui/_widgets/_mda/_mda_viewer.py b/src/micromanager_gui/_widgets/_mda/_mda_viewer.py index 13165560..8e17e603 100644 --- a/src/micromanager_gui/_widgets/_mda/_mda_viewer.py +++ b/src/micromanager_gui/_widgets/_mda/_mda_viewer.py @@ -429,7 +429,7 @@ def cmap(self, cmap: str = "grays") -> None: self._cmap = cmap def _on_save(self) -> None: - """Save the data as a tif or zarr.""" + """Save the data as a zarr.""" save_path, _ = QFileDialog.getSaveFileName( self, "Saving directory and filename.", From c276b5ec4c677f6f41741d6bbcaea0bfade5a3bd Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 19 Mar 2024 23:19:39 -0400 Subject: [PATCH 021/226] fix: example --- examples/gui.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/gui.py b/examples/gui.py index 174b57c7..59f7bd20 100644 --- a/examples/gui.py +++ b/examples/gui.py @@ -3,10 +3,8 @@ from micromanager_gui import MicroManagerGUI +app = QApplication([]) mmc = CMMCorePlus.instance() -mmc.loadSystemConfiguration() gui = MicroManagerGUI() gui.show() - -app = QApplication.instance() -app.setStyle("Fusion") +app.exec_() From 1829effe7523546ca8513c564c06b3c693831c08 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 20 Mar 2024 13:44:36 -0400 Subject: [PATCH 022/226] fix: roiSet + remove shutter toolbar button --- src/micromanager_gui/_toolbar.py | 3 +-- src/micromanager_gui/_widgets/_preview.py | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/micromanager_gui/_toolbar.py b/src/micromanager_gui/_toolbar.py index ad023abb..4beb9a4f 100644 --- a/src/micromanager_gui/_toolbar.py +++ b/src/micromanager_gui/_toolbar.py @@ -44,10 +44,9 @@ # fmt: off # key: (widget, window name, icon) WIDGETS: dict[str, tuple[type[QWidget], str, str | None]] = { - "Shutters": (_ShuttersToolbar, "Shutters Control", MDI6.hexagon_slice_6), - "Camera ROI": (_CameraRoiWidget, "Camera ROI", MDI6.crop), "Property Browser": (PropertyBrowser, "Device Property Browser", MDI6.table_large), "Group Presets": (_GroupsAndPresets, "Group & Presets Table", MDI6.table_large_plus), # noqa: E501 + "Camera ROI": (_CameraRoiWidget, "Camera ROI", MDI6.crop), "Stages": (_StagesControlWidget, "Stages Control", MDI6.arrow_all), "Pixel": (_PixelConfigurationWidget, "Pixel Configuration Table", None), "MDA": (MDAWidget, "Multi-Dimensional Acquisition", None), diff --git a/src/micromanager_gui/_widgets/_preview.py b/src/micromanager_gui/_widgets/_preview.py index c1003512..4599c445 100644 --- a/src/micromanager_gui/_widgets/_preview.py +++ b/src/micromanager_gui/_widgets/_preview.py @@ -126,6 +126,7 @@ def __init__( # connections self._mmc.events.systemConfigurationLoaded.connect(self._on_sys_cfg_loaded) + self._mmc.events.roiSet.connect(self._reset) self.destroyed.connect(self._disconnect) From 2938343580504c84f1580393cf5ca24feb68f35e Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Sat, 23 Mar 2024 15:17:21 -0400 Subject: [PATCH 023/226] fix: _save_layout --- src/micromanager_gui/_toolbar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/micromanager_gui/_toolbar.py b/src/micromanager_gui/_toolbar.py index 4beb9a4f..644b4fca 100644 --- a/src/micromanager_gui/_toolbar.py +++ b/src/micromanager_gui/_toolbar.py @@ -255,7 +255,7 @@ def _save_layout(self) -> None: pymmcore_wdgs: list[str] = [] for dock_wdg in self._main_window.findChildren(ScrollableDockWidget): wdg_name = dock_wdg.objectName() - if wdg_name in WIDGETS: + if wdg_name in WIDGETS and not dock_wdg.isFloating(): pymmcore_wdgs.append(wdg_name) # get the state of the napari main window as bytes From 6432b61ccec24473ddfdf32dbed8b5dbf947c1a8 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Sat, 23 Mar 2024 17:51:03 -0400 Subject: [PATCH 024/226] docs: Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e7d5ab5a..59c582a7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# micromanager-gui +# micromanager-gui [WIP] [![License](https://img.shields.io/pypi/l/micromanager-gui.svg?color=green)](https://github.com/fdrgsp/micromanager-gui/raw/main/LICENSE) [![PyPI](https://img.shields.io/pypi/v/micromanager-gui.svg?color=green)](https://pypi.org/project/micromanager-gui) From 07162cd62ab862a2cfcb7186ae6daecd580b863a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 May 2024 19:42:34 +0000 Subject: [PATCH 025/226] ci(pre-commit.ci): autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - https://github.com/charliermarsh/ruff-pre-commit → https://github.com/astral-sh/ruff-pre-commit - [github.com/astral-sh/ruff-pre-commit: v0.3.3 → v0.4.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.3...v0.4.3) - [github.com/psf/black: 24.3.0 → 24.4.2](https://github.com/psf/black/compare/24.3.0...24.4.2) - [github.com/pre-commit/mirrors-mypy: v1.9.0 → v1.10.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.9.0...v1.10.0) --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index aff3cc73..392a33c4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,14 +12,14 @@ repos: # - id: conventional-pre-commit # stages: [commit-msg] - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.3.3 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.3 hooks: - id: ruff args: [--fix] - repo: https://github.com/psf/black - rev: 24.3.0 + rev: 24.4.2 hooks: - id: black @@ -29,7 +29,7 @@ repos: - id: validate-pyproject - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.9.0 + rev: v1.10.0 hooks: - id: mypy files: "^src/" From 3e4054b07dbce6fb2acb1904a25cd18ad64d959f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 7 May 2024 16:31:26 -0400 Subject: [PATCH 026/226] feat: example using new MDAViewer --- src/micromanager_gui/_core_link.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index 08412be8..d7afb6a5 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -3,11 +3,11 @@ from typing import TYPE_CHECKING, cast from pymmcore_plus import CMMCorePlus +from pymmcore_widgets._stack_viewer_v2 import MDAViewer from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY from qtpy.QtCore import QObject, Qt from qtpy.QtWidgets import QApplication -from ._widgets._mda._mda_viewer import MDAViewer from ._widgets._preview import Preview DIALOG = Qt.WindowType.Dialog @@ -72,9 +72,7 @@ def _show_preview(self) -> None: self._preview.raise_() def _setup_viewer(self, sequence: useq.MDASequence) -> None: - self._current_viewer = MDAViewer( - self._main_window, mmcore=self._mmc, canvas_size=self._canvas_size - ) + self._current_viewer = MDAViewer(parent=self._main_window) # rename the viewer if there is a save_name in the metadata or add a digit save_meta = cast(dict, sequence.metadata.get(PYMMCW_METADATA_KEY, {})) @@ -88,7 +86,7 @@ def _setup_viewer(self, sequence: useq.MDASequence) -> None: # call it manually indted in _connect_viewer because this signal has been # emitted already - self._current_viewer.sequenceStarted(sequence) + self._current_viewer.data.sequenceStarted(sequence) # connect the signals self._connect_viewer(self._current_viewer) @@ -102,13 +100,13 @@ def _setup_viewer(self, sequence: useq.MDASequence) -> None: self._viewers.append(self._current_viewer) def _connect_viewer(self, viewer: MDAViewer) -> None: - self._mmc.mda.events.sequenceFinished.connect(viewer.sequenceFinished) - self._mmc.mda.events.frameReady.connect(viewer.frameReady) + self._mmc.mda.events.sequenceFinished.connect(viewer.data.sequenceFinished) + self._mmc.mda.events.frameReady.connect(viewer.data.frameReady) def _disconnect_viewer(self, viewer: MDAViewer) -> None: """Disconnect the signals.""" - self._mmc.mda.events.sequenceFinished.disconnect(viewer.sequenceFinished) - self._mmc.mda.events.frameReady.disconnect(viewer.frameReady) + self._mmc.mda.events.sequenceFinished.disconnect(viewer.data.sequenceFinished) + self._mmc.mda.events.frameReady.disconnect(viewer.data.frameReady) def _on_sequence_started(self, sequence: useq.MDASequence) -> None: """Show the MDAViewer when the MDA sequence starts.""" From 60c1a5cee11b8129436cc4dad5840eeb46c3e7cd Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Mon, 27 May 2024 11:56:14 -0400 Subject: [PATCH 027/226] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 59c582a7..8c140fa5 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Micro-Manager GUI based on [pymmcore-widgets](https://pymmcore-plus.github.io/pymmcore-widgets/) and [pymmcore-plus](https://pymmcore-plus.github.io/pymmcore-plus/). -Screenshot 2024-03-19 at 2 42 00 PM +Screenshot 2024-05-27 at 11 55 53 AM ## Installation From beaa562b678d68262bbcf6fd12ae20136ddc564d Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 27 May 2024 11:36:17 -0400 Subject: [PATCH 028/226] feat: update logic --- .DS_Store | Bin 0 -> 6148 bytes pyproject.toml | 7 +- src/.DS_Store | Bin 0 -> 8196 bytes src/micromanager_gui/.DS_Store | Bin 0 -> 6148 bytes src/micromanager_gui/_core_link.py | 195 +++++--- src/micromanager_gui/_init_system_config.py | 256 ---------- src/micromanager_gui/_main_window.py | 175 +++---- src/micromanager_gui/_menubar/_menubar.py | 207 ++++++++ .../_readers/_tensorstore_zarr_reader.py | 164 +++++++ src/micromanager_gui/_toolbar.py | 309 ------------ .../_shutters_toolbar.py | 0 src/micromanager_gui/_toolbar/_snap_live.py | 32 ++ src/micromanager_gui/_util.py | 101 ---- src/micromanager_gui/_widgets/_camera_roi.py | 18 - .../_widgets/_config_wizard.py | 45 -- .../_widgets/_group_and_preset.py | 32 -- .../_widgets/_mda/_mda_viewer.py | 441 ------------------ .../_widgets/_mda/_sliders.py | 110 ----- src/micromanager_gui/_widgets/_mda_widget.py | 163 +++++++ .../_widgets/_pixel_configurations.py | 44 -- src/micromanager_gui/_widgets/_preview.py | 211 +++++---- ...snap_and_live.py => _snap_live_buttons.py} | 21 +- 22 files changed, 901 insertions(+), 1630 deletions(-) create mode 100644 .DS_Store create mode 100644 src/.DS_Store create mode 100644 src/micromanager_gui/.DS_Store delete mode 100644 src/micromanager_gui/_init_system_config.py create mode 100644 src/micromanager_gui/_menubar/_menubar.py create mode 100644 src/micromanager_gui/_readers/_tensorstore_zarr_reader.py delete mode 100644 src/micromanager_gui/_toolbar.py rename src/micromanager_gui/{_widgets => _toolbar}/_shutters_toolbar.py (100%) create mode 100644 src/micromanager_gui/_toolbar/_snap_live.py delete mode 100644 src/micromanager_gui/_util.py delete mode 100644 src/micromanager_gui/_widgets/_camera_roi.py delete mode 100644 src/micromanager_gui/_widgets/_config_wizard.py delete mode 100644 src/micromanager_gui/_widgets/_group_and_preset.py delete mode 100644 src/micromanager_gui/_widgets/_mda/_mda_viewer.py delete mode 100644 src/micromanager_gui/_widgets/_mda/_sliders.py create mode 100644 src/micromanager_gui/_widgets/_mda_widget.py delete mode 100644 src/micromanager_gui/_widgets/_pixel_configurations.py rename src/micromanager_gui/_widgets/{_snap_and_live.py => _snap_live_buttons.py} (70%) diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..8b270c71e2a005e2188fa38b11f70cd6d939f130 GIT binary patch literal 6148 zcmeHK!AiqG5S?wSZV;gdg&qT51*?J)yo6eRz=$4HYGQ&0(`;!{E0jXc`a^z+-{Z{g zTGZ-E5GgY-`zEt9o6g&^n*jjP8O1vQO#qOngf$0;Z-nMa=cHym4Md^W7(fOI6mfBr z%|)~0KQcgjCvndnA%s4B*uO}r3^x_(P{0swqH!^ZG8LicB^qRDQMTK!qE>G#Ew9KG zxhk)MJ2eiT;P(vB`QV}w`W7pL z`slzxw*ZLr8L0(r>Ln=0wdh-{4B`xmFsX= 0.7.1", - "pymmcore-plus >= 0.9.4", + "pymmcore-widgets@git+https://github.com/fdrgsp/pymmcore-plus.git@viewer_v2_and_tensorstore", + "pymmcore-plus@git+https://github.com/fdrgsp/pymmcore-plus.git@add_tensorstore_to_runner", "qtpy", "vispy", "zarr", @@ -67,6 +67,9 @@ repository = "https://github.com/fdrgsp/micromanager-gui" [tool.hatch.version] source = "vcs" +[tool.hatch.metadata] +allow-direct-references = true + # https://hatch.pypa.io/latest/config/build/#file-selection # [tool.hatch.build.targets.sdist] # include = ["/src", "/tests"] diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..747eb34d8a39508fff29d20672ff37af4298f3fb GIT binary patch literal 8196 zcmeHMU2GIp6u#fIz|27HE#;@|09k3$A}!eR(;^ty{_&Gy3T*!<;5xfAz=Y{c-I?8j zgjglMfPeAD2gScvqmc)V!D#eJ5}(Az$b*__;)^DtkH$pLojVJ*K*T3WIybrZ%(>^B zd*__{ojWI)xu;2!L-_2K^&HS)qPv-cN9^VU1$Fqx(fn(XEa%9Bu zEPt}wFL_oF*=D6c)l@CV8_t~R+`O?h-QKb3Y-{>V=f+K~>CTR=XU}Tt(#8k3AIzO} z$36eNbPe(Kz-nf>df{&Tbc1q_^s}n2;^#LNKQ(=!F*K}?=J?_=IWsU3I=&YS+r@xr zj@Jg{^xlH+?JwFwK2q@A(zqAocwNDC3nkYK?Jn2NI#1ZaXlS1b#nV{e7sH-NF~@l+ zun*d#=alVU(hf!)T281fndJ<$cNZ;PwWg`1ZOgVDJ-hn{rfcr2<#l?!K1PxALg$!c z7Y~_1!S*c2J3ch&SmBA0vJ*IY*B?h)S-yI3lJpVpz>8;!JaSV($`HjP$n&}=-Q4+i9e%1x=*s&CK- zPtuWVJdiW(`X*z@HS@NsaZB#gp>H)t$}+YZ@0XL?^?GBp;E#`!i-9KRb{bvAqhi~A zW)Kcc*q(T$D^k`=va#TVS+oZx33Blp?V;J|xM*G&^;u5nM?#9mzO-Q+63ca2R^&s& zvgI|#0VAvOt12Cl=*Vs`xDiuK`l7ykU}%oVjWtr85wk;8~xai1IS_! zN0CPXHjcqZ31ytZX*`YdxPa&IJYL2dcoT2oZCu7j_!ytyb6mw&n8vsG71!|_{!prw zdCGjHR^iHGrCw=Lnw2(Xi_#(ATd5?_p_!Odb`t-u7X>9yd{HRw?Gh-}w@cr1PbAQ_ zMN!Huu3xfrb>rIg8#}h|ya6qV#R~lX3F-*RGw>fBdz4bMP&ggsmcDXTC7EufmrS=I zN&)+nDl(BTh_&xuqf!d+c`@76Ol&`&FWFQ}t4is{m59|-ZS5K*6|aifmMz3WGWtW? zG)gaC9kVNU5X&MamFn)HWv+1bzhn0jyUadiSJ*UR_b2u!g@@cRN@#7lSuui_$J zkCFTyF3rN`-kI3M<-5UVIa~Bx-xEJfS1>+vnK{Q*Nsk*u{ohB~o%{|k=OBKw$s? literal 0 HcmV?d00001 diff --git a/src/micromanager_gui/.DS_Store b/src/micromanager_gui/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..142ee1a752a8d5fa44d59ad15dc0dae46a91835a GIT binary patch literal 6148 zcmeHKyH3ME5S)b+mS|E^-Y@V6RusN~4@d|`2q}^!0-|-tZ!`NqN@OU~P(-uV-uU)* z?&K+My#Q?WG28)503F>CUp~yu*WG7!RS`#vJ@(k)5pTocX+O*UJ>c998@yxWbHv|` zW9;FJpLoT97d$iV;G6p*Pn1akDIf);fE17dXDd(@w7WT*DS{M`0++6U-w%!M*cVQT zadmKr7J#^5IE?SnOAwm}h<)Ld$Oz4nN=&L%i(yG;yj5ObI3*?>7AJF`I@zj2u{fRa z7U{4)QKJ-)0;dXG=XB}y|DOIw|9?u-ObSSWi&DTA>xcDPNtJkx7C7P~aQ%j2m?T literal 0 HcmV?d00001 diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index d7afb6a5..d15cc198 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -6,98 +6,147 @@ from pymmcore_widgets._stack_viewer_v2 import MDAViewer from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY from qtpy.QtCore import QObject, Qt -from qtpy.QtWidgets import QApplication +from qtpy.QtWidgets import QTabBar, QTabWidget from ._widgets._preview import Preview DIALOG = Qt.WindowType.Dialog +VIEWER_TEMP_DIR = None +NO_R_BTN = (0, QTabBar.ButtonPosition.RightSide, None) +NO_L_BTN = (0, QTabBar.ButtonPosition.LeftSide, None) if TYPE_CHECKING: import useq from ._main_window import MicroManagerGUI + from ._widgets._mda_widget import _MDAWidget -class _CoreLink(QObject): +class CoreViewersLink(QObject): def __init__(self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None): super().__init__(parent) - self._mmc = mmcore or CMMCorePlus.instance() - self._main_window = parent + self._mmc = mmcore or CMMCorePlus.instance() - # set max canvas size to the screen height - app = QApplication.instance() - screen_height = app.primaryScreen().geometry().height() - self._canvas_size = (screen_height, screen_height) - - self._is_mda_running: bool = False - - # to keep track of the viewers + # Tab widget for the viewers (preview and MDA) + self._viewer_tab = QTabWidget() + # Enable the close button on tabs + self._viewer_tab.setTabsClosable(True) + self._viewer_tab.tabCloseRequested.connect(self._close_tab) + self._main_window._central_wdg_layout.addWidget(self._viewer_tab, 0, 0) + + # preview tab + self._preview = Preview(self._main_window, mmcore=self._mmc) + self._viewer_tab.addTab(self._preview, "Preview") + # remove the preview tab close button + self._viewer_tab.tabBar().setTabButton(*NO_R_BTN) + self._viewer_tab.tabBar().setTabButton(*NO_L_BTN) + + # keep track of the current mda viewer self._current_viewer: MDAViewer | None = None - self._viewers: list[MDAViewer] = [] - # preview widget - self._preview = Preview(parent, mmcore=self._mmc, canvas_size=self._canvas_size) - self._preview.setWindowFlags(DIALOG) - self._preview.hide() + self._mda_running: bool = False + + # the _MDAWidget. It should have been set in the _MenuBar at startup + self._mda = cast("_MDAWidget", self._main_window._menu_bar._mda) - # core connections ev = self._mmc.events - ev.imageSnapped.connect(self._show_preview) - ev.continuousSequenceAcquisitionStarted.connect(self._show_preview) + ev.continuousSequenceAcquisitionStarted.connect(self._set_preview_tab) + ev.imageSnapped.connect(self._set_preview_tab) + self._mmc.mda.events.sequenceStarted.connect(self._on_sequence_started) self._mmc.mda.events.sequenceFinished.connect(self._on_sequence_finished) + self._mmc.mda.events.sequencePauseToggled.connect(self._enable_gui) - self.destroyed.connect(self._disconnect) - - def _disconnect(self) -> None: - """Disconnect signals.""" - ev = self._mmc.events - ev.imageSnapped.disconnect(self._show_preview) - ev.continuousSequenceAcquisitionStarted.disconnect(self._show_preview) - self._mmc.mda.events.sequenceStarted.disconnect(self._on_sequence_started) - self._mmc.mda.events.sequenceFinished.disconnect(self._on_sequence_finished) - - def _show_preview(self) -> None: - """Show the preview widget.""" - # do not show if MDA is running - if self._is_mda_running: + def _close_tab(self, index: int) -> None: + """Close the tab at the given index.""" + if index == 0: return - # show if hidden, raise if visible - if self._preview.isHidden(): - self._preview.resize(self._preview.sizeHint() / 2) - self._preview.show() - else: - self._preview.raise_() + widget = self._viewer_tab.widget(index) + self._viewer_tab.removeTab(index) + widget.deleteLater() + + # Delete the current viewer + del self._current_viewer + self._current_viewer = None + + def _on_sequence_started(self, sequence: useq.MDASequence) -> None: + """Show the MDAViewer when the MDA sequence starts.""" + self._mda_running = True + + # disable the menu bar + self._main_window._menu_bar._enable(False) + + # pause until the viewer is ready + self._mmc.mda.toggle_pause() + # setup the viewer + self._setup_viewer(sequence) + # resume the sequence + self._mmc.mda.toggle_pause() def _setup_viewer(self, sequence: useq.MDASequence) -> None: - self._current_viewer = MDAViewer(parent=self._main_window) - - # rename the viewer if there is a save_name in the metadata or add a digit - save_meta = cast(dict, sequence.metadata.get(PYMMCW_METADATA_KEY, {})) - save_name = save_meta.get("save_name") - save_name = ( - save_name - if save_name is not None - else f"MDA Viewer {len(self._viewers) + 1}" - ) - self._current_viewer.setWindowTitle(save_name) - - # call it manually indted in _connect_viewer because this signal has been + """Setup the MDAViewer.""" + # get the MDAWidget writer + datastore = self._mda.writer if self._mda is not None else None + self._current_viewer = MDAViewer(parent=self._main_window, datastore=datastore) + + # rename the viewer if there is a save_name' in the metadata or add a digit + meta = cast(dict, sequence.metadata.get(PYMMCW_METADATA_KEY, {})) + viewer_name = self._get_viewer_name(meta.get("save_name")) + self._viewer_tab.addTab(self._current_viewer, viewer_name) + self._viewer_tab.setCurrentWidget(self._current_viewer) + + # call it manually insted in _connect_viewer because this signal has been # emitted already self._current_viewer.data.sequenceStarted(sequence) + # disable the LUT drop down and the mono/composite button (temporary) + self._enable_gui(False) + # connect the signals self._connect_viewer(self._current_viewer) - # set the dialog window flags and show - self._current_viewer.setWindowFlags(DIALOG) - self._current_viewer.resize(self._current_viewer.sizeHint() / 2) - self._current_viewer.show() + def _get_viewer_name(self, viewer_name: str | None) -> str: + """Get the viewer name from the metadata. + + If viewer_name is None, get the highest index for the viewer name. Otherwise, + return the viewer name. + """ + if viewer_name: + return viewer_name + + # loop through the tabs and get the highest index for the viewer name + index = 0 + for v in range(self._viewer_tab.count()): + tab_name = self._viewer_tab.tabText(v) + if tab_name.startswith("MDA Viewer"): + idx = tab_name.replace("MDA Viewer ", "") + if idx.isdigit(): + index = max(index, int(idx)) + return f"MDA Viewer {index + 1}" + + def _on_sequence_finished(self, sequence: useq.MDASequence) -> None: + """Hide the MDAViewer when the MDA sequence finishes.""" + self._main_window._menu_bar._enable(True) + + self._mda_running = False + + # reset the mda writer to None + self._mda.writer = None - # store the viewer - self._viewers.append(self._current_viewer) + if self._current_viewer is None: + return + + # enable the LUT drop down and the mono/composite button (temporary) + self._enable_gui(True) + + # call it before we disconnect the signals or it will not be called + self._current_viewer.data.sequenceFinished(sequence) + + self._disconnect_viewer(self._current_viewer) + + self._current_viewer = None def _connect_viewer(self, viewer: MDAViewer) -> None: self._mmc.mda.events.sequenceFinished.connect(viewer.data.sequenceFinished) @@ -105,24 +154,20 @@ def _connect_viewer(self, viewer: MDAViewer) -> None: def _disconnect_viewer(self, viewer: MDAViewer) -> None: """Disconnect the signals.""" - self._mmc.mda.events.sequenceFinished.disconnect(viewer.data.sequenceFinished) self._mmc.mda.events.frameReady.disconnect(viewer.data.frameReady) + self._mmc.mda.events.sequenceFinished.disconnect(viewer.data.sequenceFinished) - def _on_sequence_started(self, sequence: useq.MDASequence) -> None: - """Show the MDAViewer when the MDA sequence starts.""" - self._is_mda_running = True - self._preview.hide() + def _enable_gui(self, state: bool) -> None: + """Pause the viewer when the MDA sequence is paused.""" + self._main_window._menu_bar._enable(state) + if self._current_viewer is None: + return - # pause until the viewer is ready - self._mmc.mda.toggle_pause() - # setup the viewer - self._setup_viewer(sequence) - # resume the sequence - self._mmc.mda.toggle_pause() + # self._current_viewer._lut_drop.setEnabled(state) + self._current_viewer._channel_mode_btn.setEnabled(state) - def _on_sequence_finished(self, sequence: useq.MDASequence) -> None: - """Hide the MDAViewer when the MDA sequence finishes.""" - self._is_mda_running = False - if self._current_viewer is None: + def _set_preview_tab(self) -> None: + """Set the preview tab.""" + if self._mda_running: return - self._disconnect_viewer(self._current_viewer) + self._viewer_tab.setCurrentWidget(self._preview) diff --git a/src/micromanager_gui/_init_system_config.py b/src/micromanager_gui/_init_system_config.py deleted file mode 100644 index 292b609d..00000000 --- a/src/micromanager_gui/_init_system_config.py +++ /dev/null @@ -1,256 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path -from typing import cast -from warnings import warn - -from pymmcore_plus import CMMCorePlus, find_micromanager -from pymmcore_widgets import ConfigWizard -from pymmcore_widgets.hcwizard.finish_page import DEST_CONFIG -from qtpy.QtCore import QObject -from qtpy.QtWidgets import ( - QComboBox, - QDialog, - QDialogButtonBox, - QFileDialog, - QGridLayout, - QLabel, - QPushButton, - QSizePolicy, - QWidget, -) - -from micromanager_gui._util import ( - USER_CONFIGS_PATHS, - USER_DIR, - add_path_to_config_json, - load_sys_config, -) - -FIXED = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) -NEW = "New Hardware Configuration" - - -class InitializeSystemConfigurations(QObject): - def __init__( - self, - parent: QObject | None = None, - config: Path | str | None = None, - mmcore: CMMCorePlus | None = None, - ) -> None: - super().__init__(parent) - - self._mmc = mmcore or CMMCorePlus.instance() - - self._initialize() - - # if a config is provided, load it - if config is not None: - # add the config to the system configurations json and set it as the - # current configuration path. - add_path_to_config_json(config) - load_sys_config(config) - # if no config is provided, show a dialog to select one or to create a new one - else: - self._startup_dialog = StartupConfigurationsDialog( - parent=self.parent(), config=config, mmcore=self._mmc - ) - self._startup_dialog.show() - - def _initialize(self) -> None: - """Create or update the list of Micro-Manager hardware configurations paths. - - This method is called everytime napari-micromanager is loaded and it updates (or - create if does not yet exists) the list of Micro-Manager configurations paths - saved in the USER_CONFIGS_PATHS as a json file. - """ - # create USER_CONFIGS_PATHS if it doesn't exist - if not USER_CONFIGS_PATHS.exists(): - USER_DIR.mkdir(parents=True, exist_ok=True) - with open(USER_CONFIGS_PATHS, "w") as f: - json.dump({"paths": []}, f) - - # get the paths from the json file - configs_paths = self._get_config_paths() - - # write the data back to the file - with open(USER_CONFIGS_PATHS, "w") as f: - json.dump({"paths": configs_paths}, f) - - def _get_config_paths(self) -> list[str]: - """Return the paths from the json file. - - If a file stored in the json file doesn't exist, it is removed from the list. - - The method also adds all the .cfg files in the MicroManager folder to the list - if they are not already there. - """ - try: - with open(USER_CONFIGS_PATHS) as f: - data = json.load(f) - - # get path list from json file - paths = cast(list, data.get("paths", [])) - - # remove any path that doesn't exist - for path in paths: - if not Path(path).exists(): - paths.remove(path) - - # get all the .cfg files in the MicroManager folder - cfg_files = self._get_micromanager_cfg_files() - - # add all the .cfg files to the list if they are not already there - for cfg in reversed(cfg_files): - if str(cfg) not in paths: - # using insert so we leave the empty string at the end - paths.insert(0, str(cfg)) - - except json.JSONDecodeError: - paths = [] - warn("Error reading the json file.", stacklevel=2) - - return paths - - def _get_micromanager_cfg_files(self) -> list[Path]: - """Return all the .cfg files from all the MicroManager folders.""" - mm: list = find_micromanager(False) - cfg_files: list[Path] = [] - for mm_dir in mm: - cfg_files.extend(Path(mm_dir).glob("*.cfg")) - - return cfg_files - - -class StartupConfigurationsDialog(QDialog): - """A dialog to select the Micro-Manager Hardware configuration files at startup.""" - - def __init__( - self, - parent: QWidget | None = None, - *, - config: Path | str | None = None, - mmcore: CMMCorePlus | None = None, - ) -> None: - super().__init__(parent) - self.setWindowTitle("Micro-Manager Hardware System Configurations") - - self._mmc = mmcore or CMMCorePlus.instance() - self._config = config - - # label - cfg_lbl = QLabel("Configuration file:") - cfg_lbl.setSizePolicy(FIXED) - - # combo box - self.cfg_combo = QComboBox() - # `AdjustToMinimumContents` is not available in all qtpy backends so using - # `AdjustToMinimumContentsLengthWithIcon` instead - self.cfg_combo.setSizeAdjustPolicy( - QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon - ) - - # browse button - self.browse_btn = QPushButton("...") - self.browse_btn.setSizePolicy(FIXED) - self.browse_btn.clicked.connect(self._on_browse_clicked) - - # Create OK and Cancel buttons - button_box = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel - ) - button_box.accepted.connect(self.accept) - button_box.rejected.connect(self.reject) - - # add widgets to layout - wdg_layout = QGridLayout(self) - wdg_layout.addWidget(cfg_lbl, 0, 0) - wdg_layout.addWidget(self.cfg_combo, 0, 1) - wdg_layout.addWidget(self.browse_btn, 0, 2) - wdg_layout.addWidget(button_box, 2, 0, 1, 3) - - self._initialize() - - def accept(self) -> None: - super().accept() - config = self.cfg_combo.currentText() - # if current text is not at index 0, update the json file and insert it at the - # first position so it will be shown as the fort option in the combo box next - # time the dialog is shown. - if config != self.cfg_combo.itemText(0): - add_path_to_config_json(config) - - # if the user selected NEW, show the config wizard - if config == NEW: - self._cfg_wizard = HardwareConfigWizard(parent=self) - self._cfg_wizard.show() - else: - load_sys_config(config) - - def _initialize(self) -> None: - """Initialize the dialog with the Micro-Manager configuration files.""" - # return if the json file doesn't exist - if not USER_CONFIGS_PATHS.exists(): - return - - # Read the existing data - try: - with open(USER_CONFIGS_PATHS) as f: - configs_paths = json.load(f) - except json.JSONDecodeError: - configs_paths = {"paths": []} - - configs_paths = cast(list, configs_paths.get("paths", [])) - # add the paths to the combo box - self.cfg_combo.addItems([*configs_paths, NEW]) - - # resize the widget so its width is not too small - self.resize(600, self.minimumSizeHint().height()) - - def _on_browse_clicked(self) -> None: - """Open a file dialog to select a file. - - If a file path is provided, it is added to the USER_CONFIGS_PATHS json file and - to the combo box. - """ - path, _ = QFileDialog.getOpenFileName( - self, "Open file", "", "MicroManager files (*.cfg)" - ) - if path: - # using insert so we leave the empty string at the end - self.cfg_combo.insertItem(0, path) - self.cfg_combo.setCurrentText(path) - # add the config to the system configurations json and set it as the - # current configuration path. - add_path_to_config_json(path) - - -class HardwareConfigWizard(ConfigWizard): - """A wizard to create a new Micro-Manager hardware configuration file. - - Subclassing to load the newly created configuration file and to add it to the - USER_CONFIGS_PATHS json file. - """ - - def __init__( - self, - config_file: str = "", - core: CMMCorePlus | None = None, - parent: QWidget | None = None, - ): - super().__init__(config_file, core, parent) - - self.setWindowTitle("Micro-Manager Hardware Configuration Wizard") - - def accept(self) -> None: - """Accept the wizard and save the configuration to a file. - - Overriding to add the new configuration file to the USER_CONFIGS_PATHS json file - and to load it. - """ - super().accept() - dest = self.field(DEST_CONFIG) - # add the path to the USER_CONFIGS_PATHS list - add_path_to_config_json(dest) - load_sys_config(dest) diff --git a/src/micromanager_gui/_main_window.py b/src/micromanager_gui/_main_window.py index 2f092b17..801e8654 100644 --- a/src/micromanager_gui/_main_window.py +++ b/src/micromanager_gui/_main_window.py @@ -1,38 +1,30 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING +import sys +from pathlib import Path +from warnings import warn +sys.path.append(str(Path(__file__).resolve().parent.parent)) from pymmcore_plus import CMMCorePlus -from pymmcore_widgets.hcwizard.intro_page import SRC_CONFIG -from qtpy.QtCore import Qt +from pymmcore_widgets._stack_viewer_v2._mda_viewer import StackViewer +from qtpy.QtGui import QDragEnterEvent, QDropEvent from qtpy.QtWidgets import ( - QAction, + QGridLayout, QMainWindow, - QMenuBar, - QTabWidget, - QVBoxLayout, QWidget, ) -from ._core_link import _CoreLink -from ._init_system_config import InitializeSystemConfigurations -from ._toolbar import MainToolBar -from ._util import load_sys_config_dialog, save_sys_config_dialog -from ._widgets._config_wizard import HardwareConfigWizard - -if TYPE_CHECKING: - from qtpy.QtGui import QCloseEvent - -FLAGS = Qt.WindowType.Dialog -DEFAULT = "Experiment" -ALLOWED_AREAS = ( - Qt.DockWidgetArea.LeftDockWidgetArea - | Qt.DockWidgetArea.RightDockWidgetArea - # | Qt.DockWidgetArea.BottomDockWidgetArea +from micromanager_gui._readers._tensorstore_zarr_reader import ( + TensorstoreZarrReader, ) +from ._core_link import CoreViewersLink +from ._menubar._menubar import _MenuBar +from ._toolbar._shutters_toolbar import _ShuttersToolbar +from ._toolbar._snap_live import _SnapLive + class MicroManagerGUI(QMainWindow): + """Micro-Manager minimal GUI.""" + def __init__( self, parent: QWidget | None = None, @@ -41,94 +33,63 @@ def __init__( config: str | None = None, ) -> None: super().__init__(parent) + self.setAcceptDrops(True) - self._mmc = mmcore or CMMCorePlus.instance() - - self.setWindowTitle("Micro-Manager GUI") + self.setWindowTitle("Micro-Manager") # extend size to fill the screen self.showMaximized() - # add menu - self._add_menu() + # get global CMMCorePlus instance + self._mmc = mmcore or CMMCorePlus.instance() + + # central widget + central_wdg = QWidget(self) + self._central_wdg_layout = QGridLayout(central_wdg) + self.setCentralWidget(central_wdg) + + # add the menu bar (and the logic to create/show widgets) + self._menu_bar = _MenuBar(parent=self, mmcore=self._mmc) + self.setMenuBar(self._menu_bar) # add toolbar - self._toolbar = MainToolBar(self) - self.contextMenuEvent = self._toolbar.contextMenuEvent - - # add central widget - central_widget = QWidget() - central_widget.setLayout(QVBoxLayout()) - self.setCentralWidget(central_widget) - - # set tabbed dockwidgets tabs to the top - self.setTabPosition( - Qt.DockWidgetArea.AllDockWidgetAreas, QTabWidget.TabPosition.North - ) - - # link to the core - self._core_link = _CoreLink(self, mmcore=self._mmc) - - self._wizard: HardwareConfigWizard | None = None - - # load latest layout - self._toolbar._widgets_toolbar._load_layout() - - # handle the system configurations at startup. - # with this we create/updatethe list of the Micro-Manager hardware system - # configurations files path stored as a json file in the user's configuration - # file directory (USER_CONFIGS_PATHS). - # a dialog will be also displayed if no system configuration file is - # provided to either select one from the list of available ones or to create - # a new one. - self._init_cfg = InitializeSystemConfigurations( - parent=self, config=config, mmcore=self._mmc - ) - - def _add_menu(self) -> None: - - menubar = QMenuBar(self) - - # main Micro-Manager menu - mm_menu = menubar.addMenu("Micro-Manager") - - # Configurations Sub-Menu - configurations_menu = mm_menu.addMenu("System Configurations") - # save cfg - self.act_save_configuration = QAction("Save Configuration", self) - self.act_save_configuration.triggered.connect(self._save_cfg) - configurations_menu.addAction(self.act_save_configuration) - # load cfg - self.act_load_configuration = QAction("Load Configuration", self) - self.act_load_configuration.triggered.connect(self._load_cfg) - configurations_menu.addAction(self.act_load_configuration) - # cfg wizard - self.act_cfg_wizard = QAction("Hardware Configuration Wizard", self) - self.act_cfg_wizard.triggered.connect(self._show_config_wizard) - configurations_menu.addAction(self.act_cfg_wizard) - - def _save_cfg(self) -> None: - """Save the current Micro-Manager system configuration.""" - save_sys_config_dialog(parent=self, mmcore=self._mmc) - - def _load_cfg(self) -> None: - """Load a Micro-Manager system configuration.""" - load_sys_config_dialog(parent=self, mmcore=self._mmc) - - def _show_config_wizard(self) -> None: - """Show the Micro-Manager Hardware Configuration Wizard.""" - if self._wizard is None: - self._wizard = HardwareConfigWizard(parent=self) - - if self._wizard.isVisible(): - self._wizard.raise_() + self._shutters_toolbar = _ShuttersToolbar(parent=self, mmcore=self._mmc) + self.addToolBar(self._shutters_toolbar) + self._snap_live_toolbar = _SnapLive(parent=self, mmcore=self._mmc) + self.addToolBar(self._snap_live_toolbar) + + # link the MDA viewers + self._core_link = CoreViewersLink(self, mmcore=self._mmc) + + if config is not None: + try: + self._mmc.unloadAllDevices() + self._mmc.loadSystemConfiguration(config) + except FileNotFoundError: + # don't crash if the user passed an invalid config + warn(f"Config file {config} not found. Nothing loaded.", stacklevel=2) + + def dragEnterEvent(self, event: QDragEnterEvent) -> None: + if event.mimeData().hasUrls(): + event.acceptProposedAction() else: - current_cfg = self._mmc.systemConfigurationFile() or "" - self._wizard.setField(SRC_CONFIG, current_cfg) - self._wizard.show() - - def closeEvent(self, event: QCloseEvent) -> None: - # close all viewers - for viewer in self._core_link._viewers: - viewer.close() - super().closeEvent(event) + super().dragEnterEvent(event) + + def dropEvent(self, event: QDropEvent) -> None: + """Open a tensorstore from a directory dropped in the window.""" + for idx, url in enumerate(event.mimeData().urls()): + path = url.toLocalFile() + # if is not a dir, continue + if not Path(path).is_dir(): + continue + + # if is a dir, open it as a tensorstore + try: + reader = TensorstoreZarrReader(path) + s = StackViewer(reader.store, parent=self) + self._core_link._viewer_tab.addTab(s, f"Zarr Tensorstore_{idx}") + self._core_link._viewer_tab.setCurrentWidget(s) + except Exception as e: + warn(f"Error opening tensorstore: {e}!", stacklevel=2) + + super().dropEvent(event) diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py new file mode 100644 index 00000000..66cb5245 --- /dev/null +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -0,0 +1,207 @@ +import warnings +from typing import TYPE_CHECKING, cast + +from pymmcore_plus import CMMCorePlus +from pymmcore_widgets import ( + CameraRoiWidget, + ConfigWizard, + GroupPresetTableWidget, + PixelConfigurationWidget, + PropertyBrowser, +) +from pymmcore_widgets.hcwizard.intro_page import SRC_CONFIG +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QAction, + QDockWidget, + QFileDialog, + QMenuBar, + QScrollArea, + QTabWidget, + QWidget, +) + +from micromanager_gui._widgets._mda_widget import _MDAWidget +from micromanager_gui._widgets._stage_control import _StagesControlWidget + +if TYPE_CHECKING: + from micromanager_gui._main_window import MicroManagerGUI + +FLAGS = Qt.WindowType.Dialog +WIDGETS = { + "Property Browser": PropertyBrowser, + "Pixel Configuration": PixelConfigurationWidget, +} +DOCKWIDGETS = { + "MDA Widget": _MDAWidget, + "Groups and Presets": GroupPresetTableWidget, + "Stage Control": _StagesControlWidget, + "Camera ROI": CameraRoiWidget, +} +RIGHT = Qt.DockWidgetArea.RightDockWidgetArea +LEFT = Qt.DockWidgetArea.LeftDockWidgetArea + + +class ScrollableDockWidget(QDockWidget): + """A QDockWidget with a QScrollArea.""" + + def __init__( + self, + parent: QWidget | None = None, + *, + title: str, + widget: QWidget, + ): + super().__init__(title, parent) + self.main_widget = widget + # set allowed dock areas + self.setAllowedAreas(LEFT | RIGHT) + + # create the scroll area and set it as the widget of the QDockwidget + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + super().setWidget(self.scroll_area) + + # set the widget to the scroll area + self.scroll_area.setWidget(widget) + # resize the dock widget to the size hint of the widget + self.resize(widget.minimumSizeHint()) + + +class _MenuBar(QMenuBar): + """Menu Bar for the Micro-Manager GUI. + + It contains the actions to create and show widgets and dockwidgets. + """ + + def __init__( + self, parent: "MicroManagerGUI", *, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent) + self._main_window = parent + + # set tabbed dockwidgets tabs to the top + self._main_window.setTabPosition( + Qt.DockWidgetArea.AllDockWidgetAreas, QTabWidget.TabPosition.North + ) + + self._mmc = mmcore or CMMCorePlus.instance() + + # to keep track of the widgets + self._widgets: dict[str, QWidget | ScrollableDockWidget] = {} + + # widgets + self._wizard: ConfigWizard | None = None # is in a different menu + self._mda: _MDAWidget | None = None + + # configurations_menu + self._configurations_menu = self.addMenu("System Configurations") + # hardware cfg wizard + self._act_cfg_wizard = QAction("Hardware Configuration Wizard", self) + self._act_cfg_wizard.triggered.connect(self._show_config_wizard) + self._configurations_menu.addAction(self._act_cfg_wizard) + # save cfg + self._act_save_configuration = QAction("Save Configuration", self) + self._act_save_configuration.triggered.connect(self._save_cfg) + self._configurations_menu.addAction(self._act_save_configuration) + # load cfg + self._act_load_configuration = QAction("Load Configuration", self) + self._act_load_configuration.triggered.connect(self._load_cfg) + self._configurations_menu.addAction(self._act_load_configuration) + + # # widgets_menu + self._widgets_menu = self.addMenu("Widgets") + + # create actions from WIDGETS and DOCKWIDGETS + keys = {*WIDGETS.keys(), *DOCKWIDGETS.keys()} + for action_name in sorted(keys): + action = QAction(action_name, self) + action.triggered.connect(self._show_widget) + self._widgets_menu.addAction(action) + + # create 'Group and Presets' and 'MDA' widgets at the startup + self._create_dock_widget("Groups and Presets", dock_area=LEFT) + mda = self._create_dock_widget("MDA Widget") + self._mda = cast(_MDAWidget, mda.main_widget) + + def _enable(self, enable: bool) -> None: + """Enable or disable the actions.""" + self._configurations_menu.setEnabled(enable) + self._widgets_menu.setEnabled(enable) + + def _save_cfg(self) -> None: + (filename, _) = QFileDialog.getSaveFileName( + self, "Save Micro-Manager Configuration." + ) + if filename: + self._mmc.saveSystemConfiguration( + filename if str(filename).endswith(".cfg") else f"{filename}.cfg" + ) + + def _load_cfg(self) -> None: + """Open file dialog to select a config file.""" + (filename, _) = QFileDialog.getOpenFileName( + self, "Select a Micro-Manager configuration file", "", "cfg(*.cfg)" + ) + if filename: + self._mmc.unloadAllDevices() + self._mmc.loadSystemConfiguration(filename) + + def _show_config_wizard(self) -> None: + """Show the Micro-Manager Hardware Configuration Wizard.""" + if self._wizard is None: + self._wizard = ConfigWizard(parent=self, core=self._mmc) + self._wizard.setWindowFlags(FLAGS) + if self._wizard.isVisible(): + self._wizard.raise_() + else: + current_cfg = self._mmc.systemConfigurationFile() or "" + self._wizard.setField(SRC_CONFIG, current_cfg) + self._wizard.show() + + def _show_widget(self) -> None: + """Create or show a widget.""" + # get the action that triggered the signal + sender = cast(QAction, self.sender()) + # get action name + action_name = sender.text() + + if action_name not in {*WIDGETS.keys(), *DOCKWIDGETS.keys()}: + warnings.warn(f"Widget '{action_name}' not found.", stacklevel=2) + return + + # already created + if action_name in self._widgets: + wdg = self._widgets[action_name] + wdg.show() + wdg.raise_() + return + + # create dock widget + if action_name in DOCKWIDGETS: + self._create_dock_widget(action_name) + # create widget + else: + wdg = self._create_widget(action_name) + wdg.show() + + def _create_dock_widget( + self, action_name: str, dock_area: Qt.DockWidgetArea = RIGHT + ) -> ScrollableDockWidget: + """Create a dock widget with a scroll area.""" + wdg = DOCKWIDGETS[action_name](parent=self, mmcore=self._mmc) + dock = ScrollableDockWidget( + self, + title=action_name, + widget=wdg, + ) + self._main_window.addDockWidget(dock_area, dock) + self._widgets[action_name] = dock + return dock + + def _create_widget(self, action_name: str) -> QWidget: + """Create a widget.""" + wdg = WIDGETS[action_name](parent=self, mmcore=self._mmc) + wdg.setWindowFlags(FLAGS) + self._widgets[action_name] = wdg + return wdg diff --git a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py new file mode 100644 index 00000000..6735307b --- /dev/null +++ b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py @@ -0,0 +1,164 @@ +import json +from pathlib import Path +from typing import Mapping + +import numpy as np +import tensorstore as ts +import useq +from tifffile import imwrite +from tqdm import tqdm + + +class TensorstoreZarrReader: + """Read data from a tensorstore zarr file. + + Parameters + ---------- + path : str | Path + The path to the tensorstore zarr file. + + Attributes + ---------- + path : Path + The path to the tensorstore zarr file. + store : ts.TensorStore + The tensorstore. + metadata : dict + The metadata from the acquisition. They are stored in the `.zattrs` file and + should contain two keys: `useq_MDASequence` and `useq_MDASequence`. + sequence : useq.MDASequence + The acquired useq.MDASequence. It is loaded from the metadata using the + `useq.MDASequence` key. + + Usage + ----- + reader = TensorZarrReader("path/to/file") + # to get the numpy array for a specific axis, for example, the first time point for + # the first position and the first z-slice: + data = reader.isel({"p": 0, "t": 1, "z": 0}) + """ + + def __init__(self, path: str | Path): + self._path = path + + spec = { + "driver": "zarr", + "kvstore": {"driver": "file", "path": self._path}, + } + + self._store = ts.open(spec) + + self._metadata: dict = {} + if metadata_json := self.store.kvstore.read(".zattrs").result().value: + self._metadata = json.loads(metadata_json) + + @property + def path(self) -> Path: + """Return the path.""" + return Path(self._path) + + @property + def store(self) -> ts.TensorStore: + """Return the tensorstore.""" + return self._store.result() + + @property + def metadata(self) -> dict: + return self._metadata + + @property + def sequence(self) -> useq.MDASequence: + seq = self._metadata.get("useq_MDASequence") + return useq.MDASequence(**json.loads(seq)) if seq is not None else None + + def _get_axis_index(self, indexers: Mapping[str, int]) -> tuple[object, ...]: + """Return a tuple to index the data for the given axis.""" + if self.sequence is None: + raise ValueError("No 'useq.MDASequence' found in the metadata!") + + axis_order = self.sequence.axis_order + + # if any of the indexers are not in the axis order, raise an error + if not set(indexers.keys()).issubset(set(axis_order)): + raise ValueError("Invalid axis in indexers!") + + # get the correct index for the axis + # e.g. (slice(None), 1, slice(None), slice(None)) + return tuple( + indexers[axis] if axis in indexers else slice(None) for axis in axis_order + ) + + def isel( + self, indexers: Mapping[str, int], metadata: bool = False + ) -> np.ndarray | tuple[np.ndarray, dict]: + """Select data from the array. + + Parameters + ---------- + indexers : Mapping[str, int] + The indexers to select the data. + metadata : bool + If True, return the metadata as well as a list of dictionaries. By default, + False. + """ + index = self._get_axis_index(indexers) + data = self.store[index].read().result().squeeze() + if metadata: + meta = self._get_metadata_from_index(indexers) + return data, meta + return data + + def _get_metadata_from_index(self, indexers: Mapping[str, int]) -> list[dict]: + """Return the metadata for the given indexers.""" + metadata = [] + for meta in self._metadata.get("frame_metadatas", []): + event_index = meta["Event"]["index"] # e.g. {"p": 0, "t": 1} + if indexers.items() <= event_index.items(): + metadata.append(meta) + return metadata + + def write_tiff( + self, + path: str | Path, + indexers: Mapping[str, int] | list[Mapping[str, int]] | None = None, + ) -> None: + """Write the data to a tiff file. + + Parameters + ---------- + path : str | Path + The path to the tiff file. If `indexers` is a Mapping of axis and index, + the path should be a file path (e.g. 'path/to/file.tif'). Otherwise, it + should be a directory path (e.g. 'path/to/directory'). + indexers : Mapping[str, int] | list[Mapping[str, int]] | None + The indexers to select the data. If None, write all the data per position + to a tiff file. If a list of Mapping of axis and index + (e.g. [{"p": 0, "t": 1}, {"p": 1, "t": 0}]), write the data for the given + indexes to a tiff file. If a Mapping of axis and index (e.g. + {"p": 0, "t": 1}), write the data for the given index to a tiff file. + """ + # TODO: add metadata + if indexers is None: + if pos := len(self.sequence.stage_positions): + with tqdm(total=pos) as pbar: + for i in range(pos): + data, metadata = self.isel({"p": i}, metadata=True) + imwrite(Path(path) / f"p{i}.tif", data, imagej=True) + pbar.update(1) + + elif isinstance(indexers, list): + for index in indexers: + data, metadata = self.isel(index, metadata=True) + name = "_".join(f"{k}{v}" for k, v in index.items()) + imwrite(Path(path) / f"{name}.tif", data, imagej=True) + + else: + data, metadata = self.isel(indexers, metadata=True) + imj = len(data.shape) <= 5 + try: + imwrite(path, data, imagej=imj) + except IsADirectoryError as e: + raise IsADirectoryError( + "The path should be a file path, not a directory! " + "(e.g. 'path/to/file.tif')" + ) from e diff --git a/src/micromanager_gui/_toolbar.py b/src/micromanager_gui/_toolbar.py deleted file mode 100644 index 644b4fca..00000000 --- a/src/micromanager_gui/_toolbar.py +++ /dev/null @@ -1,309 +0,0 @@ -from __future__ import annotations - -import base64 -import json -from typing import TYPE_CHECKING, Any, cast - -from fonticon_mdi6 import MDI6 -from pymmcore_widgets import MDAWidget, PropertyBrowser -from qtpy.QtCore import QByteArray, QSize, Qt -from qtpy.QtWidgets import ( - QDockWidget, - QPushButton, - QScrollArea, - QSizePolicy, - QToolBar, - QWidget, -) -from superqt.fonticon import icon - -from micromanager_gui._util import ( - USER_DIR, - USER_LAYOUT_PATH, -) - -from ._widgets._camera_roi import _CameraRoiWidget -from ._widgets._group_and_preset import _GroupsAndPresets -from ._widgets._pixel_configurations import _PixelConfigurationWidget -from ._widgets._shutters_toolbar import _ShuttersToolbar -from ._widgets._snap_and_live import Live, Snap -from ._widgets._stage_control import _StagesControlWidget - -if TYPE_CHECKING: - from ._main_window import MicroManagerGUI - - -BTN_SIZE = (60, 40) -ALLOWED_AREAS = ( - Qt.DockWidgetArea.LeftDockWidgetArea - | Qt.DockWidgetArea.RightDockWidgetArea - # | Qt.DockWidgetArea.BottomDockWidgetArea -) - - -# fmt: off -# key: (widget, window name, icon) -WIDGETS: dict[str, tuple[type[QWidget], str, str | None]] = { - "Property Browser": (PropertyBrowser, "Device Property Browser", MDI6.table_large), - "Group Presets": (_GroupsAndPresets, "Group & Presets Table", MDI6.table_large_plus), # noqa: E501 - "Camera ROI": (_CameraRoiWidget, "Camera ROI", MDI6.crop), - "Stages": (_StagesControlWidget, "Stages Control", MDI6.arrow_all), - "Pixel": (_PixelConfigurationWidget, "Pixel Configuration Table", None), - "MDA": (MDAWidget, "Multi-Dimensional Acquisition", None), -} -# fmt: on - - -class ScrollableDockWidget(QDockWidget): - """A QDockWidget with a QScrollArea.""" - - def __init__( - self, - title: str, - parent: QWidget | None = None, - *, - widget: QWidget, - objectName: str, - ): - super().__init__(title, parent) - # set the object name - self.setObjectName(objectName) - - # set allowed dock areas - self.setAllowedAreas(ALLOWED_AREAS) - - # create the scroll area and set it as the widget of the QDockwidget - self.scroll_area = QScrollArea() - self.scroll_area.setWidgetResizable(True) - super().setWidget(self.scroll_area) - - # set the widget to the scroll area - self.scroll_area.setWidget(widget) - # resize the dock widget to the size hint of the widget - self.resize(widget.minimumSizeHint()) - - -class MainToolBar(QToolBar): - """A QToolBar containing QPushButtons for pymmcore-widgets.""" - - def __init__(self, parent: MicroManagerGUI) -> None: - super().__init__(parent) - - self._main_window = parent - - self.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) - - # snap and live toolbar - self._snap_live_toolbar = SnapLiveToolBar(self) - self._main_window.addToolBar( - Qt.ToolBarArea.TopToolBarArea, self._snap_live_toolbar - ) - - # widgets toolbar - self._widgets_toolbar = _WidgetsToolBar( - self, main_window=self._main_window, main_toolbar=self - ) - self._main_window.addToolBar( - Qt.ToolBarArea.TopToolBarArea, self._widgets_toolbar - ) - - # shutters toolbar - self._shutter_toolbar = _ShuttersToolbar(self) - self._main_window.addToolBar( - Qt.ToolBarArea.TopToolBarArea, self._shutter_toolbar - ) - - def contextMenuEvent(self, event: Any) -> None: - """Remove all actions from the context menu but the shutter toolbar.""" - menu = self._main_window.createPopupMenu() - for action in menu.actions(): - if action.text() == "Shutters ToolBar": - continue - menu.removeAction(action) - menu.exec_(event.globalPos()) - - -class SnapLiveToolBar(QToolBar): - """A QToolBar containing QPushButtons for snap and live.""" - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__("Snap/Live Toolbar", parent) - - self.setObjectName("Snap/Live ToolBar") - - self.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) - - self._snap_button = Snap() - self._live_button = Live() - - self.addWidget(self._snap_button) - self.addWidget(self._live_button) - - -class _WidgetsToolBar(QToolBar): - """A QToolBar containing QPushButtons for pymmcore-widgets. - - e.g. Property Browser, MDAWidget, StagesWidget, ... - - The QPushButton.whatsThis() property is used to store the key that - will be used by the `_show_widget` method. - """ - - def __init__( - self, - parent: QWidget | None = None, - *, - main_window: MicroManagerGUI, - main_toolbar: MainToolBar, - ) -> None: - super().__init__("Widgets ToolBar", parent) - - self.setObjectName("Widgets ToolBar") - - self.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) - - self._main_window = main_window - self._main_toolbar = main_toolbar - - # keep track of the created widgets - self._widgets: dict[str, ScrollableDockWidget] = {} - - for key in WIDGETS: - _, windows_name, btn_icon = WIDGETS[key] - btn = QPushButton() - btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - btn.setToolTip(windows_name) - btn.setWhatsThis(key) - btn.setIcon(icon(btn_icon)) if btn_icon else btn.setText(key) - btn.setFixedSize(*BTN_SIZE) - btn.setIconSize(QSize(25, 25)) - if key == "Shutters": - btn.clicked.connect(self._show_shutters_toolbar) - else: - btn.clicked.connect(self._show_widget) - self.addWidget(btn) - - def _show_shutters_toolbar(self) -> None: - """Show or raise the shutters toolbar.""" - if self._main_toolbar._shutter_toolbar is None: - return - if self._main_toolbar._shutter_toolbar.isVisible(): - self._main_toolbar._shutter_toolbar.hide() - else: - self._main_toolbar._shutter_toolbar.show() - - def _show_widget(self, key: str = "") -> None: - """Show or raise a widget.""" - if not key: - # using QPushButton.whatsThis() property to get the key. - btn = cast(QPushButton, self.sender()) - key = btn.whatsThis() - - if key in self._widgets: - # already exists - wdg = self._widgets[key] - wdg.show() - wdg.raise_() - return - - wdg = self._create_widget(key) - wdg.show() - - def _create_widget(self, key: str) -> ScrollableDockWidget: - """Create a widget for the first time.""" - try: - wdg_cls = WIDGETS[key][0] - except KeyError as e: - raise KeyError( - "Not a recognized widget key. " - f"Must be one of {list(WIDGETS)} " - " or the `whatsThis` property of a `sender` `QPushButton`." - ) from e - - wdg = wdg_cls(parent=self, mmcore=self._main_window._mmc) - - windows_title = WIDGETS[key][1] - dock = ScrollableDockWidget(windows_title, self, widget=wdg, objectName=key) - self._connect_dock_widget(dock) - self._main_window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock) - dock.setFloating(True) - self._widgets[key] = dock - - return dock - - def _connect_dock_widget(self, dock_wdg: QDockWidget) -> None: - """Connect the dock widget to the main window.""" - dock_wdg.visibilityChanged.connect(self._save_layout) - dock_wdg.topLevelChanged.connect(self._save_layout) - dock_wdg.dockLocationChanged.connect(self._save_layout) - - def _save_layout(self) -> None: - """Save the napa-micromanager layout to a json file. - - The json file has two keys: - - "layout_state" where the state of napari main window is stored using the - saveState() method. The state is base64 encoded to be able to save it to the - json file. - - "pymmcore_widgets" where the names of the docked pymmcore_widgets are stored. - - IMPORTANT: The "pymmcore_widgets" key is crucial in our layout saving process. - It stores the names of all active pymmcore_widgets at the time of saving. Before - restoring the layout, we must recreate these widgets. If not, they won't be - included in the restored layout. - """ - # get the names of the pymmcore_widgets that are part of the layout - pymmcore_wdgs: list[str] = [] - for dock_wdg in self._main_window.findChildren(ScrollableDockWidget): - wdg_name = dock_wdg.objectName() - if wdg_name in WIDGETS and not dock_wdg.isFloating(): - pymmcore_wdgs.append(wdg_name) - - # get the state of the napari main window as bytes - state_bytes = self._main_window.saveState().data() - - # Create dictionary with widget names and layout state. The layout state is - # base64 encoded to be able to save it to a json file. - data = { - "pymmcore_widgets": pymmcore_wdgs, - "layout_state": base64.b64encode(state_bytes).decode(), - } - - # if the user layout path does not exist, create it - if not USER_LAYOUT_PATH.exists(): - USER_DIR.mkdir(parents=True, exist_ok=True) - - try: - with open(USER_LAYOUT_PATH, "w") as json_file: - json.dump(data, json_file) - except Exception as e: - print(f"Was not able to save layout to file. Error: {e}") - - def _load_layout(self) -> None: - """Load the napari-micromanager layout from a json file.""" - if not USER_LAYOUT_PATH.exists(): - return - - try: - with open(USER_LAYOUT_PATH) as f: - data = json.load(f) - - # get the layout state bytes - state_bytes = data.get("layout_state") - - if state_bytes is None: - return - - # add pymmcore_widgets to the main window - pymmcore_wdgs = data.get("pymmcore_widgets", []) - for wdg_name in pymmcore_wdgs: - if wdg_name in WIDGETS: - self._show_widget(wdg_name) - - # Convert base64 encoded string back to bytes - state_bytes = base64.b64decode(state_bytes) - - # restore the layout state - self._main_window.restoreState(QByteArray(state_bytes)) - - except Exception as e: - print(f"Was not able to load layout from file. Error: {e}") diff --git a/src/micromanager_gui/_widgets/_shutters_toolbar.py b/src/micromanager_gui/_toolbar/_shutters_toolbar.py similarity index 100% rename from src/micromanager_gui/_widgets/_shutters_toolbar.py rename to src/micromanager_gui/_toolbar/_shutters_toolbar.py diff --git a/src/micromanager_gui/_toolbar/_snap_live.py b/src/micromanager_gui/_toolbar/_snap_live.py new file mode 100644 index 00000000..9a8a3690 --- /dev/null +++ b/src/micromanager_gui/_toolbar/_snap_live.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from pymmcore_plus import CMMCorePlus +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QToolBar, QWidget + +from micromanager_gui._widgets._snap_live_buttons import Live, Snap + + +class _SnapLive(QToolBar): + """A QToolBar for the Snap and Live buttons.""" + + def __init__( + self, parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__("Snap Live", parent) + + self.setObjectName("Snap Live") + + self.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) + + self._mmc = mmcore or CMMCorePlus.instance() + + # snap button + self._snap = Snap(mmcore=self._mmc) + self._snap.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.addWidget(self._snap) + + # live button + self._live = Live(mmcore=self._mmc) + self._live.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.addWidget(self._live) diff --git a/src/micromanager_gui/_util.py b/src/micromanager_gui/_util.py deleted file mode 100644 index 7e092caa..00000000 --- a/src/micromanager_gui/_util.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import ContextManager, cast -from warnings import warn - -from platformdirs import user_config_dir -from pymmcore_plus import CMMCorePlus -from pymmcore_plus.core.events import CMMCoreSignaler, PCoreSignaler -from qtpy.QtWidgets import ( - QFileDialog, - QWidget, -) -from superqt.utils import signals_blocked - -USER_DIR = Path(user_config_dir("micromanager_gui")) -USER_LAYOUT_PATH = USER_DIR / "micromanager_gui_layout.json" -USER_CONFIGS_PATHS = USER_DIR / "system_configurations.json" - - -def block_core(mmcore_events: CMMCoreSignaler | PCoreSignaler) -> ContextManager: - """Block core signals.""" - if isinstance(mmcore_events, CMMCoreSignaler): - return mmcore_events.blocked() # type: ignore - elif isinstance(mmcore_events, PCoreSignaler): - return signals_blocked(mmcore_events) # type: ignore - else: - raise ValueError("Unknown core signaler.") - - -def add_path_to_config_json(path: Path | str) -> None: - """Update the stystem configurations json file with the new path.""" - import json - - if not USER_CONFIGS_PATHS.exists(): - return - - if isinstance(path, Path): - path = str(path) - - # Read the existing data - try: - with open(USER_CONFIGS_PATHS) as f: - data = json.load(f) - except json.JSONDecodeError: - data = {"paths": []} - - # Append the new path. using insert so we leave the empty string at the end - paths = cast(list, data.get("paths", [])) - if path in paths: - paths.remove(path) - paths.insert(0, path) - - # Write the data back to the file - with open(USER_CONFIGS_PATHS, "w") as f: - json.dump({"paths": paths}, f) - - -def save_sys_config_dialog( - parent: QWidget | None = None, mmcore: CMMCorePlus | None = None -) -> None: - """Open file dialog to save a config file. - - The file will be also saved in the USER_CONFIGS_PATHS jason file if it doesn't - yet exist. - """ - (filename, _) = QFileDialog.getSaveFileName( - parent, "Save Micro-Manager Configuration." - ) - if filename: - filename = filename if str(filename).endswith(".cfg") else f"{filename}.cfg" - mmcore = mmcore or CMMCorePlus.instance() - mmcore.saveSystemConfiguration(filename) - add_path_to_config_json(filename) - - -def load_sys_config_dialog( - parent: QWidget | None = None, mmcore: CMMCorePlus | None = None -) -> None: - """Open file dialog to select a config file. - - The loaded file will be also saved in the USER_CONFIGS_PATHS jason file if it - doesn't yet exist. - """ - (filename, _) = QFileDialog.getOpenFileName( - parent, "Select a Micro-Manager configuration file", "", "cfg(*.cfg)" - ) - if filename: - add_path_to_config_json(filename) - mmcore = mmcore or CMMCorePlus.instance() - mmcore.loadSystemConfiguration(filename) - - -def load_sys_config(config: Path | str, mmcore: CMMCorePlus | None = None) -> None: - """Load a system configuration with a warning if the file is not found.""" - mmcore = mmcore or CMMCorePlus.instance() - try: - mmcore.loadSystemConfiguration(config) - except FileNotFoundError: - # don't crash if the user passed an invalid config - warn(f"Config file {config} not found. Nothing loaded.", stacklevel=2) diff --git a/src/micromanager_gui/_widgets/_camera_roi.py b/src/micromanager_gui/_widgets/_camera_roi.py deleted file mode 100644 index 2489c5bc..00000000 --- a/src/micromanager_gui/_widgets/_camera_roi.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from pymmcore_widgets import CameraRoiWidget - -if TYPE_CHECKING: - from pymmcore_plus import CMMCorePlus - from qtpy.QtWidgets import QWidget - - -class _CameraRoiWidget(CameraRoiWidget): - """A subclass of CameraRoiWidget that sets a fixed height.""" - - def __init__(self, parent: QWidget, *, mmcore: CMMCorePlus | None = None): - super().__init__(parent=parent, mmcore=mmcore) - - self.setFixedHeight(self.minimumSizeHint().height()) diff --git a/src/micromanager_gui/_widgets/_config_wizard.py b/src/micromanager_gui/_widgets/_config_wizard.py deleted file mode 100644 index 832c7ee2..00000000 --- a/src/micromanager_gui/_widgets/_config_wizard.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from pymmcore_widgets import ConfigWizard -from pymmcore_widgets.hcwizard.finish_page import DEST_CONFIG - -from micromanager_gui._util import ( - add_path_to_config_json, - load_sys_config, -) - -if TYPE_CHECKING: - from pymmcore_plus import CMMCorePlus - from qtpy.QtWidgets import QWidget - - -class HardwareConfigWizard(ConfigWizard): - """A wizard to create a new Micro-Manager hardware configuration file. - - Subclassing to load the newly created configuration file and to add it to the - USER_CONFIGS_PATHS json file. - """ - - def __init__( - self, - config_file: str = "", - core: CMMCorePlus | None = None, - parent: QWidget | None = None, - ): - super().__init__(config_file, core, parent) - - self.setWindowTitle("Micro-Manager Hardware Configuration Wizard") - - def accept(self) -> None: - """Accept the wizard and save the configuration to a file. - - Overriding to add the new configuration file to the USER_CONFIGS_PATHS json file - and to load it. - """ - super().accept() - dest = self.field(DEST_CONFIG) - # add the path to the USER_CONFIGS_PATHS list - add_path_to_config_json(dest) - load_sys_config(dest) diff --git a/src/micromanager_gui/_widgets/_group_and_preset.py b/src/micromanager_gui/_widgets/_group_and_preset.py deleted file mode 100644 index d397d96f..00000000 --- a/src/micromanager_gui/_widgets/_group_and_preset.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from pymmcore_widgets import GroupPresetTableWidget - -from micromanager_gui._util import load_sys_config_dialog, save_sys_config_dialog - -if TYPE_CHECKING: - from pymmcore_plus import CMMCorePlus - from qtpy.QtWidgets import QWidget - - -class _GroupsAndPresets(GroupPresetTableWidget): - """Subclass of GroupPresetTableWidget. - - Overwrite the save and load methods to store the saved or loaded configuration in - the USER_CONFIGS_PATHS json config file. - """ - - def __init__( - self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None - ) -> None: - super().__init__(parent=parent, mmcore=mmcore) - - def _save_cfg(self) -> None: - """Open file dialog to save the current configuration.""" - save_sys_config_dialog(parent=self, mmcore=self._mmc) - - def _load_cfg(self) -> None: - """Open file dialog to select a config file.""" - load_sys_config_dialog(parent=self, mmcore=self._mmc) diff --git a/src/micromanager_gui/_widgets/_mda/_mda_viewer.py b/src/micromanager_gui/_widgets/_mda/_mda_viewer.py deleted file mode 100644 index 8e17e603..00000000 --- a/src/micromanager_gui/_widgets/_mda/_mda_viewer.py +++ /dev/null @@ -1,441 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, MutableMapping, cast - -import zarr -from fonticon_mdi6 import MDI6 -from pymmcore_plus import CMMCorePlus -from pymmcore_plus.mda.handlers import OMEZarrWriter -from pymmcore_plus.mda.handlers._ome_zarr_writer import POS_PREFIX -from qtpy.QtCore import QSize, Qt -from qtpy.QtWidgets import ( - QCheckBox, - QFileDialog, - QGroupBox, - QHBoxLayout, - QPushButton, - QVBoxLayout, - QWidget, -) -from superqt import QLabeledDoubleRangeSlider -from superqt.fonticon import icon -from superqt.utils import signals_blocked - -from ._sliders import _AxisSlider - -if TYPE_CHECKING: - import os - from typing import Literal - - import numpy as np - import useq - from fsspec import FSMap - -BTN_SIZE = (60, 40) - - -class MDAViewer(OMEZarrWriter, QWidget): - """A Widget that displays an MDA sequence. - - Parameters - ---------- - parent : QWidget | None - Optional parent widget. By default, None. - mmcore : CMMCorePlus | None - Optional [`pymmcore_plus.CMMCorePlus`][] micromanager core. - By default, None. If not specified, the widget will use the active - (or create a new) - [`CMMCorePlus.instance`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance]. - store : MutableMapping | str | os.PathLike | FSMap | None = None - The store to use for the zarr group. By default, None. - """ - - def __init__( - self, - parent: QWidget | None = None, - mmcore: CMMCorePlus | None = None, - store: MutableMapping | str | os.PathLike | FSMap | None = None, - canvas_size: tuple[int, int] | None = None, - *args: Any, - **kwargs: Any, - ): - try: - from vispy import scene - except ImportError as e: - raise ImportError( - "vispy is required for ImagePreview. " - "Please run `pip install pymmcore-widgets[image]`" - ) from e - - super().__init__(store, *args, **kwargs) - QWidget.__init__(self, parent) - - self.setWindowTitle("MDA Viewer") - - self._mmc = mmcore or CMMCorePlus.instance() - self._canvas_size = canvas_size - - # buttons groupbox - btn_wdg = QGroupBox() - btn_wdg_layout = QHBoxLayout(btn_wdg) - btn_wdg_layout.setContentsMargins(10, 0, 10, 0) - # auto contrast checkbox - self._auto = QCheckBox("Auto") - self._auto.setChecked(True) - self._auto.setToolTip("Auto Contrast") - self._auto.setFixedSize(*BTN_SIZE) - self._auto.toggled.connect(self._clims_auto) - # LUT slider - self._lut_slider = QLabeledDoubleRangeSlider() - self._lut_slider.setDecimals(0) - self._lut_slider.valueChanged.connect(self._on_range_changed) - # reset view button - self._reset_view = QPushButton() - self._reset_view.clicked.connect(self._reset) - self._reset_view.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self._reset_view.setToolTip("Reset View") - self._reset_view.setIcon(icon(MDI6.home_outline)) - self._reset_view.setIconSize(QSize(25, 25)) - self._reset_view.setFixedSize(*BTN_SIZE) - # save button - self._save = QPushButton() - self._save.clicked.connect(self._on_save) - self._save.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self._save.setToolTip("Save as Zarr") - self._save.setIcon(icon(MDI6.content_save_outline)) - self._save.setIconSize(QSize(25, 25)) - self._save.setFixedSize(*BTN_SIZE) - - # add to layout - btn_wdg_layout.addWidget(self._lut_slider) - btn_wdg_layout.addWidget(self._auto) - btn_wdg_layout.addWidget(self._reset_view) - btn_wdg_layout.addWidget(self._save) - - # # connect core signals - self._mmc.events.systemConfigurationLoaded.connect(self._on_sys_cfg_loaded) - - self._mda_running: bool = False - - self._sliders: dict[str, _AxisSlider] | None = None - - self._imcls = scene.visuals.Image - self._clims: tuple[float, float] | Literal["auto"] = "auto" - self._cmap: str = "grays" - - self._canvas = scene.SceneCanvas( - keys="interactive", size=(512, 512), parent=self - ) - self.view = self._canvas.central_widget.add_view(camera="panzoom") - self.view.camera.aspect = 1 - - self.image: scene.visuals.Image | None = None - - self._sliders_widget = QGroupBox() - self._sliders_layout = QVBoxLayout(self._sliders_widget) - self._sliders_layout.setContentsMargins(10, 0, 10, 0) - self._sliders_layout.setSpacing(5) - - self.setLayout(QVBoxLayout()) - self.layout().setContentsMargins(0, 0, 0, 0) - self.layout().setSpacing(5) - self.layout().addWidget(self._canvas.native) - self.layout().addWidget(self._sliders_widget) - self.layout().addWidget(btn_wdg) - - self.destroyed.connect(self._disconnect) - - if bit := self._mmc.getImageBitDepth(): - with signals_blocked(self._lut_slider): - self._lut_slider.setRange(0, 2**bit - 1) - self._lut_slider.setValue((0, 2**bit - 1)) - - self._on_sys_cfg_loaded() - - def _on_sys_cfg_loaded(self) -> None: - """Set the canvas size to half of the image size.""" - self._canvas.size = self._canvas_size or ( - int(self._mmc.getImageWidth() / 2), - int(self._mmc.getImageHeight() / 2), - ) - - def _disconnect(self) -> None: - """Disconnect the signals.""" - self._mmc.events.systemConfigurationLoaded.disconnect(self._on_sys_cfg_loaded) - - def sequenceStarted(self, sequence: useq.MDASequence) -> None: - # this method is called be in `_CoreLink` when the MDA sequence starts - self._mda_running = True - super().sequenceStarted(sequence) - - def sequenceFinished(self, sequence: useq.MDASequence) -> None: - super().sequenceFinished(sequence) - if not self._sliders: - self._sliders_widget.hide() - self._mda_running = False - self._disconnect() - - def frameReady(self, image: np.ndarray, event: useq.MDAEvent, meta: dict) -> None: - """Update the image and sliders when a new frame is ready.""" - super().frameReady(image, event, meta) - # update the image in the viewer - self._update_image(image) - # get the position key to select which array to use - key = f"{POS_PREFIX}{event.index.get(POS_PREFIX, 0)}" - # get the data array - data = self.position_arrays[key] - # get the index keys from zarr attrs and remove 'x' and 'y' - index_keys = cast(list[str], data.attrs["_ARRAY_DIMENSIONS"][:-2]) - - if self._sliders is not None: - self._update_sliders_position(event, index_keys) - - if self._sliders is None: - # if self._sliders is None, create the sliders - self._initialize_sliders(data, index_keys, event) - else: - # create any missing slider if the shape of the data has changed (e.g. if - # the shape of the position differs from any of the previous) - self._update_sliders(data, index_keys) - - def _update_image(self, image: np.ndarray) -> None: - """Update the image in the viewer.""" - clim = (image.min(), image.max()) if self._clims == "auto" else self._clims - if self.image is None: - # first time we see this position, create the image - self.image = self._imcls( - image, cmap=self._cmap, clim=clim, parent=self.view.scene - ) - self.view.camera.set_range(margin=0) - else: - # we have seen this position before, update the image - self.image.set_data(image) - self.image.clim = clim - - # update the LUT slider to match the new image - with signals_blocked(self._lut_slider): - if isinstance(clim, tuple): - self._lut_slider.setValue(clim) - else: - self._lut_slider.setValue((image.min(), image.max())) - - def _update_sliders_position( - self, event: useq.MDAEvent, index_keys: list[str] - ) -> None: - """Update the sliders to match the new position.""" - if self._sliders is None: - return - - # move the position sliders to the current position - if POS_PREFIX in self._sliders: - self._update_slider_range_and_value(POS_PREFIX, event) - self._enable_sliders(index_keys) - # move all the other sliders to the current position - for key in index_keys: - if key in self._sliders: - self._update_slider_range_and_value(key, event) - - def _update_slider_range_and_value(self, key: str, event: useq.MDAEvent) -> None: - """Update the sliders to match the new dimensions.""" - if self._sliders is None: - return - index = event.index.get(key, 0) - # set the range of the slider - self._sliders[key].setRange(0, index) - # set the value of the slider - # block signals to avoid triggering "_on_slider_value_changed" - self._sliders[key].blockSignals(True) - self._sliders[key].setValue(index) - self._sliders[key].blockSignals(False) - - def _enable_sliders(self, dims: list[str]) -> None: - """Enable only sliders with the keys that are in the data attrs.""" - # useful when we have a jagged array - if self._sliders is None: - return - for sl in self._sliders: - if sl == POS_PREFIX: - continue - self._sliders[sl].setEnabled(sl in dims) - - def _initialize_sliders( - self, data: np.ndarray, index_keys: list[str], event: useq.MDAEvent - ) -> None: - """Create the sliders for the first time.""" - if event.sequence is None: - return - - self._sliders = {} - - # create position slider if there is more than one position. - # the OMEZarrDatastore divides the data into positions using the POS_PREFIX - # so we can use that to create the position sliders - if POS_PREFIX not in self._sliders and len(event.sequence.stage_positions) > 1: - self._create_and_add_slider(POS_PREFIX, 1) - - if POS_PREFIX in index_keys: - index_keys.remove(POS_PREFIX) - - # create sliders for any other axis - self._create_sliders_for_dimensions(data, index_keys) - - def _create_and_add_slider(self, key: str, range_end: int) -> None: - """Create a slider for the given key and add it to the _sliders_layout.""" - slider = self._create_axis_slider(key, range_end) - if slider is not None: - self._sliders_layout.addWidget(slider) - - def _create_axis_slider(self, key: str, range_end: int) -> _AxisSlider | None: - """Create a slider for the given key.""" - if self._sliders is None: - return None - slider = _AxisSlider(key, parent=self) - slider.valueChanged.connect(self._on_slider_value_changed) - slider.setRange(0, range_end) - self._sliders[key] = slider - return slider - - def _create_sliders_for_dimensions( - self, data: np.ndarray, index_keys: list[str] - ) -> None: - """Create a slider for each index key if the corresponding shape is > 1.""" - if self._sliders is None: - return - for idx, sh in enumerate(data.shape[:-2]): - if sh > 1 and index_keys[idx] not in self._sliders: - self._create_and_add_slider(index_keys[idx], data.shape[idx] - 1) - - def _update_sliders(self, data: np.ndarray, index_keys: list[str]) -> None: - """Update the sliders to match the new dimensions.""" - if self._sliders is None: - return - - if POS_PREFIX in index_keys: - index_keys.remove(POS_PREFIX) - - # create a slider if the key is not yet in the sliders - if any(k not in self._sliders for k in index_keys): - self._create_sliders_for_dimensions(data, index_keys) - - def _on_slider_value_changed(self, value: int) -> None: - """Update the shown image when the slider value changes.""" - if self._sliders is None or self.image is None: - return - - # get the position slider - pos_slider = self._sliders.get(POS_PREFIX, None) - - # get the position key to select which array to use - key = "p0" if pos_slider is None else f"p{pos_slider.value()}" - - # get the data array - data = self.position_arrays[key] - - # get the index keys from zarr attrs and remove 'x' and 'y' - dims = data.attrs["_ARRAY_DIMENSIONS"][:-2] - - # disable sliders that are not in the data attrs (if we have multiple positions) - sender = cast(_AxisSlider, self.sender()) - if sender.axis == POS_PREFIX: - self._enable_sliders(dims) - if not self._mda_running: - # update the sliders range to match the data array - # e.g. if a pos has a 2x1 grid and another has a 2x2 grid, when moving - # the pos slider the range of the g slider should change - self._set_slider_range(dims, data) - - # get the index values from the sliders - index = tuple( - self._sliders[dim].value() - for dim in dims - if dim in self._sliders and dim != POS_PREFIX - ) - - # get the image from the data array - # squeeze the data to remove any dimensions of size 1 - image = data[index].squeeze() - - # display the image in the viewer - self.image.set_data(image) - clim = (image.min(), image.max()) if self._clims == "auto" else self._clims - self.image.clim = clim - - def _set_slider_range(self, dims: list[str], data: np.ndarray) -> None: - """Set the range of the sliders to match the data.""" - if self._sliders is None: - return - for dim in dims: - if dim in self._sliders: - self._sliders[dim].setRange(0, data.shape[dims.index(dim)] - 1) - - def _reset(self) -> None: - """Reset the preview.""" - x = (0, self._mmc.getImageWidth()) if self._mmc.getImageWidth() else None - y = (0, self._mmc.getImageHeight()) if self._mmc.getImageHeight() else None - self.view.camera.set_range(x, y, margin=0) - - def _on_range_changed(self, range: tuple[float, float]) -> None: - """Update the LUT range.""" - self.clims = range - self._auto.setChecked(False) - - def _clims_auto(self, state: bool) -> None: - """Set the LUT range to auto.""" - self.clims = "auto" if state else self._lut_slider.value() - - if self.image is None: - return - - image = self.image._data - with signals_blocked(self._lut_slider): - self._lut_slider.setValue((image.min(), image.max())) - - @property - def clims(self) -> tuple[float, float] | Literal["auto"]: - """Get the contrast limits of the image.""" - return self._clims - - @clims.setter - def clims(self, clims: tuple[float, float] | Literal["auto"] = "auto") -> None: - """Set the contrast limits of the image. - - Parameters - ---------- - clims : tuple[float, float], or "auto" - The contrast limits to set. - """ - if self.image is not None: - self.image.clim = clims - - self._clims = clims - - @property - def cmap(self) -> str: - """Get the colormap (lookup table) of the image.""" - return self._cmap - - @cmap.setter - def cmap(self, cmap: str = "grays") -> None: - """Set the colormap (lookup table) of the image. - - Parameters - ---------- - cmap : str - The colormap to use. - """ - if self.image is not None: - self.image.cmap = cmap - - self._cmap = cmap - - def _on_save(self) -> None: - """Save the data as a zarr.""" - save_path, _ = QFileDialog.getSaveFileName( - self, - "Saving directory and filename.", - "", - "ZARR (*.zarr);", - ) - if save_path: - dir_store = zarr.DirectoryStore(save_path) - zarr.copy_store(self._group.attrs.store, dir_store) diff --git a/src/micromanager_gui/_widgets/_mda/_sliders.py b/src/micromanager_gui/_widgets/_mda/_sliders.py deleted file mode 100644 index 211754a7..00000000 --- a/src/micromanager_gui/_widgets/_mda/_sliders.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -from typing import Any, cast - -from fonticon_mdi6 import MDI6 -from qtpy import QtCore -from qtpy.QtWidgets import ( - QBoxLayout, - QLabel, - QMenu, - QPushButton, - QSizePolicy, - QSpinBox, - QWidget, - QWidgetAction, -) -from superqt import QLabeledSlider -from superqt.fonticon import icon - -FIXED = QSizePolicy.Policy.Fixed -ICON_SIZE = (24, 24) - - -class _AxisSlider(QLabeledSlider): - def __init__( - self, - axis: str = "", - orientation: QtCore.Qt.Orientation = QtCore.Qt.Orientation.Horizontal, - parent: QWidget | None = None, - ) -> None: - super().__init__(orientation, parent) - self.axis = axis - name_label = QLabel(axis.lower()) - name_label.setSizePolicy(FIXED, FIXED) - name_label.setFixedWidth(20) - - self._play_btn = QPushButton(icon(MDI6.play), "", self) - self._play_btn.setMaximumWidth(self._play_btn.sizeHint().height()) - self._play_btn.setCheckable(True) - self._play_btn.toggled.connect(self._on_play_toggled) - # Enable the custom context menu for the play button - self._play_btn.setContextMenuPolicy( - QtCore.Qt.ContextMenuPolicy.CustomContextMenu - ) - self._play_btn.customContextMenuRequested.connect(self._showContextMenu) - - self._timer_id: int | None = None - - self._interval: int = 10 - self._interval_spin = QSpinBox() - self._interval_spin.setSuffix(" fps") - self._interval_spin.setRange(1, 1000) - self._interval_spin.setValue(self._interval) - self._interval_spin.valueChanged.connect( - lambda val: setattr(self, "_interval", val) - ) - - self._length_label = QLabel() - self.rangeChanged.connect(self._on_range_changed) - - layout = cast(QBoxLayout, self.layout()) - layout.setContentsMargins(10, 0, 0, 0) - layout.insertWidget(0, self._play_btn, 0, QtCore.Qt.AlignmentFlag.AlignRight) - layout.insertWidget(0, name_label, 0, QtCore.Qt.AlignmentFlag.AlignVCenter) - layout.addWidget(self._length_label, 0, QtCore.Qt.AlignmentFlag.AlignVCenter) - - self.installEventFilter(self) - self.setPageStep(1) - self.last_val = 0 - - def _on_play_toggled(self, state: bool) -> None: - if state: - self._play_btn.setIcon(icon(MDI6.pause)) - self._timer_id = self.startTimer(int(1000 / self._interval)) # ms - elif self._timer_id is not None: - self._play_btn.setIcon(icon(MDI6.play)) - self.killTimer(self._timer_id) - self._timer_id = None - - def _showContextMenu(self, position: QtCore.QPoint) -> None: - """Context menu to change the interval of the play button.""" - # toggle off the play button - self._play_btn.setChecked(False) - # create context menu - context_menu = QMenu(self) - # create a QWidgetAction and set its default widget to the QSpinBox - spin_box_action = QWidgetAction(self) - spin_box_action.setDefaultWidget(self._interval_spin) - # add the QWidgetAction to the menu - context_menu.addAction(spin_box_action) - # show the context menu - context_menu.exec_(self._play_btn.mapToGlobal(position)) - - def timerEvent(self, e: QtCore.QTimerEvent) -> None: - """Move the slider to the next value when play is toggled.""" - self.setValue( - self.minimum() - + (self.value() - self.minimum() + 1) - % (self.maximum() - self.minimum() + 1) - ) - - def _on_range_changed(self, min_: int, max_: int) -> None: - self._length_label.setText(f"/ {max_}") - - def eventFilter(self, source: QtCore.QObject, event: QtCore.QEvent) -> Any: - if event.type() == QtCore.QEvent.Type.Paint and self.underMouse(): - if self.value() != self.last_val: - self.sliderMoved.emit(self.value()) - self.last_val = self.value() - return super().eventFilter(source, event) diff --git a/src/micromanager_gui/_widgets/_mda_widget.py b/src/micromanager_gui/_widgets/_mda_widget.py new file mode 100644 index 00000000..5773df80 --- /dev/null +++ b/src/micromanager_gui/_widgets/_mda_widget.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, cast + +from pymmcore_plus.mda.handlers import ( + ImageSequenceWriter, + OMETiffWriter, + OMEZarrWriter, + TensorStoreHandler, +) +from pymmcore_widgets.mda import MDAWidget +from pymmcore_widgets.mda._save_widget import ( + OME_TIFF, + OME_ZARR, + WRITERS, + ZARR_TESNSORSTORE, +) +from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY +from useq import MDASequence + +OME_TIFFS = tuple(WRITERS[OME_TIFF]) +GB_CACHE = 2_000_000_000 # 2 GB for tensorstore cache + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from qtpy.QtWidgets import ( + QVBoxLayout, + QWidget, + ) + from useq import MDASequence + + +class _MDAWidget(MDAWidget): + """Main napari-micromanager GUI.""" + + def __init__( + self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent=parent, mmcore=mmcore) + + # writer for saving the MDA sequence. This is used by the MDAViewer to set its + # internal datastore. If _writer is None, the MDAViewer will use its default + # internal datastore. + self.writer: OMETiffWriter | OMETiffWriter | TensorStoreHandler | None = None + + # setContentsMargins + pos_layout = cast("QVBoxLayout", self.stage_positions.layout()) + pos_layout.setContentsMargins(10, 10, 10, 10) + time_layout = cast("QVBoxLayout", self.time_plan.layout()) + time_layout.setContentsMargins(10, 10, 10, 10) + + def _on_mda_finished(self, sequence: MDASequence) -> None: + """Handle the end of the MDA sequence.""" + self.writer = None + super()._on_mda_finished(sequence) + + def run_mda(self) -> None: + """Run the MDA sequence experiment.""" + # in case the user does not press enter after editing the save name. + self.save_info.save_name.editingFinished.emit() + + # if autofocus has been requested, but the autofocus device is not engaged, + # and position-specific offsets haven't been set, show a warning + pos = self.stage_positions + if ( + self.af_axis.value() + and not self._mmc.isContinuousFocusLocked() + and (not self.tab_wdg.isChecked(pos) or not pos.af_per_position.isChecked()) + and not self._confirm_af_intentions() + ): + return + + sequence = self.value() + + # reset the writer + self.writer = None + + # technically, this is in the metadata as well, but isChecked is more direct + if self.save_info.isChecked(): + save_path = self._update_save_path_from_metadata( + sequence, update_metadata=True + ) + if isinstance(save_path, Path): + # get save format from metadata + save_meta = sequence.metadata.get(PYMMCW_METADATA_KEY, {}) + save_format = save_meta.get("format") + # set the writer to use for saving the MDA sequence. + # NOTE: 'self._writer' is used by the 'MDAViewer' to set its datastore + self.writer = self._create_mda_viewer_writer(save_format, save_path) + # at this point, if self.writer is None, it means thet a + # ImageSequenceWriter should be used to save the sequence. + if self.writer is None: + output = ImageSequenceWriter(save_path) + # Since any other type of writer will be handled by the 'MDAViewer', + # we need to pass a writer to the engine only if it is a + # 'ImageSequenceWriter'. + self._mmc.run_mda(sequence, output=output) + return + + self._mmc.run_mda(sequence) + + def _create_mda_viewer_writer( + self, save_format: str, save_path: Path + ) -> OMEZarrWriter | OMETiffWriter | TensorStoreHandler | None: + """Create a writer for the MDAViewer based on the save format.""" + # use internal OME-TIFF writer if selected + if OME_TIFF in save_format: + # if OME-TIFF, save_path should be a directory without extension, so + # we need to add the ".ome.tif" to correctly use the OMETiffWriter + if not save_path.name.endswith(OME_TIFFS): + save_path = save_path.with_suffix(OME_TIFF) + return OMETiffWriter(save_path) + elif OME_ZARR in save_format: + return OMEZarrWriter(save_path) + elif ZARR_TESNSORSTORE in save_format: + return self._create_zarr_tensorstore(save_path) + # cannot use the ImageSequenceWriter here because the MDAViewer will not be + # able to handle it. + return None + + def _create_zarr_tensorstore(self, save_path: Path) -> TensorStoreHandler: + """Create a Zarr TensorStore writer.""" + return TensorStoreHandler( + driver="zarr", + path=save_path, + delete_existing=True, + spec={"context": {"cache_pool": {"total_bytes_limit": GB_CACHE}}}, + ) + + def _update_save_path_from_metadata( + self, + sequence: MDASequence, + update_widget: bool = True, + update_metadata: bool = False, + ) -> Path | None: + """Get the next available save path from sequence metadata and update widget. + + Parameters + ---------- + sequence : MDASequence + The MDA sequence to get the save path from. (must be in the + 'pymmcore_widgets' key of the metadata) + update_widget : bool, optional + Whether to update the save widget with the new path, by default True. + update_metadata : bool, optional + Whether to update the Sequence metadata with the new path, by default False. + """ + if ( + (meta := sequence.metadata.get(PYMMCW_METADATA_KEY, {})) + and (save_dir := meta.get("save_dir")) + and (save_name := meta.get("save_name")) + ): + requested = (Path(save_dir) / str(save_name)).expanduser().resolve() + next_path = self.get_next_available_path(requested) + + if next_path != requested: + if update_widget: + self.save_info.setValue(next_path) + if update_metadata: + meta.update(self.save_info.value()) + return Path(next_path) + return None diff --git a/src/micromanager_gui/_widgets/_pixel_configurations.py b/src/micromanager_gui/_widgets/_pixel_configurations.py deleted file mode 100644 index b2549afc..00000000 --- a/src/micromanager_gui/_widgets/_pixel_configurations.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, cast - -from pymmcore_plus.model import PixelSizeGroup -from pymmcore_widgets import PixelConfigurationWidget -from qtpy.QtWidgets import QHBoxLayout, QPushButton, QWidget - -if TYPE_CHECKING: - from pymmcore_plus import CMMCorePlus - - -class _PixelConfigurationWidget(PixelConfigurationWidget): - """A Subclass of PixelConfigurationWidget. - - Remove cancel button and hide the parent widget since this will become a - dock widget. - """ - - def __init__(self, parent: QWidget, *, mmcore: CMMCorePlus | None = None): - super().__init__(parent=parent, mmcore=mmcore) - self._parent = parent - - # hide cancel button - btns_layout = cast(QHBoxLayout, self.layout().children()[0]) - cancel_btn = cast(QPushButton, btns_layout.itemAt(1).widget()) - cancel_btn.hide() - - # remove close() method from _on_apply - def _on_apply(self) -> None: - """Update the pixel configuration.""" - # check if there are errors in the pixel configurations - if self._check_for_errors(): - return - - # delete all the pixel size configurations - for resolutionID in self._mmc.getAvailablePixelSizeConfigs(): - self._mmc.deletePixelSizeConfig(resolutionID) - - # create the new pixel size configurations - px_groups = PixelSizeGroup(presets=self._value_to_dict(self.value())) - px_groups.apply_to_core(self._mmc) - - self._parent.hide() diff --git a/src/micromanager_gui/_widgets/_preview.py b/src/micromanager_gui/_widgets/_preview.py index 4599c445..1bd66916 100644 --- a/src/micromanager_gui/_widgets/_preview.py +++ b/src/micromanager_gui/_widgets/_preview.py @@ -1,32 +1,57 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -# import tifffile +import numpy as np +import tifffile from fonticon_mdi6 import MDI6 -from pymmcore_plus import CMMCorePlus +from pymmcore_plus import CMMCorePlus, Metadata from pymmcore_widgets import ImagePreview from qtpy.QtCore import QSize, Qt from qtpy.QtWidgets import ( - QCheckBox, - # QFileDialog, - QGroupBox, + QFileDialog, QHBoxLayout, QPushButton, + QSizePolicy, QVBoxLayout, QWidget, ) -from superqt import QLabeledDoubleRangeSlider +from superqt import QLabeledRangeSlider from superqt.fonticon import icon from superqt.utils import signals_blocked -from ._snap_and_live import Live, Snap - -if TYPE_CHECKING: - import numpy as np - from qtpy.QtGui import QCloseEvent - -BTN_SIZE = (60, 40) +from ._snap_live_buttons import Live, Snap + +BTN_SIZE = 30 +ICON_SIZE = QSize(25, 25) +SS = """ +QSlider::groove:horizontal { + height: 15px; + background: qlineargradient( + x1:0, y1:0, x2:0, y2:1, + stop:0 rgba(128, 128, 128, 0.25), + stop:1 rgba(128, 128, 128, 0.1) + ); + border-radius: 3px; +} + +QSlider::handle:horizontal { + width: 38px; + background: #999999; + border-radius: 3px; +} + +QLabel { font-size: 12px; } + +QRangeSlider { qproperty-barColor: qlineargradient( + x1:0, y1:0, x2:0, y2:1, + stop:0 rgba(100, 80, 120, 0.2), + stop:1 rgba(100, 80, 120, 0.4) + )} + +SliderLabel { + font-size: 12px; + color: white; +} +""" class _ImagePreview(ImagePreview): @@ -47,14 +72,32 @@ def __init__( self._preview_wdg = preview_widget - def _update_image(self, image: np.ndarray) -> None: + # the metadata associated with the image + self._meta: Metadata | dict = {} + + def _on_image_snapped(self) -> None: + if self._mmc.mda.is_running() and not self._use_with_mda: + return + self._update_image(self._mmc.getTaggedImage()) + + def _on_streaming_stop(self) -> None: + self.streaming_timer.stop() + self._meta = self._mmc.getTags() + + def _update_image(self, data: tuple[np.ndarray, Metadata] | np.ndarray) -> None: + """Update the image and the _clims slider.""" + if isinstance(data, np.ndarray): + image = data + else: + image, self._meta = data + super()._update_image(image) if self.image is None: return - with signals_blocked(self._preview_wdg._lut_slider): - self._preview_wdg._lut_slider.setValue(self.image.clim) + with signals_blocked(self._preview_wdg._clims): + self._preview_wdg._clims.setValue(self.image.clim) class Preview(QWidget): @@ -65,64 +108,78 @@ def __init__( parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None, - canvas_size: tuple[int, int] | None = None, ): super().__init__(parent) self.setWindowTitle("Image Preview") self._mmc = mmcore or CMMCorePlus.instance() - self._canvas_size = canvas_size main_layout = QVBoxLayout() self.setLayout(main_layout) # preview self._image_preview = _ImagePreview(self, mmcore=self._mmc, preview_widget=self) + self._image_preview.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) main_layout.addWidget(self._image_preview) # buttons - btn_wdg = QGroupBox() - btn_wdg_layout = QHBoxLayout(btn_wdg) - btn_wdg_layout.setContentsMargins(0, 0, 0, 0) + bottom_wdg = QWidget() + bottom_wdg_layout = QHBoxLayout(bottom_wdg) + bottom_wdg_layout.setContentsMargins(0, 0, 0, 0) + # auto contrast checkbox - self._auto = QCheckBox("Auto") - self._auto.setChecked(True) - self._auto.setToolTip("Auto Contrast") - self._auto.setFixedSize(*BTN_SIZE) - self._auto.toggled.connect(self._clims_auto) + self._auto_clim = QPushButton("Auto") + self._auto_clim.setMaximumWidth(42) + self._auto_clim.setCheckable(True) + self._auto_clim.setChecked(True) + self._auto_clim.toggled.connect(self._clims_auto) # LUT slider - self._lut_slider = QLabeledDoubleRangeSlider() - self._lut_slider.setDecimals(0) - self._lut_slider.valueChanged.connect(self._on_range_changed) + self._clims = QLabeledRangeSlider(Qt.Orientation.Horizontal) + self._clims.setStyleSheet(SS) + self._clims.setHandleLabelPosition( + QLabeledRangeSlider.LabelPosition.LabelsOnHandle + ) + self._clims.setEdgeLabelMode(QLabeledRangeSlider.EdgeLabelMode.NoLabel) + self._clims.setRange(0, 2**8) + self._clims.valueChanged.connect(self._on_clims_changed) + + # buttons widget + btns_wdg = QWidget() + btns_layout = QHBoxLayout(btns_wdg) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.setSpacing(5) # snap and live buttons self._snap = Snap(mmcore=self._mmc) self._snap.setFocusPolicy(Qt.FocusPolicy.NoFocus) + btns_layout.addWidget(self._snap) self._live = Live(mmcore=self._mmc) self._live.setFocusPolicy(Qt.FocusPolicy.NoFocus) + btns_layout.addWidget(self._live) # reset view button self._reset_view = QPushButton() self._reset_view.clicked.connect(self._reset) self._reset_view.setFocusPolicy(Qt.FocusPolicy.NoFocus) self._reset_view.setToolTip("Reset View") - self._reset_view.setIcon(icon(MDI6.home_outline)) - self._reset_view.setIconSize(QSize(25, 25)) - self._reset_view.setFixedSize(*BTN_SIZE) + self._reset_view.setIcon(icon(MDI6.fullscreen)) + self._reset_view.setIconSize(ICON_SIZE) + self._reset_view.setFixedWidth(BTN_SIZE) + btns_layout.addWidget(self._reset_view) # save button - # self._save = QPushButton() - # self._save.clicked.connect(self._on_save) - # self._save.setFocusPolicy(Qt.FocusPolicy.NoFocus) - # self._save.setToolTip("Save Image") - # self._save.setIcon(icon(MDI6.content_save_outline)) - # self._save.setIconSize(QSize(25, 25)) - # self._save.setFixedSize(*BTN_SIZE) - - btn_wdg_layout.addWidget(self._lut_slider) - btn_wdg_layout.addWidget(self._auto) - btn_wdg_layout.addWidget(self._snap) - btn_wdg_layout.addWidget(self._live) - btn_wdg_layout.addWidget(self._reset_view) - # btn_wdg_layout.addWidget(self._save) - main_layout.addWidget(btn_wdg) + self._save = QPushButton() + self._save.clicked.connect(self._on_save) + self._save.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self._save.setToolTip("Save Image") + self._save.setIcon(icon(MDI6.content_save_outline)) + self._save.setIconSize(ICON_SIZE) + self._save.setFixedWidth(BTN_SIZE) + btns_layout.addWidget(self._save) + + bottom_wdg_layout.addWidget(self._clims) + bottom_wdg_layout.addWidget(self._auto_clim) + bottom_wdg_layout.addWidget(btns_wdg) + main_layout.addWidget(bottom_wdg) # connections self._mmc.events.systemConfigurationLoaded.connect(self._on_sys_cfg_loaded) @@ -140,15 +197,9 @@ def _on_sys_cfg_loaded(self) -> None: """Update the LUT slider range and the canvas size.""" # update the LUT slider range if bit := self._mmc.getImageBitDepth(): - with signals_blocked(self._lut_slider): - self._lut_slider.setRange(0, 2**bit - 1) - self._lut_slider.setValue((0, 2**bit - 1)) - - # set the canvas size to half of the image size - self._image_preview._canvas.size = self._canvas_size or ( - int(self._mmc.getImageWidth()), - int(self._mmc.getImageHeight()), - ) + with signals_blocked(self._clims): + self._clims.setRange(0, 2**bit - 1) + self._clims.setValue((0, 2**bit - 1)) def _reset(self) -> None: """Reset the preview.""" @@ -156,33 +207,31 @@ def _reset(self) -> None: y = (0, self._mmc.getImageHeight()) if self._mmc.getImageHeight() else None self._image_preview.view.camera.set_range(x, y, margin=0) - def _on_range_changed(self, range: tuple[float, float]) -> None: + def _on_clims_changed(self, range: tuple[float, float]) -> None: """Update the LUT range.""" self._image_preview.clims = range - self._auto.setChecked(False) + self._auto_clim.setChecked(False) def _clims_auto(self, state: bool) -> None: """Set the LUT range to auto.""" - self._image_preview.clims = "auto" if state else self._lut_slider.value() + self._image_preview.clims = "auto" if state else self._clims.value() if self._image_preview.image is not None: data = self._image_preview.image._data - with signals_blocked(self._lut_slider): - self._lut_slider.setValue((data.min(), data.max())) - - # def _on_save(self) -> None: - # """Save the image as tif.""" - # # TODO: add metadata - # if self._image_preview.image is None: - # return - # path, _ = QFileDialog.getSaveFileName( - # self, "Save Image", "", "TIFF (*.tif *.tiff)" - # ) - # if not path: - # return - # tifffile.imwrite(path, self._image_preview.image._data, imagej=True) - - def closeEvent(self, event: QCloseEvent | None) -> None: - # stop live acquisition if running - if self._mmc.isSequenceRunning(): - self._mmc.stopSequenceAcquisition() - super().closeEvent(event) + with signals_blocked(self._clims): + self._clims.setValue((data.min(), data.max())) + + def _on_save(self) -> None: + """Save the image as tif.""" + if self._image_preview.image is None: + return + path, _ = QFileDialog.getSaveFileName( + self, "Save Image", "", "TIFF (*.tif *.tiff)" + ) + if not path: + return + tifffile.imwrite( + path, + self._image_preview.image._data, + imagej=True, + # description=self._image_preview._meta, # TODO: ome-tiff + ) diff --git a/src/micromanager_gui/_widgets/_snap_and_live.py b/src/micromanager_gui/_widgets/_snap_live_buttons.py similarity index 70% rename from src/micromanager_gui/_widgets/_snap_and_live.py rename to src/micromanager_gui/_widgets/_snap_live_buttons.py index ce00e42a..630c5d44 100644 --- a/src/micromanager_gui/_widgets/_snap_and_live.py +++ b/src/micromanager_gui/_widgets/_snap_live_buttons.py @@ -3,17 +3,18 @@ from typing import TYPE_CHECKING from fonticon_mdi6 import MDI6 -from pymmcore_widgets import ( - LiveButton, - SnapButton, -) +from pymmcore_widgets import LiveButton, SnapButton +from qtpy.QtCore import QSize from superqt.fonticon import icon if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus - from qtpy.QtWidgets import QWidget + from qtpy.QtWidgets import ( + QWidget, + ) -BTN_SIZE = (60, 40) +BTN_SIZE = 30 +ICON_SIZE = QSize(25, 25) class Snap(SnapButton): @@ -26,7 +27,8 @@ def __init__( self.setToolTip("Snap Image") self.setIcon(icon(MDI6.camera_outline)) self.setText("") - self.setFixedSize(*BTN_SIZE) + self.setFixedWidth(BTN_SIZE) + self.setIconSize(ICON_SIZE) class Live(LiveButton): @@ -40,5 +42,6 @@ def __init__( self.button_text_on = "" self.button_text_off = "" self.icon_color_on = () - self.icon_color_off = "#C33" - self.setFixedSize(*BTN_SIZE) + self.icon_color_off = "magenta" + self.setFixedWidth(BTN_SIZE) + self.setIconSize(ICON_SIZE) From 72c7e2bedf8500207164f03ef00fb20505539404 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 27 May 2024 11:51:00 -0400 Subject: [PATCH 029/226] fix: pre-commit --- .pre-commit-config.yaml | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 392a33c4..3537607d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,25 +3,18 @@ ci: autofix_commit_msg: "style(pre-commit.ci): auto fixes [...]" autoupdate_commit_msg: "ci(pre-commit.ci): autoupdate" -default_install_hook_types: [pre-commit, commit-msg] - repos: - # - repo: https://github.com/compilerla/conventional-pre-commit - # rev: v2.1.1 - # hooks: - # - id: conventional-pre-commit - # stages: [commit-msg] + - repo: https://github.com/crate-ci/typos + rev: v1.21.0 + hooks: + - id: typos - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.4.3 hooks: - id: ruff - args: [--fix] - - - repo: https://github.com/psf/black - rev: 24.4.2 - hooks: - - id: black + args: [--fix, --unsafe-fixes] + - id: ruff-format - repo: https://github.com/abravalheri/validate-pyproject rev: v0.16 From 7b2662f0eb39ea8fe32ddbbe82663aa5b061863f Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 27 May 2024 12:01:17 -0400 Subject: [PATCH 030/226] fix: readme.md --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 8c140fa5..26472899 100644 --- a/README.md +++ b/README.md @@ -34,3 +34,9 @@ You also need to install the `Micro-Manager` device adapters and C++ core provid ```bash python -m micromanager_gui ``` + +By passing the `-c` or `-config` flag, you can specify the path to a micromanager configuration file you want to load. For example: + +```bash +python -m micromanager_gui -c path/to/config.cfg +``` From b3fdbb91320d7a389179c0a51b9cec98f486bd36 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 27 May 2024 13:27:41 -0400 Subject: [PATCH 031/226] fix: reader --- pyproject.toml | 3 + .../_readers/_ome_zarr_reader.py | 138 ++++++++++++++++++ .../_readers/_tensorstore_zarr_reader.py | 67 +++++---- 3 files changed, 174 insertions(+), 34 deletions(-) create mode 100644 src/micromanager_gui/_readers/_ome_zarr_reader.py diff --git a/pyproject.toml b/pyproject.toml index e0fae102..adb52e07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -166,3 +166,6 @@ ignore = [ # [tool.cibuildwheel.environment] # HATCH_BUILD_HOOKS_ENABLE = "1" + +[tool.typos.default] +extend-ignore-identifiers-re = ["(?i)nd2?.*", "(?i)ome"] \ No newline at end of file diff --git a/src/micromanager_gui/_readers/_ome_zarr_reader.py b/src/micromanager_gui/_readers/_ome_zarr_reader.py new file mode 100644 index 00000000..5b5f1997 --- /dev/null +++ b/src/micromanager_gui/_readers/_ome_zarr_reader.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Mapping, cast + +import useq +import zarr + +if TYPE_CHECKING: + import numpy as np + from zarr.hierarchy import Group + +EVENT = "Event" +FRAME_META = "frame_meta" +ARRAY_DIMS = "_ARRAY_DIMENSIONS" + + +class OMEZarrReader: + """Reads a ome-zarr file generated with the 'OMEZarrWriter'. + + Parameters + ---------- + path : str | Path + The path to the ome-zarr file. + + Attributes + ---------- + path : Path + The path to the ome-zarr file. + store : zarr.Group + The zarr file. + sequence : useq.MDASequence | None + The acquired useq.MDASequence. It is loaded from the metadata using the + `useq.MDASequence` key. + + Usage + ----- + reader = OMEZarrReader("path/to/file") + # to get the numpy array for a specific axis, for example, the first time point for + # the first position and the first z-slice: + data = reader.isel({"p": 0, "t": 1, "z": 0}) + # to also get the metadata for the given index: + data, metadata = reader.isel({"p": 0, "t": 1, "z": 0}, metadata=True) + """ + + def __init__(self, path: str | Path): + self._path = path + + # open the zarr file + self._store: Group = zarr.open(self._path) + + # the useq.MDASequence if it exists + self._sequence: useq.MDASequence | None = None + + # ___________________________Public Methods___________________________ + + @property + def path(self) -> Path: + """Return the path.""" + return Path(self._path) + + @property + def store(self) -> Group: + """Return the zarr file.""" + return self._store + + @property + def sequence(self) -> useq.MDASequence | None: + """Return the MDASequence if it exists.""" + try: + seq = cast(dict, self._store["p0"].attrs["useq_MDASequence"]) + self._sequence = useq.MDASequence(**seq) if seq is not None else None + except KeyError: + self._sequence = None + return self._sequence + + def isel( + self, indexers: dict[str, int], metadata: bool = False + ) -> np.ndarray | tuple[np.ndarray, dict]: + """Select data from the array. + + Parameters + ---------- + indexers : Mapping[str, int] + The indexers to select the data. Thy should contain the 'p' axis since the + OMEZarrWriter saves each position as a separate array. If not present, it + assume the first position {"p": 0}. + metadata : bool + If True, return the metadata as well as a list of dictionaries. By default, + False. + """ + # add the position axis if not present + if "p" not in indexers: + indexers["p"] = 0 + pos_key = f"p{indexers['p']}" + index = self._get_axis_index(indexers, pos_key) + data = self.store[pos_key][index].squeeze() + if metadata: + meta = self._get_metadata_from_index(indexers, pos_key) + return data, meta + return data + + # ___________________________Private Methods___________________________ + + def _get_axis_index( + self, indexers: Mapping[str, int], pos_key: str + ) -> tuple[object, ...]: + """Return a tuple to index the data for the given axis.""" + axis_order = self.store[pos_key].attrs.get(ARRAY_DIMS) # ['t','c','y','x'] + # add p if not in the axis order + if "p" not in axis_order: + axis_order = ["p", *axis_order] + # remove x and y from the axis order + if "x" in axis_order: + axis_order.remove("x") + if "y" in axis_order: + axis_order.remove("y") + + # if any of the indexers are not in the axis order, raise an error + if not set(indexers.keys()).issubset(set(axis_order)): + raise ValueError(f"Invalid axis in indexers: {indexers}, {axis_order}") + + # get the correct index for the axis + # e.g. (slice(None), 1, slice(None), slice(None)) + return tuple( + indexers[axis] if axis in indexers else slice(None) for axis in axis_order + ) + + def _get_metadata_from_index( + self, indexers: Mapping[str, int], pos_key: str + ) -> list[dict]: + """Return the metadata for the given indexers.""" + metadata = [] + for meta in self.store[pos_key].attrs.get(FRAME_META, []): + event_index = meta["Event"]["index"] # e.g. {"p": 0, "t": 1} + if indexers.items() <= event_index.items(): + metadata.append(meta) + return metadata diff --git a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py index 6735307b..fa87e77e 100644 --- a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py +++ b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py @@ -10,7 +10,7 @@ class TensorstoreZarrReader: - """Read data from a tensorstore zarr file. + """Read a tensorstore zarr file generated with the 'TensorstoreZarrWriter'. Parameters ---------- @@ -23,9 +23,6 @@ class TensorstoreZarrReader: The path to the tensorstore zarr file. store : ts.TensorStore The tensorstore. - metadata : dict - The metadata from the acquisition. They are stored in the `.zattrs` file and - should contain two keys: `useq_MDASequence` and `useq_MDASequence`. sequence : useq.MDASequence The acquired useq.MDASequence. It is loaded from the metadata using the `useq.MDASequence` key. @@ -36,6 +33,8 @@ class TensorstoreZarrReader: # to get the numpy array for a specific axis, for example, the first time point for # the first position and the first z-slice: data = reader.isel({"p": 0, "t": 1, "z": 0}) + # to also get the metadata for the given index: + data, metadata = reader.isel({"p": 0, "t": 1, "z": 0}, metadata=True) """ def __init__(self, path: str | Path): @@ -43,7 +42,7 @@ def __init__(self, path: str | Path): spec = { "driver": "zarr", - "kvstore": {"driver": "file", "path": self._path}, + "kvstore": {"driver": "file", "path": str(self._path)}, } self._store = ts.open(spec) @@ -62,31 +61,12 @@ def store(self) -> ts.TensorStore: """Return the tensorstore.""" return self._store.result() - @property - def metadata(self) -> dict: - return self._metadata - @property def sequence(self) -> useq.MDASequence: seq = self._metadata.get("useq_MDASequence") return useq.MDASequence(**json.loads(seq)) if seq is not None else None - def _get_axis_index(self, indexers: Mapping[str, int]) -> tuple[object, ...]: - """Return a tuple to index the data for the given axis.""" - if self.sequence is None: - raise ValueError("No 'useq.MDASequence' found in the metadata!") - - axis_order = self.sequence.axis_order - - # if any of the indexers are not in the axis order, raise an error - if not set(indexers.keys()).issubset(set(axis_order)): - raise ValueError("Invalid axis in indexers!") - - # get the correct index for the axis - # e.g. (slice(None), 1, slice(None), slice(None)) - return tuple( - indexers[axis] if axis in indexers else slice(None) for axis in axis_order - ) + # ___________________________Public Methods___________________________ def isel( self, indexers: Mapping[str, int], metadata: bool = False @@ -108,15 +88,6 @@ def isel( return data, meta return data - def _get_metadata_from_index(self, indexers: Mapping[str, int]) -> list[dict]: - """Return the metadata for the given indexers.""" - metadata = [] - for meta in self._metadata.get("frame_metadatas", []): - event_index = meta["Event"]["index"] # e.g. {"p": 0, "t": 1} - if indexers.items() <= event_index.items(): - metadata.append(meta) - return metadata - def write_tiff( self, path: str | Path, @@ -162,3 +133,31 @@ def write_tiff( "The path should be a file path, not a directory! " "(e.g. 'path/to/file.tif')" ) from e + + # ___________________________Private Methods___________________________ + + def _get_axis_index(self, indexers: Mapping[str, int]) -> tuple[object, ...]: + """Return a tuple to index the data for the given axis.""" + if self.sequence is None: + raise ValueError("No 'useq.MDASequence' found in the metadata!") + + axis_order = self.sequence.axis_order + + # if any of the indexers are not in the axis order, raise an error + if not set(indexers.keys()).issubset(set(axis_order)): + raise ValueError("Invalid axis in indexers!") + + # get the correct index for the axis + # e.g. (slice(None), 1, slice(None), slice(None)) + return tuple( + indexers[axis] if axis in indexers else slice(None) for axis in axis_order + ) + + def _get_metadata_from_index(self, indexers: Mapping[str, int]) -> list[dict]: + """Return the metadata for the given indexers.""" + metadata = [] + for meta in self._metadata.get("frame_metadatas", []): + event_index = meta["Event"]["index"] # e.g. {"p": 0, "t": 1} + if indexers.items() <= event_index.items(): + metadata.append(meta) + return metadata From bc3560d4982f0da8884665d4a8cbf891abe87d6e Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 27 May 2024 13:28:48 -0400 Subject: [PATCH 032/226] fix: _open_datastore --- src/micromanager_gui/_main_window.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/micromanager_gui/_main_window.py b/src/micromanager_gui/_main_window.py index 801e8654..ff7ee175 100644 --- a/src/micromanager_gui/_main_window.py +++ b/src/micromanager_gui/_main_window.py @@ -78,18 +78,24 @@ def dragEnterEvent(self, event: QDragEnterEvent) -> None: def dropEvent(self, event: QDropEvent) -> None: """Open a tensorstore from a directory dropped in the window.""" for idx, url in enumerate(event.mimeData().urls()): - path = url.toLocalFile() - # if is not a dir, continue - if not Path(path).is_dir(): - continue + path = Path(url.toLocalFile()) - # if is a dir, open it as a tensorstore + sw = self._open_datastore(idx, path) + + if sw is not None: + self._core_link._viewer_tab.addTab(sw, f"datastore_{idx}") + self._core_link._viewer_tab.setCurrentWidget(sw) + + super().dropEvent(event) + + def _open_datastore(self, idx: int, path: Path) -> StackViewer | None: + if path.name.endswith(".tensorstore.zarr"): try: reader = TensorstoreZarrReader(path) - s = StackViewer(reader.store, parent=self) - self._core_link._viewer_tab.addTab(s, f"Zarr Tensorstore_{idx}") - self._core_link._viewer_tab.setCurrentWidget(s) + return StackViewer(reader.store, parent=self) except Exception as e: - warn(f"Error opening tensorstore: {e}!", stacklevel=2) - - super().dropEvent(event) + warn(f"Error opening tensorstore-zarr: {e}!", stacklevel=2) + return None + else: + warn(f"Not yet supported format: {path.name}!", stacklevel=2) + return None From b7fb85d6b86db87b6f0afc848485d1a391b8041a Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Mon, 27 May 2024 12:02:06 -0400 Subject: [PATCH 033/226] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 26472899..365b2491 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ You also need to install the `Micro-Manager` device adapters and C++ core provid python -m micromanager_gui ``` -By passing the `-c` or `-config` flag, you can specify the path to a micromanager configuration file you want to load. For example: +By passing the `-c` or `-config` flag, you can specify the path of a micromanager configuration file you want to load. For example: ```bash python -m micromanager_gui -c path/to/config.cfg From 11d34c2ccb0f5f8b44f3794a8a69c6e7006124bb Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 27 May 2024 13:38:05 -0400 Subject: [PATCH 034/226] fix: dependencies --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index adb52e07..a71e73fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ ] dynamic = ["version"] dependencies = [ - "pymmcore-widgets@git+https://github.com/fdrgsp/pymmcore-plus.git@viewer_v2_and_tensorstore", + "pymmcore-widgets@git+https://github.com/fdrgsp/pymmcore-widgets.git@viewer_v2_and_tensorstore", "pymmcore-plus@git+https://github.com/fdrgsp/pymmcore-plus.git@add_tensorstore_to_runner", "qtpy", "vispy", From 2e55b3d00590dbab5b688273303907544ad292da Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 27 May 2024 13:44:17 -0400 Subject: [PATCH 035/226] fix: tqdm --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a71e73fb..5641b2bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,8 @@ dependencies = [ "qtpy", "vispy", "zarr", - "tifffile" + "tifffile", + "tqdm" ] # extras From 479dddfc414de607647358c36db755d989609a4d Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 27 May 2024 13:47:25 -0400 Subject: [PATCH 036/226] fix: "yaml" --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5641b2bd..a06e9867 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,8 @@ dependencies = [ "vispy", "zarr", "tifffile", - "tqdm" + "tqdm", + "yaml" ] # extras From 80f480e4b8f708a48a388a8bc49d7de70a9c7e40 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 27 May 2024 13:48:35 -0400 Subject: [PATCH 037/226] fix: pyyaml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a06e9867..2ca73b4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "zarr", "tifffile", "tqdm", - "yaml" + "pyyaml" ] # extras From dc3e1f290d20b97eacfd684f66e839717c235f61 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 27 May 2024 14:18:06 -0400 Subject: [PATCH 038/226] feat: close viewers menu --- src/micromanager_gui/_menubar/_menubar.py | 36 ++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index 66cb5245..1fd03d74 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -109,9 +109,20 @@ def __init__( self._act_load_configuration.triggered.connect(self._load_cfg) self._configurations_menu.addAction(self._act_load_configuration) - # # widgets_menu + # widgets_menu self._widgets_menu = self.addMenu("Widgets") + # viewer menu + self._viewer_menu = self.addMenu("Viewers") + self._act_close_all = QAction("Close All Viewers", self) + self._act_close_all.triggered.connect(self._close_all) + self._viewer_menu.addAction(self._act_close_all) + self._act_close_all_but_current = QAction( + "Close All Viewers but the Current", self + ) + self._act_close_all_but_current.triggered.connect(self._close_all_but_current) + self._viewer_menu.addAction(self._act_close_all_but_current) + # create actions from WIDGETS and DOCKWIDGETS keys = {*WIDGETS.keys(), *DOCKWIDGETS.keys()} for action_name in sorted(keys): @@ -159,6 +170,29 @@ def _show_config_wizard(self) -> None: self._wizard.setField(SRC_CONFIG, current_cfg) self._wizard.show() + def _close_all(self, skip: bool | list[int] | None = None) -> None: + """Close all viewers.""" + # the QAction sends a bool when triggered. We don't want to handle a bool + # so we convert it to an empty list. + if isinstance(skip, bool) or skip is None: + skip = [] + viewer_tab = self._main_window._core_link._viewer_tab + for index in reversed(range(viewer_tab.count())): + if index in skip or index == 0: # 0 to skip the prewiew tab + continue + widget = viewer_tab.widget(index) + viewer_tab.removeTab(index) + widget.deleteLater() + + def _close_all_but_current(self) -> None: + """Close all viewers except the current one.""" + # build the list of indexes to skip + viewer_tab = self._main_window._core_link._viewer_tab + current = viewer_tab.currentWidget() + skip = [viewer_tab.indexOf(current)] + # close all but the current one + self._close_all(skip) + def _show_widget(self) -> None: """Create or show a widget.""" # get the action that triggered the signal From 119a5c043780d63f3ea1831274cfbeda0c0b3ae4 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 27 May 2024 14:30:49 -0400 Subject: [PATCH 039/226] fix: gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 82f92755..f27f8954 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.DS_Store + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] From 8f184fdaffa6c9c957f616d789de359ff28d6414 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Mon, 27 May 2024 14:23:00 -0400 Subject: [PATCH 040/226] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 365b2491..974da861 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Micro-Manager GUI based on [pymmcore-widgets](https://pymmcore-plus.github.io/pymmcore-widgets/) and [pymmcore-plus](https://pymmcore-plus.github.io/pymmcore-plus/). -Screenshot 2024-05-27 at 11 55 53 AM +Screenshot 2024-05-27 at 2 21 05 PM ## Installation From 55d65688a8fa7fa2afa72a4c537660ef1a218cea Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Sun, 2 Jun 2024 14:03:40 -0400 Subject: [PATCH 041/226] fix: update tensorstor reader --- .../_readers/_tensorstore_zarr_reader.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py index fa87e77e..a4f2d1a6 100644 --- a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py +++ b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py @@ -108,13 +108,15 @@ def write_tiff( indexes to a tiff file. If a Mapping of axis and index (e.g. {"p": 0, "t": 1}), write the data for the given index to a tiff file. """ - # TODO: add metadata if indexers is None: if pos := len(self.sequence.stage_positions): with tqdm(total=pos) as pbar: for i in range(pos): data, metadata = self.isel({"p": i}, metadata=True) imwrite(Path(path) / f"p{i}.tif", data, imagej=True) + # save metadata as json + with open(Path(path) / f"p{i}.json", "w") as f: + json.dump(metadata, f) pbar.update(1) elif isinstance(indexers, list): @@ -122,17 +124,19 @@ def write_tiff( data, metadata = self.isel(index, metadata=True) name = "_".join(f"{k}{v}" for k, v in index.items()) imwrite(Path(path) / f"{name}.tif", data, imagej=True) + # save metadata as json + with open(Path(path) / f"{name}.json", "w") as f: + json.dump(metadata, f) else: data, metadata = self.isel(indexers, metadata=True) imj = len(data.shape) <= 5 - try: - imwrite(path, data, imagej=imj) - except IsADirectoryError as e: - raise IsADirectoryError( - "The path should be a file path, not a directory! " - "(e.g. 'path/to/file.tif')" - ) from e + if Path(path).suffix not in {".tif", ".tiff"}: + path = Path(path).with_suffix(".tiff") + imwrite(path, data, imagej=imj) + # save metadata as json + with open(Path(path).with_suffix(".json"), "w") as f: + json.dump(metadata, f) # ___________________________Private Methods___________________________ From db84981b8088abdbb4f926c07492b95a5df4d386 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 17:04:25 -0400 Subject: [PATCH 042/226] fix: add exp wdg --- src/micromanager_gui/_main_window.py | 4 ++-- src/micromanager_gui/_toolbar/_snap_live.py | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/micromanager_gui/_main_window.py b/src/micromanager_gui/_main_window.py index ff7ee175..674189ef 100644 --- a/src/micromanager_gui/_main_window.py +++ b/src/micromanager_gui/_main_window.py @@ -53,10 +53,10 @@ def __init__( self.setMenuBar(self._menu_bar) # add toolbar - self._shutters_toolbar = _ShuttersToolbar(parent=self, mmcore=self._mmc) - self.addToolBar(self._shutters_toolbar) self._snap_live_toolbar = _SnapLive(parent=self, mmcore=self._mmc) self.addToolBar(self._snap_live_toolbar) + self._shutters_toolbar = _ShuttersToolbar(parent=self, mmcore=self._mmc) + self.addToolBar(self._shutters_toolbar) # link the MDA viewers self._core_link = CoreViewersLink(self, mmcore=self._mmc) diff --git a/src/micromanager_gui/_toolbar/_snap_live.py b/src/micromanager_gui/_toolbar/_snap_live.py index 9a8a3690..8d183f97 100644 --- a/src/micromanager_gui/_toolbar/_snap_live.py +++ b/src/micromanager_gui/_toolbar/_snap_live.py @@ -1,8 +1,9 @@ from __future__ import annotations from pymmcore_plus import CMMCorePlus +from pymmcore_widgets import DefaultCameraExposureWidget from qtpy.QtCore import Qt -from qtpy.QtWidgets import QToolBar, QWidget +from qtpy.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QToolBar, QWidget from micromanager_gui._widgets._snap_live_buttons import Live, Snap @@ -30,3 +31,15 @@ def __init__( self._live = Live(mmcore=self._mmc) self._live.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.addWidget(self._live) + + # camera exposure widget + exp_wdg = QGroupBox() + exp_wdg_layout = QHBoxLayout(exp_wdg) + exp_wdg_layout.setContentsMargins(5, 0, 0, 0) + exp_wdg_layout.setSpacing(0) + exp = QLabel("Exposure:") + self._exposure = DefaultCameraExposureWidget(mmcore=self._mmc) + self._exposure.layout().setContentsMargins(0, 0, 0, 0) + exp_wdg_layout.addWidget(exp) + exp_wdg_layout.addWidget(self._exposure) + self.addWidget(exp_wdg) From bda59933d60d54933c22a5e164f5178a70edd779 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 17:04:56 -0400 Subject: [PATCH 043/226] fix: exp --- src/micromanager_gui/_toolbar/_snap_live.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/micromanager_gui/_toolbar/_snap_live.py b/src/micromanager_gui/_toolbar/_snap_live.py index 8d183f97..4af924e8 100644 --- a/src/micromanager_gui/_toolbar/_snap_live.py +++ b/src/micromanager_gui/_toolbar/_snap_live.py @@ -35,7 +35,7 @@ def __init__( # camera exposure widget exp_wdg = QGroupBox() exp_wdg_layout = QHBoxLayout(exp_wdg) - exp_wdg_layout.setContentsMargins(5, 0, 0, 0) + exp_wdg_layout.setContentsMargins(5, 0, 5, 0) exp_wdg_layout.setSpacing(0) exp = QLabel("Exposure:") self._exposure = DefaultCameraExposureWidget(mmcore=self._mmc) From 0c6c5640310f24d06ec845fbc56c9fe980369f4c Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 17:07:42 -0400 Subject: [PATCH 044/226] fix: update readers --- .../_readers/_ome_zarr_reader.py | 89 ++++++++++++++++--- .../_readers/_tensorstore_zarr_reader.py | 16 ++-- 2 files changed, 87 insertions(+), 18 deletions(-) diff --git a/src/micromanager_gui/_readers/_ome_zarr_reader.py b/src/micromanager_gui/_readers/_ome_zarr_reader.py index 5b5f1997..55534232 100644 --- a/src/micromanager_gui/_readers/_ome_zarr_reader.py +++ b/src/micromanager_gui/_readers/_ome_zarr_reader.py @@ -1,10 +1,13 @@ from __future__ import annotations +import json from pathlib import Path from typing import TYPE_CHECKING, Mapping, cast import useq import zarr +from tifffile import imwrite +from tqdm import tqdm if TYPE_CHECKING: import numpy as np @@ -75,7 +78,7 @@ def sequence(self) -> useq.MDASequence | None: return self._sequence def isel( - self, indexers: dict[str, int], metadata: bool = False + self, indexers: Mapping[str, int], metadata: bool = False ) -> np.ndarray | tuple[np.ndarray, dict]: """Select data from the array. @@ -89,10 +92,13 @@ def isel( If True, return the metadata as well as a list of dictionaries. By default, False. """ - # add the position axis if not present - if "p" not in indexers: - indexers["p"] = 0 - pos_key = f"p{indexers['p']}" + if len(self.store.keys()) > 1 and "p" not in indexers: + raise ValueError( + "The indexers should contain the 'p' axis since the zarr store has " + "more than one position." + ) + + pos_key = f"p{indexers.get('p', 0)}" index = self._get_axis_index(indexers, pos_key) data = self.store[pos_key][index].squeeze() if metadata: @@ -100,25 +106,84 @@ def isel( return data, meta return data + def write_tiff( + self, + path: str | Path, + indexers: Mapping[str, int] | list[Mapping[str, int]] | None = None, + ) -> None: + """Write the data to a tiff file. + + Parameters + ---------- + path : str | Path + The path to the tiff file. If `indexers` is a Mapping of axis and index, + the path should be a file path (e.g. 'path/to/file.tif'). Otherwise, it + should be a directory path (e.g. 'path/to/directory'). + indexers : Mapping[str, int] | list[Mapping[str, int]] | None + The indexers to select the data. If None, write all the data per position + to a tiff file. If a list of Mapping of axis and index + (e.g. [{"p": 0, "t": 1}, {"p": 1, "t": 0}]), write the data for the given + indexes to a tiff file. If a Mapping of axis and index (e.g. + {"p": 0, "t": 1}), write the data for the given index to a tiff file. + """ + if indexers is None: + keys = [ + key + for key in self.store.keys() + if key.startswith("p") and key[1:].isdigit() + ] + if pos := len(keys): + if not Path(path).exists(): + Path(path).mkdir(parents=True, exist_ok=False) + with tqdm(total=pos) as pbar: + for i in range(pos): + data, metadata = self.isel({"p": i}, metadata=True) + imwrite(Path(path) / f"p{i}.tif", data, imagej=True) + # save metadata as json + dest = Path(path) / f"p{i}.json" + dest.write_text(json.dumps(metadata)) + pbar.update(1) + + elif isinstance(indexers, list): + if not Path(path).exists(): + Path(path).mkdir(parents=True, exist_ok=False) + for index in indexers: + data, metadata = self.isel(index, metadata=True) + name = "_".join(f"{k}{v}" for k, v in index.items()) + imwrite(Path(path) / f"{name}.tif", data, imagej=True) + # save metadata as json + dest = Path(path) / f"{name}.json" + dest.write_text(json.dumps(metadata)) + + else: + data, metadata = self.isel(indexers, metadata=True) + imj = len(data.shape) <= 5 + if Path(path).suffix not in {".tif", ".tiff"}: + path = Path(path).with_suffix(".tiff") + imwrite(path, data, imagej=imj) + # save metadata as json + dest = Path(path).with_suffix(".json") + dest.write_text(json.dumps(metadata)) + # ___________________________Private Methods___________________________ def _get_axis_index( self, indexers: Mapping[str, int], pos_key: str ) -> tuple[object, ...]: """Return a tuple to index the data for the given axis.""" - axis_order = self.store[pos_key].attrs.get(ARRAY_DIMS) # ['t','c','y','x'] - # add p if not in the axis order - if "p" not in axis_order: - axis_order = ["p", *axis_order] + axis_order = self.store[pos_key].attrs.get(ARRAY_DIMS, []) # ['t','c','y','x'] # remove x and y from the axis order if "x" in axis_order: axis_order.remove("x") if "y" in axis_order: axis_order.remove("y") - # if any of the indexers are not in the axis order, raise an error - if not set(indexers.keys()).issubset(set(axis_order)): - raise ValueError(f"Invalid axis in indexers: {indexers}, {axis_order}") + # if any of the indexers are not in the axis order, raise an error, NOTE: we + # add "p" to the axis order since the ome-zarr is saved per position + if not set(indexers.keys()).issubset({"p", *axis_order}): + raise ValueError( + f"Invalid axis in indexers {indexers}: available {axis_order}" + ) # get the correct index for the axis # e.g. (slice(None), 1, slice(None), slice(None)) diff --git a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py index a4f2d1a6..73ae14d9 100644 --- a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py +++ b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py @@ -110,23 +110,27 @@ def write_tiff( """ if indexers is None: if pos := len(self.sequence.stage_positions): + if not Path(path).exists(): + Path(path).mkdir(parents=True, exist_ok=False) with tqdm(total=pos) as pbar: for i in range(pos): data, metadata = self.isel({"p": i}, metadata=True) imwrite(Path(path) / f"p{i}.tif", data, imagej=True) # save metadata as json - with open(Path(path) / f"p{i}.json", "w") as f: - json.dump(metadata, f) + dest = Path(path) / f"p{i}.json" + dest.write_text(json.dumps(metadata)) pbar.update(1) elif isinstance(indexers, list): + if not Path(path).exists(): + Path(path).mkdir(parents=True, exist_ok=False) for index in indexers: data, metadata = self.isel(index, metadata=True) name = "_".join(f"{k}{v}" for k, v in index.items()) imwrite(Path(path) / f"{name}.tif", data, imagej=True) # save metadata as json - with open(Path(path) / f"{name}.json", "w") as f: - json.dump(metadata, f) + dest = Path(path) / f"{name}.json" + dest.write_text(json.dumps(metadata)) else: data, metadata = self.isel(indexers, metadata=True) @@ -135,8 +139,8 @@ def write_tiff( path = Path(path).with_suffix(".tiff") imwrite(path, data, imagej=imj) # save metadata as json - with open(Path(path).with_suffix(".json"), "w") as f: - json.dump(metadata, f) + dest = Path(path).with_suffix(".json") + dest.write_text(json.dumps(metadata)) # ___________________________Private Methods___________________________ From f81331d92faf2a58f3c0ffb93a9768c249b0e837 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 17:14:53 -0400 Subject: [PATCH 045/226] fix: remove DS_Store --- .DS_Store | Bin 6148 -> 0 bytes src/.DS_Store | Bin 8196 -> 0 bytes src/micromanager_gui/.DS_Store | Bin 6148 -> 0 bytes 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store delete mode 100644 src/.DS_Store delete mode 100644 src/micromanager_gui/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 8b270c71e2a005e2188fa38b11f70cd6d939f130..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK!AiqG5S?wSZV;gdg&qT51*?J)yo6eRz=$4HYGQ&0(`;!{E0jXc`a^z+-{Z{g zTGZ-E5GgY-`zEt9o6g&^n*jjP8O1vQO#qOngf$0;Z-nMa=cHym4Md^W7(fOI6mfBr z%|)~0KQcgjCvndnA%s4B*uO}r3^x_(P{0swqH!^ZG8LicB^qRDQMTK!qE>G#Ew9KG zxhk)MJ2eiT;P(vB`QV}w`W7pL z`slzxw*ZLr8L0(r>Ln=0wdh-{4B`xmFsX^B zd*__{ojWI)xu;2!L-_2K^&HS)qPv-cN9^VU1$Fqx(fn(XEa%9Bu zEPt}wFL_oF*=D6c)l@CV8_t~R+`O?h-QKb3Y-{>V=f+K~>CTR=XU}Tt(#8k3AIzO} z$36eNbPe(Kz-nf>df{&Tbc1q_^s}n2;^#LNKQ(=!F*K}?=J?_=IWsU3I=&YS+r@xr zj@Jg{^xlH+?JwFwK2q@A(zqAocwNDC3nkYK?Jn2NI#1ZaXlS1b#nV{e7sH-NF~@l+ zun*d#=alVU(hf!)T281fndJ<$cNZ;PwWg`1ZOgVDJ-hn{rfcr2<#l?!K1PxALg$!c z7Y~_1!S*c2J3ch&SmBA0vJ*IY*B?h)S-yI3lJpVpz>8;!JaSV($`HjP$n&}=-Q4+i9e%1x=*s&CK- zPtuWVJdiW(`X*z@HS@NsaZB#gp>H)t$}+YZ@0XL?^?GBp;E#`!i-9KRb{bvAqhi~A zW)Kcc*q(T$D^k`=va#TVS+oZx33Blp?V;J|xM*G&^;u5nM?#9mzO-Q+63ca2R^&s& zvgI|#0VAvOt12Cl=*Vs`xDiuK`l7ykU}%oVjWtr85wk;8~xai1IS_! zN0CPXHjcqZ31ytZX*`YdxPa&IJYL2dcoT2oZCu7j_!ytyb6mw&n8vsG71!|_{!prw zdCGjHR^iHGrCw=Lnw2(Xi_#(ATd5?_p_!Odb`t-u7X>9yd{HRw?Gh-}w@cr1PbAQ_ zMN!Huu3xfrb>rIg8#}h|ya6qV#R~lX3F-*RGw>fBdz4bMP&ggsmcDXTC7EufmrS=I zN&)+nDl(BTh_&xuqf!d+c`@76Ol&`&FWFQ}t4is{m59|-ZS5K*6|aifmMz3WGWtW? zG)gaC9kVNU5X&MamFn)HWv+1bzhn0jyUadiSJ*UR_b2u!g@@cRN@#7lSuui_$J zkCFTyF3rN`-kI3M<-5UVIa~Bx-xEJfS1>+vnK{Q*Nsk*u{ohB~o%{|k=OBKw$s? diff --git a/src/micromanager_gui/.DS_Store b/src/micromanager_gui/.DS_Store deleted file mode 100644 index 142ee1a752a8d5fa44d59ad15dc0dae46a91835a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKyH3ME5S)b+mS|E^-Y@V6RusN~4@d|`2q}^!0-|-tZ!`NqN@OU~P(-uV-uU)* z?&K+My#Q?WG28)503F>CUp~yu*WG7!RS`#vJ@(k)5pTocX+O*UJ>c998@yxWbHv|` zW9;FJpLoT97d$iV;G6p*Pn1akDIf);fE17dXDd(@w7WT*DS{M`0++6U-w%!M*cVQT zadmKr7J#^5IE?SnOAwm}h<)Ld$Oz4nN=&L%i(yG;yj5ObI3*?>7AJF`I@zj2u{fRa z7U{4)QKJ-)0;dXG=XB}y|DOIw|9?u-ObSSWi&DTA>xcDPNtJkx7C7P~aQ%j2m?T From cd80d56b3c0270ba562ac4680feebb553cdb19d1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 19:47:44 +0000 Subject: [PATCH 046/226] ci(pre-commit.ci): autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/crate-ci/typos: v1.21.0 → v1.22.0](https://github.com/crate-ci/typos/compare/v1.21.0...v1.22.0) - [github.com/astral-sh/ruff-pre-commit: v0.4.3 → v0.4.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.3...v0.4.7) - [github.com/abravalheri/validate-pyproject: v0.16 → v0.18](https://github.com/abravalheri/validate-pyproject/compare/v0.16...v0.18) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3537607d..6386ad29 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,19 +5,19 @@ ci: repos: - repo: https://github.com/crate-ci/typos - rev: v1.21.0 + rev: v1.22.0 hooks: - id: typos - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.3 + rev: v0.4.7 hooks: - id: ruff args: [--fix, --unsafe-fixes] - id: ruff-format - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.16 + rev: v0.18 hooks: - id: validate-pyproject From c76b86a830a4c530737dfd0546fbadaa36531adc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Jun 2024 19:47:51 +0000 Subject: [PATCH 047/226] style(pre-commit.ci): auto fixes [...] --- src/micromanager_gui/_core_link.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index d15cc198..477c16d3 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -97,7 +97,7 @@ def _setup_viewer(self, sequence: useq.MDASequence) -> None: self._viewer_tab.addTab(self._current_viewer, viewer_name) self._viewer_tab.setCurrentWidget(self._current_viewer) - # call it manually insted in _connect_viewer because this signal has been + # call it manually instead in _connect_viewer because this signal has been # emitted already self._current_viewer.data.sequenceStarted(sequence) From a138f659a660f2fab0dcecd0d1a42f3061c14360 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 17:30:33 -0400 Subject: [PATCH 048/226] test: wip --- tests/test_micromanager_gui.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index 363b3e20..f2734c91 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -1,2 +1,20 @@ -def test_something(): +def test_load_gui(): + # assert MDA + pass + + +def test_snap(): + pass + + +def test_live(): + pass + + +def test_mda_viewer(): + # test that if we save, the datastore of the viewer is the same as the one we saved + pass + + +def test_readers(): pass From 46ceff7db533f8075a49e4504fa6adebcb26514c Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 18:54:41 -0400 Subject: [PATCH 049/226] fix: closeEvent --- src/micromanager_gui/_main_window.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/micromanager_gui/_main_window.py b/src/micromanager_gui/_main_window.py index 674189ef..d6ec702f 100644 --- a/src/micromanager_gui/_main_window.py +++ b/src/micromanager_gui/_main_window.py @@ -5,7 +5,7 @@ sys.path.append(str(Path(__file__).resolve().parent.parent)) from pymmcore_plus import CMMCorePlus from pymmcore_widgets._stack_viewer_v2._mda_viewer import StackViewer -from qtpy.QtGui import QDragEnterEvent, QDropEvent +from qtpy.QtGui import QCloseEvent, QDragEnterEvent, QDropEvent from qtpy.QtWidgets import ( QGridLayout, QMainWindow, @@ -99,3 +99,8 @@ def _open_datastore(self, idx: int, path: Path) -> StackViewer | None: else: warn(f"Not yet supported format: {path.name}!", stacklevel=2) return None + + def closeEvent(self, event: QCloseEvent) -> None: + """Close all widgets before closing.""" + self.deleteLater() + super().closeEvent(event) From 258247742e55cf7ecbfbb57f944a32ca44bb4ec0 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 23:26:07 -0400 Subject: [PATCH 050/226] fix: exp --- src/micromanager_gui/_toolbar/_snap_live.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/micromanager_gui/_toolbar/_snap_live.py b/src/micromanager_gui/_toolbar/_snap_live.py index 4af924e8..0404df41 100644 --- a/src/micromanager_gui/_toolbar/_snap_live.py +++ b/src/micromanager_gui/_toolbar/_snap_live.py @@ -3,7 +3,7 @@ from pymmcore_plus import CMMCorePlus from pymmcore_widgets import DefaultCameraExposureWidget from qtpy.QtCore import Qt -from qtpy.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QToolBar, QWidget +from qtpy.QtWidgets import QHBoxLayout, QLabel, QToolBar, QWidget from micromanager_gui._widgets._snap_live_buttons import Live, Snap @@ -33,11 +33,11 @@ def __init__( self.addWidget(self._live) # camera exposure widget - exp_wdg = QGroupBox() + exp_wdg = QWidget() exp_wdg_layout = QHBoxLayout(exp_wdg) - exp_wdg_layout.setContentsMargins(5, 0, 5, 0) - exp_wdg_layout.setSpacing(0) - exp = QLabel("Exposure:") + exp_wdg_layout.setContentsMargins(5, 0, 0, 0) + exp_wdg_layout.setSpacing(3) + exp = QLabel("Exp:") self._exposure = DefaultCameraExposureWidget(mmcore=self._mmc) self._exposure.layout().setContentsMargins(0, 0, 0, 0) exp_wdg_layout.addWidget(exp) From 3e8c3270fc86936241423ae04ebcf9a8d3ed5f18 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 20:40:35 -0400 Subject: [PATCH 051/226] test: add test --- pyproject.toml | 2 +- tests/conftest.py | 43 ++++++ tests/test_config.cfg | 255 +++++++++++++++++++++++++++++++++ tests/test_micromanager_gui.py | 191 ++++++++++++++++++++++-- 4 files changed, 478 insertions(+), 13 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_config.cfg diff --git a/pyproject.toml b/pyproject.toml index 2ca73b4a..5a3a98e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ # extras # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] -test = ["pytest>=6.0", "pytest-cov"] +test = ["pytest>=6.0", "pytest-cov", "pytest-qt"] dev = [ "black", "ipython", diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..dbe1e3ed --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,43 @@ +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest +from pymmcore_plus import CMMCorePlus +from pymmcore_plus.core import _mmcore_plus + +if TYPE_CHECKING: + from pytest import FixtureRequest + from qtpy.QtWidgets import QApplication + +TEST_CONFIG = str(Path(__file__).parent / "test_config.cfg") + + +# to create a new CMMCorePlus() for every test +@pytest.fixture(autouse=True) +def global_mmcore(): + mmc = CMMCorePlus() + mmc.loadSystemConfiguration(TEST_CONFIG) + with patch.object(_mmcore_plus, "_instance", mmc): + yield mmc + + +@pytest.fixture() +def _run_after_each_test(request: "FixtureRequest", qapp: "QApplication"): + """Run after each test to ensure no widgets have been left around. + + When this test fails, it means that a widget being tested has an issue closing + cleanly. Perhaps a strong reference has leaked somewhere. Look for + `functools.partial(self._method)` or `lambda: self._method` being used in that + widget's code. + """ + nbefore = len(qapp.topLevelWidgets()) + failures_before = request.session.testsfailed + yield + # if the test failed, don't worry about checking widgets + if request.session.testsfailed - failures_before: + return + remaining = qapp.topLevelWidgets() + if len(remaining) > nbefore: + test = f"{request.node.path.name}::{request.node.originalname}" + raise AssertionError(f"topLevelWidgets remaining after {test!r}: {remaining}") diff --git a/tests/test_config.cfg b/tests/test_config.cfg new file mode 100644 index 00000000..4ea868bd --- /dev/null +++ b/tests/test_config.cfg @@ -0,0 +1,255 @@ +# Generated by Configurator on Wed Jan 04 20:53:27 EST 2023 + +# Reset +Property,Core,Initialize,0 + +# Devices +Device,DHub,DemoCamera,DHub +Device,Camera,DemoCamera,DCam +Device,Dichroic,DemoCamera,DWheel +Device,Emission,DemoCamera,DWheel +Device,Excitation,DemoCamera,DWheel +Device,Objective,DemoCamera,DObjective +Device,Z,DemoCamera,DStage +Device,Path,DemoCamera,DLightPath +Device,XY,DemoCamera,DXYStage +Device,Shutter,DemoCamera,DShutter +Device,Autofocus,DemoCamera,DAutoFocus +Device,Multi Shutter,Utilities,Multi Shutter +Device,DHub1,DemoCamera,DHub +Device,Z1,DemoCamera,DStage +Device,StateDev,DemoCamera,DStateDevice +Device,StateDev Shutter,Utilities,State Device Shutter + +# Pre-init settings for devices + +# Pre-init settings for COM ports + +# Hub (parent) references +Parent,Camera,DHub +Parent,Dichroic,DHub +Parent,Emission,DHub +Parent,Excitation,DHub +Parent,Objective,DHub +Parent,Z,DHub +Parent,Path,DHub +Parent,XY,DHub +Parent,Shutter,DHub +Parent,Autofocus,DHub +Parent,Z1,DHub1 + +# Initialize +Property,Core,Initialize,1 + +# Delays + +# Focus directions +FocusDirection,Z,0 +FocusDirection,Z1,0 + +# Roles +Property,Core,Camera,Camera +Property,Core,Shutter,Shutter +Property,Core,Focus,Z +Property,Core,AutoShutter,1 + +# Camera-synchronized devices + +# Labels +# Dichroic +Label,Dichroic,9,State-9 +Label,Dichroic,8,State-8 +Label,Dichroic,7,State-7 +Label,Dichroic,6,State-6 +Label,Dichroic,5,State-5 +Label,Dichroic,4,State-4 +Label,Dichroic,3,State-3 +Label,Dichroic,2,Q585LP +Label,Dichroic,1,Q505LP +Label,Dichroic,0,400DCLP +# Emission +Label,Emission,9,State-9 +Label,Emission,8,State-8 +Label,Emission,7,State-7 +Label,Emission,6,State-6 +Label,Emission,5,State-5 +Label,Emission,4,State-4 +Label,Emission,3,Chroma-HQ700 +Label,Emission,2,Chroma-HQ535 +Label,Emission,1,Chroma-D460 +Label,Emission,0,Chroma-HQ620 +# Excitation +Label,Excitation,9,State-9 +Label,Excitation,8,State-8 +Label,Excitation,7,State-7 +Label,Excitation,6,State-6 +Label,Excitation,5,State-5 +Label,Excitation,4,State-4 +Label,Excitation,3,Chroma-HQ620 +Label,Excitation,2,Chroma-HQ570 +Label,Excitation,1,Chroma-HQ480 +Label,Excitation,0,Chroma-D360 +# Objective +Label,Objective,5,Objective-5 +Label,Objective,4,Objective-4 +Label,Objective,3,Nikon 20X Plan Fluor ELWD +Label,Objective,2,Objective-2 +Label,Objective,1,Nikon 10X S Fluor +Label,Objective,0,Nikon 40X Plan Fluor ELWD +# Path +Label,Path,2,State-2 +Label,Path,1,State-1 +Label,Path,0,State-0 + +# Configuration presets +# Group: Camera +# Preset: HighRes +ConfigGroup,Camera,HighRes,Camera,Binning,1 +ConfigGroup,Camera,HighRes,Camera,BitDepth,12 + +# Preset: MedRes +ConfigGroup,Camera,MedRes,Camera,Binning,2 +ConfigGroup,Camera,MedRes,Camera,BitDepth,10 + +# Preset: LowRes +ConfigGroup,Camera,LowRes,Camera,Binning,4 +ConfigGroup,Camera,LowRes,Camera,BitDepth,8 + + +# Group: LightPath +# Preset: Camera-left +ConfigGroup,LightPath,Camera-left,Path,State,1 + +# Preset: Eyepiece +ConfigGroup,LightPath,Eyepiece,Path,State,0 + +# Preset: Camera-right +ConfigGroup,LightPath,Camera-right,Path,State,2 + + +# Group: Channel +# Preset: FITC +ConfigGroup,Channel,FITC,Dichroic,Label,Q505LP +ConfigGroup,Channel,FITC,Emission,Label,Chroma-HQ535 +ConfigGroup,Channel,FITC,Excitation,Label,Chroma-HQ480 +ConfigGroup,Channel,FITC,Camera,Mode,Artificial Waves +ConfigGroup,Channel,FITC,Multi Shutter,Physical Shutter 1,StateDev Shutter +ConfigGroup,Channel,FITC,Multi Shutter,Physical Shutter 2,Undefined +ConfigGroup,Channel,FITC,Multi Shutter,Physical Shutter 3,Shutter +ConfigGroup,Channel,FITC,Multi Shutter,Physical Shutter 4,Undefined +ConfigGroup,Channel,FITC,StateDev Shutter,State Device,StateDev +ConfigGroup,Channel,FITC,StateDev,Label,State-2 + +# Preset: Rhodamine +ConfigGroup,Channel,Rhodamine,Dichroic,Label,Q585LP +ConfigGroup,Channel,Rhodamine,Emission,Label,Chroma-HQ700 +ConfigGroup,Channel,Rhodamine,Excitation,Label,Chroma-HQ570 +ConfigGroup,Channel,Rhodamine,Camera,Mode,Artificial Waves +ConfigGroup,Channel,Rhodamine,Multi Shutter,Physical Shutter 1,Undefined +ConfigGroup,Channel,Rhodamine,Multi Shutter,Physical Shutter 2,StateDev Shutter +ConfigGroup,Channel,Rhodamine,Multi Shutter,Physical Shutter 3,Undefined +ConfigGroup,Channel,Rhodamine,Multi Shutter,Physical Shutter 4,Shutter +ConfigGroup,Channel,Rhodamine,StateDev Shutter,State Device,StateDev +ConfigGroup,Channel,Rhodamine,StateDev,Label,State-3 + +# Preset: DAPI +ConfigGroup,Channel,DAPI,Dichroic,Label,400DCLP +ConfigGroup,Channel,DAPI,Emission,Label,Chroma-HQ620 +ConfigGroup,Channel,DAPI,Excitation,Label,Chroma-D360 +ConfigGroup,Channel,DAPI,Camera,Mode,Color Test Pattern +ConfigGroup,Channel,DAPI,Multi Shutter,Physical Shutter 1,Undefined +ConfigGroup,Channel,DAPI,Multi Shutter,Physical Shutter 2,Shutter +ConfigGroup,Channel,DAPI,Multi Shutter,Physical Shutter 3,StateDev Shutter +ConfigGroup,Channel,DAPI,Multi Shutter,Physical Shutter 4,Undefined +ConfigGroup,Channel,DAPI,StateDev Shutter,State Device,StateDev +ConfigGroup,Channel,DAPI,StateDev,Label,State-1 + +# Preset: Cy5 +ConfigGroup,Channel,Cy5,Dichroic,Label,400DCLP +ConfigGroup,Channel,Cy5,Emission,Label,Chroma-HQ700 +ConfigGroup,Channel,Cy5,Excitation,Label,Chroma-HQ570 +ConfigGroup,Channel,Cy5,Camera,Mode,Noise +ConfigGroup,Channel,Cy5,Multi Shutter,Physical Shutter 1,Shutter +ConfigGroup,Channel,Cy5,Multi Shutter,Physical Shutter 2,StateDev Shutter +ConfigGroup,Channel,Cy5,Multi Shutter,Physical Shutter 3,Undefined +ConfigGroup,Channel,Cy5,Multi Shutter,Physical Shutter 4,Undefined +ConfigGroup,Channel,Cy5,StateDev Shutter,State Device,StateDev +ConfigGroup,Channel,Cy5,StateDev,Label,State-0 + + +# Group: System +# Preset: Startup +ConfigGroup,System,Startup,Camera,BitDepth,16 +ConfigGroup,System,Startup,Camera,ScanMode,1 +ConfigGroup,System,Startup,Objective,Label,Nikon 10X S Fluor +ConfigGroup,System,Startup,Camera,Binning,1 +ConfigGroup,System,Startup,Core,ChannelGroup,Channel + + +# Group: Objective +# Preset: 20X +ConfigGroup,Objective,20X,Objective,State,3 + +# Preset: 40X +ConfigGroup,Objective,40X,Objective,State,0 + +# Preset: 10X +ConfigGroup,Objective,10X,Objective,State,1 + + +# Group: _lineedit_test +# Preset: NewPreset +ConfigGroup,_lineedit_test,NewPreset,Camera,OnCameraCCDXSize,512 + + +# Group: _as_Channel_properties +# Preset: ex_dic_em_2 +ConfigGroup,_as_Channel_properties,ex_dic_em_2,Dichroic,Label,State-6 +ConfigGroup,_as_Channel_properties,ex_dic_em_2,Emission,Label,State-7 +ConfigGroup,_as_Channel_properties,ex_dic_em_2,Excitation,Label,State-8 + +# Preset: ex_dic_em_1 +ConfigGroup,_as_Channel_properties,ex_dic_em_1,Dichroic,Label,State-3 +ConfigGroup,_as_Channel_properties,ex_dic_em_1,Emission,Label,State-4 +ConfigGroup,_as_Channel_properties,ex_dic_em_1,Excitation,Label,State-5 + + +# Group: _slider_test +# Preset: NewPreset +ConfigGroup,_slider_test,NewPreset,Camera,TestProperty1,0.0000 + + +# Group: _combobox_no_preset_test +# Preset: 14 +ConfigGroup,_combobox_no_preset_test,14,Camera,BitDepth,14 + +# Preset: 12 +ConfigGroup,_combobox_no_preset_test,12,Camera,BitDepth,12 + +# Preset: 10 +ConfigGroup,_combobox_no_preset_test,10,Camera,BitDepth,10 + +# Preset: 32 +ConfigGroup,_combobox_no_preset_test,32,Camera,BitDepth,32 + +# Preset: 8 +ConfigGroup,_combobox_no_preset_test,8,Camera,BitDepth,8 + +# Preset: 16 +ConfigGroup,_combobox_no_preset_test,16,Camera,BitDepth,16 + + + +# PixelSize settings +# Resolution preset: Res20x +ConfigPixelSize,Res20x,Objective,Label,Nikon 20X Plan Fluor ELWD +PixelSize_um,Res20x,0.5 +PixelSizeAffine,Res20x,1.0,0.0,0.0,0.0,1.0,0.0 +# Resolution preset: Res40x +ConfigPixelSize,Res40x,Objective,Label,Nikon 40X Plan Fluor ELWD +PixelSize_um,Res40x,0.25 +PixelSizeAffine,Res40x,1.0,0.0,0.0,0.0,1.0,0.0 +# Resolution preset: Res10x +ConfigPixelSize,Res10x,Objective,Label,Nikon 10X S Fluor +PixelSize_um,Res10x,1.0 +PixelSizeAffine,Res10x,1.0,0.0,0.0,0.0,1.0,0.0 diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index f2734c91..76a72158 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -1,20 +1,187 @@ -def test_load_gui(): - # assert MDA - pass +from pathlib import Path +import useq +from pymmcore_plus import CMMCorePlus +from pymmcore_plus.mda.handlers import TensorStoreHandler +from pymmcore_widgets._stack_viewer_v2._mda_viewer import MDAViewer +from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY +from pytestqt.qtbot import QtBot -def test_snap(): - pass +from micromanager_gui import MicroManagerGUI +from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS +# from micromanager_gui._readers._tensorstore_zarr_reader import TensorstoreZarrReader +from micromanager_gui._readers._ome_zarr_reader import OMEZarrReader -def test_live(): - pass +def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): + gui = MicroManagerGUI(mmcore=global_mmcore) + qtbot.addWidget(gui) + assert gui._menu_bar._mda + assert gui._core_link._preview + assert not gui._core_link._preview.isHidden() + assert gui._core_link._viewer_tab.count() == 1 + assert gui._core_link._viewer_tab.tabText(0) == "Preview" + assert gui._core_link._current_viewer is None + assert gui._core_link._mda_running is False -def test_mda_viewer(): - # test that if we save, the datastore of the viewer is the same as the one we saved - pass +def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): + gui = MicroManagerGUI(mmcore=global_mmcore) + qtbot.addWidget(gui) + menu = gui._menu_bar -def test_readers(): - pass + assert len(menu._widgets.keys()) == 2 # MDA and GroupPreset widgets + for action in menu._widgets_menu.actions(): + action.trigger() + assert len(menu._widgets.keys()) == len(WIDGETS) + len(DOCKWIDGETS) + + +def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): + gui = MicroManagerGUI(mmcore=global_mmcore) + qtbot.addWidget(gui) + menu = gui._menu_bar + assert gui._core_link._viewer_tab.tabText(0) == "Preview" + # add a viewer + gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA1") + gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA2") + assert gui._core_link._viewer_tab.count() == 3 + + menu._close_all() + assert gui._core_link._viewer_tab.count() == 1 + + gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA3") + gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA4") + gui._core_link._viewer_tab.setCurrentIndex(2) + assert gui._core_link._viewer_tab.count() == 3 + + menu._close_all_but_current() + assert gui._core_link._viewer_tab.count() == 2 + assert gui._core_link._viewer_tab.tabText(0) == "Preview" + assert gui._core_link._viewer_tab.tabText(1) == "MDA4" + + +def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): + gui = MicroManagerGUI(mmcore=global_mmcore) + qtbot.addWidget(gui) + + assert gui._core_link._preview + assert gui._core_link._preview._image_preview.image is None + assert not gui._core_link._preview.isHidden() + + with qtbot.waitSignal(global_mmcore.events.imageSnapped): + gui._core_link._preview._snap._snap() + assert gui._core_link._preview._image_preview.image + assert gui._core_link._preview._image_preview.image._data.shape + + +def test_live(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): + gui = MicroManagerGUI(mmcore=global_mmcore) + qtbot.addWidget(gui) + + assert gui._core_link._preview + assert gui._core_link._preview._image_preview.image is None + assert not gui._core_link._preview.isHidden() + + with qtbot.waitSignal(global_mmcore.events.continuousSequenceAcquisitionStarted): + gui._core_link._preview._live._toggle_live_mode() + assert global_mmcore.isSequenceRunning() + with qtbot.waitSignal(global_mmcore.events.sequenceAcquisitionStopped): + gui._core_link._preview._live._toggle_live_mode() + assert not global_mmcore.isSequenceRunning() + + +# _run_after_each_test not using because it gives an error (to fix) +def test_mda_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): + gui = MicroManagerGUI(mmcore=global_mmcore) + qtbot.addWidget(gui) + + mda = useq.MDASequence(channels=["DAPI", "FITC"]) + with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): + global_mmcore.mda.run(mda) + assert gui._core_link._viewer_tab.count() == 2 + assert gui._core_link._viewer_tab.tabText(1) == "MDA Viewer 1" + assert gui._core_link._viewer_tab.currentIndex() == 1 + + dest = tmp_path / "ts.tensorstore.zarr" + mda = useq.MDASequence( + channels=["FITC", "DAPI"], + metadata={ + PYMMCW_METADATA_KEY: { + "format": "tensorstore-zarr", + "save_dir": str(dest), + "save_name": "t.tensorstore.zarr", + "should_save": True, + } + }, + ) + gui._menu_bar._mda.setValue(mda) + + with qtbot.waitSignal(global_mmcore.mda.events.sequenceStarted): + gui._menu_bar._mda.run_mda() + + assert isinstance(gui._menu_bar._mda.writer, TensorStoreHandler) + assert gui._core_link._viewer_tab.count() == 3 + assert gui._core_link._viewer_tab.tabText(2) == "t.tensorstore.zarr" + + # saving tensorstore and MDAViewer datastore should be the same + assert gui._core_link._mda.writer == gui._core_link._viewer_tab.widget(2)._data + gui._menu_bar._close_all() + + +def test_ome_zarr_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): + dest = tmp_path / "z.ome.zarr" + + mda = useq.MDASequence( + channels=["FITC", "DAPI"], + stage_positions=[(0, 0), (0, 1)], + time_plan={"loops": 3, "interval": 0.1}, + metadata={ + PYMMCW_METADATA_KEY: { + "format": "ome-zarr", + "save_dir": str(dest), + "save_name": "z.ome.zarr", + "should_save": True, + } + }, + ) + + with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): + global_mmcore.mda.run(mda, output=dest) + + assert dest.exists() + + z = OMEZarrReader(dest) + assert z.store + assert z.sequence + assert z.isel({"p": 0}).shape == (3, 2, 512, 512) + assert z.isel({"p": 0, "t": 0}).shape == (2, 512, 512) + + +# def test_tensor_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): +# dest = tmp_path / "ts.tensorstore.zarr" + +# mda = useq.MDASequence( +# channels=["FITC", "DAPI"], +# stage_positions=[(0, 0), (0, 1)], +# time_plan={"loops": 3, "interval": 0.1}, +# metadata={ +# PYMMCW_METADATA_KEY: { +# "format": "tensorstore-zarr", +# "save_dir": str(dest), +# "save_name": "t.tensorstore.zarr", +# "should_save": True, +# } +# }, +# ) + +# with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): +# global_mmcore.mda.run(mda, output=dest) + +# assert dest.exists() + +# ts = TensorstoreZarrReader(dest) +# assert ts.store +# assert ts.sequence +# data = ts.isel({"p": 0}) +# # assert data.shape == (3, 2, 2, 512, 512) From cf89fedc01efd638df05dbea62c554a74fabf18c Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 20:58:30 -0400 Subject: [PATCH 052/226] test: update --- tests/test_micromanager_gui.py | 65 +++++++++++++++++----------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index 76a72158..4c9905b4 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -9,9 +9,8 @@ from micromanager_gui import MicroManagerGUI from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS - -# from micromanager_gui._readers._tensorstore_zarr_reader import TensorstoreZarrReader from micromanager_gui._readers._ome_zarr_reader import OMEZarrReader +from micromanager_gui._readers._tensorstore_zarr_reader import TensorstoreZarrReader def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): @@ -130,8 +129,6 @@ def test_mda_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): def test_ome_zarr_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): - dest = tmp_path / "z.ome.zarr" - mda = useq.MDASequence( channels=["FITC", "DAPI"], stage_positions=[(0, 0), (0, 1)], @@ -139,13 +136,14 @@ def test_ome_zarr_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Pat metadata={ PYMMCW_METADATA_KEY: { "format": "ome-zarr", - "save_dir": str(dest), + "save_dir": str(tmp_path), "save_name": "z.ome.zarr", "should_save": True, } }, ) + dest = tmp_path / "z.ome.zarr" with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): global_mmcore.mda.run(mda, output=dest) @@ -158,30 +156,33 @@ def test_ome_zarr_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Pat assert z.isel({"p": 0, "t": 0}).shape == (2, 512, 512) -# def test_tensor_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): -# dest = tmp_path / "ts.tensorstore.zarr" - -# mda = useq.MDASequence( -# channels=["FITC", "DAPI"], -# stage_positions=[(0, 0), (0, 1)], -# time_plan={"loops": 3, "interval": 0.1}, -# metadata={ -# PYMMCW_METADATA_KEY: { -# "format": "tensorstore-zarr", -# "save_dir": str(dest), -# "save_name": "t.tensorstore.zarr", -# "should_save": True, -# } -# }, -# ) - -# with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): -# global_mmcore.mda.run(mda, output=dest) - -# assert dest.exists() - -# ts = TensorstoreZarrReader(dest) -# assert ts.store -# assert ts.sequence -# data = ts.isel({"p": 0}) -# # assert data.shape == (3, 2, 2, 512, 512) +def test_tensorstore_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): + mda = useq.MDASequence( + channels=["FITC", "DAPI"], + stage_positions=[(0, 0), (0, 1)], + time_plan={"loops": 3, "interval": 0.1}, + metadata={ + PYMMCW_METADATA_KEY: { + "format": "tensorstore-zarr", + "save_dir": str(tmp_path), + "save_name": "ts.tensorstore.zarr", + "should_save": True, + } + }, + ) + + dest = tmp_path / "ts.tensorstore.zarr" + with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): + global_mmcore.mda.run(mda, output=tmp_path / "ts.tensorstore.zarr") + + assert dest.exists() + + ts = TensorstoreZarrReader(dest) + assert ts.store + # this part will only work in the "calcium" branch since there is an issue with + # saving the metadata of the tensorstore. In the "calcium" branch, the tensorstore + # handler is subclassed to save the metadata correctly (TODO: to fix) + # assert ts.sequence + # assert ts.isel({"p": 0}).shape == (3, 2, 512, 512) + # assert ts.isel({"t": 0}).shape == (3, 2, 512, 512) + # assert ts.isel({"p": 0, "t": 0}).shape == (2, 512, 512) From 9c42ac7a686f93059de2151b9d40e1494988961b Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 21:16:19 -0400 Subject: [PATCH 053/226] test: update --- tests/test_micromanager_gui.py | 63 +++++++++++++++++----------------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index 4c9905b4..b944c2ce 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -10,7 +10,6 @@ from micromanager_gui import MicroManagerGUI from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS from micromanager_gui._readers._ome_zarr_reader import OMEZarrReader -from micromanager_gui._readers._tensorstore_zarr_reader import TensorstoreZarrReader def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): @@ -156,33 +155,35 @@ def test_ome_zarr_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Pat assert z.isel({"p": 0, "t": 0}).shape == (2, 512, 512) -def test_tensorstore_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): - mda = useq.MDASequence( - channels=["FITC", "DAPI"], - stage_positions=[(0, 0), (0, 1)], - time_plan={"loops": 3, "interval": 0.1}, - metadata={ - PYMMCW_METADATA_KEY: { - "format": "tensorstore-zarr", - "save_dir": str(tmp_path), - "save_name": "ts.tensorstore.zarr", - "should_save": True, - } - }, - ) - - dest = tmp_path / "ts.tensorstore.zarr" - with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): - global_mmcore.mda.run(mda, output=tmp_path / "ts.tensorstore.zarr") - - assert dest.exists() - - ts = TensorstoreZarrReader(dest) - assert ts.store - # this part will only work in the "calcium" branch since there is an issue with - # saving the metadata of the tensorstore. In the "calcium" branch, the tensorstore - # handler is subclassed to save the metadata correctly (TODO: to fix) - # assert ts.sequence - # assert ts.isel({"p": 0}).shape == (3, 2, 512, 512) - # assert ts.isel({"t": 0}).shape == (3, 2, 512, 512) - # assert ts.isel({"p": 0, "t": 0}).shape == (2, 512, 512) +# NOTE: this works only if we use the internal _TensorStoreHandler, in the "calcium" +# branch. TODO: fix the main TensorStoreHandler because it does not write the ".zattrs" +# def test_tensorstore_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): +# mda = useq.MDASequence( +# channels=["FITC", "DAPI"], +# stage_positions=[(0, 0), (0, 1)], +# time_plan={"loops": 3, "interval": 0.1}, +# metadata={ +# PYMMCW_METADATA_KEY: { +# "format": "tensorstore-zarr", +# "save_dir": str(tmp_path), +# "save_name": "ts.tensorstore.zarr", +# "should_save": True, +# } +# }, +# ) + +# dest = tmp_path / "ts.tensorstore.zarr" +# writer = _TensorStoreHandler(path=dest, delete_existing=True) +# with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): +# global_mmcore.mda.run(mda, output=writer) + +# assert dest.exists() + +# ts = TensorstoreZarrReader(dest) +# assert ts.store +# assert ts.sequence +# assert ts.isel({"p": 0}).shape == (3, 2, 512, 512) +# assert ts.isel({"t": 0}).shape == (2, 2, 512, 512) +# assert ts.isel({"p": 0, "t": 0}).shape == (2, 512, 512) +# _, metadata = ts.isel({"p": 0}, metadata=True) +# assert metadata From 058db35796bf20ef4bd0aab758dcf8d85054f0e1 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 21:18:19 -0400 Subject: [PATCH 054/226] test: fix pyproject.toml --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 5a3a98e3..2555b213 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,10 @@ dependencies = [ # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] test = ["pytest>=6.0", "pytest-cov", "pytest-qt"] +pyqt5 = ["PyQt5"] +pyside2 = ["PySide2"] +pyqt6 = ["PyQt6"] +pyside6 = ["PySide6<6.5"] dev = [ "black", "ipython", From a23f3b32a59217f5cc9c6440618adb88c8ddf9e1 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 21:22:56 -0400 Subject: [PATCH 055/226] test: fix ci --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28692e06..72904d2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,20 @@ jobs: matrix: python-version: ['3.8', '3.9', '3.10', '3.11'] platform: [ubuntu-latest, macos-latest, windows-latest] + backend: [pyside6, pyqt6] + include: + - platform: windows-latest + python-version: "3.10" + backend: pyqt5 + - platform: windows-latest + python-version: "3.10" + backend: pyside2 + - platform: windows-latest + python-version: "3.11" + backend: pyqt5 + - platform: windows-latest + python-version: "3.11" + backend: pyside2 steps: - uses: actions/checkout@v4 From fdf019da1ffafba25eb453a329d5b19da3ef020e Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 21:28:59 -0400 Subject: [PATCH 056/226] test: fix ci --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72904d2c..cf5fe1b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,8 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] - platform: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.9', '3.11'] + platform: [ubuntu-latest, macos-13, windows-latest] backend: [pyside6, pyqt6] include: - platform: windows-latest @@ -59,7 +59,7 @@ jobs: - name: Install dependencies run: | python -m pip install -U pip - python -m pip install .[test] ${{ github.event_name == 'schedule' && '--pre' || '' }} + python -m pip install -e .[test,${{ matrix.backend }}] - name: Test run: pytest --color=yes --cov --cov-report=xml --cov-report=term-missing From 24e26dd9ad6c6abcb966c4b75550acf75e5535dc Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 21:34:50 -0400 Subject: [PATCH 057/226] test: fix ci --- .github/workflows/ci.yml | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf5fe1b3..0baffd50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,42 +55,37 @@ jobs: cache-dependency-path: "pyproject.toml" cache: "pip" - # if running a cron job, we add the --pre flag to test against pre-releases - name: Install dependencies run: | python -m pip install -U pip python -m pip install -e .[test,${{ matrix.backend }}] - - name: Test - run: pytest --color=yes --cov --cov-report=xml --cov-report=term-missing + - name: Install Windows OpenGL + if: runner.os == 'Windows' + run: | + git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git + powershell gl-ci-helpers/appveyor/install_opengl.ps1 + if (Test-Path -Path "C:\Windows\system32\opengl32.dll" -PathType Leaf) {Exit 0} else {Exit 1} - # If something goes wrong, we can open an issue in the repo - - name: Report --pre Failures - if: failure() && github.event_name == 'schedule' - uses: JasonEtco/create-an-issue@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PLATFORM: ${{ matrix.platform }} - PYTHON: ${{ matrix.python-version }} - RUN_ID: ${{ github.run_id }} - TITLE: '[test-bot] pip install --pre is failing' - with: - filename: .github/TEST_FAIL_TEMPLATE.md - update_existing: true + - name: Install Micro-Manager + run: mmcore install + + - name: Test + run: pytest -v --cov=pymmcore_widgets --cov-report=xml --color=yes - name: Coverage uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} deploy: name: Deploy needs: test - if: success() && startsWith(github.ref, 'refs/tags/') && github.event_name != 'schedule' + if: "success() && startsWith(github.ref, 'refs/tags/')" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 @@ -99,15 +94,18 @@ jobs: - name: install run: | - pip install -U pip build twine + git tag + pip install -U pip + pip install -U build twine python -m build twine check dist/* + ls -lh dist - name: Build and publish run: twine upload dist/* env: TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - uses: softprops/action-gh-release@v2 with: From 20951ac3e5e652cdf5f38fd0673439675c48e94d Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 21:36:28 -0400 Subject: [PATCH 058/226] test: fix ci --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0baffd50..fd5b7e6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: fail-fast: false matrix: python-version: ['3.9', '3.11'] - platform: [ubuntu-latest, macos-13, windows-latest] + platform: [macos-13, windows-latest] backend: [pyside6, pyqt6] include: - platform: windows-latest From 29fb85cff4a377844a6ff3769a67db7453745fd4 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 21:40:00 -0400 Subject: [PATCH 059/226] test: fix --- tests/conftest.py | 4 +++- tests/test_micromanager_gui.py | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index dbe1e3ed..f344b3f2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import patch @@ -23,7 +25,7 @@ def global_mmcore(): @pytest.fixture() -def _run_after_each_test(request: "FixtureRequest", qapp: "QApplication"): +def _run_after_each_test(request: FixtureRequest, qapp: QApplication): """Run after each test to ensure no widgets have been left around. When this test fails, it means that a widget being tested has an issue closing diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index b944c2ce..3fe06474 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -1,16 +1,22 @@ -from pathlib import Path +from __future__ import annotations + +from typing import TYPE_CHECKING import useq -from pymmcore_plus import CMMCorePlus from pymmcore_plus.mda.handlers import TensorStoreHandler from pymmcore_widgets._stack_viewer_v2._mda_viewer import MDAViewer from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY -from pytestqt.qtbot import QtBot from micromanager_gui import MicroManagerGUI from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS from micromanager_gui._readers._ome_zarr_reader import OMEZarrReader +if TYPE_CHECKING: + from pathlib import Path + + from pymmcore_plus import CMMCorePlus + from pytestqt.qtbot import QtBot + def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): gui = MicroManagerGUI(mmcore=global_mmcore) From 1d28c3f9bab599dfd0267357c5b25c45f3a02a3b Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 21:42:55 -0400 Subject: [PATCH 060/226] test: exclude pyside2 3.11 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd5b7e6e..9b5d555c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,8 +41,8 @@ jobs: - platform: windows-latest python-version: "3.11" backend: pyqt5 - - platform: windows-latest - python-version: "3.11" + exclude: + - python-version: "3.11" backend: pyside2 steps: From aa687fd179c87f4614e7252781366b1a169354e1 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 21:44:54 -0400 Subject: [PATCH 061/226] test: fix --- .github/workflows/ci.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b5d555c..1852f06f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.9', '3.11'] + python-version: ['3.10', '3.11'] platform: [macos-13, windows-latest] backend: [pyside6, pyqt6] include: @@ -41,9 +41,6 @@ jobs: - platform: windows-latest python-version: "3.11" backend: pyqt5 - exclude: - - python-version: "3.11" - backend: pyside2 steps: - uses: actions/checkout@v4 From 4e24b7bc1a7a7731291000d1319a075253ba17c0 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 21:46:45 -0400 Subject: [PATCH 062/226] test: fix ci name --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1852f06f..f2d71b21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - run: pipx run check-manifest test: - name: ${{ matrix.platform }} (${{ matrix.python-version }}) + name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{ matrix.backend }} runs-on: ${{ matrix.platform }} strategy: fail-fast: false From 18a5fa5d38a247480a906d5dbeb9a0bef0e6cbb5 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 21:50:37 -0400 Subject: [PATCH 063/226] test: fix --- tests/conftest.py | 26 -------------------------- tests/test_micromanager_gui.py | 11 +++++------ 2 files changed, 5 insertions(+), 32 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f344b3f2..661ee35f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,17 +1,12 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING from unittest.mock import patch import pytest from pymmcore_plus import CMMCorePlus from pymmcore_plus.core import _mmcore_plus -if TYPE_CHECKING: - from pytest import FixtureRequest - from qtpy.QtWidgets import QApplication - TEST_CONFIG = str(Path(__file__).parent / "test_config.cfg") @@ -22,24 +17,3 @@ def global_mmcore(): mmc.loadSystemConfiguration(TEST_CONFIG) with patch.object(_mmcore_plus, "_instance", mmc): yield mmc - - -@pytest.fixture() -def _run_after_each_test(request: FixtureRequest, qapp: QApplication): - """Run after each test to ensure no widgets have been left around. - - When this test fails, it means that a widget being tested has an issue closing - cleanly. Perhaps a strong reference has leaked somewhere. Look for - `functools.partial(self._method)` or `lambda: self._method` being used in that - widget's code. - """ - nbefore = len(qapp.topLevelWidgets()) - failures_before = request.session.testsfailed - yield - # if the test failed, don't worry about checking widgets - if request.session.testsfailed - failures_before: - return - remaining = qapp.topLevelWidgets() - if len(remaining) > nbefore: - test = f"{request.node.path.name}::{request.node.originalname}" - raise AssertionError(f"topLevelWidgets remaining after {test!r}: {remaining}") diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index 3fe06474..3baa37af 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -18,7 +18,7 @@ from pytestqt.qtbot import QtBot -def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) assert gui._menu_bar._mda @@ -30,7 +30,7 @@ def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test assert gui._core_link._mda_running is False -def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) menu = gui._menu_bar @@ -41,7 +41,7 @@ def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test assert len(menu._widgets.keys()) == len(WIDGETS) + len(DOCKWIDGETS) -def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) menu = gui._menu_bar @@ -65,7 +65,7 @@ def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_t assert gui._core_link._viewer_tab.tabText(1) == "MDA4" -def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) @@ -79,7 +79,7 @@ def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): assert gui._core_link._preview._image_preview.image._data.shape -def test_live(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +def test_live(qtbot: QtBot, global_mmcore: CMMCorePlus): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) @@ -95,7 +95,6 @@ def test_live(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): assert not global_mmcore.isSequenceRunning() -# _run_after_each_test not using because it gives an error (to fix) def test_mda_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) From f0aca4f3b2f9adbf4de322d0f56a5076341f3a17 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 22:03:18 -0400 Subject: [PATCH 064/226] fix: tensor writer --- src/micromanager_gui/_widgets/_mda_widget.py | 6 +- .../_writers/_tensorstore_zarr.py | 78 +++++++++++++++++++ tests/test_micromanager_gui.py | 66 ++++++++-------- 3 files changed, 116 insertions(+), 34 deletions(-) create mode 100644 src/micromanager_gui/_writers/_tensorstore_zarr.py diff --git a/src/micromanager_gui/_widgets/_mda_widget.py b/src/micromanager_gui/_widgets/_mda_widget.py index 5773df80..505bd005 100644 --- a/src/micromanager_gui/_widgets/_mda_widget.py +++ b/src/micromanager_gui/_widgets/_mda_widget.py @@ -19,6 +19,8 @@ from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY from useq import MDASequence +from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler + OME_TIFFS = tuple(WRITERS[OME_TIFF]) GB_CACHE = 2_000_000_000 # 2 GB for tensorstore cache @@ -119,9 +121,9 @@ def _create_mda_viewer_writer( # able to handle it. return None - def _create_zarr_tensorstore(self, save_path: Path) -> TensorStoreHandler: + def _create_zarr_tensorstore(self, save_path: Path) -> _TensorStoreHandler: """Create a Zarr TensorStore writer.""" - return TensorStoreHandler( + return _TensorStoreHandler( driver="zarr", path=save_path, delete_existing=True, diff --git a/src/micromanager_gui/_writers/_tensorstore_zarr.py b/src/micromanager_gui/_writers/_tensorstore_zarr.py new file mode 100644 index 00000000..22c4c41f --- /dev/null +++ b/src/micromanager_gui/_writers/_tensorstore_zarr.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +import time +from typing import TYPE_CHECKING, Literal, Mapping, TypeAlias + +from pymmcore_plus._logger import logger +from pymmcore_plus.mda.handlers import TensorStoreHandler + +if TYPE_CHECKING: + from os import PathLike + +TsDriver: TypeAlias = Literal["zarr", "zarr3", "n5", "neuroglancer_precomputed"] + +WAIT_TIME = 10 # seconds + + +class _TensorStoreHandler(TensorStoreHandler): + def __init__( + self, + *, + driver: TsDriver = "zarr", + kvstore: str | dict | None = "memory://", + path: str | PathLike | None = None, + delete_existing: bool = False, + spec: Mapping | None = None, + ) -> None: + super().__init__( + driver=driver, + kvstore=kvstore, + path=path, + delete_existing=delete_existing, + spec=spec, + ) + + # override this method to make sure the ".zattrs" file is written + def finalize_metadata(self) -> None: + """Finalize and flush metadata to storage.""" + if not (store := self._store) or not store.kvstore: + return + + data = [] + for event, meta in self.frame_metadatas: + # FIXME: unnecessary ser/des + js = event.model_dump_json(exclude={"sequence"}, exclude_defaults=True) + meta["Event"] = json.loads(js) + data.append(meta) + + metadata = { + "useq_MDASequence": self.current_sequence.model_dump_json( + exclude_defaults=True + ), + "frame_metadatas": data, + } + + if not self._nd_storage: + metadata["frame_indices"] = [ + (tuple(dict(k).items()), v) for k, v in self._frame_indices.items() + ] + + if self.ts_driver.startswith("zarr"): + store.kvstore.write(".zattrs", json.dumps(metadata)) + attrs = store.kvstore.read(".zattrs").result().value + logger.info("Writing 'tensorstore_zarr' store 'zattrs' to disk.") + start_time = time.time() + # HACK: wait for attrs to be written. If we don't have the while loop, + # most of the time the attrs will not be written. To avoid looping forever, + # we wait for WAIT_TIME seconds. If the attrs are not written by then, + # we continue. + while not attrs and not time.time() - start_time > WAIT_TIME: + store.kvstore.write(".zattrs", json.dumps(metadata)) + attrs = store.kvstore.read(".zattrs").result().value + logger.info("'tensorstore_zarr' 'zattrs' written to disk.") + + elif self.ts_driver == "n5": + attrs = json.loads(store.kvstore.read("attributes.json").result().value) + attrs.update(metadata) + store.kvstore.write("attributes.json", json.dumps(attrs)) diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index 3baa37af..ebba346a 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -10,6 +10,8 @@ from micromanager_gui import MicroManagerGUI from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS from micromanager_gui._readers._ome_zarr_reader import OMEZarrReader +from micromanager_gui._readers._tensorstore_zarr_reader import TensorstoreZarrReader +from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler if TYPE_CHECKING: from pathlib import Path @@ -160,35 +162,35 @@ def test_ome_zarr_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Pat assert z.isel({"p": 0, "t": 0}).shape == (2, 512, 512) -# NOTE: this works only if we use the internal _TensorStoreHandler, in the "calcium" -# branch. TODO: fix the main TensorStoreHandler because it does not write the ".zattrs" -# def test_tensorstore_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): -# mda = useq.MDASequence( -# channels=["FITC", "DAPI"], -# stage_positions=[(0, 0), (0, 1)], -# time_plan={"loops": 3, "interval": 0.1}, -# metadata={ -# PYMMCW_METADATA_KEY: { -# "format": "tensorstore-zarr", -# "save_dir": str(tmp_path), -# "save_name": "ts.tensorstore.zarr", -# "should_save": True, -# } -# }, -# ) - -# dest = tmp_path / "ts.tensorstore.zarr" -# writer = _TensorStoreHandler(path=dest, delete_existing=True) -# with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): -# global_mmcore.mda.run(mda, output=writer) - -# assert dest.exists() - -# ts = TensorstoreZarrReader(dest) -# assert ts.store -# assert ts.sequence -# assert ts.isel({"p": 0}).shape == (3, 2, 512, 512) -# assert ts.isel({"t": 0}).shape == (2, 2, 512, 512) -# assert ts.isel({"p": 0, "t": 0}).shape == (2, 512, 512) -# _, metadata = ts.isel({"p": 0}, metadata=True) -# assert metadata +# NOTE: this works only if we use the internal _TensorStoreHandler +# TODO: fix the main TensorStoreHandler because it does not write the ".zattrs" +def test_tensorstore_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): + mda = useq.MDASequence( + channels=["FITC", "DAPI"], + stage_positions=[(0, 0), (0, 1)], + time_plan={"loops": 3, "interval": 0.1}, + metadata={ + PYMMCW_METADATA_KEY: { + "format": "tensorstore-zarr", + "save_dir": str(tmp_path), + "save_name": "ts.tensorstore.zarr", + "should_save": True, + } + }, + ) + + dest = tmp_path / "ts.tensorstore.zarr" + writer = _TensorStoreHandler(path=dest, delete_existing=True) + with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): + global_mmcore.mda.run(mda, output=writer) + + assert dest.exists() + + ts = TensorstoreZarrReader(dest) + assert ts.store + assert ts.sequence + assert ts.isel({"p": 0}).shape == (3, 2, 512, 512) + assert ts.isel({"t": 0}).shape == (2, 2, 512, 512) + assert ts.isel({"p": 0, "t": 0}).shape == (2, 512, 512) + _, metadata = ts.isel({"p": 0}, metadata=True) + assert metadata From a658d07926165ef19fa3519172484cf47dedfb58 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 22:06:35 -0400 Subject: [PATCH 065/226] test: update --- tests/test_micromanager_gui.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index ebba346a..532b970e 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +import pytest import useq from pymmcore_plus.mda.handlers import TensorStoreHandler from pymmcore_widgets._stack_viewer_v2._mda_viewer import MDAViewer @@ -67,6 +68,7 @@ def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus): assert gui._core_link._viewer_tab.tabText(1) == "MDA4" +@pytest.mark.skip(reason="Run only locally") def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) @@ -81,6 +83,7 @@ def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus): assert gui._core_link._preview._image_preview.image._data.shape +@pytest.mark.skip(reason="Run only locally") def test_live(qtbot: QtBot, global_mmcore: CMMCorePlus): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) From d8a7c578b0b6c60b7acad9b72c7b42d71a5d8cdf Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 22:07:11 -0400 Subject: [PATCH 066/226] test: fix --- tests/test_micromanager_gui.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index 532b970e..b9dc2654 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -111,13 +111,12 @@ def test_mda_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): assert gui._core_link._viewer_tab.tabText(1) == "MDA Viewer 1" assert gui._core_link._viewer_tab.currentIndex() == 1 - dest = tmp_path / "ts.tensorstore.zarr" mda = useq.MDASequence( channels=["FITC", "DAPI"], metadata={ PYMMCW_METADATA_KEY: { "format": "tensorstore-zarr", - "save_dir": str(dest), + "save_dir": str(tmp_path), "save_name": "t.tensorstore.zarr", "should_save": True, } From 0c18208741d28221e82008e911a0c101b7af9819 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 22:10:31 -0400 Subject: [PATCH 067/226] test: fix --- tests/test_micromanager_gui.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index b9dc2654..ff88875c 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -67,6 +67,8 @@ def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus): assert gui._core_link._viewer_tab.tabText(0) == "Preview" assert gui._core_link._viewer_tab.tabText(1) == "MDA4" + menu._close_all() + @pytest.mark.skip(reason="Run only locally") def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus): From 8e76cd3cefbb914758bb3b33d31ab5eb547cf00f Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 22:14:32 -0400 Subject: [PATCH 068/226] test: wip --- tests/test_micromanager_gui.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index ff88875c..43242777 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -102,6 +102,7 @@ def test_live(qtbot: QtBot, global_mmcore: CMMCorePlus): assert not global_mmcore.isSequenceRunning() +@pytest.mark.skip def test_mda_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) From 7817aeee071c4fc34e731eac64e2304cc5597ff8 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 22:17:51 -0400 Subject: [PATCH 069/226] test: wip --- tests/test_micromanager_gui.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index 43242777..2de8ca35 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -33,6 +33,7 @@ def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus): assert gui._core_link._mda_running is False +@pytest.mark.skip def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) @@ -44,6 +45,7 @@ def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus): assert len(menu._widgets.keys()) == len(WIDGETS) + len(DOCKWIDGETS) +@pytest.mark.skip def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) @@ -139,6 +141,7 @@ def test_mda_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): gui._menu_bar._close_all() +@pytest.mark.skip def test_ome_zarr_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): mda = useq.MDASequence( channels=["FITC", "DAPI"], @@ -169,6 +172,7 @@ def test_ome_zarr_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Pat # NOTE: this works only if we use the internal _TensorStoreHandler # TODO: fix the main TensorStoreHandler because it does not write the ".zattrs" +@pytest.mark.skip def test_tensorstore_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): mda = useq.MDASequence( channels=["FITC", "DAPI"], From e38b80f821e9318885f61b7bec2474a5a192149e Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 22:39:34 -0400 Subject: [PATCH 070/226] test: tix --- src/micromanager_gui/_main_window.py | 9 ++++++-- tests/conftest.py | 31 ++++++++++++++++++++++++++++ tests/test_micromanager_gui.py | 2 +- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/micromanager_gui/_main_window.py b/src/micromanager_gui/_main_window.py index d6ec702f..d9982fe2 100644 --- a/src/micromanager_gui/_main_window.py +++ b/src/micromanager_gui/_main_window.py @@ -1,8 +1,6 @@ -import sys from pathlib import Path from warnings import warn -sys.path.append(str(Path(__file__).resolve().parent.parent)) from pymmcore_plus import CMMCorePlus from pymmcore_widgets._stack_viewer_v2._mda_viewer import StackViewer from qtpy.QtGui import QCloseEvent, QDragEnterEvent, QDropEvent @@ -103,4 +101,11 @@ def _open_datastore(self, idx: int, path: Path) -> StackViewer | None: def closeEvent(self, event: QCloseEvent) -> None: """Close all widgets before closing.""" self.deleteLater() + # delete any remaining widgets + from qtpy.QtWidgets import QApplication + + if qapp := QApplication.instance(): + if remaining := qapp.topLevelWidgets(): + for w in remaining: + w.deleteLater() super().closeEvent(event) diff --git a/tests/conftest.py b/tests/conftest.py index 661ee35f..153beea9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,17 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING from unittest.mock import patch import pytest from pymmcore_plus import CMMCorePlus from pymmcore_plus.core import _mmcore_plus +if TYPE_CHECKING: + from pytest import FixtureRequest + from qtpy.QtWidgets import QApplication + TEST_CONFIG = str(Path(__file__).parent / "test_config.cfg") @@ -17,3 +22,29 @@ def global_mmcore(): mmc.loadSystemConfiguration(TEST_CONFIG) with patch.object(_mmcore_plus, "_instance", mmc): yield mmc + + +@pytest.fixture() +def _run_after_each_test(request: FixtureRequest, qapp: QApplication): + """Run after each test to ensure no widgets have been left around. + + When this test fails, it means that a widget being tested has an issue closing + cleanly. Perhaps a strong reference has leaked somewhere. Look for + `functools.partial(self._method)` or `lambda: self._method` being used in that + widget's code. + """ + nbefore = len(qapp.topLevelWidgets()) + failures_before = request.session.testsfailed + yield + # if the test failed, don't worry about checking widgets + if request.session.testsfailed - failures_before: + return + remaining = qapp.topLevelWidgets() + + print() + for r in remaining: + print(r, r.parent()) + + if len(remaining) > nbefore: + test = f"{request.node.path.name}::{request.node.originalname}" + raise AssertionError(f"topLevelWidgets remaining after {test!r}: {remaining}") diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index 2de8ca35..c09ef9c3 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -21,7 +21,7 @@ from pytestqt.qtbot import QtBot -def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus): +def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) assert gui._menu_bar._mda From eb6541c4fc75aede7cb7e5adb2f1eacc4c75405c Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 22:45:19 -0400 Subject: [PATCH 071/226] test: fix --- .github/workflows/ci.yml | 6 ++---- tests/test_micromanager_gui.py | 20 +++++++------------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2d71b21..23eb98ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,17 +30,15 @@ jobs: matrix: python-version: ['3.10', '3.11'] platform: [macos-13, windows-latest] - backend: [pyside6, pyqt6] + backend: [pyqt6] include: - platform: windows-latest python-version: "3.10" backend: pyqt5 - - platform: windows-latest - python-version: "3.10" - backend: pyside2 - platform: windows-latest python-version: "3.11" backend: pyqt5 + # NOTE: pyside 2 and pyqt6 fails for some reason. maybe try to fix it at some point steps: - uses: actions/checkout@v4 diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index c09ef9c3..5ae1dc88 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -2,7 +2,6 @@ from typing import TYPE_CHECKING -import pytest import useq from pymmcore_plus.mda.handlers import TensorStoreHandler from pymmcore_widgets._stack_viewer_v2._mda_viewer import MDAViewer @@ -33,8 +32,7 @@ def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test assert gui._core_link._mda_running is False -@pytest.mark.skip -def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus): +def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) menu = gui._menu_bar @@ -45,8 +43,7 @@ def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus): assert len(menu._widgets.keys()) == len(WIDGETS) + len(DOCKWIDGETS) -@pytest.mark.skip -def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus): +def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) menu = gui._menu_bar @@ -72,8 +69,7 @@ def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus): menu._close_all() -@pytest.mark.skip(reason="Run only locally") -def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus): +def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) @@ -87,8 +83,7 @@ def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus): assert gui._core_link._preview._image_preview.image._data.shape -@pytest.mark.skip(reason="Run only locally") -def test_live(qtbot: QtBot, global_mmcore: CMMCorePlus): +def test_live(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) @@ -104,8 +99,9 @@ def test_live(qtbot: QtBot, global_mmcore: CMMCorePlus): assert not global_mmcore.isSequenceRunning() -@pytest.mark.skip -def test_mda_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): +def test_mda_viewer( + qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path, _run_after_each_test +): gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) @@ -141,7 +137,6 @@ def test_mda_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): gui._menu_bar._close_all() -@pytest.mark.skip def test_ome_zarr_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): mda = useq.MDASequence( channels=["FITC", "DAPI"], @@ -172,7 +167,6 @@ def test_ome_zarr_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Pat # NOTE: this works only if we use the internal _TensorStoreHandler # TODO: fix the main TensorStoreHandler because it does not write the ".zattrs" -@pytest.mark.skip def test_tensorstore_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): mda = useq.MDASequence( channels=["FITC", "DAPI"], From 864649facc8f017f7f1d2ac3d5db2cd824e9baa8 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 22:52:31 -0400 Subject: [PATCH 072/226] fix: readme --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 974da861..85c2b3a5 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,16 @@ A Micro-Manager GUI based on [pymmcore-widgets](https://pymmcore-plus.github.io/ pip install git+https://github.com/fdrgsp/micromanager-gui ``` -### Installing PyQt or PySide +### Installing PyQt -Since `micromanager-gui` relies on either the [PyQt](https://riverbankcomputing.com/software/pyqt/) or [PySide](https://www.qt.io/qt-for-python) libraries, you also **need** to install one of these packages. You can use any of the available versions of these libraries: [PyQt5](https://pypi.org/project/PyQt5/), [PyQt6](https://pypi.org/project/PyQt6/), [PySide2](https://pypi.org/project/PySide2/) or [PySide6](https://pypi.org/project/PySide6/). For example, to install [PyQt6](https://riverbankcomputing.com/software/pyqt/download), you can use: +Since `micromanager-gui` relies on the [PyQt](https://riverbankcomputing.com/software/pyqt/) library, you also **need** to install one of these packages. You can use any of the available versions of [PyQt6](https://pypi.org/project/PyQt6/) or [PyQt5](https://pypi.org/project/PyQt5/). For example, to install [PyQt6](https://riverbankcomputing.com/software/pyqt/download), you can use: ```sh pip install PyQt6 ``` +Note: tests are running on [PyQt6](https://pypi.org/project/PyQt6/) and [PyQt5](https://pypi.org/project/PyQt5/). + ### Installing Micro-Manager You also need to install the `Micro-Manager` device adapters and C++ core provided by [mmCoreAndDevices](https://github.com/micro-manager/mmCoreAndDevices#mmcoreanddevices). This can be done by following the steps described in the `pymmcore-plus` [documentation page](https://pymmcore-plus.github.io/pymmcore-plus/install/#installing-micro-manager-device-adapters). From 5e81ba6a6c6ff6f395b3eae1d679bfc961ce8da2 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 22:58:28 -0400 Subject: [PATCH 073/226] test: wip --- tests/test_micromanager_gui.py | 239 +++++++++++++++++---------------- 1 file changed, 120 insertions(+), 119 deletions(-) diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index 5ae1dc88..762e3f05 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -3,12 +3,13 @@ from typing import TYPE_CHECKING import useq -from pymmcore_plus.mda.handlers import TensorStoreHandler -from pymmcore_widgets._stack_viewer_v2._mda_viewer import MDAViewer + +# from pymmcore_plus.mda.handlers import TensorStoreHandler +# from pymmcore_widgets._stack_viewer_v2._mda_viewer import MDAViewer from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY -from micromanager_gui import MicroManagerGUI -from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS +# from micromanager_gui import MicroManagerGUI +# from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS from micromanager_gui._readers._ome_zarr_reader import OMEZarrReader from micromanager_gui._readers._tensorstore_zarr_reader import TensorstoreZarrReader from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler @@ -20,121 +21,121 @@ from pytestqt.qtbot import QtBot -def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): - gui = MicroManagerGUI(mmcore=global_mmcore) - qtbot.addWidget(gui) - assert gui._menu_bar._mda - assert gui._core_link._preview - assert not gui._core_link._preview.isHidden() - assert gui._core_link._viewer_tab.count() == 1 - assert gui._core_link._viewer_tab.tabText(0) == "Preview" - assert gui._core_link._current_viewer is None - assert gui._core_link._mda_running is False - - -def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): - gui = MicroManagerGUI(mmcore=global_mmcore) - qtbot.addWidget(gui) - menu = gui._menu_bar - - assert len(menu._widgets.keys()) == 2 # MDA and GroupPreset widgets - for action in menu._widgets_menu.actions(): - action.trigger() - assert len(menu._widgets.keys()) == len(WIDGETS) + len(DOCKWIDGETS) - - -def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): - gui = MicroManagerGUI(mmcore=global_mmcore) - qtbot.addWidget(gui) - menu = gui._menu_bar - assert gui._core_link._viewer_tab.tabText(0) == "Preview" - # add a viewer - gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA1") - gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA2") - assert gui._core_link._viewer_tab.count() == 3 - - menu._close_all() - assert gui._core_link._viewer_tab.count() == 1 - - gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA3") - gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA4") - gui._core_link._viewer_tab.setCurrentIndex(2) - assert gui._core_link._viewer_tab.count() == 3 - - menu._close_all_but_current() - assert gui._core_link._viewer_tab.count() == 2 - assert gui._core_link._viewer_tab.tabText(0) == "Preview" - assert gui._core_link._viewer_tab.tabText(1) == "MDA4" - - menu._close_all() - - -def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): - gui = MicroManagerGUI(mmcore=global_mmcore) - qtbot.addWidget(gui) - - assert gui._core_link._preview - assert gui._core_link._preview._image_preview.image is None - assert not gui._core_link._preview.isHidden() - - with qtbot.waitSignal(global_mmcore.events.imageSnapped): - gui._core_link._preview._snap._snap() - assert gui._core_link._preview._image_preview.image - assert gui._core_link._preview._image_preview.image._data.shape - - -def test_live(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): - gui = MicroManagerGUI(mmcore=global_mmcore) - qtbot.addWidget(gui) - - assert gui._core_link._preview - assert gui._core_link._preview._image_preview.image is None - assert not gui._core_link._preview.isHidden() - - with qtbot.waitSignal(global_mmcore.events.continuousSequenceAcquisitionStarted): - gui._core_link._preview._live._toggle_live_mode() - assert global_mmcore.isSequenceRunning() - with qtbot.waitSignal(global_mmcore.events.sequenceAcquisitionStopped): - gui._core_link._preview._live._toggle_live_mode() - assert not global_mmcore.isSequenceRunning() - - -def test_mda_viewer( - qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path, _run_after_each_test -): - gui = MicroManagerGUI(mmcore=global_mmcore) - qtbot.addWidget(gui) - - mda = useq.MDASequence(channels=["DAPI", "FITC"]) - with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): - global_mmcore.mda.run(mda) - assert gui._core_link._viewer_tab.count() == 2 - assert gui._core_link._viewer_tab.tabText(1) == "MDA Viewer 1" - assert gui._core_link._viewer_tab.currentIndex() == 1 - - mda = useq.MDASequence( - channels=["FITC", "DAPI"], - metadata={ - PYMMCW_METADATA_KEY: { - "format": "tensorstore-zarr", - "save_dir": str(tmp_path), - "save_name": "t.tensorstore.zarr", - "should_save": True, - } - }, - ) - gui._menu_bar._mda.setValue(mda) - - with qtbot.waitSignal(global_mmcore.mda.events.sequenceStarted): - gui._menu_bar._mda.run_mda() - - assert isinstance(gui._menu_bar._mda.writer, TensorStoreHandler) - assert gui._core_link._viewer_tab.count() == 3 - assert gui._core_link._viewer_tab.tabText(2) == "t.tensorstore.zarr" - - # saving tensorstore and MDAViewer datastore should be the same - assert gui._core_link._mda.writer == gui._core_link._viewer_tab.widget(2)._data - gui._menu_bar._close_all() +# def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +# gui = MicroManagerGUI(mmcore=global_mmcore) +# qtbot.addWidget(gui) +# assert gui._menu_bar._mda +# assert gui._core_link._preview +# assert not gui._core_link._preview.isHidden() +# assert gui._core_link._viewer_tab.count() == 1 +# assert gui._core_link._viewer_tab.tabText(0) == "Preview" +# assert gui._core_link._current_viewer is None +# assert gui._core_link._mda_running is False + + +# def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +# gui = MicroManagerGUI(mmcore=global_mmcore) +# qtbot.addWidget(gui) +# menu = gui._menu_bar + +# assert len(menu._widgets.keys()) == 2 # MDA and GroupPreset widgets +# for action in menu._widgets_menu.actions(): +# action.trigger() +# assert len(menu._widgets.keys()) == len(WIDGETS) + len(DOCKWIDGETS) + + +# def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +# gui = MicroManagerGUI(mmcore=global_mmcore) +# qtbot.addWidget(gui) +# menu = gui._menu_bar +# assert gui._core_link._viewer_tab.tabText(0) == "Preview" +# # add a viewer +# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA1") +# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA2") +# assert gui._core_link._viewer_tab.count() == 3 + +# menu._close_all() +# assert gui._core_link._viewer_tab.count() == 1 + +# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA3") +# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA4") +# gui._core_link._viewer_tab.setCurrentIndex(2) +# assert gui._core_link._viewer_tab.count() == 3 + +# menu._close_all_but_current() +# assert gui._core_link._viewer_tab.count() == 2 +# assert gui._core_link._viewer_tab.tabText(0) == "Preview" +# assert gui._core_link._viewer_tab.tabText(1) == "MDA4" + +# menu._close_all() + + +# def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +# gui = MicroManagerGUI(mmcore=global_mmcore) +# qtbot.addWidget(gui) + +# assert gui._core_link._preview +# assert gui._core_link._preview._image_preview.image is None +# assert not gui._core_link._preview.isHidden() + +# with qtbot.waitSignal(global_mmcore.events.imageSnapped): +# gui._core_link._preview._snap._snap() +# assert gui._core_link._preview._image_preview.image +# assert gui._core_link._preview._image_preview.image._data.shape + + +# def test_live(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +# gui = MicroManagerGUI(mmcore=global_mmcore) +# qtbot.addWidget(gui) + +# assert gui._core_link._preview +# assert gui._core_link._preview._image_preview.image is None +# assert not gui._core_link._preview.isHidden() + +# with qtbot.waitSignal(global_mmcore.events.continuousSequenceAcquisitionStarted): +# gui._core_link._preview._live._toggle_live_mode() +# assert global_mmcore.isSequenceRunning() +# with qtbot.waitSignal(global_mmcore.events.sequenceAcquisitionStopped): +# gui._core_link._preview._live._toggle_live_mode() +# assert not global_mmcore.isSequenceRunning() + + +# def test_mda_viewer( +# qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path, _run_after_each_test +# ): +# gui = MicroManagerGUI(mmcore=global_mmcore) +# qtbot.addWidget(gui) + +# mda = useq.MDASequence(channels=["DAPI", "FITC"]) +# with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): +# global_mmcore.mda.run(mda) +# assert gui._core_link._viewer_tab.count() == 2 +# assert gui._core_link._viewer_tab.tabText(1) == "MDA Viewer 1" +# assert gui._core_link._viewer_tab.currentIndex() == 1 + +# mda = useq.MDASequence( +# channels=["FITC", "DAPI"], +# metadata={ +# PYMMCW_METADATA_KEY: { +# "format": "tensorstore-zarr", +# "save_dir": str(tmp_path), +# "save_name": "t.tensorstore.zarr", +# "should_save": True, +# } +# }, +# ) +# gui._menu_bar._mda.setValue(mda) + +# with qtbot.waitSignal(global_mmcore.mda.events.sequenceStarted): +# gui._menu_bar._mda.run_mda() + +# assert isinstance(gui._menu_bar._mda.writer, TensorStoreHandler) +# assert gui._core_link._viewer_tab.count() == 3 +# assert gui._core_link._viewer_tab.tabText(2) == "t.tensorstore.zarr" + +# # saving tensorstore and MDAViewer datastore should be the same +# assert gui._core_link._mda.writer == gui._core_link._viewer_tab.widget(2)._data +# gui._menu_bar._close_all() def test_ome_zarr_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): From 3244c0a5b91a686e996fd0ffeb3555c2d125a0f7 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 23:02:26 -0400 Subject: [PATCH 074/226] test: wip --- tests/test_micromanager_gui.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index 762e3f05..0479e405 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -8,7 +8,8 @@ # from pymmcore_widgets._stack_viewer_v2._mda_viewer import MDAViewer from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY -# from micromanager_gui import MicroManagerGUI +from micromanager_gui import MicroManagerGUI + # from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS from micromanager_gui._readers._ome_zarr_reader import OMEZarrReader from micromanager_gui._readers._tensorstore_zarr_reader import TensorstoreZarrReader @@ -21,16 +22,16 @@ from pytestqt.qtbot import QtBot -# def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): -# gui = MicroManagerGUI(mmcore=global_mmcore) -# qtbot.addWidget(gui) -# assert gui._menu_bar._mda -# assert gui._core_link._preview -# assert not gui._core_link._preview.isHidden() -# assert gui._core_link._viewer_tab.count() == 1 -# assert gui._core_link._viewer_tab.tabText(0) == "Preview" -# assert gui._core_link._current_viewer is None -# assert gui._core_link._mda_running is False +def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): + gui = MicroManagerGUI(mmcore=global_mmcore) + qtbot.addWidget(gui) + assert gui._menu_bar._mda + assert gui._core_link._preview + assert not gui._core_link._preview.isHidden() + assert gui._core_link._viewer_tab.count() == 1 + assert gui._core_link._viewer_tab.tabText(0) == "Preview" + assert gui._core_link._current_viewer is None + assert gui._core_link._mda_running is False # def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): From 17db4ace88c8b25b8bf4bea3e30c12ea520fed99 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 23:08:45 -0400 Subject: [PATCH 075/226] test: wip --- tests/test_micromanager_gui.py | 124 ++++++++++++++++----------------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py index 0479e405..de99feb2 100644 --- a/tests/test_micromanager_gui.py +++ b/tests/test_micromanager_gui.py @@ -34,6 +34,68 @@ def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test assert gui._core_link._mda_running is False +def test_ome_zarr_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): + mda = useq.MDASequence( + channels=["FITC", "DAPI"], + stage_positions=[(0, 0), (0, 1)], + time_plan={"loops": 3, "interval": 0.1}, + metadata={ + PYMMCW_METADATA_KEY: { + "format": "ome-zarr", + "save_dir": str(tmp_path), + "save_name": "z.ome.zarr", + "should_save": True, + } + }, + ) + + dest = tmp_path / "z.ome.zarr" + with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): + global_mmcore.mda.run(mda, output=dest) + + assert dest.exists() + + z = OMEZarrReader(dest) + assert z.store + assert z.sequence + assert z.isel({"p": 0}).shape == (3, 2, 512, 512) + assert z.isel({"p": 0, "t": 0}).shape == (2, 512, 512) + + +# NOTE: this works only if we use the internal _TensorStoreHandler +# TODO: fix the main TensorStoreHandler because it does not write the ".zattrs" +def test_tensorstore_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): + mda = useq.MDASequence( + channels=["FITC", "DAPI"], + stage_positions=[(0, 0), (0, 1)], + time_plan={"loops": 3, "interval": 0.1}, + metadata={ + PYMMCW_METADATA_KEY: { + "format": "tensorstore-zarr", + "save_dir": str(tmp_path), + "save_name": "ts.tensorstore.zarr", + "should_save": True, + } + }, + ) + + dest = tmp_path / "ts.tensorstore.zarr" + writer = _TensorStoreHandler(path=dest, delete_existing=True) + with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): + global_mmcore.mda.run(mda, output=writer) + + assert dest.exists() + + ts = TensorstoreZarrReader(dest) + assert ts.store + assert ts.sequence + assert ts.isel({"p": 0}).shape == (3, 2, 512, 512) + assert ts.isel({"t": 0}).shape == (2, 2, 512, 512) + assert ts.isel({"p": 0, "t": 0}).shape == (2, 512, 512) + _, metadata = ts.isel({"p": 0}, metadata=True) + assert metadata + + # def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): # gui = MicroManagerGUI(mmcore=global_mmcore) # qtbot.addWidget(gui) @@ -137,65 +199,3 @@ def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test # # saving tensorstore and MDAViewer datastore should be the same # assert gui._core_link._mda.writer == gui._core_link._viewer_tab.widget(2)._data # gui._menu_bar._close_all() - - -def test_ome_zarr_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): - mda = useq.MDASequence( - channels=["FITC", "DAPI"], - stage_positions=[(0, 0), (0, 1)], - time_plan={"loops": 3, "interval": 0.1}, - metadata={ - PYMMCW_METADATA_KEY: { - "format": "ome-zarr", - "save_dir": str(tmp_path), - "save_name": "z.ome.zarr", - "should_save": True, - } - }, - ) - - dest = tmp_path / "z.ome.zarr" - with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): - global_mmcore.mda.run(mda, output=dest) - - assert dest.exists() - - z = OMEZarrReader(dest) - assert z.store - assert z.sequence - assert z.isel({"p": 0}).shape == (3, 2, 512, 512) - assert z.isel({"p": 0, "t": 0}).shape == (2, 512, 512) - - -# NOTE: this works only if we use the internal _TensorStoreHandler -# TODO: fix the main TensorStoreHandler because it does not write the ".zattrs" -def test_tensorstore_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): - mda = useq.MDASequence( - channels=["FITC", "DAPI"], - stage_positions=[(0, 0), (0, 1)], - time_plan={"loops": 3, "interval": 0.1}, - metadata={ - PYMMCW_METADATA_KEY: { - "format": "tensorstore-zarr", - "save_dir": str(tmp_path), - "save_name": "ts.tensorstore.zarr", - "should_save": True, - } - }, - ) - - dest = tmp_path / "ts.tensorstore.zarr" - writer = _TensorStoreHandler(path=dest, delete_existing=True) - with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): - global_mmcore.mda.run(mda, output=writer) - - assert dest.exists() - - ts = TensorstoreZarrReader(dest) - assert ts.store - assert ts.sequence - assert ts.isel({"p": 0}).shape == (3, 2, 512, 512) - assert ts.isel({"t": 0}).shape == (2, 2, 512, 512) - assert ts.isel({"p": 0, "t": 0}).shape == (2, 512, 512) - _, metadata = ts.isel({"p": 0}, metadata=True) - assert metadata From 6e31da23db4c7cd8f5effe581339ba2232621817 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 3 Jun 2024 23:30:28 -0400 Subject: [PATCH 076/226] fix: preview _on_save --- src/micromanager_gui/_widgets/_preview.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/micromanager_gui/_widgets/_preview.py b/src/micromanager_gui/_widgets/_preview.py index 1bd66916..5c99a5e0 100644 --- a/src/micromanager_gui/_widgets/_preview.py +++ b/src/micromanager_gui/_widgets/_preview.py @@ -1,5 +1,8 @@ from __future__ import annotations +import json +from pathlib import Path + import numpy as np import tifffile from fonticon_mdi6 import MDI6 @@ -235,3 +238,6 @@ def _on_save(self) -> None: imagej=True, # description=self._image_preview._meta, # TODO: ome-tiff ) + # save meta as json + dest = Path(path).with_suffix(".json") + dest.write_text(json.dumps(self._image_preview._meta)) From d8ecf98cd1c2ba6dc121847b451c3b8832f9d25b Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 4 Jun 2024 01:25:11 -0400 Subject: [PATCH 077/226] fix: _enable --- src/micromanager_gui/_menubar/_menubar.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index 1fd03d74..145e132d 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -139,6 +139,7 @@ def _enable(self, enable: bool) -> None: """Enable or disable the actions.""" self._configurations_menu.setEnabled(enable) self._widgets_menu.setEnabled(enable) + self._viewer_menu.setEnabled(enable) def _save_cfg(self) -> None: (filename, _) = QFileDialog.getSaveFileName( From 75e47f453f6ff12d4dcf01198b82b9da8bbb8008 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Mon, 3 Jun 2024 23:50:11 -0400 Subject: [PATCH 078/226] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 85c2b3a5..3ab11108 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ A Micro-Manager GUI based on [pymmcore-widgets](https://pymmcore-plus.github.io/pymmcore-widgets/) and [pymmcore-plus](https://pymmcore-plus.github.io/pymmcore-plus/). -Screenshot 2024-05-27 at 2 21 05 PM + +Screenshot 2024-06-03 at 11 49 45 PM ## Installation From 00b8761685ddf4b13d44ad19b7fe02f8cfbd7f93 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 5 Jun 2024 16:36:30 -0400 Subject: [PATCH 079/226] feat: update readers + tests --- .../_readers/_ome_zarr_reader.py | 82 ++++++----- .../_readers/_tensorstore_zarr_reader.py | 87 +++++++----- tests/test_gui.py | 130 ++++++++++++++++++ tests/test_readers.py | 117 ++++++++++++++++ 4 files changed, 353 insertions(+), 63 deletions(-) create mode 100644 tests/test_gui.py create mode 100644 tests/test_readers.py diff --git a/src/micromanager_gui/_readers/_ome_zarr_reader.py b/src/micromanager_gui/_readers/_ome_zarr_reader.py index 55534232..0fc6c1f1 100644 --- a/src/micromanager_gui/_readers/_ome_zarr_reader.py +++ b/src/micromanager_gui/_readers/_ome_zarr_reader.py @@ -2,7 +2,7 @@ import json from pathlib import Path -from typing import TYPE_CHECKING, Mapping, cast +from typing import TYPE_CHECKING, Any, Mapping, cast import useq import zarr @@ -78,7 +78,10 @@ def sequence(self) -> useq.MDASequence | None: return self._sequence def isel( - self, indexers: Mapping[str, int], metadata: bool = False + self, + indexers: Mapping[str, int] | None = None, + metadata: bool = False, + **kwargs: Any, ) -> np.ndarray | tuple[np.ndarray, dict]: """Select data from the array. @@ -86,12 +89,26 @@ def isel( ---------- indexers : Mapping[str, int] The indexers to select the data. Thy should contain the 'p' axis since the - OMEZarrWriter saves each position as a separate array. If not present, it + OMEZarrWriter saves each position as a separate array. If None, it assume the first position {"p": 0}. metadata : bool If True, return the metadata as well as a list of dictionaries. By default, False. + **kwargs : Any + Additional way to pass the indexers. You can pass the indexers as kwargs + (e.g. p=0, t=1). NOTE: kwargs will overwrite the indexers if already present + in the indexers mapping. """ + if indexers is None: + indexers = {} + if kwargs: + if all( + isinstance(k, str) and isinstance(v, int) for k, v in kwargs.items() + ): + indexers = {**indexers, **kwargs} + else: + raise TypeError("kwargs must be a mapping from strings to integers") + if len(self.store.keys()) > 1 and "p" not in indexers: raise ValueError( "The indexers should contain the 'p' axis since the zarr store has " @@ -109,7 +126,8 @@ def isel( def write_tiff( self, path: str | Path, - indexers: Mapping[str, int] | list[Mapping[str, int]] | None = None, + indexers: Mapping[str, int] | None = None, + **kwargs: Any, ) -> None: """Write the data to a tiff file. @@ -119,14 +137,37 @@ def write_tiff( The path to the tiff file. If `indexers` is a Mapping of axis and index, the path should be a file path (e.g. 'path/to/file.tif'). Otherwise, it should be a directory path (e.g. 'path/to/directory'). - indexers : Mapping[str, int] | list[Mapping[str, int]] | None + indexers : Mapping[str, int] | None The indexers to select the data. If None, write all the data per position - to a tiff file. If a list of Mapping of axis and index - (e.g. [{"p": 0, "t": 1}, {"p": 1, "t": 0}]), write the data for the given - indexes to a tiff file. If a Mapping of axis and index (e.g. - {"p": 0, "t": 1}), write the data for the given index to a tiff file. + to a tiff file. If a Mapping of axis and index (e.g. {"p": 0, "t": 1}), + write the data for the given index to a tiff file. + **kwargs : Any + Additional way to pass the indexers. You can pass the indexers as kwargs + (e.g. p=0, t=1). NOTE: kwargs will overwrite the indexers if already present + in the indexers mapping. """ - if indexers is None: + if kwargs: + indexers = indexers or {} + if all( + isinstance(k, str) and isinstance(v, int) for k, v in kwargs.items() + ): + indexers = {**indexers, **kwargs} + else: + raise TypeError( + "kwargs must be a mapping from strings to integers (e.g. p=0, t=1)!" + ) + + if indexers: + data, metadata = self.isel(indexers, metadata=True) + imj = len(data.shape) <= 5 + if Path(path).suffix not in {".tif", ".tiff"}: + path = Path(path).with_suffix(".tiff") + imwrite(path, data, imagej=imj) + # save metadata as json + dest = Path(path).with_suffix(".json") + dest.write_text(json.dumps(metadata)) + + else: keys = [ key for key in self.store.keys() @@ -144,27 +185,6 @@ def write_tiff( dest.write_text(json.dumps(metadata)) pbar.update(1) - elif isinstance(indexers, list): - if not Path(path).exists(): - Path(path).mkdir(parents=True, exist_ok=False) - for index in indexers: - data, metadata = self.isel(index, metadata=True) - name = "_".join(f"{k}{v}" for k, v in index.items()) - imwrite(Path(path) / f"{name}.tif", data, imagej=True) - # save metadata as json - dest = Path(path) / f"{name}.json" - dest.write_text(json.dumps(metadata)) - - else: - data, metadata = self.isel(indexers, metadata=True) - imj = len(data.shape) <= 5 - if Path(path).suffix not in {".tif", ".tiff"}: - path = Path(path).with_suffix(".tiff") - imwrite(path, data, imagej=imj) - # save metadata as json - dest = Path(path).with_suffix(".json") - dest.write_text(json.dumps(metadata)) - # ___________________________Private Methods___________________________ def _get_axis_index( diff --git a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py index 73ae14d9..b8a169a7 100644 --- a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py +++ b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py @@ -1,6 +1,6 @@ import json from pathlib import Path -from typing import Mapping +from typing import Any, Mapping import numpy as np import tensorstore as ts @@ -69,18 +69,38 @@ def sequence(self) -> useq.MDASequence: # ___________________________Public Methods___________________________ def isel( - self, indexers: Mapping[str, int], metadata: bool = False + self, + indexers: Mapping[str, int] | None = None, + metadata: bool = False, + **kwargs: Any, ) -> np.ndarray | tuple[np.ndarray, dict]: """Select data from the array. Parameters ---------- - indexers : Mapping[str, int] - The indexers to select the data. + indexers : Mapping[str, int] | None + The indexers to select the data (e.g. {"p": 0, "t": 1}). If None, return + the entire data. metadata : bool If True, return the metadata as well as a list of dictionaries. By default, False. + **kwargs : Any + Additional way to pass the indexers. You can pass the indexers as kwargs + (e.g. p=0, t=1). NOTE: kwargs will overwrite the indexers if already present + in the indexers mapping. """ + if indexers is None: + indexers = {} + if kwargs: + if all( + isinstance(k, str) and isinstance(v, int) for k, v in kwargs.items() + ): + indexers = {**indexers, **kwargs} + else: + raise TypeError( + "kwargs must be a mapping from strings to integers (e.g. p=0, t=1)!" + ) + index = self._get_axis_index(indexers) data = self.store[index].read().result().squeeze() if metadata: @@ -91,7 +111,8 @@ def isel( def write_tiff( self, path: str | Path, - indexers: Mapping[str, int] | list[Mapping[str, int]] | None = None, + indexers: Mapping[str, int] | None = None, + **kwargs: Any, ) -> None: """Write the data to a tiff file. @@ -101,14 +122,37 @@ def write_tiff( The path to the tiff file. If `indexers` is a Mapping of axis and index, the path should be a file path (e.g. 'path/to/file.tif'). Otherwise, it should be a directory path (e.g. 'path/to/directory'). - indexers : Mapping[str, int] | list[Mapping[str, int]] | None + indexers : Mapping[str, int] | None The indexers to select the data. If None, write all the data per position - to a tiff file. If a list of Mapping of axis and index - (e.g. [{"p": 0, "t": 1}, {"p": 1, "t": 0}]), write the data for the given - indexes to a tiff file. If a Mapping of axis and index (e.g. - {"p": 0, "t": 1}), write the data for the given index to a tiff file. + to a tiff file. If a Mapping of axis and index (e.g. {"p": 0, "t": 1}), + write the data for the given index to a tiff file. + **kwargs : Any + Additional way to pass the indexers. You can pass the indexers as kwargs + (e.g. p=0, t=1). NOTE: kwargs will overwrite the indexers if already present + in the indexers mapping. """ - if indexers is None: + if kwargs: + indexers = indexers or {} + if all( + isinstance(k, str) and isinstance(v, int) for k, v in kwargs.items() + ): + indexers = {**indexers, **kwargs} + else: + raise TypeError("kwargs must be a mapping from strings to integers") + + if indexers: + data, metadata = self.isel(indexers, metadata=True) + imj = len(data.shape) <= 5 + if Path(path).suffix not in {".tif", ".tiff"}: + path = Path(path).with_suffix(".tiff") + if not Path(path).parent.exists(): + Path(path).parent.mkdir(parents=True, exist_ok=True) + imwrite(path, data, imagej=imj) + # save metadata as json + dest = Path(path).with_suffix(".json") + dest.write_text(json.dumps(metadata)) + + else: if pos := len(self.sequence.stage_positions): if not Path(path).exists(): Path(path).mkdir(parents=True, exist_ok=False) @@ -121,27 +165,6 @@ def write_tiff( dest.write_text(json.dumps(metadata)) pbar.update(1) - elif isinstance(indexers, list): - if not Path(path).exists(): - Path(path).mkdir(parents=True, exist_ok=False) - for index in indexers: - data, metadata = self.isel(index, metadata=True) - name = "_".join(f"{k}{v}" for k, v in index.items()) - imwrite(Path(path) / f"{name}.tif", data, imagej=True) - # save metadata as json - dest = Path(path) / f"{name}.json" - dest.write_text(json.dumps(metadata)) - - else: - data, metadata = self.isel(indexers, metadata=True) - imj = len(data.shape) <= 5 - if Path(path).suffix not in {".tif", ".tiff"}: - path = Path(path).with_suffix(".tiff") - imwrite(path, data, imagej=imj) - # save metadata as json - dest = Path(path).with_suffix(".json") - dest.write_text(json.dumps(metadata)) - # ___________________________Private Methods___________________________ def _get_axis_index(self, indexers: Mapping[str, int]) -> tuple[object, ...]: diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 00000000..0d3b73d4 --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +# from pymmcore_plus.mda.handlers import TensorStoreHandler +# from pymmcore_widgets._stack_viewer_v2._mda_viewer import MDAViewer +from micromanager_gui import MicroManagerGUI + +# from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from pytestqt.qtbot import QtBot + + +def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): + gui = MicroManagerGUI(mmcore=global_mmcore) + qtbot.addWidget(gui) + assert gui._menu_bar._mda + assert gui._core_link._preview + assert not gui._core_link._preview.isHidden() + assert gui._core_link._viewer_tab.count() == 1 + assert gui._core_link._viewer_tab.tabText(0) == "Preview" + assert gui._core_link._current_viewer is None + assert gui._core_link._mda_running is False + + +# def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +# gui = MicroManagerGUI(mmcore=global_mmcore) +# qtbot.addWidget(gui) +# menu = gui._menu_bar + +# assert len(menu._widgets.keys()) == 2 # MDA and GroupPreset widgets +# for action in menu._widgets_menu.actions(): +# action.trigger() +# assert len(menu._widgets.keys()) == len(WIDGETS) + len(DOCKWIDGETS) + + +# def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +# gui = MicroManagerGUI(mmcore=global_mmcore) +# qtbot.addWidget(gui) +# menu = gui._menu_bar +# assert gui._core_link._viewer_tab.tabText(0) == "Preview" +# # add a viewer +# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA1") +# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA2") +# assert gui._core_link._viewer_tab.count() == 3 + +# menu._close_all() +# assert gui._core_link._viewer_tab.count() == 1 + +# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA3") +# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA4") +# gui._core_link._viewer_tab.setCurrentIndex(2) +# assert gui._core_link._viewer_tab.count() == 3 + +# menu._close_all_but_current() +# assert gui._core_link._viewer_tab.count() == 2 +# assert gui._core_link._viewer_tab.tabText(0) == "Preview" +# assert gui._core_link._viewer_tab.tabText(1) == "MDA4" + +# menu._close_all() + + +# def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +# gui = MicroManagerGUI(mmcore=global_mmcore) +# qtbot.addWidget(gui) + +# assert gui._core_link._preview +# assert gui._core_link._preview._image_preview.image is None +# assert not gui._core_link._preview.isHidden() + +# with qtbot.waitSignal(global_mmcore.events.imageSnapped): +# gui._core_link._preview._snap._snap() +# assert gui._core_link._preview._image_preview.image +# assert gui._core_link._preview._image_preview.image._data.shape + + +# def test_live(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +# gui = MicroManagerGUI(mmcore=global_mmcore) +# qtbot.addWidget(gui) + +# assert gui._core_link._preview +# assert gui._core_link._preview._image_preview.image is None +# assert not gui._core_link._preview.isHidden() + +# with qtbot.waitSignal(global_mmcore.events.continuousSequenceAcquisitionStarted): +# gui._core_link._preview._live._toggle_live_mode() +# assert global_mmcore.isSequenceRunning() +# with qtbot.waitSignal(global_mmcore.events.sequenceAcquisitionStopped): +# gui._core_link._preview._live._toggle_live_mode() +# assert not global_mmcore.isSequenceRunning() + + +# def test_mda_viewer( +# qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path, _run_after_each_test +# ): +# gui = MicroManagerGUI(mmcore=global_mmcore) +# qtbot.addWidget(gui) + +# mda = useq.MDASequence(channels=["DAPI", "FITC"]) +# with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): +# global_mmcore.mda.run(mda) +# assert gui._core_link._viewer_tab.count() == 2 +# assert gui._core_link._viewer_tab.tabText(1) == "MDA Viewer 1" +# assert gui._core_link._viewer_tab.currentIndex() == 1 + +# mda = useq.MDASequence( +# channels=["FITC", "DAPI"], +# metadata={ +# PYMMCW_METADATA_KEY: { +# "format": "tensorstore-zarr", +# "save_dir": str(tmp_path), +# "save_name": "t.tensorstore.zarr", +# "should_save": True, +# } +# }, +# ) +# gui._menu_bar._mda.setValue(mda) + +# with qtbot.waitSignal(global_mmcore.mda.events.sequenceStarted): +# gui._menu_bar._mda.run_mda() + +# assert isinstance(gui._menu_bar._mda.writer, TensorStoreHandler) +# assert gui._core_link._viewer_tab.count() == 3 +# assert gui._core_link._viewer_tab.tabText(2) == "t.tensorstore.zarr" + +# # saving tensorstore and MDAViewer datastore should be the same +# assert gui._core_link._mda.writer == gui._core_link._viewer_tab.widget(2)._data +# gui._menu_bar._close_all() diff --git a/tests/test_readers.py b/tests/test_readers.py new file mode 100644 index 00000000..976e1263 --- /dev/null +++ b/tests/test_readers.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +import tifffile +import useq + +# from pymmcore_plus.mda.handlers import TensorStoreHandler +# from pymmcore_widgets._stack_viewer_v2._mda_viewer import MDAViewer +from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY + +# from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS +from micromanager_gui._readers._ome_zarr_reader import OMEZarrReader +from micromanager_gui._readers._tensorstore_zarr_reader import TensorstoreZarrReader +from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler + +if TYPE_CHECKING: + from pathlib import Path + + from pymmcore_plus import CMMCorePlus + from pytestqt.qtbot import QtBot + + +# fmt: off +files = [ + # indexers, expected files, file_to_check, expected shape + ({}, ["p0.tif", "p0.json", "p1.tif", "p1.json"], "p0.tif", (3, 2, 512, 512)), + ({"p": 0}, ["test.tiff", "test.json"], "test.tiff", (3, 2, 512, 512)), + ({"p": 0, "t": 0}, ["test.tiff", "test.json"], "test.tiff", (2, 512, 512)), + # when a tuple is passed, first element is indexers and second is the kwargs + (({"p": 0}, {"p": 0}), ["test.tiff", "test.json"], "test.tiff", (3, 2, 512, 512)), + (({"p": 0}, {"t": 0}), ["test.tiff", "test.json"], "test.tiff", (2, 512, 512)), +] + +MDA = useq.MDASequence( + channels=["FITC", "DAPI"], + stage_positions=[(0, 0), (0, 1)], + time_plan={"loops": 3, "interval": 0.1}, +) +ZARR_META = {"format": "ome-zarr", "save_name": "z.ome.zarr"} +TENSOR_META = { + "format": "tensorstore-zarr", + "save_name": "ts.tensorstore.zarr", +} + +writers = [ + (ZARR_META, "z.ome.zarr", "", OMEZarrReader), + (TENSOR_META, "ts.tensorstore.zarr", _TensorStoreHandler, TensorstoreZarrReader), +] +# fmt: on + + +# NOTE: the tensorstore reader works only if we use the internal _TensorStoreHandler +# TODO: fix the main TensorStoreHandler because it does not write the ".zattrs" +@pytest.mark.parametrize("writers", writers) +@pytest.mark.parametrize("kwargs", [True, False]) +@pytest.mark.parametrize("files", files) +def test_readers( + qtbot: QtBot, + global_mmcore: CMMCorePlus, + tmp_path: Path, + writers: tuple, + files: tuple, + kwargs: bool, +): + meta, name, writer, reader = writers + indexers, expected_files, file_to_check, expected_shape = files + + mda = MDA.replace( + metadata={ + PYMMCW_METADATA_KEY: { + **meta, + "save_dir": str(tmp_path), + "should_save": True, + } + } + ) + + dest = tmp_path / name + writer = writer(path=dest) if writer else dest + with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): + global_mmcore.mda.run(mda, output=writer) + + assert dest.exists() + + w = reader(path=dest) + assert w.store + assert w.sequence + + assert w.isel({"p": 0}).shape == (3, 2, 512, 512) + assert w.isel({"p": 0, "t": 0}).shape == (2, 512, 512) + _, metadata = w.isel({"p": 0}, metadata=True) + assert metadata + + # test saving as tiff + dest = tmp_path / "test" + + if not indexers and "ome.zarr" in name: + return # skipping since the no 'p' index error will be raised + + # if indexers is a tuple, use one as indexers and the other as kwargs + if isinstance(indexers, tuple): + # skip if kwargs is False since we don't want to test it twice + if not kwargs: + return + w.write_tiff(dest, indexers[0], **indexers[1]) + # depends om kwargs (once as dict and once as kwargs) + else: + w.write_tiff(dest, **indexers) if kwargs else w.write_tiff(dest, indexers) + # all files in dest + parent = dest.parent if indexers else dest + dir_files = [f.name for f in parent.iterdir()] + assert all(f in dir_files for f in expected_files) + # open with tifffile and check the shape + with tifffile.TiffFile(parent / file_to_check) as tif: + assert tif.asarray().shape == expected_shape From 475d3597309b04b2ed75079e2e2d4006a3f9ba64 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 5 Jun 2024 16:37:03 -0400 Subject: [PATCH 080/226] test: fix --- tests/test_micromanager_gui.py | 201 --------------------------------- 1 file changed, 201 deletions(-) delete mode 100644 tests/test_micromanager_gui.py diff --git a/tests/test_micromanager_gui.py b/tests/test_micromanager_gui.py deleted file mode 100644 index de99feb2..00000000 --- a/tests/test_micromanager_gui.py +++ /dev/null @@ -1,201 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import useq - -# from pymmcore_plus.mda.handlers import TensorStoreHandler -# from pymmcore_widgets._stack_viewer_v2._mda_viewer import MDAViewer -from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY - -from micromanager_gui import MicroManagerGUI - -# from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS -from micromanager_gui._readers._ome_zarr_reader import OMEZarrReader -from micromanager_gui._readers._tensorstore_zarr_reader import TensorstoreZarrReader -from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler - -if TYPE_CHECKING: - from pathlib import Path - - from pymmcore_plus import CMMCorePlus - from pytestqt.qtbot import QtBot - - -def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): - gui = MicroManagerGUI(mmcore=global_mmcore) - qtbot.addWidget(gui) - assert gui._menu_bar._mda - assert gui._core_link._preview - assert not gui._core_link._preview.isHidden() - assert gui._core_link._viewer_tab.count() == 1 - assert gui._core_link._viewer_tab.tabText(0) == "Preview" - assert gui._core_link._current_viewer is None - assert gui._core_link._mda_running is False - - -def test_ome_zarr_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): - mda = useq.MDASequence( - channels=["FITC", "DAPI"], - stage_positions=[(0, 0), (0, 1)], - time_plan={"loops": 3, "interval": 0.1}, - metadata={ - PYMMCW_METADATA_KEY: { - "format": "ome-zarr", - "save_dir": str(tmp_path), - "save_name": "z.ome.zarr", - "should_save": True, - } - }, - ) - - dest = tmp_path / "z.ome.zarr" - with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): - global_mmcore.mda.run(mda, output=dest) - - assert dest.exists() - - z = OMEZarrReader(dest) - assert z.store - assert z.sequence - assert z.isel({"p": 0}).shape == (3, 2, 512, 512) - assert z.isel({"p": 0, "t": 0}).shape == (2, 512, 512) - - -# NOTE: this works only if we use the internal _TensorStoreHandler -# TODO: fix the main TensorStoreHandler because it does not write the ".zattrs" -def test_tensorstore_reader(qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path): - mda = useq.MDASequence( - channels=["FITC", "DAPI"], - stage_positions=[(0, 0), (0, 1)], - time_plan={"loops": 3, "interval": 0.1}, - metadata={ - PYMMCW_METADATA_KEY: { - "format": "tensorstore-zarr", - "save_dir": str(tmp_path), - "save_name": "ts.tensorstore.zarr", - "should_save": True, - } - }, - ) - - dest = tmp_path / "ts.tensorstore.zarr" - writer = _TensorStoreHandler(path=dest, delete_existing=True) - with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): - global_mmcore.mda.run(mda, output=writer) - - assert dest.exists() - - ts = TensorstoreZarrReader(dest) - assert ts.store - assert ts.sequence - assert ts.isel({"p": 0}).shape == (3, 2, 512, 512) - assert ts.isel({"t": 0}).shape == (2, 2, 512, 512) - assert ts.isel({"p": 0, "t": 0}).shape == (2, 512, 512) - _, metadata = ts.isel({"p": 0}, metadata=True) - assert metadata - - -# def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): -# gui = MicroManagerGUI(mmcore=global_mmcore) -# qtbot.addWidget(gui) -# menu = gui._menu_bar - -# assert len(menu._widgets.keys()) == 2 # MDA and GroupPreset widgets -# for action in menu._widgets_menu.actions(): -# action.trigger() -# assert len(menu._widgets.keys()) == len(WIDGETS) + len(DOCKWIDGETS) - - -# def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): -# gui = MicroManagerGUI(mmcore=global_mmcore) -# qtbot.addWidget(gui) -# menu = gui._menu_bar -# assert gui._core_link._viewer_tab.tabText(0) == "Preview" -# # add a viewer -# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA1") -# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA2") -# assert gui._core_link._viewer_tab.count() == 3 - -# menu._close_all() -# assert gui._core_link._viewer_tab.count() == 1 - -# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA3") -# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA4") -# gui._core_link._viewer_tab.setCurrentIndex(2) -# assert gui._core_link._viewer_tab.count() == 3 - -# menu._close_all_but_current() -# assert gui._core_link._viewer_tab.count() == 2 -# assert gui._core_link._viewer_tab.tabText(0) == "Preview" -# assert gui._core_link._viewer_tab.tabText(1) == "MDA4" - -# menu._close_all() - - -# def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): -# gui = MicroManagerGUI(mmcore=global_mmcore) -# qtbot.addWidget(gui) - -# assert gui._core_link._preview -# assert gui._core_link._preview._image_preview.image is None -# assert not gui._core_link._preview.isHidden() - -# with qtbot.waitSignal(global_mmcore.events.imageSnapped): -# gui._core_link._preview._snap._snap() -# assert gui._core_link._preview._image_preview.image -# assert gui._core_link._preview._image_preview.image._data.shape - - -# def test_live(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): -# gui = MicroManagerGUI(mmcore=global_mmcore) -# qtbot.addWidget(gui) - -# assert gui._core_link._preview -# assert gui._core_link._preview._image_preview.image is None -# assert not gui._core_link._preview.isHidden() - -# with qtbot.waitSignal(global_mmcore.events.continuousSequenceAcquisitionStarted): -# gui._core_link._preview._live._toggle_live_mode() -# assert global_mmcore.isSequenceRunning() -# with qtbot.waitSignal(global_mmcore.events.sequenceAcquisitionStopped): -# gui._core_link._preview._live._toggle_live_mode() -# assert not global_mmcore.isSequenceRunning() - - -# def test_mda_viewer( -# qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path, _run_after_each_test -# ): -# gui = MicroManagerGUI(mmcore=global_mmcore) -# qtbot.addWidget(gui) - -# mda = useq.MDASequence(channels=["DAPI", "FITC"]) -# with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): -# global_mmcore.mda.run(mda) -# assert gui._core_link._viewer_tab.count() == 2 -# assert gui._core_link._viewer_tab.tabText(1) == "MDA Viewer 1" -# assert gui._core_link._viewer_tab.currentIndex() == 1 - -# mda = useq.MDASequence( -# channels=["FITC", "DAPI"], -# metadata={ -# PYMMCW_METADATA_KEY: { -# "format": "tensorstore-zarr", -# "save_dir": str(tmp_path), -# "save_name": "t.tensorstore.zarr", -# "should_save": True, -# } -# }, -# ) -# gui._menu_bar._mda.setValue(mda) - -# with qtbot.waitSignal(global_mmcore.mda.events.sequenceStarted): -# gui._menu_bar._mda.run_mda() - -# assert isinstance(gui._menu_bar._mda.writer, TensorStoreHandler) -# assert gui._core_link._viewer_tab.count() == 3 -# assert gui._core_link._viewer_tab.tabText(2) == "t.tensorstore.zarr" - -# # saving tensorstore and MDAViewer datastore should be the same -# assert gui._core_link._mda.writer == gui._core_link._viewer_tab.widget(2)._data -# gui._menu_bar._close_all() From d946059bdbc13eeb74c17c211a91f9868af0c5b2 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 5 Jun 2024 21:46:55 -0400 Subject: [PATCH 081/226] fix: showMaximized --- src/micromanager_gui/_main_window.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/micromanager_gui/_main_window.py b/src/micromanager_gui/_main_window.py index d9982fe2..3eb467d5 100644 --- a/src/micromanager_gui/_main_window.py +++ b/src/micromanager_gui/_main_window.py @@ -35,9 +35,6 @@ def __init__( self.setWindowTitle("Micro-Manager") - # extend size to fill the screen - self.showMaximized() - # get global CMMCorePlus instance self._mmc = mmcore or CMMCorePlus.instance() @@ -59,6 +56,9 @@ def __init__( # link the MDA viewers self._core_link = CoreViewersLink(self, mmcore=self._mmc) + # extend size to fill the screen + self.showMaximized() + if config is not None: try: self._mmc.unloadAllDevices() From e80b1f6ed256bf9fea7be34c8b57b407fe69be5b Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 5 Jun 2024 17:01:06 -0400 Subject: [PATCH 082/226] test: update --- tests/test_gui.py | 92 ++++++++++++++++------------------------------- 1 file changed, 31 insertions(+), 61 deletions(-) diff --git a/tests/test_gui.py b/tests/test_gui.py index 0d3b73d4..670d9827 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -3,10 +3,10 @@ from typing import TYPE_CHECKING # from pymmcore_plus.mda.handlers import TensorStoreHandler -# from pymmcore_widgets._stack_viewer_v2._mda_viewer import MDAViewer -from micromanager_gui import MicroManagerGUI +from pymmcore_widgets._stack_viewer_v2._mda_viewer import MDAViewer -# from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS +from micromanager_gui import MicroManagerGUI +from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus @@ -25,71 +25,41 @@ def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test assert gui._core_link._mda_running is False -# def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): -# gui = MicroManagerGUI(mmcore=global_mmcore) -# qtbot.addWidget(gui) -# menu = gui._menu_bar - -# assert len(menu._widgets.keys()) == 2 # MDA and GroupPreset widgets -# for action in menu._widgets_menu.actions(): -# action.trigger() -# assert len(menu._widgets.keys()) == len(WIDGETS) + len(DOCKWIDGETS) - - -# def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): -# gui = MicroManagerGUI(mmcore=global_mmcore) -# qtbot.addWidget(gui) -# menu = gui._menu_bar -# assert gui._core_link._viewer_tab.tabText(0) == "Preview" -# # add a viewer -# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA1") -# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA2") -# assert gui._core_link._viewer_tab.count() == 3 - -# menu._close_all() -# assert gui._core_link._viewer_tab.count() == 1 - -# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA3") -# gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA4") -# gui._core_link._viewer_tab.setCurrentIndex(2) -# assert gui._core_link._viewer_tab.count() == 3 - -# menu._close_all_but_current() -# assert gui._core_link._viewer_tab.count() == 2 -# assert gui._core_link._viewer_tab.tabText(0) == "Preview" -# assert gui._core_link._viewer_tab.tabText(1) == "MDA4" - -# menu._close_all() - +def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): + gui = MicroManagerGUI(mmcore=global_mmcore) + qtbot.addWidget(gui) + menu = gui._menu_bar -# def test_snap(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): -# gui = MicroManagerGUI(mmcore=global_mmcore) -# qtbot.addWidget(gui) + assert len(menu._widgets.keys()) == 2 # MDA and GroupPreset widgets + for action in menu._widgets_menu.actions(): + action.trigger() + assert len(menu._widgets.keys()) == len(WIDGETS) + len(DOCKWIDGETS) -# assert gui._core_link._preview -# assert gui._core_link._preview._image_preview.image is None -# assert not gui._core_link._preview.isHidden() -# with qtbot.waitSignal(global_mmcore.events.imageSnapped): -# gui._core_link._preview._snap._snap() -# assert gui._core_link._preview._image_preview.image -# assert gui._core_link._preview._image_preview.image._data.shape +def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): + gui = MicroManagerGUI(mmcore=global_mmcore) + qtbot.addWidget(gui) + menu = gui._menu_bar + assert gui._core_link._viewer_tab.tabText(0) == "Preview" + # add a viewer + gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA1") + gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA2") + assert gui._core_link._viewer_tab.count() == 3 + menu._close_all() + assert gui._core_link._viewer_tab.count() == 1 -# def test_live(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): -# gui = MicroManagerGUI(mmcore=global_mmcore) -# qtbot.addWidget(gui) + gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA3") + gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA4") + gui._core_link._viewer_tab.setCurrentIndex(2) + assert gui._core_link._viewer_tab.count() == 3 -# assert gui._core_link._preview -# assert gui._core_link._preview._image_preview.image is None -# assert not gui._core_link._preview.isHidden() + menu._close_all_but_current() + assert gui._core_link._viewer_tab.count() == 2 + assert gui._core_link._viewer_tab.tabText(0) == "Preview" + assert gui._core_link._viewer_tab.tabText(1) == "MDA4" -# with qtbot.waitSignal(global_mmcore.events.continuousSequenceAcquisitionStarted): -# gui._core_link._preview._live._toggle_live_mode() -# assert global_mmcore.isSequenceRunning() -# with qtbot.waitSignal(global_mmcore.events.sequenceAcquisitionStopped): -# gui._core_link._preview._live._toggle_live_mode() -# assert not global_mmcore.isSequenceRunning() + menu._close_all() # def test_mda_viewer( From 0ae5fdea3e0980d7f8aa71080bc6b776f64e1e0f Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 5 Jun 2024 17:12:22 -0400 Subject: [PATCH 083/226] test: add more --- tests/test_gui.py | 38 ----------------------- tests/test_mda_viewer.py | 66 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 38 deletions(-) create mode 100644 tests/test_mda_viewer.py diff --git a/tests/test_gui.py b/tests/test_gui.py index 670d9827..b19f2baf 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -60,41 +60,3 @@ def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_t assert gui._core_link._viewer_tab.tabText(1) == "MDA4" menu._close_all() - - -# def test_mda_viewer( -# qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path, _run_after_each_test -# ): -# gui = MicroManagerGUI(mmcore=global_mmcore) -# qtbot.addWidget(gui) - -# mda = useq.MDASequence(channels=["DAPI", "FITC"]) -# with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): -# global_mmcore.mda.run(mda) -# assert gui._core_link._viewer_tab.count() == 2 -# assert gui._core_link._viewer_tab.tabText(1) == "MDA Viewer 1" -# assert gui._core_link._viewer_tab.currentIndex() == 1 - -# mda = useq.MDASequence( -# channels=["FITC", "DAPI"], -# metadata={ -# PYMMCW_METADATA_KEY: { -# "format": "tensorstore-zarr", -# "save_dir": str(tmp_path), -# "save_name": "t.tensorstore.zarr", -# "should_save": True, -# } -# }, -# ) -# gui._menu_bar._mda.setValue(mda) - -# with qtbot.waitSignal(global_mmcore.mda.events.sequenceStarted): -# gui._menu_bar._mda.run_mda() - -# assert isinstance(gui._menu_bar._mda.writer, TensorStoreHandler) -# assert gui._core_link._viewer_tab.count() == 3 -# assert gui._core_link._viewer_tab.tabText(2) == "t.tensorstore.zarr" - -# # saving tensorstore and MDAViewer datastore should be the same -# assert gui._core_link._mda.writer == gui._core_link._viewer_tab.widget(2)._data -# gui._menu_bar._close_all() diff --git a/tests/test_mda_viewer.py b/tests/test_mda_viewer.py new file mode 100644 index 00000000..17f1bf9e --- /dev/null +++ b/tests/test_mda_viewer.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import useq + +# from pymmcore_plus.mda.handlers import TensorStoreHandler +# from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY +from micromanager_gui import MicroManagerGUI + +if TYPE_CHECKING: + from pathlib import Path + + from pymmcore_plus import CMMCorePlus + from pytestqt.qtbot import QtBot + + +def test_mda_viewer_no_saving( + qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path, _run_after_each_test +): + gui = MicroManagerGUI(mmcore=global_mmcore) + qtbot.addWidget(gui) + + mda = useq.MDASequence(channels=["DAPI", "FITC"]) + with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): + global_mmcore.mda.run(mda) + assert gui._core_link._viewer_tab.count() == 2 + assert gui._core_link._viewer_tab.tabText(1) == "MDA Viewer 1" + assert gui._core_link._viewer_tab.currentIndex() == 1 + + with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): + global_mmcore.mda.run(mda) + assert gui._core_link._viewer_tab.count() == 3 + assert gui._core_link._viewer_tab.tabText(1) == "MDA Viewer 2" + assert gui._core_link._viewer_tab.currentIndex() == 2 + + +# def test_mda_viewer_saving( +# qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path, _run_after_each_test +# ): +# gui = MicroManagerGUI(mmcore=global_mmcore) +# qtbot.addWidget(gui) + +# mda = useq.MDASequence( +# channels=["FITC", "DAPI"], +# metadata={ +# PYMMCW_METADATA_KEY: { +# "format": "tensorstore-zarr", +# "save_dir": str(tmp_path), +# "save_name": "t.tensorstore.zarr", +# "should_save": True, +# } +# }, +# ) +# gui._menu_bar._mda.setValue(mda) + +# with qtbot.waitSignal(global_mmcore.mda.events.sequenceStarted): +# gui._menu_bar._mda.run_mda() + +# assert isinstance(gui._menu_bar._mda.writer, TensorStoreHandler) +# assert gui._core_link._viewer_tab.count() == 2 +# assert gui._core_link._viewer_tab.tabText(1) == "t.tensorstore.zarr" + +# # saving tensorstore and MDAViewer datastore should be the same +# assert gui._core_link._mda.writer == gui._core_link._viewer_tab.widget(1)._data +# gui._menu_bar._close_all() From ea50645420966dc12e43772d78b468600389e8ac Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 5 Jun 2024 17:13:02 -0400 Subject: [PATCH 084/226] test: fix --- tests/test_mda_viewer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_mda_viewer.py b/tests/test_mda_viewer.py index 17f1bf9e..d2734b0f 100644 --- a/tests/test_mda_viewer.py +++ b/tests/test_mda_viewer.py @@ -31,7 +31,7 @@ def test_mda_viewer_no_saving( with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): global_mmcore.mda.run(mda) assert gui._core_link._viewer_tab.count() == 3 - assert gui._core_link._viewer_tab.tabText(1) == "MDA Viewer 2" + assert gui._core_link._viewer_tab.tabText(2) == "MDA Viewer 2" assert gui._core_link._viewer_tab.currentIndex() == 2 From b0f2ad949d700a2befbbe6b1ced145d8b53c19f8 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 5 Jun 2024 21:42:17 -0400 Subject: [PATCH 085/226] test: test_mda_viewer_saving --- src/micromanager_gui/_core_link.py | 3 -- tests/test_mda_viewer.py | 82 ++++++++++++++++++------------ 2 files changed, 50 insertions(+), 35 deletions(-) diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index 477c16d3..49277c5a 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -132,9 +132,6 @@ def _on_sequence_finished(self, sequence: useq.MDASequence) -> None: self._mda_running = False - # reset the mda writer to None - self._mda.writer = None - if self._current_viewer is None: return diff --git a/tests/test_mda_viewer.py b/tests/test_mda_viewer.py index d2734b0f..d05da9f5 100644 --- a/tests/test_mda_viewer.py +++ b/tests/test_mda_viewer.py @@ -1,11 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast +import pytest import useq +from pymmcore_plus.mda.handlers import OMETiffWriter, OMEZarrWriter, TensorStoreHandler +from pymmcore_widgets._stack_viewer_v2 import MDAViewer +from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY -# from pymmcore_plus.mda.handlers import TensorStoreHandler -# from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY from micromanager_gui import MicroManagerGUI if TYPE_CHECKING: @@ -35,32 +37,48 @@ def test_mda_viewer_no_saving( assert gui._core_link._viewer_tab.currentIndex() == 2 -# def test_mda_viewer_saving( -# qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path, _run_after_each_test -# ): -# gui = MicroManagerGUI(mmcore=global_mmcore) -# qtbot.addWidget(gui) - -# mda = useq.MDASequence( -# channels=["FITC", "DAPI"], -# metadata={ -# PYMMCW_METADATA_KEY: { -# "format": "tensorstore-zarr", -# "save_dir": str(tmp_path), -# "save_name": "t.tensorstore.zarr", -# "should_save": True, -# } -# }, -# ) -# gui._menu_bar._mda.setValue(mda) - -# with qtbot.waitSignal(global_mmcore.mda.events.sequenceStarted): -# gui._menu_bar._mda.run_mda() - -# assert isinstance(gui._menu_bar._mda.writer, TensorStoreHandler) -# assert gui._core_link._viewer_tab.count() == 2 -# assert gui._core_link._viewer_tab.tabText(1) == "t.tensorstore.zarr" - -# # saving tensorstore and MDAViewer datastore should be the same -# assert gui._core_link._mda.writer == gui._core_link._viewer_tab.widget(1)._data -# gui._menu_bar._close_all() +writers = [ + ("tensorstore-zarr", "ts.tensorstore.zarr", TensorStoreHandler), + ("ome-tiff", "t.ome.tiff", OMETiffWriter), + ("ome-zarr", "z.ome.zarr", OMEZarrWriter), +] + + +@pytest.mark.skip("run only locally, for some reason sometimes it fails on CI.") +@pytest.mark.parametrize("writers", writers) +def test_mda_viewer_saving( + qtbot: QtBot, + global_mmcore: CMMCorePlus, + tmp_path: Path, + writers: tuple[str, str, type], + _run_after_each_test, +): + gui = MicroManagerGUI(mmcore=global_mmcore) + qtbot.addWidget(gui) + + file_format, save_name, writer = writers + + mda = useq.MDASequence( + channels=["FITC", "DAPI"], + metadata={ + PYMMCW_METADATA_KEY: { + "format": file_format, + "save_dir": str(tmp_path), + "save_name": save_name, + "should_save": True, + } + }, + ) + gui._menu_bar._mda.setValue(mda) + + with qtbot.waitSignal(global_mmcore.mda.events.sequenceStarted): + gui._menu_bar._mda.run_mda() + + assert isinstance(gui._menu_bar._mda.writer, writer) + assert gui._core_link._viewer_tab.count() == 2 + assert gui._core_link._viewer_tab.tabText(1) == save_name + + # saving datastore and MDAViewer datastore should be the same + viewer = cast(MDAViewer, gui._core_link._viewer_tab.widget(1)) + assert viewer.data == gui._core_link._mda.writer + gui._menu_bar._close_all() From 74963796761bf82110a2e0164f5144077c77632c Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 5 Jun 2024 21:55:08 -0400 Subject: [PATCH 086/226] test: rename --- tests/{test_readers.py => test_readers_writers.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_readers.py => test_readers_writers.py} (100%) diff --git a/tests/test_readers.py b/tests/test_readers_writers.py similarity index 100% rename from tests/test_readers.py rename to tests/test_readers_writers.py From 20aa326ea37ac5cf35acafa1875aa14335d91718 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 6 Jun 2024 15:12:46 -0400 Subject: [PATCH 087/226] fix: dependencies --- .pre-commit-config.yaml | 5 ++--- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6386ad29..16e32c2a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,6 +26,5 @@ repos: hooks: - id: mypy files: "^src/" - # # you have to add the things you want to type check against here - # additional_dependencies: - # - numpy + additional_dependencies: + - pymmcore-plus@git+https://github.com/fdrgsp/pymmcore-plus.git # -> when released pymmcore-plus >=0.9.6 diff --git a/pyproject.toml b/pyproject.toml index 2555b213..f2248a35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ "pymmcore-widgets@git+https://github.com/fdrgsp/pymmcore-widgets.git@viewer_v2_and_tensorstore", - "pymmcore-plus@git+https://github.com/fdrgsp/pymmcore-plus.git@add_tensorstore_to_runner", + "pymmcore-plus@git+https://github.com/fdrgsp/pymmcore-plus.git", "qtpy", "vispy", "zarr", From b783248d74c0606f0f84a0015abe6dcc2d8e68f5 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 6 Jun 2024 15:14:02 -0400 Subject: [PATCH 088/226] fix: dependencies --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f2248a35..cc2db65f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ "pymmcore-widgets@git+https://github.com/fdrgsp/pymmcore-widgets.git@viewer_v2_and_tensorstore", - "pymmcore-plus@git+https://github.com/fdrgsp/pymmcore-plus.git", + "pymmcore-plus@git+https://github.com/fdrgsp/pymmcore-plus.git", # -> when released pymmcore-plus >=0.9.6 "qtpy", "vispy", "zarr", From dd12850e5e00fee213a5dd977e1b6208cfd8e5d6 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 6 Jun 2024 18:43:02 -0400 Subject: [PATCH 089/226] fix: readme --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 3ab11108..f8b07afa 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ A Micro-Manager GUI based on [pymmcore-widgets](https://pymmcore-plus.github.io/ Screenshot 2024-06-03 at 11 49 45 PM +## Python version + +The package is tested on Python 3.10 and 3.11/ + ## Installation ```bash From 67addf6e1d2382a8e70b7977424641f92452f631 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 6 Jun 2024 18:43:58 -0400 Subject: [PATCH 090/226] fix: readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f8b07afa..0f3d8dd6 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A Micro-Manager GUI based on [pymmcore-widgets](https://pymmcore-plus.github.io/ ## Python version -The package is tested on Python 3.10 and 3.11/ +The package is tested on Python 3.10 and 3.11> ## Installation From 7d6d948b9a279030c2229f3cee90928c1311c43c Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 6 Jun 2024 18:44:11 -0400 Subject: [PATCH 091/226] fix: readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0f3d8dd6..86f0e80c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A Micro-Manager GUI based on [pymmcore-widgets](https://pymmcore-plus.github.io/ ## Python version -The package is tested on Python 3.10 and 3.11> +The package is tested on Python 3.10 and 3.11. ## Installation From 35065dc2c7f5dad1bd7d98fe979da4da24b8f0ef Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 10 Jun 2024 09:12:53 -0400 Subject: [PATCH 092/226] test: update --- tests/test_mda_viewer.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tests/test_mda_viewer.py b/tests/test_mda_viewer.py index d05da9f5..c7080756 100644 --- a/tests/test_mda_viewer.py +++ b/tests/test_mda_viewer.py @@ -1,10 +1,11 @@ from __future__ import annotations from typing import TYPE_CHECKING, cast +from unittest.mock import patch import pytest import useq -from pymmcore_plus.mda.handlers import OMETiffWriter, OMEZarrWriter, TensorStoreHandler +from pymmcore_plus.mda.handlers import OMETiffWriter from pymmcore_widgets._stack_viewer_v2 import MDAViewer from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY @@ -24,27 +25,27 @@ def test_mda_viewer_no_saving( qtbot.addWidget(gui) mda = useq.MDASequence(channels=["DAPI", "FITC"]) - with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): - global_mmcore.mda.run(mda) + + # simulate that the core run_mda method was called + gui._core_link._on_sequence_started(sequence=mda) assert gui._core_link._viewer_tab.count() == 2 assert gui._core_link._viewer_tab.tabText(1) == "MDA Viewer 1" assert gui._core_link._viewer_tab.currentIndex() == 1 - - with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): - global_mmcore.mda.run(mda) + # simulate that the core run_mda method was called again + gui._core_link._on_sequence_started(sequence=mda) assert gui._core_link._viewer_tab.count() == 3 assert gui._core_link._viewer_tab.tabText(2) == "MDA Viewer 2" assert gui._core_link._viewer_tab.currentIndex() == 2 writers = [ - ("tensorstore-zarr", "ts.tensorstore.zarr", TensorStoreHandler), + # ("tensorstore-zarr", "ts.tensorstore.zarr", TensorStoreHandler), ("ome-tiff", "t.ome.tiff", OMETiffWriter), - ("ome-zarr", "z.ome.zarr", OMEZarrWriter), + # ("ome-zarr", "z.ome.zarr", OMEZarrWriter), ] -@pytest.mark.skip("run only locally, for some reason sometimes it fails on CI.") +# @pytest.mark.skip("run only locally, for some reason sometimes it fails on CI.") @pytest.mark.parametrize("writers", writers) def test_mda_viewer_saving( qtbot: QtBot, @@ -71,8 +72,16 @@ def test_mda_viewer_saving( ) gui._menu_bar._mda.setValue(mda) - with qtbot.waitSignal(global_mmcore.mda.events.sequenceStarted): + # patch the run_mda method to avoid running the MDA sequence + def _run_mda(seq): + print("Running MDA") + return True + + # set the writer attribute of the MDAWidget without running the MDA sequence + with patch.object(global_mmcore, "run_mda", _run_mda): gui._menu_bar._mda.run_mda() + # simulate that the core run_mda method was called + gui._core_link._on_sequence_started(sequence=mda) assert isinstance(gui._menu_bar._mda.writer, writer) assert gui._core_link._viewer_tab.count() == 2 @@ -81,4 +90,5 @@ def test_mda_viewer_saving( # saving datastore and MDAViewer datastore should be the same viewer = cast(MDAViewer, gui._core_link._viewer_tab.widget(1)) assert viewer.data == gui._core_link._mda.writer - gui._menu_bar._close_all() + + # # gui._menu_bar._close_all() From 163b90e998156b3d5cf38de7f54a30de7a000f93 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 10 Jun 2024 10:24:13 -0400 Subject: [PATCH 093/226] test: update --- tests/test_mda_viewer.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/test_mda_viewer.py b/tests/test_mda_viewer.py index c7080756..03765a28 100644 --- a/tests/test_mda_viewer.py +++ b/tests/test_mda_viewer.py @@ -5,7 +5,7 @@ import pytest import useq -from pymmcore_plus.mda.handlers import OMETiffWriter +from pymmcore_plus.mda.handlers import OMETiffWriter, OMEZarrWriter, TensorStoreHandler from pymmcore_widgets._stack_viewer_v2 import MDAViewer from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY @@ -39,13 +39,12 @@ def test_mda_viewer_no_saving( writers = [ - # ("tensorstore-zarr", "ts.tensorstore.zarr", TensorStoreHandler), + ("tensorstore-zarr", "ts.tensorstore.zarr", TensorStoreHandler), ("ome-tiff", "t.ome.tiff", OMETiffWriter), - # ("ome-zarr", "z.ome.zarr", OMEZarrWriter), + ("ome-zarr", "z.ome.zarr", OMEZarrWriter), ] -# @pytest.mark.skip("run only locally, for some reason sometimes it fails on CI.") @pytest.mark.parametrize("writers", writers) def test_mda_viewer_saving( qtbot: QtBot, @@ -90,5 +89,3 @@ def _run_mda(seq): # saving datastore and MDAViewer datastore should be the same viewer = cast(MDAViewer, gui._core_link._viewer_tab.widget(1)) assert viewer.data == gui._core_link._mda.writer - - # # gui._menu_bar._close_all() From fc5cb5ac98f1cf3d998b82fd5a5a8d708e363faa Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Fri, 7 Jun 2024 15:53:40 -0400 Subject: [PATCH 094/226] feat: add axix labels to tensorstore reader --- .../_readers/_tensorstore_zarr_reader.py | 25 +++++++++++++------ src/micromanager_gui/_widgets/_mda_widget.py | 2 +- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py index b8a169a7..8439773a 100644 --- a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py +++ b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py @@ -1,6 +1,6 @@ import json from pathlib import Path -from typing import Any, Mapping +from typing import Any, Mapping, cast import numpy as np import tensorstore as ts @@ -45,12 +45,21 @@ def __init__(self, path: str | Path): "kvstore": {"driver": "file", "path": str(self._path)}, } - self._store = ts.open(spec) + _store = ts.open(spec).result() self._metadata: dict = {} - if metadata_json := self.store.kvstore.read(".zattrs").result().value: + if metadata_json := _store.kvstore.read(".zattrs").result().value: self._metadata = json.loads(metadata_json) + # set the axis labels + if self.sequence is not None: + # not sure if is x, y or y, x + axis_order = [*self.sequence.axis_order, "y", "x"] + if len(axis_order) > 2: + _store = _store[ts.d[:].label[*axis_order]] + + self._store = _store + @property def path(self) -> Path: """Return the path.""" @@ -59,10 +68,10 @@ def path(self) -> Path: @property def store(self) -> ts.TensorStore: """Return the tensorstore.""" - return self._store.result() + return self._store @property - def sequence(self) -> useq.MDASequence: + def sequence(self) -> useq.MDASequence | None: seq = self._metadata.get("useq_MDASequence") return useq.MDASequence(**json.loads(seq)) if seq is not None else None @@ -73,7 +82,7 @@ def isel( indexers: Mapping[str, int] | None = None, metadata: bool = False, **kwargs: Any, - ) -> np.ndarray | tuple[np.ndarray, dict]: + ) -> np.ndarray | tuple[np.ndarray, list[dict]]: """Select data from the array. Parameters @@ -102,7 +111,7 @@ def isel( ) index = self._get_axis_index(indexers) - data = self.store[index].read().result().squeeze() + data = cast(np.ndarray, self.store[index].read().result().squeeze()) if metadata: meta = self._get_metadata_from_index(indexers) return data, meta @@ -153,6 +162,8 @@ def write_tiff( dest.write_text(json.dumps(metadata)) else: + if self.sequence is None: + raise ValueError("No 'useq.MDASequence' found in the metadata!") if pos := len(self.sequence.stage_positions): if not Path(path).exists(): Path(path).mkdir(parents=True, exist_ok=False) diff --git a/src/micromanager_gui/_widgets/_mda_widget.py b/src/micromanager_gui/_widgets/_mda_widget.py index 505bd005..eeed8964 100644 --- a/src/micromanager_gui/_widgets/_mda_widget.py +++ b/src/micromanager_gui/_widgets/_mda_widget.py @@ -44,7 +44,7 @@ def __init__( # writer for saving the MDA sequence. This is used by the MDAViewer to set its # internal datastore. If _writer is None, the MDAViewer will use its default # internal datastore. - self.writer: OMETiffWriter | OMETiffWriter | TensorStoreHandler | None = None + self.writer: OMETiffWriter | OMEZarrWriter | TensorStoreHandler | None = None # setContentsMargins pos_layout = cast("QVBoxLayout", self.stage_positions.layout()) From ec97e5ef0775390e38970947cfda6b32b0a12005 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Fri, 7 Jun 2024 16:18:00 -0400 Subject: [PATCH 095/226] test: fix --- .../_readers/_tensorstore_zarr_reader.py | 10 +++++++++- tests/test_readers_writers.py | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py index 8439773a..ea5a8ab8 100644 --- a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py +++ b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py @@ -1,4 +1,5 @@ import json +import warnings from pathlib import Path from typing import Any, Mapping, cast @@ -56,7 +57,14 @@ def __init__(self, path: str | Path): # not sure if is x, y or y, x axis_order = [*self.sequence.axis_order, "y", "x"] if len(axis_order) > 2: - _store = _store[ts.d[:].label[*axis_order]] + try: + _store = _store[ts.d[:].label[*axis_order]] + except IndexError as e: + warnings.warn( + f"Error setting the axis labels: {e}." + "`axis_order`: {axis_order}, `shape`: {_store.shape}.", + stacklevel=2, + ) self._store = _store diff --git a/tests/test_readers_writers.py b/tests/test_readers_writers.py index 976e1263..c2fa2097 100644 --- a/tests/test_readers_writers.py +++ b/tests/test_readers_writers.py @@ -34,6 +34,7 @@ ] MDA = useq.MDASequence( + axis_order=["p", "t", "c"], channels=["FITC", "DAPI"], stage_positions=[(0, 0), (0, 1)], time_plan={"loops": 3, "interval": 0.1}, From c101f7c3e4184d7045dc830cb2b4d16d75e7cc2f Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Fri, 7 Jun 2024 16:18:27 -0400 Subject: [PATCH 096/226] fix: annotations --- src/micromanager_gui/_readers/_tensorstore_zarr_reader.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py index ea5a8ab8..f6f05fc9 100644 --- a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py +++ b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import warnings from pathlib import Path From 979e67da7ec6fac2b5547b9985ccaebf484217f9 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Fri, 7 Jun 2024 16:26:47 -0400 Subject: [PATCH 097/226] fix: use tuple --- src/micromanager_gui/_readers/_tensorstore_zarr_reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py index f6f05fc9..f1c20dad 100644 --- a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py +++ b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py @@ -57,7 +57,7 @@ def __init__(self, path: str | Path): # set the axis labels if self.sequence is not None: # not sure if is x, y or y, x - axis_order = [*self.sequence.axis_order, "y", "x"] + axis_order = (*self.sequence.axis_order, "y", "x") if len(axis_order) > 2: try: _store = _store[ts.d[:].label[*axis_order]] From b2f8fc1e68c44fec7ad96f22c0725ebc39bc6de9 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Fri, 7 Jun 2024 16:29:24 -0400 Subject: [PATCH 098/226] fix: update --- src/micromanager_gui/_readers/_ome_zarr_reader.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/micromanager_gui/_readers/_ome_zarr_reader.py b/src/micromanager_gui/_readers/_ome_zarr_reader.py index 0fc6c1f1..e2642c8d 100644 --- a/src/micromanager_gui/_readers/_ome_zarr_reader.py +++ b/src/micromanager_gui/_readers/_ome_zarr_reader.py @@ -4,13 +4,13 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Mapping, cast +import numpy as np import useq import zarr from tifffile import imwrite from tqdm import tqdm if TYPE_CHECKING: - import numpy as np from zarr.hierarchy import Group EVENT = "Event" @@ -82,7 +82,7 @@ def isel( indexers: Mapping[str, int] | None = None, metadata: bool = False, **kwargs: Any, - ) -> np.ndarray | tuple[np.ndarray, dict]: + ) -> np.ndarray | tuple[np.ndarray, list[dict]]: """Select data from the array. Parameters @@ -117,7 +117,7 @@ def isel( pos_key = f"p{indexers.get('p', 0)}" index = self._get_axis_index(indexers, pos_key) - data = self.store[pos_key][index].squeeze() + data = cast(np.ndarray, self.store[pos_key][index].squeeze()) if metadata: meta = self._get_metadata_from_index(indexers, pos_key) return data, meta From a152482356aacaafe39c4a5c3475ac288c685281 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Fri, 7 Jun 2024 16:37:11 -0400 Subject: [PATCH 099/226] fix: label --- src/micromanager_gui/_readers/_tensorstore_zarr_reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py index f1c20dad..16cfc76a 100644 --- a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py +++ b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py @@ -60,7 +60,7 @@ def __init__(self, path: str | Path): axis_order = (*self.sequence.axis_order, "y", "x") if len(axis_order) > 2: try: - _store = _store[ts.d[:].label[*axis_order]] + _store = _store[ts.d[:].label[axis_order]] except IndexError as e: warnings.warn( f"Error setting the axis labels: {e}." From 5f665a96562f2f5b6dd89add53baf3985f09a947 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 10 Jun 2024 10:57:06 -0400 Subject: [PATCH 100/226] fix: update pymmcore-plus version --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 16e32c2a..5b2cf6c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,4 +27,4 @@ repos: - id: mypy files: "^src/" additional_dependencies: - - pymmcore-plus@git+https://github.com/fdrgsp/pymmcore-plus.git # -> when released pymmcore-plus >=0.9.6 + - pymmcore-plus >=0.10.0 diff --git a/pyproject.toml b/pyproject.toml index cc2db65f..bf698c3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ "pymmcore-widgets@git+https://github.com/fdrgsp/pymmcore-widgets.git@viewer_v2_and_tensorstore", - "pymmcore-plus@git+https://github.com/fdrgsp/pymmcore-plus.git", # -> when released pymmcore-plus >=0.9.6 + "pymmcore-plus >=0.10.0", "qtpy", "vispy", "zarr", From 8af403a747a9ccb84a3bb218ec1c1a3dd35e2a85 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 10 Jun 2024 16:46:11 -0400 Subject: [PATCH 101/226] fix: update stage widget --- .../_widgets/_stage_control.py | 120 ++++++++---------- 1 file changed, 56 insertions(+), 64 deletions(-) diff --git a/src/micromanager_gui/_widgets/_stage_control.py b/src/micromanager_gui/_widgets/_stage_control.py index a3352532..682e6d84 100644 --- a/src/micromanager_gui/_widgets/_stage_control.py +++ b/src/micromanager_gui/_widgets/_stage_control.py @@ -1,16 +1,55 @@ from __future__ import annotations -from typing import cast +from typing import TYPE_CHECKING from pymmcore_plus import CMMCorePlus, DeviceType from pymmcore_widgets import StageWidget -from qtpy.QtCore import QMimeData, Qt -from qtpy.QtGui import QDrag, QDragEnterEvent, QDropEvent, QMouseEvent -from qtpy.QtWidgets import QGroupBox, QHBoxLayout, QSizePolicy, QWidget +from qtpy.QtWidgets import ( + QGridLayout, + QGroupBox, + QHBoxLayout, + QMenu, + QSizePolicy, + QWidget, +) + +if TYPE_CHECKING: + from qtpy.QtGui import QWheelEvent STAGE_DEVICES = {DeviceType.Stage, DeviceType.XYStage} +class Group(QGroupBox): + def __init__(self, name: str) -> None: + super().__init__(name) + self._name = name + + self.setLayout(QHBoxLayout()) + self.layout().setContentsMargins(0, 0, 0, 0) + self.layout().setSpacing(0) + + self.setSizePolicy( + QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + ) + + +class Stage(StageWidget): + """Stage control widget with wheel event for z-axis control.""" + + def __init__(self, device: str) -> None: + super().__init__(device=device) + + def wheelEvent(self, event: QWheelEvent) -> None: + if self._dtype != DeviceType.Stage: + return + delta = event.angleDelta().y() + increment = self._step.value() + if delta > 0: + self._move_stage(0, increment) + elif delta < 0: + self._move_stage(0, -increment) + + class _StagesControlWidget(QWidget): """A widget to control all the XY and Z loaded stages.""" @@ -19,10 +58,13 @@ def __init__( ) -> None: super().__init__(parent=parent) - self.setAcceptDrops(True) - self.setLayout(QHBoxLayout()) - self.layout().setContentsMargins(5, 5, 5, 5) - self.layout().setSpacing(5) + self._stage_wdgs: list[Group] = [] + + self._context_menu = QMenu(self) + + self._layout = QGridLayout(self) + self._layout.setContentsMargins(5, 5, 5, 5) + self._layout.setSpacing(5) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) @@ -37,17 +79,17 @@ def _on_cfg_loaded(self) -> None: ) stage_dev_list = list(self._mmc.getLoadedDevicesOfType(DeviceType.XYStage)) stage_dev_list.extend(iter(self._mmc.getLoadedDevicesOfType(DeviceType.Stage))) - for stage_dev in stage_dev_list: + for idx, stage_dev in enumerate(stage_dev_list): if self._mmc.getDeviceType(stage_dev) is DeviceType.XYStage: - bx = _DragGroupBox("XY Control") + bx = Group("XY Control") elif self._mmc.getDeviceType(stage_dev) is DeviceType.Stage: - bx = _DragGroupBox("Z Control") + bx = Group("Z Control") else: continue - bx.setLayout(QHBoxLayout()) + self._stage_wdgs.append(bx) bx.setSizePolicy(sizepolicy) - bx.layout().addWidget(StageWidget(device=stage_dev)) - self.layout().addWidget(bx) + bx.layout().addWidget(Stage(device=stage_dev)) + self._layout.addWidget(bx, idx // 2, idx % 2) self.resize(self.sizeHint()) def _clear(self) -> None: @@ -56,53 +98,3 @@ def _clear(self) -> None: if wdg := item.widget(): wdg.setParent(QWidget()) wdg.deleteLater() - - def dragEnterEvent(self, event: QDragEnterEvent) -> None: - event.accept() - - def dropEvent(self, event: QDropEvent) -> None: - pos = event.pos() - - wdgs: list[tuple[int, _DragGroupBox, int, int]] = [] - zones: list[tuple[int, int]] = [] - for i in range(self.layout().count()): - wdg = cast(_DragGroupBox, self.layout().itemAt(i).widget()) - wdgs.append((i, wdg, wdg.x(), wdg.x() + wdg.width())) - zones.append((wdg.x(), wdg.x() + wdg.width())) - - for idx, w, _, _ in wdgs: - if not w.start_pos: - continue - - try: - curr_idx = next( - ( - i - for i, z in enumerate(zones) - if pos.x() >= z[0] and pos.x() <= z[1] - ) - ) - except StopIteration: - break - - if curr_idx == idx: - w.start_pos = 0 - break - cast(QHBoxLayout, self.layout()).insertWidget(curr_idx, w) - w.start_pos = 0 - break - event.accept() - - -class _DragGroupBox(QGroupBox): - def __init__(self, name: str, start_pos: int = 0) -> None: - super().__init__() - self._name = name - self.start_pos = start_pos - - def mouseMoveEvent(self, event: QMouseEvent) -> None: - drag = QDrag(self) - mime = QMimeData() - drag.setMimeData(mime) - self.start_pos = event.pos().x() - drag.exec_(Qt.DropAction.MoveAction) From d3a7f4527f9f817dfa6df6437943b7828792457c Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 13 Jun 2024 16:29:00 -0400 Subject: [PATCH 102/226] fix: pyproject --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5b2cf6c5..0d51c0a9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,4 +27,4 @@ repos: - id: mypy files: "^src/" additional_dependencies: - - pymmcore-plus >=0.10.0 + - pymmcore-plus >=0.10.2 diff --git a/pyproject.toml b/pyproject.toml index bf698c3a..c327b770 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ "pymmcore-widgets@git+https://github.com/fdrgsp/pymmcore-widgets.git@viewer_v2_and_tensorstore", - "pymmcore-plus >=0.10.0", + "pymmcore-plus >=0.10.2", "qtpy", "vispy", "zarr", From 0b80e55dc39ff91f432bb469703d711bc8e5cee8 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 19 Jun 2024 13:01:34 -0400 Subject: [PATCH 103/226] fix: __future__ --- src/micromanager_gui/_main_window.py | 7 ++++++- src/micromanager_gui/_menubar/_menubar.py | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/micromanager_gui/_main_window.py b/src/micromanager_gui/_main_window.py index 3eb467d5..eb8d1f89 100644 --- a/src/micromanager_gui/_main_window.py +++ b/src/micromanager_gui/_main_window.py @@ -1,9 +1,11 @@ +from __future__ import annotations + from pathlib import Path +from typing import TYPE_CHECKING from warnings import warn from pymmcore_plus import CMMCorePlus from pymmcore_widgets._stack_viewer_v2._mda_viewer import StackViewer -from qtpy.QtGui import QCloseEvent, QDragEnterEvent, QDropEvent from qtpy.QtWidgets import ( QGridLayout, QMainWindow, @@ -19,6 +21,9 @@ from ._toolbar._shutters_toolbar import _ShuttersToolbar from ._toolbar._snap_live import _SnapLive +if TYPE_CHECKING: + from qtpy.QtGui import QCloseEvent, QDragEnterEvent, QDropEvent + class MicroManagerGUI(QMainWindow): """Micro-Manager minimal GUI.""" diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index 145e132d..2230d04e 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import warnings from typing import TYPE_CHECKING, cast @@ -75,7 +77,7 @@ class _MenuBar(QMenuBar): """ def __init__( - self, parent: "MicroManagerGUI", *, mmcore: CMMCorePlus | None = None + self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None ) -> None: super().__init__(parent) self._main_window = parent From 6622283aa5b945dc93085ee4c9050b5a690255e9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 20:20:36 +0000 Subject: [PATCH 104/226] ci(pre-commit.ci): autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/crate-ci/typos: v1.22.0 → v1.22.9](https://github.com/crate-ci/typos/compare/v1.22.0...v1.22.9) - [github.com/astral-sh/ruff-pre-commit: v0.4.7 → v0.5.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.7...v0.5.0) - [github.com/pre-commit/mirrors-mypy: v1.10.0 → v1.10.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.10.0...v1.10.1) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0d51c0a9..0848331b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,12 +5,12 @@ ci: repos: - repo: https://github.com/crate-ci/typos - rev: v1.22.0 + rev: v1.22.9 hooks: - id: typos - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.4.7 + rev: v0.5.0 hooks: - id: ruff args: [--fix, --unsafe-fixes] @@ -22,7 +22,7 @@ repos: - id: validate-pyproject - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.0 + rev: v1.10.1 hooks: - id: mypy files: "^src/" From 6b290f78b9d2601ddeb3d7698391425bc190fe56 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 2 Jul 2024 20:31:19 -0400 Subject: [PATCH 105/226] fix: prevent crash on exception --- pyproject.toml | 4 ++-- src/micromanager_gui/__main__.py | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c327b770..a142de6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,8 +61,8 @@ homepage = "https://github.com/fdrgsp/micromanager-gui" repository = "https://github.com/fdrgsp/micromanager-gui" # same as console_scripts entry point -# [project.scripts] -# spam-cli = "spam:main_cli" +[project.scripts] +mmgui = "micromanager_gui.__main__:main" # Entry points # https://peps.python.org/pep-0621/#entry-points diff --git a/src/micromanager_gui/__main__.py b/src/micromanager_gui/__main__.py index 821f5839..77fd9989 100644 --- a/src/micromanager_gui/__main__.py +++ b/src/micromanager_gui/__main__.py @@ -2,12 +2,16 @@ import argparse import sys -from typing import Sequence +import traceback +from typing import TYPE_CHECKING, Sequence from qtpy.QtWidgets import QApplication from micromanager_gui import MicroManagerGUI +if TYPE_CHECKING: + from types import TracebackType + def main(args: Sequence[str] | None = None) -> None: """Run the Micro-Manager GUI.""" @@ -28,8 +32,20 @@ def main(args: Sequence[str] | None = None) -> None: app = QApplication([]) win = MicroManagerGUI(config=parsed_args.config) win.show() + + sys.excepthook = _our_excepthook app.exec_() +def _our_excepthook( + type: type[BaseException], value: BaseException, tb: TracebackType | None +): + """Excepthook that prints the traceback to the console. + + By default, Qt's excepthook raises sys.exit(), which is not what we want. + """ + traceback.print_exception(type, value, tb) + + if __name__ == "__main__": main() From ab3d7eb78a54cdf2a440f293cfc8d1f75f90d2be Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 2 Jul 2024 20:32:09 -0400 Subject: [PATCH 106/226] lint --- src/micromanager_gui/__main__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/micromanager_gui/__main__.py b/src/micromanager_gui/__main__.py index 77fd9989..800974b9 100644 --- a/src/micromanager_gui/__main__.py +++ b/src/micromanager_gui/__main__.py @@ -39,11 +39,12 @@ def main(args: Sequence[str] | None = None) -> None: def _our_excepthook( type: type[BaseException], value: BaseException, tb: TracebackType | None -): +) -> None: """Excepthook that prints the traceback to the console. By default, Qt's excepthook raises sys.exit(), which is not what we want. """ + # this could be elaborated to do all kinds of things... traceback.print_exception(type, value, tb) From 6d7ecbfdebb7c7ce17c9e5c2500aa3b43642c084 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Sat, 6 Jul 2024 20:43:48 -0400 Subject: [PATCH 107/226] fix: temporary pin pymmcore-plus ==0.10.2 --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0848331b..8e0f6dc4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,4 +27,4 @@ repos: - id: mypy files: "^src/" additional_dependencies: - - pymmcore-plus >=0.10.2 + - pymmcore-plus ==0.10.2 diff --git a/pyproject.toml b/pyproject.toml index a142de6f..61f95b74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ "pymmcore-widgets@git+https://github.com/fdrgsp/pymmcore-widgets.git@viewer_v2_and_tensorstore", - "pymmcore-plus >=0.10.2", + "pymmcore-plus ==0.10.2", "qtpy", "vispy", "zarr", From 998d9343c1ce1a9e67ed8a8109bbdb492eb257a8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 3 Jul 2024 14:35:06 -0400 Subject: [PATCH 108/226] feat: add install widget --- src/micromanager_gui/_menubar/_menubar.py | 2 ++ src/micromanager_gui/_widgets/_install_widget.py | 10 ++++++++++ 2 files changed, 12 insertions(+) create mode 100644 src/micromanager_gui/_widgets/_install_widget.py diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index 2230d04e..96b63068 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -23,6 +23,7 @@ QWidget, ) +from micromanager_gui._widgets._install_widget import _InstallWidget from micromanager_gui._widgets._mda_widget import _MDAWidget from micromanager_gui._widgets._stage_control import _StagesControlWidget @@ -33,6 +34,7 @@ WIDGETS = { "Property Browser": PropertyBrowser, "Pixel Configuration": PixelConfigurationWidget, + "Install Devices": _InstallWidget, } DOCKWIDGETS = { "MDA Widget": _MDAWidget, diff --git a/src/micromanager_gui/_widgets/_install_widget.py b/src/micromanager_gui/_widgets/_install_widget.py new file mode 100644 index 00000000..15791489 --- /dev/null +++ b/src/micromanager_gui/_widgets/_install_widget.py @@ -0,0 +1,10 @@ +from typing import Any + +from pymmcore_widgets import InstallWidget +from qtpy.QtWidgets import QWidget + + +class _InstallWidget(InstallWidget): + def __init__(self, parent: QWidget | None = None, **kwargs: Any) -> None: + super().__init__(parent) + self.resize(800, 400) From 4fc30401f7159c2c2e95c0d4c998cf476dedecac Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Sat, 6 Jul 2024 23:57:24 -0400 Subject: [PATCH 109/226] fix: fix for pymmcore->=0.11.0 --- .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- src/micromanager_gui/_core_link.py | 15 +++++---- .../_readers/_ome_zarr_reader.py | 2 +- .../_readers/_tensorstore_zarr_reader.py | 15 +++++---- .../_writers/_tensorstore_zarr.py | 33 ++++++------------- tests/test_mda_viewer.py | 7 ++-- 7 files changed, 35 insertions(+), 41 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8e0f6dc4..3f3deb68 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,4 +27,4 @@ repos: - id: mypy files: "^src/" additional_dependencies: - - pymmcore-plus ==0.10.2 + - pymmcore-plus >=0.11.0 diff --git a/pyproject.toml b/pyproject.toml index 61f95b74..186bd099 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ dynamic = ["version"] dependencies = [ "pymmcore-widgets@git+https://github.com/fdrgsp/pymmcore-widgets.git@viewer_v2_and_tensorstore", - "pymmcore-plus ==0.10.2", + "pymmcore-plus >=0.11.0", "qtpy", "vispy", "zarr", diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index 49277c5a..0a15db48 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -17,6 +17,7 @@ if TYPE_CHECKING: import useq + from pymmcore_plus.metadata import SummaryMetaV1 from ._main_window import MicroManagerGUI from ._widgets._mda_widget import _MDAWidget @@ -71,7 +72,9 @@ def _close_tab(self, index: int) -> None: del self._current_viewer self._current_viewer = None - def _on_sequence_started(self, sequence: useq.MDASequence) -> None: + def _on_sequence_started( + self, sequence: useq.MDASequence, meta: SummaryMetaV1 + ) -> None: """Show the MDAViewer when the MDA sequence starts.""" self._mda_running = True @@ -81,25 +84,25 @@ def _on_sequence_started(self, sequence: useq.MDASequence) -> None: # pause until the viewer is ready self._mmc.mda.toggle_pause() # setup the viewer - self._setup_viewer(sequence) + self._setup_viewer(sequence, meta) # resume the sequence self._mmc.mda.toggle_pause() - def _setup_viewer(self, sequence: useq.MDASequence) -> None: + def _setup_viewer(self, sequence: useq.MDASequence, meta: SummaryMetaV1) -> None: """Setup the MDAViewer.""" # get the MDAWidget writer datastore = self._mda.writer if self._mda is not None else None self._current_viewer = MDAViewer(parent=self._main_window, datastore=datastore) # rename the viewer if there is a save_name' in the metadata or add a digit - meta = cast(dict, sequence.metadata.get(PYMMCW_METADATA_KEY, {})) - viewer_name = self._get_viewer_name(meta.get("save_name")) + pmmcw_meta = cast(dict, sequence.metadata.get(PYMMCW_METADATA_KEY, {})) + viewer_name = self._get_viewer_name(pmmcw_meta.get("save_name")) self._viewer_tab.addTab(self._current_viewer, viewer_name) self._viewer_tab.setCurrentWidget(self._current_viewer) # call it manually instead in _connect_viewer because this signal has been # emitted already - self._current_viewer.data.sequenceStarted(sequence) + self._current_viewer.data.sequenceStarted(sequence, meta) # disable the LUT drop down and the mono/composite button (temporary) self._enable_gui(False) diff --git a/src/micromanager_gui/_readers/_ome_zarr_reader.py b/src/micromanager_gui/_readers/_ome_zarr_reader.py index e2642c8d..6d8893ea 100644 --- a/src/micromanager_gui/_readers/_ome_zarr_reader.py +++ b/src/micromanager_gui/_readers/_ome_zarr_reader.py @@ -217,7 +217,7 @@ def _get_metadata_from_index( """Return the metadata for the given indexers.""" metadata = [] for meta in self.store[pos_key].attrs.get(FRAME_META, []): - event_index = meta["Event"]["index"] # e.g. {"p": 0, "t": 1} + event_index = meta["mda_event"]["index"] # e.g. {"p": 0, "t": 1} if indexers.items() <= event_index.items(): metadata.append(meta) return metadata diff --git a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py index 16cfc76a..0ef07607 100644 --- a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py +++ b/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py @@ -8,6 +8,7 @@ import numpy as np import tensorstore as ts import useq +from pymmcore_plus.metadata.serialize import json_loads from tifffile import imwrite from tqdm import tqdm @@ -50,9 +51,10 @@ def __init__(self, path: str | Path): _store = ts.open(spec).result() - self._metadata: dict = {} + self._metadata: list = [] if metadata_json := _store.kvstore.read(".zattrs").result().value: - self._metadata = json.loads(metadata_json) + metadata_dict = json_loads(metadata_json) + self._metadata = metadata_dict.get("frame_metadatas", []) # set the axis labels if self.sequence is not None: @@ -82,8 +84,9 @@ def store(self) -> ts.TensorStore: @property def sequence(self) -> useq.MDASequence | None: - seq = self._metadata.get("useq_MDASequence") - return useq.MDASequence(**json.loads(seq)) if seq is not None else None + # getting the sequence from the first frame metadata within the "mda_event" key + seq = self._metadata[0].get("mda_event", {}).get("sequence") + return useq.MDASequence(**seq) if seq is not None else None # ___________________________Public Methods___________________________ @@ -208,8 +211,8 @@ def _get_axis_index(self, indexers: Mapping[str, int]) -> tuple[object, ...]: def _get_metadata_from_index(self, indexers: Mapping[str, int]) -> list[dict]: """Return the metadata for the given indexers.""" metadata = [] - for meta in self._metadata.get("frame_metadatas", []): - event_index = meta["Event"]["index"] # e.g. {"p": 0, "t": 1} + for meta in self._metadata: + event_index = meta["mda_event"]["index"] # e.g. {"p": 0, "t": 1} if indexers.items() <= event_index.items(): metadata.append(meta) return metadata diff --git a/src/micromanager_gui/_writers/_tensorstore_zarr.py b/src/micromanager_gui/_writers/_tensorstore_zarr.py index 22c4c41f..9b28a3cc 100644 --- a/src/micromanager_gui/_writers/_tensorstore_zarr.py +++ b/src/micromanager_gui/_writers/_tensorstore_zarr.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import time from typing import TYPE_CHECKING, Literal, Mapping, TypeAlias @@ -9,6 +8,7 @@ if TYPE_CHECKING: from os import PathLike +from pymmcore_plus.metadata.serialize import json_dumps, json_loads TsDriver: TypeAlias = Literal["zarr", "zarr3", "n5", "neuroglancer_precomputed"] @@ -37,29 +37,17 @@ def __init__( def finalize_metadata(self) -> None: """Finalize and flush metadata to storage.""" if not (store := self._store) or not store.kvstore: - return - - data = [] - for event, meta in self.frame_metadatas: - # FIXME: unnecessary ser/des - js = event.model_dump_json(exclude={"sequence"}, exclude_defaults=True) - meta["Event"] = json.loads(js) - data.append(meta) - - metadata = { - "useq_MDASequence": self.current_sequence.model_dump_json( - exclude_defaults=True - ), - "frame_metadatas": data, - } + return # pragma: no cover + metadata = {"frame_metadatas": [m[1] for m in self.frame_metadatas]} if not self._nd_storage: metadata["frame_indices"] = [ - (tuple(dict(k).items()), v) for k, v in self._frame_indices.items() + (tuple(dict(k).items()), v) # type: ignore + for k, v in self._frame_indices.items() ] if self.ts_driver.startswith("zarr"): - store.kvstore.write(".zattrs", json.dumps(metadata)) + store.kvstore.write(".zattrs", json_dumps(metadata).decode("utf-8")) attrs = store.kvstore.read(".zattrs").result().value logger.info("Writing 'tensorstore_zarr' store 'zattrs' to disk.") start_time = time.time() @@ -68,11 +56,10 @@ def finalize_metadata(self) -> None: # we wait for WAIT_TIME seconds. If the attrs are not written by then, # we continue. while not attrs and not time.time() - start_time > WAIT_TIME: - store.kvstore.write(".zattrs", json.dumps(metadata)) + store.kvstore.write(".zattrs", json_dumps(metadata).decode("utf-8")) attrs = store.kvstore.read(".zattrs").result().value - logger.info("'tensorstore_zarr' 'zattrs' written to disk.") - elif self.ts_driver == "n5": - attrs = json.loads(store.kvstore.read("attributes.json").result().value) + elif self.ts_driver == "n5": # pragma: no cover + attrs = json_loads(store.kvstore.read("attributes.json").result().value) attrs.update(metadata) - store.kvstore.write("attributes.json", json.dumps(attrs)) + store.kvstore.write("attributes.json", json_dumps(attrs).decode("utf-8")) diff --git a/tests/test_mda_viewer.py b/tests/test_mda_viewer.py index 03765a28..a76e519d 100644 --- a/tests/test_mda_viewer.py +++ b/tests/test_mda_viewer.py @@ -6,6 +6,7 @@ import pytest import useq from pymmcore_plus.mda.handlers import OMETiffWriter, OMEZarrWriter, TensorStoreHandler +from pymmcore_plus.metadata import SummaryMetaV1 from pymmcore_widgets._stack_viewer_v2 import MDAViewer from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY @@ -27,12 +28,12 @@ def test_mda_viewer_no_saving( mda = useq.MDASequence(channels=["DAPI", "FITC"]) # simulate that the core run_mda method was called - gui._core_link._on_sequence_started(sequence=mda) + gui._core_link._on_sequence_started(sequence=mda, meta=SummaryMetaV1()) assert gui._core_link._viewer_tab.count() == 2 assert gui._core_link._viewer_tab.tabText(1) == "MDA Viewer 1" assert gui._core_link._viewer_tab.currentIndex() == 1 # simulate that the core run_mda method was called again - gui._core_link._on_sequence_started(sequence=mda) + gui._core_link._on_sequence_started(sequence=mda, meta=SummaryMetaV1()) assert gui._core_link._viewer_tab.count() == 3 assert gui._core_link._viewer_tab.tabText(2) == "MDA Viewer 2" assert gui._core_link._viewer_tab.currentIndex() == 2 @@ -80,7 +81,7 @@ def _run_mda(seq): with patch.object(global_mmcore, "run_mda", _run_mda): gui._menu_bar._mda.run_mda() # simulate that the core run_mda method was called - gui._core_link._on_sequence_started(sequence=mda) + gui._core_link._on_sequence_started(sequence=mda, meta=SummaryMetaV1()) assert isinstance(gui._menu_bar._mda.writer, writer) assert gui._core_link._viewer_tab.count() == 2 From aa4518e5e181bf45ab63cc1663486d3551976f05 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Wed, 17 Jul 2024 22:28:42 -0400 Subject: [PATCH 110/226] Update README.md --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 86f0e80c..f289cdb7 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,15 @@ Note: tests are running on [PyQt6](https://pypi.org/project/PyQt6/) and [PyQt5]( You also need to install the `Micro-Manager` device adapters and C++ core provided by [mmCoreAndDevices](https://github.com/micro-manager/mmCoreAndDevices#mmcoreanddevices). This can be done by following the steps described in the `pymmcore-plus` [documentation page](https://pymmcore-plus.github.io/pymmcore-plus/install/#installing-micro-manager-device-adapters). -## To run the GUI +## To run the Micro-Manger GUI ```bash -python -m micromanager_gui +mmgui ``` By passing the `-c` or `-config` flag, you can specify the path of a micromanager configuration file you want to load. For example: ```bash -python -m micromanager_gui -c path/to/config.cfg +mmgui -c path/to/config.cfg ``` + From 3810a822019f2eaf3b30250ddd903373783d12d0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Aug 2024 20:19:52 +0000 Subject: [PATCH 111/226] ci(pre-commit.ci): autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/crate-ci/typos: v1.22.9 → v1.23.6](https://github.com/crate-ci/typos/compare/v1.22.9...v1.23.6) - [github.com/astral-sh/ruff-pre-commit: v0.5.0 → v0.5.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.0...v0.5.6) - [github.com/pre-commit/mirrors-mypy: v1.10.1 → v1.11.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.10.1...v1.11.1) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3f3deb68..f70631e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,12 +5,12 @@ ci: repos: - repo: https://github.com/crate-ci/typos - rev: v1.22.9 + rev: v1.23.6 hooks: - id: typos - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.0 + rev: v0.5.6 hooks: - id: ruff args: [--fix, --unsafe-fixes] @@ -22,7 +22,7 @@ repos: - id: validate-pyproject - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.1 + rev: v1.11.1 hooks: - id: mypy files: "^src/" From a2ded92a5c831af3d4584661bfb2866f076775d6 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 19 Aug 2024 16:29:07 -0400 Subject: [PATCH 112/226] fix: typo --- src/micromanager_gui/_widgets/_mda_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/micromanager_gui/_widgets/_mda_widget.py b/src/micromanager_gui/_widgets/_mda_widget.py index eeed8964..05b09078 100644 --- a/src/micromanager_gui/_widgets/_mda_widget.py +++ b/src/micromanager_gui/_widgets/_mda_widget.py @@ -90,7 +90,7 @@ def run_mda(self) -> None: # set the writer to use for saving the MDA sequence. # NOTE: 'self._writer' is used by the 'MDAViewer' to set its datastore self.writer = self._create_mda_viewer_writer(save_format, save_path) - # at this point, if self.writer is None, it means thet a + # at this point, if self.writer is None, it means that a # ImageSequenceWriter should be used to save the sequence. if self.writer is None: output = ImageSequenceWriter(save_path) From 3c1cdff72f14f452bc9306131b3ac77b8d2d5eb9 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 9 Oct 2024 14:50:19 -0400 Subject: [PATCH 113/226] feat: update mda widget + stack viewer + tests --- pyproject.toml | 7 +- src/micromanager_gui/_core_link.py | 9 +- src/micromanager_gui/_main_window.py | 2 +- src/micromanager_gui/_menubar/_menubar.py | 8 +- src/micromanager_gui/_widgets/_mda_widget.py | 165 ----- .../_widgets/_mda_widget/__init__.py | 8 + .../_widgets/_mda_widget/_core_mda.py | 563 ++++++++++++++++++ .../_widgets/_mda_widget/_save_widget.py | 229 +++++++ .../_widgets/_stack_viewer/__init__.py | 4 + .../_stack_viewer/_backends/__init__.py | 36 ++ .../_stack_viewer/_backends/_pygfx.py | 164 +++++ .../_stack_viewer/_backends/_vispy.py | 144 +++++ .../_widgets/_stack_viewer/_dims_slider.py | 523 ++++++++++++++++ .../_widgets/_stack_viewer/_indexing.py | 292 +++++++++ .../_widgets/_stack_viewer/_lut_control.py | 121 ++++ .../_widgets/_stack_viewer/_mda_viewer.py | 66 ++ .../_widgets/_stack_viewer/_protocols.py | 43 ++ .../_widgets/_stack_viewer/_save_button.py | 34 ++ .../_widgets/_stack_viewer/_stack_viewer.py | 541 +++++++++++++++++ test.ome.zarr/.zgroup | 3 + tests/test_gui.py | 4 +- tests/test_mda_viewer.py | 41 +- tests/test_readers_writers.py | 4 - tests/test_save_widget.py | 103 ++++ tests/test_stack_viewer.py | 52 ++ 25 files changed, 2976 insertions(+), 190 deletions(-) delete mode 100644 src/micromanager_gui/_widgets/_mda_widget.py create mode 100644 src/micromanager_gui/_widgets/_mda_widget/__init__.py create mode 100644 src/micromanager_gui/_widgets/_mda_widget/_core_mda.py create mode 100644 src/micromanager_gui/_widgets/_mda_widget/_save_widget.py create mode 100644 src/micromanager_gui/_widgets/_stack_viewer/__init__.py create mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_backends/__init__.py create mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_backends/_pygfx.py create mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_backends/_vispy.py create mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_dims_slider.py create mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_indexing.py create mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_lut_control.py create mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_mda_viewer.py create mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_protocols.py create mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_save_button.py create mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_stack_viewer.py create mode 100644 test.ome.zarr/.zgroup create mode 100644 tests/test_save_widget.py create mode 100644 tests/test_stack_viewer.py diff --git a/pyproject.toml b/pyproject.toml index 186bd099..6aa48093 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,14 +26,16 @@ classifiers = [ ] dynamic = ["version"] dependencies = [ - "pymmcore-widgets@git+https://github.com/fdrgsp/pymmcore-widgets.git@viewer_v2_and_tensorstore", + "pymmcore-widgets >=0.8.0", "pymmcore-plus >=0.11.0", "qtpy", "vispy", "zarr", "tifffile", "tqdm", - "pyyaml" + "pyyaml", + "cmap", + "pyconify" ] # extras @@ -132,6 +134,7 @@ disallow_any_generics = false disallow_subclassing_any = false show_error_codes = true pretty = true +plugins = ["pydantic.mypy"] # # module specific overrides # [[tool.mypy.overrides]] diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index 0a15db48..53e4596c 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -3,11 +3,12 @@ from typing import TYPE_CHECKING, cast from pymmcore_plus import CMMCorePlus -from pymmcore_widgets._stack_viewer_v2 import MDAViewer from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY from qtpy.QtCore import QObject, Qt from qtpy.QtWidgets import QTabBar, QTabWidget +from micromanager_gui._widgets._stack_viewer import MDAViewer + from ._widgets._preview import Preview DIALOG = Qt.WindowType.Dialog @@ -20,7 +21,7 @@ from pymmcore_plus.metadata import SummaryMetaV1 from ._main_window import MicroManagerGUI - from ._widgets._mda_widget import _MDAWidget + from ._widgets._mda_widget import MDAWidget class CoreViewersLink(QObject): @@ -49,8 +50,8 @@ def __init__(self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None self._mda_running: bool = False - # the _MDAWidget. It should have been set in the _MenuBar at startup - self._mda = cast("_MDAWidget", self._main_window._menu_bar._mda) + # the MDAWidget. It should have been set in the _MenuBar at startup + self._mda = cast("MDAWidget", self._main_window._menu_bar._mda) ev = self._mmc.events ev.continuousSequenceAcquisitionStarted.connect(self._set_preview_tab) diff --git a/src/micromanager_gui/_main_window.py b/src/micromanager_gui/_main_window.py index eb8d1f89..1aea40d0 100644 --- a/src/micromanager_gui/_main_window.py +++ b/src/micromanager_gui/_main_window.py @@ -5,7 +5,6 @@ from warnings import warn from pymmcore_plus import CMMCorePlus -from pymmcore_widgets._stack_viewer_v2._mda_viewer import StackViewer from qtpy.QtWidgets import ( QGridLayout, QMainWindow, @@ -15,6 +14,7 @@ from micromanager_gui._readers._tensorstore_zarr_reader import ( TensorstoreZarrReader, ) +from micromanager_gui._widgets._stack_viewer import StackViewer from ._core_link import CoreViewersLink from ._menubar._menubar import _MenuBar diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index 96b63068..24b6747f 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -24,7 +24,7 @@ ) from micromanager_gui._widgets._install_widget import _InstallWidget -from micromanager_gui._widgets._mda_widget import _MDAWidget +from micromanager_gui._widgets._mda_widget import MDAWidget from micromanager_gui._widgets._stage_control import _StagesControlWidget if TYPE_CHECKING: @@ -37,7 +37,7 @@ "Install Devices": _InstallWidget, } DOCKWIDGETS = { - "MDA Widget": _MDAWidget, + "MDA Widget": MDAWidget, "Groups and Presets": GroupPresetTableWidget, "Stage Control": _StagesControlWidget, "Camera ROI": CameraRoiWidget, @@ -96,7 +96,7 @@ def __init__( # widgets self._wizard: ConfigWizard | None = None # is in a different menu - self._mda: _MDAWidget | None = None + self._mda: MDAWidget | None = None # configurations_menu self._configurations_menu = self.addMenu("System Configurations") @@ -137,7 +137,7 @@ def __init__( # create 'Group and Presets' and 'MDA' widgets at the startup self._create_dock_widget("Groups and Presets", dock_area=LEFT) mda = self._create_dock_widget("MDA Widget") - self._mda = cast(_MDAWidget, mda.main_widget) + self._mda = cast(MDAWidget, mda.main_widget) def _enable(self, enable: bool) -> None: """Enable or disable the actions.""" diff --git a/src/micromanager_gui/_widgets/_mda_widget.py b/src/micromanager_gui/_widgets/_mda_widget.py deleted file mode 100644 index 05b09078..00000000 --- a/src/micromanager_gui/_widgets/_mda_widget.py +++ /dev/null @@ -1,165 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING, cast - -from pymmcore_plus.mda.handlers import ( - ImageSequenceWriter, - OMETiffWriter, - OMEZarrWriter, - TensorStoreHandler, -) -from pymmcore_widgets.mda import MDAWidget -from pymmcore_widgets.mda._save_widget import ( - OME_TIFF, - OME_ZARR, - WRITERS, - ZARR_TESNSORSTORE, -) -from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY -from useq import MDASequence - -from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler - -OME_TIFFS = tuple(WRITERS[OME_TIFF]) -GB_CACHE = 2_000_000_000 # 2 GB for tensorstore cache - -if TYPE_CHECKING: - from pymmcore_plus import CMMCorePlus - from qtpy.QtWidgets import ( - QVBoxLayout, - QWidget, - ) - from useq import MDASequence - - -class _MDAWidget(MDAWidget): - """Main napari-micromanager GUI.""" - - def __init__( - self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None - ) -> None: - super().__init__(parent=parent, mmcore=mmcore) - - # writer for saving the MDA sequence. This is used by the MDAViewer to set its - # internal datastore. If _writer is None, the MDAViewer will use its default - # internal datastore. - self.writer: OMETiffWriter | OMEZarrWriter | TensorStoreHandler | None = None - - # setContentsMargins - pos_layout = cast("QVBoxLayout", self.stage_positions.layout()) - pos_layout.setContentsMargins(10, 10, 10, 10) - time_layout = cast("QVBoxLayout", self.time_plan.layout()) - time_layout.setContentsMargins(10, 10, 10, 10) - - def _on_mda_finished(self, sequence: MDASequence) -> None: - """Handle the end of the MDA sequence.""" - self.writer = None - super()._on_mda_finished(sequence) - - def run_mda(self) -> None: - """Run the MDA sequence experiment.""" - # in case the user does not press enter after editing the save name. - self.save_info.save_name.editingFinished.emit() - - # if autofocus has been requested, but the autofocus device is not engaged, - # and position-specific offsets haven't been set, show a warning - pos = self.stage_positions - if ( - self.af_axis.value() - and not self._mmc.isContinuousFocusLocked() - and (not self.tab_wdg.isChecked(pos) or not pos.af_per_position.isChecked()) - and not self._confirm_af_intentions() - ): - return - - sequence = self.value() - - # reset the writer - self.writer = None - - # technically, this is in the metadata as well, but isChecked is more direct - if self.save_info.isChecked(): - save_path = self._update_save_path_from_metadata( - sequence, update_metadata=True - ) - if isinstance(save_path, Path): - # get save format from metadata - save_meta = sequence.metadata.get(PYMMCW_METADATA_KEY, {}) - save_format = save_meta.get("format") - # set the writer to use for saving the MDA sequence. - # NOTE: 'self._writer' is used by the 'MDAViewer' to set its datastore - self.writer = self._create_mda_viewer_writer(save_format, save_path) - # at this point, if self.writer is None, it means that a - # ImageSequenceWriter should be used to save the sequence. - if self.writer is None: - output = ImageSequenceWriter(save_path) - # Since any other type of writer will be handled by the 'MDAViewer', - # we need to pass a writer to the engine only if it is a - # 'ImageSequenceWriter'. - self._mmc.run_mda(sequence, output=output) - return - - self._mmc.run_mda(sequence) - - def _create_mda_viewer_writer( - self, save_format: str, save_path: Path - ) -> OMEZarrWriter | OMETiffWriter | TensorStoreHandler | None: - """Create a writer for the MDAViewer based on the save format.""" - # use internal OME-TIFF writer if selected - if OME_TIFF in save_format: - # if OME-TIFF, save_path should be a directory without extension, so - # we need to add the ".ome.tif" to correctly use the OMETiffWriter - if not save_path.name.endswith(OME_TIFFS): - save_path = save_path.with_suffix(OME_TIFF) - return OMETiffWriter(save_path) - elif OME_ZARR in save_format: - return OMEZarrWriter(save_path) - elif ZARR_TESNSORSTORE in save_format: - return self._create_zarr_tensorstore(save_path) - # cannot use the ImageSequenceWriter here because the MDAViewer will not be - # able to handle it. - return None - - def _create_zarr_tensorstore(self, save_path: Path) -> _TensorStoreHandler: - """Create a Zarr TensorStore writer.""" - return _TensorStoreHandler( - driver="zarr", - path=save_path, - delete_existing=True, - spec={"context": {"cache_pool": {"total_bytes_limit": GB_CACHE}}}, - ) - - def _update_save_path_from_metadata( - self, - sequence: MDASequence, - update_widget: bool = True, - update_metadata: bool = False, - ) -> Path | None: - """Get the next available save path from sequence metadata and update widget. - - Parameters - ---------- - sequence : MDASequence - The MDA sequence to get the save path from. (must be in the - 'pymmcore_widgets' key of the metadata) - update_widget : bool, optional - Whether to update the save widget with the new path, by default True. - update_metadata : bool, optional - Whether to update the Sequence metadata with the new path, by default False. - """ - if ( - (meta := sequence.metadata.get(PYMMCW_METADATA_KEY, {})) - and (save_dir := meta.get("save_dir")) - and (save_name := meta.get("save_name")) - ): - requested = (Path(save_dir) / str(save_name)).expanduser().resolve() - next_path = self.get_next_available_path(requested) - - if next_path != requested: - if update_widget: - self.save_info.setValue(next_path) - if update_metadata: - meta.update(self.save_info.value()) - return Path(next_path) - return None diff --git a/src/micromanager_gui/_widgets/_mda_widget/__init__.py b/src/micromanager_gui/_widgets/_mda_widget/__init__.py new file mode 100644 index 00000000..aac7b065 --- /dev/null +++ b/src/micromanager_gui/_widgets/_mda_widget/__init__.py @@ -0,0 +1,8 @@ +"""MDA widgets for the micromanager-gui package. + +micromanager-gui: https://github.com/fdrgsp/micromanager-gui +""" + +from ._core_mda import MDAWidget + +__all__ = ["MDAWidget"] diff --git a/src/micromanager_gui/_widgets/_mda_widget/_core_mda.py b/src/micromanager_gui/_widgets/_mda_widget/_core_mda.py new file mode 100644 index 00000000..ee75a2f4 --- /dev/null +++ b/src/micromanager_gui/_widgets/_mda_widget/_core_mda.py @@ -0,0 +1,563 @@ +from __future__ import annotations + +import re +from contextlib import suppress +from pathlib import Path +from typing import cast + +from fonticon_mdi6 import MDI6 +from pymmcore_plus import CMMCorePlus, Keyword +from pymmcore_plus.mda.handlers import ( + ImageSequenceWriter, + OMETiffWriter, + OMEZarrWriter, + TensorStoreHandler, +) +from pymmcore_widgets.mda._core_channels import CoreConnectedChannelTable +from pymmcore_widgets.mda._core_grid import CoreConnectedGridPlanWidget +from pymmcore_widgets.mda._core_positions import CoreConnectedPositionTable +from pymmcore_widgets.mda._core_z import CoreConnectedZPlanWidget +from pymmcore_widgets.useq_widgets import MDASequenceWidget +from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY, MDATabs +from pymmcore_widgets.useq_widgets._time import TimePlanWidget +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import ( + QBoxLayout, + QHBoxLayout, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, +) +from superqt.fonticon import icon +from useq import MDASequence, Position + +from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler + +from ._save_widget import ( + OME_TIFF, + OME_ZARR, + WRITERS, + ZARR_TESNSORSTORE, + SaveGroupBox, +) + +OME_TIFFS = tuple(WRITERS[OME_TIFF]) +GB_CACHE = 2_000_000_000 # 2 GB for tensorstore cache + +NUM_SPLIT = re.compile(r"(.*?)(?:_(\d{3,}))?$") + + +def get_next_available_path(requested_path: Path | str, min_digits: int = 3) -> Path: + """Get the next available paths (filepath or folderpath if extension = ""). + + This method adds a counter of min_digits to the filename or foldername to ensure + that the path is unique. + + Parameters + ---------- + requested_path : Path | str + A path to a file or folder that may or may not exist. + min_digits : int, optional + The min_digits number of digits to be used for the counter. By default, 3. + """ + if isinstance(requested_path, str): # pragma: no cover + requested_path = Path(requested_path) + + directory = requested_path.parent + extension = requested_path.suffix + # ome files like .ome.tiff or .ome.zarr are special,treated as a single extension + if (stem := requested_path.stem).endswith(".ome"): + extension = f".ome{extension}" + stem = stem[:-4] + elif (stem := requested_path.stem).endswith(".tensorstore"): + extension = f".tensorstore{extension}" + stem = stem[:-12] + + # look for ANY existing files in the folder that follow the pattern of + # stem_###.extension + current_max = 0 + for existing in directory.glob(f"*{extension}"): + # cannot use existing.stem because of the ome (2-part-extension) special case + base = existing.name.replace(extension, "") + # if the base name ends with a number, increase the current_max + if (match := NUM_SPLIT.match(base)) and (num := match.group(2)): + current_max = max(int(num), current_max) + # if it has more digits than expected, update the ndigits + if len(num) > min_digits: + min_digits = len(num) + + # if the path does not exist and there are no existing files, + # return the requested path + if not requested_path.exists() and current_max == 0: + return requested_path + + current_max += 1 + # otherwise return the next path greater than the current_max + # remove any existing counter from the stem + if match := NUM_SPLIT.match(stem): + stem, num = match.groups() + if num: + # if the requested path has a counter that is greater than any other files + # use it + current_max = max(int(num), current_max) + return directory / f"{stem}_{current_max:0{min_digits}d}{extension}" + + +class CoreMDATabs(MDATabs): + def __init__( + self, parent: QWidget | None = None, core: CMMCorePlus | None = None + ) -> None: + self._mmc = core or CMMCorePlus.instance() + super().__init__(parent) + + def create_subwidgets(self) -> None: + self.time_plan = TimePlanWidget(1) + self.stage_positions = CoreConnectedPositionTable(1, self._mmc) + self.z_plan = CoreConnectedZPlanWidget(self._mmc) + self.grid_plan = CoreConnectedGridPlanWidget(self._mmc) + self.channels = CoreConnectedChannelTable(1, self._mmc) + + def _enable_tabs(self, enable: bool) -> None: + """Enable or disable the tab checkboxes and their contents. + + However, we can still mover through the tabs and see their contents. + """ + # disable tab checkboxes + for cbox in self._cboxes: + cbox.setEnabled(enable) + # disable tabs contents + self.time_plan.setEnabled(enable) + self.stage_positions.setEnabled(enable) + self.z_plan.setEnabled(enable) + self.grid_plan.setEnabled(enable) + self.channels.setEnabled(enable) + + +class MDAWidget(MDASequenceWidget): + """Main MDA Widget connected to a [`pymmcore_plus.CMMCorePlus`][] instance. + + It provides a GUI to construct and run a [`useq.MDASequence`][]. Unlike + [`useq_widgets.MDASequenceWidget`][pymmcore_widgets.MDASequenceWidget], this + widget is connected to a [`pymmcore_plus.CMMCorePlus`][] instance, enabling + awareness and control of the current state of the microscope. + + Parameters + ---------- + parent : QWidget | None + Optional parent widget, by default None. + mmcore : CMMCorePlus | None + Optional [`CMMCorePlus`][pymmcore_plus.CMMCorePlus] micromanager core. + By default, None. If not specified, the widget will use the active + (or create a new) + [`CMMCorePlus.instance`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance]. + """ + + def __init__( + self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None + ) -> None: + # create a couple core-connected variants of the tab widgets + self._mmc = mmcore or CMMCorePlus.instance() + + super().__init__(parent=parent, tab_widget=CoreMDATabs(None, self._mmc)) + + self.save_info = SaveGroupBox(parent=self) + self.save_info.valueChanged.connect(self.valueChanged) + self.control_btns = _MDAControlButtons(self._mmc, self) + + # writer for saving the MDA sequence. This is used by the MDAViewer to set its + # internal datastore. If _writer is None, the MDAViewer will use its default + # internal datastore. + self.writer: OMETiffWriter | OMEZarrWriter | TensorStoreHandler | None = None + + # -------- initialize ----------- + + self._on_sys_config_loaded() + + # ------------ layout ------------ + + layout = cast("QBoxLayout", self.layout()) + layout.insertWidget(0, self.save_info) + layout.addWidget(self.control_btns) + + # setContentsMargins + pos_layout = cast("QVBoxLayout", self.stage_positions.layout()) + pos_layout.setContentsMargins(10, 10, 10, 10) + time_layout = cast("QVBoxLayout", self.time_plan.layout()) + time_layout.setContentsMargins(10, 10, 10, 10) + + # ------------ connect signals ------------ + + self.control_btns.run_btn.clicked.connect(self.run_mda) + self.control_btns.pause_btn.released.connect(self._mmc.mda.toggle_pause) + self.control_btns.cancel_btn.released.connect(self._mmc.mda.cancel) + self._mmc.mda.events.sequenceStarted.connect(self._on_mda_started) + self._mmc.mda.events.sequenceFinished.connect(self._on_mda_finished) + self._mmc.events.systemConfigurationLoaded.connect(self._on_sys_config_loaded) + + self.destroyed.connect(self._disconnect) + + # ----------- Override type hints in superclass ----------- + + @property + def channels(self) -> CoreConnectedChannelTable: + return cast("CoreConnectedChannelTable", self.tab_wdg.channels) + + @property + def z_plan(self) -> CoreConnectedZPlanWidget: + return cast("CoreConnectedZPlanWidget", self.tab_wdg.z_plan) + + @property + def stage_positions(self) -> CoreConnectedPositionTable: + return cast("CoreConnectedPositionTable", self.tab_wdg.stage_positions) + + @property + def grid_plan(self) -> CoreConnectedGridPlanWidget: + return cast("CoreConnectedGridPlanWidget", self.tab_wdg.grid_plan) + + # ------------------- public Methods ---------------------- + + def value(self) -> MDASequence: + """Set the current state of the widget from a [`useq.MDASequence`][].""" + val = super().value() + replace: dict = {} + + # if the z plan is relative and there are stage positions but the 'include z' is + # unchecked, use the current z stage position as the relative starting one. + if ( + val.z_plan + and val.z_plan.is_relative + and (val.stage_positions and not self.stage_positions.include_z.isChecked()) + ): + z = self._mmc.getZPosition() if self._mmc.getFocusDevice() else None + replace["stage_positions"] = tuple( + pos.replace(z=z) for pos in val.stage_positions + ) + + # if there is an autofocus_plan but the autofocus_motor_offset is None, set it + # to the current value + if (afplan := val.autofocus_plan) and afplan.autofocus_motor_offset is None: + p2 = afplan.replace(autofocus_motor_offset=self._mmc.getAutoFocusOffset()) + replace["autofocus_plan"] = p2 + + # if there are no stage positions, use the current stage position + if not val.stage_positions: + replace["stage_positions"] = (self._get_current_stage_position(),) + # if "p" is not in the axis order, we need to add it or the position will + # not be in the event + if "p" not in val.axis_order: + axis_order = list(val.axis_order) + # add the "p" axis at the beginning or after the "t" as the default + if "t" in axis_order: + axis_order.insert(axis_order.index("t") + 1, "p") + else: + axis_order.insert(0, "p") + replace["axis_order"] = tuple(axis_order) + + if replace: + val = val.replace(**replace) + + meta: dict = val.metadata.setdefault(PYMMCW_METADATA_KEY, {}) + if self.save_info.isChecked(): + meta.update(self.save_info.value()) + return val # type: ignore + + def setValue(self, value: MDASequence) -> None: + """Get the current state of the widget as a [`useq.MDASequence`][].""" + super().setValue(value) + self.save_info.setValue(value.metadata.get(PYMMCW_METADATA_KEY, {})) + + def get_next_available_path(self, requested_path: Path) -> Path: + """Get the next available path. + + This method is called immediately before running an MDA to ensure that the file + being saved does not overwrite an existing file. It is also called at the end + of the experiment to update the save widget with the next available path. + + It may be overridden to provide custom behavior, but it should always return a + Path object to a non-existing file or folder. + + The default behavior adds/increments a 3-digit counter at the end of the path + (before the extension) if the path already exists. + + Parameters + ---------- + requested_path : Path + The path we are requesting for use. + """ + return get_next_available_path(requested_path=requested_path) + + def prepare_mda( + self, + ) -> ( + bool + | OMEZarrWriter + | OMETiffWriter + | TensorStoreHandler + | ImageSequenceWriter + | None + ): + """Prepare the MDA sequence experiment. + + Returns + ------- + bool + False if MDA to be cancelled due to autofocus issue. + str | Path + Preparation successful, save path to be used for saving and saving active + None + Preparation successful, saving deactivated + """ + # in case the user does not press enter after editing the save name. + self.save_info.save_name.editingFinished.emit() + + # if autofocus has been requested, but the autofocus device is not engaged, + # and position-specific offsets haven't been set, show a warning + pos = self.stage_positions + if ( + self.af_axis.value() + and not self._mmc.isContinuousFocusLocked() + and (not self.tab_wdg.isChecked(pos) or not pos.af_per_position.isChecked()) + and not self._confirm_af_intentions() + ): + return False + + sequence = self.value() + + # technically, this is in the metadata as well, but isChecked is more direct + if self.save_info.isChecked(): + save_path = self._update_save_path_from_metadata( + sequence, update_metadata=True + ) + if isinstance(save_path, Path): + # get save format from metadata + save_meta = sequence.metadata.get(PYMMCW_METADATA_KEY, {}) + save_format = save_meta.get("format") + # set the writer to use for saving the MDA sequence. + # NOTE: 'self._writer' is used by the 'MDAViewer' to set its datastore + self.writer = self._create_writer(save_format, save_path) + # at this point, if self.writer is None, it means that a + # ImageSequenceWriter should be used to save the sequence. + if self.writer is None: + # Since any other type of writer will be handled by the 'MDAViewer', + # we need to pass a writer to the engine only if it is a + # 'ImageSequenceWriter'. + return ImageSequenceWriter(save_path) + return None + + def execute_mda(self, output: Path | str | object | None) -> None: + """Execute the MDA experiment corresponding to the current value.""" + sequence = self.value() + # run the MDA experiment asynchronously + self._mmc.run_mda(sequence, output=output) + + def run_mda(self) -> None: + save_path = self.prepare_mda() + if save_path is False: + return + self.execute_mda(save_path) + + # ------------------- private Methods ---------------------- + + def _create_writer( + self, save_format: str, save_path: Path + ) -> OMEZarrWriter | OMETiffWriter | TensorStoreHandler | None: + """Create a writer for the MDAViewer based on the save format.""" + # use internal OME-TIFF writer if selected + if OME_TIFF in save_format: + # if OME-TIFF, save_path should be a directory without extension, so + # we need to add the ".ome.tif" to correctly use the OMETiffWriter + if not save_path.name.endswith(OME_TIFFS): + save_path = save_path.with_suffix(OME_TIFF) + return OMETiffWriter(save_path) + elif OME_ZARR in save_format: + return OMEZarrWriter(save_path) + elif ZARR_TESNSORSTORE in save_format: + return self._create_zarr_tensorstore(save_path) + # cannot use the ImageSequenceWriter here because the MDAViewer will not be + # able to handle it. + return None + + def _create_zarr_tensorstore(self, save_path: Path) -> _TensorStoreHandler: + """Create a Zarr TensorStore writer.""" + return _TensorStoreHandler( + driver="zarr", + path=save_path, + delete_existing=True, + spec={"context": {"cache_pool": {"total_bytes_limit": GB_CACHE}}}, + ) + + def _on_sys_config_loaded(self) -> None: + # TODO: connect objective change event to update suggested step + self.z_plan.setSuggestedStep(_guess_NA(self._mmc) or 0.5) + + def _get_current_stage_position(self) -> Position: + """Return the current stage position.""" + x = self._mmc.getXPosition() if self._mmc.getXYStageDevice() else None + y = self._mmc.getYPosition() if self._mmc.getXYStageDevice() else None + z = self._mmc.getPosition() if self._mmc.getFocusDevice() else None + return Position(x=x, y=y, z=z) + + def _update_save_path_from_metadata( + self, + sequence: MDASequence, + update_widget: bool = True, + update_metadata: bool = False, + ) -> Path | None: + """Get the next available save path from sequence metadata and update widget. + + Parameters + ---------- + sequence : MDASequence + The MDA sequence to get the save path from. (must be in the + 'pymmcore_widgets' key of the metadata) + update_widget : bool, optional + Whether to update the save widget with the new path, by default True. + update_metadata : bool, optional + Whether to update the Sequence metadata with the new path, by default False. + """ + if ( + (meta := sequence.metadata.get(PYMMCW_METADATA_KEY, {})) + and (save_dir := meta.get("save_dir")) + and (save_name := meta.get("save_name")) + ): + requested = (Path(save_dir) / str(save_name)).expanduser().resolve() + next_path = self.get_next_available_path(requested) + if next_path != requested: + if update_widget: + self.save_info.setValue(next_path) + if update_metadata: + meta.update(self.save_info.value()) + return next_path + return None + + def _confirm_af_intentions(self) -> bool: + msg = ( + "You've selected to use autofocus for this experiment, " + f"but the '{self._mmc.getAutoFocusDevice()!r}' autofocus device " + "is not currently engaged. " + "\n\nRun anyway?" + ) + + response = QMessageBox.warning( + self, + "Confirm AutoFocus", + msg, + QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Cancel, + ) + return bool(response == QMessageBox.StandardButton.Ok) + + def _enable_widgets(self, enable: bool) -> None: + for child in self.children(): + if isinstance(child, CoreMDATabs): + child._enable_tabs(enable) + elif child is not self.control_btns and hasattr(child, "setEnabled"): + child.setEnabled(enable) + + def _on_mda_started(self) -> None: + self._enable_widgets(False) + + def _on_mda_finished(self, sequence: MDASequence) -> None: + self.writer = None + self._enable_widgets(True) + # update the save name in the gui with the next available path + # FIXME: this is actually a bit error prone in the case of super fast + # experiments and delayed writers that haven't yet written anything to disk + # (e.g. the next available path might be the same as the current one) + # however, the quick fix of using a QTimer.singleShot(0, ...) makes for + # difficulties in testing. + # FIXME: Also, we really don't care about the last sequence at this point + # anyway. We should just update the save widget with the next available path + # based on what's currently in the save widget, since that's what really + # matters (not whatever the last requested mda was) + self._update_save_path_from_metadata(sequence) + + def _disconnect(self) -> None: + with suppress(Exception): + self._mmc.mda.events.sequenceStarted.disconnect(self._on_mda_started) + self._mmc.mda.events.sequenceFinished.disconnect(self._on_mda_finished) + + +class _MDAControlButtons(QWidget): + """Run, pause, and cancel buttons at the bottom of the MDA Widget.""" + + def __init__(self, mmcore: CMMCorePlus, parent: QWidget | None = None) -> None: + super().__init__(parent) + + self._mmc = mmcore + self._mmc.mda.events.sequencePauseToggled.connect(self._on_mda_paused) + self._mmc.mda.events.sequenceStarted.connect(self._on_mda_started) + self._mmc.mda.events.sequenceFinished.connect(self._on_mda_finished) + + icon_size = QSize(24, 24) + self.run_btn = QPushButton("Run") + self.run_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.run_btn.setIcon(icon(MDI6.play_circle_outline, color="lime")) + self.run_btn.setIconSize(icon_size) + + self.pause_btn = QPushButton("Pause") + self.pause_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.pause_btn.setIcon(icon(MDI6.pause_circle_outline, color="green")) + self.pause_btn.setIconSize(icon_size) + self.pause_btn.hide() + + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.cancel_btn.setIcon(icon(MDI6.stop_circle_outline, color="#C33")) + self.cancel_btn.setIconSize(icon_size) + self.cancel_btn.hide() + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addStretch() + layout.addWidget(self.run_btn) + layout.addWidget(self.pause_btn) + layout.addWidget(self.cancel_btn) + + self.destroyed.connect(self._disconnect) + + def _on_mda_started(self) -> None: + self.run_btn.hide() + self.pause_btn.show() + self.cancel_btn.show() + + def _on_mda_finished(self) -> None: + self.run_btn.show() + self.pause_btn.hide() + self.cancel_btn.hide() + self._on_mda_paused(False) + + def _on_mda_paused(self, paused: bool) -> None: + if paused: + self.pause_btn.setIcon(icon(MDI6.play_circle_outline, color="lime")) + self.pause_btn.setText("Resume") + else: + self.pause_btn.setIcon(icon(MDI6.pause_circle_outline, color="green")) + self.pause_btn.setText("Pause") + + def _disconnect(self) -> None: + with suppress(Exception): + self._mmc.mda.events.sequencePauseToggled.disconnect(self._on_mda_paused) + self._mmc.mda.events.sequenceStarted.disconnect(self._on_mda_started) + self._mmc.mda.events.sequenceFinished.disconnect(self._on_mda_finished) + + +def _guess_NA(core: CMMCorePlus) -> float | None: + with suppress(RuntimeError): + if not (pix_cfg := core.getCurrentPixelSizeConfig()): + return None # pragma: no cover + + data = core.getPixelSizeConfigData(pix_cfg) + for obj in core.guessObjectiveDevices(): + key = (obj, Keyword.Label) + if key in data: + val = data[key] + for word in val.split(): + try: + na = float(word) + except ValueError: + continue + if 0.1 < na < 1.5: + return na + return None diff --git a/src/micromanager_gui/_widgets/_mda_widget/_save_widget.py b/src/micromanager_gui/_widgets/_mda_widget/_save_widget.py new file mode 100644 index 00000000..008f0319 --- /dev/null +++ b/src/micromanager_gui/_widgets/_mda_widget/_save_widget.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING +from warnings import warn + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import ( + QComboBox, + QFileDialog, + QGridLayout, + QGroupBox, + QLabel, + QLineEdit, + QPushButton, + QWidget, +) + +if TYPE_CHECKING: + from typing import TypedDict + + from qtpy.QtGui import QFocusEvent + + class SaveInfo(TypedDict): + save_dir: str + save_name: str + format: str + should_save: bool + + +ZARR_TESNSORSTORE = "tensorstore-zarr" +OME_ZARR = "ome-zarr" +OME_TIFF = "ome-tiff" +TIFF_SEQ = "tiff-sequence" + +# dict with writer name and extension +WRITERS: dict[str, list[str]] = { + ZARR_TESNSORSTORE: [".tensorstore.zarr"], + OME_ZARR: [".ome.zarr"], + OME_TIFF: [".ome.tif", ".ome.tiff"], + TIFF_SEQ: [""], +} + +EXT_TO_WRITER = {x: w for w, exts in WRITERS.items() for x in exts} +ALL_EXTENSIONS = [x for exts in WRITERS.values() for x in exts if x] +DIRECTORY_WRITERS = {TIFF_SEQ} # technically could be zarr too + +FILE_NAME = "Filename:" +SUBFOLDER = "Subfolder:" + + +def _known_extension(name: str) -> str | None: + """Return a known extension if the name ends with one. + + Note that all non-None return values are guaranteed to be in EXTENSION_TO_WRITER. + """ + return next((ext for ext in ALL_EXTENSIONS if name.endswith(ext)), None) + + +def _strip_known_extension(name: str) -> str: + """Strip a known extension from the name if it ends with one.""" + if ext := _known_extension(name): + name = name[: -len(ext)] + return name.rstrip(".").rstrip() # remove trailing dots and spaces + + +class _FocusOutLineEdit(QLineEdit): + """A QLineEdit that emits an editingFinished signal when it loses focus. + + This is useful in case the user does not press enter after editing the save name. + """ + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + + def focusOutEvent(self, event: QFocusEvent | None) -> None: # pragma: no cover + super().focusOutEvent(event) + self.editingFinished.emit() + + +class SaveGroupBox(QGroupBox): + """A Widget to gather information about MDA file saving.""" + + valueChanged = Signal() + + def __init__( + self, title: str = "Save Acquisition", parent: QWidget | None = None + ) -> None: + super().__init__(title, parent) + self.setCheckable(True) + self.setChecked(False) + + self.name_label = QLabel(FILE_NAME) + + self.save_dir = QLineEdit() + self.save_dir.setPlaceholderText("Select Save Directory") + self.save_name = _FocusOutLineEdit() + self.save_name.setPlaceholderText("Enter Experiment Name") + self.save_name.editingFinished.connect(self._update_writer_from_name) + + self._writer_combo = QComboBox() + self._writer_combo.addItems(list(WRITERS)) + self._writer_combo.currentTextChanged.connect(self._on_writer_combo_changed) + + browse_btn = QPushButton(text="...") + browse_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) + browse_btn.clicked.connect(self._on_browse_clicked) + + grid = QGridLayout(self) + grid.addWidget(QLabel("Directory:"), 0, 0) + grid.addWidget(self.save_dir, 0, 1, 1, 2) + grid.addWidget(browse_btn, 0, 3) + grid.addWidget(self.name_label, 1, 0) + grid.addWidget(self.save_name, 1, 1) + grid.addWidget(self._writer_combo, 1, 2, 1, 2) + + # prevent jiggling when toggling the checkbox + width = self.fontMetrics().horizontalAdvance(SUBFOLDER) + grid.setColumnMinimumWidth(0, width) + self.setFixedHeight(self.minimumSizeHint().height()) + + # connect + self.toggled.connect(self.valueChanged) + self.save_dir.textChanged.connect(self.valueChanged) + self.save_name.textChanged.connect(self.valueChanged) + + def currentPath(self) -> Path: + """Return the current save destination as a Path object.""" + return Path(self.save_dir.text(), str(self.save_name.text())) + + def setCurrentPath(self, path: str | Path) -> None: + """Set the save destination from a string or Path object.""" + path = Path(path) + self.save_dir.setText(str(path.parent)) + self.save_name.setText(path.name) + self._update_writer_from_name(allow_name_change=False) + + def value(self) -> SaveInfo: + """Return current state of the save widget.""" + return { + "save_dir": self.save_dir.text(), + "save_name": self.save_name.text(), + "format": self._writer_combo.currentText(), + "should_save": self.isChecked(), + } + + def setValue(self, value: dict | str | Path) -> None: + """Set the current state of the save widget. + + If value is a dict, keys should be: + - save_dir: str - Set the save directory. + - save_name: str - Set the save name. + - format: str - Set the combo box to the writer with this name. + - should_save: bool - Set the checked state of the checkbox. + """ + if isinstance(value, (str, Path)): + self.setCurrentPath(value) + self.setChecked(True) + return + + if (fmt := value.get("format")) and fmt not in WRITERS: # pragma: no cover + raise ValueError(f"Invalid format {fmt!r}. Must be one of {list(WRITERS)}") + + self.save_dir.setText(value.get("save_dir", "")) + self.save_name.setText(str(value.get("save_name", ""))) + self.setChecked(value.get("should_save", False)) + + if fmt: + self._writer_combo.setCurrentText(str(fmt)) + else: + self._update_writer_from_name() + + def _update_writer_from_name(self, allow_name_change: bool = True) -> None: + """Called when the user finishes editing the save_name widget. + + Updates the combo box to the writer with the same extension as the save name. + + Parameters + ---------- + allow_name_change : bool, optional + If True (default), allow the widget to update the save_name value + if the current name does not end with a known extension. If False, + the name will not be changed + """ + name = self.save_name.text() + if extension := _known_extension(name): + self._writer_combo.setCurrentText(EXT_TO_WRITER[extension]) + + elif not allow_name_change: + if ext := Path(name).suffix: + warn( + f"Invalid format {ext!r}. Defaulting to {TIFF_SEQ} writer.", + stacklevel=2, + ) + self._writer_combo.setCurrentText(TIFF_SEQ) + elif name: + # otherwise, if the name is not empty, add the first extension from the + # current writer + ext = WRITERS[self._writer_combo.currentText()][0] + self.save_name.setText(name + ext) + + def _on_browse_clicked(self) -> None: # pragma: no cover + """Open a dialog to select the save directory.""" + if save_dir := QFileDialog.getExistingDirectory( + self, "Select Save Directory", self.save_dir.text() + ): + self.save_dir.setText(save_dir) + + def _on_writer_combo_changed(self, writer: str) -> None: + """Called when the writer format combo box is changed. + + Updates save name to have the correct extension, and updates the label to + "Subfolder" or "Filename" depending on the writer type + """ + # update the label + self.name_label.setText(SUBFOLDER if writer in DIRECTORY_WRITERS else FILE_NAME) + + # if the name currently end with a known extension from the selected + # writer, then we're done + this_writer_extensions = WRITERS[writer] + current_name = self.save_name.text() + for ext in this_writer_extensions: + if ext and current_name.endswith(ext): + return + + # otherwise strip any known extension and add the first one from the new writer. + if name := _strip_known_extension(current_name): + name += this_writer_extensions[0] + self.save_name.setText(name) diff --git a/src/micromanager_gui/_widgets/_stack_viewer/__init__.py b/src/micromanager_gui/_widgets/_stack_viewer/__init__.py new file mode 100644 index 00000000..d144dff4 --- /dev/null +++ b/src/micromanager_gui/_widgets/_stack_viewer/__init__.py @@ -0,0 +1,4 @@ +from ._mda_viewer import MDAViewer +from ._stack_viewer import StackViewer + +__all__ = ["StackViewer", "MDAViewer"] diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_backends/__init__.py b/src/micromanager_gui/_widgets/_stack_viewer/_backends/__init__.py new file mode 100644 index 00000000..5813a5ba --- /dev/null +++ b/src/micromanager_gui/_widgets/_stack_viewer/_backends/__init__.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import importlib +import importlib.util +import os +import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from micromanager_gui._widgets._stack_viewer._protocols import PCanvas + + +def get_canvas(backend: str | None = None) -> type[PCanvas]: + backend = backend or os.getenv("CANVAS_BACKEND", None) + if backend == "vispy" or (backend is None and "vispy" in sys.modules): + from ._vispy import VispyViewerCanvas + + return VispyViewerCanvas + + if backend == "pygfx" or (backend is None and "pygfx" in sys.modules): + from ._pygfx import PyGFXViewerCanvas + + return PyGFXViewerCanvas + + if backend is None: + if importlib.util.find_spec("vispy") is not None: + from ._vispy import VispyViewerCanvas + + return VispyViewerCanvas + + if importlib.util.find_spec("pygfx") is not None: + from ._pygfx import PyGFXViewerCanvas + + return PyGFXViewerCanvas + + raise RuntimeError("No canvas backend found") diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_backends/_pygfx.py b/src/micromanager_gui/_widgets/_stack_viewer/_backends/_pygfx.py new file mode 100644 index 00000000..37fe110b --- /dev/null +++ b/src/micromanager_gui/_widgets/_stack_viewer/_backends/_pygfx.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, cast + +import numpy as np +import pygfx +from qtpy.QtCore import QSize +from wgpu.gui.qt import QWgpuCanvas + +if TYPE_CHECKING: + import cmap + from pygfx.materials import ImageBasicMaterial + from pygfx.resources import Texture + from qtpy.QtWidgets import QWidget + + +class PyGFXImageHandle: + def __init__(self, image: pygfx.Image, render: Callable) -> None: + self._image = image + self._render = render + self._grid = cast("Texture", image.geometry.grid) + self._material = cast("ImageBasicMaterial", image.material) + + @property + def data(self) -> np.ndarray: + return self._grid.data # type: ignore + + @data.setter + def data(self, data: np.ndarray) -> None: + self._grid.data[:] = data + self._grid.update_range((0, 0, 0), self._grid.size) + + @property + def visible(self) -> bool: + return bool(self._image.visible) + + @visible.setter + def visible(self, visible: bool) -> None: + self._image.visible = visible + self._render() + + @property + def clim(self) -> Any: + return self._material.clim + + @clim.setter + def clim(self, clims: tuple[float, float]) -> None: + self._material.clim = clims + self._render() + + @property + def cmap(self) -> cmap.Colormap: + return self._cmap + + @cmap.setter + def cmap(self, cmap: cmap.Colormap) -> None: + self._cmap = cmap + self._material.map = cmap.to_pygfx() + self._render() + + def remove(self) -> None: + if (par := self._image.parent) is not None: + par.remove(self._image) + + +class _QWgpuCanvas(QWgpuCanvas): + def sizeHint(self) -> QSize: + return QSize(512, 512) + + +class PyGFXViewerCanvas: + """pygfx-based canvas wrapper.""" + + def __init__(self, set_info: Callable[[str], None]) -> None: + self._set_info = set_info + + self._canvas = _QWgpuCanvas(size=(512, 512)) + self._renderer = pygfx.renderers.WgpuRenderer(self._canvas) + # requires https://github.com/pygfx/pygfx/pull/752 + self._renderer.blend_mode = "additive" + self._scene = pygfx.Scene() + self._camera = cam = pygfx.OrthographicCamera(512, 512) + cam.local.scale_y = -1 + + cam.local.position = (256, 256, 0) + self._controller = pygfx.PanZoomController(cam, register_events=self._renderer) + # increase zoom wheel gain + self._controller.controls.update({"wheel": ("zoom_to_point", "push", -0.005)}) + + def qwidget(self) -> QWidget: + return cast("QWidget", self._canvas) + + def refresh(self) -> None: + self._canvas.update() + self._canvas.request_draw(self._animate) + + def _animate(self) -> None: + self._renderer.render(self._scene, self._camera) + + def add_image( + self, data: np.ndarray | None = None, cmap: cmap.Colormap | None = None + ) -> PyGFXImageHandle: + """Add a new Image node to the scene.""" + image = pygfx.Image( + pygfx.Geometry(grid=pygfx.Texture(data, dim=2)), + # depth_test=False for additive-like blending + pygfx.ImageBasicMaterial(depth_test=False), + ) + self._scene.add(image) + # FIXME: I suspect there are more performant ways to refresh the canvas + # look into it. + handle = PyGFXImageHandle(image, self.refresh) + if cmap is not None: + handle.cmap = cmap + return handle + + def set_range( + self, + x: tuple[float, float] | None = None, + y: tuple[float, float] | None = None, + margin: float = 0.05, + ) -> None: + """Update the range of the PanZoomCamera. + + When called with no arguments, the range is set to the full extent of the data. + """ + if not self._scene.children: + return + + cam = self._camera + cam.show_object(self._scene) + + width, height, depth = np.ptp(self._scene.get_world_bounding_box(), axis=0) + if width < 0.01: + width = 1 + if height < 0.01: + height = 1 + cam.width = width + cam.height = height + cam.zoom = 1 - margin + self.refresh() + + # def _on_mouse_move(self, event: SceneMouseEvent) -> None: + # """Mouse moved on the canvas, display the pixel value and position.""" + # images = [] + # # Get the images the mouse is over + # seen = set() + # while visual := self._canvas.visual_at(event.pos): + # if isinstance(visual, scene.visuals.Image): + # images.append(visual) + # visual.interactive = False + # seen.add(visual) + # for visual in seen: + # visual.interactive = True + # if not images: + # return + + # tform = images[0].get_transform("canvas", "visual") + # px, py, *_ = (int(x) for x in tform.map(event.pos)) + # text = f"[{py}, {px}]" + # for c, img in enumerate(images): + # with suppress(IndexError): + # text += f" c{c}: {img._data[py, px]}" + # self._set_info(text) diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_backends/_vispy.py b/src/micromanager_gui/_widgets/_stack_viewer/_backends/_vispy.py new file mode 100644 index 00000000..d17e49ea --- /dev/null +++ b/src/micromanager_gui/_widgets/_stack_viewer/_backends/_vispy.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from contextlib import suppress +from typing import TYPE_CHECKING, Any, Callable, cast + +import numpy as np +from superqt.utils import qthrottled +from vispy import scene + +if TYPE_CHECKING: + import cmap + from qtpy.QtWidgets import QWidget + from vispy.scene.events import SceneMouseEvent + + +class VispyImageHandle: + def __init__(self, image: scene.visuals.Image) -> None: + self._image = image + + @property + def data(self) -> np.ndarray: + return self._image._data # type: ignore + + @data.setter + def data(self, data: np.ndarray) -> None: + self._image.set_data(data) + + @property + def visible(self) -> bool: + return bool(self._image.visible) + + @visible.setter + def visible(self, visible: bool) -> None: + self._image.visible = visible + + @property + def clim(self) -> Any: + return self._image.clim + + @clim.setter + def clim(self, clims: tuple[float, float]) -> None: + with suppress(ZeroDivisionError): + self._image.clim = clims + + @property + def cmap(self) -> cmap.Colormap: + return self._cmap + + @cmap.setter + def cmap(self, cmap: cmap.Colormap) -> None: + self._cmap = cmap + self._image.cmap = cmap.to_vispy() + + @property + def transform(self) -> np.ndarray: + raise NotImplementedError + + @transform.setter + def transform(self, transform: np.ndarray) -> None: + raise NotImplementedError + + def remove(self) -> None: + self._image.parent = None + + +class VispyViewerCanvas: + """Vispy-based viewer for data. + + All vispy-specific code is encapsulated in this class (and non-vispy canvases + could be swapped in if needed as long as they implement the same interface). + """ + + def __init__(self, set_info: Callable[[str], None]) -> None: + self._set_info = set_info + self._canvas = scene.SceneCanvas() + self._canvas.events.mouse_move.connect(qthrottled(self._on_mouse_move, 60)) + self._camera = scene.PanZoomCamera(aspect=1, flip=(0, 1)) + self._has_set_range = False + + central_wdg: scene.Widget = self._canvas.central_widget + self._view: scene.ViewBox = central_wdg.add_view(camera=self._camera) + + def qwidget(self) -> QWidget: + return cast("QWidget", self._canvas.native) + + def refresh(self) -> None: + self._canvas.update() + + def add_image( + self, data: np.ndarray | None = None, cmap: cmap.Colormap | None = None + ) -> VispyImageHandle: + """Add a new Image node to the scene.""" + img = scene.visuals.Image(data, parent=self._view.scene) + img.set_gl_state("additive", depth_test=False) + img.interactive = True + if not self._has_set_range: + self.set_range() + self._has_set_range = True + handle = VispyImageHandle(img) + if cmap is not None: + handle.cmap = cmap + return handle + + def set_range( + self, + x: tuple[float, float] | None = None, + y: tuple[float, float] | None = None, + margin: float = 0.01, + ) -> None: + """Update the range of the PanZoomCamera. + + When called with no arguments, the range is set to the full extent of the data. + """ + self._camera.set_range(x=x, y=y, margin=margin) + + def _on_mouse_move(self, event: SceneMouseEvent) -> None: + """Mouse moved on the canvas, display the pixel value and position.""" + images = [] + # Get the images the mouse is over + # FIXME: must be a better way to do this + seen = set() + try: + while visual := self._canvas.visual_at(event.pos): + if isinstance(visual, scene.visuals.Image): + images.append(visual) + visual.interactive = False + seen.add(visual) + except Exception: + return + for visual in seen: + visual.interactive = True + if not images: + return + + tform = images[0].get_transform("canvas", "visual") + px, py, *_ = (int(x) for x in tform.map(event.pos)) + text = f"[{py}, {px}]" + for c, img in enumerate(reversed(images)): + with suppress(IndexError): + value = img._data[py, px] + if isinstance(value, (np.floating, float)): + value = f"{value:.2f}" + text += f" {c}: {value}" + self._set_info(text) diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_dims_slider.py b/src/micromanager_gui/_widgets/_stack_viewer/_dims_slider.py new file mode 100644 index 00000000..2987c280 --- /dev/null +++ b/src/micromanager_gui/_widgets/_stack_viewer/_dims_slider.py @@ -0,0 +1,523 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast +from warnings import warn + +from qtpy.QtCore import QPoint, QPointF, QSize, Qt, Signal +from qtpy.QtGui import QCursor, QResizeEvent +from qtpy.QtWidgets import ( + QDialog, + QDoubleSpinBox, + QFormLayout, + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QSlider, + QSpinBox, + QVBoxLayout, + QWidget, +) +from superqt import QElidingLabel, QLabeledRangeSlider +from superqt.iconify import QIconifyIcon +from superqt.utils import signals_blocked + +if TYPE_CHECKING: + from typing import Hashable, Mapping, TypeAlias + + from PyQt6.QtGui import QResizeEvent + + # any hashable represent a single dimension in a AND array + DimKey: TypeAlias = Hashable + # any object that can be used to index a single dimension in an AND array + Index: TypeAlias = int | slice + # a mapping from dimension keys to indices (eg. {"x": 0, "y": slice(5, 10)}) + # this object is used frequently to query or set the currently displayed slice + Indices: TypeAlias = Mapping[DimKey, Index] + # mapping of dimension keys to the maximum value for that dimension + Sizes: TypeAlias = Mapping[DimKey, int] + + +SS = """ +QSlider::groove:horizontal { + height: 15px; + background: qlineargradient( + x1:0, y1:0, x2:0, y2:1, + stop:0 rgba(128, 128, 128, 0.25), + stop:1 rgba(128, 128, 128, 0.1) + ); + border-radius: 3px; +} + +QSlider::handle:horizontal { + width: 38px; + background: #999999; + border-radius: 3px; +} + +QLabel { font-size: 12px; } + +QRangeSlider { qproperty-barColor: qlineargradient( + x1:0, y1:0, x2:0, y2:1, + stop:0 rgba(100, 80, 120, 0.2), + stop:1 rgba(100, 80, 120, 0.4) + )} + +SliderLabel { + font-size: 12px; + color: white; +} +""" + + +class QtPopup(QDialog): + """A generic popup window.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setModal(False) # if False, then clicking anywhere else closes it + self.setWindowFlags(Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint) + + self.frame = QFrame(self) + layout = QVBoxLayout(self) + layout.addWidget(self.frame) + layout.setContentsMargins(0, 0, 0, 0) + + def show_above_mouse(self, *args: Any) -> None: + """Show popup dialog above the mouse cursor position.""" + pos = QCursor().pos() # mouse position + szhint = self.sizeHint() + pos -= QPoint(szhint.width() // 2, szhint.height() + 14) + self.move(pos) + self.resize(self.sizeHint()) + self.show() + + +class PlayButton(QPushButton): + """Just a styled QPushButton that toggles between play and pause icons.""" + + fpsChanged = Signal(float) + + PLAY_ICON = "bi:play-fill" + PAUSE_ICON = "bi:pause-fill" + + def __init__(self, fps: float = 20, parent: QWidget | None = None) -> None: + icn = QIconifyIcon(self.PLAY_ICON, color="#888888") + icn.addKey(self.PAUSE_ICON, state=QIconifyIcon.State.On, color="#4580DD") + super().__init__(icn, "", parent) + self.spin = QDoubleSpinBox(self) + self.spin.setRange(0.5, 100) + self.spin.setValue(fps) + self.spin.valueChanged.connect(self.fpsChanged) + self.setCheckable(True) + self.setFixedSize(14, 18) + self.setIconSize(QSize(16, 16)) + self.setStyleSheet("border: none; padding: 0; margin: 0;") + + self._popup = QtPopup(self) + form = QFormLayout(self._popup.frame) + form.setContentsMargins(6, 6, 6, 6) + form.addRow("FPS", self.spin) + + def mousePressEvent(self, e: Any) -> None: + if e and e.button() == Qt.MouseButton.RightButton: + self._show_fps_dialog(e.globalPosition()) + else: + super().mousePressEvent(e) + + def _show_fps_dialog(self, pos: QPointF) -> None: + self._popup.show_above_mouse() + + +class LockButton(QPushButton): + LOCK_ICON = "uis:unlock" + UNLOCK_ICON = "uis:lock" + + def __init__(self, text: str = "", parent: QWidget | None = None) -> None: + icn = QIconifyIcon(self.LOCK_ICON, color="#888888") + icn.addKey(self.UNLOCK_ICON, state=QIconifyIcon.State.On, color="red") + super().__init__(icn, text, parent) + self.setCheckable(True) + self.setFixedSize(20, 20) + self.setIconSize(QSize(14, 14)) + self.setStyleSheet("border: none; padding: 0; margin: 0;") + + +class DimsSlider(QWidget): + """A single slider in the DimsSliders widget. + + Provides a play/pause button that toggles animation of the slider value. + Has a QLabeledSlider for the actual value. + Adds a label for the maximum value (e.g. "3 / 10") + """ + + valueChanged = Signal(object, object) # where object is int | slice + + def __init__(self, dimension_key: DimKey, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setStyleSheet(SS) + self._slice_mode = False + self._dim_key = dimension_key + + self._timer_id: int | None = None # timer for play button + self._play_btn = PlayButton(parent=self) + self._play_btn.fpsChanged.connect(self.set_fps) + self._play_btn.toggled.connect(self._toggle_animation) + + self._dim_key = dimension_key + self._dim_label = QElidingLabel(str(dimension_key).upper()) + self._dim_label.setToolTip("Double-click to toggle slice mode") + + # note, this lock button only prevents the slider from updating programmatically + # using self.setValue, it doesn't prevent the user from changing the value. + self._lock_btn = LockButton(parent=self) + + self._pos_label = QSpinBox(self) + self._pos_label.valueChanged.connect(self._on_pos_label_edited) + self._pos_label.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons) + self._pos_label.setAlignment(Qt.AlignmentFlag.AlignRight) + self._pos_label.setStyleSheet( + "border: none; padding: 0; margin: 0; background: transparent" + ) + self._out_of_label = QLabel(self) + + self._int_slider = QSlider(Qt.Orientation.Horizontal) + self._int_slider.rangeChanged.connect(self._on_range_changed) + self._int_slider.valueChanged.connect(self._on_int_value_changed) + + self._slice_slider = slc = QLabeledRangeSlider(Qt.Orientation.Horizontal) + slc.setHandleLabelPosition(QLabeledRangeSlider.LabelPosition.LabelsOnHandle) + slc.setEdgeLabelMode(QLabeledRangeSlider.EdgeLabelMode.NoLabel) + slc.setVisible(False) + slc.rangeChanged.connect(self._on_range_changed) + slc.valueChanged.connect(self._on_slice_value_changed) + + self.installEventFilter(self) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + layout.addWidget(self._play_btn) + layout.addWidget(self._dim_label) + layout.addWidget(self._int_slider) + layout.addWidget(self._slice_slider) + layout.addWidget(self._pos_label) + layout.addWidget(self._out_of_label) + layout.addWidget(self._lock_btn) + self.setMinimumHeight(22) + + def resizeEvent(self, a0: QResizeEvent | None) -> None: + if isinstance(par := self.parent(), DimsSliders): + par.resizeEvent(None) + + def mouseDoubleClickEvent(self, a0: Any) -> None: + self._set_slice_mode(not self._slice_mode) + super().mouseDoubleClickEvent(a0) + + def containMaximum(self, max_val: int) -> None: + if max_val > self._int_slider.maximum(): + self._int_slider.setMaximum(max_val) + if max_val > self._slice_slider.maximum(): + self._slice_slider.setMaximum(max_val) + + def setMaximum(self, max_val: int) -> None: + self._int_slider.setMaximum(max_val) + self._slice_slider.setMaximum(max_val) + + def setMinimum(self, min_val: int) -> None: + self._int_slider.setMinimum(min_val) + self._slice_slider.setMinimum(min_val) + + def containMinimum(self, min_val: int) -> None: + if min_val < self._int_slider.minimum(): + self._int_slider.setMinimum(min_val) + if min_val < self._slice_slider.minimum(): + self._slice_slider.setMinimum(min_val) + + def setRange(self, min_val: int, max_val: int) -> None: + self._int_slider.setRange(min_val, max_val) + self._slice_slider.setRange(min_val, max_val) + + def value(self) -> Index: + if not self._slice_mode: + return self._int_slider.value() # type: ignore + start, *_, stop = cast("tuple[int, ...]", self._slice_slider.value()) + if start == stop: + return start + return slice(start, stop) + + def setValue(self, val: Index) -> None: + # variant of setValue that always updates the maximum + self._set_slice_mode(isinstance(val, slice)) + if self._lock_btn.isChecked(): + return + if isinstance(val, slice): + start = int(val.start) if val.start is not None else 0 + stop = ( + int(val.stop) if val.stop is not None else self._slice_slider.maximum() + ) + self._slice_slider.setValue((start, stop)) + else: + self._int_slider.setValue(val) + # self._slice_slider.setValue((val, val + 1)) + + def forceValue(self, val: Index) -> None: + """Set value and increase range if necessary.""" + if isinstance(val, slice): + if isinstance(val.start, int): + self.containMinimum(val.start) + if isinstance(val.stop, int): + self.containMaximum(val.stop) + else: + self.containMinimum(val) + self.containMaximum(val) + self.setValue(val) + + def _set_slice_mode(self, mode: bool = True) -> None: + if mode == self._slice_mode: + return + self._slice_mode = bool(mode) + self._slice_slider.setVisible(self._slice_mode) + self._int_slider.setVisible(not self._slice_mode) + # self._pos_label.setVisible(not self._slice_mode) + self.valueChanged.emit(self._dim_key, self.value()) + + def set_fps(self, fps: float) -> None: + self._play_btn.spin.setValue(fps) + self._toggle_animation(self._play_btn.isChecked()) + + def _toggle_animation(self, checked: bool) -> None: + if checked: + if self._timer_id is not None: + self.killTimer(self._timer_id) + interval = int(1000 / self._play_btn.spin.value()) + self._timer_id = self.startTimer(interval) + elif self._timer_id is not None: + self.killTimer(self._timer_id) + self._timer_id = None + + def timerEvent(self, event: Any) -> None: + """Handle timer event for play button, move to the next frame.""" + # TODO + # for now just increment the value by 1, but we should be able to + # take FPS into account better and skip additional frames if the timerEvent + # is delayed for some reason. + inc = 1 + if self._slice_mode: + val = cast(tuple[int, int], self._slice_slider.value()) + next_val = [v + inc for v in val] + if next_val[1] > self._slice_slider.maximum(): + # wrap around, without going below the min handle + next_val = [v - val[0] for v in val] + self._slice_slider.setValue(next_val) + else: + ival = self._int_slider.value() + ival = (ival + inc) % (self._int_slider.maximum() + 1) + self._int_slider.setValue(ival) + + def _on_pos_label_edited(self) -> None: + if self._slice_mode: + self._slice_slider.setValue( + (self._slice_slider.value()[0], self._pos_label.value()) + ) + else: + self._int_slider.setValue(self._pos_label.value()) + + def _on_range_changed(self, min: int, max: int) -> None: + self._out_of_label.setText(f"| {max}") + self._pos_label.setRange(min, max) + self.resizeEvent(None) + self.setVisible(min != max) + + def setVisible(self, visible: bool) -> None: + if self._has_no_range(): + visible = False + super().setVisible(visible) + + def _has_no_range(self) -> bool: + if self._slice_mode: + return bool(self._slice_slider.minimum() == self._slice_slider.maximum()) + return bool(self._int_slider.minimum() == self._int_slider.maximum()) + + def _on_int_value_changed(self, value: int) -> None: + self._pos_label.setValue(value) + if not self._slice_mode: + self.valueChanged.emit(self._dim_key, value) + + def _on_slice_value_changed(self, value: tuple[int, int]) -> None: + self._pos_label.setValue(int(value[1])) + with signals_blocked(self._int_slider): + self._int_slider.setValue(int(value[0])) + if self._slice_mode: + self.valueChanged.emit(self._dim_key, slice(*value)) + + +class DimsSliders(QWidget): + """A Collection of DimsSlider widgets for each dimension in the data. + + Maintains the global current index and emits a signal when it changes. + """ + + valueChanged = Signal(dict) # dict is of type Indices + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._locks_visible: bool | Mapping[DimKey, bool] = False + self._sliders: dict[DimKey, DimsSlider] = {} + self._current_index: dict[DimKey, Index] = {} + self._invisible_dims: set[DimKey] = set() + + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + def __contains__(self, key: DimKey) -> bool: + """Return True if the dimension key is present in the DimsSliders.""" + return key in self._sliders + + def slider(self, key: DimKey) -> DimsSlider: + """Return the DimsSlider widget for the given dimension key.""" + return self._sliders[key] + + def value(self) -> Indices: + """Return mapping of {dim_key -> current index} for each dimension.""" + return self._current_index.copy() + + def setValue(self, values: Indices) -> None: + """Set the current index for each dimension. + + Parameters + ---------- + values : Mapping[Hashable, int | slice] + Mapping of {dim_key -> index} for each dimension. If value is a slice, + the slider will be in slice mode. If the dimension is not present in the + DimsSliders, it will be added. + """ + if self._current_index == values: + return + with signals_blocked(self): + for dim, index in values.items(): + self.add_or_update_dimension(dim, index) + # FIXME: i don't know why this this is ever empty ... only happens on pyside6 + if val := self.value(): + self.valueChanged.emit(val) + + def minima(self) -> Sizes: + """Return mapping of {dim_key -> minimum value} for each dimension.""" + return {k: v._int_slider.minimum() for k, v in self._sliders.items()} + + def setMinima(self, values: Sizes) -> None: + """Set the minimum value for each dimension. + + Parameters + ---------- + values : Mapping[Hashable, int] + Mapping of {dim_key -> minimum value} for each dimension. + """ + for name, min_val in values.items(): + if name not in self._sliders: + self.add_dimension(name) + self._sliders[name].setMinimum(min_val) + + def maxima(self) -> Sizes: + """Return mapping of {dim_key -> maximum value} for each dimension.""" + return {k: v._int_slider.maximum() for k, v in self._sliders.items()} + + def setMaxima(self, values: Sizes) -> None: + """Set the maximum value for each dimension. + + Parameters + ---------- + values : Mapping[Hashable, int] + Mapping of {dim_key -> maximum value} for each dimension. + """ + for name, max_val in values.items(): + if name not in self._sliders: + self.add_dimension(name) + self._sliders[name].setMaximum(max_val) + + def set_locks_visible(self, visible: bool | Mapping[DimKey, bool]) -> None: + """Set the visibility of the lock buttons for all dimensions.""" + self._locks_visible = visible + for dim, slider in self._sliders.items(): + viz = visible if isinstance(visible, bool) else visible.get(dim, False) + slider._lock_btn.setVisible(viz) + + def add_dimension(self, name: DimKey, val: Index | None = None) -> None: + """Add a new dimension to the DimsSliders widget. + + Parameters + ---------- + name : Hashable + The name of the dimension. + val : int | slice, optional + The initial value for the dimension. If a slice, the slider will be in + slice mode. + """ + self._sliders[name] = slider = DimsSlider(dimension_key=name, parent=self) + if isinstance(self._locks_visible, dict) and name in self._locks_visible: + slider._lock_btn.setVisible(self._locks_visible[name]) + else: + slider._lock_btn.setVisible(bool(self._locks_visible)) + + val_int = val.start if isinstance(val, slice) else val + slider.setVisible(name not in self._invisible_dims) + if isinstance(val_int, int): + slider.setRange(val_int, val_int) + elif isinstance(val_int, slice): + slider.setRange(val_int.start or 0, val_int.stop or 1) + + val = val if val is not None else 0 + self._current_index[name] = val + slider.forceValue(val) + slider.valueChanged.connect(self._on_dim_slider_value_changed) + cast("QVBoxLayout", self.layout()).addWidget(slider) + + def set_dimension_visible(self, key: DimKey, visible: bool) -> None: + """Set the visibility of a dimension in the DimsSliders widget. + + Once a dimension is hidden, it will not be shown again until it is explicitly + made visible again with this method. + """ + if visible: + self._invisible_dims.discard(key) + else: + self._invisible_dims.add(key) + if key in self._sliders: + self._sliders[key].setVisible(visible) + + def remove_dimension(self, key: DimKey) -> None: + """Remove a dimension from the DimsSliders widget.""" + try: + slider = self._sliders.pop(key) + except KeyError: + warn(f"Dimension {key} not found in DimsSliders", stacklevel=2) + return + cast("QVBoxLayout", self.layout()).removeWidget(slider) + slider.deleteLater() + + def _on_dim_slider_value_changed(self, key: DimKey, value: Index) -> None: + self._current_index[key] = value + self.valueChanged.emit(self.value()) + + def add_or_update_dimension(self, key: DimKey, value: Index) -> None: + """Add a dimension if it doesn't exist, otherwise update the value.""" + if key in self._sliders: + self._sliders[key].forceValue(value) + else: + self.add_dimension(key, value) + + def resizeEvent(self, a0: QResizeEvent | None) -> None: + # align all labels + if sliders := list(self._sliders.values()): + for lbl in ("_dim_label", "_pos_label", "_out_of_label"): + lbl_width = max(getattr(s, lbl).sizeHint().width() for s in sliders) + for s in sliders: + getattr(s, lbl).setFixedWidth(lbl_width) + + super().resizeEvent(a0) + + def sizeHint(self) -> QSize: + return super().sizeHint().boundedTo(QSize(9999, 0)) diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_indexing.py b/src/micromanager_gui/_widgets/_stack_viewer/_indexing.py new file mode 100644 index 00000000..65c1cc4f --- /dev/null +++ b/src/micromanager_gui/_widgets/_stack_viewer/_indexing.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +import sys +import warnings +from abc import abstractmethod +from concurrent.futures import Future, ThreadPoolExecutor +from contextlib import suppress +from typing import TYPE_CHECKING, Generic, Hashable, Mapping, Sequence, TypeVar, cast + +import numpy as np + +if TYPE_CHECKING: + from pathlib import Path + from typing import Any, Protocol, TypeGuard + + import dask.array as da + import numpy.typing as npt + import tensorstore as ts + import xarray as xr + from pymmcore_plus.mda.handlers import TensorStoreHandler + from pymmcore_plus.mda.handlers._5d_writer_base import _5DWriterBase + + from ._dims_slider import Index, Indices + + class SupportsIndexing(Protocol): + def __getitem__(self, key: Index | tuple[Index, ...]) -> npt.ArrayLike: ... + @property + def shape(self) -> tuple[int, ...]: ... + + +ArrayT = TypeVar("ArrayT") +MAX_CHANNELS = 16 +# Create a global executor +_EXECUTOR = ThreadPoolExecutor(max_workers=1) + + +class DataWrapper(Generic[ArrayT]): + def __init__(self, data: ArrayT) -> None: + self._data = data + + @classmethod + def create(cls, data: ArrayT) -> DataWrapper[ArrayT]: + if isinstance(data, DataWrapper): + return data + if MMTensorStoreWrapper.supports(data): + return MMTensorStoreWrapper(data) # type: ignore + if MM5DWriter.supports(data): + return MM5DWriter(data) # type: ignore + if XarrayWrapper.supports(data): + return XarrayWrapper(data) + if DaskWrapper.supports(data): + return DaskWrapper(data) + if TensorstoreWrapper.supports(data): + return TensorstoreWrapper(data) + if ArrayLikeWrapper.supports(data): + return ArrayLikeWrapper(data) + raise NotImplementedError(f"Don't know how to wrap type {type(data)}") + + @abstractmethod + def isel(self, indexers: Indices) -> np.ndarray: + """Select a slice from a data store using (possibly) named indices. + + For xarray.DataArray, use the built-in isel method. + For any other duck-typed array, use numpy-style indexing, where indexers + is a mapping of axis to slice objects or indices. + """ + raise NotImplementedError + + def isel_async(self, indexers: Indices) -> Future[tuple[Indices, np.ndarray]]: + """Asynchronous version of isel.""" + return _EXECUTOR.submit(lambda: (indexers, self.isel(indexers))) + + @classmethod + @abstractmethod + def supports(cls, obj: Any) -> bool: + """Return True if this wrapper can handle the given object.""" + raise NotImplementedError + + def guess_channel_axis(self) -> Hashable | None: + """Return the (best guess) axis name for the channel dimension.""" + if isinstance(shp := getattr(self._data, "shape", None), Sequence): + # for numpy arrays, use the smallest dimension as the channel axis + if min(shp) <= MAX_CHANNELS: + return shp.index(min(shp)) + return None + + def save_as_zarr(self, save_loc: str | Path) -> None: + raise NotImplementedError("save_as_zarr not implemented for this data type.") + + def sizes(self) -> Mapping[Hashable, int]: + if (shape := getattr(self._data, "shape", None)) and isinstance(shape, tuple): + _sizes: dict[Hashable, int] = {} + for i, val in enumerate(shape): + if isinstance(val, int): + _sizes[i] = val + elif isinstance(val, Sequence) and len(val) == 2: + _sizes[val[0]] = int(val[1]) + else: + raise ValueError( + f"Invalid size: {val}. Must be an int or a 2-tuple." + ) + return _sizes + raise NotImplementedError(f"Cannot determine sizes for {type(self._data)}") + + def summary_info(self) -> str: + """Return info label with information about the data.""" + package = getattr(self._data, "__module__", "").split(".")[0] + info = f"{package}.{getattr(type(self._data), '__qualname__', '')}" + + if sizes := self.sizes(): + # if all of the dimension keys are just integers, omit them from size_str + if all(isinstance(x, int) for x in sizes): + size_str = repr(tuple(sizes.values())) + # otherwise, include the keys in the size_str + else: + size_str = ", ".join(f"{k}:{v}" for k, v in sizes.items()) + size_str = f"({size_str})" + info += f" {size_str}" + if dtype := getattr(self._data, "dtype", ""): + info += f", {dtype}" + if nbytes := getattr(self._data, "nbytes", 0) / 1e6: + info += f", {nbytes:.2f}MB" + return info + + +class MMTensorStoreWrapper(DataWrapper["TensorStoreHandler"]): + def sizes(self) -> Mapping[Hashable, int]: + with suppress(Exception): + return self._data.current_sequence.sizes # type: ignore + return {} + + def guess_channel_axis(self) -> Hashable | None: + return "c" + + @classmethod + def supports(cls, obj: Any) -> TypeGuard[TensorStoreHandler]: + from pymmcore_plus.mda.handlers import TensorStoreHandler + + return isinstance(obj, TensorStoreHandler) + + def isel(self, indexers: Indices) -> np.ndarray: + return self._data.isel(indexers) # type: ignore + + def save_as_zarr(self, save_loc: str | Path) -> None: + if (store := self._data.store) is None: + return + import tensorstore as ts + + new_spec = store.spec().to_json() + new_spec["kvstore"] = {"driver": "file", "path": str(save_loc)} + new_ts = ts.open(new_spec, create=True).result() + new_ts[:] = store.read().result() + + +class MM5DWriter(DataWrapper["_5DWriterBase"]): + def guess_channel_axis(self) -> Hashable | None: + return "c" + + @classmethod + def supports(cls, obj: Any) -> TypeGuard[_5DWriterBase]: + try: + from pymmcore_plus.mda.handlers._5d_writer_base import _5DWriterBase + except ImportError: + from pymmcore_plus.mda.handlers import OMETiffWriter, OMEZarrWriter + + _5DWriterBase = (OMETiffWriter, OMEZarrWriter) # type: ignore + if isinstance(obj, _5DWriterBase): + return True + return False + + def sizes(self) -> Mapping[Hashable, int]: + try: + return super().sizes() + except NotImplementedError: + return {} + + def save_as_zarr(self, save_loc: str | Path) -> None: + import zarr + from pymmcore_plus.mda.handlers import OMEZarrWriter + + if isinstance(self._data, OMEZarrWriter): + zarr.copy_store(self._data.group.store, zarr.DirectoryStore(save_loc)) + raise NotImplementedError(f"Cannot save {type(self._data)} data to Zarr.") + + def isel(self, indexers: Indices) -> np.ndarray: + p_index = indexers.get("p", 0) + if isinstance(p_index, slice): + warnings.warn("Cannot slice over position index", stacklevel=2) # TODO + p_index = p_index.start + p_index = cast(int, p_index) + + try: + sizes = [*list(self._data.position_sizes[p_index]), "y", "x"] + except IndexError as e: + raise IndexError( + f"Position index {p_index} out of range for " + f"{len(self._data.position_sizes)}" + ) from e + + data = self._data.position_arrays[self._data.get_position_key(p_index)] + full = slice(None, None) + index = tuple(indexers.get(k, full) for k in sizes) + return data[index] # type: ignore + + +class XarrayWrapper(DataWrapper["xr.DataArray"]): + def isel(self, indexers: Indices) -> np.ndarray: + return np.asarray(self._data.isel(indexers)) + + def sizes(self) -> Mapping[Hashable, int]: + return {k: int(v) for k, v in self._data.sizes.items()} + + @classmethod + def supports(cls, obj: Any) -> TypeGuard[xr.DataArray]: + if (xr := sys.modules.get("xarray")) and isinstance(obj, xr.DataArray): + return True + return False + + def guess_channel_axis(self) -> Hashable | None: + for d in self._data.dims: + if str(d).lower() in ("channel", "ch", "c"): + return cast("Hashable", d) + return None + + def save_as_zarr(self, save_loc: str | Path) -> None: + self._data.to_zarr(save_loc) + + +class DaskWrapper(DataWrapper["da.Array"]): + def isel(self, indexers: Indices) -> np.ndarray: + idx = tuple(indexers.get(k, slice(None)) for k in range(len(self._data.shape))) + return np.asarray(self._data[idx].compute()) + + @classmethod + def supports(cls, obj: Any) -> TypeGuard[da.Array]: + if (da := sys.modules.get("dask.array")) and isinstance(obj, da.Array): + return True + return False + + def save_as_zarr(self, save_loc: str | Path) -> None: + self._data.to_zarr(url=str(save_loc)) + + +class TensorstoreWrapper(DataWrapper["ts.TensorStore"]): + def __init__(self, data: Any) -> None: + super().__init__(data) + import tensorstore as ts + + self._ts = ts + + def sizes(self) -> Mapping[Hashable, int]: + return {dim.label: dim.size for dim in self._data.domain} + + def isel(self, indexers: Indices) -> np.ndarray: + # result = self._data[self._ts.d[*indexers][*indexers.values()]].read().result() + result = ( + self._data[self._ts.d[tuple(indexers.keys())][tuple(indexers.values())]] + .read() + .result() + ) + return np.asarray(result) + + @classmethod + def supports(cls, obj: Any) -> TypeGuard[ts.TensorStore]: + if (ts := sys.modules.get("tensorstore")) and isinstance(obj, ts.TensorStore): + return True + return False + + +class ArrayLikeWrapper(DataWrapper): + def isel(self, indexers: Indices) -> np.ndarray: + idx = tuple(indexers.get(k, slice(None)) for k in range(len(self._data.shape))) + return np.asarray(self._data[idx]) + + @classmethod + def supports(cls, obj: Any) -> TypeGuard[SupportsIndexing]: + if ( + isinstance(obj, np.ndarray) + or hasattr(obj, "__array_function__") + or hasattr(obj, "__array_namespace__") + or (hasattr(obj, "__getitem__") and hasattr(obj, "__array__")) + ): + return True + return False + + def save_as_zarr(self, save_loc: str | Path) -> None: + import zarr + + if isinstance(self._data, zarr.Array): + self._data.store = zarr.DirectoryStore(save_loc) + else: + zarr.save(str(save_loc), self._data) diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_lut_control.py b/src/micromanager_gui/_widgets/_stack_viewer/_lut_control.py new file mode 100644 index 00000000..65ba6977 --- /dev/null +++ b/src/micromanager_gui/_widgets/_stack_viewer/_lut_control.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Iterable, cast + +import numpy as np +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QCheckBox, QFrame, QHBoxLayout, QPushButton, QWidget +from superqt import QLabeledRangeSlider +from superqt.cmap import QColormapComboBox +from superqt.utils import signals_blocked + +from ._dims_slider import SS + +if TYPE_CHECKING: + import cmap + + from ._protocols import PImageHandle + + +class CmapCombo(QColormapComboBox): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent, allow_user_colormaps=True, add_colormap_text="Add...") + self.setMinimumSize(120, 21) + # self.setStyleSheet("background-color: transparent;") + + def showPopup(self) -> None: + super().showPopup() + popup = self.findChild(QFrame) + popup.setMinimumWidth(self.width() + 100) + popup.move(popup.x(), popup.y() - self.height() - popup.height()) + + +class LutControl(QWidget): + def __init__( + self, + name: str = "", + handles: Iterable[PImageHandle] = (), + parent: QWidget | None = None, + cmaplist: Iterable[Any] = (), + ) -> None: + super().__init__(parent) + self._handles = handles + self._name = name + + self._visible = QCheckBox(name) + self._visible.setChecked(True) + self._visible.toggled.connect(self._on_visible_changed) + + self._cmap = CmapCombo() + self._cmap.currentColormapChanged.connect(self._on_cmap_changed) + for handle in handles: + self._cmap.addColormap(handle.cmap) + for color in cmaplist: + self._cmap.addColormap(color) + + self._clims = QLabeledRangeSlider(Qt.Orientation.Horizontal) + self._clims.setStyleSheet(SS) + self._clims.setHandleLabelPosition( + QLabeledRangeSlider.LabelPosition.LabelsOnHandle + ) + self._clims.setEdgeLabelMode(QLabeledRangeSlider.EdgeLabelMode.NoLabel) + self._clims.setRange(0, 2**8) + self._clims.valueChanged.connect(self._on_clims_changed) + + self._auto_clim = QPushButton("Auto") + self._auto_clim.setMaximumWidth(42) + self._auto_clim.setCheckable(True) + self._auto_clim.setChecked(True) + self._auto_clim.toggled.connect(self.update_autoscale) + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._visible) + layout.addWidget(self._cmap) + layout.addWidget(self._clims) + layout.addWidget(self._auto_clim) + + self.update_autoscale() + + def autoscaleChecked(self) -> bool: + return cast("bool", self._auto_clim.isChecked()) + + def _on_clims_changed(self, clims: tuple[float, float]) -> None: + self._auto_clim.setChecked(False) + for handle in self._handles: + handle.clim = clims + + def _on_visible_changed(self, visible: bool) -> None: + for handle in self._handles: + handle.visible = visible + if visible: + self.update_autoscale() + + def _on_cmap_changed(self, cmap: cmap.Colormap) -> None: + for handle in self._handles: + handle.cmap = cmap + + def update_autoscale(self) -> None: + if ( + not self._auto_clim.isChecked() + or not self._visible.isChecked() + or not self._handles + ): + return + + # find the min and max values for the current channel + clims = [np.inf, -np.inf] + for handle in self._handles: + clims[0] = min(clims[0], np.nanmin(handle.data)) + clims[1] = max(clims[1], np.nanmax(handle.data)) + + mi, ma = tuple(int(x) for x in clims) + if mi != ma: + for handle in self._handles: + handle.clim = (mi, ma) + + # set the slider values to the new clims + with signals_blocked(self._clims): + self._clims.setMinimum(min(mi, self._clims.minimum())) + self._clims.setMaximum(max(ma, self._clims.maximum())) + self._clims.setValue((mi, ma)) diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_mda_viewer.py b/src/micromanager_gui/_widgets/_stack_viewer/_mda_viewer.py new file mode 100644 index 00000000..29f32a3a --- /dev/null +++ b/src/micromanager_gui/_widgets/_stack_viewer/_mda_viewer.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Any, Mapping + +import superqt +import useq +from pymmcore_plus.mda.handlers import TensorStoreHandler + +from ._save_button import SaveButton +from ._stack_viewer import StackViewer + +if TYPE_CHECKING: + from pymmcore_plus.mda.handlers._5d_writer_base import _5DWriterBase + from qtpy.QtWidgets import QWidget + + +class MDAViewer(StackViewer): + """StackViewer specialized for pymmcore-plus MDA acquisitions.""" + + _data: _5DWriterBase | TensorStoreHandler + + def __init__( + self, + datastore: _5DWriterBase | TensorStoreHandler | None = None, + *, + parent: QWidget | None = None, + ): + if datastore is None: + datastore = TensorStoreHandler() + + # patch the frameReady method to call the superframeReady method + # AFTER handling the event + self._superframeReady = getattr(datastore, "frameReady", None) + if callable(self._superframeReady): + datastore.frameReady = self._patched_frame_ready # type: ignore + else: # pragma: no cover + warnings.warn( + "MDAViewer: datastore does not have a frameReady method to patch, " + "are you sure this is a valid data handler?", + stacklevel=2, + ) + + super().__init__(datastore, parent=parent, channel_axis="c") + self._save_btn = SaveButton(self._data_wrapper) + self._btns.addWidget(self._save_btn) + self.dims_sliders.set_locks_visible(True) + self._channel_names: dict[int, str] = {} + + def _patched_frame_ready(self, *args: Any) -> None: + self._superframeReady(*args) # type: ignore + if len(args) >= 2 and isinstance(e := args[1], useq.MDAEvent): + self._on_frame_ready(e) + + @superqt.ensure_main_thread # type: ignore + def _on_frame_ready(self, event: useq.MDAEvent) -> None: + c = event.index.get(self._channel_axis) # type: ignore + if c not in self._channel_names and c is not None and event.channel: + self._channel_names[c] = event.channel.config + self.setIndex(event.index) # type: ignore + + def _get_channel_name(self, index: Mapping) -> str: + if self._channel_axis in index: + if name := self._channel_names.get(index[self._channel_axis]): + return name + return super()._get_channel_name(index) diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_protocols.py b/src/micromanager_gui/_widgets/_stack_viewer/_protocols.py new file mode 100644 index 00000000..8b8d5d67 --- /dev/null +++ b/src/micromanager_gui/_widgets/_stack_viewer/_protocols.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, Protocol + +if TYPE_CHECKING: + import cmap + import numpy as np + from qtpy.QtWidgets import QWidget + + +class PImageHandle(Protocol): + @property + def data(self) -> np.ndarray: ... + @data.setter + def data(self, data: np.ndarray) -> None: ... + @property + def visible(self) -> bool: ... + @visible.setter + def visible(self, visible: bool) -> None: ... + @property + def clim(self) -> Any: ... + @clim.setter + def clim(self, clims: tuple[float, float]) -> None: ... + @property + def cmap(self) -> Any: ... + @cmap.setter + def cmap(self, cmap: Any) -> None: ... + def remove(self) -> None: ... + + +class PCanvas(Protocol): + def __init__(self, set_info: Callable[[str], None]) -> None: ... + def set_range( + self, + x: tuple[float, float] | None = ..., + y: tuple[float, float] | None = ..., + margin: float = ..., + ) -> None: ... + def refresh(self) -> None: ... + def qwidget(self) -> QWidget: ... + def add_image( + self, data: np.ndarray | None = ..., cmap: cmap.Colormap | None = ... + ) -> PImageHandle: ... diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_save_button.py b/src/micromanager_gui/_widgets/_stack_viewer/_save_button.py new file mode 100644 index 00000000..85520641 --- /dev/null +++ b/src/micromanager_gui/_widgets/_stack_viewer/_save_button.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from qtpy.QtWidgets import QFileDialog, QPushButton, QWidget +from superqt.iconify import QIconifyIcon + +if TYPE_CHECKING: + from ._indexing import DataWrapper + + +class SaveButton(QPushButton): + def __init__( + self, + data_wrapper: DataWrapper, + parent: QWidget | None = None, + ): + super().__init__(parent=parent) + self.setIcon(QIconifyIcon("mdi:content-save")) + self.clicked.connect(self._on_click) + + self._data_wrapper = data_wrapper + self._last_loc = str(Path.home()) + + def _on_click(self) -> None: + self._last_loc, _ = QFileDialog.getSaveFileName( + self, "Choose destination", str(self._last_loc), "" + ) + suffix = Path(self._last_loc).suffix + if suffix in (".zarr", ".ome.zarr", ""): + self._data_wrapper.save_as_zarr(self._last_loc) + else: + raise ValueError(f"Unsupported file format: {self._last_loc}") diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_stack_viewer.py b/src/micromanager_gui/_widgets/_stack_viewer/_stack_viewer.py new file mode 100644 index 00000000..0ad90962 --- /dev/null +++ b/src/micromanager_gui/_widgets/_stack_viewer/_stack_viewer.py @@ -0,0 +1,541 @@ +from __future__ import annotations + +from collections import defaultdict +from enum import Enum +from itertools import cycle +from typing import TYPE_CHECKING, Iterable, Mapping, Sequence, cast + +import cmap +import numpy as np +from qtpy.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget +from superqt import QCollapsible, QElidingLabel, QIconifyIcon, ensure_main_thread +from superqt.utils import qthrottled, signals_blocked + +from ._backends import get_canvas +from ._dims_slider import DimsSliders +from ._indexing import DataWrapper +from ._lut_control import LutControl + +if TYPE_CHECKING: + from concurrent.futures import Future + from typing import Any, Callable, Hashable, TypeAlias + + from qtpy.QtGui import QCloseEvent + + from ._dims_slider import DimKey, Indices, Sizes + from ._protocols import PCanvas, PImageHandle + + ImgKey: TypeAlias = Hashable + # any mapping of dimensions to sizes + SizesLike: TypeAlias = Sizes | Iterable[int | tuple[DimKey, int] | Sequence] + +MID_GRAY = "#888888" +GRAYS = cmap.Colormap("gray") +DEFAULT_COLORMAPS = [ + cmap.Colormap("green"), + cmap.Colormap("magenta"), + cmap.Colormap("cyan"), + cmap.Colormap("yellow"), + cmap.Colormap("red"), + cmap.Colormap("blue"), + cmap.Colormap("cubehelix"), + cmap.Colormap("gray"), +] +ALL_CHANNELS = slice(None) + + +class ChannelMode(str, Enum): + COMPOSITE = "composite" + MONO = "mono" + + def __str__(self) -> str: + return self.value + + +class ChannelModeButton(QPushButton): + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setCheckable(True) + self.toggled.connect(self.next_mode) + + def next_mode(self) -> None: + if self.isChecked(): + self.setMode(ChannelMode.MONO) + else: + self.setMode(ChannelMode.COMPOSITE) + + def mode(self) -> ChannelMode: + return ChannelMode.MONO if self.isChecked() else ChannelMode.COMPOSITE + + def setMode(self, mode: ChannelMode) -> None: + # we show the name of the next mode, not the current one + other = ChannelMode.COMPOSITE if mode is ChannelMode.MONO else ChannelMode.MONO + self.setText(str(other)) + self.setChecked(mode == ChannelMode.MONO) + + +# @dataclass +# class LutModel: +# name: str = "" +# autoscale: bool = True +# min: float = 0.0 +# max: float = 1.0 +# colormap: cmap.Colormap = GRAYS +# visible: bool = True + + +# @dataclass +# class ViewerModel: +# data: Any = None +# # dimensions of the data that will *not* be sliced. +# visualized_dims: Container[DimKey] = (-2, -1) +# # the axis that represents the channels in the data +# channel_axis: DimKey | None = None +# # the mode for displaying the channels +# # if MONO, only the current selection of channel_axis is displayed +# # if COMPOSITE, the full channel_axis is sliced, and luts determine display +# channel_mode: ChannelMode = ChannelMode.MONO +# # map of index in the channel_axis to LutModel +# luts: Mapping[int, LutModel] = {} + + +class StackViewer(QWidget): + """A viewer for ND arrays. + + This widget displays a single slice from an ND array (or a composite of slices in + different colormaps). The widget provides sliders to select the slice to display, + and buttons to control the display mode of the channels. + + An important concept in this widget is the "index". The index is a mapping of + dimensions to integers or slices that define the slice of the data to display. For + example, a numpy slice of `[0, 1, 5:10]` would be represented as + `{0: 0, 1: 1, 2: slice(5, 10)}`, but dimensions can also be named, e.g. + `{'t': 0, 'c': 1, 'z': slice(5, 10)}`. The index is used to select the data from + the datastore, and to determine the position of the sliders. + + The flow of data is as follows: + + - The user sets the data using the `set_data` method. This will set the number + and range of the sliders to the shape of the data, and display the first slice. + - The user can then use the sliders to select the slice to display. The current + slice is defined as a `Mapping` of `{dim -> int|slice}` and can be retrieved + with the `_dims_sliders.value()` method. To programmatically set the current + position, use the `setIndex` method. This will set the values of the sliders, + which in turn will trigger the display of the new slice via the + `_update_data_for_index` method. + - `_update_data_for_index` is an asynchronous method that retrieves the data for + the given index from the datastore (using `_isel`) and queues the + `_on_data_slice_ready` method to be called when the data is ready. The logic + for extracting data from the datastore is defined in `_indexing.py`, which handles + idiosyncrasies of different datastores (e.g. xarray, tensorstore, etc). + - `_on_data_slice_ready` is called when the data is ready, and updates the image. + Note that if the slice is multidimensional, the data will be reduced to 2D using + max intensity projection (and double-clicking on any given dimension slider will + turn it into a range slider allowing a projection to be made over that dimension). + - The image is displayed on the canvas, which is an object that implements the + `PCanvas` protocol (mostly, it has an `add_image` method that returns a handle + to the added image that can be used to update the data and display). This + small abstraction allows for various backends to be used (e.g. vispy, pygfx, etc). + + Parameters + ---------- + data : Any + The data to display. This can be an ND array, an xarray DataArray, or any + object that supports numpy-style indexing. + parent : QWidget, optional + The parent widget of this widget. + channel_axis : Hashable, optional + The axis that represents the channels in the data. If not provided, this will + be guessed from the data. + channel_mode : ChannelMode, optional + The initial mode for displaying the channels. If not provided, this will be + set to ChannelMode.MONO. + """ + + def __init__( + self, + data: Any, + *, + colormaps: Iterable[cmap._colormap.ColorStopsLike] | None = None, + parent: QWidget | None = None, + channel_axis: DimKey | None = None, + channel_mode: ChannelMode | str = ChannelMode.MONO, + ): + super().__init__(parent=parent) + + # ATTRIBUTES ---------------------------------------------------- + + # dimensions of the data in the datastore + self._sizes: Sizes = {} + # mapping of key to a list of objects that control image nodes in the canvas + self._img_handles: defaultdict[ImgKey, list[PImageHandle]] = defaultdict(list) + # mapping of same keys to the LutControl objects control image display props + self._lut_ctrls: dict[ImgKey, LutControl] = {} + # the set of dimensions we are currently visualizing (e.g. XY) + # this is used to control which dimensions have sliders and the behavior + # of isel when selecting data from the datastore + self._visualized_dims: set[DimKey] = set() + # the axis that represents the channels in the data + self._channel_axis = channel_axis + self._channel_mode: ChannelMode = None # type: ignore # set in set_channel_mode + # colormaps that will be cycled through when displaying composite images + # TODO: allow user to set this + if colormaps is not None: + self._cmaps = [cmap.Colormap(c) for c in colormaps] + else: + self._cmaps = DEFAULT_COLORMAPS + self._cmap_cycle = cycle(self._cmaps) + # the last future that was created by _update_data_for_index + self._last_future: Future | None = None + # WIDGETS ---------------------------------------------------- + + # the button that controls the display mode of the channels + self._channel_mode_btn = ChannelModeButton(self) + self._channel_mode_btn.clicked.connect(self.set_channel_mode) + # button to reset the zoom of the canvas + self._set_range_btn = QPushButton( + QIconifyIcon("fluent:full-screen-maximize-24-filled"), "", self + ) + self._set_range_btn.clicked.connect(self._on_set_range_clicked) + + # place to display dataset summary + self._data_info_label = QElidingLabel("", parent=self) + # place to display arbitrary text + self._hover_info_label = QLabel("", self) + # the canvas that displays the images + self._canvas: PCanvas = get_canvas()(self._hover_info_label.setText) + # the sliders that control the index of the displayed image + self._dims_sliders = DimsSliders(self) + self._dims_sliders.valueChanged.connect( + qthrottled(self._update_data_for_index, 20, leading=True) + ) + + self._lut_drop = QCollapsible("LUTs", self) + self._lut_drop.setCollapsedIcon(QIconifyIcon("bi:chevron-down", color=MID_GRAY)) + self._lut_drop.setExpandedIcon(QIconifyIcon("bi:chevron-up", color=MID_GRAY)) + lut_layout = cast("QVBoxLayout", self._lut_drop.layout()) + lut_layout.setContentsMargins(0, 1, 0, 1) + lut_layout.setSpacing(0) + if ( + hasattr(self._lut_drop, "_content") + and (layout := self._lut_drop._content.layout()) is not None + ): + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # LAYOUT ----------------------------------------------------- + + self._btns = btns = QHBoxLayout() + btns.setContentsMargins(0, 0, 0, 0) + btns.setSpacing(0) + btns.addStretch() + btns.addWidget(self._channel_mode_btn) + btns.addWidget(self._set_range_btn) + + layout = QVBoxLayout(self) + layout.setSpacing(2) + layout.setContentsMargins(6, 6, 6, 6) + layout.addWidget(self._data_info_label) + layout.addWidget(self._canvas.qwidget(), 1) + layout.addWidget(self._hover_info_label) + layout.addWidget(self._dims_sliders) + layout.addWidget(self._lut_drop) + layout.addLayout(btns) + + # SETUP ------------------------------------------------------ + + self.set_channel_mode(channel_mode) + self.set_data(data) + + # ------------------- PUBLIC API ---------------------------- + + @property + def data(self) -> Any: + """Return the data backing the view.""" + return self._data_wrapper._data + + @data.setter + def data(self, data: Any) -> None: + """Set the data backing the view.""" + raise AttributeError("Cannot set data directly. Use `set_data` method.") + + @property + def dims_sliders(self) -> DimsSliders: + """Return the DimsSliders widget.""" + return self._dims_sliders + + @property + def sizes(self) -> Sizes: + """Return sizes {dimkey: int} of the dimensions in the datastore.""" + return self._sizes + + def set_data( + self, + data: Any, + sizes: SizesLike | None = None, + channel_axis: int | None = None, + visualized_dims: Iterable[DimKey] | None = None, + ) -> None: + """Set the datastore, and, optionally, the sizes of the data.""" + # store the data + self._data_wrapper = DataWrapper.create(data) + + # determine sizes of the data + self._sizes = self._data_wrapper.sizes() if sizes is None else _to_sizes(sizes) + + # set channel axis + if channel_axis is not None: + self._channel_axis = channel_axis + elif self._channel_axis is None: + self._channel_axis = self._data_wrapper.guess_channel_axis() + + # update the dimensions we are visualizing + if visualized_dims is None: + visualized_dims = list(self._sizes)[-2:] + self.set_visualized_dims(visualized_dims) + + # update the range of all the sliders to match the sizes we set above + with signals_blocked(self._dims_sliders): + self.update_slider_ranges() + # redraw + self.setIndex({}) + # update the data info label + self._data_info_label.setText(self._data_wrapper.summary_info()) + + def set_visualized_dims(self, dims: Iterable[DimKey]) -> None: + """Set the dimensions that will be visualized. + + This dims will NOT have sliders associated with them. + """ + self._visualized_dims = set(dims) + for d in self._dims_sliders._sliders: + self._dims_sliders.set_dimension_visible(d, d not in self._visualized_dims) + for d in self._visualized_dims: + self._dims_sliders.set_dimension_visible(d, False) + + def update_slider_ranges( + self, mins: SizesLike | None = None, maxes: SizesLike | None = None + ) -> None: + """Set the maximum values of the sliders. + + If `sizes` is not provided, sizes will be inferred from the datastore. + This is mostly here as a public way to reset the + """ + if maxes is None: + maxes = self._sizes + maxes = _to_sizes(maxes) + self._dims_sliders.setMaxima({k: v - 1 for k, v in maxes.items()}) + if mins is not None: + self._dims_sliders.setMinima(_to_sizes(mins)) + + # FIXME: this needs to be moved and made user-controlled + for dim in list(maxes.keys())[-2:]: + self._dims_sliders.set_dimension_visible(dim, False) + + def set_channel_mode(self, mode: ChannelMode | str | None = None) -> None: + """Set the mode for displaying the channels. + + In "composite" mode, the channels are displayed as a composite image, using + self._channel_axis as the channel axis. In "grayscale" mode, each channel is + displayed separately. (If mode is None, the current value of the + channel_mode_picker button is used) + """ + if mode is None or isinstance(mode, bool): + mode = self._channel_mode_btn.mode() + else: + mode = ChannelMode(mode) + self._channel_mode_btn.setMode(mode) + if mode == getattr(self, "_channel_mode", None): + return + + self._channel_mode = mode + self._cmap_cycle = cycle(self._cmaps) # reset the colormap cycle + if self._channel_axis is not None: + # set the visibility of the channel slider + self._dims_sliders.set_dimension_visible( + self._channel_axis, mode != ChannelMode.COMPOSITE + ) + + if self._img_handles: + self._clear_images() + self._update_data_for_index(self._dims_sliders.value()) + + def setIndex(self, index: Indices) -> None: + """Set the index of the displayed image.""" + self._dims_sliders.setValue(index) + + # ------------------- PRIVATE METHODS ---------------------------- + + def _on_set_range_clicked(self) -> None: + # using method to swallow the parameter passed by _set_range_btn.clicked + self._canvas.set_range() + + def _image_key(self, index: Indices) -> ImgKey: + """Return the key for image handle(s) corresponding to `index`.""" + if self._channel_mode == ChannelMode.COMPOSITE: + val = index.get(self._channel_axis, 0) + if isinstance(val, slice): + return (val.start, val.stop) + return val + return 0 + + def _update_data_for_index(self, index: Indices) -> None: + """Retrieve data for `index` from datastore and update canvas image(s). + + This will pull the data from the datastore using the given index, and update + the image handle(s) with the new data. This method is *asynchronous*. It + makes a request for the new data slice and queues _on_data_future_done to be + called when the data is ready. + """ + if ( + self._channel_axis is not None + and self._channel_mode == ChannelMode.COMPOSITE + ): + index = {**index, self._channel_axis: ALL_CHANNELS} + + if self._last_future: + self._last_future.cancel() + + self._last_future = f = self._isel(index) + f.add_done_callback(self._on_data_slice_ready) + + def closeEvent(self, a0: QCloseEvent | None) -> None: + if self._last_future is not None: + self._last_future.cancel() + self._last_future = None + super().closeEvent(a0) + + def _isel(self, index: Indices) -> Future[tuple[Indices, np.ndarray]]: + """Select data from the datastore using the given index.""" + idx = {k: v for k, v in index.items() if k not in self._visualized_dims} + try: + return self._data_wrapper.isel_async(idx) + except Exception as e: + raise type(e)(f"Failed to index data with {idx}: {e}") from e + + @ensure_main_thread # type: ignore + def _on_data_slice_ready(self, future: Future[tuple[Indices, np.ndarray]]) -> None: + """Update the displayed image for the given index. + + Connected to the future returned by _isel. + """ + # NOTE: removing the reference to the last future here is important + # because the future has a reference to this widget in its _done_callbacks + # which will prevent the widget from being garbage collected if the future + self._last_future = None + if future.cancelled(): + return + + index, data = future.result() + # assume that if we have channels remaining, that they are the first axis + # FIXME: this is a bad assumption + data = iter(data) if index.get(self._channel_axis) is ALL_CHANNELS else [data] + # FIXME: + # `self._channel_axis: i` is a bug; we assume channel indices start at 0 + # but the actual values used for indices are up to the user. + for i, datum in enumerate(data): + self._update_canvas_data(datum, {**index, self._channel_axis: i}) + self._canvas.refresh() + + def _update_canvas_data(self, data: np.ndarray, index: Indices) -> None: + """Actually update the image handle(s) with the (sliced) data. + + By this point, data should be sliced from the underlying datastore. Any + dimensions remaining that are more than the number of visualized dimensions + (currently just 2D) will be reduced using max intensity projection (currently). + """ + imkey = self._image_key(index) + datum = self._reduce_data_for_display(data) + if handles := self._img_handles[imkey]: + for handle in handles: + handle.data = datum + if ctrl := self._lut_ctrls.get(imkey, None): + ctrl.update_autoscale() + else: + cm = ( + next(self._cmap_cycle) + if self._channel_mode == ChannelMode.COMPOSITE + else GRAYS + ) + # FIXME: this is a hack ... + # however, there's a bug in the vispy backend such that if the first + # image is all zeros, it persists even if the data is updated + # it's better just to not add it at all... + if np.max(datum) == 0: + return + handles.append(self._canvas.add_image(datum, cmap=cm)) + if imkey not in self._lut_ctrls: + channel_name = self._get_channel_name(index) + self._lut_ctrls[imkey] = c = LutControl( + channel_name, + handles, + self, + cmaplist=self._cmaps + DEFAULT_COLORMAPS, + ) + self._lut_drop.addWidget(c) + + def _get_channel_name(self, index: Indices) -> str: + c = index.get(self._channel_axis, 0) + return f"Ch {c}" # TODO: get name from user + + def _reduce_data_for_display( + self, data: np.ndarray, reductor: Callable[..., np.ndarray] = np.max + ) -> np.ndarray: + """Reduce the number of dimensions in the data for display. + + This function takes a data array and reduces the number of dimensions to + the max allowed for display. The default behavior is to reduce the smallest + dimensions, using np.max. This can be improved in the future. + + This also coerces 64-bit data to 32-bit data. + """ + # TODO + # - allow for 3d data + # - allow dimensions to control how they are reduced (as opposed to just max) + # - for better way to determine which dims need to be reduced (currently just + # the smallest dims) + data = data.squeeze() + visualized_dims = 2 + if extra_dims := data.ndim - visualized_dims: + shapes = sorted(enumerate(data.shape), key=lambda x: x[1]) + smallest_dims = tuple(i for i, _ in shapes[:extra_dims]) + return reductor(data, axis=smallest_dims) + + if data.dtype.itemsize > 4: # More than 32 bits + if np.issubdtype(data.dtype, np.integer): + data = data.astype(np.int32) + else: + data = data.astype(np.float32) + return data + + def _clear_images(self) -> None: + """Remove all images from the canvas.""" + for handles in self._img_handles.values(): + for handle in handles: + handle.remove() + self._img_handles.clear() + + # clear the current LutControls as well + for c in self._lut_ctrls.values(): + cast("QVBoxLayout", self.layout()).removeWidget(c) + c.deleteLater() + self._lut_ctrls.clear() + + +def _to_sizes(sizes: SizesLike | None) -> Sizes: + """Coerce `sizes` to a {dimKey -> int} mapping.""" + if sizes is None: + return {} + if isinstance(sizes, Mapping): + return {k: int(v) for k, v in sizes.items()} + if not isinstance(sizes, Iterable): + raise TypeError(f"SizeLike must be an iterable or mapping, not: {type(sizes)}") + _sizes: dict[Hashable, int] = {} + for i, val in enumerate(sizes): + if isinstance(val, int): + _sizes[i] = val + elif isinstance(val, Sequence) and len(val) == 2: + _sizes[val[0]] = int(val[1]) + else: + raise ValueError(f"Invalid size: {val}. Must be an int or a 2-tuple.") + return _sizes diff --git a/test.ome.zarr/.zgroup b/test.ome.zarr/.zgroup new file mode 100644 index 00000000..3b7daf22 --- /dev/null +++ b/test.ome.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/test_gui.py b/tests/test_gui.py index b19f2baf..4cfb1fb6 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -2,11 +2,9 @@ from typing import TYPE_CHECKING -# from pymmcore_plus.mda.handlers import TensorStoreHandler -from pymmcore_widgets._stack_viewer_v2._mda_viewer import MDAViewer - from micromanager_gui import MicroManagerGUI from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS +from micromanager_gui._widgets._stack_viewer._mda_viewer import MDAViewer if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/tests/test_mda_viewer.py b/tests/test_mda_viewer.py index a76e519d..6c7767ad 100644 --- a/tests/test_mda_viewer.py +++ b/tests/test_mda_viewer.py @@ -1,20 +1,30 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING, cast from unittest.mock import patch import pytest import useq -from pymmcore_plus.mda.handlers import OMETiffWriter, OMEZarrWriter, TensorStoreHandler +from pymmcore_plus.mda.handlers import ( + OMETiffWriter, + OMEZarrWriter, + TensorStoreHandler, +) from pymmcore_plus.metadata import SummaryMetaV1 -from pymmcore_widgets._stack_viewer_v2 import MDAViewer from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY from micromanager_gui import MicroManagerGUI +from micromanager_gui._widgets._mda_widget import MDAWidget +from micromanager_gui._widgets._mda_widget._save_widget import ( + OME_TIFF, + OME_ZARR, + TIFF_SEQ, + ZARR_TESNSORSTORE, +) +from micromanager_gui._widgets._stack_viewer import MDAViewer if TYPE_CHECKING: - from pathlib import Path - from pymmcore_plus import CMMCorePlus from pytestqt.qtbot import QtBot @@ -73,9 +83,8 @@ def test_mda_viewer_saving( gui._menu_bar._mda.setValue(mda) # patch the run_mda method to avoid running the MDA sequence - def _run_mda(seq): - print("Running MDA") - return True + def _run_mda(seq, output): + return # set the writer attribute of the MDAWidget without running the MDA sequence with patch.object(global_mmcore, "run_mda", _run_mda): @@ -90,3 +99,21 @@ def _run_mda(seq): # saving datastore and MDAViewer datastore should be the same viewer = cast(MDAViewer, gui._core_link._viewer_tab.widget(1)) assert viewer.data == gui._core_link._mda.writer + + +data = [ + ("./test.ome.tiff", OME_TIFF, OMETiffWriter), + ("./test.ome.zarr", OME_ZARR, OMEZarrWriter), + ("./test.tensorstore.zarr", ZARR_TESNSORSTORE, TensorStoreHandler), + ("./test", TIFF_SEQ, None), +] + + +@pytest.mark.parametrize("data", data) +def test_mda_writer(qtbot: QtBot, tmp_path: Path, data: tuple) -> None: + wdg = MDAWidget() + qtbot.addWidget(wdg) + wdg.show() + path, save_format, cls = data + writer = wdg._create_writer(save_format, Path(path)) + assert isinstance(writer, cls) if writer is not None else writer is None diff --git a/tests/test_readers_writers.py b/tests/test_readers_writers.py index c2fa2097..90bb4a8b 100644 --- a/tests/test_readers_writers.py +++ b/tests/test_readers_writers.py @@ -5,12 +5,8 @@ import pytest import tifffile import useq - -# from pymmcore_plus.mda.handlers import TensorStoreHandler -# from pymmcore_widgets._stack_viewer_v2._mda_viewer import MDAViewer from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY -# from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS from micromanager_gui._readers._ome_zarr_reader import OMEZarrReader from micromanager_gui._readers._tensorstore_zarr_reader import TensorstoreZarrReader from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler diff --git a/tests/test_save_widget.py b/tests/test_save_widget.py new file mode 100644 index 00000000..bac5fff9 --- /dev/null +++ b/tests/test_save_widget.py @@ -0,0 +1,103 @@ +from pathlib import Path + +import pytest +from pytestqt.qtbot import QtBot + +from micromanager_gui._widgets._mda_widget._save_widget import ( + DIRECTORY_WRITERS, + FILE_NAME, + OME_TIFF, + OME_ZARR, + SUBFOLDER, + TIFF_SEQ, + WRITERS, + ZARR_TESNSORSTORE, + SaveGroupBox, +) + + +def test_set_get_value(qtbot: QtBot) -> None: + wdg = SaveGroupBox() + qtbot.addWidget(wdg) + + # Can be set with a Path or a string, in which case `should_save` be set to True + path = Path("/some_path/some_file") + wdg.setValue(path) + assert wdg.value() == { + "save_dir": str(path.parent), + "save_name": str(path.name), + "should_save": True, + "format": TIFF_SEQ, + } + + # When setting to a file with an extension, the format is set to the known writer + wdg.setValue("/some_path/some_file.ome.tif") + assert wdg.value()["format"] == OME_TIFF + + # unrecognized extensions warn and default to TIFF_SEQ + with pytest.warns( + UserWarning, match=f"Invalid format '.png'. Defaulting to {TIFF_SEQ}." + ): + wdg.setValue("/some_path/some_file.png") + assert wdg.value() == { + "save_dir": str(path.parent), + "save_name": "some_file.png", # note, we don't change the name + "should_save": True, + "format": TIFF_SEQ, + } + + # Can be set with a dict. + # note that when setting with a dict, should_save must be set explicitly + wdg.setValue({"save_dir": str(path.parent), "save_name": "some_file.ome.zarr"}) + assert wdg.value() == { + "save_dir": str(path.parent), + "save_name": "some_file.ome.zarr", + "should_save": False, + "format": OME_ZARR, + } + + # setting zarr tensorstore format (dict) + wdg.setValue({"save_dir": str(path.parent), "save_name": "ts.tensorstore.zarr"}) + assert wdg.value() == { + "save_dir": str(path.parent), + "save_name": "ts.tensorstore.zarr", + "should_save": False, + "format": ZARR_TESNSORSTORE, + } + + # setting zarr tensorstore format (path / string) + wdg.setValue("/some_path/ts.tensorstore.zarr") + assert wdg.value() == { + "save_dir": str(path.parent), + "save_name": "ts.tensorstore.zarr", + "should_save": True, + "format": ZARR_TESNSORSTORE, + } + + +def test_save_box_autowriter_selection(qtbot: QtBot) -> None: + """Test that setting the name to known extension changes the format""" + wdg = SaveGroupBox() + qtbot.addWidget(wdg) + + wdg.save_name.setText("name.ome.tiff") + wdg.save_name.editingFinished.emit() # this only happens in the GUI + assert wdg._writer_combo.currentText() == OME_TIFF + + # and it goes both ways + wdg._writer_combo.setCurrentText(OME_ZARR) + assert wdg.save_name.text() == "name.ome.zarr" + + +@pytest.mark.parametrize("writer", WRITERS) +def test_writer_combo_text_changed(qtbot: QtBot, writer: str) -> None: + wdg = SaveGroupBox() + qtbot.addWidget(wdg) + wdg._writer_combo.setCurrentText(writer) + wdg.save_name.setText("name") + wdg.save_name.editingFinished.emit() + + assert wdg._writer_combo.currentText() == writer + expected_label = SUBFOLDER if writer in DIRECTORY_WRITERS else FILE_NAME + assert wdg.name_label.text() == expected_label + assert wdg.save_name.text() == f"name{WRITERS[writer][0]}" diff --git a/tests/test_stack_viewer.py b/tests/test_stack_viewer.py new file mode 100644 index 00000000..f8fc2049 --- /dev/null +++ b/tests/test_stack_viewer.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import dask.array as da +import numpy as np + +from micromanager_gui._widgets._stack_viewer import StackViewer + +if TYPE_CHECKING: + from pytestqt.qtbot import QtBot + + +def make_lazy_array(shape: tuple[int, ...]) -> da.Array: + rest_shape = shape[:-2] + frame_shape = shape[-2:] + + def _dask_block(block_id: tuple[int, int, int, int, int]) -> np.ndarray | None: + if isinstance(block_id, np.ndarray): + return None + size = (1,) * len(rest_shape) + frame_shape + return np.random.randint(0, 255, size=size, dtype=np.uint8) + + chunks = [(1,) * x for x in rest_shape] + [(x,) for x in frame_shape] + return da.map_blocks(_dask_block, chunks=chunks, dtype=np.uint8) # type: ignore + + +# this test is still leaking widgets and it's hard to track down... I think +# it might have to do with the cmapComboBox +# @pytest.mark.allow_leaks +def test_stack_viewer(qtbot: QtBot) -> None: + dask_arr = make_lazy_array((1000, 64, 3, 256, 256)) + v = StackViewer(dask_arr) + qtbot.addWidget(v) + v.show() + + # wait until there are no running jobs, because the callbacks + # in the futures hold a strong reference to the viewer + qtbot.waitUntil(lambda: v._last_future is None, timeout=1000) + + +def test_dims_sliders(qtbot: QtBot) -> None: + from superqt import QLabeledRangeSlider + + from micromanager_gui._widgets._stack_viewer._dims_slider import DimsSlider + + # temporary debugging + ds = DimsSlider(dimension_key="t") + qtbot.addWidget(ds) + + rs = QLabeledRangeSlider() + qtbot.addWidget(rs) From b1398637edc28b5716c9193072e2431f4699cfeb Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 9 Oct 2024 15:38:01 -0400 Subject: [PATCH 114/226] fix --- src/micromanager_gui/_widgets/_mda_widget/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/micromanager_gui/_widgets/_mda_widget/__init__.py b/src/micromanager_gui/_widgets/_mda_widget/__init__.py index aac7b065..f0298325 100644 --- a/src/micromanager_gui/_widgets/_mda_widget/__init__.py +++ b/src/micromanager_gui/_widgets/_mda_widget/__init__.py @@ -1,7 +1,4 @@ -"""MDA widgets for the micromanager-gui package. - -micromanager-gui: https://github.com/fdrgsp/micromanager-gui -""" +"""MDA widgets.""" from ._core_mda import MDAWidget From 83c22caeefe84ab8af9610dbf064860aacec222e Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 9 Oct 2024 15:40:01 -0400 Subject: [PATCH 115/226] rename --- src/micromanager_gui/_core_link.py | 2 +- src/micromanager_gui/_menubar/_menubar.py | 2 +- src/micromanager_gui/_widgets/_mda_widget.py | 165 ++++++++++++++++++ .../{_mda_widget => mda_widget}/__init__.py | 0 .../{_mda_widget => mda_widget}/_core_mda.py | 0 .../_save_widget.py | 0 tests/test_mda_viewer.py | 6 +- tests/test_save_widget.py | 2 +- 8 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 src/micromanager_gui/_widgets/_mda_widget.py rename src/micromanager_gui/_widgets/{_mda_widget => mda_widget}/__init__.py (100%) rename src/micromanager_gui/_widgets/{_mda_widget => mda_widget}/_core_mda.py (100%) rename src/micromanager_gui/_widgets/{_mda_widget => mda_widget}/_save_widget.py (100%) diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index 53e4596c..3ccc230e 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -21,7 +21,7 @@ from pymmcore_plus.metadata import SummaryMetaV1 from ._main_window import MicroManagerGUI - from ._widgets._mda_widget import MDAWidget + from ._widgets.mda_widget import MDAWidget class CoreViewersLink(QObject): diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index 24b6747f..ba087bbf 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -24,8 +24,8 @@ ) from micromanager_gui._widgets._install_widget import _InstallWidget -from micromanager_gui._widgets._mda_widget import MDAWidget from micromanager_gui._widgets._stage_control import _StagesControlWidget +from micromanager_gui._widgets.mda_widget import MDAWidget if TYPE_CHECKING: from micromanager_gui._main_window import MicroManagerGUI diff --git a/src/micromanager_gui/_widgets/_mda_widget.py b/src/micromanager_gui/_widgets/_mda_widget.py new file mode 100644 index 00000000..05b09078 --- /dev/null +++ b/src/micromanager_gui/_widgets/_mda_widget.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, cast + +from pymmcore_plus.mda.handlers import ( + ImageSequenceWriter, + OMETiffWriter, + OMEZarrWriter, + TensorStoreHandler, +) +from pymmcore_widgets.mda import MDAWidget +from pymmcore_widgets.mda._save_widget import ( + OME_TIFF, + OME_ZARR, + WRITERS, + ZARR_TESNSORSTORE, +) +from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY +from useq import MDASequence + +from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler + +OME_TIFFS = tuple(WRITERS[OME_TIFF]) +GB_CACHE = 2_000_000_000 # 2 GB for tensorstore cache + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from qtpy.QtWidgets import ( + QVBoxLayout, + QWidget, + ) + from useq import MDASequence + + +class _MDAWidget(MDAWidget): + """Main napari-micromanager GUI.""" + + def __init__( + self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent=parent, mmcore=mmcore) + + # writer for saving the MDA sequence. This is used by the MDAViewer to set its + # internal datastore. If _writer is None, the MDAViewer will use its default + # internal datastore. + self.writer: OMETiffWriter | OMEZarrWriter | TensorStoreHandler | None = None + + # setContentsMargins + pos_layout = cast("QVBoxLayout", self.stage_positions.layout()) + pos_layout.setContentsMargins(10, 10, 10, 10) + time_layout = cast("QVBoxLayout", self.time_plan.layout()) + time_layout.setContentsMargins(10, 10, 10, 10) + + def _on_mda_finished(self, sequence: MDASequence) -> None: + """Handle the end of the MDA sequence.""" + self.writer = None + super()._on_mda_finished(sequence) + + def run_mda(self) -> None: + """Run the MDA sequence experiment.""" + # in case the user does not press enter after editing the save name. + self.save_info.save_name.editingFinished.emit() + + # if autofocus has been requested, but the autofocus device is not engaged, + # and position-specific offsets haven't been set, show a warning + pos = self.stage_positions + if ( + self.af_axis.value() + and not self._mmc.isContinuousFocusLocked() + and (not self.tab_wdg.isChecked(pos) or not pos.af_per_position.isChecked()) + and not self._confirm_af_intentions() + ): + return + + sequence = self.value() + + # reset the writer + self.writer = None + + # technically, this is in the metadata as well, but isChecked is more direct + if self.save_info.isChecked(): + save_path = self._update_save_path_from_metadata( + sequence, update_metadata=True + ) + if isinstance(save_path, Path): + # get save format from metadata + save_meta = sequence.metadata.get(PYMMCW_METADATA_KEY, {}) + save_format = save_meta.get("format") + # set the writer to use for saving the MDA sequence. + # NOTE: 'self._writer' is used by the 'MDAViewer' to set its datastore + self.writer = self._create_mda_viewer_writer(save_format, save_path) + # at this point, if self.writer is None, it means that a + # ImageSequenceWriter should be used to save the sequence. + if self.writer is None: + output = ImageSequenceWriter(save_path) + # Since any other type of writer will be handled by the 'MDAViewer', + # we need to pass a writer to the engine only if it is a + # 'ImageSequenceWriter'. + self._mmc.run_mda(sequence, output=output) + return + + self._mmc.run_mda(sequence) + + def _create_mda_viewer_writer( + self, save_format: str, save_path: Path + ) -> OMEZarrWriter | OMETiffWriter | TensorStoreHandler | None: + """Create a writer for the MDAViewer based on the save format.""" + # use internal OME-TIFF writer if selected + if OME_TIFF in save_format: + # if OME-TIFF, save_path should be a directory without extension, so + # we need to add the ".ome.tif" to correctly use the OMETiffWriter + if not save_path.name.endswith(OME_TIFFS): + save_path = save_path.with_suffix(OME_TIFF) + return OMETiffWriter(save_path) + elif OME_ZARR in save_format: + return OMEZarrWriter(save_path) + elif ZARR_TESNSORSTORE in save_format: + return self._create_zarr_tensorstore(save_path) + # cannot use the ImageSequenceWriter here because the MDAViewer will not be + # able to handle it. + return None + + def _create_zarr_tensorstore(self, save_path: Path) -> _TensorStoreHandler: + """Create a Zarr TensorStore writer.""" + return _TensorStoreHandler( + driver="zarr", + path=save_path, + delete_existing=True, + spec={"context": {"cache_pool": {"total_bytes_limit": GB_CACHE}}}, + ) + + def _update_save_path_from_metadata( + self, + sequence: MDASequence, + update_widget: bool = True, + update_metadata: bool = False, + ) -> Path | None: + """Get the next available save path from sequence metadata and update widget. + + Parameters + ---------- + sequence : MDASequence + The MDA sequence to get the save path from. (must be in the + 'pymmcore_widgets' key of the metadata) + update_widget : bool, optional + Whether to update the save widget with the new path, by default True. + update_metadata : bool, optional + Whether to update the Sequence metadata with the new path, by default False. + """ + if ( + (meta := sequence.metadata.get(PYMMCW_METADATA_KEY, {})) + and (save_dir := meta.get("save_dir")) + and (save_name := meta.get("save_name")) + ): + requested = (Path(save_dir) / str(save_name)).expanduser().resolve() + next_path = self.get_next_available_path(requested) + + if next_path != requested: + if update_widget: + self.save_info.setValue(next_path) + if update_metadata: + meta.update(self.save_info.value()) + return Path(next_path) + return None diff --git a/src/micromanager_gui/_widgets/_mda_widget/__init__.py b/src/micromanager_gui/_widgets/mda_widget/__init__.py similarity index 100% rename from src/micromanager_gui/_widgets/_mda_widget/__init__.py rename to src/micromanager_gui/_widgets/mda_widget/__init__.py diff --git a/src/micromanager_gui/_widgets/_mda_widget/_core_mda.py b/src/micromanager_gui/_widgets/mda_widget/_core_mda.py similarity index 100% rename from src/micromanager_gui/_widgets/_mda_widget/_core_mda.py rename to src/micromanager_gui/_widgets/mda_widget/_core_mda.py diff --git a/src/micromanager_gui/_widgets/_mda_widget/_save_widget.py b/src/micromanager_gui/_widgets/mda_widget/_save_widget.py similarity index 100% rename from src/micromanager_gui/_widgets/_mda_widget/_save_widget.py rename to src/micromanager_gui/_widgets/mda_widget/_save_widget.py diff --git a/tests/test_mda_viewer.py b/tests/test_mda_viewer.py index 6c7767ad..6a5bdbd2 100644 --- a/tests/test_mda_viewer.py +++ b/tests/test_mda_viewer.py @@ -15,14 +15,14 @@ from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY from micromanager_gui import MicroManagerGUI -from micromanager_gui._widgets._mda_widget import MDAWidget -from micromanager_gui._widgets._mda_widget._save_widget import ( +from micromanager_gui._widgets._stack_viewer import MDAViewer +from micromanager_gui._widgets.mda_widget import MDAWidget +from micromanager_gui._widgets.mda_widget._save_widget import ( OME_TIFF, OME_ZARR, TIFF_SEQ, ZARR_TESNSORSTORE, ) -from micromanager_gui._widgets._stack_viewer import MDAViewer if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/tests/test_save_widget.py b/tests/test_save_widget.py index bac5fff9..37b064fc 100644 --- a/tests/test_save_widget.py +++ b/tests/test_save_widget.py @@ -3,7 +3,7 @@ import pytest from pytestqt.qtbot import QtBot -from micromanager_gui._widgets._mda_widget._save_widget import ( +from micromanager_gui._widgets.mda_widget._save_widget import ( DIRECTORY_WRITERS, FILE_NAME, OME_TIFF, From ede9f45f8de69a0f570f72e30e9dea046f46e4e9 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 9 Oct 2024 15:43:47 -0400 Subject: [PATCH 116/226] test: fix --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6aa48093..13106b5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ # extras # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] -test = ["pytest>=6.0", "pytest-cov", "pytest-qt"] +test = ["pytest>=6.0", "pytest-cov", "pytest-qt", "dask"] pyqt5 = ["PyQt5"] pyside2 = ["PySide2"] pyqt6 = ["PyQt6"] From 805087ff494efd39c117153713cdbb0348cc503b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 19:57:49 +0000 Subject: [PATCH 117/226] ci(pre-commit.ci): autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/crate-ci/typos: v1.23.6 → typos-dict-v0.11.27](https://github.com/crate-ci/typos/compare/v1.23.6...typos-dict-v0.11.27) - [github.com/astral-sh/ruff-pre-commit: v0.5.6 → v0.6.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.5.6...v0.6.3) - [github.com/abravalheri/validate-pyproject: v0.18 → v0.19](https://github.com/abravalheri/validate-pyproject/compare/v0.18...v0.19) - [github.com/pre-commit/mirrors-mypy: v1.11.1 → v1.11.2](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.1...v1.11.2) --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f70631e0..8ea76e81 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,24 +5,24 @@ ci: repos: - repo: https://github.com/crate-ci/typos - rev: v1.23.6 + rev: typos-dict-v0.11.27 hooks: - id: typos - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.6 + rev: v0.6.3 hooks: - id: ruff args: [--fix, --unsafe-fixes] - id: ruff-format - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.18 + rev: v0.19 hooks: - id: validate-pyproject - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.1 + rev: v1.11.2 hooks: - id: mypy files: "^src/" From ce040578dc7d3c6a9a565b8d777a0a11159a529c Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Fri, 27 Sep 2024 06:42:52 -0500 Subject: [PATCH 118/226] fix: comment --- src/micromanager_gui/_widgets/_mda_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/micromanager_gui/_widgets/_mda_widget.py b/src/micromanager_gui/_widgets/_mda_widget.py index 05b09078..73e63050 100644 --- a/src/micromanager_gui/_widgets/_mda_widget.py +++ b/src/micromanager_gui/_widgets/_mda_widget.py @@ -42,7 +42,7 @@ def __init__( super().__init__(parent=parent, mmcore=mmcore) # writer for saving the MDA sequence. This is used by the MDAViewer to set its - # internal datastore. If _writer is None, the MDAViewer will use its default + # internal datastore. If writer is None, the MDAViewer will use its default # internal datastore. self.writer: OMETiffWriter | OMEZarrWriter | TensorStoreHandler | None = None @@ -88,7 +88,7 @@ def run_mda(self) -> None: save_meta = sequence.metadata.get(PYMMCW_METADATA_KEY, {}) save_format = save_meta.get("format") # set the writer to use for saving the MDA sequence. - # NOTE: 'self._writer' is used by the 'MDAViewer' to set its datastore + # NOTE: 'self.writer' is used by the 'MDAViewer' to set its datastore self.writer = self._create_mda_viewer_writer(save_format, save_path) # at this point, if self.writer is None, it means that a # ImageSequenceWriter should be used to save the sequence. From a25079cb320ecb770f48aa7ce78f0d56f15b04bd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 21:08:37 +0000 Subject: [PATCH 119/226] ci(pre-commit.ci): autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/crate-ci/typos: typos-dict-v0.11.27 → v1.26.0](https://github.com/crate-ci/typos/compare/typos-dict-v0.11.27...v1.26.0) - [github.com/astral-sh/ruff-pre-commit: v0.6.3 → v0.6.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.3...v0.6.9) - [github.com/abravalheri/validate-pyproject: v0.19 → v0.20.2](https://github.com/abravalheri/validate-pyproject/compare/v0.19...v0.20.2) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8ea76e81..f7bfd72d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,19 +5,19 @@ ci: repos: - repo: https://github.com/crate-ci/typos - rev: typos-dict-v0.11.27 + rev: v1.26.0 hooks: - id: typos - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.3 + rev: v0.6.9 hooks: - id: ruff args: [--fix, --unsafe-fixes] - id: ruff-format - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.19 + rev: v0.20.2 hooks: - id: validate-pyproject From b75f69b8c4381089ae613f87355a7ebb124fe4a0 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 9 Oct 2024 15:59:27 -0400 Subject: [PATCH 120/226] test: fix --- tests/test_stack_viewer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_stack_viewer.py b/tests/test_stack_viewer.py index f8fc2049..435d7e3a 100644 --- a/tests/test_stack_viewer.py +++ b/tests/test_stack_viewer.py @@ -4,6 +4,7 @@ import dask.array as da import numpy as np +import pytest from micromanager_gui._widgets._stack_viewer import StackViewer @@ -27,7 +28,7 @@ def _dask_block(block_id: tuple[int, int, int, int, int]) -> np.ndarray | None: # this test is still leaking widgets and it's hard to track down... I think # it might have to do with the cmapComboBox -# @pytest.mark.allow_leaks +@pytest.mark.allow_leaks def test_stack_viewer(qtbot: QtBot) -> None: dask_arr = make_lazy_array((1000, 64, 3, 256, 256)) v = StackViewer(dask_arr) From ef32d50f516da8d7de5913ef34578cd6607b7003 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 9 Oct 2024 16:04:08 -0400 Subject: [PATCH 121/226] test: fix --- pyproject.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 13106b5f..c77ce5fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -122,9 +122,14 @@ docstring-code-format = true # https://docs.pytest.org/en/6.2.x/customize.html [tool.pytest.ini_options] +markers = ["allow_leaks"] minversion = "6.0" testpaths = ["tests"] -filterwarnings = ["error"] +filterwarnings = [ + "error", + "ignore:distutils Version classes are deprecated", + # warning, but not error, that will show up on useq<0.3.3 +] # https://mypy.readthedocs.io/en/stable/config_file.html [tool.mypy] From 17bcd48a74040746f4fa26a21e2b1fbdecd9f322 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 9 Oct 2024 16:14:30 -0400 Subject: [PATCH 122/226] fix: remove unused --- src/micromanager_gui/_widgets/_mda_widget.py | 165 ------------------- 1 file changed, 165 deletions(-) delete mode 100644 src/micromanager_gui/_widgets/_mda_widget.py diff --git a/src/micromanager_gui/_widgets/_mda_widget.py b/src/micromanager_gui/_widgets/_mda_widget.py deleted file mode 100644 index 73e63050..00000000 --- a/src/micromanager_gui/_widgets/_mda_widget.py +++ /dev/null @@ -1,165 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING, cast - -from pymmcore_plus.mda.handlers import ( - ImageSequenceWriter, - OMETiffWriter, - OMEZarrWriter, - TensorStoreHandler, -) -from pymmcore_widgets.mda import MDAWidget -from pymmcore_widgets.mda._save_widget import ( - OME_TIFF, - OME_ZARR, - WRITERS, - ZARR_TESNSORSTORE, -) -from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY -from useq import MDASequence - -from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler - -OME_TIFFS = tuple(WRITERS[OME_TIFF]) -GB_CACHE = 2_000_000_000 # 2 GB for tensorstore cache - -if TYPE_CHECKING: - from pymmcore_plus import CMMCorePlus - from qtpy.QtWidgets import ( - QVBoxLayout, - QWidget, - ) - from useq import MDASequence - - -class _MDAWidget(MDAWidget): - """Main napari-micromanager GUI.""" - - def __init__( - self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None - ) -> None: - super().__init__(parent=parent, mmcore=mmcore) - - # writer for saving the MDA sequence. This is used by the MDAViewer to set its - # internal datastore. If writer is None, the MDAViewer will use its default - # internal datastore. - self.writer: OMETiffWriter | OMEZarrWriter | TensorStoreHandler | None = None - - # setContentsMargins - pos_layout = cast("QVBoxLayout", self.stage_positions.layout()) - pos_layout.setContentsMargins(10, 10, 10, 10) - time_layout = cast("QVBoxLayout", self.time_plan.layout()) - time_layout.setContentsMargins(10, 10, 10, 10) - - def _on_mda_finished(self, sequence: MDASequence) -> None: - """Handle the end of the MDA sequence.""" - self.writer = None - super()._on_mda_finished(sequence) - - def run_mda(self) -> None: - """Run the MDA sequence experiment.""" - # in case the user does not press enter after editing the save name. - self.save_info.save_name.editingFinished.emit() - - # if autofocus has been requested, but the autofocus device is not engaged, - # and position-specific offsets haven't been set, show a warning - pos = self.stage_positions - if ( - self.af_axis.value() - and not self._mmc.isContinuousFocusLocked() - and (not self.tab_wdg.isChecked(pos) or not pos.af_per_position.isChecked()) - and not self._confirm_af_intentions() - ): - return - - sequence = self.value() - - # reset the writer - self.writer = None - - # technically, this is in the metadata as well, but isChecked is more direct - if self.save_info.isChecked(): - save_path = self._update_save_path_from_metadata( - sequence, update_metadata=True - ) - if isinstance(save_path, Path): - # get save format from metadata - save_meta = sequence.metadata.get(PYMMCW_METADATA_KEY, {}) - save_format = save_meta.get("format") - # set the writer to use for saving the MDA sequence. - # NOTE: 'self.writer' is used by the 'MDAViewer' to set its datastore - self.writer = self._create_mda_viewer_writer(save_format, save_path) - # at this point, if self.writer is None, it means that a - # ImageSequenceWriter should be used to save the sequence. - if self.writer is None: - output = ImageSequenceWriter(save_path) - # Since any other type of writer will be handled by the 'MDAViewer', - # we need to pass a writer to the engine only if it is a - # 'ImageSequenceWriter'. - self._mmc.run_mda(sequence, output=output) - return - - self._mmc.run_mda(sequence) - - def _create_mda_viewer_writer( - self, save_format: str, save_path: Path - ) -> OMEZarrWriter | OMETiffWriter | TensorStoreHandler | None: - """Create a writer for the MDAViewer based on the save format.""" - # use internal OME-TIFF writer if selected - if OME_TIFF in save_format: - # if OME-TIFF, save_path should be a directory without extension, so - # we need to add the ".ome.tif" to correctly use the OMETiffWriter - if not save_path.name.endswith(OME_TIFFS): - save_path = save_path.with_suffix(OME_TIFF) - return OMETiffWriter(save_path) - elif OME_ZARR in save_format: - return OMEZarrWriter(save_path) - elif ZARR_TESNSORSTORE in save_format: - return self._create_zarr_tensorstore(save_path) - # cannot use the ImageSequenceWriter here because the MDAViewer will not be - # able to handle it. - return None - - def _create_zarr_tensorstore(self, save_path: Path) -> _TensorStoreHandler: - """Create a Zarr TensorStore writer.""" - return _TensorStoreHandler( - driver="zarr", - path=save_path, - delete_existing=True, - spec={"context": {"cache_pool": {"total_bytes_limit": GB_CACHE}}}, - ) - - def _update_save_path_from_metadata( - self, - sequence: MDASequence, - update_widget: bool = True, - update_metadata: bool = False, - ) -> Path | None: - """Get the next available save path from sequence metadata and update widget. - - Parameters - ---------- - sequence : MDASequence - The MDA sequence to get the save path from. (must be in the - 'pymmcore_widgets' key of the metadata) - update_widget : bool, optional - Whether to update the save widget with the new path, by default True. - update_metadata : bool, optional - Whether to update the Sequence metadata with the new path, by default False. - """ - if ( - (meta := sequence.metadata.get(PYMMCW_METADATA_KEY, {})) - and (save_dir := meta.get("save_dir")) - and (save_name := meta.get("save_name")) - ): - requested = (Path(save_dir) / str(save_name)).expanduser().resolve() - next_path = self.get_next_available_path(requested) - - if next_path != requested: - if update_widget: - self.save_info.setValue(next_path) - if update_metadata: - meta.update(self.save_info.value()) - return Path(next_path) - return None From c8abc37fa25ea7c517e6b1fe8cfb2bde6279a1d3 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 9 Oct 2024 16:17:31 -0400 Subject: [PATCH 123/226] fix: rename --- src/micromanager_gui/_core_link.py | 2 +- src/micromanager_gui/_menubar/_menubar.py | 2 +- .../_widgets/{mda_widget => _mda_widget}/__init__.py | 0 .../_widgets/{mda_widget => _mda_widget}/_core_mda.py | 0 .../_widgets/{mda_widget => _mda_widget}/_save_widget.py | 0 tests/test_mda_viewer.py | 6 +++--- tests/test_save_widget.py | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename src/micromanager_gui/_widgets/{mda_widget => _mda_widget}/__init__.py (100%) rename src/micromanager_gui/_widgets/{mda_widget => _mda_widget}/_core_mda.py (100%) rename src/micromanager_gui/_widgets/{mda_widget => _mda_widget}/_save_widget.py (100%) diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index 3ccc230e..53e4596c 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -21,7 +21,7 @@ from pymmcore_plus.metadata import SummaryMetaV1 from ._main_window import MicroManagerGUI - from ._widgets.mda_widget import MDAWidget + from ._widgets._mda_widget import MDAWidget class CoreViewersLink(QObject): diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index ba087bbf..24b6747f 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -24,8 +24,8 @@ ) from micromanager_gui._widgets._install_widget import _InstallWidget +from micromanager_gui._widgets._mda_widget import MDAWidget from micromanager_gui._widgets._stage_control import _StagesControlWidget -from micromanager_gui._widgets.mda_widget import MDAWidget if TYPE_CHECKING: from micromanager_gui._main_window import MicroManagerGUI diff --git a/src/micromanager_gui/_widgets/mda_widget/__init__.py b/src/micromanager_gui/_widgets/_mda_widget/__init__.py similarity index 100% rename from src/micromanager_gui/_widgets/mda_widget/__init__.py rename to src/micromanager_gui/_widgets/_mda_widget/__init__.py diff --git a/src/micromanager_gui/_widgets/mda_widget/_core_mda.py b/src/micromanager_gui/_widgets/_mda_widget/_core_mda.py similarity index 100% rename from src/micromanager_gui/_widgets/mda_widget/_core_mda.py rename to src/micromanager_gui/_widgets/_mda_widget/_core_mda.py diff --git a/src/micromanager_gui/_widgets/mda_widget/_save_widget.py b/src/micromanager_gui/_widgets/_mda_widget/_save_widget.py similarity index 100% rename from src/micromanager_gui/_widgets/mda_widget/_save_widget.py rename to src/micromanager_gui/_widgets/_mda_widget/_save_widget.py diff --git a/tests/test_mda_viewer.py b/tests/test_mda_viewer.py index 6a5bdbd2..6c7767ad 100644 --- a/tests/test_mda_viewer.py +++ b/tests/test_mda_viewer.py @@ -15,14 +15,14 @@ from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY from micromanager_gui import MicroManagerGUI -from micromanager_gui._widgets._stack_viewer import MDAViewer -from micromanager_gui._widgets.mda_widget import MDAWidget -from micromanager_gui._widgets.mda_widget._save_widget import ( +from micromanager_gui._widgets._mda_widget import MDAWidget +from micromanager_gui._widgets._mda_widget._save_widget import ( OME_TIFF, OME_ZARR, TIFF_SEQ, ZARR_TESNSORSTORE, ) +from micromanager_gui._widgets._stack_viewer import MDAViewer if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/tests/test_save_widget.py b/tests/test_save_widget.py index 37b064fc..bac5fff9 100644 --- a/tests/test_save_widget.py +++ b/tests/test_save_widget.py @@ -3,7 +3,7 @@ import pytest from pytestqt.qtbot import QtBot -from micromanager_gui._widgets.mda_widget._save_widget import ( +from micromanager_gui._widgets._mda_widget._save_widget import ( DIRECTORY_WRITERS, FILE_NAME, OME_TIFF, From aec3a736e127ff28b4a90e330bdf3856a90dc5bd Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Thu, 10 Oct 2024 09:19:35 -0400 Subject: [PATCH 124/226] fix: subclass MDAWidget instead --- .../_widgets/_mda_widget/__init__.py | 2 +- .../_widgets/_mda_widget/_core_mda.py | 563 ------------------ .../_widgets/_mda_widget/_mda_widget.py | 217 +++++++ 3 files changed, 218 insertions(+), 564 deletions(-) delete mode 100644 src/micromanager_gui/_widgets/_mda_widget/_core_mda.py create mode 100644 src/micromanager_gui/_widgets/_mda_widget/_mda_widget.py diff --git a/src/micromanager_gui/_widgets/_mda_widget/__init__.py b/src/micromanager_gui/_widgets/_mda_widget/__init__.py index f0298325..4cbeb69c 100644 --- a/src/micromanager_gui/_widgets/_mda_widget/__init__.py +++ b/src/micromanager_gui/_widgets/_mda_widget/__init__.py @@ -1,5 +1,5 @@ """MDA widgets.""" -from ._core_mda import MDAWidget +from ._mda_widget import MDAWidget_ as MDAWidget __all__ = ["MDAWidget"] diff --git a/src/micromanager_gui/_widgets/_mda_widget/_core_mda.py b/src/micromanager_gui/_widgets/_mda_widget/_core_mda.py deleted file mode 100644 index ee75a2f4..00000000 --- a/src/micromanager_gui/_widgets/_mda_widget/_core_mda.py +++ /dev/null @@ -1,563 +0,0 @@ -from __future__ import annotations - -import re -from contextlib import suppress -from pathlib import Path -from typing import cast - -from fonticon_mdi6 import MDI6 -from pymmcore_plus import CMMCorePlus, Keyword -from pymmcore_plus.mda.handlers import ( - ImageSequenceWriter, - OMETiffWriter, - OMEZarrWriter, - TensorStoreHandler, -) -from pymmcore_widgets.mda._core_channels import CoreConnectedChannelTable -from pymmcore_widgets.mda._core_grid import CoreConnectedGridPlanWidget -from pymmcore_widgets.mda._core_positions import CoreConnectedPositionTable -from pymmcore_widgets.mda._core_z import CoreConnectedZPlanWidget -from pymmcore_widgets.useq_widgets import MDASequenceWidget -from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY, MDATabs -from pymmcore_widgets.useq_widgets._time import TimePlanWidget -from qtpy.QtCore import QSize, Qt -from qtpy.QtWidgets import ( - QBoxLayout, - QHBoxLayout, - QMessageBox, - QPushButton, - QVBoxLayout, - QWidget, -) -from superqt.fonticon import icon -from useq import MDASequence, Position - -from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler - -from ._save_widget import ( - OME_TIFF, - OME_ZARR, - WRITERS, - ZARR_TESNSORSTORE, - SaveGroupBox, -) - -OME_TIFFS = tuple(WRITERS[OME_TIFF]) -GB_CACHE = 2_000_000_000 # 2 GB for tensorstore cache - -NUM_SPLIT = re.compile(r"(.*?)(?:_(\d{3,}))?$") - - -def get_next_available_path(requested_path: Path | str, min_digits: int = 3) -> Path: - """Get the next available paths (filepath or folderpath if extension = ""). - - This method adds a counter of min_digits to the filename or foldername to ensure - that the path is unique. - - Parameters - ---------- - requested_path : Path | str - A path to a file or folder that may or may not exist. - min_digits : int, optional - The min_digits number of digits to be used for the counter. By default, 3. - """ - if isinstance(requested_path, str): # pragma: no cover - requested_path = Path(requested_path) - - directory = requested_path.parent - extension = requested_path.suffix - # ome files like .ome.tiff or .ome.zarr are special,treated as a single extension - if (stem := requested_path.stem).endswith(".ome"): - extension = f".ome{extension}" - stem = stem[:-4] - elif (stem := requested_path.stem).endswith(".tensorstore"): - extension = f".tensorstore{extension}" - stem = stem[:-12] - - # look for ANY existing files in the folder that follow the pattern of - # stem_###.extension - current_max = 0 - for existing in directory.glob(f"*{extension}"): - # cannot use existing.stem because of the ome (2-part-extension) special case - base = existing.name.replace(extension, "") - # if the base name ends with a number, increase the current_max - if (match := NUM_SPLIT.match(base)) and (num := match.group(2)): - current_max = max(int(num), current_max) - # if it has more digits than expected, update the ndigits - if len(num) > min_digits: - min_digits = len(num) - - # if the path does not exist and there are no existing files, - # return the requested path - if not requested_path.exists() and current_max == 0: - return requested_path - - current_max += 1 - # otherwise return the next path greater than the current_max - # remove any existing counter from the stem - if match := NUM_SPLIT.match(stem): - stem, num = match.groups() - if num: - # if the requested path has a counter that is greater than any other files - # use it - current_max = max(int(num), current_max) - return directory / f"{stem}_{current_max:0{min_digits}d}{extension}" - - -class CoreMDATabs(MDATabs): - def __init__( - self, parent: QWidget | None = None, core: CMMCorePlus | None = None - ) -> None: - self._mmc = core or CMMCorePlus.instance() - super().__init__(parent) - - def create_subwidgets(self) -> None: - self.time_plan = TimePlanWidget(1) - self.stage_positions = CoreConnectedPositionTable(1, self._mmc) - self.z_plan = CoreConnectedZPlanWidget(self._mmc) - self.grid_plan = CoreConnectedGridPlanWidget(self._mmc) - self.channels = CoreConnectedChannelTable(1, self._mmc) - - def _enable_tabs(self, enable: bool) -> None: - """Enable or disable the tab checkboxes and their contents. - - However, we can still mover through the tabs and see their contents. - """ - # disable tab checkboxes - for cbox in self._cboxes: - cbox.setEnabled(enable) - # disable tabs contents - self.time_plan.setEnabled(enable) - self.stage_positions.setEnabled(enable) - self.z_plan.setEnabled(enable) - self.grid_plan.setEnabled(enable) - self.channels.setEnabled(enable) - - -class MDAWidget(MDASequenceWidget): - """Main MDA Widget connected to a [`pymmcore_plus.CMMCorePlus`][] instance. - - It provides a GUI to construct and run a [`useq.MDASequence`][]. Unlike - [`useq_widgets.MDASequenceWidget`][pymmcore_widgets.MDASequenceWidget], this - widget is connected to a [`pymmcore_plus.CMMCorePlus`][] instance, enabling - awareness and control of the current state of the microscope. - - Parameters - ---------- - parent : QWidget | None - Optional parent widget, by default None. - mmcore : CMMCorePlus | None - Optional [`CMMCorePlus`][pymmcore_plus.CMMCorePlus] micromanager core. - By default, None. If not specified, the widget will use the active - (or create a new) - [`CMMCorePlus.instance`][pymmcore_plus.core._mmcore_plus.CMMCorePlus.instance]. - """ - - def __init__( - self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None - ) -> None: - # create a couple core-connected variants of the tab widgets - self._mmc = mmcore or CMMCorePlus.instance() - - super().__init__(parent=parent, tab_widget=CoreMDATabs(None, self._mmc)) - - self.save_info = SaveGroupBox(parent=self) - self.save_info.valueChanged.connect(self.valueChanged) - self.control_btns = _MDAControlButtons(self._mmc, self) - - # writer for saving the MDA sequence. This is used by the MDAViewer to set its - # internal datastore. If _writer is None, the MDAViewer will use its default - # internal datastore. - self.writer: OMETiffWriter | OMEZarrWriter | TensorStoreHandler | None = None - - # -------- initialize ----------- - - self._on_sys_config_loaded() - - # ------------ layout ------------ - - layout = cast("QBoxLayout", self.layout()) - layout.insertWidget(0, self.save_info) - layout.addWidget(self.control_btns) - - # setContentsMargins - pos_layout = cast("QVBoxLayout", self.stage_positions.layout()) - pos_layout.setContentsMargins(10, 10, 10, 10) - time_layout = cast("QVBoxLayout", self.time_plan.layout()) - time_layout.setContentsMargins(10, 10, 10, 10) - - # ------------ connect signals ------------ - - self.control_btns.run_btn.clicked.connect(self.run_mda) - self.control_btns.pause_btn.released.connect(self._mmc.mda.toggle_pause) - self.control_btns.cancel_btn.released.connect(self._mmc.mda.cancel) - self._mmc.mda.events.sequenceStarted.connect(self._on_mda_started) - self._mmc.mda.events.sequenceFinished.connect(self._on_mda_finished) - self._mmc.events.systemConfigurationLoaded.connect(self._on_sys_config_loaded) - - self.destroyed.connect(self._disconnect) - - # ----------- Override type hints in superclass ----------- - - @property - def channels(self) -> CoreConnectedChannelTable: - return cast("CoreConnectedChannelTable", self.tab_wdg.channels) - - @property - def z_plan(self) -> CoreConnectedZPlanWidget: - return cast("CoreConnectedZPlanWidget", self.tab_wdg.z_plan) - - @property - def stage_positions(self) -> CoreConnectedPositionTable: - return cast("CoreConnectedPositionTable", self.tab_wdg.stage_positions) - - @property - def grid_plan(self) -> CoreConnectedGridPlanWidget: - return cast("CoreConnectedGridPlanWidget", self.tab_wdg.grid_plan) - - # ------------------- public Methods ---------------------- - - def value(self) -> MDASequence: - """Set the current state of the widget from a [`useq.MDASequence`][].""" - val = super().value() - replace: dict = {} - - # if the z plan is relative and there are stage positions but the 'include z' is - # unchecked, use the current z stage position as the relative starting one. - if ( - val.z_plan - and val.z_plan.is_relative - and (val.stage_positions and not self.stage_positions.include_z.isChecked()) - ): - z = self._mmc.getZPosition() if self._mmc.getFocusDevice() else None - replace["stage_positions"] = tuple( - pos.replace(z=z) for pos in val.stage_positions - ) - - # if there is an autofocus_plan but the autofocus_motor_offset is None, set it - # to the current value - if (afplan := val.autofocus_plan) and afplan.autofocus_motor_offset is None: - p2 = afplan.replace(autofocus_motor_offset=self._mmc.getAutoFocusOffset()) - replace["autofocus_plan"] = p2 - - # if there are no stage positions, use the current stage position - if not val.stage_positions: - replace["stage_positions"] = (self._get_current_stage_position(),) - # if "p" is not in the axis order, we need to add it or the position will - # not be in the event - if "p" not in val.axis_order: - axis_order = list(val.axis_order) - # add the "p" axis at the beginning or after the "t" as the default - if "t" in axis_order: - axis_order.insert(axis_order.index("t") + 1, "p") - else: - axis_order.insert(0, "p") - replace["axis_order"] = tuple(axis_order) - - if replace: - val = val.replace(**replace) - - meta: dict = val.metadata.setdefault(PYMMCW_METADATA_KEY, {}) - if self.save_info.isChecked(): - meta.update(self.save_info.value()) - return val # type: ignore - - def setValue(self, value: MDASequence) -> None: - """Get the current state of the widget as a [`useq.MDASequence`][].""" - super().setValue(value) - self.save_info.setValue(value.metadata.get(PYMMCW_METADATA_KEY, {})) - - def get_next_available_path(self, requested_path: Path) -> Path: - """Get the next available path. - - This method is called immediately before running an MDA to ensure that the file - being saved does not overwrite an existing file. It is also called at the end - of the experiment to update the save widget with the next available path. - - It may be overridden to provide custom behavior, but it should always return a - Path object to a non-existing file or folder. - - The default behavior adds/increments a 3-digit counter at the end of the path - (before the extension) if the path already exists. - - Parameters - ---------- - requested_path : Path - The path we are requesting for use. - """ - return get_next_available_path(requested_path=requested_path) - - def prepare_mda( - self, - ) -> ( - bool - | OMEZarrWriter - | OMETiffWriter - | TensorStoreHandler - | ImageSequenceWriter - | None - ): - """Prepare the MDA sequence experiment. - - Returns - ------- - bool - False if MDA to be cancelled due to autofocus issue. - str | Path - Preparation successful, save path to be used for saving and saving active - None - Preparation successful, saving deactivated - """ - # in case the user does not press enter after editing the save name. - self.save_info.save_name.editingFinished.emit() - - # if autofocus has been requested, but the autofocus device is not engaged, - # and position-specific offsets haven't been set, show a warning - pos = self.stage_positions - if ( - self.af_axis.value() - and not self._mmc.isContinuousFocusLocked() - and (not self.tab_wdg.isChecked(pos) or not pos.af_per_position.isChecked()) - and not self._confirm_af_intentions() - ): - return False - - sequence = self.value() - - # technically, this is in the metadata as well, but isChecked is more direct - if self.save_info.isChecked(): - save_path = self._update_save_path_from_metadata( - sequence, update_metadata=True - ) - if isinstance(save_path, Path): - # get save format from metadata - save_meta = sequence.metadata.get(PYMMCW_METADATA_KEY, {}) - save_format = save_meta.get("format") - # set the writer to use for saving the MDA sequence. - # NOTE: 'self._writer' is used by the 'MDAViewer' to set its datastore - self.writer = self._create_writer(save_format, save_path) - # at this point, if self.writer is None, it means that a - # ImageSequenceWriter should be used to save the sequence. - if self.writer is None: - # Since any other type of writer will be handled by the 'MDAViewer', - # we need to pass a writer to the engine only if it is a - # 'ImageSequenceWriter'. - return ImageSequenceWriter(save_path) - return None - - def execute_mda(self, output: Path | str | object | None) -> None: - """Execute the MDA experiment corresponding to the current value.""" - sequence = self.value() - # run the MDA experiment asynchronously - self._mmc.run_mda(sequence, output=output) - - def run_mda(self) -> None: - save_path = self.prepare_mda() - if save_path is False: - return - self.execute_mda(save_path) - - # ------------------- private Methods ---------------------- - - def _create_writer( - self, save_format: str, save_path: Path - ) -> OMEZarrWriter | OMETiffWriter | TensorStoreHandler | None: - """Create a writer for the MDAViewer based on the save format.""" - # use internal OME-TIFF writer if selected - if OME_TIFF in save_format: - # if OME-TIFF, save_path should be a directory without extension, so - # we need to add the ".ome.tif" to correctly use the OMETiffWriter - if not save_path.name.endswith(OME_TIFFS): - save_path = save_path.with_suffix(OME_TIFF) - return OMETiffWriter(save_path) - elif OME_ZARR in save_format: - return OMEZarrWriter(save_path) - elif ZARR_TESNSORSTORE in save_format: - return self._create_zarr_tensorstore(save_path) - # cannot use the ImageSequenceWriter here because the MDAViewer will not be - # able to handle it. - return None - - def _create_zarr_tensorstore(self, save_path: Path) -> _TensorStoreHandler: - """Create a Zarr TensorStore writer.""" - return _TensorStoreHandler( - driver="zarr", - path=save_path, - delete_existing=True, - spec={"context": {"cache_pool": {"total_bytes_limit": GB_CACHE}}}, - ) - - def _on_sys_config_loaded(self) -> None: - # TODO: connect objective change event to update suggested step - self.z_plan.setSuggestedStep(_guess_NA(self._mmc) or 0.5) - - def _get_current_stage_position(self) -> Position: - """Return the current stage position.""" - x = self._mmc.getXPosition() if self._mmc.getXYStageDevice() else None - y = self._mmc.getYPosition() if self._mmc.getXYStageDevice() else None - z = self._mmc.getPosition() if self._mmc.getFocusDevice() else None - return Position(x=x, y=y, z=z) - - def _update_save_path_from_metadata( - self, - sequence: MDASequence, - update_widget: bool = True, - update_metadata: bool = False, - ) -> Path | None: - """Get the next available save path from sequence metadata and update widget. - - Parameters - ---------- - sequence : MDASequence - The MDA sequence to get the save path from. (must be in the - 'pymmcore_widgets' key of the metadata) - update_widget : bool, optional - Whether to update the save widget with the new path, by default True. - update_metadata : bool, optional - Whether to update the Sequence metadata with the new path, by default False. - """ - if ( - (meta := sequence.metadata.get(PYMMCW_METADATA_KEY, {})) - and (save_dir := meta.get("save_dir")) - and (save_name := meta.get("save_name")) - ): - requested = (Path(save_dir) / str(save_name)).expanduser().resolve() - next_path = self.get_next_available_path(requested) - if next_path != requested: - if update_widget: - self.save_info.setValue(next_path) - if update_metadata: - meta.update(self.save_info.value()) - return next_path - return None - - def _confirm_af_intentions(self) -> bool: - msg = ( - "You've selected to use autofocus for this experiment, " - f"but the '{self._mmc.getAutoFocusDevice()!r}' autofocus device " - "is not currently engaged. " - "\n\nRun anyway?" - ) - - response = QMessageBox.warning( - self, - "Confirm AutoFocus", - msg, - QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel, - QMessageBox.StandardButton.Cancel, - ) - return bool(response == QMessageBox.StandardButton.Ok) - - def _enable_widgets(self, enable: bool) -> None: - for child in self.children(): - if isinstance(child, CoreMDATabs): - child._enable_tabs(enable) - elif child is not self.control_btns and hasattr(child, "setEnabled"): - child.setEnabled(enable) - - def _on_mda_started(self) -> None: - self._enable_widgets(False) - - def _on_mda_finished(self, sequence: MDASequence) -> None: - self.writer = None - self._enable_widgets(True) - # update the save name in the gui with the next available path - # FIXME: this is actually a bit error prone in the case of super fast - # experiments and delayed writers that haven't yet written anything to disk - # (e.g. the next available path might be the same as the current one) - # however, the quick fix of using a QTimer.singleShot(0, ...) makes for - # difficulties in testing. - # FIXME: Also, we really don't care about the last sequence at this point - # anyway. We should just update the save widget with the next available path - # based on what's currently in the save widget, since that's what really - # matters (not whatever the last requested mda was) - self._update_save_path_from_metadata(sequence) - - def _disconnect(self) -> None: - with suppress(Exception): - self._mmc.mda.events.sequenceStarted.disconnect(self._on_mda_started) - self._mmc.mda.events.sequenceFinished.disconnect(self._on_mda_finished) - - -class _MDAControlButtons(QWidget): - """Run, pause, and cancel buttons at the bottom of the MDA Widget.""" - - def __init__(self, mmcore: CMMCorePlus, parent: QWidget | None = None) -> None: - super().__init__(parent) - - self._mmc = mmcore - self._mmc.mda.events.sequencePauseToggled.connect(self._on_mda_paused) - self._mmc.mda.events.sequenceStarted.connect(self._on_mda_started) - self._mmc.mda.events.sequenceFinished.connect(self._on_mda_finished) - - icon_size = QSize(24, 24) - self.run_btn = QPushButton("Run") - self.run_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.run_btn.setIcon(icon(MDI6.play_circle_outline, color="lime")) - self.run_btn.setIconSize(icon_size) - - self.pause_btn = QPushButton("Pause") - self.pause_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.pause_btn.setIcon(icon(MDI6.pause_circle_outline, color="green")) - self.pause_btn.setIconSize(icon_size) - self.pause_btn.hide() - - self.cancel_btn = QPushButton("Cancel") - self.cancel_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.cancel_btn.setIcon(icon(MDI6.stop_circle_outline, color="#C33")) - self.cancel_btn.setIconSize(icon_size) - self.cancel_btn.hide() - - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addStretch() - layout.addWidget(self.run_btn) - layout.addWidget(self.pause_btn) - layout.addWidget(self.cancel_btn) - - self.destroyed.connect(self._disconnect) - - def _on_mda_started(self) -> None: - self.run_btn.hide() - self.pause_btn.show() - self.cancel_btn.show() - - def _on_mda_finished(self) -> None: - self.run_btn.show() - self.pause_btn.hide() - self.cancel_btn.hide() - self._on_mda_paused(False) - - def _on_mda_paused(self, paused: bool) -> None: - if paused: - self.pause_btn.setIcon(icon(MDI6.play_circle_outline, color="lime")) - self.pause_btn.setText("Resume") - else: - self.pause_btn.setIcon(icon(MDI6.pause_circle_outline, color="green")) - self.pause_btn.setText("Pause") - - def _disconnect(self) -> None: - with suppress(Exception): - self._mmc.mda.events.sequencePauseToggled.disconnect(self._on_mda_paused) - self._mmc.mda.events.sequenceStarted.disconnect(self._on_mda_started) - self._mmc.mda.events.sequenceFinished.disconnect(self._on_mda_finished) - - -def _guess_NA(core: CMMCorePlus) -> float | None: - with suppress(RuntimeError): - if not (pix_cfg := core.getCurrentPixelSizeConfig()): - return None # pragma: no cover - - data = core.getPixelSizeConfigData(pix_cfg) - for obj in core.guessObjectiveDevices(): - key = (obj, Keyword.Label) - if key in data: - val = data[key] - for word in val.split(): - try: - na = float(word) - except ValueError: - continue - if 0.1 < na < 1.5: - return na - return None diff --git a/src/micromanager_gui/_widgets/_mda_widget/_mda_widget.py b/src/micromanager_gui/_widgets/_mda_widget/_mda_widget.py new file mode 100644 index 00000000..96a62880 --- /dev/null +++ b/src/micromanager_gui/_widgets/_mda_widget/_mda_widget.py @@ -0,0 +1,217 @@ +import re +from pathlib import Path +from typing import cast + +from pymmcore_plus import CMMCorePlus +from pymmcore_plus.mda.handlers import ( + ImageSequenceWriter, + OMETiffWriter, + OMEZarrWriter, + TensorStoreHandler, +) +from pymmcore_widgets import MDAWidget +from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY +from qtpy.QtWidgets import QBoxLayout, QWidget +from useq import MDASequence + +from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler + +from ._save_widget import ( + OME_TIFF, + OME_ZARR, + WRITERS, + ZARR_TESNSORSTORE, + SaveGroupBox, +) + +NUM_SPLIT = re.compile(r"(.*?)(?:_(\d{3,}))?$") +OME_TIFFS = tuple(WRITERS[OME_TIFF]) +GB_CACHE = 2_000_000_000 # 2 GB for tensorstore cache + + +def get_next_available_path(requested_path: Path | str, min_digits: int = 3) -> Path: + """Get the next available paths (filepath or folderpath if extension = ""). + + This method adds a counter of min_digits to the filename or foldername to ensure + that the path is unique. + + Parameters + ---------- + requested_path : Path | str + A path to a file or folder that may or may not exist. + min_digits : int, optional + The min_digits number of digits to be used for the counter. By default, 3. + """ + if isinstance(requested_path, str): # pragma: no cover + requested_path = Path(requested_path) + + directory = requested_path.parent + extension = requested_path.suffix + # ome files like .ome.tiff or .ome.zarr are special,treated as a single extension + if (stem := requested_path.stem).endswith(".ome"): + extension = f".ome{extension}" + stem = stem[:-4] + elif (stem := requested_path.stem).endswith(".tensorstore"): + extension = f".tensorstore{extension}" + stem = stem[:-12] + + # look for ANY existing files in the folder that follow the pattern of + # stem_###.extension + current_max = 0 + for existing in directory.glob(f"*{extension}"): + # cannot use existing.stem because of the ome (2-part-extension) special case + base = existing.name.replace(extension, "") + # if the base name ends with a number, increase the current_max + if (match := NUM_SPLIT.match(base)) and (num := match.group(2)): + current_max = max(int(num), current_max) + # if it has more digits than expected, update the ndigits + if len(num) > min_digits: + min_digits = len(num) + + # if the path does not exist and there are no existing files, + # return the requested path + if not requested_path.exists() and current_max == 0: + return requested_path + + current_max += 1 + # otherwise return the next path greater than the current_max + # remove any existing counter from the stem + if match := NUM_SPLIT.match(stem): + stem, num = match.groups() + if num: + # if the requested path has a counter that is greater than any other files + # use it + current_max = max(int(num), current_max) + return directory / f"{stem}_{current_max:0{min_digits}d}{extension}" + + +class MDAWidget_(MDAWidget): + """Multi-dimensional acquisition widget.""" + + def __init__( + self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent=parent, mmcore=mmcore) + + # writer for saving the MDA sequence. This is used by the MDAViewer to set its + # internal datastore. If _writer is None, the MDAViewer will use its default + # internal datastore. + self.writer: OMETiffWriter | OMEZarrWriter | TensorStoreHandler | None = None + + main_layout = cast(QBoxLayout, self.layout()) + + # remove the existing save_info widget from the layout and replace it with + # the custom SaveGroupBox widget that also handles tensorstore-zarr + if hasattr(self, "save_info"): + self.save_info.valueChanged.disconnect(self.valueChanged) + main_layout.removeWidget(self.save_info) + self.save_info.deleteLater() + self.save_info: SaveGroupBox = SaveGroupBox(parent=self) + self.save_info.valueChanged.connect(self.valueChanged) + main_layout.insertWidget(0, self.save_info) + + def get_next_available_path(self, requested_path: Path) -> Path: + """Get the next available path. + + Overwrites the method in the parent class to use the custom + 'get_next_available_path' function. + """ + return get_next_available_path(requested_path=requested_path) + + def prepare_mda( + self, + ) -> ( + bool + | OMEZarrWriter + | OMETiffWriter + | TensorStoreHandler + | ImageSequenceWriter + | None + ): + """Prepare the MDA sequence experiment. + + This method sets the writer to use for saving the MDA sequence. + """ + # in case the user does not press enter after editing the save name. + self.save_info.save_name.editingFinished.emit() + + # if autofocus has been requested, but the autofocus device is not engaged, + # and position-specific offsets haven't been set, show a warning + pos = self.stage_positions + if ( + self.af_axis.value() + and not self._mmc.isContinuousFocusLocked() + and (not self.tab_wdg.isChecked(pos) or not pos.af_per_position.isChecked()) + and not self._confirm_af_intentions() + ): + return False + + sequence = self.value() + + # technically, this is in the metadata as well, but isChecked is more direct + if self.save_info.isChecked(): + save_path = self._update_save_path_from_metadata( + sequence, update_metadata=True + ) + if isinstance(save_path, Path): + # get save format from metadata + save_meta = sequence.metadata.get(PYMMCW_METADATA_KEY, {}) + save_format = save_meta.get("format") + # set the writer to use for saving the MDA sequence. + # NOTE: 'self._writer' is used by the 'MDAViewer' to set its datastore + self.writer = self._create_writer(save_format, save_path) + # at this point, if self.writer is None, it means that a + # ImageSequenceWriter should be used to save the sequence. + if self.writer is None: + # Since any other type of writer will be handled by the 'MDAViewer', + # we need to pass a writer to the engine only if it is a + # 'ImageSequenceWriter'. + return ImageSequenceWriter(save_path) + return None + + def run_mda(self) -> None: + """Run the MDA experiment.""" + save_path = self.prepare_mda() + if save_path is False: + return + self.execute_mda(save_path) + + def execute_mda(self, output: Path | str | object | None) -> None: + """Execute the MDA experiment corresponding to the current value.""" + sequence = self.value() + # run the MDA experiment asynchronously + self._mmc.run_mda(sequence, output=output) + + # ------------------- private Methods ---------------------- + + def _on_mda_finished(self, sequence: MDASequence) -> None: + self.writer = None + super()._on_mda_finished(sequence) + + def _create_writer( + self, save_format: str, save_path: Path + ) -> OMEZarrWriter | OMETiffWriter | TensorStoreHandler | None: + """Create a writer for the MDAViewer based on the save format.""" + # use internal OME-TIFF writer if selected + if OME_TIFF in save_format: + # if OME-TIFF, save_path should be a directory without extension, so + # we need to add the ".ome.tif" to correctly use the OMETiffWriter + if not save_path.name.endswith(OME_TIFFS): + save_path = save_path.with_suffix(OME_TIFF) + return OMETiffWriter(save_path) + elif OME_ZARR in save_format: + return OMEZarrWriter(save_path) + elif ZARR_TESNSORSTORE in save_format: + return self._create_zarr_tensorstore(save_path) + # cannot use the ImageSequenceWriter here because the MDAViewer will not be + # able to handle it. + return None + + def _create_zarr_tensorstore(self, save_path: Path) -> _TensorStoreHandler: + """Create a Zarr TensorStore writer.""" + return _TensorStoreHandler( + driver="zarr", + path=save_path, + delete_existing=True, + spec={"context": {"cache_pool": {"total_bytes_limit": GB_CACHE}}}, + ) From 895d444f7f21a359c40700028e1e7adad4199188 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Sat, 19 Oct 2024 11:48:25 -0400 Subject: [PATCH 125/226] fix: fix _clear method in stage wiidget --- .../_widgets/_stage_control.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/micromanager_gui/_widgets/_stage_control.py b/src/micromanager_gui/_widgets/_stage_control.py index 682e6d84..da8f92ea 100644 --- a/src/micromanager_gui/_widgets/_stage_control.py +++ b/src/micromanager_gui/_widgets/_stage_control.py @@ -19,7 +19,7 @@ STAGE_DEVICES = {DeviceType.Stage, DeviceType.XYStage} -class Group(QGroupBox): +class _Group(QGroupBox): def __init__(self, name: str) -> None: super().__init__(name) self._name = name @@ -33,7 +33,7 @@ def __init__(self, name: str) -> None: ) -class Stage(StageWidget): +class _Stage(StageWidget): """Stage control widget with wheel event for z-axis control.""" def __init__(self, device: str) -> None: @@ -58,7 +58,7 @@ def __init__( ) -> None: super().__init__(parent=parent) - self._stage_wdgs: list[Group] = [] + self._stage_wdgs: list[_Group] = [] self._context_menu = QMenu(self) @@ -81,20 +81,20 @@ def _on_cfg_loaded(self) -> None: stage_dev_list.extend(iter(self._mmc.getLoadedDevicesOfType(DeviceType.Stage))) for idx, stage_dev in enumerate(stage_dev_list): if self._mmc.getDeviceType(stage_dev) is DeviceType.XYStage: - bx = Group("XY Control") + bx = _Group("XY Control") elif self._mmc.getDeviceType(stage_dev) is DeviceType.Stage: - bx = Group("Z Control") + bx = _Group("Z Control") else: continue self._stage_wdgs.append(bx) bx.setSizePolicy(sizepolicy) - bx.layout().addWidget(Stage(device=stage_dev)) + bx.layout().addWidget(_Stage(device=stage_dev)) self._layout.addWidget(bx, idx // 2, idx % 2) self.resize(self.sizeHint()) def _clear(self) -> None: - for i in reversed(range(self.layout().count())): - if item := self.layout().takeAt(i): - if wdg := item.widget(): - wdg.setParent(QWidget()) - wdg.deleteLater() + while self._layout.count(): + item = self._layout.takeAt(0) + if widget := item.widget(): + widget.setParent(None) + widget.deleteLater() From d9a661ce4f26e3060be7e3c3296686c88b3b10a4 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Sat, 19 Oct 2024 13:56:03 -0400 Subject: [PATCH 126/226] fix: fix stage widget + test --- src/micromanager_gui/_menubar/_menubar.py | 4 +-- .../_widgets/_stage_control.py | 32 +++++++++---------- tests/test_stage_widget.py | 16 ++++++++++ 3 files changed, 33 insertions(+), 19 deletions(-) create mode 100644 tests/test_stage_widget.py diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index 24b6747f..8c9bcb63 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -25,7 +25,7 @@ from micromanager_gui._widgets._install_widget import _InstallWidget from micromanager_gui._widgets._mda_widget import MDAWidget -from micromanager_gui._widgets._stage_control import _StagesControlWidget +from micromanager_gui._widgets._stage_control import StagesControlWidget if TYPE_CHECKING: from micromanager_gui._main_window import MicroManagerGUI @@ -39,7 +39,7 @@ DOCKWIDGETS = { "MDA Widget": MDAWidget, "Groups and Presets": GroupPresetTableWidget, - "Stage Control": _StagesControlWidget, + "Stage Control": StagesControlWidget, "Camera ROI": CameraRoiWidget, } RIGHT = Qt.DockWidgetArea.RightDockWidgetArea diff --git a/src/micromanager_gui/_widgets/_stage_control.py b/src/micromanager_gui/_widgets/_stage_control.py index da8f92ea..5954ad17 100644 --- a/src/micromanager_gui/_widgets/_stage_control.py +++ b/src/micromanager_gui/_widgets/_stage_control.py @@ -20,9 +20,8 @@ class _Group(QGroupBox): - def __init__(self, name: str) -> None: - super().__init__(name) - self._name = name + def __init__(self, name: str, parent: QWidget | None = None) -> None: + super().__init__(name, parent) self.setLayout(QHBoxLayout()) self.layout().setContentsMargins(0, 0, 0, 0) @@ -36,8 +35,8 @@ def __init__(self, name: str) -> None: class _Stage(StageWidget): """Stage control widget with wheel event for z-axis control.""" - def __init__(self, device: str) -> None: - super().__init__(device=device) + def __init__(self, device: str, parent: QWidget | None = None) -> None: + super().__init__(device=device, parent=parent) def wheelEvent(self, event: QWheelEvent) -> None: if self._dtype != DeviceType.Stage: @@ -50,7 +49,7 @@ def wheelEvent(self, event: QWheelEvent) -> None: self._move_stage(0, -increment) -class _StagesControlWidget(QWidget): +class StagesControlWidget(QWidget): """A widget to control all the XY and Z loaded stages.""" def __init__( @@ -58,7 +57,7 @@ def __init__( ) -> None: super().__init__(parent=parent) - self._stage_wdgs: list[_Group] = [] + self._mmc = mmcore or CMMCorePlus.instance() self._context_menu = QMenu(self) @@ -68,10 +67,10 @@ def __init__( self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self._mmc = CMMCorePlus.instance() - self._on_cfg_loaded() self._mmc.events.systemConfigurationLoaded.connect(self._on_cfg_loaded) + self._on_cfg_loaded() + def _on_cfg_loaded(self) -> None: self._clear() sizepolicy = QSizePolicy( @@ -80,15 +79,14 @@ def _on_cfg_loaded(self) -> None: stage_dev_list = list(self._mmc.getLoadedDevicesOfType(DeviceType.XYStage)) stage_dev_list.extend(iter(self._mmc.getLoadedDevicesOfType(DeviceType.Stage))) for idx, stage_dev in enumerate(stage_dev_list): - if self._mmc.getDeviceType(stage_dev) is DeviceType.XYStage: - bx = _Group("XY Control") - elif self._mmc.getDeviceType(stage_dev) is DeviceType.Stage: - bx = _Group("Z Control") - else: + if ( + self._mmc.getDeviceType(stage_dev) is not DeviceType.XYStage + and self._mmc.getDeviceType(stage_dev) is not DeviceType.Stage + ): continue - self._stage_wdgs.append(bx) + bx = _Group(stage_dev, self) bx.setSizePolicy(sizepolicy) - bx.layout().addWidget(_Stage(device=stage_dev)) + bx.layout().addWidget(_Stage(device=stage_dev, parent=bx)) self._layout.addWidget(bx, idx // 2, idx % 2) self.resize(self.sizeHint()) @@ -96,5 +94,5 @@ def _clear(self) -> None: while self._layout.count(): item = self._layout.takeAt(0) if widget := item.widget(): - widget.setParent(None) + widget.setParent(self) widget.deleteLater() diff --git a/tests/test_stage_widget.py b/tests/test_stage_widget.py new file mode 100644 index 00000000..29936a36 --- /dev/null +++ b/tests/test_stage_widget.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from micromanager_gui._widgets._stage_control import StagesControlWidget + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from pytestqt.qtbot import QtBot + + +def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): + s = StagesControlWidget(mmcore=global_mmcore) + qtbot.addWidget(s) + global_mmcore.loadSystemConfiguration() + global_mmcore.loadSystemConfiguration() From e4dbad289ecba8e817f5101c32e19f9eaf68774e Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Sat, 19 Oct 2024 13:59:08 -0400 Subject: [PATCH 127/226] test: update --- tests/test_stage_widget.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_stage_widget.py b/tests/test_stage_widget.py index 29936a36..5a959a03 100644 --- a/tests/test_stage_widget.py +++ b/tests/test_stage_widget.py @@ -12,5 +12,9 @@ def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): s = StagesControlWidget(mmcore=global_mmcore) qtbot.addWidget(s) + group1 = s._layout.takeAt(0).widget() + group2 = s._layout.takeAt(0).widget() + assert group1.title() == "XY" + assert group2.title() == "Z" global_mmcore.loadSystemConfiguration() global_mmcore.loadSystemConfiguration() From d6143a8d2ffe03b31cf98b88c42f528f83350376 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Sun, 3 Nov 2024 10:23:59 -0500 Subject: [PATCH 128/226] wip --- .../_widgets/_previerw_ndv.py | 143 ++++++++++++++++++ .../_widgets/_snap_live_buttons.py | 70 ++++++++- 2 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 src/micromanager_gui/_widgets/_previerw_ndv.py diff --git a/src/micromanager_gui/_widgets/_previerw_ndv.py b/src/micromanager_gui/_widgets/_previerw_ndv.py new file mode 100644 index 00000000..40300061 --- /dev/null +++ b/src/micromanager_gui/_widgets/_previerw_ndv.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Hashable, Mapping + +import tensorstore as ts +from ndv import DataWrapper, NDViewer +from pymmcore_plus import CMMCorePlus +from qtpy import QtCore +from superqt.utils import ensure_main_thread + +from ._snap_live_buttons import BTN_SIZE, ICON_SIZE, Live, SaveButton, Snap + +if TYPE_CHECKING: + import numpy as np + from qtpy.QtGui import QCloseEvent + + +def _data_type(mmc: CMMCorePlus) -> ts.dtype: + px_type = mmc.getBytesPerPixel() + if px_type == 1: + return ts.uint8 + elif px_type == 2: + return ts.uint16 + elif px_type == 4: + return ts.uint32 + else: + raise Exception(f"Unsupported Pixel Type: {px_type}") + + +class Preview(NDViewer): + """An NDViewer subclass tailored to active data viewing.""" + + def __init__(self, mmc: CMMCorePlus | None = None) -> None: + super().__init__(data=None) + self.setWindowTitle("Preview") + self.live_view: bool = False + self._mmc = mmc if mmc is not None else CMMCorePlus.instance() + + # BUTTONS + self._btns.setSpacing(5) + self._channel_mode_btn.hide() + self._ndims_btn.hide() + self._set_range_btn.setIconSize(ICON_SIZE) + self._set_range_btn.setFixedWidth(BTN_SIZE) + self._btns.insertWidget(2, Snap(mmcore=self._mmc)) + self._btns.insertWidget(3, Live(mmcore=self._mmc)) + self.save_btn = SaveButton(mmcore=self._mmc, viewer=self) + self._btns.insertWidget(4, self.save_btn) + + # Create initial buffer + self.ts_array = None + self.ts_shape = (0, 0) + self.bytes_per_pixel = 0 + + # Connections + ev = self._mmc.events + ev.imageSnapped.connect(self._handle_snap) + ev.continuousSequenceAcquisitionStarted.connect(self._start_live_viewer) + ev.sequenceAcquisitionStopped.connect(self._stop_live_viewer) + + # # Begin TODO: Remove once https://github.com/pyapp-kit/ndv/issues/39 solved + + def _update_datastore(self) -> Any: + if ( + self.ts_array is None + or self.ts_shape[0] != self._mmc.getImageHeight() + or self.ts_shape[1] != self._mmc.getImageWidth() + or self.bytes_per_pixel != self._mmc.getBytesPerPixel() + ): + self.ts_shape = (self._mmc.getImageHeight(), self._mmc.getImageWidth()) + self.bytes_per_pixel = self._mmc.getBytesPerPixel() + self.ts_array = ts.open( + {"driver": "zarr", "kvstore": {"driver": "memory"}}, + create=True, + shape=self.ts_shape, + dtype=_data_type(self._mmc), + ).result() + super().set_data(self.ts_array) + return self.ts_array + + def set_data( + self, + data: DataWrapper[Any] | Any, + *, + initial_index: Mapping[Hashable, int | slice] | None = None, + ) -> None: + if initial_index is None: + initial_index = {} + array = self._update_datastore() + array[:] = data + self.set_current_index(initial_index) + + # # End TODO: Remove once https://github.com/pyapp-kit/ndv/issues/39 solved + + # -- SNAP VIEWER -- # + + @ensure_main_thread # type: ignore + def _handle_snap(self) -> None: + if self._mmc.mda.is_running(): + # This signal is emitted during MDAs as well - we want to ignore those. + return + self.set_data(self._mmc.getImage()) + + # -- LIVE VIEWER -- # + + @ensure_main_thread # type: ignore + def _start_live_viewer(self) -> None: + self.live_view = True + + # Start timer to update live viewer + interval = int(self._mmc.getExposure()) + self._live_timer_id = self.startTimer( + interval, QtCore.Qt.TimerType.PreciseTimer + ) + + def _stop_live_viewer(self, cameraLabel: str) -> None: + # Pause live viewer, but leave it open. + if self.live_view: + self.live_view = False + self.killTimer(self._live_timer_id) + self._live_timer_id = None + + def _update_viewer(self, data: np.ndarray | None = None) -> None: + """Update viewer with the latest image from the circular buffer.""" + if data is None: + if self._mmc.getRemainingImageCount() == 0: + return + try: + self.set_data(self._mmc.getLastImage()) + except (RuntimeError, IndexError): + # circular buffer empty + return + + def timerEvent(self, a0: QtCore.QTimerEvent | None) -> None: + """Handles TimerEvents.""" + # Handle the timer event by updating the viewer (on gui thread) + self._update_viewer() + + # -- HELPERS -- # + + def closeEvent(self, event: QCloseEvent | None) -> None: + self._mmc.stopSequenceAcquisition() + super().closeEvent(event) diff --git a/src/micromanager_gui/_widgets/_snap_live_buttons.py b/src/micromanager_gui/_widgets/_snap_live_buttons.py index 630c5d44..0cdb05c2 100644 --- a/src/micromanager_gui/_widgets/_snap_live_buttons.py +++ b/src/micromanager_gui/_widgets/_snap_live_buttons.py @@ -1,17 +1,19 @@ from __future__ import annotations +from os import path from typing import TYPE_CHECKING +import tifffile from fonticon_mdi6 import MDI6 +from pymmcore_plus import CMMCorePlus from pymmcore_widgets import LiveButton, SnapButton from qtpy.QtCore import QSize +from qtpy.QtWidgets import QFileDialog, QPushButton, QSizePolicy from superqt.fonticon import icon if TYPE_CHECKING: - from pymmcore_plus import CMMCorePlus - from qtpy.QtWidgets import ( - QWidget, - ) + from ndv import NDViewer + from qtpy.QtWidgets import QWidget BTN_SIZE = 30 ICON_SIZE = QSize(25, 25) @@ -42,6 +44,64 @@ def __init__( self.button_text_on = "" self.button_text_off = "" self.icon_color_on = () - self.icon_color_off = "magenta" + self.icon_color_off = "#C33" self.setFixedWidth(BTN_SIZE) self.setIconSize(ICON_SIZE) + + +class SaveButton(QPushButton): + """Create a QPushButton to save Viewfinder data. + + TODO + + Parameters + ---------- + viewfinder : Viewfinder | None + The `Viewfinder` displaying the data to save. + parent : QWidget | None + Optional parent widget. + + """ + + def __init__( + self, + viewer: NDViewer, + *, + parent: QWidget | None = None, + mmcore: CMMCorePlus | None = None, + ) -> None: + super().__init__(parent=parent) + + self.setSizePolicy( + QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + ) + self._viewer = viewer + self._mmc = mmcore if mmcore is not None else CMMCorePlus.instance() + + self._create_button() + + def _create_button(self) -> None: + self.setIcon(icon(MDI6.content_save)) + self.setIconSize(ICON_SIZE) + self.setFixedWidth(BTN_SIZE) + + self.clicked.connect(self._save_data) + + def _save_data(self) -> None: + # Stop sequence acquisitions + self._mmc.stopSequenceAcquisition() + + (file, _) = QFileDialog.getSaveFileName( + self._viewer, + "Save Image", + "", # + "*.tif", # Acceptable extensions + ) + (p, extension) = path.splitext(file) + if extension == ".tif": + data = self._viewer.data_wrapper.isel({}) + # TODO: Save metadata? + tifffile.imwrite(file, data=data) + # TODO: Zarr seems like it would be easily supported through + # self._view.data_wrapper.save_as_zarr, but it is not implemented + # by TensorStoreWrapper From 972e58b3fff9c70352148438ce07d187106421a1 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Sun, 3 Nov 2024 17:02:15 -0500 Subject: [PATCH 129/226] feat: use ndv --- src/micromanager_gui/_core_link.py | 9 +- src/micromanager_gui/_main_window.py | 18 +- src/micromanager_gui/_readers/__init__.py | 4 + src/micromanager_gui/_widgets/_preview.py | 243 -------- .../_widgets/_snap_live_buttons.py | 64 +-- .../_widgets/_stack_viewer/__init__.py | 4 - .../_stack_viewer/_backends/__init__.py | 36 -- .../_stack_viewer/_backends/_pygfx.py | 164 ------ .../_stack_viewer/_backends/_vispy.py | 144 ----- .../_widgets/_stack_viewer/_dims_slider.py | 523 ----------------- .../_widgets/_stack_viewer/_indexing.py | 292 ---------- .../_widgets/_stack_viewer/_lut_control.py | 121 ---- .../_widgets/_stack_viewer/_mda_viewer.py | 66 --- .../_widgets/_stack_viewer/_protocols.py | 43 -- .../_widgets/_stack_viewer/_stack_viewer.py | 541 ------------------ .../_widgets/_viewers/__init__.py | 4 + .../_widgets/_viewers/_mda_viewer/__init__.py | 3 + .../_viewers/_mda_viewer/_data_wrappers.py | 81 +++ .../_mda_viewer/_mda_save_button.py} | 20 +- .../_viewers/_mda_viewer/_mda_viewer.py | 58 ++ .../_viewers/_preview_viewer/__init__.py | 3 + .../_preview_viewer/_preview_save_button.py | 70 +++ .../_preview_viewer/_preview_viewer.py} | 70 ++- test.ome.zarr/.zgroup | 3 - tests/test_stack_viewer.py | 53 -- 25 files changed, 299 insertions(+), 2338 deletions(-) create mode 100644 src/micromanager_gui/_readers/__init__.py delete mode 100644 src/micromanager_gui/_widgets/_preview.py delete mode 100644 src/micromanager_gui/_widgets/_stack_viewer/__init__.py delete mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_backends/__init__.py delete mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_backends/_pygfx.py delete mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_backends/_vispy.py delete mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_dims_slider.py delete mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_indexing.py delete mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_lut_control.py delete mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_mda_viewer.py delete mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_protocols.py delete mode 100644 src/micromanager_gui/_widgets/_stack_viewer/_stack_viewer.py create mode 100644 src/micromanager_gui/_widgets/_viewers/__init__.py create mode 100644 src/micromanager_gui/_widgets/_viewers/_mda_viewer/__init__.py create mode 100644 src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py rename src/micromanager_gui/_widgets/{_stack_viewer/_save_button.py => _viewers/_mda_viewer/_mda_save_button.py} (62%) create mode 100644 src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py create mode 100644 src/micromanager_gui/_widgets/_viewers/_preview_viewer/__init__.py create mode 100644 src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_save_button.py rename src/micromanager_gui/_widgets/{_previerw_ndv.py => _viewers/_preview_viewer/_preview_viewer.py} (70%) delete mode 100644 test.ome.zarr/.zgroup delete mode 100644 tests/test_stack_viewer.py diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index 53e4596c..a0c4c618 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -7,9 +7,10 @@ from qtpy.QtCore import QObject, Qt from qtpy.QtWidgets import QTabBar, QTabWidget -from micromanager_gui._widgets._stack_viewer import MDAViewer +from micromanager_gui._widgets._viewers import MDAViewer -from ._widgets._preview import Preview +# from ._widgets._preview import Preview +from ._widgets._viewers import Preview DIALOG = Qt.WindowType.Dialog VIEWER_TEMP_DIR = None @@ -39,7 +40,7 @@ def __init__(self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None self._main_window._central_wdg_layout.addWidget(self._viewer_tab, 0, 0) # preview tab - self._preview = Preview(self._main_window, mmcore=self._mmc) + self._preview = Preview(parent=self._main_window, mmcore=self._mmc) self._viewer_tab.addTab(self._preview, "Preview") # remove the preview tab close button self._viewer_tab.tabBar().setTabButton(*NO_R_BTN) @@ -93,7 +94,7 @@ def _setup_viewer(self, sequence: useq.MDASequence, meta: SummaryMetaV1) -> None """Setup the MDAViewer.""" # get the MDAWidget writer datastore = self._mda.writer if self._mda is not None else None - self._current_viewer = MDAViewer(parent=self._main_window, datastore=datastore) + self._current_viewer = MDAViewer(parent=self._main_window, data=datastore) # rename the viewer if there is a save_name' in the metadata or add a digit pmmcw_meta = cast(dict, sequence.metadata.get(PYMMCW_METADATA_KEY, {})) diff --git a/src/micromanager_gui/_main_window.py b/src/micromanager_gui/_main_window.py index 1aea40d0..87665352 100644 --- a/src/micromanager_gui/_main_window.py +++ b/src/micromanager_gui/_main_window.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING from warnings import warn +from ndv import NDViewer from pymmcore_plus import CMMCorePlus from qtpy.QtWidgets import ( QGridLayout, @@ -11,10 +12,7 @@ QWidget, ) -from micromanager_gui._readers._tensorstore_zarr_reader import ( - TensorstoreZarrReader, -) -from micromanager_gui._widgets._stack_viewer import StackViewer +from micromanager_gui._readers import TensorstoreZarrReader from ._core_link import CoreViewersLink from ._menubar._menubar import _MenuBar @@ -91,14 +89,22 @@ def dropEvent(self, event: QDropEvent) -> None: super().dropEvent(event) - def _open_datastore(self, idx: int, path: Path) -> StackViewer | None: + def _open_datastore(self, idx: int, path: Path) -> NDViewer | None: if path.name.endswith(".tensorstore.zarr"): try: reader = TensorstoreZarrReader(path) - return StackViewer(reader.store, parent=self) + return NDViewer(reader.store, parent=self) except Exception as e: warn(f"Error opening tensorstore-zarr: {e}!", stacklevel=2) return None + # TODO: implement with OMEZarrReader + # elif path.name.endswith(".ome.zarr"): + # try: + # reader = OMEZarrReader(path) + # return NDViewer(reader.store, parent=self) + # except Exception as e: + # warn(f"Error opening OME-zarr: {e}!", stacklevel=2) + # return None else: warn(f"Not yet supported format: {path.name}!", stacklevel=2) return None diff --git a/src/micromanager_gui/_readers/__init__.py b/src/micromanager_gui/_readers/__init__.py new file mode 100644 index 00000000..0deccb38 --- /dev/null +++ b/src/micromanager_gui/_readers/__init__.py @@ -0,0 +1,4 @@ +from ._ome_zarr_reader import OMEZarrReader +from ._tensorstore_zarr_reader import TensorstoreZarrReader + +__all__ = ["OMEZarrReader", "TensorstoreZarrReader"] diff --git a/src/micromanager_gui/_widgets/_preview.py b/src/micromanager_gui/_widgets/_preview.py deleted file mode 100644 index 5c99a5e0..00000000 --- a/src/micromanager_gui/_widgets/_preview.py +++ /dev/null @@ -1,243 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path - -import numpy as np -import tifffile -from fonticon_mdi6 import MDI6 -from pymmcore_plus import CMMCorePlus, Metadata -from pymmcore_widgets import ImagePreview -from qtpy.QtCore import QSize, Qt -from qtpy.QtWidgets import ( - QFileDialog, - QHBoxLayout, - QPushButton, - QSizePolicy, - QVBoxLayout, - QWidget, -) -from superqt import QLabeledRangeSlider -from superqt.fonticon import icon -from superqt.utils import signals_blocked - -from ._snap_live_buttons import Live, Snap - -BTN_SIZE = 30 -ICON_SIZE = QSize(25, 25) -SS = """ -QSlider::groove:horizontal { - height: 15px; - background: qlineargradient( - x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(128, 128, 128, 0.25), - stop:1 rgba(128, 128, 128, 0.1) - ); - border-radius: 3px; -} - -QSlider::handle:horizontal { - width: 38px; - background: #999999; - border-radius: 3px; -} - -QLabel { font-size: 12px; } - -QRangeSlider { qproperty-barColor: qlineargradient( - x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(100, 80, 120, 0.2), - stop:1 rgba(100, 80, 120, 0.4) - )} - -SliderLabel { - font-size: 12px; - color: white; -} -""" - - -class _ImagePreview(ImagePreview): - """Subclass of ImagePreview. - - This subclass updates the LUT slider when the image is updated. - """ - - def __init__( - self, - parent: QWidget | None = None, - *, - mmcore: CMMCorePlus | None = None, - preview_widget: Preview, - use_with_mda: bool = False, - ): - super().__init__(parent=parent, mmcore=mmcore, use_with_mda=use_with_mda) - - self._preview_wdg = preview_widget - - # the metadata associated with the image - self._meta: Metadata | dict = {} - - def _on_image_snapped(self) -> None: - if self._mmc.mda.is_running() and not self._use_with_mda: - return - self._update_image(self._mmc.getTaggedImage()) - - def _on_streaming_stop(self) -> None: - self.streaming_timer.stop() - self._meta = self._mmc.getTags() - - def _update_image(self, data: tuple[np.ndarray, Metadata] | np.ndarray) -> None: - """Update the image and the _clims slider.""" - if isinstance(data, np.ndarray): - image = data - else: - image, self._meta = data - - super()._update_image(image) - - if self.image is None: - return - - with signals_blocked(self._preview_wdg._clims): - self._preview_wdg._clims.setValue(self.image.clim) - - -class Preview(QWidget): - """A widget containing an ImagePreview and buttons for image preview.""" - - def __init__( - self, - parent: QWidget | None = None, - *, - mmcore: CMMCorePlus | None = None, - ): - super().__init__(parent) - self.setWindowTitle("Image Preview") - - self._mmc = mmcore or CMMCorePlus.instance() - - main_layout = QVBoxLayout() - self.setLayout(main_layout) - - # preview - self._image_preview = _ImagePreview(self, mmcore=self._mmc, preview_widget=self) - self._image_preview.setSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding - ) - main_layout.addWidget(self._image_preview) - - # buttons - bottom_wdg = QWidget() - bottom_wdg_layout = QHBoxLayout(bottom_wdg) - bottom_wdg_layout.setContentsMargins(0, 0, 0, 0) - - # auto contrast checkbox - self._auto_clim = QPushButton("Auto") - self._auto_clim.setMaximumWidth(42) - self._auto_clim.setCheckable(True) - self._auto_clim.setChecked(True) - self._auto_clim.toggled.connect(self._clims_auto) - # LUT slider - self._clims = QLabeledRangeSlider(Qt.Orientation.Horizontal) - self._clims.setStyleSheet(SS) - self._clims.setHandleLabelPosition( - QLabeledRangeSlider.LabelPosition.LabelsOnHandle - ) - self._clims.setEdgeLabelMode(QLabeledRangeSlider.EdgeLabelMode.NoLabel) - self._clims.setRange(0, 2**8) - self._clims.valueChanged.connect(self._on_clims_changed) - - # buttons widget - btns_wdg = QWidget() - btns_layout = QHBoxLayout(btns_wdg) - btns_layout.setContentsMargins(0, 0, 0, 0) - btns_layout.setSpacing(5) - # snap and live buttons - self._snap = Snap(mmcore=self._mmc) - self._snap.setFocusPolicy(Qt.FocusPolicy.NoFocus) - btns_layout.addWidget(self._snap) - self._live = Live(mmcore=self._mmc) - self._live.setFocusPolicy(Qt.FocusPolicy.NoFocus) - btns_layout.addWidget(self._live) - # reset view button - self._reset_view = QPushButton() - self._reset_view.clicked.connect(self._reset) - self._reset_view.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self._reset_view.setToolTip("Reset View") - self._reset_view.setIcon(icon(MDI6.fullscreen)) - self._reset_view.setIconSize(ICON_SIZE) - self._reset_view.setFixedWidth(BTN_SIZE) - btns_layout.addWidget(self._reset_view) - # save button - self._save = QPushButton() - self._save.clicked.connect(self._on_save) - self._save.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self._save.setToolTip("Save Image") - self._save.setIcon(icon(MDI6.content_save_outline)) - self._save.setIconSize(ICON_SIZE) - self._save.setFixedWidth(BTN_SIZE) - btns_layout.addWidget(self._save) - - bottom_wdg_layout.addWidget(self._clims) - bottom_wdg_layout.addWidget(self._auto_clim) - bottom_wdg_layout.addWidget(btns_wdg) - main_layout.addWidget(bottom_wdg) - - # connections - self._mmc.events.systemConfigurationLoaded.connect(self._on_sys_cfg_loaded) - self._mmc.events.roiSet.connect(self._reset) - - self.destroyed.connect(self._disconnect) - - self._reset() - self._on_sys_cfg_loaded() - - def _disconnect(self) -> None: - self._mmc.events.systemConfigurationLoaded.disconnect(self._on_sys_cfg_loaded) - - def _on_sys_cfg_loaded(self) -> None: - """Update the LUT slider range and the canvas size.""" - # update the LUT slider range - if bit := self._mmc.getImageBitDepth(): - with signals_blocked(self._clims): - self._clims.setRange(0, 2**bit - 1) - self._clims.setValue((0, 2**bit - 1)) - - def _reset(self) -> None: - """Reset the preview.""" - x = (0, self._mmc.getImageWidth()) if self._mmc.getImageWidth() else None - y = (0, self._mmc.getImageHeight()) if self._mmc.getImageHeight() else None - self._image_preview.view.camera.set_range(x, y, margin=0) - - def _on_clims_changed(self, range: tuple[float, float]) -> None: - """Update the LUT range.""" - self._image_preview.clims = range - self._auto_clim.setChecked(False) - - def _clims_auto(self, state: bool) -> None: - """Set the LUT range to auto.""" - self._image_preview.clims = "auto" if state else self._clims.value() - if self._image_preview.image is not None: - data = self._image_preview.image._data - with signals_blocked(self._clims): - self._clims.setValue((data.min(), data.max())) - - def _on_save(self) -> None: - """Save the image as tif.""" - if self._image_preview.image is None: - return - path, _ = QFileDialog.getSaveFileName( - self, "Save Image", "", "TIFF (*.tif *.tiff)" - ) - if not path: - return - tifffile.imwrite( - path, - self._image_preview.image._data, - imagej=True, - # description=self._image_preview._meta, # TODO: ome-tiff - ) - # save meta as json - dest = Path(path).with_suffix(".json") - dest.write_text(json.dumps(self._image_preview._meta)) diff --git a/src/micromanager_gui/_widgets/_snap_live_buttons.py b/src/micromanager_gui/_widgets/_snap_live_buttons.py index 0cdb05c2..5d59d335 100644 --- a/src/micromanager_gui/_widgets/_snap_live_buttons.py +++ b/src/micromanager_gui/_widgets/_snap_live_buttons.py @@ -1,18 +1,14 @@ from __future__ import annotations -from os import path from typing import TYPE_CHECKING -import tifffile from fonticon_mdi6 import MDI6 -from pymmcore_plus import CMMCorePlus from pymmcore_widgets import LiveButton, SnapButton from qtpy.QtCore import QSize -from qtpy.QtWidgets import QFileDialog, QPushButton, QSizePolicy from superqt.fonticon import icon if TYPE_CHECKING: - from ndv import NDViewer + from pymmcore_plus import CMMCorePlus from qtpy.QtWidgets import QWidget BTN_SIZE = 30 @@ -47,61 +43,3 @@ def __init__( self.icon_color_off = "#C33" self.setFixedWidth(BTN_SIZE) self.setIconSize(ICON_SIZE) - - -class SaveButton(QPushButton): - """Create a QPushButton to save Viewfinder data. - - TODO - - Parameters - ---------- - viewfinder : Viewfinder | None - The `Viewfinder` displaying the data to save. - parent : QWidget | None - Optional parent widget. - - """ - - def __init__( - self, - viewer: NDViewer, - *, - parent: QWidget | None = None, - mmcore: CMMCorePlus | None = None, - ) -> None: - super().__init__(parent=parent) - - self.setSizePolicy( - QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) - ) - self._viewer = viewer - self._mmc = mmcore if mmcore is not None else CMMCorePlus.instance() - - self._create_button() - - def _create_button(self) -> None: - self.setIcon(icon(MDI6.content_save)) - self.setIconSize(ICON_SIZE) - self.setFixedWidth(BTN_SIZE) - - self.clicked.connect(self._save_data) - - def _save_data(self) -> None: - # Stop sequence acquisitions - self._mmc.stopSequenceAcquisition() - - (file, _) = QFileDialog.getSaveFileName( - self._viewer, - "Save Image", - "", # - "*.tif", # Acceptable extensions - ) - (p, extension) = path.splitext(file) - if extension == ".tif": - data = self._viewer.data_wrapper.isel({}) - # TODO: Save metadata? - tifffile.imwrite(file, data=data) - # TODO: Zarr seems like it would be easily supported through - # self._view.data_wrapper.save_as_zarr, but it is not implemented - # by TensorStoreWrapper diff --git a/src/micromanager_gui/_widgets/_stack_viewer/__init__.py b/src/micromanager_gui/_widgets/_stack_viewer/__init__.py deleted file mode 100644 index d144dff4..00000000 --- a/src/micromanager_gui/_widgets/_stack_viewer/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from ._mda_viewer import MDAViewer -from ._stack_viewer import StackViewer - -__all__ = ["StackViewer", "MDAViewer"] diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_backends/__init__.py b/src/micromanager_gui/_widgets/_stack_viewer/_backends/__init__.py deleted file mode 100644 index 5813a5ba..00000000 --- a/src/micromanager_gui/_widgets/_stack_viewer/_backends/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -import importlib -import importlib.util -import os -import sys -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from micromanager_gui._widgets._stack_viewer._protocols import PCanvas - - -def get_canvas(backend: str | None = None) -> type[PCanvas]: - backend = backend or os.getenv("CANVAS_BACKEND", None) - if backend == "vispy" or (backend is None and "vispy" in sys.modules): - from ._vispy import VispyViewerCanvas - - return VispyViewerCanvas - - if backend == "pygfx" or (backend is None and "pygfx" in sys.modules): - from ._pygfx import PyGFXViewerCanvas - - return PyGFXViewerCanvas - - if backend is None: - if importlib.util.find_spec("vispy") is not None: - from ._vispy import VispyViewerCanvas - - return VispyViewerCanvas - - if importlib.util.find_spec("pygfx") is not None: - from ._pygfx import PyGFXViewerCanvas - - return PyGFXViewerCanvas - - raise RuntimeError("No canvas backend found") diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_backends/_pygfx.py b/src/micromanager_gui/_widgets/_stack_viewer/_backends/_pygfx.py deleted file mode 100644 index 37fe110b..00000000 --- a/src/micromanager_gui/_widgets/_stack_viewer/_backends/_pygfx.py +++ /dev/null @@ -1,164 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Callable, cast - -import numpy as np -import pygfx -from qtpy.QtCore import QSize -from wgpu.gui.qt import QWgpuCanvas - -if TYPE_CHECKING: - import cmap - from pygfx.materials import ImageBasicMaterial - from pygfx.resources import Texture - from qtpy.QtWidgets import QWidget - - -class PyGFXImageHandle: - def __init__(self, image: pygfx.Image, render: Callable) -> None: - self._image = image - self._render = render - self._grid = cast("Texture", image.geometry.grid) - self._material = cast("ImageBasicMaterial", image.material) - - @property - def data(self) -> np.ndarray: - return self._grid.data # type: ignore - - @data.setter - def data(self, data: np.ndarray) -> None: - self._grid.data[:] = data - self._grid.update_range((0, 0, 0), self._grid.size) - - @property - def visible(self) -> bool: - return bool(self._image.visible) - - @visible.setter - def visible(self, visible: bool) -> None: - self._image.visible = visible - self._render() - - @property - def clim(self) -> Any: - return self._material.clim - - @clim.setter - def clim(self, clims: tuple[float, float]) -> None: - self._material.clim = clims - self._render() - - @property - def cmap(self) -> cmap.Colormap: - return self._cmap - - @cmap.setter - def cmap(self, cmap: cmap.Colormap) -> None: - self._cmap = cmap - self._material.map = cmap.to_pygfx() - self._render() - - def remove(self) -> None: - if (par := self._image.parent) is not None: - par.remove(self._image) - - -class _QWgpuCanvas(QWgpuCanvas): - def sizeHint(self) -> QSize: - return QSize(512, 512) - - -class PyGFXViewerCanvas: - """pygfx-based canvas wrapper.""" - - def __init__(self, set_info: Callable[[str], None]) -> None: - self._set_info = set_info - - self._canvas = _QWgpuCanvas(size=(512, 512)) - self._renderer = pygfx.renderers.WgpuRenderer(self._canvas) - # requires https://github.com/pygfx/pygfx/pull/752 - self._renderer.blend_mode = "additive" - self._scene = pygfx.Scene() - self._camera = cam = pygfx.OrthographicCamera(512, 512) - cam.local.scale_y = -1 - - cam.local.position = (256, 256, 0) - self._controller = pygfx.PanZoomController(cam, register_events=self._renderer) - # increase zoom wheel gain - self._controller.controls.update({"wheel": ("zoom_to_point", "push", -0.005)}) - - def qwidget(self) -> QWidget: - return cast("QWidget", self._canvas) - - def refresh(self) -> None: - self._canvas.update() - self._canvas.request_draw(self._animate) - - def _animate(self) -> None: - self._renderer.render(self._scene, self._camera) - - def add_image( - self, data: np.ndarray | None = None, cmap: cmap.Colormap | None = None - ) -> PyGFXImageHandle: - """Add a new Image node to the scene.""" - image = pygfx.Image( - pygfx.Geometry(grid=pygfx.Texture(data, dim=2)), - # depth_test=False for additive-like blending - pygfx.ImageBasicMaterial(depth_test=False), - ) - self._scene.add(image) - # FIXME: I suspect there are more performant ways to refresh the canvas - # look into it. - handle = PyGFXImageHandle(image, self.refresh) - if cmap is not None: - handle.cmap = cmap - return handle - - def set_range( - self, - x: tuple[float, float] | None = None, - y: tuple[float, float] | None = None, - margin: float = 0.05, - ) -> None: - """Update the range of the PanZoomCamera. - - When called with no arguments, the range is set to the full extent of the data. - """ - if not self._scene.children: - return - - cam = self._camera - cam.show_object(self._scene) - - width, height, depth = np.ptp(self._scene.get_world_bounding_box(), axis=0) - if width < 0.01: - width = 1 - if height < 0.01: - height = 1 - cam.width = width - cam.height = height - cam.zoom = 1 - margin - self.refresh() - - # def _on_mouse_move(self, event: SceneMouseEvent) -> None: - # """Mouse moved on the canvas, display the pixel value and position.""" - # images = [] - # # Get the images the mouse is over - # seen = set() - # while visual := self._canvas.visual_at(event.pos): - # if isinstance(visual, scene.visuals.Image): - # images.append(visual) - # visual.interactive = False - # seen.add(visual) - # for visual in seen: - # visual.interactive = True - # if not images: - # return - - # tform = images[0].get_transform("canvas", "visual") - # px, py, *_ = (int(x) for x in tform.map(event.pos)) - # text = f"[{py}, {px}]" - # for c, img in enumerate(images): - # with suppress(IndexError): - # text += f" c{c}: {img._data[py, px]}" - # self._set_info(text) diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_backends/_vispy.py b/src/micromanager_gui/_widgets/_stack_viewer/_backends/_vispy.py deleted file mode 100644 index d17e49ea..00000000 --- a/src/micromanager_gui/_widgets/_stack_viewer/_backends/_vispy.py +++ /dev/null @@ -1,144 +0,0 @@ -from __future__ import annotations - -from contextlib import suppress -from typing import TYPE_CHECKING, Any, Callable, cast - -import numpy as np -from superqt.utils import qthrottled -from vispy import scene - -if TYPE_CHECKING: - import cmap - from qtpy.QtWidgets import QWidget - from vispy.scene.events import SceneMouseEvent - - -class VispyImageHandle: - def __init__(self, image: scene.visuals.Image) -> None: - self._image = image - - @property - def data(self) -> np.ndarray: - return self._image._data # type: ignore - - @data.setter - def data(self, data: np.ndarray) -> None: - self._image.set_data(data) - - @property - def visible(self) -> bool: - return bool(self._image.visible) - - @visible.setter - def visible(self, visible: bool) -> None: - self._image.visible = visible - - @property - def clim(self) -> Any: - return self._image.clim - - @clim.setter - def clim(self, clims: tuple[float, float]) -> None: - with suppress(ZeroDivisionError): - self._image.clim = clims - - @property - def cmap(self) -> cmap.Colormap: - return self._cmap - - @cmap.setter - def cmap(self, cmap: cmap.Colormap) -> None: - self._cmap = cmap - self._image.cmap = cmap.to_vispy() - - @property - def transform(self) -> np.ndarray: - raise NotImplementedError - - @transform.setter - def transform(self, transform: np.ndarray) -> None: - raise NotImplementedError - - def remove(self) -> None: - self._image.parent = None - - -class VispyViewerCanvas: - """Vispy-based viewer for data. - - All vispy-specific code is encapsulated in this class (and non-vispy canvases - could be swapped in if needed as long as they implement the same interface). - """ - - def __init__(self, set_info: Callable[[str], None]) -> None: - self._set_info = set_info - self._canvas = scene.SceneCanvas() - self._canvas.events.mouse_move.connect(qthrottled(self._on_mouse_move, 60)) - self._camera = scene.PanZoomCamera(aspect=1, flip=(0, 1)) - self._has_set_range = False - - central_wdg: scene.Widget = self._canvas.central_widget - self._view: scene.ViewBox = central_wdg.add_view(camera=self._camera) - - def qwidget(self) -> QWidget: - return cast("QWidget", self._canvas.native) - - def refresh(self) -> None: - self._canvas.update() - - def add_image( - self, data: np.ndarray | None = None, cmap: cmap.Colormap | None = None - ) -> VispyImageHandle: - """Add a new Image node to the scene.""" - img = scene.visuals.Image(data, parent=self._view.scene) - img.set_gl_state("additive", depth_test=False) - img.interactive = True - if not self._has_set_range: - self.set_range() - self._has_set_range = True - handle = VispyImageHandle(img) - if cmap is not None: - handle.cmap = cmap - return handle - - def set_range( - self, - x: tuple[float, float] | None = None, - y: tuple[float, float] | None = None, - margin: float = 0.01, - ) -> None: - """Update the range of the PanZoomCamera. - - When called with no arguments, the range is set to the full extent of the data. - """ - self._camera.set_range(x=x, y=y, margin=margin) - - def _on_mouse_move(self, event: SceneMouseEvent) -> None: - """Mouse moved on the canvas, display the pixel value and position.""" - images = [] - # Get the images the mouse is over - # FIXME: must be a better way to do this - seen = set() - try: - while visual := self._canvas.visual_at(event.pos): - if isinstance(visual, scene.visuals.Image): - images.append(visual) - visual.interactive = False - seen.add(visual) - except Exception: - return - for visual in seen: - visual.interactive = True - if not images: - return - - tform = images[0].get_transform("canvas", "visual") - px, py, *_ = (int(x) for x in tform.map(event.pos)) - text = f"[{py}, {px}]" - for c, img in enumerate(reversed(images)): - with suppress(IndexError): - value = img._data[py, px] - if isinstance(value, (np.floating, float)): - value = f"{value:.2f}" - text += f" {c}: {value}" - self._set_info(text) diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_dims_slider.py b/src/micromanager_gui/_widgets/_stack_viewer/_dims_slider.py deleted file mode 100644 index 2987c280..00000000 --- a/src/micromanager_gui/_widgets/_stack_viewer/_dims_slider.py +++ /dev/null @@ -1,523 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, cast -from warnings import warn - -from qtpy.QtCore import QPoint, QPointF, QSize, Qt, Signal -from qtpy.QtGui import QCursor, QResizeEvent -from qtpy.QtWidgets import ( - QDialog, - QDoubleSpinBox, - QFormLayout, - QFrame, - QHBoxLayout, - QLabel, - QPushButton, - QSizePolicy, - QSlider, - QSpinBox, - QVBoxLayout, - QWidget, -) -from superqt import QElidingLabel, QLabeledRangeSlider -from superqt.iconify import QIconifyIcon -from superqt.utils import signals_blocked - -if TYPE_CHECKING: - from typing import Hashable, Mapping, TypeAlias - - from PyQt6.QtGui import QResizeEvent - - # any hashable represent a single dimension in a AND array - DimKey: TypeAlias = Hashable - # any object that can be used to index a single dimension in an AND array - Index: TypeAlias = int | slice - # a mapping from dimension keys to indices (eg. {"x": 0, "y": slice(5, 10)}) - # this object is used frequently to query or set the currently displayed slice - Indices: TypeAlias = Mapping[DimKey, Index] - # mapping of dimension keys to the maximum value for that dimension - Sizes: TypeAlias = Mapping[DimKey, int] - - -SS = """ -QSlider::groove:horizontal { - height: 15px; - background: qlineargradient( - x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(128, 128, 128, 0.25), - stop:1 rgba(128, 128, 128, 0.1) - ); - border-radius: 3px; -} - -QSlider::handle:horizontal { - width: 38px; - background: #999999; - border-radius: 3px; -} - -QLabel { font-size: 12px; } - -QRangeSlider { qproperty-barColor: qlineargradient( - x1:0, y1:0, x2:0, y2:1, - stop:0 rgba(100, 80, 120, 0.2), - stop:1 rgba(100, 80, 120, 0.4) - )} - -SliderLabel { - font-size: 12px; - color: white; -} -""" - - -class QtPopup(QDialog): - """A generic popup window.""" - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setModal(False) # if False, then clicking anywhere else closes it - self.setWindowFlags(Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint) - - self.frame = QFrame(self) - layout = QVBoxLayout(self) - layout.addWidget(self.frame) - layout.setContentsMargins(0, 0, 0, 0) - - def show_above_mouse(self, *args: Any) -> None: - """Show popup dialog above the mouse cursor position.""" - pos = QCursor().pos() # mouse position - szhint = self.sizeHint() - pos -= QPoint(szhint.width() // 2, szhint.height() + 14) - self.move(pos) - self.resize(self.sizeHint()) - self.show() - - -class PlayButton(QPushButton): - """Just a styled QPushButton that toggles between play and pause icons.""" - - fpsChanged = Signal(float) - - PLAY_ICON = "bi:play-fill" - PAUSE_ICON = "bi:pause-fill" - - def __init__(self, fps: float = 20, parent: QWidget | None = None) -> None: - icn = QIconifyIcon(self.PLAY_ICON, color="#888888") - icn.addKey(self.PAUSE_ICON, state=QIconifyIcon.State.On, color="#4580DD") - super().__init__(icn, "", parent) - self.spin = QDoubleSpinBox(self) - self.spin.setRange(0.5, 100) - self.spin.setValue(fps) - self.spin.valueChanged.connect(self.fpsChanged) - self.setCheckable(True) - self.setFixedSize(14, 18) - self.setIconSize(QSize(16, 16)) - self.setStyleSheet("border: none; padding: 0; margin: 0;") - - self._popup = QtPopup(self) - form = QFormLayout(self._popup.frame) - form.setContentsMargins(6, 6, 6, 6) - form.addRow("FPS", self.spin) - - def mousePressEvent(self, e: Any) -> None: - if e and e.button() == Qt.MouseButton.RightButton: - self._show_fps_dialog(e.globalPosition()) - else: - super().mousePressEvent(e) - - def _show_fps_dialog(self, pos: QPointF) -> None: - self._popup.show_above_mouse() - - -class LockButton(QPushButton): - LOCK_ICON = "uis:unlock" - UNLOCK_ICON = "uis:lock" - - def __init__(self, text: str = "", parent: QWidget | None = None) -> None: - icn = QIconifyIcon(self.LOCK_ICON, color="#888888") - icn.addKey(self.UNLOCK_ICON, state=QIconifyIcon.State.On, color="red") - super().__init__(icn, text, parent) - self.setCheckable(True) - self.setFixedSize(20, 20) - self.setIconSize(QSize(14, 14)) - self.setStyleSheet("border: none; padding: 0; margin: 0;") - - -class DimsSlider(QWidget): - """A single slider in the DimsSliders widget. - - Provides a play/pause button that toggles animation of the slider value. - Has a QLabeledSlider for the actual value. - Adds a label for the maximum value (e.g. "3 / 10") - """ - - valueChanged = Signal(object, object) # where object is int | slice - - def __init__(self, dimension_key: DimKey, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setStyleSheet(SS) - self._slice_mode = False - self._dim_key = dimension_key - - self._timer_id: int | None = None # timer for play button - self._play_btn = PlayButton(parent=self) - self._play_btn.fpsChanged.connect(self.set_fps) - self._play_btn.toggled.connect(self._toggle_animation) - - self._dim_key = dimension_key - self._dim_label = QElidingLabel(str(dimension_key).upper()) - self._dim_label.setToolTip("Double-click to toggle slice mode") - - # note, this lock button only prevents the slider from updating programmatically - # using self.setValue, it doesn't prevent the user from changing the value. - self._lock_btn = LockButton(parent=self) - - self._pos_label = QSpinBox(self) - self._pos_label.valueChanged.connect(self._on_pos_label_edited) - self._pos_label.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons) - self._pos_label.setAlignment(Qt.AlignmentFlag.AlignRight) - self._pos_label.setStyleSheet( - "border: none; padding: 0; margin: 0; background: transparent" - ) - self._out_of_label = QLabel(self) - - self._int_slider = QSlider(Qt.Orientation.Horizontal) - self._int_slider.rangeChanged.connect(self._on_range_changed) - self._int_slider.valueChanged.connect(self._on_int_value_changed) - - self._slice_slider = slc = QLabeledRangeSlider(Qt.Orientation.Horizontal) - slc.setHandleLabelPosition(QLabeledRangeSlider.LabelPosition.LabelsOnHandle) - slc.setEdgeLabelMode(QLabeledRangeSlider.EdgeLabelMode.NoLabel) - slc.setVisible(False) - slc.rangeChanged.connect(self._on_range_changed) - slc.valueChanged.connect(self._on_slice_value_changed) - - self.installEventFilter(self) - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(2) - layout.addWidget(self._play_btn) - layout.addWidget(self._dim_label) - layout.addWidget(self._int_slider) - layout.addWidget(self._slice_slider) - layout.addWidget(self._pos_label) - layout.addWidget(self._out_of_label) - layout.addWidget(self._lock_btn) - self.setMinimumHeight(22) - - def resizeEvent(self, a0: QResizeEvent | None) -> None: - if isinstance(par := self.parent(), DimsSliders): - par.resizeEvent(None) - - def mouseDoubleClickEvent(self, a0: Any) -> None: - self._set_slice_mode(not self._slice_mode) - super().mouseDoubleClickEvent(a0) - - def containMaximum(self, max_val: int) -> None: - if max_val > self._int_slider.maximum(): - self._int_slider.setMaximum(max_val) - if max_val > self._slice_slider.maximum(): - self._slice_slider.setMaximum(max_val) - - def setMaximum(self, max_val: int) -> None: - self._int_slider.setMaximum(max_val) - self._slice_slider.setMaximum(max_val) - - def setMinimum(self, min_val: int) -> None: - self._int_slider.setMinimum(min_val) - self._slice_slider.setMinimum(min_val) - - def containMinimum(self, min_val: int) -> None: - if min_val < self._int_slider.minimum(): - self._int_slider.setMinimum(min_val) - if min_val < self._slice_slider.minimum(): - self._slice_slider.setMinimum(min_val) - - def setRange(self, min_val: int, max_val: int) -> None: - self._int_slider.setRange(min_val, max_val) - self._slice_slider.setRange(min_val, max_val) - - def value(self) -> Index: - if not self._slice_mode: - return self._int_slider.value() # type: ignore - start, *_, stop = cast("tuple[int, ...]", self._slice_slider.value()) - if start == stop: - return start - return slice(start, stop) - - def setValue(self, val: Index) -> None: - # variant of setValue that always updates the maximum - self._set_slice_mode(isinstance(val, slice)) - if self._lock_btn.isChecked(): - return - if isinstance(val, slice): - start = int(val.start) if val.start is not None else 0 - stop = ( - int(val.stop) if val.stop is not None else self._slice_slider.maximum() - ) - self._slice_slider.setValue((start, stop)) - else: - self._int_slider.setValue(val) - # self._slice_slider.setValue((val, val + 1)) - - def forceValue(self, val: Index) -> None: - """Set value and increase range if necessary.""" - if isinstance(val, slice): - if isinstance(val.start, int): - self.containMinimum(val.start) - if isinstance(val.stop, int): - self.containMaximum(val.stop) - else: - self.containMinimum(val) - self.containMaximum(val) - self.setValue(val) - - def _set_slice_mode(self, mode: bool = True) -> None: - if mode == self._slice_mode: - return - self._slice_mode = bool(mode) - self._slice_slider.setVisible(self._slice_mode) - self._int_slider.setVisible(not self._slice_mode) - # self._pos_label.setVisible(not self._slice_mode) - self.valueChanged.emit(self._dim_key, self.value()) - - def set_fps(self, fps: float) -> None: - self._play_btn.spin.setValue(fps) - self._toggle_animation(self._play_btn.isChecked()) - - def _toggle_animation(self, checked: bool) -> None: - if checked: - if self._timer_id is not None: - self.killTimer(self._timer_id) - interval = int(1000 / self._play_btn.spin.value()) - self._timer_id = self.startTimer(interval) - elif self._timer_id is not None: - self.killTimer(self._timer_id) - self._timer_id = None - - def timerEvent(self, event: Any) -> None: - """Handle timer event for play button, move to the next frame.""" - # TODO - # for now just increment the value by 1, but we should be able to - # take FPS into account better and skip additional frames if the timerEvent - # is delayed for some reason. - inc = 1 - if self._slice_mode: - val = cast(tuple[int, int], self._slice_slider.value()) - next_val = [v + inc for v in val] - if next_val[1] > self._slice_slider.maximum(): - # wrap around, without going below the min handle - next_val = [v - val[0] for v in val] - self._slice_slider.setValue(next_val) - else: - ival = self._int_slider.value() - ival = (ival + inc) % (self._int_slider.maximum() + 1) - self._int_slider.setValue(ival) - - def _on_pos_label_edited(self) -> None: - if self._slice_mode: - self._slice_slider.setValue( - (self._slice_slider.value()[0], self._pos_label.value()) - ) - else: - self._int_slider.setValue(self._pos_label.value()) - - def _on_range_changed(self, min: int, max: int) -> None: - self._out_of_label.setText(f"| {max}") - self._pos_label.setRange(min, max) - self.resizeEvent(None) - self.setVisible(min != max) - - def setVisible(self, visible: bool) -> None: - if self._has_no_range(): - visible = False - super().setVisible(visible) - - def _has_no_range(self) -> bool: - if self._slice_mode: - return bool(self._slice_slider.minimum() == self._slice_slider.maximum()) - return bool(self._int_slider.minimum() == self._int_slider.maximum()) - - def _on_int_value_changed(self, value: int) -> None: - self._pos_label.setValue(value) - if not self._slice_mode: - self.valueChanged.emit(self._dim_key, value) - - def _on_slice_value_changed(self, value: tuple[int, int]) -> None: - self._pos_label.setValue(int(value[1])) - with signals_blocked(self._int_slider): - self._int_slider.setValue(int(value[0])) - if self._slice_mode: - self.valueChanged.emit(self._dim_key, slice(*value)) - - -class DimsSliders(QWidget): - """A Collection of DimsSlider widgets for each dimension in the data. - - Maintains the global current index and emits a signal when it changes. - """ - - valueChanged = Signal(dict) # dict is of type Indices - - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self._locks_visible: bool | Mapping[DimKey, bool] = False - self._sliders: dict[DimKey, DimsSlider] = {} - self._current_index: dict[DimKey, Index] = {} - self._invisible_dims: set[DimKey] = set() - - self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) - - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - def __contains__(self, key: DimKey) -> bool: - """Return True if the dimension key is present in the DimsSliders.""" - return key in self._sliders - - def slider(self, key: DimKey) -> DimsSlider: - """Return the DimsSlider widget for the given dimension key.""" - return self._sliders[key] - - def value(self) -> Indices: - """Return mapping of {dim_key -> current index} for each dimension.""" - return self._current_index.copy() - - def setValue(self, values: Indices) -> None: - """Set the current index for each dimension. - - Parameters - ---------- - values : Mapping[Hashable, int | slice] - Mapping of {dim_key -> index} for each dimension. If value is a slice, - the slider will be in slice mode. If the dimension is not present in the - DimsSliders, it will be added. - """ - if self._current_index == values: - return - with signals_blocked(self): - for dim, index in values.items(): - self.add_or_update_dimension(dim, index) - # FIXME: i don't know why this this is ever empty ... only happens on pyside6 - if val := self.value(): - self.valueChanged.emit(val) - - def minima(self) -> Sizes: - """Return mapping of {dim_key -> minimum value} for each dimension.""" - return {k: v._int_slider.minimum() for k, v in self._sliders.items()} - - def setMinima(self, values: Sizes) -> None: - """Set the minimum value for each dimension. - - Parameters - ---------- - values : Mapping[Hashable, int] - Mapping of {dim_key -> minimum value} for each dimension. - """ - for name, min_val in values.items(): - if name not in self._sliders: - self.add_dimension(name) - self._sliders[name].setMinimum(min_val) - - def maxima(self) -> Sizes: - """Return mapping of {dim_key -> maximum value} for each dimension.""" - return {k: v._int_slider.maximum() for k, v in self._sliders.items()} - - def setMaxima(self, values: Sizes) -> None: - """Set the maximum value for each dimension. - - Parameters - ---------- - values : Mapping[Hashable, int] - Mapping of {dim_key -> maximum value} for each dimension. - """ - for name, max_val in values.items(): - if name not in self._sliders: - self.add_dimension(name) - self._sliders[name].setMaximum(max_val) - - def set_locks_visible(self, visible: bool | Mapping[DimKey, bool]) -> None: - """Set the visibility of the lock buttons for all dimensions.""" - self._locks_visible = visible - for dim, slider in self._sliders.items(): - viz = visible if isinstance(visible, bool) else visible.get(dim, False) - slider._lock_btn.setVisible(viz) - - def add_dimension(self, name: DimKey, val: Index | None = None) -> None: - """Add a new dimension to the DimsSliders widget. - - Parameters - ---------- - name : Hashable - The name of the dimension. - val : int | slice, optional - The initial value for the dimension. If a slice, the slider will be in - slice mode. - """ - self._sliders[name] = slider = DimsSlider(dimension_key=name, parent=self) - if isinstance(self._locks_visible, dict) and name in self._locks_visible: - slider._lock_btn.setVisible(self._locks_visible[name]) - else: - slider._lock_btn.setVisible(bool(self._locks_visible)) - - val_int = val.start if isinstance(val, slice) else val - slider.setVisible(name not in self._invisible_dims) - if isinstance(val_int, int): - slider.setRange(val_int, val_int) - elif isinstance(val_int, slice): - slider.setRange(val_int.start or 0, val_int.stop or 1) - - val = val if val is not None else 0 - self._current_index[name] = val - slider.forceValue(val) - slider.valueChanged.connect(self._on_dim_slider_value_changed) - cast("QVBoxLayout", self.layout()).addWidget(slider) - - def set_dimension_visible(self, key: DimKey, visible: bool) -> None: - """Set the visibility of a dimension in the DimsSliders widget. - - Once a dimension is hidden, it will not be shown again until it is explicitly - made visible again with this method. - """ - if visible: - self._invisible_dims.discard(key) - else: - self._invisible_dims.add(key) - if key in self._sliders: - self._sliders[key].setVisible(visible) - - def remove_dimension(self, key: DimKey) -> None: - """Remove a dimension from the DimsSliders widget.""" - try: - slider = self._sliders.pop(key) - except KeyError: - warn(f"Dimension {key} not found in DimsSliders", stacklevel=2) - return - cast("QVBoxLayout", self.layout()).removeWidget(slider) - slider.deleteLater() - - def _on_dim_slider_value_changed(self, key: DimKey, value: Index) -> None: - self._current_index[key] = value - self.valueChanged.emit(self.value()) - - def add_or_update_dimension(self, key: DimKey, value: Index) -> None: - """Add a dimension if it doesn't exist, otherwise update the value.""" - if key in self._sliders: - self._sliders[key].forceValue(value) - else: - self.add_dimension(key, value) - - def resizeEvent(self, a0: QResizeEvent | None) -> None: - # align all labels - if sliders := list(self._sliders.values()): - for lbl in ("_dim_label", "_pos_label", "_out_of_label"): - lbl_width = max(getattr(s, lbl).sizeHint().width() for s in sliders) - for s in sliders: - getattr(s, lbl).setFixedWidth(lbl_width) - - super().resizeEvent(a0) - - def sizeHint(self) -> QSize: - return super().sizeHint().boundedTo(QSize(9999, 0)) diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_indexing.py b/src/micromanager_gui/_widgets/_stack_viewer/_indexing.py deleted file mode 100644 index 65c1cc4f..00000000 --- a/src/micromanager_gui/_widgets/_stack_viewer/_indexing.py +++ /dev/null @@ -1,292 +0,0 @@ -from __future__ import annotations - -import sys -import warnings -from abc import abstractmethod -from concurrent.futures import Future, ThreadPoolExecutor -from contextlib import suppress -from typing import TYPE_CHECKING, Generic, Hashable, Mapping, Sequence, TypeVar, cast - -import numpy as np - -if TYPE_CHECKING: - from pathlib import Path - from typing import Any, Protocol, TypeGuard - - import dask.array as da - import numpy.typing as npt - import tensorstore as ts - import xarray as xr - from pymmcore_plus.mda.handlers import TensorStoreHandler - from pymmcore_plus.mda.handlers._5d_writer_base import _5DWriterBase - - from ._dims_slider import Index, Indices - - class SupportsIndexing(Protocol): - def __getitem__(self, key: Index | tuple[Index, ...]) -> npt.ArrayLike: ... - @property - def shape(self) -> tuple[int, ...]: ... - - -ArrayT = TypeVar("ArrayT") -MAX_CHANNELS = 16 -# Create a global executor -_EXECUTOR = ThreadPoolExecutor(max_workers=1) - - -class DataWrapper(Generic[ArrayT]): - def __init__(self, data: ArrayT) -> None: - self._data = data - - @classmethod - def create(cls, data: ArrayT) -> DataWrapper[ArrayT]: - if isinstance(data, DataWrapper): - return data - if MMTensorStoreWrapper.supports(data): - return MMTensorStoreWrapper(data) # type: ignore - if MM5DWriter.supports(data): - return MM5DWriter(data) # type: ignore - if XarrayWrapper.supports(data): - return XarrayWrapper(data) - if DaskWrapper.supports(data): - return DaskWrapper(data) - if TensorstoreWrapper.supports(data): - return TensorstoreWrapper(data) - if ArrayLikeWrapper.supports(data): - return ArrayLikeWrapper(data) - raise NotImplementedError(f"Don't know how to wrap type {type(data)}") - - @abstractmethod - def isel(self, indexers: Indices) -> np.ndarray: - """Select a slice from a data store using (possibly) named indices. - - For xarray.DataArray, use the built-in isel method. - For any other duck-typed array, use numpy-style indexing, where indexers - is a mapping of axis to slice objects or indices. - """ - raise NotImplementedError - - def isel_async(self, indexers: Indices) -> Future[tuple[Indices, np.ndarray]]: - """Asynchronous version of isel.""" - return _EXECUTOR.submit(lambda: (indexers, self.isel(indexers))) - - @classmethod - @abstractmethod - def supports(cls, obj: Any) -> bool: - """Return True if this wrapper can handle the given object.""" - raise NotImplementedError - - def guess_channel_axis(self) -> Hashable | None: - """Return the (best guess) axis name for the channel dimension.""" - if isinstance(shp := getattr(self._data, "shape", None), Sequence): - # for numpy arrays, use the smallest dimension as the channel axis - if min(shp) <= MAX_CHANNELS: - return shp.index(min(shp)) - return None - - def save_as_zarr(self, save_loc: str | Path) -> None: - raise NotImplementedError("save_as_zarr not implemented for this data type.") - - def sizes(self) -> Mapping[Hashable, int]: - if (shape := getattr(self._data, "shape", None)) and isinstance(shape, tuple): - _sizes: dict[Hashable, int] = {} - for i, val in enumerate(shape): - if isinstance(val, int): - _sizes[i] = val - elif isinstance(val, Sequence) and len(val) == 2: - _sizes[val[0]] = int(val[1]) - else: - raise ValueError( - f"Invalid size: {val}. Must be an int or a 2-tuple." - ) - return _sizes - raise NotImplementedError(f"Cannot determine sizes for {type(self._data)}") - - def summary_info(self) -> str: - """Return info label with information about the data.""" - package = getattr(self._data, "__module__", "").split(".")[0] - info = f"{package}.{getattr(type(self._data), '__qualname__', '')}" - - if sizes := self.sizes(): - # if all of the dimension keys are just integers, omit them from size_str - if all(isinstance(x, int) for x in sizes): - size_str = repr(tuple(sizes.values())) - # otherwise, include the keys in the size_str - else: - size_str = ", ".join(f"{k}:{v}" for k, v in sizes.items()) - size_str = f"({size_str})" - info += f" {size_str}" - if dtype := getattr(self._data, "dtype", ""): - info += f", {dtype}" - if nbytes := getattr(self._data, "nbytes", 0) / 1e6: - info += f", {nbytes:.2f}MB" - return info - - -class MMTensorStoreWrapper(DataWrapper["TensorStoreHandler"]): - def sizes(self) -> Mapping[Hashable, int]: - with suppress(Exception): - return self._data.current_sequence.sizes # type: ignore - return {} - - def guess_channel_axis(self) -> Hashable | None: - return "c" - - @classmethod - def supports(cls, obj: Any) -> TypeGuard[TensorStoreHandler]: - from pymmcore_plus.mda.handlers import TensorStoreHandler - - return isinstance(obj, TensorStoreHandler) - - def isel(self, indexers: Indices) -> np.ndarray: - return self._data.isel(indexers) # type: ignore - - def save_as_zarr(self, save_loc: str | Path) -> None: - if (store := self._data.store) is None: - return - import tensorstore as ts - - new_spec = store.spec().to_json() - new_spec["kvstore"] = {"driver": "file", "path": str(save_loc)} - new_ts = ts.open(new_spec, create=True).result() - new_ts[:] = store.read().result() - - -class MM5DWriter(DataWrapper["_5DWriterBase"]): - def guess_channel_axis(self) -> Hashable | None: - return "c" - - @classmethod - def supports(cls, obj: Any) -> TypeGuard[_5DWriterBase]: - try: - from pymmcore_plus.mda.handlers._5d_writer_base import _5DWriterBase - except ImportError: - from pymmcore_plus.mda.handlers import OMETiffWriter, OMEZarrWriter - - _5DWriterBase = (OMETiffWriter, OMEZarrWriter) # type: ignore - if isinstance(obj, _5DWriterBase): - return True - return False - - def sizes(self) -> Mapping[Hashable, int]: - try: - return super().sizes() - except NotImplementedError: - return {} - - def save_as_zarr(self, save_loc: str | Path) -> None: - import zarr - from pymmcore_plus.mda.handlers import OMEZarrWriter - - if isinstance(self._data, OMEZarrWriter): - zarr.copy_store(self._data.group.store, zarr.DirectoryStore(save_loc)) - raise NotImplementedError(f"Cannot save {type(self._data)} data to Zarr.") - - def isel(self, indexers: Indices) -> np.ndarray: - p_index = indexers.get("p", 0) - if isinstance(p_index, slice): - warnings.warn("Cannot slice over position index", stacklevel=2) # TODO - p_index = p_index.start - p_index = cast(int, p_index) - - try: - sizes = [*list(self._data.position_sizes[p_index]), "y", "x"] - except IndexError as e: - raise IndexError( - f"Position index {p_index} out of range for " - f"{len(self._data.position_sizes)}" - ) from e - - data = self._data.position_arrays[self._data.get_position_key(p_index)] - full = slice(None, None) - index = tuple(indexers.get(k, full) for k in sizes) - return data[index] # type: ignore - - -class XarrayWrapper(DataWrapper["xr.DataArray"]): - def isel(self, indexers: Indices) -> np.ndarray: - return np.asarray(self._data.isel(indexers)) - - def sizes(self) -> Mapping[Hashable, int]: - return {k: int(v) for k, v in self._data.sizes.items()} - - @classmethod - def supports(cls, obj: Any) -> TypeGuard[xr.DataArray]: - if (xr := sys.modules.get("xarray")) and isinstance(obj, xr.DataArray): - return True - return False - - def guess_channel_axis(self) -> Hashable | None: - for d in self._data.dims: - if str(d).lower() in ("channel", "ch", "c"): - return cast("Hashable", d) - return None - - def save_as_zarr(self, save_loc: str | Path) -> None: - self._data.to_zarr(save_loc) - - -class DaskWrapper(DataWrapper["da.Array"]): - def isel(self, indexers: Indices) -> np.ndarray: - idx = tuple(indexers.get(k, slice(None)) for k in range(len(self._data.shape))) - return np.asarray(self._data[idx].compute()) - - @classmethod - def supports(cls, obj: Any) -> TypeGuard[da.Array]: - if (da := sys.modules.get("dask.array")) and isinstance(obj, da.Array): - return True - return False - - def save_as_zarr(self, save_loc: str | Path) -> None: - self._data.to_zarr(url=str(save_loc)) - - -class TensorstoreWrapper(DataWrapper["ts.TensorStore"]): - def __init__(self, data: Any) -> None: - super().__init__(data) - import tensorstore as ts - - self._ts = ts - - def sizes(self) -> Mapping[Hashable, int]: - return {dim.label: dim.size for dim in self._data.domain} - - def isel(self, indexers: Indices) -> np.ndarray: - # result = self._data[self._ts.d[*indexers][*indexers.values()]].read().result() - result = ( - self._data[self._ts.d[tuple(indexers.keys())][tuple(indexers.values())]] - .read() - .result() - ) - return np.asarray(result) - - @classmethod - def supports(cls, obj: Any) -> TypeGuard[ts.TensorStore]: - if (ts := sys.modules.get("tensorstore")) and isinstance(obj, ts.TensorStore): - return True - return False - - -class ArrayLikeWrapper(DataWrapper): - def isel(self, indexers: Indices) -> np.ndarray: - idx = tuple(indexers.get(k, slice(None)) for k in range(len(self._data.shape))) - return np.asarray(self._data[idx]) - - @classmethod - def supports(cls, obj: Any) -> TypeGuard[SupportsIndexing]: - if ( - isinstance(obj, np.ndarray) - or hasattr(obj, "__array_function__") - or hasattr(obj, "__array_namespace__") - or (hasattr(obj, "__getitem__") and hasattr(obj, "__array__")) - ): - return True - return False - - def save_as_zarr(self, save_loc: str | Path) -> None: - import zarr - - if isinstance(self._data, zarr.Array): - self._data.store = zarr.DirectoryStore(save_loc) - else: - zarr.save(str(save_loc), self._data) diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_lut_control.py b/src/micromanager_gui/_widgets/_stack_viewer/_lut_control.py deleted file mode 100644 index 65ba6977..00000000 --- a/src/micromanager_gui/_widgets/_stack_viewer/_lut_control.py +++ /dev/null @@ -1,121 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Iterable, cast - -import numpy as np -from qtpy.QtCore import Qt -from qtpy.QtWidgets import QCheckBox, QFrame, QHBoxLayout, QPushButton, QWidget -from superqt import QLabeledRangeSlider -from superqt.cmap import QColormapComboBox -from superqt.utils import signals_blocked - -from ._dims_slider import SS - -if TYPE_CHECKING: - import cmap - - from ._protocols import PImageHandle - - -class CmapCombo(QColormapComboBox): - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent, allow_user_colormaps=True, add_colormap_text="Add...") - self.setMinimumSize(120, 21) - # self.setStyleSheet("background-color: transparent;") - - def showPopup(self) -> None: - super().showPopup() - popup = self.findChild(QFrame) - popup.setMinimumWidth(self.width() + 100) - popup.move(popup.x(), popup.y() - self.height() - popup.height()) - - -class LutControl(QWidget): - def __init__( - self, - name: str = "", - handles: Iterable[PImageHandle] = (), - parent: QWidget | None = None, - cmaplist: Iterable[Any] = (), - ) -> None: - super().__init__(parent) - self._handles = handles - self._name = name - - self._visible = QCheckBox(name) - self._visible.setChecked(True) - self._visible.toggled.connect(self._on_visible_changed) - - self._cmap = CmapCombo() - self._cmap.currentColormapChanged.connect(self._on_cmap_changed) - for handle in handles: - self._cmap.addColormap(handle.cmap) - for color in cmaplist: - self._cmap.addColormap(color) - - self._clims = QLabeledRangeSlider(Qt.Orientation.Horizontal) - self._clims.setStyleSheet(SS) - self._clims.setHandleLabelPosition( - QLabeledRangeSlider.LabelPosition.LabelsOnHandle - ) - self._clims.setEdgeLabelMode(QLabeledRangeSlider.EdgeLabelMode.NoLabel) - self._clims.setRange(0, 2**8) - self._clims.valueChanged.connect(self._on_clims_changed) - - self._auto_clim = QPushButton("Auto") - self._auto_clim.setMaximumWidth(42) - self._auto_clim.setCheckable(True) - self._auto_clim.setChecked(True) - self._auto_clim.toggled.connect(self.update_autoscale) - - layout = QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(self._visible) - layout.addWidget(self._cmap) - layout.addWidget(self._clims) - layout.addWidget(self._auto_clim) - - self.update_autoscale() - - def autoscaleChecked(self) -> bool: - return cast("bool", self._auto_clim.isChecked()) - - def _on_clims_changed(self, clims: tuple[float, float]) -> None: - self._auto_clim.setChecked(False) - for handle in self._handles: - handle.clim = clims - - def _on_visible_changed(self, visible: bool) -> None: - for handle in self._handles: - handle.visible = visible - if visible: - self.update_autoscale() - - def _on_cmap_changed(self, cmap: cmap.Colormap) -> None: - for handle in self._handles: - handle.cmap = cmap - - def update_autoscale(self) -> None: - if ( - not self._auto_clim.isChecked() - or not self._visible.isChecked() - or not self._handles - ): - return - - # find the min and max values for the current channel - clims = [np.inf, -np.inf] - for handle in self._handles: - clims[0] = min(clims[0], np.nanmin(handle.data)) - clims[1] = max(clims[1], np.nanmax(handle.data)) - - mi, ma = tuple(int(x) for x in clims) - if mi != ma: - for handle in self._handles: - handle.clim = (mi, ma) - - # set the slider values to the new clims - with signals_blocked(self._clims): - self._clims.setMinimum(min(mi, self._clims.minimum())) - self._clims.setMaximum(max(ma, self._clims.maximum())) - self._clims.setValue((mi, ma)) diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_mda_viewer.py b/src/micromanager_gui/_widgets/_stack_viewer/_mda_viewer.py deleted file mode 100644 index 29f32a3a..00000000 --- a/src/micromanager_gui/_widgets/_stack_viewer/_mda_viewer.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -import warnings -from typing import TYPE_CHECKING, Any, Mapping - -import superqt -import useq -from pymmcore_plus.mda.handlers import TensorStoreHandler - -from ._save_button import SaveButton -from ._stack_viewer import StackViewer - -if TYPE_CHECKING: - from pymmcore_plus.mda.handlers._5d_writer_base import _5DWriterBase - from qtpy.QtWidgets import QWidget - - -class MDAViewer(StackViewer): - """StackViewer specialized for pymmcore-plus MDA acquisitions.""" - - _data: _5DWriterBase | TensorStoreHandler - - def __init__( - self, - datastore: _5DWriterBase | TensorStoreHandler | None = None, - *, - parent: QWidget | None = None, - ): - if datastore is None: - datastore = TensorStoreHandler() - - # patch the frameReady method to call the superframeReady method - # AFTER handling the event - self._superframeReady = getattr(datastore, "frameReady", None) - if callable(self._superframeReady): - datastore.frameReady = self._patched_frame_ready # type: ignore - else: # pragma: no cover - warnings.warn( - "MDAViewer: datastore does not have a frameReady method to patch, " - "are you sure this is a valid data handler?", - stacklevel=2, - ) - - super().__init__(datastore, parent=parent, channel_axis="c") - self._save_btn = SaveButton(self._data_wrapper) - self._btns.addWidget(self._save_btn) - self.dims_sliders.set_locks_visible(True) - self._channel_names: dict[int, str] = {} - - def _patched_frame_ready(self, *args: Any) -> None: - self._superframeReady(*args) # type: ignore - if len(args) >= 2 and isinstance(e := args[1], useq.MDAEvent): - self._on_frame_ready(e) - - @superqt.ensure_main_thread # type: ignore - def _on_frame_ready(self, event: useq.MDAEvent) -> None: - c = event.index.get(self._channel_axis) # type: ignore - if c not in self._channel_names and c is not None and event.channel: - self._channel_names[c] = event.channel.config - self.setIndex(event.index) # type: ignore - - def _get_channel_name(self, index: Mapping) -> str: - if self._channel_axis in index: - if name := self._channel_names.get(index[self._channel_axis]): - return name - return super()._get_channel_name(index) diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_protocols.py b/src/micromanager_gui/_widgets/_stack_viewer/_protocols.py deleted file mode 100644 index 8b8d5d67..00000000 --- a/src/micromanager_gui/_widgets/_stack_viewer/_protocols.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Callable, Protocol - -if TYPE_CHECKING: - import cmap - import numpy as np - from qtpy.QtWidgets import QWidget - - -class PImageHandle(Protocol): - @property - def data(self) -> np.ndarray: ... - @data.setter - def data(self, data: np.ndarray) -> None: ... - @property - def visible(self) -> bool: ... - @visible.setter - def visible(self, visible: bool) -> None: ... - @property - def clim(self) -> Any: ... - @clim.setter - def clim(self, clims: tuple[float, float]) -> None: ... - @property - def cmap(self) -> Any: ... - @cmap.setter - def cmap(self, cmap: Any) -> None: ... - def remove(self) -> None: ... - - -class PCanvas(Protocol): - def __init__(self, set_info: Callable[[str], None]) -> None: ... - def set_range( - self, - x: tuple[float, float] | None = ..., - y: tuple[float, float] | None = ..., - margin: float = ..., - ) -> None: ... - def refresh(self) -> None: ... - def qwidget(self) -> QWidget: ... - def add_image( - self, data: np.ndarray | None = ..., cmap: cmap.Colormap | None = ... - ) -> PImageHandle: ... diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_stack_viewer.py b/src/micromanager_gui/_widgets/_stack_viewer/_stack_viewer.py deleted file mode 100644 index 0ad90962..00000000 --- a/src/micromanager_gui/_widgets/_stack_viewer/_stack_viewer.py +++ /dev/null @@ -1,541 +0,0 @@ -from __future__ import annotations - -from collections import defaultdict -from enum import Enum -from itertools import cycle -from typing import TYPE_CHECKING, Iterable, Mapping, Sequence, cast - -import cmap -import numpy as np -from qtpy.QtWidgets import QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget -from superqt import QCollapsible, QElidingLabel, QIconifyIcon, ensure_main_thread -from superqt.utils import qthrottled, signals_blocked - -from ._backends import get_canvas -from ._dims_slider import DimsSliders -from ._indexing import DataWrapper -from ._lut_control import LutControl - -if TYPE_CHECKING: - from concurrent.futures import Future - from typing import Any, Callable, Hashable, TypeAlias - - from qtpy.QtGui import QCloseEvent - - from ._dims_slider import DimKey, Indices, Sizes - from ._protocols import PCanvas, PImageHandle - - ImgKey: TypeAlias = Hashable - # any mapping of dimensions to sizes - SizesLike: TypeAlias = Sizes | Iterable[int | tuple[DimKey, int] | Sequence] - -MID_GRAY = "#888888" -GRAYS = cmap.Colormap("gray") -DEFAULT_COLORMAPS = [ - cmap.Colormap("green"), - cmap.Colormap("magenta"), - cmap.Colormap("cyan"), - cmap.Colormap("yellow"), - cmap.Colormap("red"), - cmap.Colormap("blue"), - cmap.Colormap("cubehelix"), - cmap.Colormap("gray"), -] -ALL_CHANNELS = slice(None) - - -class ChannelMode(str, Enum): - COMPOSITE = "composite" - MONO = "mono" - - def __str__(self) -> str: - return self.value - - -class ChannelModeButton(QPushButton): - def __init__(self, parent: QWidget | None = None): - super().__init__(parent) - self.setCheckable(True) - self.toggled.connect(self.next_mode) - - def next_mode(self) -> None: - if self.isChecked(): - self.setMode(ChannelMode.MONO) - else: - self.setMode(ChannelMode.COMPOSITE) - - def mode(self) -> ChannelMode: - return ChannelMode.MONO if self.isChecked() else ChannelMode.COMPOSITE - - def setMode(self, mode: ChannelMode) -> None: - # we show the name of the next mode, not the current one - other = ChannelMode.COMPOSITE if mode is ChannelMode.MONO else ChannelMode.MONO - self.setText(str(other)) - self.setChecked(mode == ChannelMode.MONO) - - -# @dataclass -# class LutModel: -# name: str = "" -# autoscale: bool = True -# min: float = 0.0 -# max: float = 1.0 -# colormap: cmap.Colormap = GRAYS -# visible: bool = True - - -# @dataclass -# class ViewerModel: -# data: Any = None -# # dimensions of the data that will *not* be sliced. -# visualized_dims: Container[DimKey] = (-2, -1) -# # the axis that represents the channels in the data -# channel_axis: DimKey | None = None -# # the mode for displaying the channels -# # if MONO, only the current selection of channel_axis is displayed -# # if COMPOSITE, the full channel_axis is sliced, and luts determine display -# channel_mode: ChannelMode = ChannelMode.MONO -# # map of index in the channel_axis to LutModel -# luts: Mapping[int, LutModel] = {} - - -class StackViewer(QWidget): - """A viewer for ND arrays. - - This widget displays a single slice from an ND array (or a composite of slices in - different colormaps). The widget provides sliders to select the slice to display, - and buttons to control the display mode of the channels. - - An important concept in this widget is the "index". The index is a mapping of - dimensions to integers or slices that define the slice of the data to display. For - example, a numpy slice of `[0, 1, 5:10]` would be represented as - `{0: 0, 1: 1, 2: slice(5, 10)}`, but dimensions can also be named, e.g. - `{'t': 0, 'c': 1, 'z': slice(5, 10)}`. The index is used to select the data from - the datastore, and to determine the position of the sliders. - - The flow of data is as follows: - - - The user sets the data using the `set_data` method. This will set the number - and range of the sliders to the shape of the data, and display the first slice. - - The user can then use the sliders to select the slice to display. The current - slice is defined as a `Mapping` of `{dim -> int|slice}` and can be retrieved - with the `_dims_sliders.value()` method. To programmatically set the current - position, use the `setIndex` method. This will set the values of the sliders, - which in turn will trigger the display of the new slice via the - `_update_data_for_index` method. - - `_update_data_for_index` is an asynchronous method that retrieves the data for - the given index from the datastore (using `_isel`) and queues the - `_on_data_slice_ready` method to be called when the data is ready. The logic - for extracting data from the datastore is defined in `_indexing.py`, which handles - idiosyncrasies of different datastores (e.g. xarray, tensorstore, etc). - - `_on_data_slice_ready` is called when the data is ready, and updates the image. - Note that if the slice is multidimensional, the data will be reduced to 2D using - max intensity projection (and double-clicking on any given dimension slider will - turn it into a range slider allowing a projection to be made over that dimension). - - The image is displayed on the canvas, which is an object that implements the - `PCanvas` protocol (mostly, it has an `add_image` method that returns a handle - to the added image that can be used to update the data and display). This - small abstraction allows for various backends to be used (e.g. vispy, pygfx, etc). - - Parameters - ---------- - data : Any - The data to display. This can be an ND array, an xarray DataArray, or any - object that supports numpy-style indexing. - parent : QWidget, optional - The parent widget of this widget. - channel_axis : Hashable, optional - The axis that represents the channels in the data. If not provided, this will - be guessed from the data. - channel_mode : ChannelMode, optional - The initial mode for displaying the channels. If not provided, this will be - set to ChannelMode.MONO. - """ - - def __init__( - self, - data: Any, - *, - colormaps: Iterable[cmap._colormap.ColorStopsLike] | None = None, - parent: QWidget | None = None, - channel_axis: DimKey | None = None, - channel_mode: ChannelMode | str = ChannelMode.MONO, - ): - super().__init__(parent=parent) - - # ATTRIBUTES ---------------------------------------------------- - - # dimensions of the data in the datastore - self._sizes: Sizes = {} - # mapping of key to a list of objects that control image nodes in the canvas - self._img_handles: defaultdict[ImgKey, list[PImageHandle]] = defaultdict(list) - # mapping of same keys to the LutControl objects control image display props - self._lut_ctrls: dict[ImgKey, LutControl] = {} - # the set of dimensions we are currently visualizing (e.g. XY) - # this is used to control which dimensions have sliders and the behavior - # of isel when selecting data from the datastore - self._visualized_dims: set[DimKey] = set() - # the axis that represents the channels in the data - self._channel_axis = channel_axis - self._channel_mode: ChannelMode = None # type: ignore # set in set_channel_mode - # colormaps that will be cycled through when displaying composite images - # TODO: allow user to set this - if colormaps is not None: - self._cmaps = [cmap.Colormap(c) for c in colormaps] - else: - self._cmaps = DEFAULT_COLORMAPS - self._cmap_cycle = cycle(self._cmaps) - # the last future that was created by _update_data_for_index - self._last_future: Future | None = None - # WIDGETS ---------------------------------------------------- - - # the button that controls the display mode of the channels - self._channel_mode_btn = ChannelModeButton(self) - self._channel_mode_btn.clicked.connect(self.set_channel_mode) - # button to reset the zoom of the canvas - self._set_range_btn = QPushButton( - QIconifyIcon("fluent:full-screen-maximize-24-filled"), "", self - ) - self._set_range_btn.clicked.connect(self._on_set_range_clicked) - - # place to display dataset summary - self._data_info_label = QElidingLabel("", parent=self) - # place to display arbitrary text - self._hover_info_label = QLabel("", self) - # the canvas that displays the images - self._canvas: PCanvas = get_canvas()(self._hover_info_label.setText) - # the sliders that control the index of the displayed image - self._dims_sliders = DimsSliders(self) - self._dims_sliders.valueChanged.connect( - qthrottled(self._update_data_for_index, 20, leading=True) - ) - - self._lut_drop = QCollapsible("LUTs", self) - self._lut_drop.setCollapsedIcon(QIconifyIcon("bi:chevron-down", color=MID_GRAY)) - self._lut_drop.setExpandedIcon(QIconifyIcon("bi:chevron-up", color=MID_GRAY)) - lut_layout = cast("QVBoxLayout", self._lut_drop.layout()) - lut_layout.setContentsMargins(0, 1, 0, 1) - lut_layout.setSpacing(0) - if ( - hasattr(self._lut_drop, "_content") - and (layout := self._lut_drop._content.layout()) is not None - ): - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - # LAYOUT ----------------------------------------------------- - - self._btns = btns = QHBoxLayout() - btns.setContentsMargins(0, 0, 0, 0) - btns.setSpacing(0) - btns.addStretch() - btns.addWidget(self._channel_mode_btn) - btns.addWidget(self._set_range_btn) - - layout = QVBoxLayout(self) - layout.setSpacing(2) - layout.setContentsMargins(6, 6, 6, 6) - layout.addWidget(self._data_info_label) - layout.addWidget(self._canvas.qwidget(), 1) - layout.addWidget(self._hover_info_label) - layout.addWidget(self._dims_sliders) - layout.addWidget(self._lut_drop) - layout.addLayout(btns) - - # SETUP ------------------------------------------------------ - - self.set_channel_mode(channel_mode) - self.set_data(data) - - # ------------------- PUBLIC API ---------------------------- - - @property - def data(self) -> Any: - """Return the data backing the view.""" - return self._data_wrapper._data - - @data.setter - def data(self, data: Any) -> None: - """Set the data backing the view.""" - raise AttributeError("Cannot set data directly. Use `set_data` method.") - - @property - def dims_sliders(self) -> DimsSliders: - """Return the DimsSliders widget.""" - return self._dims_sliders - - @property - def sizes(self) -> Sizes: - """Return sizes {dimkey: int} of the dimensions in the datastore.""" - return self._sizes - - def set_data( - self, - data: Any, - sizes: SizesLike | None = None, - channel_axis: int | None = None, - visualized_dims: Iterable[DimKey] | None = None, - ) -> None: - """Set the datastore, and, optionally, the sizes of the data.""" - # store the data - self._data_wrapper = DataWrapper.create(data) - - # determine sizes of the data - self._sizes = self._data_wrapper.sizes() if sizes is None else _to_sizes(sizes) - - # set channel axis - if channel_axis is not None: - self._channel_axis = channel_axis - elif self._channel_axis is None: - self._channel_axis = self._data_wrapper.guess_channel_axis() - - # update the dimensions we are visualizing - if visualized_dims is None: - visualized_dims = list(self._sizes)[-2:] - self.set_visualized_dims(visualized_dims) - - # update the range of all the sliders to match the sizes we set above - with signals_blocked(self._dims_sliders): - self.update_slider_ranges() - # redraw - self.setIndex({}) - # update the data info label - self._data_info_label.setText(self._data_wrapper.summary_info()) - - def set_visualized_dims(self, dims: Iterable[DimKey]) -> None: - """Set the dimensions that will be visualized. - - This dims will NOT have sliders associated with them. - """ - self._visualized_dims = set(dims) - for d in self._dims_sliders._sliders: - self._dims_sliders.set_dimension_visible(d, d not in self._visualized_dims) - for d in self._visualized_dims: - self._dims_sliders.set_dimension_visible(d, False) - - def update_slider_ranges( - self, mins: SizesLike | None = None, maxes: SizesLike | None = None - ) -> None: - """Set the maximum values of the sliders. - - If `sizes` is not provided, sizes will be inferred from the datastore. - This is mostly here as a public way to reset the - """ - if maxes is None: - maxes = self._sizes - maxes = _to_sizes(maxes) - self._dims_sliders.setMaxima({k: v - 1 for k, v in maxes.items()}) - if mins is not None: - self._dims_sliders.setMinima(_to_sizes(mins)) - - # FIXME: this needs to be moved and made user-controlled - for dim in list(maxes.keys())[-2:]: - self._dims_sliders.set_dimension_visible(dim, False) - - def set_channel_mode(self, mode: ChannelMode | str | None = None) -> None: - """Set the mode for displaying the channels. - - In "composite" mode, the channels are displayed as a composite image, using - self._channel_axis as the channel axis. In "grayscale" mode, each channel is - displayed separately. (If mode is None, the current value of the - channel_mode_picker button is used) - """ - if mode is None or isinstance(mode, bool): - mode = self._channel_mode_btn.mode() - else: - mode = ChannelMode(mode) - self._channel_mode_btn.setMode(mode) - if mode == getattr(self, "_channel_mode", None): - return - - self._channel_mode = mode - self._cmap_cycle = cycle(self._cmaps) # reset the colormap cycle - if self._channel_axis is not None: - # set the visibility of the channel slider - self._dims_sliders.set_dimension_visible( - self._channel_axis, mode != ChannelMode.COMPOSITE - ) - - if self._img_handles: - self._clear_images() - self._update_data_for_index(self._dims_sliders.value()) - - def setIndex(self, index: Indices) -> None: - """Set the index of the displayed image.""" - self._dims_sliders.setValue(index) - - # ------------------- PRIVATE METHODS ---------------------------- - - def _on_set_range_clicked(self) -> None: - # using method to swallow the parameter passed by _set_range_btn.clicked - self._canvas.set_range() - - def _image_key(self, index: Indices) -> ImgKey: - """Return the key for image handle(s) corresponding to `index`.""" - if self._channel_mode == ChannelMode.COMPOSITE: - val = index.get(self._channel_axis, 0) - if isinstance(val, slice): - return (val.start, val.stop) - return val - return 0 - - def _update_data_for_index(self, index: Indices) -> None: - """Retrieve data for `index` from datastore and update canvas image(s). - - This will pull the data from the datastore using the given index, and update - the image handle(s) with the new data. This method is *asynchronous*. It - makes a request for the new data slice and queues _on_data_future_done to be - called when the data is ready. - """ - if ( - self._channel_axis is not None - and self._channel_mode == ChannelMode.COMPOSITE - ): - index = {**index, self._channel_axis: ALL_CHANNELS} - - if self._last_future: - self._last_future.cancel() - - self._last_future = f = self._isel(index) - f.add_done_callback(self._on_data_slice_ready) - - def closeEvent(self, a0: QCloseEvent | None) -> None: - if self._last_future is not None: - self._last_future.cancel() - self._last_future = None - super().closeEvent(a0) - - def _isel(self, index: Indices) -> Future[tuple[Indices, np.ndarray]]: - """Select data from the datastore using the given index.""" - idx = {k: v for k, v in index.items() if k not in self._visualized_dims} - try: - return self._data_wrapper.isel_async(idx) - except Exception as e: - raise type(e)(f"Failed to index data with {idx}: {e}") from e - - @ensure_main_thread # type: ignore - def _on_data_slice_ready(self, future: Future[tuple[Indices, np.ndarray]]) -> None: - """Update the displayed image for the given index. - - Connected to the future returned by _isel. - """ - # NOTE: removing the reference to the last future here is important - # because the future has a reference to this widget in its _done_callbacks - # which will prevent the widget from being garbage collected if the future - self._last_future = None - if future.cancelled(): - return - - index, data = future.result() - # assume that if we have channels remaining, that they are the first axis - # FIXME: this is a bad assumption - data = iter(data) if index.get(self._channel_axis) is ALL_CHANNELS else [data] - # FIXME: - # `self._channel_axis: i` is a bug; we assume channel indices start at 0 - # but the actual values used for indices are up to the user. - for i, datum in enumerate(data): - self._update_canvas_data(datum, {**index, self._channel_axis: i}) - self._canvas.refresh() - - def _update_canvas_data(self, data: np.ndarray, index: Indices) -> None: - """Actually update the image handle(s) with the (sliced) data. - - By this point, data should be sliced from the underlying datastore. Any - dimensions remaining that are more than the number of visualized dimensions - (currently just 2D) will be reduced using max intensity projection (currently). - """ - imkey = self._image_key(index) - datum = self._reduce_data_for_display(data) - if handles := self._img_handles[imkey]: - for handle in handles: - handle.data = datum - if ctrl := self._lut_ctrls.get(imkey, None): - ctrl.update_autoscale() - else: - cm = ( - next(self._cmap_cycle) - if self._channel_mode == ChannelMode.COMPOSITE - else GRAYS - ) - # FIXME: this is a hack ... - # however, there's a bug in the vispy backend such that if the first - # image is all zeros, it persists even if the data is updated - # it's better just to not add it at all... - if np.max(datum) == 0: - return - handles.append(self._canvas.add_image(datum, cmap=cm)) - if imkey not in self._lut_ctrls: - channel_name = self._get_channel_name(index) - self._lut_ctrls[imkey] = c = LutControl( - channel_name, - handles, - self, - cmaplist=self._cmaps + DEFAULT_COLORMAPS, - ) - self._lut_drop.addWidget(c) - - def _get_channel_name(self, index: Indices) -> str: - c = index.get(self._channel_axis, 0) - return f"Ch {c}" # TODO: get name from user - - def _reduce_data_for_display( - self, data: np.ndarray, reductor: Callable[..., np.ndarray] = np.max - ) -> np.ndarray: - """Reduce the number of dimensions in the data for display. - - This function takes a data array and reduces the number of dimensions to - the max allowed for display. The default behavior is to reduce the smallest - dimensions, using np.max. This can be improved in the future. - - This also coerces 64-bit data to 32-bit data. - """ - # TODO - # - allow for 3d data - # - allow dimensions to control how they are reduced (as opposed to just max) - # - for better way to determine which dims need to be reduced (currently just - # the smallest dims) - data = data.squeeze() - visualized_dims = 2 - if extra_dims := data.ndim - visualized_dims: - shapes = sorted(enumerate(data.shape), key=lambda x: x[1]) - smallest_dims = tuple(i for i, _ in shapes[:extra_dims]) - return reductor(data, axis=smallest_dims) - - if data.dtype.itemsize > 4: # More than 32 bits - if np.issubdtype(data.dtype, np.integer): - data = data.astype(np.int32) - else: - data = data.astype(np.float32) - return data - - def _clear_images(self) -> None: - """Remove all images from the canvas.""" - for handles in self._img_handles.values(): - for handle in handles: - handle.remove() - self._img_handles.clear() - - # clear the current LutControls as well - for c in self._lut_ctrls.values(): - cast("QVBoxLayout", self.layout()).removeWidget(c) - c.deleteLater() - self._lut_ctrls.clear() - - -def _to_sizes(sizes: SizesLike | None) -> Sizes: - """Coerce `sizes` to a {dimKey -> int} mapping.""" - if sizes is None: - return {} - if isinstance(sizes, Mapping): - return {k: int(v) for k, v in sizes.items()} - if not isinstance(sizes, Iterable): - raise TypeError(f"SizeLike must be an iterable or mapping, not: {type(sizes)}") - _sizes: dict[Hashable, int] = {} - for i, val in enumerate(sizes): - if isinstance(val, int): - _sizes[i] = val - elif isinstance(val, Sequence) and len(val) == 2: - _sizes[val[0]] = int(val[1]) - else: - raise ValueError(f"Invalid size: {val}. Must be an int or a 2-tuple.") - return _sizes diff --git a/src/micromanager_gui/_widgets/_viewers/__init__.py b/src/micromanager_gui/_widgets/_viewers/__init__.py new file mode 100644 index 00000000..656914ea --- /dev/null +++ b/src/micromanager_gui/_widgets/_viewers/__init__.py @@ -0,0 +1,4 @@ +from ._mda_viewer import MDAViewer +from ._preview_viewer import Preview + +__all__ = ["MDAViewer", "Preview"] diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/__init__.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/__init__.py new file mode 100644 index 00000000..6cb7e298 --- /dev/null +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/__init__.py @@ -0,0 +1,3 @@ +from ._mda_viewer import MDAViewer + +__all__ = ["MDAViewer"] diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py new file mode 100644 index 00000000..608f83e9 --- /dev/null +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from contextlib import suppress +from typing import TYPE_CHECKING, Any, Hashable, Mapping, TypeGuard + +from ndv import DataWrapper +from pymmcore_plus.mda.handlers import TensorStoreHandler + +if TYPE_CHECKING: + from pathlib import Path + + from pymmcore_plus.mda.handlers._5d_writer_base import _5DWriterBase + + +class _MMTensorstoreWrapper(DataWrapper["TensorStoreHandler"]): + """Wrapper for pymmcore_plus.mda.handlers.TensorStoreHandler objects.""" + + def __init__(self, data: Any) -> None: + super().__init__(data) + + self._data: TensorStoreHandler = data + + @classmethod + def supports(cls, obj: Any) -> bool: + return isinstance(obj, TensorStoreHandler) + + def sizes(self) -> Mapping[str, int]: + with suppress(Exception): + return self._data.current_sequence.sizes + return {} + + def guess_channel_axis(self) -> Hashable | None: + return "c" + + def isel(self, indexers: Mapping[str, int]) -> Any: + return self._data.isel(indexers) + + def save_as_zarr(self, save_loc: str | Path) -> None: + import tensorstore as ts + + if (store := self._data.store) is None: + return + new_spec = store.spec().to_json() + new_spec["kvstore"] = {"driver": "file", "path": str(save_loc)} + new_ts = ts.open(new_spec, create=True).result() + new_ts[:] = store.read().result() + + +class _MM5DWriterWrapper(DataWrapper["_5DWriterBase"]): + """Wrapper for pymmcore_plus.mda.handlers._5DWriterBase objects.""" + + @classmethod + def supports(cls, obj: Any) -> TypeGuard[_5DWriterBase]: + try: + from pymmcore_plus.mda.handlers._5d_writer_base import _5DWriterBase + except ImportError: + from pymmcore_plus.mda.handlers import OMETiffWriter, OMEZarrWriter + + _5DWriterBase = (OMETiffWriter, OMEZarrWriter) # type: ignore + + return isinstance(obj, _5DWriterBase) + + def sizes(self) -> Mapping[Hashable, int]: + try: + return super().sizes() # type: ignore + except NotImplementedError: + return {} + + def guess_channel_axis(self) -> Hashable | None: + return "c" + + def isel(self, indexers: Mapping[str, int]) -> Any: + return self._data.isel(indexers) + + def save_as_zarr(self, save_loc: str | Path) -> None: + import zarr + from pymmcore_plus.mda.handlers import OMEZarrWriter + + if isinstance(self._data, OMEZarrWriter): + zarr.copy_store(self._data.group.store, zarr.DirectoryStore(save_loc)) + raise NotImplementedError(f"Cannot save {type(self._data)} data to Zarr.") diff --git a/src/micromanager_gui/_widgets/_stack_viewer/_save_button.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py similarity index 62% rename from src/micromanager_gui/_widgets/_stack_viewer/_save_button.py rename to src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py index 85520641..f1d136b4 100644 --- a/src/micromanager_gui/_widgets/_stack_viewer/_save_button.py +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py @@ -3,32 +3,40 @@ from pathlib import Path from typing import TYPE_CHECKING -from qtpy.QtWidgets import QFileDialog, QPushButton, QWidget -from superqt.iconify import QIconifyIcon +from fonticon_mdi6 import MDI6 +from qtpy.QtWidgets import ( + QFileDialog, + QPushButton, + QWidget, +) +from superqt.fonticon import icon if TYPE_CHECKING: - from ._indexing import DataWrapper + from ndv import DataWrapper -class SaveButton(QPushButton): +class MDASaveButton(QPushButton): def __init__( self, data_wrapper: DataWrapper, parent: QWidget | None = None, ): super().__init__(parent=parent) - self.setIcon(QIconifyIcon("mdi:content-save")) + self.setIcon(icon(MDI6.content_save_outline)) self.clicked.connect(self._on_click) self._data_wrapper = data_wrapper self._last_loc = str(Path.home()) def _on_click(self) -> None: + # TODO: Add support for other file formats self._last_loc, _ = QFileDialog.getSaveFileName( self, "Choose destination", str(self._last_loc), "" ) + if not self._last_loc: + return suffix = Path(self._last_loc).suffix if suffix in (".zarr", ".ome.zarr", ""): self._data_wrapper.save_as_zarr(self._last_loc) else: - raise ValueError(f"Unsupported file format: {self._last_loc}") + raise ValueError(f"File format not yet supported: {self._last_loc}") diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py new file mode 100644 index 00000000..a8a3322c --- /dev/null +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Any + +from ndv import NDViewer +from pymmcore_plus.mda.handlers import TensorStoreHandler +from superqt import ensure_main_thread +from useq import MDAEvent + +from ._mda_save_button import MDASaveButton + +if TYPE_CHECKING: + from pymmcore_plus.mda.handlers._5d_writer_base import _5DWriterBase + from qtpy.QtWidgets import QWidget + + +class MDAViewer(NDViewer): + """NDViewer specialized for pymmcore-plus MDA acquisitions.""" + + from ._data_wrappers import _MM5DWriterWrapper, _MMTensorstoreWrapper + + def __init__( + self, + data: _5DWriterBase | TensorStoreHandler | None = None, + *, + parent: QWidget | None = None, + **kwargs: Any, + ): + if data is None: + data = TensorStoreHandler() + + # patch the frameReady method to call the superframeReady method + # AFTER handling the event + self._superframeReady = getattr(data, "frameReady", None) + if callable(self._superframeReady): + data.frameReady = self._patched_frame_ready # type: ignore + else: # pragma: no cover + warnings.warn( + "MDAViewer: data does not have a frameReady method to patch, " + "are you sure this is a valid data handler?", + stacklevel=2, + ) + + super().__init__(data, parent=parent, channel_axis="c", **kwargs) + + self._save_btn = MDASaveButton(self._data_wrapper) + self._btns.insertWidget(3, self._save_btn) + self.dims_sliders.set_locks_visible(True) + + def _patched_frame_ready(self, *args: Any) -> None: + self._superframeReady(*args) # type: ignore + if len(args) >= 2 and isinstance(e := args[1], MDAEvent): + self._on_frame_ready(e) + + @ensure_main_thread # type: ignore + def _on_frame_ready(self, event: MDAEvent) -> None: + self.set_current_index(event.index) diff --git a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/__init__.py b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/__init__.py new file mode 100644 index 00000000..4edab24d --- /dev/null +++ b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/__init__.py @@ -0,0 +1,3 @@ +from ._preview_viewer import Preview + +__all__ = ["Preview"] diff --git a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_save_button.py b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_save_button.py new file mode 100644 index 00000000..55d37379 --- /dev/null +++ b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_save_button.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +import tifffile +from fonticon_mdi6 import MDI6 +from pymmcore_plus import CMMCorePlus +from qtpy.QtWidgets import QFileDialog, QPushButton, QSizePolicy +from superqt.fonticon import icon + +if TYPE_CHECKING: + from qtpy.QtWidgets import QWidget + + from micromanager_gui._widgets._viewers import Preview + + +class SaveButton(QPushButton): + """Create a QPushButton to save Viewfinder data. + + TODO + + Parameters + ---------- + viewfinder : Viewfinder | None + The `Viewfinder` displaying the data to save. + parent : QWidget | None + Optional parent widget. + + """ + + def __init__( + self, + viewer: Preview, + *, + parent: QWidget | None = None, + mmcore: CMMCorePlus | None = None, + ) -> None: + super().__init__(parent=parent) + + self.setSizePolicy( + QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + ) + self.setIcon(icon(MDI6.content_save_outline)) + + self._viewer = viewer + self._mmc = mmcore if mmcore is not None else CMMCorePlus.instance() + + self.clicked.connect(self._on_click) + + def _on_click(self) -> None: + # TODO: Add support for other file formats + # Stop sequence acquisitions + self._mmc.stopSequenceAcquisition() + + path, _ = QFileDialog.getSaveFileName( + self, "Save Image", "", "TIFF (*.tif *.tiff)" + ) + if not path: + return + tifffile.imwrite( + path, + self._viewer.data_wrapper.isel({}), + imagej=True, + # description=self._image_preview._meta, # TODO: ome-tiff + ) + # save meta as json + dest = Path(path).with_suffix(".json") + dest.write_text(json.dumps(self._viewer._meta)) diff --git a/src/micromanager_gui/_widgets/_previerw_ndv.py b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py similarity index 70% rename from src/micromanager_gui/_widgets/_previerw_ndv.py rename to src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py index 40300061..b2e8066b 100644 --- a/src/micromanager_gui/_widgets/_previerw_ndv.py +++ b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py @@ -4,15 +4,18 @@ import tensorstore as ts from ndv import DataWrapper, NDViewer -from pymmcore_plus import CMMCorePlus +from pymmcore_plus import CMMCorePlus, Metadata from qtpy import QtCore from superqt.utils import ensure_main_thread -from ._snap_live_buttons import BTN_SIZE, ICON_SIZE, Live, SaveButton, Snap +from micromanager_gui._widgets._snap_live_buttons import Live, Snap + +from ._preview_save_button import SaveButton if TYPE_CHECKING: import numpy as np from qtpy.QtGui import QCloseEvent + from qtpy.QtWidgets import QWidget def _data_type(mmc: CMMCorePlus) -> ts.dtype: @@ -30,35 +33,53 @@ def _data_type(mmc: CMMCorePlus) -> ts.dtype: class Preview(NDViewer): """An NDViewer subclass tailored to active data viewing.""" - def __init__(self, mmc: CMMCorePlus | None = None) -> None: - super().__init__(data=None) + def __init__( + self, mmcore: CMMCorePlus | None = None, parent: QWidget | None = None + ): + super().__init__(data=None, parent=parent) self.setWindowTitle("Preview") self.live_view: bool = False - self._mmc = mmc if mmc is not None else CMMCorePlus.instance() + self._meta: Metadata | dict = {} + self._mmc = mmcore if mmcore is not None else CMMCorePlus.instance() - # BUTTONS - self._btns.setSpacing(5) + # custom buttons + # hide the channel mode and ndims buttons self._channel_mode_btn.hide() self._ndims_btn.hide() - self._set_range_btn.setIconSize(ICON_SIZE) - self._set_range_btn.setFixedWidth(BTN_SIZE) - self._btns.insertWidget(2, Snap(mmcore=self._mmc)) - self._btns.insertWidget(3, Live(mmcore=self._mmc)) + + # snap and live buttons + snap_btn = Snap(mmcore=self._mmc) + live_btn = Live(mmcore=self._mmc) + icon_size = self._set_range_btn.iconSize() + btn_size = self._set_range_btn.sizeHint().width() + snap_btn.setIconSize(icon_size) + snap_btn.setFixedWidth(btn_size) + live_btn.setIconSize(icon_size) + live_btn.setFixedWidth(btn_size) + + # save button self.save_btn = SaveButton(mmcore=self._mmc, viewer=self) - self._btns.insertWidget(4, self.save_btn) - # Create initial buffer + self._btns.insertWidget(1, snap_btn) + self._btns.insertWidget(2, live_btn) + self._btns.insertWidget(3, self.save_btn) + + # create initial buffer self.ts_array = None self.ts_shape = (0, 0) self.bytes_per_pixel = 0 - # Connections + # connections ev = self._mmc.events ev.imageSnapped.connect(self._handle_snap) ev.continuousSequenceAcquisitionStarted.connect(self._start_live_viewer) ev.sequenceAcquisitionStopped.connect(self._stop_live_viewer) - # # Begin TODO: Remove once https://github.com/pyapp-kit/ndv/issues/39 solved + def closeEvent(self, event: QCloseEvent | None) -> None: + self._mmc.stopSequenceAcquisition() + super().closeEvent(event) + + # Begin TODO: Remove once https://github.com/pyapp-kit/ndv/issues/39 solved def _update_datastore(self) -> Any: if ( @@ -90,18 +111,20 @@ def set_data( array[:] = data self.set_current_index(initial_index) - # # End TODO: Remove once https://github.com/pyapp-kit/ndv/issues/39 solved - - # -- SNAP VIEWER -- # + # End TODO: Remove once https://github.com/pyapp-kit/ndv/issues/39 solved + # Snap ------------------------------------------------------------- @ensure_main_thread # type: ignore def _handle_snap(self) -> None: if self._mmc.mda.is_running(): # This signal is emitted during MDAs as well - we want to ignore those. return - self.set_data(self._mmc.getImage()) + # self.set_data(self._mmc.getImage()) + img, meta = self._mmc.getTaggedImage() + self.set_data(img) + self._meta = meta - # -- LIVE VIEWER -- # + # Live ------------------------------------------------------- @ensure_main_thread # type: ignore def _start_live_viewer(self) -> None: @@ -119,6 +142,7 @@ def _stop_live_viewer(self, cameraLabel: str) -> None: self.live_view = False self.killTimer(self._live_timer_id) self._live_timer_id = None + self._meta = self._mmc.getTags() def _update_viewer(self, data: np.ndarray | None = None) -> None: """Update viewer with the latest image from the circular buffer.""" @@ -135,9 +159,3 @@ def timerEvent(self, a0: QtCore.QTimerEvent | None) -> None: """Handles TimerEvents.""" # Handle the timer event by updating the viewer (on gui thread) self._update_viewer() - - # -- HELPERS -- # - - def closeEvent(self, event: QCloseEvent | None) -> None: - self._mmc.stopSequenceAcquisition() - super().closeEvent(event) diff --git a/test.ome.zarr/.zgroup b/test.ome.zarr/.zgroup deleted file mode 100644 index 3b7daf22..00000000 --- a/test.ome.zarr/.zgroup +++ /dev/null @@ -1,3 +0,0 @@ -{ - "zarr_format": 2 -} \ No newline at end of file diff --git a/tests/test_stack_viewer.py b/tests/test_stack_viewer.py deleted file mode 100644 index 435d7e3a..00000000 --- a/tests/test_stack_viewer.py +++ /dev/null @@ -1,53 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import dask.array as da -import numpy as np -import pytest - -from micromanager_gui._widgets._stack_viewer import StackViewer - -if TYPE_CHECKING: - from pytestqt.qtbot import QtBot - - -def make_lazy_array(shape: tuple[int, ...]) -> da.Array: - rest_shape = shape[:-2] - frame_shape = shape[-2:] - - def _dask_block(block_id: tuple[int, int, int, int, int]) -> np.ndarray | None: - if isinstance(block_id, np.ndarray): - return None - size = (1,) * len(rest_shape) + frame_shape - return np.random.randint(0, 255, size=size, dtype=np.uint8) - - chunks = [(1,) * x for x in rest_shape] + [(x,) for x in frame_shape] - return da.map_blocks(_dask_block, chunks=chunks, dtype=np.uint8) # type: ignore - - -# this test is still leaking widgets and it's hard to track down... I think -# it might have to do with the cmapComboBox -@pytest.mark.allow_leaks -def test_stack_viewer(qtbot: QtBot) -> None: - dask_arr = make_lazy_array((1000, 64, 3, 256, 256)) - v = StackViewer(dask_arr) - qtbot.addWidget(v) - v.show() - - # wait until there are no running jobs, because the callbacks - # in the futures hold a strong reference to the viewer - qtbot.waitUntil(lambda: v._last_future is None, timeout=1000) - - -def test_dims_sliders(qtbot: QtBot) -> None: - from superqt import QLabeledRangeSlider - - from micromanager_gui._widgets._stack_viewer._dims_slider import DimsSlider - - # temporary debugging - ds = DimsSlider(dimension_key="t") - qtbot.addWidget(ds) - - rs = QLabeledRangeSlider() - qtbot.addWidget(rs) From 0a6b0852399527fadd3558e1887b7fa1d9ccba4c Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Sun, 3 Nov 2024 17:06:20 -0500 Subject: [PATCH 130/226] fix: test --- tests/test_gui.py | 2 +- tests/test_mda_viewer.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_gui.py b/tests/test_gui.py index 4cfb1fb6..6999a510 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -4,7 +4,7 @@ from micromanager_gui import MicroManagerGUI from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS -from micromanager_gui._widgets._stack_viewer._mda_viewer import MDAViewer +from micromanager_gui._widgets._viewers import MDAViewer if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/tests/test_mda_viewer.py b/tests/test_mda_viewer.py index 6c7767ad..6c3c344e 100644 --- a/tests/test_mda_viewer.py +++ b/tests/test_mda_viewer.py @@ -22,7 +22,7 @@ TIFF_SEQ, ZARR_TESNSORSTORE, ) -from micromanager_gui._widgets._stack_viewer import MDAViewer +from micromanager_gui._widgets._viewers import MDAViewer if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus From dd18168d70d92519e7797bb39a6b093e0e41512d Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Sun, 3 Nov 2024 17:10:58 -0500 Subject: [PATCH 131/226] fix: add comment --- .../_widgets/_viewers/_preview_viewer/_preview_viewer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py index b2e8066b..08e7e9fa 100644 --- a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py +++ b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py @@ -33,6 +33,8 @@ def _data_type(mmc: CMMCorePlus) -> ts.dtype: class Preview(NDViewer): """An NDViewer subclass tailored to active data viewing.""" + # based on: https://github.com/gselzer/pymmcore-plus-sandbox/blob/53ac7e8ca3b4874816583b8b74024a75432b8fc9/src/pymmcore_plus_sandbox/_viewfinder.py#L154-L211 + def __init__( self, mmcore: CMMCorePlus | None = None, parent: QWidget | None = None ): From deea305dd13b873788709be23b399d472d32b641 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Sun, 3 Nov 2024 17:17:09 -0500 Subject: [PATCH 132/226] fix: ndv in pyproject.toml --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c77ce5fc..59fa36e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,8 @@ dependencies = [ "tqdm", "pyyaml", "cmap", - "pyconify" + "pyconify", + "ndv" ] # extras From 45c06397977b6a773fcc569a4203e273e2c7ca1e Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Sun, 3 Nov 2024 17:43:26 -0500 Subject: [PATCH 133/226] fix: _restart_live --- .../_widgets/_viewers/_preview_viewer/_preview_viewer.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py index 08e7e9fa..025c835b 100644 --- a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py +++ b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py @@ -77,6 +77,9 @@ def __init__( ev.continuousSequenceAcquisitionStarted.connect(self._start_live_viewer) ev.sequenceAcquisitionStopped.connect(self._stop_live_viewer) + self._mmc.events.exposureChanged.connect(self._restart_live) + self._mmc.events.configSet.connect(self._restart_live) + def closeEvent(self, event: QCloseEvent | None) -> None: self._mmc.stopSequenceAcquisition() super().closeEvent(event) @@ -157,6 +160,12 @@ def _update_viewer(self, data: np.ndarray | None = None) -> None: # circular buffer empty return + def _restart_live(self, exposure: float) -> None: + if not self.live_view: + return + self._mmc.stopSequenceAcquisition() + self._mmc.startContinuousSequenceAcquisition() + def timerEvent(self, a0: QtCore.QTimerEvent | None) -> None: """Handles TimerEvents.""" # Handle the timer event by updating the viewer (on gui thread) From 9dc90c2f94325ac9a9a68349d89f9ffd0d22e6a0 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 4 Nov 2024 14:46:37 -0500 Subject: [PATCH 134/226] feat: update readers --- src/micromanager_gui/_main_window.py | 2 +- .../{_readers => readers}/__init__.py | 0 .../{_readers => readers}/_ome_zarr_reader.py | 21 +++++++++++-- .../_tensorstore_zarr_reader.py | 31 ++++++++++++------- tests/test_readers_writers.py | 4 +-- 5 files changed, 41 insertions(+), 17 deletions(-) rename src/micromanager_gui/{_readers => readers}/__init__.py (100%) rename src/micromanager_gui/{_readers => readers}/_ome_zarr_reader.py (93%) rename src/micromanager_gui/{_readers => readers}/_tensorstore_zarr_reader.py (91%) diff --git a/src/micromanager_gui/_main_window.py b/src/micromanager_gui/_main_window.py index 87665352..15b9ec29 100644 --- a/src/micromanager_gui/_main_window.py +++ b/src/micromanager_gui/_main_window.py @@ -12,7 +12,7 @@ QWidget, ) -from micromanager_gui._readers import TensorstoreZarrReader +from micromanager_gui.readers import TensorstoreZarrReader from ._core_link import CoreViewersLink from ._menubar._menubar import _MenuBar diff --git a/src/micromanager_gui/_readers/__init__.py b/src/micromanager_gui/readers/__init__.py similarity index 100% rename from src/micromanager_gui/_readers/__init__.py rename to src/micromanager_gui/readers/__init__.py diff --git a/src/micromanager_gui/_readers/_ome_zarr_reader.py b/src/micromanager_gui/readers/_ome_zarr_reader.py similarity index 93% rename from src/micromanager_gui/_readers/_ome_zarr_reader.py rename to src/micromanager_gui/readers/_ome_zarr_reader.py index 6d8893ea..b0e46031 100644 --- a/src/micromanager_gui/_readers/_ome_zarr_reader.py +++ b/src/micromanager_gui/readers/_ome_zarr_reader.py @@ -23,7 +23,7 @@ class OMEZarrReader: Parameters ---------- - path : str | Path + data : str | Path The path to the ome-zarr file. Attributes @@ -36,6 +36,11 @@ class OMEZarrReader: The acquired useq.MDASequence. It is loaded from the metadata using the `useq.MDASequence` key. + Methods + ------- + metadata() + Return the unstructured full metadata. + Usage ----- reader = OMEZarrReader("path/to/file") @@ -46,8 +51,8 @@ class OMEZarrReader: data, metadata = reader.isel({"p": 0, "t": 1, "z": 0}, metadata=True) """ - def __init__(self, path: str | Path): - self._path = path + def __init__(self, data: str | Path): + self._path = data # open the zarr file self._store: Group = zarr.open(self._path) @@ -77,6 +82,16 @@ def sequence(self) -> useq.MDASequence | None: self._sequence = None return self._sequence + def metadata(self) -> list[dict]: + """Return the unstructured full metadata.""" + # concatenate the metadata for all the positions + return [ + meta + for key in self.store.keys() + if key.startswith("p") and key[1:].isdigit() + for meta in self.store[key].attrs.get(FRAME_META, []) + ] + def isel( self, indexers: Mapping[str, int] | None = None, diff --git a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py b/src/micromanager_gui/readers/_tensorstore_zarr_reader.py similarity index 91% rename from src/micromanager_gui/_readers/_tensorstore_zarr_reader.py rename to src/micromanager_gui/readers/_tensorstore_zarr_reader.py index 0ef07607..8c854675 100644 --- a/src/micromanager_gui/_readers/_tensorstore_zarr_reader.py +++ b/src/micromanager_gui/readers/_tensorstore_zarr_reader.py @@ -18,8 +18,8 @@ class TensorstoreZarrReader: Parameters ---------- - path : str | Path - The path to the tensorstore zarr file. + data : str | Path | ts.Tensorstore + The path to the tensorstore zarr file or the tensorstore zarr file itself. Attributes ---------- @@ -27,6 +27,8 @@ class TensorstoreZarrReader: The path to the tensorstore zarr file. store : ts.TensorStore The tensorstore. + metadata : list[dict] + The unstructured full metadata. sequence : useq.MDASequence The acquired useq.MDASequence. It is loaded from the metadata using the `useq.MDASequence` key. @@ -41,15 +43,17 @@ class TensorstoreZarrReader: data, metadata = reader.isel({"p": 0, "t": 1, "z": 0}, metadata=True) """ - def __init__(self, path: str | Path): - self._path = path - - spec = { - "driver": "zarr", - "kvstore": {"driver": "file", "path": str(self._path)}, - } - - _store = ts.open(spec).result() + def __init__(self, data: str | Path | ts.Tensorstore): + if isinstance(data, ts.Tensorstore): + self._path = data.kvstore.path.result().value + _store = data + else: + self._path = data + spec = { + "driver": "zarr", + "kvstore": {"driver": "file", "path": str(self._path)}, + } + _store = ts.open(spec).result() self._metadata: list = [] if metadata_json := _store.kvstore.read(".zattrs").result().value: @@ -82,6 +86,11 @@ def store(self) -> ts.TensorStore: """Return the tensorstore.""" return self._store + @property + def metadata(self) -> list[dict]: + """Return the unstructured full metadata.""" + return self._metadata + @property def sequence(self) -> useq.MDASequence | None: # getting the sequence from the first frame metadata within the "mda_event" key diff --git a/tests/test_readers_writers.py b/tests/test_readers_writers.py index 90bb4a8b..6341c2f5 100644 --- a/tests/test_readers_writers.py +++ b/tests/test_readers_writers.py @@ -7,9 +7,9 @@ import useq from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY -from micromanager_gui._readers._ome_zarr_reader import OMEZarrReader -from micromanager_gui._readers._tensorstore_zarr_reader import TensorstoreZarrReader from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler +from micromanager_gui.readers._ome_zarr_reader import OMEZarrReader +from micromanager_gui.readers._tensorstore_zarr_reader import TensorstoreZarrReader if TYPE_CHECKING: from pathlib import Path From f676ffcad77c9fcb9e9dfb660bdc27047e01e908 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 4 Nov 2024 14:53:44 -0500 Subject: [PATCH 135/226] feat: update mm data_wrappers --- .../_viewers/_mda_viewer/_data_wrappers.py | 23 +++++++++++++------ .../_viewers/_mda_viewer/_mda_viewer.py | 2 +- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py index 608f83e9..7c6d18e0 100644 --- a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py @@ -5,6 +5,7 @@ from ndv import DataWrapper from pymmcore_plus.mda.handlers import TensorStoreHandler +from micromanager_gui.readers import TensorstoreZarrReader if TYPE_CHECKING: from pathlib import Path @@ -12,7 +13,7 @@ from pymmcore_plus.mda.handlers._5d_writer_base import _5DWriterBase -class _MMTensorstoreWrapper(DataWrapper["TensorStoreHandler"]): +class MMTensorstoreWrapper(DataWrapper["TensorStoreHandler"]): """Wrapper for pymmcore_plus.mda.handlers.TensorStoreHandler objects.""" def __init__(self, data: Any) -> None: @@ -36,6 +37,8 @@ def isel(self, indexers: Mapping[str, int]) -> Any: return self._data.isel(indexers) def save_as_zarr(self, save_loc: str | Path) -> None: + # to have access to the metadata, the generated zarr file should be opened with + # the micromanager_gui.readers.TensorstoreZarrReader import tensorstore as ts if (store := self._data.store) is None: @@ -44,9 +47,17 @@ def save_as_zarr(self, save_loc: str | Path) -> None: new_spec["kvstore"] = {"driver": "file", "path": str(save_loc)} new_ts = ts.open(new_spec, create=True).result() new_ts[:] = store.read().result() + if meta_json := store.kvstore.read(".zattrs").result().value: + new_ts.kvstore.write(".zattrs", meta_json).result() + + def save_as_tiff(self, save_loc: str | Path) -> None: + if (store := self._data.store) is None: + return + reader = TensorstoreZarrReader(store) + reader.write_tiff(save_loc) -class _MM5DWriterWrapper(DataWrapper["_5DWriterBase"]): +class MM5DWriterWrapper(DataWrapper["_5DWriterBase"]): """Wrapper for pymmcore_plus.mda.handlers._5DWriterBase objects.""" @classmethod @@ -73,9 +84,7 @@ def isel(self, indexers: Mapping[str, int]) -> Any: return self._data.isel(indexers) def save_as_zarr(self, save_loc: str | Path) -> None: - import zarr - from pymmcore_plus.mda.handlers import OMEZarrWriter + raise NotImplementedError - if isinstance(self._data, OMEZarrWriter): - zarr.copy_store(self._data.group.store, zarr.DirectoryStore(save_loc)) - raise NotImplementedError(f"Cannot save {type(self._data)} data to Zarr.") + def save_as_tiff(self, save_loc: str | Path) -> None: + raise NotImplementedError diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py index a8a3322c..bd71933e 100644 --- a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py @@ -18,7 +18,7 @@ class MDAViewer(NDViewer): """NDViewer specialized for pymmcore-plus MDA acquisitions.""" - from ._data_wrappers import _MM5DWriterWrapper, _MMTensorstoreWrapper + from ._data_wrappers import MM5DWriterWrapper, MMTensorstoreWrapper def __init__( self, From 56e0bd85dffae86d43cd8a6f5dcc042064238d3e Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 4 Nov 2024 14:59:13 -0500 Subject: [PATCH 136/226] fix: fix readers --- .../_widgets/_viewers/_mda_viewer/_data_wrappers.py | 1 + src/micromanager_gui/readers/_tensorstore_zarr_reader.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py index 7c6d18e0..206aa33a 100644 --- a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py @@ -5,6 +5,7 @@ from ndv import DataWrapper from pymmcore_plus.mda.handlers import TensorStoreHandler + from micromanager_gui.readers import TensorstoreZarrReader if TYPE_CHECKING: diff --git a/src/micromanager_gui/readers/_tensorstore_zarr_reader.py b/src/micromanager_gui/readers/_tensorstore_zarr_reader.py index 8c854675..8e338705 100644 --- a/src/micromanager_gui/readers/_tensorstore_zarr_reader.py +++ b/src/micromanager_gui/readers/_tensorstore_zarr_reader.py @@ -43,9 +43,9 @@ class TensorstoreZarrReader: data, metadata = reader.isel({"p": 0, "t": 1, "z": 0}, metadata=True) """ - def __init__(self, data: str | Path | ts.Tensorstore): - if isinstance(data, ts.Tensorstore): - self._path = data.kvstore.path.result().value + def __init__(self, data: str | Path | ts.TensorStore): + if isinstance(data, ts.TensorStore): + self._path = data.kvstore.path _store = data else: self._path = data From 2b1e52b657ba8bda2faa77f52a7d84e6289cde22 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 4 Nov 2024 15:13:09 -0500 Subject: [PATCH 137/226] fix: save button logic --- .../_viewers/_mda_viewer/_data_wrappers.py | 3 +++ .../_viewers/_mda_viewer/_mda_save_button.py | 20 ++++++++++++++++--- .../_viewers/_mda_viewer/_mda_viewer.py | 9 +++++++-- .../readers/_tensorstore_zarr_reader.py | 1 + 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py index 206aa33a..0c7692e6 100644 --- a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py @@ -40,6 +40,9 @@ def isel(self, indexers: Mapping[str, int]) -> Any: def save_as_zarr(self, save_loc: str | Path) -> None: # to have access to the metadata, the generated zarr file should be opened with # the micromanager_gui.readers.TensorstoreZarrReader + + # TODO: find a way to save as ome-zarr + import tensorstore as ts if (store := self._data.store) is None: diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py index f1d136b4..da7d40e7 100644 --- a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py @@ -11,6 +11,8 @@ ) from superqt.fonticon import icon +from ._data_wrappers import MMTensorstoreWrapper + if TYPE_CHECKING: from ndv import DataWrapper @@ -29,14 +31,26 @@ def __init__( self._last_loc = str(Path.home()) def _on_click(self) -> None: - # TODO: Add support for other file formats + # TODO: add support for MM5DWriterWrapper. Ath the moment the MDAViewer will + # only show the save button if a pymmcore_plus.mda.handlers.TensorStoreHandler + # is used (and thus the MMTensorstoreWrapper) + if not isinstance(self._data_wrapper, MMTensorstoreWrapper): + raise ValueError( + "Only `MMTensorstoreWrapper` data wrappers are currently supported." + ) + self._last_loc, _ = QFileDialog.getSaveFileName( - self, "Choose destination", str(self._last_loc), "" + self, + "Choose destination", + str(self._last_loc), + "TIFF (*.tif *.tiff);;Zarr (*.zarr)", ) if not self._last_loc: return suffix = Path(self._last_loc).suffix - if suffix in (".zarr", ".ome.zarr", ""): + if suffix == ".zarr": self._data_wrapper.save_as_zarr(self._last_loc) + elif suffix in {".tif", ".tiff"}: + self._data_wrapper.save_as_tiff(self._last_loc) else: raise ValueError(f"File format not yet supported: {self._last_loc}") diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py index bd71933e..da41dfc0 100644 --- a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py @@ -44,8 +44,13 @@ def __init__( super().__init__(data, parent=parent, channel_axis="c", **kwargs) - self._save_btn = MDASaveButton(self._data_wrapper) - self._btns.insertWidget(3, self._save_btn) + # add the save button only if using a TensorStoreHandler (and thus the + # MMTensorstoreWrapper) since we didn't yet implement the save_as_zarr and + # save_as_tiff methods in the MM5DWriterWrapper. + if isinstance(data, TensorStoreHandler): + self._save_btn = MDASaveButton(self._data_wrapper) + self._btns.insertWidget(3, self._save_btn) + self.dims_sliders.set_locks_visible(True) def _patched_frame_ready(self, *args: Any) -> None: diff --git a/src/micromanager_gui/readers/_tensorstore_zarr_reader.py b/src/micromanager_gui/readers/_tensorstore_zarr_reader.py index 8e338705..1ca889e2 100644 --- a/src/micromanager_gui/readers/_tensorstore_zarr_reader.py +++ b/src/micromanager_gui/readers/_tensorstore_zarr_reader.py @@ -162,6 +162,7 @@ def write_tiff( (e.g. p=0, t=1). NOTE: kwargs will overwrite the indexers if already present in the indexers mapping. """ + # TODO: add support for ome-tiff if kwargs: indexers = indexers or {} if all( From 378b37703ea0cec9a159c086ac2422a5fb93c1e9 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 4 Nov 2024 16:45:10 -0500 Subject: [PATCH 138/226] feat: update wrapper save methods --- .../_viewers/_mda_viewer/_data_wrappers.py | 28 ++++++++++++++++--- .../_viewers/_mda_viewer/_mda_save_button.py | 16 ++++------- .../_viewers/_mda_viewer/_mda_viewer.py | 9 +++--- .../readers/_ome_zarr_reader.py | 19 +++++++------ 4 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py index 0c7692e6..f3ca201f 100644 --- a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py @@ -4,9 +4,9 @@ from typing import TYPE_CHECKING, Any, Hashable, Mapping, TypeGuard from ndv import DataWrapper -from pymmcore_plus.mda.handlers import TensorStoreHandler +from pymmcore_plus.mda.handlers import OMEZarrWriter, TensorStoreHandler -from micromanager_gui.readers import TensorstoreZarrReader +from micromanager_gui.readers import OMEZarrReader, TensorstoreZarrReader if TYPE_CHECKING: from pathlib import Path @@ -88,7 +88,27 @@ def isel(self, indexers: Mapping[str, int]) -> Any: return self._data.isel(indexers) def save_as_zarr(self, save_loc: str | Path) -> None: - raise NotImplementedError + # TODO: implement logic for OMETiffWriter + if isinstance(self._data, OMEZarrWriter): + import zarr + + # save a copy of the ome-zarr file + new_store = zarr.DirectoryStore(str(save_loc)) + new_group = zarr.group(store=new_store, overwrite=True) + # the group property returns a zarr.hierarchy.Group object + zarr.copy_all(self._data.group, new_group) + else: # OMETiffWriter + raise NotImplementedError( + "Saving as Zarr is not yet implemented for OMETiffWriter." + ) def save_as_tiff(self, save_loc: str | Path) -> None: - raise NotImplementedError + # TODO: implement logic for OMETiffWriter + if isinstance(self._data, OMEZarrWriter): + # the group property returns a zarr.hierarchy.Group object + reader = OMEZarrReader(self._data.group) + reader.write_tiff(save_loc) + else: # OMETiffWriter + raise NotImplementedError( + "Saving as TIFF is not yet implemented for OMETiffWriter." + ) diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py index da7d40e7..cf34b04c 100644 --- a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py @@ -11,7 +11,7 @@ ) from superqt.fonticon import icon -from ._data_wrappers import MMTensorstoreWrapper +from ._data_wrappers import MM5DWriterWrapper, MMTensorstoreWrapper if TYPE_CHECKING: from ndv import DataWrapper @@ -31,26 +31,20 @@ def __init__( self._last_loc = str(Path.home()) def _on_click(self) -> None: - # TODO: add support for MM5DWriterWrapper. Ath the moment the MDAViewer will - # only show the save button if a pymmcore_plus.mda.handlers.TensorStoreHandler - # is used (and thus the MMTensorstoreWrapper) - if not isinstance(self._data_wrapper, MMTensorstoreWrapper): - raise ValueError( - "Only `MMTensorstoreWrapper` data wrappers are currently supported." - ) - self._last_loc, _ = QFileDialog.getSaveFileName( self, "Choose destination", str(self._last_loc), - "TIFF (*.tif *.tiff);;Zarr (*.zarr)", + "TIFF (*.tif *.tiff);;ZARR (*.zarr)", ) if not self._last_loc: return suffix = Path(self._last_loc).suffix if suffix == ".zarr": self._data_wrapper.save_as_zarr(self._last_loc) - elif suffix in {".tif", ".tiff"}: + elif suffix in {".tif", ".tiff"} and isinstance( + self._data_wrapper, (MMTensorstoreWrapper, MM5DWriterWrapper) + ): self._data_wrapper.save_as_tiff(self._last_loc) else: raise ValueError(f"File format not yet supported: {self._last_loc}") diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py index da41dfc0..0fc794d6 100644 --- a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any from ndv import NDViewer -from pymmcore_plus.mda.handlers import TensorStoreHandler +from pymmcore_plus.mda.handlers import OMEZarrWriter, TensorStoreHandler from superqt import ensure_main_thread from useq import MDAEvent @@ -45,9 +45,10 @@ def __init__( super().__init__(data, parent=parent, channel_axis="c", **kwargs) # add the save button only if using a TensorStoreHandler (and thus the - # MMTensorstoreWrapper) since we didn't yet implement the save_as_zarr and - # save_as_tiff methods in the MM5DWriterWrapper. - if isinstance(data, TensorStoreHandler): + # MMTensorstoreWrapper) or OMEZarrWriter (and thus the MM5DWriterWrapper) + # since we didn't yet implement the save_as_zarr and save_as_tiff methods + # for OMETiffWriter in the MM5DWriterWrapper. + if isinstance(data, (TensorStoreHandler, OMEZarrWriter)): self._save_btn = MDASaveButton(self._data_wrapper) self._btns.insertWidget(3, self._save_btn) diff --git a/src/micromanager_gui/readers/_ome_zarr_reader.py b/src/micromanager_gui/readers/_ome_zarr_reader.py index b0e46031..f5fce7c8 100644 --- a/src/micromanager_gui/readers/_ome_zarr_reader.py +++ b/src/micromanager_gui/readers/_ome_zarr_reader.py @@ -2,16 +2,14 @@ import json from pathlib import Path -from typing import TYPE_CHECKING, Any, Mapping, cast +from typing import Any, Mapping, cast import numpy as np import useq import zarr from tifffile import imwrite from tqdm import tqdm - -if TYPE_CHECKING: - from zarr.hierarchy import Group +from zarr.hierarchy import Group EVENT = "Event" FRAME_META = "frame_meta" @@ -23,8 +21,8 @@ class OMEZarrReader: Parameters ---------- - data : str | Path - The path to the ome-zarr file. + data : str | Path | Group + The path to the ome-zarr file or the zarr group itself. Attributes ---------- @@ -51,11 +49,14 @@ class OMEZarrReader: data, metadata = reader.isel({"p": 0, "t": 1, "z": 0}, metadata=True) """ - def __init__(self, data: str | Path): + def __init__(self, data: str | Path | Group): self._path = data - # open the zarr file - self._store: Group = zarr.open(self._path) + if isinstance(data, Group): + self._store = data + else: + # open the zarr file + self._store: Group = zarr.open(self._path) # the useq.MDASequence if it exists self._sequence: useq.MDASequence | None = None From 80bc9f6790247a2894fdfc782977b73addeb96ef Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 20:24:20 +0000 Subject: [PATCH 139/226] ci(pre-commit.ci): autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/crate-ci/typos: v1.26.0 → v1.27.0](https://github.com/crate-ci/typos/compare/v1.26.0...v1.27.0) - [github.com/astral-sh/ruff-pre-commit: v0.6.9 → v0.7.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.9...v0.7.2) - [github.com/abravalheri/validate-pyproject: v0.20.2 → v0.22](https://github.com/abravalheri/validate-pyproject/compare/v0.20.2...v0.22) - [github.com/pre-commit/mirrors-mypy: v1.11.2 → v1.13.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.2...v1.13.0) --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f7bfd72d..5d60f87b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,24 +5,24 @@ ci: repos: - repo: https://github.com/crate-ci/typos - rev: v1.26.0 + rev: v1.27.0 hooks: - id: typos - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + rev: v0.7.2 hooks: - id: ruff args: [--fix, --unsafe-fixes] - id: ruff-format - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.20.2 + rev: v0.22 hooks: - id: validate-pyproject - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.2 + rev: v1.13.0 hooks: - id: mypy files: "^src/" From 83772c8b03d09df7535307fd8ff2a9f162384dd4 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 4 Nov 2024 17:03:38 -0500 Subject: [PATCH 140/226] test: fix --- tests/test_readers_writers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_readers_writers.py b/tests/test_readers_writers.py index 6341c2f5..9e11c251 100644 --- a/tests/test_readers_writers.py +++ b/tests/test_readers_writers.py @@ -81,10 +81,15 @@ def test_readers( assert dest.exists() - w = reader(path=dest) + w = reader(data=dest) assert w.store assert w.sequence + # test that the reader can accept the actual store as input on top of the path + w1 = reader(data=w.store) + assert isinstance(w1, type(w)) + assert w1.sequence == w.sequence + assert w.isel({"p": 0}).shape == (3, 2, 512, 512) assert w.isel({"p": 0, "t": 0}).shape == (2, 512, 512) _, metadata = w.isel({"p": 0}, metadata=True) From 04db24b90ddc037006b3627c458ccf605dd7a1c9 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 4 Nov 2024 17:21:16 -0500 Subject: [PATCH 141/226] test: fix --- src/micromanager_gui/readers/_ome_zarr_reader.py | 8 ++------ tests/test_readers_writers.py | 11 ++++++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/micromanager_gui/readers/_ome_zarr_reader.py b/src/micromanager_gui/readers/_ome_zarr_reader.py index f5fce7c8..046c2b07 100644 --- a/src/micromanager_gui/readers/_ome_zarr_reader.py +++ b/src/micromanager_gui/readers/_ome_zarr_reader.py @@ -50,13 +50,9 @@ class OMEZarrReader: """ def __init__(self, data: str | Path | Group): - self._path = data - if isinstance(data, Group): - self._store = data - else: - # open the zarr file - self._store: Group = zarr.open(self._path) + self._path = data.path if isinstance(data, Group) else data + self._store: Group = data if isinstance(data, Group) else zarr.open(self._path) # the useq.MDASequence if it exists self._sequence: useq.MDASequence | None = None diff --git a/tests/test_readers_writers.py b/tests/test_readers_writers.py index 9e11c251..544a5aee 100644 --- a/tests/test_readers_writers.py +++ b/tests/test_readers_writers.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING import pytest @@ -12,7 +13,6 @@ from micromanager_gui.readers._tensorstore_zarr_reader import TensorstoreZarrReader if TYPE_CHECKING: - from pathlib import Path from pymmcore_plus import CMMCorePlus from pytestqt.qtbot import QtBot @@ -84,11 +84,20 @@ def test_readers( w = reader(data=dest) assert w.store assert w.sequence + assert w.path == Path(dest) + assert ( + w.metadata + if isinstance(w, TensorstoreZarrReader) + else w.metadata() + if isinstance(w, OMEZarrReader) + else None + ) # test that the reader can accept the actual store as input on top of the path w1 = reader(data=w.store) assert isinstance(w1, type(w)) assert w1.sequence == w.sequence + assert w1.path assert w.isel({"p": 0}).shape == (3, 2, 512, 512) assert w.isel({"p": 0, "t": 0}).shape == (2, 512, 512) From 552d3e15cf728b57fe779c6dc57bd9aaadfd79f9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 22:21:32 +0000 Subject: [PATCH 142/226] style(pre-commit.ci): auto fixes [...] --- src/micromanager_gui/readers/_ome_zarr_reader.py | 1 - tests/test_readers_writers.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/micromanager_gui/readers/_ome_zarr_reader.py b/src/micromanager_gui/readers/_ome_zarr_reader.py index 046c2b07..f2c239eb 100644 --- a/src/micromanager_gui/readers/_ome_zarr_reader.py +++ b/src/micromanager_gui/readers/_ome_zarr_reader.py @@ -50,7 +50,6 @@ class OMEZarrReader: """ def __init__(self, data: str | Path | Group): - self._path = data.path if isinstance(data, Group) else data self._store: Group = data if isinstance(data, Group) else zarr.open(self._path) diff --git a/tests/test_readers_writers.py b/tests/test_readers_writers.py index 544a5aee..d655dedc 100644 --- a/tests/test_readers_writers.py +++ b/tests/test_readers_writers.py @@ -13,7 +13,6 @@ from micromanager_gui.readers._tensorstore_zarr_reader import TensorstoreZarrReader if TYPE_CHECKING: - from pymmcore_plus import CMMCorePlus from pytestqt.qtbot import QtBot From f3926f63c54abf170667e083ace036566063048f Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 4 Nov 2024 17:24:16 -0500 Subject: [PATCH 143/226] fix --- src/micromanager_gui/readers/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/micromanager_gui/readers/__init__.py b/src/micromanager_gui/readers/__init__.py index 0deccb38..4b06d3e5 100644 --- a/src/micromanager_gui/readers/__init__.py +++ b/src/micromanager_gui/readers/__init__.py @@ -1,3 +1,5 @@ +"""Readers for different file formats.""" + from ._ome_zarr_reader import OMEZarrReader from ._tensorstore_zarr_reader import TensorstoreZarrReader From 0c412cceb7d189d204d3c7e337e68f14d53d5db1 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 4 Nov 2024 17:56:46 -0500 Subject: [PATCH 144/226] feat: add console --- pyproject.toml | 3 +- src/micromanager_gui/_menubar/_menubar.py | 21 +++++++++++ src/micromanager_gui/_widgets/_console.py | 45 +++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 src/micromanager_gui/_widgets/_console.py diff --git a/pyproject.toml b/pyproject.toml index 59fa36e3..4a60d2cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,8 @@ dependencies = [ "pyyaml", "cmap", "pyconify", - "ndv" + "ndv", + "qtconsole", ] # extras diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index 8c9bcb63..8e466745 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -23,6 +23,7 @@ QWidget, ) +from micromanager_gui._widgets._console import MMConsole from micromanager_gui._widgets._install_widget import _InstallWidget from micromanager_gui._widgets._mda_widget import MDAWidget from micromanager_gui._widgets._stage_control import StagesControlWidget @@ -97,6 +98,7 @@ def __init__( # widgets self._wizard: ConfigWizard | None = None # is in a different menu self._mda: MDAWidget | None = None + self._console: MMConsole | None = None # configurations_menu self._configurations_menu = self.addMenu("System Configurations") @@ -127,6 +129,11 @@ def __init__( self._act_close_all_but_current.triggered.connect(self._close_all_but_current) self._viewer_menu.addAction(self._act_close_all_but_current) + # add console action to widgets menu + self._act_console = QAction("Console", self) + self._act_console.triggered.connect(self._launch_console) + self._widgets_menu.addAction(self._act_console) + # create actions from WIDGETS and DOCKWIDGETS keys = {*WIDGETS.keys(), *DOCKWIDGETS.keys()} for action_name in sorted(keys): @@ -175,6 +182,20 @@ def _show_config_wizard(self) -> None: self._wizard.setField(SRC_CONFIG, current_cfg) self._wizard.show() + def _launch_console(self) -> None: + """Launch the console.""" + if self._console is None: + # All values in the dictionary below can be accessed from the console using + # the associated string key + user_vars = { + "mmc": self._mmc, # CMMCorePlus instance + "wdgs": self._widgets, # dictionary of all the widgets + "mda": self._mda, # quick access to the MDA widget + } + self._console = MMConsole(user_vars) + self._console.show() + self._console.raise_() + def _close_all(self, skip: bool | list[int] | None = None) -> None: """Close all viewers.""" # the QAction sends a bool when triggered. We don't want to handle a bool diff --git a/src/micromanager_gui/_widgets/_console.py b/src/micromanager_gui/_widgets/_console.py new file mode 100644 index 00000000..cb2a9e3a --- /dev/null +++ b/src/micromanager_gui/_widgets/_console.py @@ -0,0 +1,45 @@ +from qtconsole.inprocess import QtInProcessKernelManager +from qtconsole.rich_jupyter_widget import RichJupyterWidget +from qtpy.QtGui import QCloseEvent + + +class MMConsole(RichJupyterWidget): + """A Qt widget for an IPython console, providing access to UI components. + + Copied from gselzer: https://github.com/gselzer/pymmcore-plus-sandbox/blob/53ac7e8ca3b4874816583b8b74024a75432b8fc9/src/pymmcore_plus_sandbox/_console_widget.py#L5 + """ + + def __init__(self, user_variables: dict | None = None) -> None: + if user_variables is None: + user_variables = {} + super().__init__() + self.setWindowTitle("micromanager-gui console") + + # this makes calling `setFocus()` on a QtConsole give keyboard focus to + # the underlying `QTextEdit` widget + self.setFocusProxy(self._control) + + # Create an in-process kernel + self.kernel_manager = QtInProcessKernelManager() + self.kernel_manager.start_kernel(show_banner=False) + self.kernel_manager.kernel.gui = "qt" + self.kernel_client = self.kernel_manager.client() + self.kernel_client.start_channels() + self.shell = self.kernel_manager.kernel.shell + self.push = self.shell.push + + # Add any user variables + self.push(user_variables) + + def closeEvent(self, event: QCloseEvent) -> None: + """Clean up the integrated console in napari.""" + if self.kernel_client is not None: + self.kernel_client.stop_channels() + if self.kernel_manager is not None and self.kernel_manager.has_kernel: + self.kernel_manager.shutdown_kernel() + + # RichJupyterWidget doesn't clean these up + self._completion_widget.deleteLater() + self._call_tip_widget.deleteLater() + self.deleteLater() + event.accept() From c1e2e41dfdf7cf3dfec774796a6c08275bce9c57 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 4 Nov 2024 19:19:23 -0500 Subject: [PATCH 145/226] wip, try both qtconsole and pyqtconsole --- pyproject.toml | 3 +- src/micromanager_gui/_menubar/_menubar.py | 29 ++++++++++- src/micromanager_gui/_widgets/_console.py | 61 ++++++++++++++++++++++- 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4a60d2cc..b550bf89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,8 @@ dependencies = [ "cmap", "pyconify", "ndv", - "qtconsole", + "qtconsole", # NOTE: remove if deciding to use pyqtconsole + "pyqtconsole", # NOTE: remove if deciding to use qtconsole ] # extras diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index 8e466745..a90beadf 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -23,7 +23,7 @@ QWidget, ) -from micromanager_gui._widgets._console import MMConsole +from micromanager_gui._widgets._console import MMConsole, MMConsoleJupyter from micromanager_gui._widgets._install_widget import _InstallWidget from micromanager_gui._widgets._mda_widget import MDAWidget from micromanager_gui._widgets._stage_control import StagesControlWidget @@ -98,7 +98,10 @@ def __init__( # widgets self._wizard: ConfigWizard | None = None # is in a different menu self._mda: MDAWidget | None = None + + # TODO: remove one or the other self._console: MMConsole | None = None + self._console_jupyter: MMConsoleJupyter | None = None # configurations_menu self._configurations_menu = self.addMenu("System Configurations") @@ -130,9 +133,13 @@ def __init__( self._viewer_menu.addAction(self._act_close_all_but_current) # add console action to widgets menu + # TODO: remove one or the other self._act_console = QAction("Console", self) self._act_console.triggered.connect(self._launch_console) self._widgets_menu.addAction(self._act_console) + self._act_console_jupyter = QAction("Console JupYter", self) + self._act_console_jupyter.triggered.connect(self._launch_console_jupyter) + self._widgets_menu.addAction(self._act_console_jupyter) # create actions from WIDGETS and DOCKWIDGETS keys = {*WIDGETS.keys(), *DOCKWIDGETS.keys()} @@ -182,6 +189,8 @@ def _show_config_wizard(self) -> None: self._wizard.setField(SRC_CONFIG, current_cfg) self._wizard.show() + # TODO START-------------------------------------------------------------- + # ***REMOVE ONE OF THE TWO CONSOLE METHODS DEPENDING ON THE CONSOLE WE KEEP*** def _launch_console(self) -> None: """Launch the console.""" if self._console is None: @@ -192,10 +201,26 @@ def _launch_console(self) -> None: "wdgs": self._widgets, # dictionary of all the widgets "mda": self._mda, # quick access to the MDA widget } - self._console = MMConsole(user_vars) + self._console = MMConsole(self, user_vars) self._console.show() self._console.raise_() + def _launch_console_jupyter(self) -> None: + """Launch the console.""" + if self._console_jupyter is None: + # All values in the dictionary below can be accessed from the console using + # the associated string key + user_vars = { + "mmc": self._mmc, # CMMCorePlus instance + "wdgs": self._widgets, # dictionary of all the widgets + "mda": self._mda, # quick access to the MDA widget + } + self._console_jupyter = MMConsoleJupyter(self, user_vars) + self._console_jupyter.show() + self._console_jupyter.raise_() + + # TODO END------------------------------------------------------------------- + def _close_all(self, skip: bool | list[int] | None = None) -> None: """Close all viewers.""" # the QAction sends a bool when triggered. We don't want to handle a bool diff --git a/src/micromanager_gui/_widgets/_console.py b/src/micromanager_gui/_widgets/_console.py index cb2a9e3a..8bd43013 100644 --- a/src/micromanager_gui/_widgets/_console.py +++ b/src/micromanager_gui/_widgets/_console.py @@ -1,9 +1,67 @@ +from typing import Any + +import pyqtconsole.highlighter as hl +from pyqtconsole.console import PythonConsole from qtconsole.inprocess import QtInProcessKernelManager from qtconsole.rich_jupyter_widget import RichJupyterWidget from qtpy.QtGui import QCloseEvent +from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget + +# override the default formats to change blue and red colors +FORMATS = { + "keyword": hl.format("green", "bold"), + "operator": hl.format("magenta"), + "inprompt": hl.format("green", "bold"), + "outprompt": hl.format("magenta", "bold"), +} + + +class MMConsole(QDialog): + """A Qt widget for an IPython console, providing access to UI components.""" + + def __init__( + self, parent: QWidget | None = None, user_variables: dict | None = None + ) -> None: + super().__init__(parent=parent) + self.setWindowTitle("micromanager-gui console") + + self._console = PythonConsole(parent=self, formats=FORMATS) + self._console.eval_in_thread() + + layput = QVBoxLayout(self) + layput.addWidget(self._console) + + # Add user variables if provided + if user_variables is not None: + self.push(user_variables) + + def push(self, user_variables: dict[str, Any]) -> None: + """Push a dictionary of variables to the console. + This is an alternative to using the native `push_local_ns` method. + """ + for key, value in user_variables.items(): + self._console.push_local_ns(key, value) -class MMConsole(RichJupyterWidget): + +class MMConsoleJupyter(QDialog): + """A Qt widget for an IPython console, providing access to UI components.""" + + def __init__( + self, parent: QWidget | None = None, user_variables: dict | None = None + ) -> None: + super().__init__(parent=parent) + self.setWindowTitle("micromanager-gui console") + + self._console = _JupyterConsole(user_variables=user_variables) + + layput = QVBoxLayout(self) + layput.addWidget(self._console) + + self._console.push(user_variables) + + +class _JupyterConsole(RichJupyterWidget): """A Qt widget for an IPython console, providing access to UI components. Copied from gselzer: https://github.com/gselzer/pymmcore-plus-sandbox/blob/53ac7e8ca3b4874816583b8b74024a75432b8fc9/src/pymmcore_plus_sandbox/_console_widget.py#L5 @@ -13,7 +71,6 @@ def __init__(self, user_variables: dict | None = None) -> None: if user_variables is None: user_variables = {} super().__init__() - self.setWindowTitle("micromanager-gui console") # this makes calling `setFocus()` on a QtConsole give keyboard focus to # the underlying `QTextEdit` widget From 7e9169aec0d3d2462da8d64600fc1ff586a52dc5 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 4 Nov 2024 19:22:11 -0500 Subject: [PATCH 146/226] fix: console --- src/micromanager_gui/_menubar/_menubar.py | 8 +++++--- src/micromanager_gui/_widgets/_console.py | 7 ++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index a90beadf..5c3f39a8 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -99,9 +99,10 @@ def __init__( self._wizard: ConfigWizard | None = None # is in a different menu self._mda: MDAWidget | None = None - # TODO: remove one or the other + # TODO: remove one or the other ------------------------------------ self._console: MMConsole | None = None self._console_jupyter: MMConsoleJupyter | None = None + # ------------------------------------------------------------------- # configurations_menu self._configurations_menu = self.addMenu("System Configurations") @@ -132,14 +133,15 @@ def __init__( self._act_close_all_but_current.triggered.connect(self._close_all_but_current) self._viewer_menu.addAction(self._act_close_all_but_current) + # TODO: remove one or the other ------------------------------------------- # add console action to widgets menu - # TODO: remove one or the other self._act_console = QAction("Console", self) self._act_console.triggered.connect(self._launch_console) self._widgets_menu.addAction(self._act_console) - self._act_console_jupyter = QAction("Console JupYter", self) + self._act_console_jupyter = QAction("Console Jupyter", self) self._act_console_jupyter.triggered.connect(self._launch_console_jupyter) self._widgets_menu.addAction(self._act_console_jupyter) + # -------------------------------------------------------------------------- # create actions from WIDGETS and DOCKWIDGETS keys = {*WIDGETS.keys(), *DOCKWIDGETS.keys()} diff --git a/src/micromanager_gui/_widgets/_console.py b/src/micromanager_gui/_widgets/_console.py index 8bd43013..bc032a58 100644 --- a/src/micromanager_gui/_widgets/_console.py +++ b/src/micromanager_gui/_widgets/_console.py @@ -32,14 +32,15 @@ def __init__( layput.addWidget(self._console) # Add user variables if provided - if user_variables is not None: - self.push(user_variables) + self.push(user_variables) - def push(self, user_variables: dict[str, Any]) -> None: + def push(self, user_variables: dict[str, Any] | None) -> None: """Push a dictionary of variables to the console. This is an alternative to using the native `push_local_ns` method. """ + if user_variables is None: + return for key, value in user_variables.items(): self._console.push_local_ns(key, value) From 3b1561f3d20ea804e5c8a4a248bd432bb89a74fe Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 5 Nov 2024 08:40:46 -0500 Subject: [PATCH 147/226] fix: update console --- src/micromanager_gui/_menubar/_menubar.py | 38 +++++++++++------------ src/micromanager_gui/_widgets/_console.py | 31 ++++++------------ 2 files changed, 28 insertions(+), 41 deletions(-) diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index 5c3f39a8..1b25c098 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -23,7 +23,7 @@ QWidget, ) -from micromanager_gui._widgets._console import MMConsole, MMConsoleJupyter +from micromanager_gui._widgets._console import MMConsole, MMPyQtConsole from micromanager_gui._widgets._install_widget import _InstallWidget from micromanager_gui._widgets._mda_widget import MDAWidget from micromanager_gui._widgets._stage_control import StagesControlWidget @@ -100,8 +100,8 @@ def __init__( self._mda: MDAWidget | None = None # TODO: remove one or the other ------------------------------------ - self._console: MMConsole | None = None - self._console_jupyter: MMConsoleJupyter | None = None + self._pyqt_console: MMPyQtConsole | None = None + self._mm_console: MMConsole | None = None # ------------------------------------------------------------------- # configurations_menu @@ -135,12 +135,12 @@ def __init__( # TODO: remove one or the other ------------------------------------------- # add console action to widgets menu - self._act_console = QAction("Console", self) - self._act_console.triggered.connect(self._launch_console) - self._widgets_menu.addAction(self._act_console) - self._act_console_jupyter = QAction("Console Jupyter", self) - self._act_console_jupyter.triggered.connect(self._launch_console_jupyter) - self._widgets_menu.addAction(self._act_console_jupyter) + self._act_pyqt_console = QAction("Console PyQt", self) + self._act_pyqt_console.triggered.connect(self._launch_pyqt_console) + self._widgets_menu.addAction(self._act_pyqt_console) + self._act_mm_console = QAction("Console", self) + self._act_mm_console.triggered.connect(self._launch_mm_console) + self._widgets_menu.addAction(self._act_mm_console) # -------------------------------------------------------------------------- # create actions from WIDGETS and DOCKWIDGETS @@ -193,9 +193,9 @@ def _show_config_wizard(self) -> None: # TODO START-------------------------------------------------------------- # ***REMOVE ONE OF THE TWO CONSOLE METHODS DEPENDING ON THE CONSOLE WE KEEP*** - def _launch_console(self) -> None: + def _launch_pyqt_console(self) -> None: """Launch the console.""" - if self._console is None: + if self._pyqt_console is None: # All values in the dictionary below can be accessed from the console using # the associated string key user_vars = { @@ -203,13 +203,13 @@ def _launch_console(self) -> None: "wdgs": self._widgets, # dictionary of all the widgets "mda": self._mda, # quick access to the MDA widget } - self._console = MMConsole(self, user_vars) - self._console.show() - self._console.raise_() + self._pyqt_console = MMPyQtConsole(self, user_vars) + self._pyqt_console.show() + self._pyqt_console.raise_() - def _launch_console_jupyter(self) -> None: + def _launch_mm_console(self) -> None: """Launch the console.""" - if self._console_jupyter is None: + if self._mm_console is None: # All values in the dictionary below can be accessed from the console using # the associated string key user_vars = { @@ -217,9 +217,9 @@ def _launch_console_jupyter(self) -> None: "wdgs": self._widgets, # dictionary of all the widgets "mda": self._mda, # quick access to the MDA widget } - self._console_jupyter = MMConsoleJupyter(self, user_vars) - self._console_jupyter.show() - self._console_jupyter.raise_() + self._mm_console = MMConsole(user_vars) + self._mm_console.show() + self._mm_console.raise_() # TODO END------------------------------------------------------------------- diff --git a/src/micromanager_gui/_widgets/_console.py b/src/micromanager_gui/_widgets/_console.py index bc032a58..b3f73919 100644 --- a/src/micromanager_gui/_widgets/_console.py +++ b/src/micromanager_gui/_widgets/_console.py @@ -16,7 +16,7 @@ } -class MMConsole(QDialog): +class MMPyQtConsole(QDialog): """A Qt widget for an IPython console, providing access to UI components.""" def __init__( @@ -28,8 +28,8 @@ def __init__( self._console = PythonConsole(parent=self, formats=FORMATS) self._console.eval_in_thread() - layput = QVBoxLayout(self) - layput.addWidget(self._console) + layout = QVBoxLayout(self) + layout.addWidget(self._console) # Add user variables if provided self.push(user_variables) @@ -45,27 +45,11 @@ def push(self, user_variables: dict[str, Any] | None) -> None: self._console.push_local_ns(key, value) -class MMConsoleJupyter(QDialog): - """A Qt widget for an IPython console, providing access to UI components.""" - - def __init__( - self, parent: QWidget | None = None, user_variables: dict | None = None - ) -> None: - super().__init__(parent=parent) - self.setWindowTitle("micromanager-gui console") - - self._console = _JupyterConsole(user_variables=user_variables) - - layput = QVBoxLayout(self) - layput.addWidget(self._console) - - self._console.push(user_variables) - - -class _JupyterConsole(RichJupyterWidget): +class MMConsole(RichJupyterWidget): """A Qt widget for an IPython console, providing access to UI components. - Copied from gselzer: https://github.com/gselzer/pymmcore-plus-sandbox/blob/53ac7e8ca3b4874816583b8b74024a75432b8fc9/src/pymmcore_plus_sandbox/_console_widget.py#L5 + Copied from pymmcore-plus-sandbox by gselzer: + https://github.com/gselzer/pymmcore-plus-sandbox/blob/53ac7e8ca3b4874816583b8b74024a75432b8fc9/src/pymmcore_plus_sandbox/_console_widget.py#L5 """ def __init__(self, user_variables: dict | None = None) -> None: @@ -73,6 +57,9 @@ def __init__(self, user_variables: dict | None = None) -> None: user_variables = {} super().__init__() + self.setWindowTitle("micromanager-gui console") + self.set_default_style(colors="linux") + # this makes calling `setFocus()` on a QtConsole give keyboard focus to # the underlying `QTextEdit` widget self.setFocusProxy(self._control) From cfc0e9cdf130af203cce0f118645b14a5195e838 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 5 Nov 2024 09:14:50 -0500 Subject: [PATCH 148/226] test: fix --- pyproject.toml | 17 ----------------- tests/conftest.py | 10 ++++++++++ 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b550bf89..a9543bce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,11 +69,6 @@ repository = "https://github.com/fdrgsp/micromanager-gui" [project.scripts] mmgui = "micromanager_gui.__main__:main" -# Entry points -# https://peps.python.org/pep-0621/#entry-points -# [project.entry-points."spam.magical"] -# tomatoes = "spam:main_tomatoes" - # https://hatch.pypa.io/latest/config/metadata/ [tool.hatch.version] source = "vcs" @@ -172,17 +167,5 @@ ignore = [ "tests/**/*", ] -# # for things that require compilation -# # https://cibuildwheel.readthedocs.io/en/stable/options/ -# [tool.cibuildwheel] -# # Skip 32-bit builds & PyPy wheels on all platforms -# skip = ["*-manylinux_i686", "*-musllinux_i686", "*-win32", "pp*"] -# test-extras = ["test"] -# test-command = "pytest {project}/tests -v" -# test-skip = "*-musllinux*" - -# [tool.cibuildwheel.environment] -# HATCH_BUILD_HOOKS_ENABLE = "1" - [tool.typos.default] extend-ignore-identifiers-re = ["(?i)nd2?.*", "(?i)ome"] \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 153beea9..2a67c8dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,15 @@ from __future__ import annotations +import os + +# This is a temporary fix due to a `DeprecationWarning` from the `qtconsole` package: +# """DeprecationWarning: Jupyter is migrating its paths to use standard platformdirs +# given by the platformdirs library. To remove this warning and +# see the appropriate new directories, set the environment variable +# `JUPYTER_PLATFORM_DIRS=1` and then run `jupyter --paths`. +# The use of platformdirs will be the default in `jupyter_core` v6""" +os.environ["JUPYTER_PLATFORM_DIRS"] = "1" + from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import patch From c556202c623bd61aadcab5d53c28ea797adcf131 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 5 Nov 2024 10:23:10 -0500 Subject: [PATCH 149/226] feat: update console + console variables --- pyproject.toml | 3 +- src/micromanager_gui/_core_link.py | 26 +++++++++- src/micromanager_gui/_menubar/_menubar.py | 48 +++++++------------ .../_widgets/{_console.py => _mm_console.py} | 46 ++---------------- 4 files changed, 47 insertions(+), 76 deletions(-) rename src/micromanager_gui/_widgets/{_console.py => _mm_console.py} (58%) diff --git a/pyproject.toml b/pyproject.toml index a9543bce..a9356817 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,8 +37,7 @@ dependencies = [ "cmap", "pyconify", "ndv", - "qtconsole", # NOTE: remove if deciding to use pyqtconsole - "pyqtconsole", # NOTE: remove if deciding to use qtconsole + "qtconsole", ] # extras diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index a0c4c618..2f3b50f1 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -9,7 +9,7 @@ from micromanager_gui._widgets._viewers import MDAViewer -# from ._widgets._preview import Preview +from ._menubar._menubar import PREVIEW, VIEWERS from ._widgets._viewers import Preview DIALOG = Qt.WindowType.Dialog @@ -23,6 +23,7 @@ from ._main_window import MicroManagerGUI from ._widgets._mda_widget import MDAWidget + from ._widgets._mm_console import MMConsole class CoreViewersLink(QObject): @@ -112,6 +113,18 @@ def _setup_viewer(self, sequence: useq.MDASequence, meta: SummaryMetaV1) -> None # connect the signals self._connect_viewer(self._current_viewer) + # update the viewers variable in the console with the new viewer + return self._update_viewers_in_mm_console(viewer_name, self._current_viewer) + + def _update_viewers_in_mm_console( + self, viewer_name: str, mda_viewer: MDAViewer + ) -> None: + """Update the viewers variable in the MMConsole.""" + if console := self._get_mm_console(): + if VIEWERS not in console.get_user_variables(): + return + console.shell.user_ns[VIEWERS].update({viewer_name: mda_viewer}) + def _get_viewer_name(self, viewer_name: str | None) -> str: """Get the viewer name from the metadata. @@ -173,3 +186,14 @@ def _set_preview_tab(self) -> None: if self._mda_running: return self._viewer_tab.setCurrentWidget(self._preview) + + # add the preview to the console if it is not there already + if console := self._get_mm_console(): + if PREVIEW not in console.get_user_variables(): + return + if console.shell.user_ns[PREVIEW] is None: + console.shell.user_ns[PREVIEW] = self._preview + + def _get_mm_console(self) -> MMConsole | None: + """Rertun the MMConsole if it exists.""" + return self._main_window._menu_bar._mm_console diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index 1b25c098..b3ca65a0 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -23,9 +23,9 @@ QWidget, ) -from micromanager_gui._widgets._console import MMConsole, MMPyQtConsole from micromanager_gui._widgets._install_widget import _InstallWidget from micromanager_gui._widgets._mda_widget import MDAWidget +from micromanager_gui._widgets._mm_console import MMConsole from micromanager_gui._widgets._stage_control import StagesControlWidget if TYPE_CHECKING: @@ -46,6 +46,12 @@ RIGHT = Qt.DockWidgetArea.RightDockWidgetArea LEFT = Qt.DockWidgetArea.LeftDockWidgetArea +MMC = "mmc" +MDA = "mda" +WDGS = "wdgs" +VIEWERS = "viewers" +PREVIEW = "preview" + class ScrollableDockWidget(QDockWidget): """A QDockWidget with a QScrollArea.""" @@ -98,11 +104,7 @@ def __init__( # widgets self._wizard: ConfigWizard | None = None # is in a different menu self._mda: MDAWidget | None = None - - # TODO: remove one or the other ------------------------------------ - self._pyqt_console: MMPyQtConsole | None = None self._mm_console: MMConsole | None = None - # ------------------------------------------------------------------- # configurations_menu self._configurations_menu = self.addMenu("System Configurations") @@ -133,15 +135,10 @@ def __init__( self._act_close_all_but_current.triggered.connect(self._close_all_but_current) self._viewer_menu.addAction(self._act_close_all_but_current) - # TODO: remove one or the other ------------------------------------------- # add console action to widgets menu - self._act_pyqt_console = QAction("Console PyQt", self) - self._act_pyqt_console.triggered.connect(self._launch_pyqt_console) - self._widgets_menu.addAction(self._act_pyqt_console) self._act_mm_console = QAction("Console", self) self._act_mm_console.triggered.connect(self._launch_mm_console) self._widgets_menu.addAction(self._act_mm_console) - # -------------------------------------------------------------------------- # create actions from WIDGETS and DOCKWIDGETS keys = {*WIDGETS.keys(), *DOCKWIDGETS.keys()} @@ -191,38 +188,22 @@ def _show_config_wizard(self) -> None: self._wizard.setField(SRC_CONFIG, current_cfg) self._wizard.show() - # TODO START-------------------------------------------------------------- - # ***REMOVE ONE OF THE TWO CONSOLE METHODS DEPENDING ON THE CONSOLE WE KEEP*** - def _launch_pyqt_console(self) -> None: - """Launch the console.""" - if self._pyqt_console is None: - # All values in the dictionary below can be accessed from the console using - # the associated string key - user_vars = { - "mmc": self._mmc, # CMMCorePlus instance - "wdgs": self._widgets, # dictionary of all the widgets - "mda": self._mda, # quick access to the MDA widget - } - self._pyqt_console = MMPyQtConsole(self, user_vars) - self._pyqt_console.show() - self._pyqt_console.raise_() - def _launch_mm_console(self) -> None: """Launch the console.""" if self._mm_console is None: # All values in the dictionary below can be accessed from the console using # the associated string key user_vars = { - "mmc": self._mmc, # CMMCorePlus instance - "wdgs": self._widgets, # dictionary of all the widgets - "mda": self._mda, # quick access to the MDA widget + MMC: self._mmc, # CMMCorePlus instance + WDGS: self._widgets, # dictionary of all the widgets + MDA: self._mda, # quick access to the MDA widget + VIEWERS: {}, # dictionary of all the viewers, empty for now + PREVIEW: None, # quick access to the preview widget } self._mm_console = MMConsole(user_vars) self._mm_console.show() self._mm_console.raise_() - # TODO END------------------------------------------------------------------- - def _close_all(self, skip: bool | list[int] | None = None) -> None: """Close all viewers.""" # the QAction sends a bool when triggered. We don't want to handle a bool @@ -233,10 +214,15 @@ def _close_all(self, skip: bool | list[int] | None = None) -> None: for index in reversed(range(viewer_tab.count())): if index in skip or index == 0: # 0 to skip the prewiew tab continue + tab_name = viewer_tab.tabText(index) widget = viewer_tab.widget(index) viewer_tab.removeTab(index) widget.deleteLater() + # update the viewers variable in the console + if self._mm_console is not None: + self._mm_console.shell.user_ns["viewers"].pop(tab_name, None) + def _close_all_but_current(self) -> None: """Close all viewers except the current one.""" # build the list of indexes to skip diff --git a/src/micromanager_gui/_widgets/_console.py b/src/micromanager_gui/_widgets/_mm_console.py similarity index 58% rename from src/micromanager_gui/_widgets/_console.py rename to src/micromanager_gui/_widgets/_mm_console.py index b3f73919..38f3bba4 100644 --- a/src/micromanager_gui/_widgets/_console.py +++ b/src/micromanager_gui/_widgets/_mm_console.py @@ -1,48 +1,6 @@ -from typing import Any - -import pyqtconsole.highlighter as hl -from pyqtconsole.console import PythonConsole from qtconsole.inprocess import QtInProcessKernelManager from qtconsole.rich_jupyter_widget import RichJupyterWidget from qtpy.QtGui import QCloseEvent -from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget - -# override the default formats to change blue and red colors -FORMATS = { - "keyword": hl.format("green", "bold"), - "operator": hl.format("magenta"), - "inprompt": hl.format("green", "bold"), - "outprompt": hl.format("magenta", "bold"), -} - - -class MMPyQtConsole(QDialog): - """A Qt widget for an IPython console, providing access to UI components.""" - - def __init__( - self, parent: QWidget | None = None, user_variables: dict | None = None - ) -> None: - super().__init__(parent=parent) - self.setWindowTitle("micromanager-gui console") - - self._console = PythonConsole(parent=self, formats=FORMATS) - self._console.eval_in_thread() - - layout = QVBoxLayout(self) - layout.addWidget(self._console) - - # Add user variables if provided - self.push(user_variables) - - def push(self, user_variables: dict[str, Any] | None) -> None: - """Push a dictionary of variables to the console. - - This is an alternative to using the native `push_local_ns` method. - """ - if user_variables is None: - return - for key, value in user_variables.items(): - self._console.push_local_ns(key, value) class MMConsole(RichJupyterWidget): @@ -76,6 +34,10 @@ def __init__(self, user_variables: dict | None = None) -> None: # Add any user variables self.push(user_variables) + def get_user_variables(self) -> dict: + """Return the variables pushed to the console.""" + return {k: v for k, v in self.shell.user_ns.items() if k != "__builtins__"} + def closeEvent(self, event: QCloseEvent) -> None: """Clean up the integrated console in napari.""" if self.kernel_client is not None: From 46ee85aff0594f67526a2f51632c1579dec48b25 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 5 Nov 2024 10:42:00 -0500 Subject: [PATCH 150/226] feat: MDAWidget reader method --- .../_viewers/_mda_viewer/_data_wrappers.py | 18 +++++++++--------- .../_viewers/_mda_viewer/_mda_viewer.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py index f3ca201f..e4e07556 100644 --- a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py @@ -28,14 +28,14 @@ def supports(cls, obj: Any) -> bool: def sizes(self) -> Mapping[str, int]: with suppress(Exception): - return self._data.current_sequence.sizes + return self.data.current_sequence.sizes # type: ignore return {} def guess_channel_axis(self) -> Hashable | None: return "c" def isel(self, indexers: Mapping[str, int]) -> Any: - return self._data.isel(indexers) + return self.data.isel(indexers) def save_as_zarr(self, save_loc: str | Path) -> None: # to have access to the metadata, the generated zarr file should be opened with @@ -45,7 +45,7 @@ def save_as_zarr(self, save_loc: str | Path) -> None: import tensorstore as ts - if (store := self._data.store) is None: + if (store := self.data.store) is None: return new_spec = store.spec().to_json() new_spec["kvstore"] = {"driver": "file", "path": str(save_loc)} @@ -55,7 +55,7 @@ def save_as_zarr(self, save_loc: str | Path) -> None: new_ts.kvstore.write(".zattrs", meta_json).result() def save_as_tiff(self, save_loc: str | Path) -> None: - if (store := self._data.store) is None: + if (store := self.data.store) is None: return reader = TensorstoreZarrReader(store) reader.write_tiff(save_loc) @@ -85,18 +85,18 @@ def guess_channel_axis(self) -> Hashable | None: return "c" def isel(self, indexers: Mapping[str, int]) -> Any: - return self._data.isel(indexers) + return self.data.isel(indexers) def save_as_zarr(self, save_loc: str | Path) -> None: # TODO: implement logic for OMETiffWriter - if isinstance(self._data, OMEZarrWriter): + if isinstance(self.data, OMEZarrWriter): import zarr # save a copy of the ome-zarr file new_store = zarr.DirectoryStore(str(save_loc)) new_group = zarr.group(store=new_store, overwrite=True) # the group property returns a zarr.hierarchy.Group object - zarr.copy_all(self._data.group, new_group) + zarr.copy_all(self.data.group, new_group) else: # OMETiffWriter raise NotImplementedError( "Saving as Zarr is not yet implemented for OMETiffWriter." @@ -104,9 +104,9 @@ def save_as_zarr(self, save_loc: str | Path) -> None: def save_as_tiff(self, save_loc: str | Path) -> None: # TODO: implement logic for OMETiffWriter - if isinstance(self._data, OMEZarrWriter): + if isinstance(self.data, OMEZarrWriter): # the group property returns a zarr.hierarchy.Group object - reader = OMEZarrReader(self._data.group) + reader = OMEZarrReader(self.data.group) reader.write_tiff(save_loc) else: # OMETiffWriter raise NotImplementedError( diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py index 0fc794d6..f789393f 100644 --- a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py @@ -8,6 +8,9 @@ from superqt import ensure_main_thread from useq import MDAEvent +from micromanager_gui.readers import OMEZarrReader, TensorstoreZarrReader + +from ._data_wrappers import MM5DWriterWrapper, MMTensorstoreWrapper from ._mda_save_button import MDASaveButton if TYPE_CHECKING: @@ -54,6 +57,17 @@ def __init__( self.dims_sliders.set_locks_visible(True) + def reader(self) -> Any: + """Return the reader for the data or the data if no reader is available.""" + if isinstance(self._data_wrapper, MMTensorstoreWrapper): + return TensorstoreZarrReader(self.data.store) + elif isinstance(self._data_wrapper, MM5DWriterWrapper): + if isinstance(self._data_wrapper.data, OMEZarrWriter): + return OMEZarrReader(self.data.group) + # TODO: implement logic for OMETiffWriter + else: + return self.data + def _patched_frame_ready(self, *args: Any) -> None: self._superframeReady(*args) # type: ignore if len(args) >= 2 and isinstance(e := args[1], MDAEvent): From 8f5c31b71c4dcc4d72cdda770f0328e0847778d4 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 5 Nov 2024 17:16:24 -0500 Subject: [PATCH 151/226] fix: remove viewer form console when closed --- src/micromanager_gui/_core_link.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index 2f3b50f1..5218dfee 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -63,6 +63,10 @@ def __init__(self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None self._mmc.mda.events.sequenceFinished.connect(self._on_sequence_finished) self._mmc.mda.events.sequencePauseToggled.connect(self._enable_gui) + self._viewer_tab.tabCloseRequested.connect( + self._remove_closed_mda_viewer_from_console + ) + def _close_tab(self, index: int) -> None: """Close the tab at the given index.""" if index == 0: @@ -197,3 +201,13 @@ def _set_preview_tab(self) -> None: def _get_mm_console(self) -> MMConsole | None: """Rertun the MMConsole if it exists.""" return self._main_window._menu_bar._mm_console + + def _remove_closed_mda_viewer_from_console(self, index: int) -> None: + if index == 0: # preview tab + return + if console := self._get_mm_console(): + if VIEWERS not in console.get_user_variables(): + return + # remove the item at pos index from the viewers variable in the console + viewer_name = list(console.shell.user_ns[VIEWERS].keys())[index - 1] + console.shell.user_ns[VIEWERS].pop(viewer_name, None) From ee1203156c0ae4cc4d6856f480c7079cf0d73290 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 5 Nov 2024 17:37:50 -0500 Subject: [PATCH 152/226] fix: console as dockwdg --- src/micromanager_gui/_menubar/_menubar.py | 52 +++++++++++++---------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index b3ca65a0..373d0978 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -32,6 +32,7 @@ from micromanager_gui._main_window import MicroManagerGUI FLAGS = Qt.WindowType.Dialog +CONSOLE = "Console" WIDGETS = { "Property Browser": PropertyBrowser, "Pixel Configuration": PixelConfigurationWidget, @@ -42,9 +43,11 @@ "Groups and Presets": GroupPresetTableWidget, "Stage Control": StagesControlWidget, "Camera ROI": CameraRoiWidget, + CONSOLE: MMConsole, } RIGHT = Qt.DockWidgetArea.RightDockWidgetArea LEFT = Qt.DockWidgetArea.LeftDockWidgetArea +BOTTOM = Qt.DockWidgetArea.BottomDockWidgetArea MMC = "mmc" MDA = "mda" @@ -135,11 +138,6 @@ def __init__( self._act_close_all_but_current.triggered.connect(self._close_all_but_current) self._viewer_menu.addAction(self._act_close_all_but_current) - # add console action to widgets menu - self._act_mm_console = QAction("Console", self) - self._act_mm_console.triggered.connect(self._launch_mm_console) - self._widgets_menu.addAction(self._act_mm_console) - # create actions from WIDGETS and DOCKWIDGETS keys = {*WIDGETS.keys(), *DOCKWIDGETS.keys()} for action_name in sorted(keys): @@ -188,22 +186,6 @@ def _show_config_wizard(self) -> None: self._wizard.setField(SRC_CONFIG, current_cfg) self._wizard.show() - def _launch_mm_console(self) -> None: - """Launch the console.""" - if self._mm_console is None: - # All values in the dictionary below can be accessed from the console using - # the associated string key - user_vars = { - MMC: self._mmc, # CMMCorePlus instance - WDGS: self._widgets, # dictionary of all the widgets - MDA: self._mda, # quick access to the MDA widget - VIEWERS: {}, # dictionary of all the viewers, empty for now - PREVIEW: None, # quick access to the preview widget - } - self._mm_console = MMConsole(user_vars) - self._mm_console.show() - self._mm_console.raise_() - def _close_all(self, skip: bool | list[int] | None = None) -> None: """Close all viewers.""" # the QAction sends a bool when triggered. We don't want to handle a bool @@ -252,7 +234,10 @@ def _show_widget(self) -> None: # create dock widget if action_name in DOCKWIDGETS: - self._create_dock_widget(action_name) + if action_name == "Console": + self._launch_mm_console() + else: + self._create_dock_widget(action_name) # create widget else: wdg = self._create_widget(action_name) @@ -278,3 +263,26 @@ def _create_widget(self, action_name: str) -> QWidget: wdg.setWindowFlags(FLAGS) self._widgets[action_name] = wdg return wdg + + def _launch_mm_console(self) -> None: + if self._mm_console is not None: + return + + # All values in the dictionary below can be accessed from the console using + # the associated string key + user_vars = { + MMC: self._mmc, # CMMCorePlus instance + WDGS: self._widgets, # dictionary of all the widgets + MDA: self._mda, # quick access to the MDA widget + VIEWERS: {}, # dictionary of all the viewers, empty for now + PREVIEW: None, # quick access to the preview widget + } + + self._mm_console = MMConsole(user_vars) + + dock = QDockWidget(CONSOLE, self) + dock.setAllowedAreas(LEFT | RIGHT | BOTTOM) + dock.setWidget(self._mm_console) + self._widgets[CONSOLE] = dock + + self._main_window.addDockWidget(RIGHT, dock) From 110478986f5238da7f98f06841a307db9315f9f7 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 5 Nov 2024 17:59:53 -0500 Subject: [PATCH 153/226] fix: console --- src/micromanager_gui/_core_link.py | 41 +++++++++-------------- src/micromanager_gui/_menubar/_menubar.py | 18 ++++++++-- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index 5218dfee..165fdd22 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -16,6 +16,7 @@ VIEWER_TEMP_DIR = None NO_R_BTN = (0, QTabBar.ButtonPosition.RightSide, None) NO_L_BTN = (0, QTabBar.ButtonPosition.LeftSide, None) +MDA_VIEWER = "MDA Viewer" if TYPE_CHECKING: import useq @@ -41,8 +42,8 @@ def __init__(self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None self._main_window._central_wdg_layout.addWidget(self._viewer_tab, 0, 0) # preview tab - self._preview = Preview(parent=self._main_window, mmcore=self._mmc) - self._viewer_tab.addTab(self._preview, "Preview") + self._preview: Preview = Preview(parent=self._main_window, mmcore=self._mmc) + self._viewer_tab.addTab(self._preview, PREVIEW.capitalize()) # remove the preview tab close button self._viewer_tab.tabBar().setTabButton(*NO_R_BTN) self._viewer_tab.tabBar().setTabButton(*NO_L_BTN) @@ -118,16 +119,7 @@ def _setup_viewer(self, sequence: useq.MDASequence, meta: SummaryMetaV1) -> None self._connect_viewer(self._current_viewer) # update the viewers variable in the console with the new viewer - return self._update_viewers_in_mm_console(viewer_name, self._current_viewer) - - def _update_viewers_in_mm_console( - self, viewer_name: str, mda_viewer: MDAViewer - ) -> None: - """Update the viewers variable in the MMConsole.""" - if console := self._get_mm_console(): - if VIEWERS not in console.get_user_variables(): - return - console.shell.user_ns[VIEWERS].update({viewer_name: mda_viewer}) + self._add_viewer_to_mm_console(viewer_name, self._current_viewer) def _get_viewer_name(self, viewer_name: str | None) -> str: """Get the viewer name from the metadata. @@ -142,11 +134,11 @@ def _get_viewer_name(self, viewer_name: str | None) -> str: index = 0 for v in range(self._viewer_tab.count()): tab_name = self._viewer_tab.tabText(v) - if tab_name.startswith("MDA Viewer"): - idx = tab_name.replace("MDA Viewer ", "") + if tab_name.startswith(MDA_VIEWER): + idx = tab_name.replace(f"{MDA_VIEWER} ", "") if idx.isdigit(): index = max(index, int(idx)) - return f"MDA Viewer {index + 1}" + return f"{MDA_VIEWER} {index + 1}" def _on_sequence_finished(self, sequence: useq.MDASequence) -> None: """Hide the MDAViewer when the MDA sequence finishes.""" @@ -182,26 +174,25 @@ def _enable_gui(self, state: bool) -> None: if self._current_viewer is None: return - # self._current_viewer._lut_drop.setEnabled(state) - self._current_viewer._channel_mode_btn.setEnabled(state) - def _set_preview_tab(self) -> None: """Set the preview tab.""" if self._mda_running: return self._viewer_tab.setCurrentWidget(self._preview) - # add the preview to the console if it is not there already - if console := self._get_mm_console(): - if PREVIEW not in console.get_user_variables(): - return - if console.shell.user_ns[PREVIEW] is None: - console.shell.user_ns[PREVIEW] = self._preview - def _get_mm_console(self) -> MMConsole | None: """Rertun the MMConsole if it exists.""" return self._main_window._menu_bar._mm_console + def _add_viewer_to_mm_console( + self, viewer_name: str, mda_viewer: MDAViewer + ) -> None: + """Update the viewers variable in the MMConsole.""" + if console := self._get_mm_console(): + if VIEWERS not in console.get_user_variables(): + return + console.shell.user_ns[VIEWERS].update({viewer_name: mda_viewer}) + def _remove_closed_mda_viewer_from_console(self, index: int) -> None: if index == 0: # preview tab return diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index 373d0978..5b24df48 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -234,7 +234,7 @@ def _show_widget(self) -> None: # create dock widget if action_name in DOCKWIDGETS: - if action_name == "Console": + if action_name == CONSOLE: self._launch_mm_console() else: self._create_dock_widget(action_name) @@ -274,8 +274,8 @@ def _launch_mm_console(self) -> None: MMC: self._mmc, # CMMCorePlus instance WDGS: self._widgets, # dictionary of all the widgets MDA: self._mda, # quick access to the MDA widget - VIEWERS: {}, # dictionary of all the viewers, empty for now - PREVIEW: None, # quick access to the preview widget + VIEWERS: self._get_current_mda_viewers(), # dictionary of all the viewers + PREVIEW: self._main_window._core_link._preview, # access to preview widget } self._mm_console = MMConsole(user_vars) @@ -286,3 +286,15 @@ def _launch_mm_console(self) -> None: self._widgets[CONSOLE] = dock self._main_window.addDockWidget(RIGHT, dock) + + def _get_current_mda_viewers(self) -> dict[str, QWidget]: + """Update the viewers variable in the MMConsole.""" + viewers_dict = {} + tab = self._main_window._core_link._viewer_tab + for viewers in range(tab.count()): + if viewers == 0: # skip the preview tab + continue + tab_name = tab.tabText(viewers) + wdg = tab.widget(viewers) + viewers_dict[tab_name] = wdg + return viewers_dict From 75d8695235adfdc1f4b13f40ed1f68a974533ae0 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 5 Nov 2024 18:02:18 -0500 Subject: [PATCH 154/226] fix: rename --- src/micromanager_gui/_core_link.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index 165fdd22..dea749bf 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -64,9 +64,7 @@ def __init__(self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None self._mmc.mda.events.sequenceFinished.connect(self._on_sequence_finished) self._mmc.mda.events.sequencePauseToggled.connect(self._enable_gui) - self._viewer_tab.tabCloseRequested.connect( - self._remove_closed_mda_viewer_from_console - ) + self._viewer_tab.tabCloseRequested.connect(self._remove_mda_viewer_from_console) def _close_tab(self, index: int) -> None: """Close the tab at the given index.""" @@ -193,7 +191,7 @@ def _add_viewer_to_mm_console( return console.shell.user_ns[VIEWERS].update({viewer_name: mda_viewer}) - def _remove_closed_mda_viewer_from_console(self, index: int) -> None: + def _remove_mda_viewer_from_console(self, index: int) -> None: if index == 0: # preview tab return if console := self._get_mm_console(): From 316095fd97c5f7865ceb256dd234ecce5db44bf4 Mon Sep 17 00:00:00 2001 From: federico gasparoli <70725613+fdrgsp@users.noreply.github.com> Date: Tue, 5 Nov 2024 21:50:32 -0500 Subject: [PATCH 155/226] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f289cdb7..d49445c8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ A Micro-Manager GUI based on [pymmcore-widgets](https://pymmcore-plus.github.io/pymmcore-widgets/) and [pymmcore-plus](https://pymmcore-plus.github.io/pymmcore-plus/). -Screenshot 2024-06-03 at 11 49 45 PM +Screenshot 2024-11-05 at 9 49 58 PM + ## Python version From 5268acba687ed8f7d6665f7812a01aa643e10354 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 6 Nov 2024 12:07:38 -0500 Subject: [PATCH 156/226] fix --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a9356817..d2e1757c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,14 +29,13 @@ dependencies = [ "pymmcore-widgets >=0.8.0", "pymmcore-plus >=0.11.0", "qtpy", - "vispy", "zarr", "tifffile", "tqdm", "pyyaml", "cmap", "pyconify", - "ndv", + "ndv[vispy]", "qtconsole", ] From 5bb11c19a69c5cca3f4ff19cf6ea421128b0a789 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 6 Nov 2024 14:00:47 -0500 Subject: [PATCH 157/226] fix: merge methods --- src/micromanager_gui/_core_link.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index dea749bf..07c36a98 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -12,12 +12,6 @@ from ._menubar._menubar import PREVIEW, VIEWERS from ._widgets._viewers import Preview -DIALOG = Qt.WindowType.Dialog -VIEWER_TEMP_DIR = None -NO_R_BTN = (0, QTabBar.ButtonPosition.RightSide, None) -NO_L_BTN = (0, QTabBar.ButtonPosition.LeftSide, None) -MDA_VIEWER = "MDA Viewer" - if TYPE_CHECKING: import useq from pymmcore_plus.metadata import SummaryMetaV1 @@ -26,6 +20,12 @@ from ._widgets._mda_widget import MDAWidget from ._widgets._mm_console import MMConsole +DIALOG = Qt.WindowType.Dialog +VIEWER_TEMP_DIR = None +NO_R_BTN = (0, QTabBar.ButtonPosition.RightSide, None) +NO_L_BTN = (0, QTabBar.ButtonPosition.LeftSide, None) +MDA_VIEWER = "MDA Viewer" + class CoreViewersLink(QObject): def __init__(self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None): @@ -64,8 +64,6 @@ def __init__(self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None self._mmc.mda.events.sequenceFinished.connect(self._on_sequence_finished) self._mmc.mda.events.sequencePauseToggled.connect(self._enable_gui) - self._viewer_tab.tabCloseRequested.connect(self._remove_mda_viewer_from_console) - def _close_tab(self, index: int) -> None: """Close the tab at the given index.""" if index == 0: @@ -78,6 +76,14 @@ def _close_tab(self, index: int) -> None: del self._current_viewer self._current_viewer = None + # remove the viewer from the console + if console := self._get_mm_console(): + if VIEWERS not in console.get_user_variables(): + return + # remove the item at pos index from the viewers variable in the console + viewer_name = list(console.shell.user_ns[VIEWERS].keys())[index - 1] + console.shell.user_ns[VIEWERS].pop(viewer_name, None) + def _on_sequence_started( self, sequence: useq.MDASequence, meta: SummaryMetaV1 ) -> None: From 8385d594ea68a0e31c90109889fa01f26a09f487 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 6 Nov 2024 14:03:53 -0500 Subject: [PATCH 158/226] fix: remove unused --- src/micromanager_gui/_core_link.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index 07c36a98..fe1fe7c6 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -196,13 +196,3 @@ def _add_viewer_to_mm_console( if VIEWERS not in console.get_user_variables(): return console.shell.user_ns[VIEWERS].update({viewer_name: mda_viewer}) - - def _remove_mda_viewer_from_console(self, index: int) -> None: - if index == 0: # preview tab - return - if console := self._get_mm_console(): - if VIEWERS not in console.get_user_variables(): - return - # remove the item at pos index from the viewers variable in the console - viewer_name = list(console.shell.user_ns[VIEWERS].keys())[index - 1] - console.shell.user_ns[VIEWERS].pop(viewer_name, None) From 5f276ebc8676c7f5b5d26ce6467c6dc94be50114 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 6 Nov 2024 14:49:04 -0500 Subject: [PATCH 159/226] fix: init writers folder --- src/micromanager_gui/_writers/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/micromanager_gui/_writers/__init__.py diff --git a/src/micromanager_gui/_writers/__init__.py b/src/micromanager_gui/_writers/__init__.py new file mode 100644 index 00000000..24d3d8d1 --- /dev/null +++ b/src/micromanager_gui/_writers/__init__.py @@ -0,0 +1,5 @@ +"""Writer classes for different file formats.""" + +from ._tensorstore_zarr import _TensorStoreHandler + +__all__ = ["_TensorStoreHandler"] From 472cfacf3447bf918cac61dafbb6393d04a9fc1d Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 6 Nov 2024 15:10:11 -0500 Subject: [PATCH 160/226] fix return type --- src/micromanager_gui/readers/_ome_zarr_reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/micromanager_gui/readers/_ome_zarr_reader.py b/src/micromanager_gui/readers/_ome_zarr_reader.py index f2c239eb..dd2c0a58 100644 --- a/src/micromanager_gui/readers/_ome_zarr_reader.py +++ b/src/micromanager_gui/readers/_ome_zarr_reader.py @@ -49,7 +49,7 @@ class OMEZarrReader: data, metadata = reader.isel({"p": 0, "t": 1, "z": 0}, metadata=True) """ - def __init__(self, data: str | Path | Group): + def __init__(self, data: str | Path | Group) -> None: self._path = data.path if isinstance(data, Group) else data self._store: Group = data if isinstance(data, Group) else zarr.open(self._path) From 6b332f1efc8733a5e2199363bd3d5cda85572e4c Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 6 Nov 2024 15:23:05 -0500 Subject: [PATCH 161/226] fix: enable_menubar --- src/micromanager_gui/_core_link.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/micromanager_gui/_core_link.py b/src/micromanager_gui/_core_link.py index fe1fe7c6..eb3545b9 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/micromanager_gui/_core_link.py @@ -62,7 +62,7 @@ def __init__(self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None self._mmc.mda.events.sequenceStarted.connect(self._on_sequence_started) self._mmc.mda.events.sequenceFinished.connect(self._on_sequence_finished) - self._mmc.mda.events.sequencePauseToggled.connect(self._enable_gui) + self._mmc.mda.events.sequencePauseToggled.connect(self._enable_menubar) def _close_tab(self, index: int) -> None: """Close the tab at the given index.""" @@ -116,8 +116,7 @@ def _setup_viewer(self, sequence: useq.MDASequence, meta: SummaryMetaV1) -> None # emitted already self._current_viewer.data.sequenceStarted(sequence, meta) - # disable the LUT drop down and the mono/composite button (temporary) - self._enable_gui(False) + self._enable_menubar(False) # connect the signals self._connect_viewer(self._current_viewer) @@ -153,8 +152,7 @@ def _on_sequence_finished(self, sequence: useq.MDASequence) -> None: if self._current_viewer is None: return - # enable the LUT drop down and the mono/composite button (temporary) - self._enable_gui(True) + self._enable_menubar(True) # call it before we disconnect the signals or it will not be called self._current_viewer.data.sequenceFinished(sequence) @@ -172,11 +170,9 @@ def _disconnect_viewer(self, viewer: MDAViewer) -> None: self._mmc.mda.events.frameReady.disconnect(viewer.data.frameReady) self._mmc.mda.events.sequenceFinished.disconnect(viewer.data.sequenceFinished) - def _enable_gui(self, state: bool) -> None: - """Pause the viewer when the MDA sequence is paused.""" + def _enable_menubar(self, state: bool) -> None: + """Enable or disable the GUI.""" self._main_window._menu_bar._enable(state) - if self._current_viewer is None: - return def _set_preview_tab(self) -> None: """Set the preview tab.""" From 295e3908f23b27cbfc43e7f7b0df9080e54f19fb Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Fri, 8 Nov 2024 23:12:48 -0500 Subject: [PATCH 162/226] fix: hide 3d btn --- .../_widgets/_viewers/_mda_viewer/_mda_viewer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py index f789393f..0e25d268 100644 --- a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py +++ b/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py @@ -47,6 +47,9 @@ def __init__( super().__init__(data, parent=parent, channel_axis="c", **kwargs) + # temporarily hide the ndims button since we don't yet support + self._ndims_btn.hide() + # add the save button only if using a TensorStoreHandler (and thus the # MMTensorstoreWrapper) or OMEZarrWriter (and thus the MM5DWriterWrapper) # since we didn't yet implement the save_as_zarr and save_as_tiff methods From 8ef36118b90476517465b063c726f43cac819c0f Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Sun, 10 Nov 2024 18:33:53 -0500 Subject: [PATCH 163/226] feat: preview.image --- .../_widgets/_viewers/_preview_viewer/_preview_viewer.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py index 025c835b..ebbc26c8 100644 --- a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py +++ b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py @@ -80,6 +80,10 @@ def __init__( self._mmc.events.exposureChanged.connect(self._restart_live) self._mmc.events.configSet.connect(self._restart_live) + def image(self) -> Any: + """Return the current image data.""" + return self.data.read().result() + def closeEvent(self, event: QCloseEvent | None) -> None: self._mmc.stopSequenceAcquisition() super().closeEvent(event) From b0d047825d934da6e7362bfd3deedb08f1fb4d29 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 12 Nov 2024 16:37:03 -0500 Subject: [PATCH 164/226] fix: get_next_available_path --- .../_widgets/_mda_widget/_mda_widget.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/micromanager_gui/_widgets/_mda_widget/_mda_widget.py b/src/micromanager_gui/_widgets/_mda_widget/_mda_widget.py index 96a62880..25cdd1bf 100644 --- a/src/micromanager_gui/_widgets/_mda_widget/_mda_widget.py +++ b/src/micromanager_gui/_widgets/_mda_widget/_mda_widget.py @@ -44,16 +44,17 @@ def get_next_available_path(requested_path: Path | str, min_digits: int = 3) -> """ if isinstance(requested_path, str): # pragma: no cover requested_path = Path(requested_path) - directory = requested_path.parent extension = requested_path.suffix # ome files like .ome.tiff or .ome.zarr are special,treated as a single extension if (stem := requested_path.stem).endswith(".ome"): extension = f".ome{extension}" stem = stem[:-4] + # NOTE: added in micromanager_gui --------------------------------------------- elif (stem := requested_path.stem).endswith(".tensorstore"): extension = f".tensorstore{extension}" stem = stem[:-12] + # ----------------------------------------------------------------------------- # look for ANY existing files in the folder that follow the pattern of # stem_###.extension @@ -61,13 +62,19 @@ def get_next_available_path(requested_path: Path | str, min_digits: int = 3) -> for existing in directory.glob(f"*{extension}"): # cannot use existing.stem because of the ome (2-part-extension) special case base = existing.name.replace(extension, "") - # if the base name ends with a number, increase the current_max - if (match := NUM_SPLIT.match(base)) and (num := match.group(2)): + # if base name ends with a number and stem is the same, increase current_max + if ( + (match := NUM_SPLIT.match(base)) + and (num := match.group(2)) + # NOTE: added in micromanager_gui ------------------------------------- + # this breaks pymmcore_widgets test_get_next_available_paths_special_cases + and match.group(1) == stem + # --------------------------------------------------------------------- + ): current_max = max(int(num), current_max) # if it has more digits than expected, update the ndigits if len(num) > min_digits: min_digits = len(num) - # if the path does not exist and there are no existing files, # return the requested path if not requested_path.exists() and current_max == 0: From 3b77288a678ca19db812e524e7f615fe06eb0546 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Wed, 13 Nov 2024 09:44:07 -0500 Subject: [PATCH 165/226] move prop prowser to cfg menu --- src/micromanager_gui/_menubar/_menubar.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/micromanager_gui/_menubar/_menubar.py index 5b24df48..dcfc53bb 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/micromanager_gui/_menubar/_menubar.py @@ -33,8 +33,8 @@ FLAGS = Qt.WindowType.Dialog CONSOLE = "Console" +PROP_BROWSER = "Property Browser" WIDGETS = { - "Property Browser": PropertyBrowser, "Pixel Configuration": PixelConfigurationWidget, "Install Devices": _InstallWidget, } @@ -115,6 +115,10 @@ def __init__( self._act_cfg_wizard = QAction("Hardware Configuration Wizard", self) self._act_cfg_wizard.triggered.connect(self._show_config_wizard) self._configurations_menu.addAction(self._act_cfg_wizard) + # property browser + self._act_property_browser = QAction(PROP_BROWSER, self) + self._act_property_browser.triggered.connect(self._show_property_browser) + self._configurations_menu.addAction(self._act_property_browser) # save cfg self._act_save_configuration = QAction("Save Configuration", self) self._act_save_configuration.triggered.connect(self._save_cfg) @@ -287,6 +291,18 @@ def _launch_mm_console(self) -> None: self._main_window.addDockWidget(RIGHT, dock) + def _show_property_browser(self) -> None: + """Show the property browser.""" + if PROP_BROWSER in self._widgets: + pb = self._widgets[PROP_BROWSER] + pb.show() + pb.raise_() + else: + pb = PropertyBrowser(parent=self, mmcore=self._mmc) + pb.setWindowFlags(FLAGS) + self._widgets[PROP_BROWSER] = pb + pb.show() + def _get_current_mda_viewers(self) -> dict[str, QWidget]: """Update the viewers variable in the MMConsole.""" viewers_dict = {} From 1c99d46867d27cb12c744125a0e00e5ced6af1fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 05:56:29 +0000 Subject: [PATCH 166/226] ci(dependabot): bump codecov/codecov-action from 4 to 5 Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4 to 5. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4...v5) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23eb98ea..ea6ada74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: run: pytest -v --cov=pymmcore_widgets --cov-report=xml --color=yes - name: Coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} From 5ca18b60cca6a130e5b42b5d13ef0821f662e03b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 2 Dec 2024 20:25:27 +0000 Subject: [PATCH 167/226] ci(pre-commit.ci): autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/crate-ci/typos: v1.27.0 → typos-dict-v0.11.37](https://github.com/crate-ci/typos/compare/v1.27.0...typos-dict-v0.11.37) - [github.com/astral-sh/ruff-pre-commit: v0.7.2 → v0.8.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.2...v0.8.1) - [github.com/abravalheri/validate-pyproject: v0.22 → v0.23](https://github.com/abravalheri/validate-pyproject/compare/v0.22...v0.23) --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5d60f87b..0bb52069 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,19 +5,19 @@ ci: repos: - repo: https://github.com/crate-ci/typos - rev: v1.27.0 + rev: typos-dict-v0.11.37 hooks: - id: typos - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.2 + rev: v0.8.1 hooks: - id: ruff args: [--fix, --unsafe-fixes] - id: ruff-format - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.22 + rev: v0.23 hooks: - id: validate-pyproject From c1aece7dea9ad5038cd01eadc3c031821578d67d Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Tue, 3 Dec 2024 23:36:55 -0500 Subject: [PATCH 168/226] fix: unused --- .../_widgets/_viewers/_preview_viewer/_preview_viewer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py index ebbc26c8..fdef09c4 100644 --- a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py +++ b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py @@ -128,7 +128,6 @@ def _handle_snap(self) -> None: if self._mmc.mda.is_running(): # This signal is emitted during MDAs as well - we want to ignore those. return - # self.set_data(self._mmc.getImage()) img, meta = self._mmc.getTaggedImage() self.set_data(img) self._meta = meta From c81d80690fe02bc0cc858694aed409c0ce9027e5 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 16 Dec 2024 10:20:49 -0500 Subject: [PATCH 169/226] fix: fix image size and reset --- pyproject.toml | 2 +- .../_viewers/_preview_viewer/_preview_viewer.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d2e1757c..427575bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ "pyyaml", "cmap", "pyconify", - "ndv[vispy]", + "ndv[vispy] ==0.0.4", "qtconsole", ] diff --git a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py index fdef09c4..9d2460a4 100644 --- a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py +++ b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py @@ -1,9 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Hashable, Mapping +from typing import TYPE_CHECKING, Any, Hashable, Mapping, cast import tensorstore as ts from ndv import DataWrapper, NDViewer +from ndv.viewer._backends._vispy import VispyViewerCanvas from pymmcore_plus import CMMCorePlus, Metadata from qtpy import QtCore from superqt.utils import ensure_main_thread @@ -105,7 +106,17 @@ def _update_datastore(self) -> Any: shape=self.ts_shape, dtype=_data_type(self._mmc), ).result() + + # this is a hack to update the canvas with the new shape or the reset + # button will not work + self._canvas = cast(VispyViewerCanvas, self._canvas) # type: ignore + if self._canvas._current_shape: + self._canvas._current_shape = self.ts_shape + super().set_data(self.ts_array) + + self._canvas.set_range() + return self.ts_array def set_data( From c15e1e4da322e9242a2743c773b8db488a2aecc3 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 16 Dec 2024 10:22:59 -0500 Subject: [PATCH 170/226] fix: fix image size and reset --- .../_widgets/_viewers/_preview_viewer/_preview_viewer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py index 9d2460a4..e35d97e1 100644 --- a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py +++ b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py @@ -107,8 +107,8 @@ def _update_datastore(self) -> Any: dtype=_data_type(self._mmc), ).result() - # this is a hack to update the canvas with the new shape or the reset - # button will not work + # this is a hack to update the canvas with the new image shape or the + # set_range method will not work properly self._canvas = cast(VispyViewerCanvas, self._canvas) # type: ignore if self._canvas._current_shape: self._canvas._current_shape = self.ts_shape From 2262d033b9652a6ba66c0c58ef5a96d923f14bc0 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 16 Dec 2024 11:07:39 -0500 Subject: [PATCH 171/226] fix: revert --- pyproject.toml | 2 +- .../_widgets/_viewers/_preview_viewer/_preview_viewer.py | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 427575bd..d2e1757c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ "pyyaml", "cmap", "pyconify", - "ndv[vispy] ==0.0.4", + "ndv[vispy]", "qtconsole", ] diff --git a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py index e35d97e1..4f2c6924 100644 --- a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py +++ b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py @@ -1,10 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Hashable, Mapping, cast +from typing import TYPE_CHECKING, Any, Hashable, Mapping import tensorstore as ts from ndv import DataWrapper, NDViewer -from ndv.viewer._backends._vispy import VispyViewerCanvas from pymmcore_plus import CMMCorePlus, Metadata from qtpy import QtCore from superqt.utils import ensure_main_thread @@ -114,9 +113,6 @@ def _update_datastore(self) -> Any: self._canvas._current_shape = self.ts_shape super().set_data(self.ts_array) - - self._canvas.set_range() - return self.ts_array def set_data( From c1590b0fa74ba52ddff76ae724821a2b72cc80cb Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 16 Dec 2024 11:10:20 -0500 Subject: [PATCH 172/226] fix --- .../_widgets/_viewers/_preview_viewer/_preview_viewer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py index 4f2c6924..e35d97e1 100644 --- a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py +++ b/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py @@ -1,9 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Hashable, Mapping +from typing import TYPE_CHECKING, Any, Hashable, Mapping, cast import tensorstore as ts from ndv import DataWrapper, NDViewer +from ndv.viewer._backends._vispy import VispyViewerCanvas from pymmcore_plus import CMMCorePlus, Metadata from qtpy import QtCore from superqt.utils import ensure_main_thread @@ -113,6 +114,9 @@ def _update_datastore(self) -> Any: self._canvas._current_shape = self.ts_shape super().set_data(self.ts_array) + + self._canvas.set_range() + return self.ts_array def set_data( From e3826463627f22c4906c8cbd2945938695bcb467 Mon Sep 17 00:00:00 2001 From: fdrgsp Date: Mon, 16 Dec 2024 11:11:53 -0500 Subject: [PATCH 173/226] pin ndv --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d2e1757c..427575bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ "pyyaml", "cmap", "pyconify", - "ndv[vispy]", + "ndv[vispy] ==0.0.4", "qtconsole", ] From 72b717caa5ef18c84d331bad094239be108a4895 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 17 Dec 2024 08:36:11 -0500 Subject: [PATCH 174/226] bring in federico's gui --- .cruft.json | 21 - .github/ISSUE_TEMPLATE.md | 2 +- .github/workflows/ci.yml | 26 +- .github_changelog_generator | 6 - .pre-commit-config.yaml | 15 +- README.md | 51 - examples/gui.py | 2 +- pyproject.toml | 153 +- .../__init__.py | 0 .../__main__.py | 2 +- .../_core_link.py | 2 +- .../_main_window.py | 2 +- .../_menubar/_menubar.py | 10 +- .../_toolbar/_shutters_toolbar.py | 0 .../_toolbar/_snap_live.py | 2 +- .../_widgets/_install_widget.py | 0 .../_widgets/_mda_widget/__init__.py | 0 .../_widgets/_mda_widget/_mda_widget.py | 2 +- .../_widgets/_mda_widget/_save_widget.py | 0 .../_widgets/_mm_console.py | 0 .../_widgets/_snap_live_buttons.py | 0 .../_widgets/_stage_control.py | 0 .../_widgets/_viewers/__init__.py | 0 .../_widgets/_viewers/_mda_viewer/__init__.py | 0 .../_viewers/_mda_viewer/_data_wrappers.py | 2 +- .../_viewers/_mda_viewer/_mda_save_button.py | 0 .../_viewers/_mda_viewer/_mda_viewer.py | 2 +- .../_viewers/_preview_viewer/__init__.py | 0 .../_preview_viewer/_preview_save_button.py | 2 +- .../_preview_viewer/_preview_viewer.py | 2 +- .../_writers/__init__.py | 0 .../_writers/_tensorstore_zarr.py | 0 .../py.typed | 0 .../readers/__init__.py | 0 .../readers/_ome_zarr_reader.py | 0 .../readers/_tensorstore_zarr_reader.py | 0 test.ome.zarr/.zgroup | 3 + tests/test_gui.py | 6 +- tests/test_mda_viewer.py | 8 +- tests/test_readers_writers.py | 6 +- tests/test_save_widget.py | 2 +- tests/test_stage_widget.py | 2 +- uv.lock | 1990 +++++++++++++++++ 43 files changed, 2107 insertions(+), 214 deletions(-) delete mode 100644 .cruft.json delete mode 100644 .github_changelog_generator rename src/{micromanager_gui => pymmcore_gui}/__init__.py (100%) rename src/{micromanager_gui => pymmcore_gui}/__main__.py (96%) rename src/{micromanager_gui => pymmcore_gui}/_core_link.py (99%) rename src/{micromanager_gui => pymmcore_gui}/_main_window.py (98%) rename src/{micromanager_gui => pymmcore_gui}/_menubar/_menubar.py (97%) rename src/{micromanager_gui => pymmcore_gui}/_toolbar/_shutters_toolbar.py (100%) rename src/{micromanager_gui => pymmcore_gui}/_toolbar/_snap_live.py (95%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_install_widget.py (100%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_mda_widget/__init__.py (100%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_mda_widget/_mda_widget.py (99%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_mda_widget/_save_widget.py (100%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_mm_console.py (100%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_snap_live_buttons.py (100%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_stage_control.py (100%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_viewers/__init__.py (100%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_viewers/_mda_viewer/__init__.py (100%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_viewers/_mda_viewer/_data_wrappers.py (98%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_viewers/_mda_viewer/_mda_save_button.py (100%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_viewers/_mda_viewer/_mda_viewer.py (97%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_viewers/_preview_viewer/__init__.py (100%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_viewers/_preview_viewer/_preview_save_button.py (96%) rename src/{micromanager_gui => pymmcore_gui}/_widgets/_viewers/_preview_viewer/_preview_viewer.py (98%) rename src/{micromanager_gui => pymmcore_gui}/_writers/__init__.py (100%) rename src/{micromanager_gui => pymmcore_gui}/_writers/_tensorstore_zarr.py (100%) rename src/{micromanager_gui => pymmcore_gui}/py.typed (100%) rename src/{micromanager_gui => pymmcore_gui}/readers/__init__.py (100%) rename src/{micromanager_gui => pymmcore_gui}/readers/_ome_zarr_reader.py (100%) rename src/{micromanager_gui => pymmcore_gui}/readers/_tensorstore_zarr_reader.py (100%) create mode 100644 test.ome.zarr/.zgroup create mode 100644 uv.lock diff --git a/.cruft.json b/.cruft.json deleted file mode 100644 index d0b23875..00000000 --- a/.cruft.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "template": "https://github.com/fdrgsp/pyrepo-cookiecutter", - "commit": "b88c1cdc74cacc806c32985fe8692110857c0cba", - "checkout": null, - "context": { - "cookiecutter": { - "full_name": "Federico Gasparoli", - "email": "federico.gasparoli@gmail.com", - "github_username": "fdrgsp", - "project_name": "micromanager-gui", - "project_slug": "micromanager_gui", - "project_short_description": "A Micro-Manager GUI based on pymmcore-widgets and pymmcore-plus.", - "pypi_username": "fdrgsp", - "_copy_without_render": [ - ".github/workflows/*" - ], - "_template": "https://github.com/fdrgsp/pyrepo-cookiecutter" - } - }, - "directory": null -} diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index df562e54..278a3f46 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,4 +1,4 @@ -* micromanager-gui version: +* pymmcore-gui version: * Python version: * Operating System: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea6ada74..2ec2b4c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,32 +76,32 @@ jobs: deploy: name: Deploy needs: test - if: "success() && startsWith(github.ref, 'refs/tags/')" + if: success() && startsWith(github.ref, 'refs/tags/') && github.event_name != 'schedule' runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Set up Python + - name: 🐍 Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" - - name: install + - name: 👷 Build run: | - git tag - pip install -U pip - pip install -U build twine + python -m pip install build python -m build - twine check dist/* - ls -lh dist - - name: Build and publish - run: twine upload dist/* - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + - name: 🚢 Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 - uses: softprops/action-gh-release@v2 with: generate_release_notes: true + files: './dist/*' diff --git a/.github_changelog_generator b/.github_changelog_generator deleted file mode 100644 index d0bd3917..00000000 --- a/.github_changelog_generator +++ /dev/null @@ -1,6 +0,0 @@ -user=fdrgsp -project=micromanager-gui -issues=false -exclude-labels=duplicate,question,invalid,wontfix,hide -add-sections={"tests":{"prefix":"**Tests & CI:**","labels":["tests"]}, "documentation":{"prefix":"**Documentation:**", "labels":["documentation"]}} -exclude-tags-regex=.*rc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0bb52069..6817de2d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,23 +4,24 @@ ci: autoupdate_commit_msg: "ci(pre-commit.ci): autoupdate" repos: + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.23 + hooks: + - id: validate-pyproject + - repo: https://github.com/crate-ci/typos - rev: typos-dict-v0.11.37 + rev: v1.28.4 hooks: - id: typos + args: [--force-exclude] # omitting --write-changes - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.1 + rev: v0.8.3 hooks: - id: ruff args: [--fix, --unsafe-fixes] - id: ruff-format - - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.23 - hooks: - - id: validate-pyproject - - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.13.0 hooks: diff --git a/README.md b/README.md index d49445c8..e69de29b 100644 --- a/README.md +++ b/README.md @@ -1,51 +0,0 @@ -# micromanager-gui [WIP] - -[![License](https://img.shields.io/pypi/l/micromanager-gui.svg?color=green)](https://github.com/fdrgsp/micromanager-gui/raw/main/LICENSE) -[![PyPI](https://img.shields.io/pypi/v/micromanager-gui.svg?color=green)](https://pypi.org/project/micromanager-gui) -[![Python Version](https://img.shields.io/pypi/pyversions/micromanager-gui.svg?color=green)](https://python.org) -[![CI](https://github.com/fdrgsp/micromanager-gui/actions/workflows/ci.yml/badge.svg)](https://github.com/fdrgsp/micromanager-gui/actions/workflows/ci.yml) -[![codecov](https://codecov.io/gh/fdrgsp/micromanager-gui/branch/main/graph/badge.svg)](https://codecov.io/gh/fdrgsp/micromanager-gui) - -A Micro-Manager GUI based on [pymmcore-widgets](https://pymmcore-plus.github.io/pymmcore-widgets/) and [pymmcore-plus](https://pymmcore-plus.github.io/pymmcore-plus/). - - -Screenshot 2024-11-05 at 9 49 58 PM - - - -## Python version - -The package is tested on Python 3.10 and 3.11. - -## Installation - -```bash -pip install git+https://github.com/fdrgsp/micromanager-gui -``` - -### Installing PyQt - -Since `micromanager-gui` relies on the [PyQt](https://riverbankcomputing.com/software/pyqt/) library, you also **need** to install one of these packages. You can use any of the available versions of [PyQt6](https://pypi.org/project/PyQt6/) or [PyQt5](https://pypi.org/project/PyQt5/). For example, to install [PyQt6](https://riverbankcomputing.com/software/pyqt/download), you can use: - -```sh -pip install PyQt6 -``` - -Note: tests are running on [PyQt6](https://pypi.org/project/PyQt6/) and [PyQt5](https://pypi.org/project/PyQt5/). - -### Installing Micro-Manager - -You also need to install the `Micro-Manager` device adapters and C++ core provided by [mmCoreAndDevices](https://github.com/micro-manager/mmCoreAndDevices#mmcoreanddevices). This can be done by following the steps described in the `pymmcore-plus` [documentation page](https://pymmcore-plus.github.io/pymmcore-plus/install/#installing-micro-manager-device-adapters). - -## To run the Micro-Manger GUI - -```bash -mmgui -``` - -By passing the `-c` or `-config` flag, you can specify the path of a micromanager configuration file you want to load. For example: - -```bash -mmgui -c path/to/config.cfg -``` - diff --git a/examples/gui.py b/examples/gui.py index 59f7bd20..ae1a9d3d 100644 --- a/examples/gui.py +++ b/examples/gui.py @@ -1,7 +1,7 @@ from pymmcore_plus import CMMCorePlus from qtpy.QtWidgets import QApplication -from micromanager_gui import MicroManagerGUI +from pymmcore_gui import MicroManagerGUI app = QApplication([]) mmc = CMMCorePlus.instance() diff --git a/pyproject.toml b/pyproject.toml index 427575bd..01a096a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,96 +3,87 @@ requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +# https://hatch.pypa.io/latest/config/metadata/ +[tool.hatch.version] +source = "vcs" + +# read more about configuring hatch at: +# https://hatch.pypa.io/latest/config/build/ +[tool.hatch.build.targets.wheel] +only-include = ["src"] +sources = ["src"] + # https://peps.python.org/pep-0621/ [project] -name = "micromanager-gui" +name = "pymmcore-gui" +dynamic = ["version"] description = "A Micro-Manager GUI based on pymmcore-widgets and pymmcore-plus." readme = "README.md" -requires-python = ">=3.8" -license = { text = "BSD 3-Clause License" } +requires-python = ">=3.10" +license = { text = "BSD-3-Clause" } authors = [ { email = "federico.gasparoli@gmail.com", name = "Federico Gasparoli" }, + { name = "Talley Lambert", email = "talley.lambert@gmail.com" }, ] classifiers = [ "Development Status :: 3 - Alpha", "License :: OSI Approved :: BSD License", - "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Typing :: Typed", ] -dynamic = ["version"] dependencies = [ - "pymmcore-widgets >=0.8.0", - "pymmcore-plus >=0.11.0", - "qtpy", - "zarr", - "tifffile", - "tqdm", - "pyyaml", - "cmap", - "pyconify", - "ndv[vispy] ==0.0.4", - "qtconsole", + "ndv[vispy]==0.0.4", + "pymmcore-plus[cli]>=0.12.0", + "pymmcore-widgets>=0.8.0", + "pyqt6==6.7.1", + "pyyaml>=6.0.2", + "qtconsole>=5.6.1", + "qtpy>=2.4.2", + "superqt[cmap,iconify]>=0.7.0", + "tifffile>=2024.12.12", + "tqdm>=4.67.1", + "zarr>=2.18.3", ] -# extras -# https://peps.python.org/pep-0621/#dependencies-optional-dependencies -[project.optional-dependencies] -test = ["pytest>=6.0", "pytest-cov", "pytest-qt", "dask"] -pyqt5 = ["PyQt5"] -pyside2 = ["PySide2"] -pyqt6 = ["PyQt6"] -pyside6 = ["PySide6<6.5"] +[dependency-groups] dev = [ - "black", - "ipython", - "mypy", - "pdbpp", - "pre-commit", - "pytest-cov", - "pytest", - "rich", - "ruff", + "ipython>=8.30.0", + "mypy>=1.13.0", + "pdbpp>=0.10.3 ; sys_platform != 'windows'", + "pre-commit>=4.0.1", + "pytest>=8.3.4", + "pytest-cov>=6.0.0", + "pytest-qt>=4.4.0", + "rich>=13.9.4", + "ruff>=0.8.3", ] -[project.urls] -homepage = "https://github.com/fdrgsp/micromanager-gui" -repository = "https://github.com/fdrgsp/micromanager-gui" - # same as console_scripts entry point [project.scripts] -mmgui = "micromanager_gui.__main__:main" - -# https://hatch.pypa.io/latest/config/metadata/ -[tool.hatch.version] -source = "vcs" +mmgui = "pymmcore_gui.__main__:main" -[tool.hatch.metadata] -allow-direct-references = true - -# https://hatch.pypa.io/latest/config/build/#file-selection -# [tool.hatch.build.targets.sdist] -# include = ["/src", "/tests"] +[project.urls] +homepage = "https://github.com/tlambert03/pymmcore-gui" +repository = "https://github.com/tlambert03/pymmcore-gui" -[tool.hatch.build.targets.wheel] -only-include = ["src"] -sources = ["src"] -# https://beta.ruff.rs/docs/rules/ +# https://docs.astral.sh/ruff [tool.ruff] line-length = 88 -target-version = "py38" -src = ["src", "tests"] +target-version = "py310" +src = ["src"] + +# https://docs.astral.sh/ruff/rules [tool.ruff.lint] pydocstyle = { convention = "numpy" } select = [ "E", # style errors + "W", # style warnings "F", # flakes - "W", # warnings "D", # pydocstyle "D417", # Missing argument descriptions in Docstrings "I", # isort @@ -101,31 +92,21 @@ select = [ "B", # flake8-bugbear "A001", # flake8-builtins "RUF", # ruff-specific rules - "TID", # tidy - "TCH", # typecheck - # "SLF", # private-access + "TC", # flake8-type-checking + "TID", # flake8-tidy-imports ] ignore = [ "D100", # Missing docstring in public module - "D401", # First line should be in imperative mood + "D401", # First line should be in imperative mood (remove to opt in) ] [tool.ruff.lint.per-file-ignores] -"tests/*.py" = ["D", "SLF"] +"tests/*.py" = ["D", "S"] +# https://docs.astral.sh/ruff/formatter/ [tool.ruff.format] docstring-code-format = true - -# https://docs.pytest.org/en/6.2.x/customize.html -[tool.pytest.ini_options] -markers = ["allow_leaks"] -minversion = "6.0" -testpaths = ["tests"] -filterwarnings = [ - "error", - "ignore:distutils Version classes are deprecated", - # warning, but not error, that will show up on useq<0.3.3 -] +skip-magic-trailing-comma = false # default is false # https://mypy.readthedocs.io/en/stable/config_file.html [tool.mypy] @@ -135,16 +116,16 @@ disallow_any_generics = false disallow_subclassing_any = false show_error_codes = true pretty = true -plugins = ["pydantic.mypy"] - -# # module specific overrides -# [[tool.mypy.overrides]] -# module = ["numpy.*",] -# ignore_errors = true +# https://docs.pytest.org/ +[tool.pytest.ini_options] +minversion = "8.0" +testpaths = ["tests"] +filterwarnings = ["error"] -# https://coverage.readthedocs.io/en/6.4/config.html +# https://coverage.readthedocs.io/ [tool.coverage.report] +show_missing = true exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", @@ -152,18 +133,14 @@ exclude_lines = [ "except ImportError", "\\.\\.\\.", "raise NotImplementedError()", + "pass", ] + [tool.coverage.run] -source = ["micromanager_gui"] +source = ["pymmcore_gui"] -# https://github.com/mgedmin/check-manifest#configuration [tool.check-manifest] -ignore = [ - ".github_changelog_generator", - ".pre-commit-config.yaml", - ".ruff_cache/**/*", - "tests/**/*", -] +ignore = [".pre-commit-config.yaml", ".ruff_cache/**/*", "tests/**/*"] [tool.typos.default] extend-ignore-identifiers-re = ["(?i)nd2?.*", "(?i)ome"] \ No newline at end of file diff --git a/src/micromanager_gui/__init__.py b/src/pymmcore_gui/__init__.py similarity index 100% rename from src/micromanager_gui/__init__.py rename to src/pymmcore_gui/__init__.py diff --git a/src/micromanager_gui/__main__.py b/src/pymmcore_gui/__main__.py similarity index 96% rename from src/micromanager_gui/__main__.py rename to src/pymmcore_gui/__main__.py index 800974b9..44712ccf 100644 --- a/src/micromanager_gui/__main__.py +++ b/src/pymmcore_gui/__main__.py @@ -7,7 +7,7 @@ from qtpy.QtWidgets import QApplication -from micromanager_gui import MicroManagerGUI +from pymmcore_gui import MicroManagerGUI if TYPE_CHECKING: from types import TracebackType diff --git a/src/micromanager_gui/_core_link.py b/src/pymmcore_gui/_core_link.py similarity index 99% rename from src/micromanager_gui/_core_link.py rename to src/pymmcore_gui/_core_link.py index eb3545b9..70aff2a3 100644 --- a/src/micromanager_gui/_core_link.py +++ b/src/pymmcore_gui/_core_link.py @@ -7,7 +7,7 @@ from qtpy.QtCore import QObject, Qt from qtpy.QtWidgets import QTabBar, QTabWidget -from micromanager_gui._widgets._viewers import MDAViewer +from pymmcore_gui._widgets._viewers import MDAViewer from ._menubar._menubar import PREVIEW, VIEWERS from ._widgets._viewers import Preview diff --git a/src/micromanager_gui/_main_window.py b/src/pymmcore_gui/_main_window.py similarity index 98% rename from src/micromanager_gui/_main_window.py rename to src/pymmcore_gui/_main_window.py index 15b9ec29..92e6d9f4 100644 --- a/src/micromanager_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -12,7 +12,7 @@ QWidget, ) -from micromanager_gui.readers import TensorstoreZarrReader +from pymmcore_gui.readers import TensorstoreZarrReader from ._core_link import CoreViewersLink from ._menubar._menubar import _MenuBar diff --git a/src/micromanager_gui/_menubar/_menubar.py b/src/pymmcore_gui/_menubar/_menubar.py similarity index 97% rename from src/micromanager_gui/_menubar/_menubar.py rename to src/pymmcore_gui/_menubar/_menubar.py index dcfc53bb..2a5ada54 100644 --- a/src/micromanager_gui/_menubar/_menubar.py +++ b/src/pymmcore_gui/_menubar/_menubar.py @@ -23,13 +23,13 @@ QWidget, ) -from micromanager_gui._widgets._install_widget import _InstallWidget -from micromanager_gui._widgets._mda_widget import MDAWidget -from micromanager_gui._widgets._mm_console import MMConsole -from micromanager_gui._widgets._stage_control import StagesControlWidget +from pymmcore_gui._widgets._install_widget import _InstallWidget +from pymmcore_gui._widgets._mda_widget import MDAWidget +from pymmcore_gui._widgets._mm_console import MMConsole +from pymmcore_gui._widgets._stage_control import StagesControlWidget if TYPE_CHECKING: - from micromanager_gui._main_window import MicroManagerGUI + from pymmcore_gui._main_window import MicroManagerGUI FLAGS = Qt.WindowType.Dialog CONSOLE = "Console" diff --git a/src/micromanager_gui/_toolbar/_shutters_toolbar.py b/src/pymmcore_gui/_toolbar/_shutters_toolbar.py similarity index 100% rename from src/micromanager_gui/_toolbar/_shutters_toolbar.py rename to src/pymmcore_gui/_toolbar/_shutters_toolbar.py diff --git a/src/micromanager_gui/_toolbar/_snap_live.py b/src/pymmcore_gui/_toolbar/_snap_live.py similarity index 95% rename from src/micromanager_gui/_toolbar/_snap_live.py rename to src/pymmcore_gui/_toolbar/_snap_live.py index 0404df41..3d1ab48d 100644 --- a/src/micromanager_gui/_toolbar/_snap_live.py +++ b/src/pymmcore_gui/_toolbar/_snap_live.py @@ -5,7 +5,7 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import QHBoxLayout, QLabel, QToolBar, QWidget -from micromanager_gui._widgets._snap_live_buttons import Live, Snap +from pymmcore_gui._widgets._snap_live_buttons import Live, Snap class _SnapLive(QToolBar): diff --git a/src/micromanager_gui/_widgets/_install_widget.py b/src/pymmcore_gui/_widgets/_install_widget.py similarity index 100% rename from src/micromanager_gui/_widgets/_install_widget.py rename to src/pymmcore_gui/_widgets/_install_widget.py diff --git a/src/micromanager_gui/_widgets/_mda_widget/__init__.py b/src/pymmcore_gui/_widgets/_mda_widget/__init__.py similarity index 100% rename from src/micromanager_gui/_widgets/_mda_widget/__init__.py rename to src/pymmcore_gui/_widgets/_mda_widget/__init__.py diff --git a/src/micromanager_gui/_widgets/_mda_widget/_mda_widget.py b/src/pymmcore_gui/_widgets/_mda_widget/_mda_widget.py similarity index 99% rename from src/micromanager_gui/_widgets/_mda_widget/_mda_widget.py rename to src/pymmcore_gui/_widgets/_mda_widget/_mda_widget.py index 25cdd1bf..da76eb1c 100644 --- a/src/micromanager_gui/_widgets/_mda_widget/_mda_widget.py +++ b/src/pymmcore_gui/_widgets/_mda_widget/_mda_widget.py @@ -14,7 +14,7 @@ from qtpy.QtWidgets import QBoxLayout, QWidget from useq import MDASequence -from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler +from pymmcore_gui._writers._tensorstore_zarr import _TensorStoreHandler from ._save_widget import ( OME_TIFF, diff --git a/src/micromanager_gui/_widgets/_mda_widget/_save_widget.py b/src/pymmcore_gui/_widgets/_mda_widget/_save_widget.py similarity index 100% rename from src/micromanager_gui/_widgets/_mda_widget/_save_widget.py rename to src/pymmcore_gui/_widgets/_mda_widget/_save_widget.py diff --git a/src/micromanager_gui/_widgets/_mm_console.py b/src/pymmcore_gui/_widgets/_mm_console.py similarity index 100% rename from src/micromanager_gui/_widgets/_mm_console.py rename to src/pymmcore_gui/_widgets/_mm_console.py diff --git a/src/micromanager_gui/_widgets/_snap_live_buttons.py b/src/pymmcore_gui/_widgets/_snap_live_buttons.py similarity index 100% rename from src/micromanager_gui/_widgets/_snap_live_buttons.py rename to src/pymmcore_gui/_widgets/_snap_live_buttons.py diff --git a/src/micromanager_gui/_widgets/_stage_control.py b/src/pymmcore_gui/_widgets/_stage_control.py similarity index 100% rename from src/micromanager_gui/_widgets/_stage_control.py rename to src/pymmcore_gui/_widgets/_stage_control.py diff --git a/src/micromanager_gui/_widgets/_viewers/__init__.py b/src/pymmcore_gui/_widgets/_viewers/__init__.py similarity index 100% rename from src/micromanager_gui/_widgets/_viewers/__init__.py rename to src/pymmcore_gui/_widgets/_viewers/__init__.py diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/__init__.py b/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/__init__.py similarity index 100% rename from src/micromanager_gui/_widgets/_viewers/_mda_viewer/__init__.py rename to src/pymmcore_gui/_widgets/_viewers/_mda_viewer/__init__.py diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py b/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py similarity index 98% rename from src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py rename to src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py index e4e07556..65451db0 100644 --- a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py +++ b/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py @@ -6,7 +6,7 @@ from ndv import DataWrapper from pymmcore_plus.mda.handlers import OMEZarrWriter, TensorStoreHandler -from micromanager_gui.readers import OMEZarrReader, TensorstoreZarrReader +from pymmcore_gui.readers import OMEZarrReader, TensorstoreZarrReader if TYPE_CHECKING: from pathlib import Path diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py b/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py similarity index 100% rename from src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py rename to src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py diff --git a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py b/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py similarity index 97% rename from src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py rename to src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py index 0e25d268..7bebf3d4 100644 --- a/src/micromanager_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py +++ b/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py @@ -8,7 +8,7 @@ from superqt import ensure_main_thread from useq import MDAEvent -from micromanager_gui.readers import OMEZarrReader, TensorstoreZarrReader +from pymmcore_gui.readers import OMEZarrReader, TensorstoreZarrReader from ._data_wrappers import MM5DWriterWrapper, MMTensorstoreWrapper from ._mda_save_button import MDASaveButton diff --git a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/__init__.py b/src/pymmcore_gui/_widgets/_viewers/_preview_viewer/__init__.py similarity index 100% rename from src/micromanager_gui/_widgets/_viewers/_preview_viewer/__init__.py rename to src/pymmcore_gui/_widgets/_viewers/_preview_viewer/__init__.py diff --git a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_save_button.py b/src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_save_button.py similarity index 96% rename from src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_save_button.py rename to src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_save_button.py index 55d37379..6e4236b3 100644 --- a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_save_button.py +++ b/src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_save_button.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from qtpy.QtWidgets import QWidget - from micromanager_gui._widgets._viewers import Preview + from pymmcore_gui._widgets._viewers import Preview class SaveButton(QPushButton): diff --git a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py b/src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py similarity index 98% rename from src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py rename to src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py index e35d97e1..b47dfdd4 100644 --- a/src/micromanager_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py +++ b/src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py @@ -9,7 +9,7 @@ from qtpy import QtCore from superqt.utils import ensure_main_thread -from micromanager_gui._widgets._snap_live_buttons import Live, Snap +from pymmcore_gui._widgets._snap_live_buttons import Live, Snap from ._preview_save_button import SaveButton diff --git a/src/micromanager_gui/_writers/__init__.py b/src/pymmcore_gui/_writers/__init__.py similarity index 100% rename from src/micromanager_gui/_writers/__init__.py rename to src/pymmcore_gui/_writers/__init__.py diff --git a/src/micromanager_gui/_writers/_tensorstore_zarr.py b/src/pymmcore_gui/_writers/_tensorstore_zarr.py similarity index 100% rename from src/micromanager_gui/_writers/_tensorstore_zarr.py rename to src/pymmcore_gui/_writers/_tensorstore_zarr.py diff --git a/src/micromanager_gui/py.typed b/src/pymmcore_gui/py.typed similarity index 100% rename from src/micromanager_gui/py.typed rename to src/pymmcore_gui/py.typed diff --git a/src/micromanager_gui/readers/__init__.py b/src/pymmcore_gui/readers/__init__.py similarity index 100% rename from src/micromanager_gui/readers/__init__.py rename to src/pymmcore_gui/readers/__init__.py diff --git a/src/micromanager_gui/readers/_ome_zarr_reader.py b/src/pymmcore_gui/readers/_ome_zarr_reader.py similarity index 100% rename from src/micromanager_gui/readers/_ome_zarr_reader.py rename to src/pymmcore_gui/readers/_ome_zarr_reader.py diff --git a/src/micromanager_gui/readers/_tensorstore_zarr_reader.py b/src/pymmcore_gui/readers/_tensorstore_zarr_reader.py similarity index 100% rename from src/micromanager_gui/readers/_tensorstore_zarr_reader.py rename to src/pymmcore_gui/readers/_tensorstore_zarr_reader.py diff --git a/test.ome.zarr/.zgroup b/test.ome.zarr/.zgroup new file mode 100644 index 00000000..3b7daf22 --- /dev/null +++ b/test.ome.zarr/.zgroup @@ -0,0 +1,3 @@ +{ + "zarr_format": 2 +} \ No newline at end of file diff --git a/tests/test_gui.py b/tests/test_gui.py index 6999a510..344d4600 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -2,9 +2,9 @@ from typing import TYPE_CHECKING -from micromanager_gui import MicroManagerGUI -from micromanager_gui._menubar._menubar import DOCKWIDGETS, WIDGETS -from micromanager_gui._widgets._viewers import MDAViewer +from pymmcore_gui import MicroManagerGUI +from pymmcore_gui._menubar._menubar import DOCKWIDGETS, WIDGETS +from pymmcore_gui._widgets._viewers import MDAViewer if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/tests/test_mda_viewer.py b/tests/test_mda_viewer.py index 6c3c344e..f6d5ef0e 100644 --- a/tests/test_mda_viewer.py +++ b/tests/test_mda_viewer.py @@ -14,15 +14,15 @@ from pymmcore_plus.metadata import SummaryMetaV1 from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY -from micromanager_gui import MicroManagerGUI -from micromanager_gui._widgets._mda_widget import MDAWidget -from micromanager_gui._widgets._mda_widget._save_widget import ( +from pymmcore_gui import MicroManagerGUI +from pymmcore_gui._widgets._mda_widget import MDAWidget +from pymmcore_gui._widgets._mda_widget._save_widget import ( OME_TIFF, OME_ZARR, TIFF_SEQ, ZARR_TESNSORSTORE, ) -from micromanager_gui._widgets._viewers import MDAViewer +from pymmcore_gui._widgets._viewers import MDAViewer if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/tests/test_readers_writers.py b/tests/test_readers_writers.py index d655dedc..2052d254 100644 --- a/tests/test_readers_writers.py +++ b/tests/test_readers_writers.py @@ -8,9 +8,9 @@ import useq from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY -from micromanager_gui._writers._tensorstore_zarr import _TensorStoreHandler -from micromanager_gui.readers._ome_zarr_reader import OMEZarrReader -from micromanager_gui.readers._tensorstore_zarr_reader import TensorstoreZarrReader +from pymmcore_gui._writers._tensorstore_zarr import _TensorStoreHandler +from pymmcore_gui.readers._ome_zarr_reader import OMEZarrReader +from pymmcore_gui.readers._tensorstore_zarr_reader import TensorstoreZarrReader if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/tests/test_save_widget.py b/tests/test_save_widget.py index bac5fff9..a3f87dc8 100644 --- a/tests/test_save_widget.py +++ b/tests/test_save_widget.py @@ -3,7 +3,7 @@ import pytest from pytestqt.qtbot import QtBot -from micromanager_gui._widgets._mda_widget._save_widget import ( +from pymmcore_gui._widgets._mda_widget._save_widget import ( DIRECTORY_WRITERS, FILE_NAME, OME_TIFF, diff --git a/tests/test_stage_widget.py b/tests/test_stage_widget.py index 5a959a03..8bc3ecd1 100644 --- a/tests/test_stage_widget.py +++ b/tests/test_stage_widget.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING -from micromanager_gui._widgets._stage_control import StagesControlWidget +from pymmcore_gui._widgets._stage_control import StagesControlWidget if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..3f31d41d --- /dev/null +++ b/uv.lock @@ -0,0 +1,1990 @@ +version = 1 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version < '3.11'", + "python_full_version == '3.11.*'", + "python_full_version == '3.12.*'", + "python_full_version >= '3.13'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321 }, +] + +[[package]] +name = "asciitree" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/6a/885bc91484e1aa8f618f6f0228d76d0e67000b0fdd6090673b777e311913/asciitree-0.3.3.tar.gz", hash = "sha256:4aa4b9b649f85e3fcb343363d97564aa1fb62e249677f2e18a96765145cc0f6e", size = 3951 } + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, +] + +[[package]] +name = "attrs" +version = "24.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 }, +] + +[[package]] +name = "certifi" +version = "2024.12.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/8b/825cc84cf13a28bfbcba7c416ec22bf85a9584971be15b21dd8300c65b7f/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6", size = 196363 }, + { url = "https://files.pythonhosted.org/packages/23/81/d7eef6a99e42c77f444fdd7bc894b0ceca6c3a95c51239e74a722039521c/charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b", size = 125639 }, + { url = "https://files.pythonhosted.org/packages/21/67/b4564d81f48042f520c948abac7079356e94b30cb8ffb22e747532cf469d/charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99", size = 120451 }, + { url = "https://files.pythonhosted.org/packages/c2/72/12a7f0943dd71fb5b4e7b55c41327ac0a1663046a868ee4d0d8e9c369b85/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca", size = 140041 }, + { url = "https://files.pythonhosted.org/packages/67/56/fa28c2c3e31217c4c52158537a2cf5d98a6c1e89d31faf476c89391cd16b/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d", size = 150333 }, + { url = "https://files.pythonhosted.org/packages/f9/d2/466a9be1f32d89eb1554cf84073a5ed9262047acee1ab39cbaefc19635d2/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7", size = 142921 }, + { url = "https://files.pythonhosted.org/packages/f8/01/344ec40cf5d85c1da3c1f57566c59e0c9b56bcc5566c08804a95a6cc8257/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3", size = 144785 }, + { url = "https://files.pythonhosted.org/packages/73/8b/2102692cb6d7e9f03b9a33a710e0164cadfce312872e3efc7cfe22ed26b4/charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907", size = 146631 }, + { url = "https://files.pythonhosted.org/packages/d8/96/cc2c1b5d994119ce9f088a9a0c3ebd489d360a2eb058e2c8049f27092847/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b", size = 140867 }, + { url = "https://files.pythonhosted.org/packages/c9/27/cde291783715b8ec30a61c810d0120411844bc4c23b50189b81188b273db/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912", size = 149273 }, + { url = "https://files.pythonhosted.org/packages/3a/a4/8633b0fc1a2d1834d5393dafecce4a1cc56727bfd82b4dc18fc92f0d3cc3/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95", size = 152437 }, + { url = "https://files.pythonhosted.org/packages/64/ea/69af161062166b5975ccbb0961fd2384853190c70786f288684490913bf5/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e", size = 150087 }, + { url = "https://files.pythonhosted.org/packages/3b/fd/e60a9d9fd967f4ad5a92810138192f825d77b4fa2a557990fd575a47695b/charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe", size = 145142 }, + { url = "https://files.pythonhosted.org/packages/6d/02/8cb0988a1e49ac9ce2eed1e07b77ff118f2923e9ebd0ede41ba85f2dcb04/charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc", size = 94701 }, + { url = "https://files.pythonhosted.org/packages/d6/20/f1d4670a8a723c46be695dff449d86d6092916f9e99c53051954ee33a1bc/charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749", size = 102191 }, + { url = "https://files.pythonhosted.org/packages/9c/61/73589dcc7a719582bf56aae309b6103d2762b526bffe189d635a7fcfd998/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", size = 193339 }, + { url = "https://files.pythonhosted.org/packages/77/d5/8c982d58144de49f59571f940e329ad6e8615e1e82ef84584c5eeb5e1d72/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", size = 124366 }, + { url = "https://files.pythonhosted.org/packages/bf/19/411a64f01ee971bed3231111b69eb56f9331a769072de479eae7de52296d/charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", size = 118874 }, + { url = "https://files.pythonhosted.org/packages/4c/92/97509850f0d00e9f14a46bc751daabd0ad7765cff29cdfb66c68b6dad57f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", size = 138243 }, + { url = "https://files.pythonhosted.org/packages/e2/29/d227805bff72ed6d6cb1ce08eec707f7cfbd9868044893617eb331f16295/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", size = 148676 }, + { url = "https://files.pythonhosted.org/packages/13/bc/87c2c9f2c144bedfa62f894c3007cd4530ba4b5351acb10dc786428a50f0/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", size = 141289 }, + { url = "https://files.pythonhosted.org/packages/eb/5b/6f10bad0f6461fa272bfbbdf5d0023b5fb9bc6217c92bf068fa5a99820f5/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", size = 142585 }, + { url = "https://files.pythonhosted.org/packages/3b/a0/a68980ab8a1f45a36d9745d35049c1af57d27255eff8c907e3add84cf68f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", size = 144408 }, + { url = "https://files.pythonhosted.org/packages/d7/a1/493919799446464ed0299c8eef3c3fad0daf1c3cd48bff9263c731b0d9e2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", size = 139076 }, + { url = "https://files.pythonhosted.org/packages/fb/9d/9c13753a5a6e0db4a0a6edb1cef7aee39859177b64e1a1e748a6e3ba62c2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", size = 146874 }, + { url = "https://files.pythonhosted.org/packages/75/d2/0ab54463d3410709c09266dfb416d032a08f97fd7d60e94b8c6ef54ae14b/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", size = 150871 }, + { url = "https://files.pythonhosted.org/packages/8d/c9/27e41d481557be53d51e60750b85aa40eaf52b841946b3cdeff363105737/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", size = 148546 }, + { url = "https://files.pythonhosted.org/packages/ee/44/4f62042ca8cdc0cabf87c0fc00ae27cd8b53ab68be3605ba6d071f742ad3/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", size = 143048 }, + { url = "https://files.pythonhosted.org/packages/01/f8/38842422988b795220eb8038745d27a675ce066e2ada79516c118f291f07/charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", size = 94389 }, + { url = "https://files.pythonhosted.org/packages/0b/6e/b13bd47fa9023b3699e94abf565b5a2f0b0be6e9ddac9812182596ee62e4/charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", size = 101752 }, + { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 }, + { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 }, + { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 }, + { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 }, + { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 }, + { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 }, + { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 }, + { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 }, + { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 }, + { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 }, + { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 }, + { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 }, + { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 }, + { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 }, + { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 }, + { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, + { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, + { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, + { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, + { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, + { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, + { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, + { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, + { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, + { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, + { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, + { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, + { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, + { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, + { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, + { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, +] + +[[package]] +name = "click" +version = "8.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, +] + +[[package]] +name = "cmap" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/50/d1286f03f8afd169800e79310c89014ba4f281b008c1f71cb62d1d909204/cmap-0.4.0.tar.gz", hash = "sha256:6d93e8d01502f507ca8a83b76156d52b26f737f488a89154498429cad57ac1fb", size = 891824 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/66/dafbc534cec8b4a16bd2d19e37a83ccffb7c68cc73bc684f2dd3777bed5f/cmap-0.4.0-py3-none-any.whl", hash = "sha256:e54d87aa13181ad6bd2372d2930b8b0851776619077d9989fe42e3255f938f17", size = 937840 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "comm" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/a8/fb783cb0abe2b5fded9f55e5703015cdf1c9c85b3669087c538dd15a6a86/comm-0.2.2.tar.gz", hash = "sha256:3fd7a84065306e07bea1773df6eb8282de51ba82f77c72f9c85716ab11fe980e", size = 6210 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3", size = 7180 }, +] + +[[package]] +name = "coverage" +version = "7.6.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/d2/c25011f4d036cf7e8acbbee07a8e09e9018390aee25ba085596c4b83d510/coverage-7.6.9.tar.gz", hash = "sha256:4a8d8977b0c6ef5aeadcb644da9e69ae0dcfe66ec7f368c89c72e058bd71164d", size = 801710 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/f3/f830fb53bf7e4f1d5542756f61d9b740352a188f43854aab9409c8cdeb18/coverage-7.6.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:85d9636f72e8991a1706b2b55b06c27545448baf9f6dbf51c4004609aacd7dcb", size = 207024 }, + { url = "https://files.pythonhosted.org/packages/4e/e3/ea5632a3a6efd00ab0a791adc0f3e48512097a757ee7dcbee5505f57bafa/coverage-7.6.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:608a7fd78c67bee8936378299a6cb9f5149bb80238c7a566fc3e6717a4e68710", size = 207463 }, + { url = "https://files.pythonhosted.org/packages/e4/ae/18ff8b5580e27e62ebcc888082aa47694c2772782ea7011ddf58e377e98f/coverage-7.6.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96d636c77af18b5cb664ddf12dab9b15a0cfe9c0bde715da38698c8cea748bfa", size = 235902 }, + { url = "https://files.pythonhosted.org/packages/6a/52/57030a8d15ab935624d298360f0a6704885578e39f7b4f68569e59f5902d/coverage-7.6.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75cded8a3cff93da9edc31446872d2997e327921d8eed86641efafd350e1df1", size = 233806 }, + { url = "https://files.pythonhosted.org/packages/d0/c5/4466602195ecaced298d55af1e29abceb812addabefd5bd9116a204f7bab/coverage-7.6.9-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7b15f589593110ae767ce997775d645b47e5cbbf54fd322f8ebea6277466cec", size = 234966 }, + { url = "https://files.pythonhosted.org/packages/b0/1c/55552c3009b7bf96732e36548596ade771c87f89cf1f5a8e3975b33539b5/coverage-7.6.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:44349150f6811b44b25574839b39ae35291f6496eb795b7366fef3bd3cf112d3", size = 234029 }, + { url = "https://files.pythonhosted.org/packages/bb/7d/da3dca6878701182ea42c51df47a47c80eaef2a76f5aa3e891dc2a8cce3f/coverage-7.6.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d891c136b5b310d0e702e186d70cd16d1119ea8927347045124cb286b29297e5", size = 232494 }, + { url = "https://files.pythonhosted.org/packages/28/cc/39de85ac1d5652bc34ff2bee39ae251b1fdcaae53fab4b44cab75a432bc0/coverage-7.6.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:db1dab894cc139f67822a92910466531de5ea6034ddfd2b11c0d4c6257168073", size = 233611 }, + { url = "https://files.pythonhosted.org/packages/d1/2b/7eb011a9378911088708f121825a71134d0c15fac96972a0ae7a8f5a4049/coverage-7.6.9-cp310-cp310-win32.whl", hash = "sha256:41ff7b0da5af71a51b53f501a3bac65fb0ec311ebed1632e58fc6107f03b9198", size = 209712 }, + { url = "https://files.pythonhosted.org/packages/5b/35/c3f40a2269b416db34ce1dedf682a7132c26f857e33596830fa4deebabf9/coverage-7.6.9-cp310-cp310-win_amd64.whl", hash = "sha256:35371f8438028fdccfaf3570b31d98e8d9eda8bb1d6ab9473f5a390969e98717", size = 210553 }, + { url = "https://files.pythonhosted.org/packages/b1/91/b3dc2f7f38b5cca1236ab6bbb03e84046dd887707b4ec1db2baa47493b3b/coverage-7.6.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:932fc826442132dde42ee52cf66d941f581c685a6313feebed358411238f60f9", size = 207133 }, + { url = "https://files.pythonhosted.org/packages/0d/2b/53fd6cb34d443429a92b3ec737f4953627e38b3bee2a67a3c03425ba8573/coverage-7.6.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:085161be5f3b30fd9b3e7b9a8c301f935c8313dcf928a07b116324abea2c1c2c", size = 207577 }, + { url = "https://files.pythonhosted.org/packages/74/f2/68edb1e6826f980a124f21ea5be0d324180bf11de6fd1defcf9604f76df0/coverage-7.6.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc660a77e1c2bf24ddbce969af9447a9474790160cfb23de6be4fa88e3951c7", size = 239524 }, + { url = "https://files.pythonhosted.org/packages/d3/83/8fec0ee68c2c4a5ab5f0f8527277f84ed6f2bd1310ae8a19d0c5532253ab/coverage-7.6.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c69e42c892c018cd3c8d90da61d845f50a8243062b19d228189b0224150018a9", size = 236925 }, + { url = "https://files.pythonhosted.org/packages/8b/20/8f50e7c7ad271144afbc2c1c6ec5541a8c81773f59352f8db544cad1a0ec/coverage-7.6.9-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0824a28ec542a0be22f60c6ac36d679e0e262e5353203bea81d44ee81fe9c6d4", size = 238792 }, + { url = "https://files.pythonhosted.org/packages/6f/62/4ac2e5ad9e7a5c9ec351f38947528e11541f1f00e8a0cdce56f1ba7ae301/coverage-7.6.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4401ae5fc52ad8d26d2a5d8a7428b0f0c72431683f8e63e42e70606374c311a1", size = 237682 }, + { url = "https://files.pythonhosted.org/packages/58/2f/9d2203f012f3b0533c73336c74134b608742be1ce475a5c72012573cfbb4/coverage-7.6.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98caba4476a6c8d59ec1eb00c7dd862ba9beca34085642d46ed503cc2d440d4b", size = 236310 }, + { url = "https://files.pythonhosted.org/packages/33/6d/31f6ab0b4f0f781636075f757eb02141ea1b34466d9d1526dbc586ed7078/coverage-7.6.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ee5defd1733fd6ec08b168bd4f5387d5b322f45ca9e0e6c817ea6c4cd36313e3", size = 237096 }, + { url = "https://files.pythonhosted.org/packages/7d/fb/e14c38adebbda9ed8b5f7f8e03340ac05d68d27b24397f8d47478927a333/coverage-7.6.9-cp311-cp311-win32.whl", hash = "sha256:f2d1ec60d6d256bdf298cb86b78dd715980828f50c46701abc3b0a2b3f8a0dc0", size = 209682 }, + { url = "https://files.pythonhosted.org/packages/a4/11/a782af39b019066af83fdc0e8825faaccbe9d7b19a803ddb753114b429cc/coverage-7.6.9-cp311-cp311-win_amd64.whl", hash = "sha256:0d59fd927b1f04de57a2ba0137166d31c1a6dd9e764ad4af552912d70428c92b", size = 210542 }, + { url = "https://files.pythonhosted.org/packages/60/52/b16af8989a2daf0f80a88522bd8e8eed90b5fcbdecf02a6888f3e80f6ba7/coverage-7.6.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:99e266ae0b5d15f1ca8d278a668df6f51cc4b854513daab5cae695ed7b721cf8", size = 207325 }, + { url = "https://files.pythonhosted.org/packages/0f/79/6b7826fca8846c1216a113227b9f114ac3e6eacf168b4adcad0cb974aaca/coverage-7.6.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9901d36492009a0a9b94b20e52ebfc8453bf49bb2b27bca2c9706f8b4f5a554a", size = 207563 }, + { url = "https://files.pythonhosted.org/packages/a7/07/0bc73da0ccaf45d0d64ef86d33b7d7fdeef84b4c44bf6b85fb12c215c5a6/coverage-7.6.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abd3e72dd5b97e3af4246cdada7738ef0e608168de952b837b8dd7e90341f015", size = 240580 }, + { url = "https://files.pythonhosted.org/packages/71/8a/9761f409910961647d892454687cedbaccb99aae828f49486734a82ede6e/coverage-7.6.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff74026a461eb0660366fb01c650c1d00f833a086b336bdad7ab00cc952072b3", size = 237613 }, + { url = "https://files.pythonhosted.org/packages/8b/10/ee7d696a17ac94f32f2dbda1e17e730bf798ae9931aec1fc01c1944cd4de/coverage-7.6.9-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65dad5a248823a4996724a88eb51d4b31587aa7aa428562dbe459c684e5787ae", size = 239684 }, + { url = "https://files.pythonhosted.org/packages/16/60/aa1066040d3c52fff051243c2d6ccda264da72dc6d199d047624d395b2b2/coverage-7.6.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22be16571504c9ccea919fcedb459d5ab20d41172056206eb2994e2ff06118a4", size = 239112 }, + { url = "https://files.pythonhosted.org/packages/4e/e5/69f35344c6f932ba9028bf168d14a79fedb0dd4849b796d43c81ce75a3c9/coverage-7.6.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f957943bc718b87144ecaee70762bc2bc3f1a7a53c7b861103546d3a403f0a6", size = 237428 }, + { url = "https://files.pythonhosted.org/packages/32/20/adc895523c4a28f63441b8ac645abd74f9bdd499d2d175bef5b41fc7f92d/coverage-7.6.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae1387db4aecb1f485fb70a6c0148c6cdaebb6038f1d40089b1fc84a5db556f", size = 239098 }, + { url = "https://files.pythonhosted.org/packages/a9/a6/e0e74230c9bb3549ec8ffc137cfd16ea5d56e993d6bffed2218bff6187e3/coverage-7.6.9-cp312-cp312-win32.whl", hash = "sha256:1a330812d9cc7ac2182586f6d41b4d0fadf9be9049f350e0efb275c8ee8eb692", size = 209940 }, + { url = "https://files.pythonhosted.org/packages/3e/18/cb5b88349d4aa2f41ec78d65f92ea32572b30b3f55bc2b70e87578b8f434/coverage-7.6.9-cp312-cp312-win_amd64.whl", hash = "sha256:b12c6b18269ca471eedd41c1b6a1065b2f7827508edb9a7ed5555e9a56dcfc97", size = 210726 }, + { url = "https://files.pythonhosted.org/packages/35/26/9abab6539d2191dbda2ce8c97b67d74cbfc966cc5b25abb880ffc7c459bc/coverage-7.6.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:899b8cd4781c400454f2f64f7776a5d87bbd7b3e7f7bda0cb18f857bb1334664", size = 207356 }, + { url = "https://files.pythonhosted.org/packages/44/da/d49f19402240c93453f606e660a6676a2a1fbbaa6870cc23207790aa9697/coverage-7.6.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61f70dc68bd36810972e55bbbe83674ea073dd1dcc121040a08cdf3416c5349c", size = 207614 }, + { url = "https://files.pythonhosted.org/packages/da/e6/93bb9bf85497816082ec8da6124c25efa2052bd4c887dd3b317b91990c9e/coverage-7.6.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a289d23d4c46f1a82d5db4abeb40b9b5be91731ee19a379d15790e53031c014", size = 240129 }, + { url = "https://files.pythonhosted.org/packages/df/65/6a824b9406fe066835c1274a9949e06f084d3e605eb1a602727a27ec2fe3/coverage-7.6.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e216d8044a356fc0337c7a2a0536d6de07888d7bcda76febcb8adc50bdbbd00", size = 237276 }, + { url = "https://files.pythonhosted.org/packages/9f/79/6c7a800913a9dd23ac8c8da133ebb556771a5a3d4df36b46767b1baffd35/coverage-7.6.9-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c026eb44f744acaa2bda7493dad903aa5bf5fc4f2554293a798d5606710055d", size = 239267 }, + { url = "https://files.pythonhosted.org/packages/57/e7/834d530293fdc8a63ba8ff70033d5182022e569eceb9aec7fc716b678a39/coverage-7.6.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e77363e8425325384f9d49272c54045bbed2f478e9dd698dbc65dbc37860eb0a", size = 238887 }, + { url = "https://files.pythonhosted.org/packages/15/05/ec9d6080852984f7163c96984444e7cd98b338fd045b191064f943ee1c08/coverage-7.6.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:777abfab476cf83b5177b84d7486497e034eb9eaea0d746ce0c1268c71652077", size = 236970 }, + { url = "https://files.pythonhosted.org/packages/0a/d8/775937670b93156aec29f694ce37f56214ed7597e1a75b4083ee4c32121c/coverage-7.6.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:447af20e25fdbe16f26e84eb714ba21d98868705cb138252d28bc400381f6ffb", size = 238831 }, + { url = "https://files.pythonhosted.org/packages/f4/58/88551cb7fdd5ec98cb6044e8814e38583436b14040a5ece15349c44c8f7c/coverage-7.6.9-cp313-cp313-win32.whl", hash = "sha256:d872ec5aeb086cbea771c573600d47944eea2dcba8be5f3ee649bfe3cb8dc9ba", size = 210000 }, + { url = "https://files.pythonhosted.org/packages/b7/12/cfbf49b95120872785ff8d56ab1c7fe3970a65e35010c311d7dd35c5fd00/coverage-7.6.9-cp313-cp313-win_amd64.whl", hash = "sha256:fd1213c86e48dfdc5a0cc676551db467495a95a662d2396ecd58e719191446e1", size = 210753 }, + { url = "https://files.pythonhosted.org/packages/7c/68/c1cb31445599b04bde21cbbaa6d21b47c5823cdfef99eae470dfce49c35a/coverage-7.6.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9e7484d286cd5a43744e5f47b0b3fb457865baf07bafc6bee91896364e1419", size = 208091 }, + { url = "https://files.pythonhosted.org/packages/11/73/84b02c6b19c4a11eb2d5b5eabe926fb26c21c080e0852f5e5a4f01165f9e/coverage-7.6.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1cf0872ee455c03e5674b5bca5e3e68e159379c1af0903e89f5eba9ccc3a", size = 208369 }, + { url = "https://files.pythonhosted.org/packages/de/e0/ae5d878b72ff26df2e994a5c5b1c1f6a7507d976b23beecb1ed4c85411ef/coverage-7.6.9-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d10e07aa2b91835d6abec555ec8b2733347956991901eea6ffac295f83a30e4", size = 251089 }, + { url = "https://files.pythonhosted.org/packages/ab/9c/0aaac011aef95a93ef3cb2fba3fde30bc7e68a6635199ed469b1f5ea355a/coverage-7.6.9-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13a9e2d3ee855db3dd6ea1ba5203316a1b1fd8eaeffc37c5b54987e61e4194ae", size = 246806 }, + { url = "https://files.pythonhosted.org/packages/f8/19/4d5d3ae66938a7dcb2f58cef3fa5386f838f469575b0bb568c8cc9e3a33d/coverage-7.6.9-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c38bf15a40ccf5619fa2fe8f26106c7e8e080d7760aeccb3722664c8656b030", size = 249164 }, + { url = "https://files.pythonhosted.org/packages/b3/0b/4ee8a7821f682af9ad440ae3c1e379da89a998883271f088102d7ca2473d/coverage-7.6.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d5275455b3e4627c8e7154feaf7ee0743c2e7af82f6e3b561967b1cca755a0be", size = 248642 }, + { url = "https://files.pythonhosted.org/packages/8a/12/36ff1d52be18a16b4700f561852e7afd8df56363a5edcfb04cf26a0e19e0/coverage-7.6.9-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8f8770dfc6e2c6a2d4569f411015c8d751c980d17a14b0530da2d7f27ffdd88e", size = 246516 }, + { url = "https://files.pythonhosted.org/packages/43/d0/8e258f6c3a527c1655602f4f576215e055ac704de2d101710a71a2affac2/coverage-7.6.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8d2dfa71665a29b153a9681edb1c8d9c1ea50dfc2375fb4dac99ea7e21a0bcd9", size = 247783 }, + { url = "https://files.pythonhosted.org/packages/a9/0d/1e4a48d289429d38aae3babdfcadbf35ca36bdcf3efc8f09b550a845bdb5/coverage-7.6.9-cp313-cp313t-win32.whl", hash = "sha256:5e6b86b5847a016d0fbd31ffe1001b63355ed309651851295315031ea7eb5a9b", size = 210646 }, + { url = "https://files.pythonhosted.org/packages/26/74/b0729f196f328ac55e42b1e22ec2f16d8bcafe4b8158a26ec9f1cdd1d93e/coverage-7.6.9-cp313-cp313t-win_amd64.whl", hash = "sha256:97ddc94d46088304772d21b060041c97fc16bdda13c6c7f9d8fcd8d5ae0d8611", size = 211815 }, + { url = "https://files.pythonhosted.org/packages/15/0e/4ac9035ee2ee08d2b703fdad2d84283ec0bad3b46eb4ad6affb150174cb6/coverage-7.6.9-pp39.pp310-none-any.whl", hash = "sha256:f3ca78518bc6bc92828cd11867b121891d75cae4ea9e908d72030609b996db1b", size = 199270 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "debugpy" +version = "1.8.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/e7/666f4c9b0e24796af50aadc28d36d21c2e01e831a934535f956e09b3650c/debugpy-1.8.11.tar.gz", hash = "sha256:6ad2688b69235c43b020e04fecccdf6a96c8943ca9c2fb340b8adc103c655e57", size = 1640124 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/e6/4cf7422eaa591b4c7d6a9fde224095dac25283fdd99d90164f28714242b0/debugpy-1.8.11-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:2b26fefc4e31ff85593d68b9022e35e8925714a10ab4858fb1b577a8a48cb8cd", size = 2075100 }, + { url = "https://files.pythonhosted.org/packages/83/3a/e163de1df5995d95760a4d748b02fbefb1c1bf19e915b664017c40435dbf/debugpy-1.8.11-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61bc8b3b265e6949855300e84dc93d02d7a3a637f2aec6d382afd4ceb9120c9f", size = 3559724 }, + { url = "https://files.pythonhosted.org/packages/27/6c/327e19fd1bf428a1efe1a6f97b306689c54c2cebcf871b66674ead718756/debugpy-1.8.11-cp310-cp310-win32.whl", hash = "sha256:c928bbf47f65288574b78518449edaa46c82572d340e2750889bbf8cd92f3737", size = 5178068 }, + { url = "https://files.pythonhosted.org/packages/49/80/359ff8aa388f0bd4a48f0fa9ce3606396d576657ac149c6fba3cc7de8adb/debugpy-1.8.11-cp310-cp310-win_amd64.whl", hash = "sha256:8da1db4ca4f22583e834dcabdc7832e56fe16275253ee53ba66627b86e304da1", size = 5210109 }, + { url = "https://files.pythonhosted.org/packages/7c/58/8e3f7ec86c1b7985a232667b5df8f3b1b1c8401028d8f4d75e025c9556cd/debugpy-1.8.11-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:85de8474ad53ad546ff1c7c7c89230db215b9b8a02754d41cb5a76f70d0be296", size = 2173656 }, + { url = "https://files.pythonhosted.org/packages/d2/03/95738a68ade2358e5a4d63a2fd8e7ed9ad911001cfabbbb33a7f81343945/debugpy-1.8.11-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ffc382e4afa4aee367bf413f55ed17bd91b191dcaf979890af239dda435f2a1", size = 3132464 }, + { url = "https://files.pythonhosted.org/packages/ca/f4/18204891ab67300950615a6ad09b9de236203a9138f52b3b596fa17628ca/debugpy-1.8.11-cp311-cp311-win32.whl", hash = "sha256:40499a9979c55f72f4eb2fc38695419546b62594f8af194b879d2a18439c97a9", size = 5103637 }, + { url = "https://files.pythonhosted.org/packages/3b/90/3775e301cfa573b51eb8a108285681f43f5441dc4c3916feed9f386ef861/debugpy-1.8.11-cp311-cp311-win_amd64.whl", hash = "sha256:987bce16e86efa86f747d5151c54e91b3c1e36acc03ce1ddb50f9d09d16ded0e", size = 5127862 }, + { url = "https://files.pythonhosted.org/packages/c6/ae/2cf26f3111e9d94384d9c01e9d6170188b0aeda15b60a4ac6457f7c8a26f/debugpy-1.8.11-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:84e511a7545d11683d32cdb8f809ef63fc17ea2a00455cc62d0a4dbb4ed1c308", size = 2498756 }, + { url = "https://files.pythonhosted.org/packages/b0/16/ec551789d547541a46831a19aa15c147741133da188e7e6acf77510545a7/debugpy-1.8.11-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce291a5aca4985d82875d6779f61375e959208cdf09fcec40001e65fb0a54768", size = 4219136 }, + { url = "https://files.pythonhosted.org/packages/72/6f/b2b3ce673c55f882d27a6eb04a5f0c68bcad6b742ac08a86d8392ae58030/debugpy-1.8.11-cp312-cp312-win32.whl", hash = "sha256:28e45b3f827d3bf2592f3cf7ae63282e859f3259db44ed2b129093ca0ac7940b", size = 5224440 }, + { url = "https://files.pythonhosted.org/packages/77/09/b1f05be802c1caef5b3efc042fc6a7cadd13d8118b072afd04a9b9e91e06/debugpy-1.8.11-cp312-cp312-win_amd64.whl", hash = "sha256:44b1b8e6253bceada11f714acf4309ffb98bfa9ac55e4fce14f9e5d4484287a1", size = 5264578 }, + { url = "https://files.pythonhosted.org/packages/2e/66/931dc2479aa8fbf362dc6dcee707d895a84b0b2d7b64020135f20b8db1ed/debugpy-1.8.11-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:8988f7163e4381b0da7696f37eec7aca19deb02e500245df68a7159739bbd0d3", size = 2483651 }, + { url = "https://files.pythonhosted.org/packages/10/07/6c171d0fe6b8d237e35598b742f20ba062511b3a4631938cc78eefbbf847/debugpy-1.8.11-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c1f6a173d1140e557347419767d2b14ac1c9cd847e0b4c5444c7f3144697e4e", size = 4213770 }, + { url = "https://files.pythonhosted.org/packages/89/f1/0711da6ac250d4fe3bf7b3e9b14b4a86e82a98b7825075c07e19bab8da3d/debugpy-1.8.11-cp313-cp313-win32.whl", hash = "sha256:bb3b15e25891f38da3ca0740271e63ab9db61f41d4d8541745cfc1824252cb28", size = 5223911 }, + { url = "https://files.pythonhosted.org/packages/56/98/5e27fa39050749ed460025bcd0034a0a5e78a580a14079b164cc3abdeb98/debugpy-1.8.11-cp313-cp313-win_amd64.whl", hash = "sha256:d8768edcbeb34da9e11bcb8b5c2e0958d25218df7a6e56adf415ef262cd7b6d1", size = 5264166 }, + { url = "https://files.pythonhosted.org/packages/77/0a/d29a5aacf47b4383ed569b8478c02d59ee3a01ad91224d2cff8562410e43/debugpy-1.8.11-py2.py3-none-any.whl", hash = "sha256:0e22f846f4211383e6a416d04b4c13ed174d24cc5d43f5fd52e7821d0ebc8920", size = 5226874 }, +] + +[[package]] +name = "decorator" +version = "5.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/0c/8d907af351aa16b42caae42f9d6aa37b900c67308052d10fdce809f8d952/decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", size = 35016 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186", size = 9073 }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, +] + +[[package]] +name = "executing" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/7d45f492c2c4a0e8e0fad57d081a7c8a0286cdd86372b070cca1ec0caa1e/executing-2.1.0.tar.gz", hash = "sha256:8ea27ddd260da8150fa5a708269c4a10e76161e2496ec3e587da9e3c0fe4b9ab", size = 977485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/fd/afcd0496feca3276f509df3dbd5dae726fcc756f1a08d9e25abe1733f962/executing-2.1.0-py2.py3-none-any.whl", hash = "sha256:8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf", size = 25805 }, +] + +[[package]] +name = "fancycompleter" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyreadline", marker = "platform_system == 'Windows'" }, + { name = "pyrepl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/649d135442d8ecf8af5c7e235550c628056423c96c4bc6787348bdae9248/fancycompleter-0.9.1.tar.gz", hash = "sha256:09e0feb8ae242abdfd7ef2ba55069a46f011814a80fe5476be48f51b00247272", size = 10866 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/ef/c08926112034d017633f693d3afc8343393a035134a29dfc12dcd71b0375/fancycompleter-0.9.1-py3-none-any.whl", hash = "sha256:dd076bca7d9d524cc7f25ec8f35ef95388ffef9ef46def4d3d25e9b044ad7080", size = 9681 }, +] + +[[package]] +name = "fasteners" +version = "0.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/d4/e834d929be54bfadb1f3e3b931c38e956aaa3b235a46a3c764c26c774902/fasteners-0.19.tar.gz", hash = "sha256:b4f37c3ac52d8a445af3a66bce57b33b5e90b97c696b7b984f530cf8f0ded09c", size = 24832 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/bf/fd60001b3abc5222d8eaa4a204cd8c0ae78e75adc688f33ce4bf25b7fafa/fasteners-0.19-py3-none-any.whl", hash = "sha256:758819cb5d94cdedf4e836988b74de396ceacb8e2794d21f82d131fd9ee77237", size = 18679 }, +] + +[[package]] +name = "filelock" +version = "3.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, +] + +[[package]] +name = "flexcache" +version = "0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/b0/8a21e330561c65653d010ef112bf38f60890051d244ede197ddaa08e50c1/flexcache-0.3.tar.gz", hash = "sha256:18743bd5a0621bfe2cf8d519e4c3bfdf57a269c15d1ced3fb4b64e0ff4600656", size = 15816 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/cd/c883e1a7c447479d6e13985565080e3fea88ab5a107c21684c813dba1875/flexcache-0.3-py3-none-any.whl", hash = "sha256:d43c9fea82336af6e0115e308d9d33a185390b8346a017564611f1466dcd2e32", size = 13263 }, +] + +[[package]] +name = "flexparser" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/99/b4de7e39e8eaf8207ba1a8fa2241dd98b2ba72ae6e16960d8351736d8702/flexparser-0.4.tar.gz", hash = "sha256:266d98905595be2ccc5da964fe0a2c3526fbbffdc45b65b3146d75db992ef6b2", size = 31799 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/5e/3be305568fe5f34448807976dc82fc151d76c3e0e03958f34770286278c1/flexparser-0.4-py3-none-any.whl", hash = "sha256:3738b456192dcb3e15620f324c447721023c0293f6af9955b481e91d00179846", size = 27625 }, +] + +[[package]] +name = "fonticon-materialdesignicons6" +version = "6.9.96" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/17/5ccdaaf03de6527b9cb086b63fc12f0befb0e1ed41caa7f042394b8bba0a/fonticon_materialdesignicons6-6.9.96.tar.gz", hash = "sha256:b382a662520c095e513e51b8586cb71f9a9ab5f56d6df9fba89f334e0e61b297", size = 609869 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/7d/dbb0ba9372893ec0316bf8c630b9de2c8a5e2d0cc5d65c6884f13075f3f1/fonticon_materialdesignicons6-6.9.96-py3-none-any.whl", hash = "sha256:313ea02b68bcc04b604c4172a2fc93b24c799216b9b210dd7d2f8135cc4fa3b4", size = 608618 }, +] + +[[package]] +name = "freetype-py" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/9c/61ba17f846b922c2d6d101cc886b0e8fb597c109cedfcb39b8c5d2304b54/freetype-py-2.5.1.zip", hash = "sha256:cfe2686a174d0dd3d71a9d8ee9bf6a2c23f5872385cf8ce9f24af83d076e2fbd", size = 851738 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/a8/258dd138ebe60c79cd8cfaa6d021599208a33f0175a5e29b01f60c9ab2c7/freetype_py-2.5.1-py3-none-macosx_10_9_universal2.whl", hash = "sha256:d01ded2557694f06aa0413f3400c0c0b2b5ebcaabeef7aaf3d756be44f51e90b", size = 1747885 }, + { url = "https://files.pythonhosted.org/packages/a2/93/280ad06dc944e40789b0a641492321a2792db82edda485369cbc59d14366/freetype_py-2.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d2f6b3d68496797da23204b3b9c4e77e67559c80390fc0dc8b3f454ae1cd819", size = 1051055 }, + { url = "https://files.pythonhosted.org/packages/b6/36/853cad240ec63e21a37a512ee19c896b655ce1772d803a3dd80fccfe63fe/freetype_py-2.5.1-py3-none-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:289b443547e03a4f85302e3ac91376838e0d11636050166662a4f75e3087ed0b", size = 1043856 }, + { url = "https://files.pythonhosted.org/packages/93/6f/fcc1789e42b8c6617c3112196d68e87bfe7d957d80812d3c24d639782dcb/freetype_py-2.5.1-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:cd3bfdbb7e1a84818cfbc8025fca3096f4f2afcd5d4641184bf0a3a2e6f97bbf", size = 1108180 }, + { url = "https://files.pythonhosted.org/packages/2a/1b/161d3a6244b8a820aef188e4397a750d4a8196316809576d015f26594296/freetype_py-2.5.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3c1aefc4f0d5b7425f014daccc5fdc7c6f914fb7d6a695cc684f1c09cd8c1660", size = 1106792 }, + { url = "https://files.pythonhosted.org/packages/93/6e/bd7fbfacca077bc6f34f1a1109800a2c41ab50f4704d3a0507ba41009915/freetype_py-2.5.1-py3-none-win_amd64.whl", hash = "sha256:0b7f8e0342779f65ca13ef8bc103938366fecade23e6bb37cb671c2b8ad7f124", size = 814608 }, +] + +[[package]] +name = "hsluv" +version = "5.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/81/af16607fa045724e515579d312577261b436f36f419e7c677e7e88fcc943/hsluv-5.0.4.tar.gz", hash = "sha256:2281f946427a882010042844a38c7bbe9e0d0aaf9d46babe46366ed6f169b72e", size = 543090 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/36/5bddefea3d7adf22a64f9aa9701492f8a9fe6948223f5cf2602c22ec9be7/hsluv-5.0.4-py2.py3-none-any.whl", hash = "sha256:0138bd10038e2ee1b13eecae9a7d49d4ec8c320b1d7eb4f860832c792e3e4567", size = 5252 }, +] + +[[package]] +name = "identify" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/5f/05f0d167be94585d502b4adf8c7af31f1dc0b1c7e14f9938a88fdbbcf4a7/identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02", size = 99179 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/f5/09644a3ad803fae9eca8efa17e1f2aef380c7f0b02f7ec4e8d446e51d64a/identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd", size = 99049 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "ipykernel" +version = "6.29.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "platform_system == 'Darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/67594cb0c7055dc50814b21731c22a601101ea3b1b50a9a1b090e11f5d0f/ipykernel-6.29.5.tar.gz", hash = "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215", size = 163367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/5c/368ae6c01c7628438358e6d337c19b05425727fbb221d2a3c4303c372f42/ipykernel-6.29.5-py3-none-any.whl", hash = "sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5", size = 117173 }, +] + +[[package]] +name = "ipython" +version = "8.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/8b/710af065ab8ed05649afa5bd1e07401637c9ec9fb7cfda9eac7e91e9fbd4/ipython-8.30.0.tar.gz", hash = "sha256:cb0a405a306d2995a5cbb9901894d240784a9f341394c6ba3f4fe8c6eb89ff6e", size = 5592205 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/f3/1332ba2f682b07b304ad34cad2f003adcfeb349486103f4b632335074a7c/ipython-8.30.0-py3-none-any.whl", hash = "sha256:85ec56a7e20f6c38fce7727dcca699ae4ffc85985aa7b23635a8008f918ae321", size = 820765 }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, +] + +[[package]] +name = "jupyter-client" +version = "8.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105 }, +] + +[[package]] +name = "jupyter-core" +version = "5.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/11/b56381fa6c3f4cc5d2cf54a7dbf98ad9aa0b339ef7a601d6053538b079a7/jupyter_core-5.7.2.tar.gz", hash = "sha256:aa5f8d32bbf6b431ac830496da7392035d6f61b4f54872f15c4bd2a9c3f536d9", size = 87629 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/fb/108ecd1fe961941959ad0ee4e12ee7b8b1477247f30b1fdfd83ceaf017f0/jupyter_core-5.7.2-py3-none-any.whl", hash = "sha256:4f7315d2f6b4bcf2e3e7cb6e46772eba760ae459cd1f59d29eb57b0a01bd7409", size = 28965 }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/4d/2255e1c76304cbd60b48cee302b66d1dde4468dc5b1160e4b7cb43778f2a/kiwisolver-1.4.7.tar.gz", hash = "sha256:9893ff81bd7107f7b685d3017cc6583daadb4fc26e4a888350df530e41980a60", size = 97286 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/14/fc943dd65268a96347472b4fbe5dcc2f6f55034516f80576cd0dd3a8930f/kiwisolver-1.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8a9c83f75223d5e48b0bc9cb1bf2776cf01563e00ade8775ffe13b0b6e1af3a6", size = 122440 }, + { url = "https://files.pythonhosted.org/packages/1e/46/e68fed66236b69dd02fcdb506218c05ac0e39745d696d22709498896875d/kiwisolver-1.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:58370b1ffbd35407444d57057b57da5d6549d2d854fa30249771775c63b5fe17", size = 65758 }, + { url = "https://files.pythonhosted.org/packages/ef/fa/65de49c85838681fc9cb05de2a68067a683717321e01ddafb5b8024286f0/kiwisolver-1.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa0abdf853e09aff551db11fce173e2177d00786c688203f52c87ad7fcd91ef9", size = 64311 }, + { url = "https://files.pythonhosted.org/packages/42/9c/cc8d90f6ef550f65443bad5872ffa68f3dee36de4974768628bea7c14979/kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8d53103597a252fb3ab8b5845af04c7a26d5e7ea8122303dd7a021176a87e8b9", size = 1637109 }, + { url = "https://files.pythonhosted.org/packages/55/91/0a57ce324caf2ff5403edab71c508dd8f648094b18cfbb4c8cc0fde4a6ac/kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:88f17c5ffa8e9462fb79f62746428dd57b46eb931698e42e990ad63103f35e6c", size = 1617814 }, + { url = "https://files.pythonhosted.org/packages/12/5d/c36140313f2510e20207708adf36ae4919416d697ee0236b0ddfb6fd1050/kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a9ca9c710d598fd75ee5de59d5bda2684d9db36a9f50b6125eaea3969c2599", size = 1400881 }, + { url = "https://files.pythonhosted.org/packages/56/d0/786e524f9ed648324a466ca8df86298780ef2b29c25313d9a4f16992d3cf/kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f4d742cb7af1c28303a51b7a27aaee540e71bb8e24f68c736f6f2ffc82f2bf05", size = 1512972 }, + { url = "https://files.pythonhosted.org/packages/67/5a/77851f2f201e6141d63c10a0708e996a1363efaf9e1609ad0441b343763b/kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e28c7fea2196bf4c2f8d46a0415c77a1c480cc0724722f23d7410ffe9842c407", size = 1444787 }, + { url = "https://files.pythonhosted.org/packages/06/5f/1f5eaab84355885e224a6fc8d73089e8713dc7e91c121f00b9a1c58a2195/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e968b84db54f9d42046cf154e02911e39c0435c9801681e3fc9ce8a3c4130278", size = 2199212 }, + { url = "https://files.pythonhosted.org/packages/b5/28/9152a3bfe976a0ae21d445415defc9d1cd8614b2910b7614b30b27a47270/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0c18ec74c0472de033e1bebb2911c3c310eef5649133dd0bedf2a169a1b269e5", size = 2346399 }, + { url = "https://files.pythonhosted.org/packages/26/f6/453d1904c52ac3b400f4d5e240ac5fec25263716723e44be65f4d7149d13/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8f0ea6da6d393d8b2e187e6a5e3fb81f5862010a40c3945e2c6d12ae45cfb2ad", size = 2308688 }, + { url = "https://files.pythonhosted.org/packages/5a/9a/d4968499441b9ae187e81745e3277a8b4d7c60840a52dc9d535a7909fac3/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:f106407dda69ae456dd1227966bf445b157ccc80ba0dff3802bb63f30b74e895", size = 2445493 }, + { url = "https://files.pythonhosted.org/packages/07/c9/032267192e7828520dacb64dfdb1d74f292765f179e467c1cba97687f17d/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:84ec80df401cfee1457063732d90022f93951944b5b58975d34ab56bb150dfb3", size = 2262191 }, + { url = "https://files.pythonhosted.org/packages/6c/ad/db0aedb638a58b2951da46ddaeecf204be8b4f5454df020d850c7fa8dca8/kiwisolver-1.4.7-cp310-cp310-win32.whl", hash = "sha256:71bb308552200fb2c195e35ef05de12f0c878c07fc91c270eb3d6e41698c3bcc", size = 46644 }, + { url = "https://files.pythonhosted.org/packages/12/ca/d0f7b7ffbb0be1e7c2258b53554efec1fd652921f10d7d85045aff93ab61/kiwisolver-1.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:44756f9fd339de0fb6ee4f8c1696cfd19b2422e0d70b4cefc1cc7f1f64045a8c", size = 55877 }, + { url = "https://files.pythonhosted.org/packages/97/6c/cfcc128672f47a3e3c0d918ecb67830600078b025bfc32d858f2e2d5c6a4/kiwisolver-1.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:78a42513018c41c2ffd262eb676442315cbfe3c44eed82385c2ed043bc63210a", size = 48347 }, + { url = "https://files.pythonhosted.org/packages/e9/44/77429fa0a58f941d6e1c58da9efe08597d2e86bf2b2cce6626834f49d07b/kiwisolver-1.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d2b0e12a42fb4e72d509fc994713d099cbb15ebf1103545e8a45f14da2dfca54", size = 122442 }, + { url = "https://files.pythonhosted.org/packages/e5/20/8c75caed8f2462d63c7fd65e16c832b8f76cda331ac9e615e914ee80bac9/kiwisolver-1.4.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2a8781ac3edc42ea4b90bc23e7d37b665d89423818e26eb6df90698aa2287c95", size = 65762 }, + { url = "https://files.pythonhosted.org/packages/f4/98/fe010f15dc7230f45bc4cf367b012d651367fd203caaa992fd1f5963560e/kiwisolver-1.4.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:46707a10836894b559e04b0fd143e343945c97fd170d69a2d26d640b4e297935", size = 64319 }, + { url = "https://files.pythonhosted.org/packages/8b/1b/b5d618f4e58c0675654c1e5051bcf42c776703edb21c02b8c74135541f60/kiwisolver-1.4.7-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef97b8df011141c9b0f6caf23b29379f87dd13183c978a30a3c546d2c47314cb", size = 1334260 }, + { url = "https://files.pythonhosted.org/packages/b8/01/946852b13057a162a8c32c4c8d2e9ed79f0bb5d86569a40c0b5fb103e373/kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ab58c12a2cd0fc769089e6d38466c46d7f76aced0a1f54c77652446733d2d02", size = 1426589 }, + { url = "https://files.pythonhosted.org/packages/70/d1/c9f96df26b459e15cf8a965304e6e6f4eb291e0f7a9460b4ad97b047561e/kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:803b8e1459341c1bb56d1c5c010406d5edec8a0713a0945851290a7930679b51", size = 1541080 }, + { url = "https://files.pythonhosted.org/packages/d3/73/2686990eb8b02d05f3de759d6a23a4ee7d491e659007dd4c075fede4b5d0/kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9a9e8a507420fe35992ee9ecb302dab68550dedc0da9e2880dd88071c5fb052", size = 1470049 }, + { url = "https://files.pythonhosted.org/packages/a7/4b/2db7af3ed3af7c35f388d5f53c28e155cd402a55432d800c543dc6deb731/kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18077b53dc3bb490e330669a99920c5e6a496889ae8c63b58fbc57c3d7f33a18", size = 1426376 }, + { url = "https://files.pythonhosted.org/packages/05/83/2857317d04ea46dc5d115f0df7e676997bbd968ced8e2bd6f7f19cfc8d7f/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6af936f79086a89b3680a280c47ea90b4df7047b5bdf3aa5c524bbedddb9e545", size = 2222231 }, + { url = "https://files.pythonhosted.org/packages/0d/b5/866f86f5897cd4ab6d25d22e403404766a123f138bd6a02ecb2cdde52c18/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3abc5b19d24af4b77d1598a585b8a719beb8569a71568b66f4ebe1fb0449460b", size = 2368634 }, + { url = "https://files.pythonhosted.org/packages/c1/ee/73de8385403faba55f782a41260210528fe3273d0cddcf6d51648202d6d0/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:933d4de052939d90afbe6e9d5273ae05fb836cc86c15b686edd4b3560cc0ee36", size = 2329024 }, + { url = "https://files.pythonhosted.org/packages/a1/e7/cd101d8cd2cdfaa42dc06c433df17c8303d31129c9fdd16c0ea37672af91/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:65e720d2ab2b53f1f72fb5da5fb477455905ce2c88aaa671ff0a447c2c80e8e3", size = 2468484 }, + { url = "https://files.pythonhosted.org/packages/e1/72/84f09d45a10bc57a40bb58b81b99d8f22b58b2040c912b7eb97ebf625bf2/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3bf1ed55088f214ba6427484c59553123fdd9b218a42bbc8c6496d6754b1e523", size = 2284078 }, + { url = "https://files.pythonhosted.org/packages/d2/d4/71828f32b956612dc36efd7be1788980cb1e66bfb3706e6dec9acad9b4f9/kiwisolver-1.4.7-cp311-cp311-win32.whl", hash = "sha256:4c00336b9dd5ad96d0a558fd18a8b6f711b7449acce4c157e7343ba92dd0cf3d", size = 46645 }, + { url = "https://files.pythonhosted.org/packages/a1/65/d43e9a20aabcf2e798ad1aff6c143ae3a42cf506754bcb6a7ed8259c8425/kiwisolver-1.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:929e294c1ac1e9f615c62a4e4313ca1823ba37326c164ec720a803287c4c499b", size = 56022 }, + { url = "https://files.pythonhosted.org/packages/35/b3/9f75a2e06f1b4ca00b2b192bc2b739334127d27f1d0625627ff8479302ba/kiwisolver-1.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:e33e8fbd440c917106b237ef1a2f1449dfbb9b6f6e1ce17c94cd6a1e0d438376", size = 48536 }, + { url = "https://files.pythonhosted.org/packages/97/9c/0a11c714cf8b6ef91001c8212c4ef207f772dd84540104952c45c1f0a249/kiwisolver-1.4.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:5360cc32706dab3931f738d3079652d20982511f7c0ac5711483e6eab08efff2", size = 121808 }, + { url = "https://files.pythonhosted.org/packages/f2/d8/0fe8c5f5d35878ddd135f44f2af0e4e1d379e1c7b0716f97cdcb88d4fd27/kiwisolver-1.4.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942216596dc64ddb25adb215c3c783215b23626f8d84e8eff8d6d45c3f29f75a", size = 65531 }, + { url = "https://files.pythonhosted.org/packages/80/c5/57fa58276dfdfa612241d640a64ca2f76adc6ffcebdbd135b4ef60095098/kiwisolver-1.4.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:48b571ecd8bae15702e4f22d3ff6a0f13e54d3d00cd25216d5e7f658242065ee", size = 63894 }, + { url = "https://files.pythonhosted.org/packages/8b/e9/26d3edd4c4ad1c5b891d8747a4f81b1b0aba9fb9721de6600a4adc09773b/kiwisolver-1.4.7-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad42ba922c67c5f219097b28fae965e10045ddf145d2928bfac2eb2e17673640", size = 1369296 }, + { url = "https://files.pythonhosted.org/packages/b6/67/3f4850b5e6cffb75ec40577ddf54f7b82b15269cc5097ff2e968ee32ea7d/kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:612a10bdae23404a72941a0fc8fa2660c6ea1217c4ce0dbcab8a8f6543ea9e7f", size = 1461450 }, + { url = "https://files.pythonhosted.org/packages/52/be/86cbb9c9a315e98a8dc6b1d23c43cffd91d97d49318854f9c37b0e41cd68/kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e838bba3a3bac0fe06d849d29772eb1afb9745a59710762e4ba3f4cb8424483", size = 1579168 }, + { url = "https://files.pythonhosted.org/packages/0f/00/65061acf64bd5fd34c1f4ae53f20b43b0a017a541f242a60b135b9d1e301/kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22f499f6157236c19f4bbbd472fa55b063db77a16cd74d49afe28992dff8c258", size = 1507308 }, + { url = "https://files.pythonhosted.org/packages/21/e4/c0b6746fd2eb62fe702118b3ca0cb384ce95e1261cfada58ff693aeec08a/kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693902d433cf585133699972b6d7c42a8b9f8f826ebcaf0132ff55200afc599e", size = 1464186 }, + { url = "https://files.pythonhosted.org/packages/0a/0f/529d0a9fffb4d514f2782c829b0b4b371f7f441d61aa55f1de1c614c4ef3/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e77f2126c3e0b0d055f44513ed349038ac180371ed9b52fe96a32aa071a5107", size = 2247877 }, + { url = "https://files.pythonhosted.org/packages/d1/e1/66603ad779258843036d45adcbe1af0d1a889a07af4635f8b4ec7dccda35/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:657a05857bda581c3656bfc3b20e353c232e9193eb167766ad2dc58b56504948", size = 2404204 }, + { url = "https://files.pythonhosted.org/packages/8d/61/de5fb1ca7ad1f9ab7970e340a5b833d735df24689047de6ae71ab9d8d0e7/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4bfa75a048c056a411f9705856abfc872558e33c055d80af6a380e3658766038", size = 2352461 }, + { url = "https://files.pythonhosted.org/packages/ba/d2/0edc00a852e369827f7e05fd008275f550353f1f9bcd55db9363d779fc63/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:34ea1de54beef1c104422d210c47c7d2a4999bdecf42c7b5718fbe59a4cac383", size = 2501358 }, + { url = "https://files.pythonhosted.org/packages/84/15/adc15a483506aec6986c01fb7f237c3aec4d9ed4ac10b756e98a76835933/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:90da3b5f694b85231cf93586dad5e90e2d71b9428f9aad96952c99055582f520", size = 2314119 }, + { url = "https://files.pythonhosted.org/packages/36/08/3a5bb2c53c89660863a5aa1ee236912269f2af8762af04a2e11df851d7b2/kiwisolver-1.4.7-cp312-cp312-win32.whl", hash = "sha256:18e0cca3e008e17fe9b164b55735a325140a5a35faad8de92dd80265cd5eb80b", size = 46367 }, + { url = "https://files.pythonhosted.org/packages/19/93/c05f0a6d825c643779fc3c70876bff1ac221f0e31e6f701f0e9578690d70/kiwisolver-1.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:58cb20602b18f86f83a5c87d3ee1c766a79c0d452f8def86d925e6c60fbf7bfb", size = 55884 }, + { url = "https://files.pythonhosted.org/packages/d2/f9/3828d8f21b6de4279f0667fb50a9f5215e6fe57d5ec0d61905914f5b6099/kiwisolver-1.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:f5a8b53bdc0b3961f8b6125e198617c40aeed638b387913bf1ce78afb1b0be2a", size = 48528 }, + { url = "https://files.pythonhosted.org/packages/c4/06/7da99b04259b0f18b557a4effd1b9c901a747f7fdd84cf834ccf520cb0b2/kiwisolver-1.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2e6039dcbe79a8e0f044f1c39db1986a1b8071051efba3ee4d74f5b365f5226e", size = 121913 }, + { url = "https://files.pythonhosted.org/packages/97/f5/b8a370d1aa593c17882af0a6f6755aaecd643640c0ed72dcfd2eafc388b9/kiwisolver-1.4.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a1ecf0ac1c518487d9d23b1cd7139a6a65bc460cd101ab01f1be82ecf09794b6", size = 65627 }, + { url = "https://files.pythonhosted.org/packages/2a/fc/6c0374f7503522539e2d4d1b497f5ebad3f8ed07ab51aed2af988dd0fb65/kiwisolver-1.4.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ab9ccab2b5bd5702ab0803676a580fffa2aa178c2badc5557a84cc943fcf750", size = 63888 }, + { url = "https://files.pythonhosted.org/packages/bf/3e/0b7172793d0f41cae5c923492da89a2ffcd1adf764c16159ca047463ebd3/kiwisolver-1.4.7-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f816dd2277f8d63d79f9c8473a79fe54047bc0467754962840782c575522224d", size = 1369145 }, + { url = "https://files.pythonhosted.org/packages/77/92/47d050d6f6aced2d634258123f2688fbfef8ded3c5baf2c79d94d91f1f58/kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf8bcc23ceb5a1b624572a1623b9f79d2c3b337c8c455405ef231933a10da379", size = 1461448 }, + { url = "https://files.pythonhosted.org/packages/9c/1b/8f80b18e20b3b294546a1adb41701e79ae21915f4175f311a90d042301cf/kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dea0bf229319828467d7fca8c7c189780aa9ff679c94539eed7532ebe33ed37c", size = 1578750 }, + { url = "https://files.pythonhosted.org/packages/a4/fe/fe8e72f3be0a844f257cadd72689c0848c6d5c51bc1d60429e2d14ad776e/kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c06a4c7cf15ec739ce0e5971b26c93638730090add60e183530d70848ebdd34", size = 1507175 }, + { url = "https://files.pythonhosted.org/packages/39/fa/cdc0b6105d90eadc3bee525fecc9179e2b41e1ce0293caaf49cb631a6aaf/kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:913983ad2deb14e66d83c28b632fd35ba2b825031f2fa4ca29675e665dfecbe1", size = 1463963 }, + { url = "https://files.pythonhosted.org/packages/6e/5c/0c03c4e542720c6177d4f408e56d1c8315899db72d46261a4e15b8b33a41/kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5337ec7809bcd0f424c6b705ecf97941c46279cf5ed92311782c7c9c2026f07f", size = 2248220 }, + { url = "https://files.pythonhosted.org/packages/3d/ee/55ef86d5a574f4e767df7da3a3a7ff4954c996e12d4fbe9c408170cd7dcc/kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4c26ed10c4f6fa6ddb329a5120ba3b6db349ca192ae211e882970bfc9d91420b", size = 2404463 }, + { url = "https://files.pythonhosted.org/packages/0f/6d/73ad36170b4bff4825dc588acf4f3e6319cb97cd1fb3eb04d9faa6b6f212/kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c619b101e6de2222c1fcb0531e1b17bbffbe54294bfba43ea0d411d428618c27", size = 2352842 }, + { url = "https://files.pythonhosted.org/packages/0b/16/fa531ff9199d3b6473bb4d0f47416cdb08d556c03b8bc1cccf04e756b56d/kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:073a36c8273647592ea332e816e75ef8da5c303236ec0167196793eb1e34657a", size = 2501635 }, + { url = "https://files.pythonhosted.org/packages/78/7e/aa9422e78419db0cbe75fb86d8e72b433818f2e62e2e394992d23d23a583/kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ce6b2b0231bda412463e152fc18335ba32faf4e8c23a754ad50ffa70e4091ee", size = 2314556 }, + { url = "https://files.pythonhosted.org/packages/a8/b2/15f7f556df0a6e5b3772a1e076a9d9f6c538ce5f05bd590eca8106508e06/kiwisolver-1.4.7-cp313-cp313-win32.whl", hash = "sha256:f4c9aee212bc89d4e13f58be11a56cc8036cabad119259d12ace14b34476fd07", size = 46364 }, + { url = "https://files.pythonhosted.org/packages/0b/db/32e897e43a330eee8e4770bfd2737a9584b23e33587a0812b8e20aac38f7/kiwisolver-1.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:8a3ec5aa8e38fc4c8af308917ce12c536f1c88452ce554027e55b22cbbfbff76", size = 55887 }, + { url = "https://files.pythonhosted.org/packages/c8/a4/df2bdca5270ca85fd25253049eb6708d4127be2ed0e5c2650217450b59e9/kiwisolver-1.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:76c8094ac20ec259471ac53e774623eb62e6e1f56cd8690c67ce6ce4fcb05650", size = 48530 }, + { url = "https://files.pythonhosted.org/packages/ac/59/741b79775d67ab67ced9bb38552da688c0305c16e7ee24bba7a2be253fb7/kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:94252291e3fe68001b1dd747b4c0b3be12582839b95ad4d1b641924d68fd4643", size = 59491 }, + { url = "https://files.pythonhosted.org/packages/58/cc/fb239294c29a5656e99e3527f7369b174dd9cc7c3ef2dea7cb3c54a8737b/kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b7dfa3b546da08a9f622bb6becdb14b3e24aaa30adba66749d38f3cc7ea9706", size = 57648 }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2f009ac1f7aab9f81efb2d837301d255279d618d27b6015780115ac64bdd/kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd3de6481f4ed8b734da5df134cd5a6a64fe32124fe83dde1e5b5f29fe30b1e6", size = 84257 }, + { url = "https://files.pythonhosted.org/packages/81/e1/c64f50987f85b68b1c52b464bb5bf73e71570c0f7782d626d1eb283ad620/kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a91b5f9f1205845d488c928e8570dcb62b893372f63b8b6e98b863ebd2368ff2", size = 80906 }, + { url = "https://files.pythonhosted.org/packages/fd/71/1687c5c0a0be2cee39a5c9c389e546f9c6e215e46b691d00d9f646892083/kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40fa14dbd66b8b8f470d5fc79c089a66185619d31645f9b0773b88b19f7223c4", size = 79951 }, + { url = "https://files.pythonhosted.org/packages/ea/8b/d7497df4a1cae9367adf21665dd1f896c2a7aeb8769ad77b662c5e2bcce7/kiwisolver-1.4.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:eb542fe7933aa09d8d8f9d9097ef37532a7df6497819d16efe4359890a2f417a", size = 55715 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "ml-dtypes" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/79/717c5e22ad25d63ce3acdfe8ff8d64bdedec18914256c59b838218708b16/ml_dtypes-0.5.0.tar.gz", hash = "sha256:3e7d3a380fe73a63c884f06136f8baa7a5249cc8e9fdec677997dd78549f8128", size = 699367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/50/0a2048895a764b138638b5e7a62436545eb206948a5e6f77d9d5a4b02479/ml_dtypes-0.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8c32138975797e681eb175996d64356bcfa124bdbb6a70460b9768c2b35a6fa4", size = 736793 }, + { url = "https://files.pythonhosted.org/packages/0b/b1/95e7995f031bb3890884ddb22e331f24c49b0a4a8f6c448ff5984c86012e/ml_dtypes-0.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab046f2ff789b1f11b2491909682c5d089934835f9a760fafc180e47dcb676b8", size = 4387416 }, + { url = "https://files.pythonhosted.org/packages/9a/5b/d47361f882ff2ae27d764f314d18706c69859da60a6c78e6c9e81714c792/ml_dtypes-0.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7a9152f5876fef565516aa5dd1dccd6fc298a5891b2467973905103eb5c7856", size = 4496271 }, + { url = "https://files.pythonhosted.org/packages/e6/0c/a89f5c0fe9e48ed6e7e27d53e045711ee3d5b850bece5ee22fb0fb24b281/ml_dtypes-0.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:968fede07d1f9b926a63df97d25ac656cac1a57ebd33701734eaf704bc55d8d8", size = 211915 }, + { url = "https://files.pythonhosted.org/packages/fe/29/8968fd7ee026c0d04c553fb1ce1cd67f9da668cd567d62c0cdc995ce989e/ml_dtypes-0.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60275f2b51b56834e840c4809fca840565f9bf8e9a73f6d8c94f5b5935701215", size = 736792 }, + { url = "https://files.pythonhosted.org/packages/19/93/14896596644dad2e041ac5ca7237e6233c484f7defa186ff88b18ee6110b/ml_dtypes-0.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76942f6aeb5c40766d5ea62386daa4148e6a54322aaf5b53eae9e7553240222f", size = 4392038 }, + { url = "https://files.pythonhosted.org/packages/89/65/ffdbf3489b0ba2213674ea347fad3a11747be64d2d23d888f9e5abe80a18/ml_dtypes-0.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e7534392682c3098bc7341648c650864207169c654aed83143d7a19c67ae06f", size = 4499448 }, + { url = "https://files.pythonhosted.org/packages/bf/31/058b9bcf9a81abd51623985add78711a915e4b0f6045baa5f9a0b41eb039/ml_dtypes-0.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:dc74fd9995513d33eac63d64e436240f5494ec74d522a9f0920194942fc3d2d7", size = 211916 }, + { url = "https://files.pythonhosted.org/packages/1c/b7/a067839f6e435785f34b09d96938dccb3a5d9502037de243cb84a2eb3f23/ml_dtypes-0.5.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d4b1a70a3e5219790d6b55b9507606fc4e02911d1497d16c18dd721eb7efe7d0", size = 750226 }, + { url = "https://files.pythonhosted.org/packages/31/75/bf571247bb3dbea73aa33ccae57ce322b9688003cfee2f68d303ab7b987b/ml_dtypes-0.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a988bac6572630e1e9c2edd9b1277b4eefd1c86209e52b0d061b775ac33902ff", size = 4420139 }, + { url = "https://files.pythonhosted.org/packages/6f/d3/1321715a95e856d4ef4fba24e4351cf5e4c89d459ad132a8cba5fe257d72/ml_dtypes-0.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a38df8df61194aeaae1ab7579075779b4ad32cd1cffd012c28be227fa7f2a70a", size = 4471130 }, + { url = "https://files.pythonhosted.org/packages/00/3a/40c40b78a7eb456837817bfa2c5bc442db59aefdf21c5ecb94700037813d/ml_dtypes-0.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:afa08343069874a30812871d639f9c02b4158ace065601406a493a8511180c02", size = 213187 }, + { url = "https://files.pythonhosted.org/packages/b3/4a/18f670a2703e771a6775fbc354208e597ff062a88efb0cecc220a282210b/ml_dtypes-0.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d3b3db9990c3840986a0e70524e122cfa32b91139c3653df76121ba7776e015f", size = 753345 }, + { url = "https://files.pythonhosted.org/packages/ed/c6/358d85e274e22d53def0c85f3cbe0933475fa3cf6922e9dca66eb25cb22f/ml_dtypes-0.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e04fde367b2fe901b1d47234426fe8819909bd1dd862a5adb630f27789c20599", size = 4424962 }, + { url = "https://files.pythonhosted.org/packages/4c/b4/d766586e24e7a073333c8eb8bd9275f3c6fe0569b509ae7b1699d4f00c74/ml_dtypes-0.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54415257f00eb44fbcc807454efac3356f75644f1cbfc2d4e5522a72ae1dacab", size = 4475201 }, + { url = "https://files.pythonhosted.org/packages/14/87/30323ad2e52f56262019a4493fe5f5e71067c5561ce7e2f9c75de520f5e8/ml_dtypes-0.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:cb5cc7b25acabd384f75bbd78892d0c724943f3e2e1986254665a1aa10982e07", size = 213195 }, +] + +[[package]] +name = "mypy" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/8c/206de95a27722b5b5a8c85ba3100467bd86299d92a4f71c6b9aa448bfa2f/mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a", size = 11020731 }, + { url = "https://files.pythonhosted.org/packages/ab/bb/b31695a29eea76b1569fd28b4ab141a1adc9842edde080d1e8e1776862c7/mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80", size = 10184276 }, + { url = "https://files.pythonhosted.org/packages/a5/2d/4a23849729bb27934a0e079c9c1aad912167d875c7b070382a408d459651/mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7", size = 12587706 }, + { url = "https://files.pythonhosted.org/packages/5c/c3/d318e38ada50255e22e23353a469c791379825240e71b0ad03e76ca07ae6/mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f", size = 13105586 }, + { url = "https://files.pythonhosted.org/packages/4a/25/3918bc64952370c3dbdbd8c82c363804678127815febd2925b7273d9482c/mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372", size = 9632318 }, + { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027 }, + { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699 }, + { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263 }, + { url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688 }, + { url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811 }, + { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, + { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, + { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, + { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, + { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, + { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, + { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, + { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, + { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, + { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, + { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "ndv" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "qtpy" }, + { name = "superqt", extra = ["cmap", "iconify"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/a1/b3bc45071fed07a937e8b08bc845f88bd68086a817aa9b1fe0adbab2adad/ndv-0.0.4.tar.gz", hash = "sha256:ef5ba6ca3aaec0cbc8dbfef160045ab9f10505307dec6f3da76637b053f9f535", size = 34811 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/15/9215ee0483e728abd1726cff52887d0b7a35bc70653a8445a49fd83f208d/ndv-0.0.4-py3-none-any.whl", hash = "sha256:92923ec3853860eb9369511dce88baf9009c60465093579c3f50901d43d086ec", size = 34619 }, +] + +[package.optional-dependencies] +vispy = [ + { name = "pyopengl" }, + { name = "vispy" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "numcodecs" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/56/8895a76abe4ec94ebd01eeb6d74f587bc4cddd46569670e1402852a5da13/numcodecs-0.13.1.tar.gz", hash = "sha256:a3cf37881df0898f3a9c0d4477df88133fe85185bffe57ba31bcc2fa207709bc", size = 5955215 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/c0/6d72cde772bcec196b7188731d41282993b2958440f77fdf0db216f722da/numcodecs-0.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:96add4f783c5ce57cc7e650b6cac79dd101daf887c479a00a29bc1487ced180b", size = 1580012 }, + { url = "https://files.pythonhosted.org/packages/94/1d/f81fc1fa9210bbea97258242393a1f9feab4f6d8fb201f81f76003005e4b/numcodecs-0.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:237b7171609e868a20fd313748494444458ccd696062f67e198f7f8f52000c15", size = 1176919 }, + { url = "https://files.pythonhosted.org/packages/16/e4/b9ec2f4dfc34ecf724bc1beb96a9f6fa9b91801645688ffadacd485089da/numcodecs-0.13.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96e42f73c31b8c24259c5fac6adba0c3ebf95536e37749dc6c62ade2989dca28", size = 8625842 }, + { url = "https://files.pythonhosted.org/packages/fe/90/299952e1477954ec4f92813fa03e743945e3ff711bb4f6c9aace431cb3da/numcodecs-0.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:eda7d7823c9282e65234731fd6bd3986b1f9e035755f7fed248d7d366bb291ab", size = 828638 }, + { url = "https://files.pythonhosted.org/packages/f0/78/34b8e869ef143e88d62e8231f4dbfcad85e5c41302a11fc5bd2228a13df5/numcodecs-0.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2eda97dd2f90add98df6d295f2c6ae846043396e3d51a739ca5db6c03b5eb666", size = 1580199 }, + { url = "https://files.pythonhosted.org/packages/3b/cf/f70797d86bb585d258d1e6993dced30396f2044725b96ce8bcf87a02be9c/numcodecs-0.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2a86f5367af9168e30f99727ff03b27d849c31ad4522060dde0bce2923b3a8bc", size = 1177203 }, + { url = "https://files.pythonhosted.org/packages/a8/b5/d14ad69b63fde041153dfd05d7181a49c0d4864de31a7a1093c8370da957/numcodecs-0.13.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233bc7f26abce24d57e44ea8ebeb5cd17084690b4e7409dd470fdb75528d615f", size = 8868743 }, + { url = "https://files.pythonhosted.org/packages/13/d4/27a7b5af0b33f6d61e198faf177fbbf3cb83ff10d9d1a6857b7efc525ad5/numcodecs-0.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:796b3e6740107e4fa624cc636248a1580138b3f1c579160f260f76ff13a4261b", size = 829603 }, + { url = "https://files.pythonhosted.org/packages/37/3a/bc09808425e7d3df41e5fc73fc7a802c429ba8c6b05e55f133654ade019d/numcodecs-0.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5195bea384a6428f8afcece793860b1ab0ae28143c853f0b2b20d55a8947c917", size = 1575806 }, + { url = "https://files.pythonhosted.org/packages/3a/cc/dc74d0bfdf9ec192332a089d199f1e543e747c556b5659118db7a437dcca/numcodecs-0.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3501a848adaddce98a71a262fee15cd3618312692aa419da77acd18af4a6a3f6", size = 1178233 }, + { url = "https://files.pythonhosted.org/packages/d4/ce/434e8e3970b8e92ae9ab6d9db16cb9bc7aa1cd02e17c11de6848224100a1/numcodecs-0.13.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da2230484e6102e5fa3cc1a5dd37ca1f92dfbd183d91662074d6f7574e3e8f53", size = 8857827 }, + { url = "https://files.pythonhosted.org/packages/83/e7/1d8b1b266a92f9013c755b1c146c5ad71a2bff147ecbc67f86546a2e4d6a/numcodecs-0.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:e5db4824ebd5389ea30e54bc8aeccb82d514d28b6b68da6c536b8fa4596f4bca", size = 826539 }, + { url = "https://files.pythonhosted.org/packages/83/8b/06771dead2cc4a8ae1ea9907737cf1c8d37a323392fa28f938a586373468/numcodecs-0.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a60d75179fd6692e301ddfb3b266d51eb598606dcae7b9fc57f986e8d65cb43", size = 1571660 }, + { url = "https://files.pythonhosted.org/packages/f9/ea/d925bf85f92dfe4635356018da9fe4bfecb07b1c72f62b01c1bc47f936b1/numcodecs-0.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f593c7506b0ab248961a3b13cb148cc6e8355662ff124ac591822310bc55ecf", size = 1169925 }, + { url = "https://files.pythonhosted.org/packages/0f/d6/643a3839d571d8e439a2c77dc4b0b8cab18d96ac808e4a81dbe88e959ab6/numcodecs-0.13.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80d3071465f03522e776a31045ddf2cfee7f52df468b977ed3afdd7fe5869701", size = 8814257 }, + { url = "https://files.pythonhosted.org/packages/a6/c5/f3e56bc9b4e438a287fff738993d6d11abef368c0328a612ac2842ba9fca/numcodecs-0.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:90d3065ae74c9342048ae0046006f99dcb1388b7288da5a19b3bddf9c30c3176", size = 821887 }, +] + +[[package]] +name = "numpy" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/1b/1d565e0f6e156e1522ab564176b8b29d71e13d8caf003a08768df3d5cec5/numpy-2.2.0.tar.gz", hash = "sha256:140dd80ff8981a583a60980be1a655068f8adebf7a45a06a6858c873fcdcd4a0", size = 20225497 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/81/3882353e097204fe4d7a5fe026b694b0104b78f930c969faadeed1538e00/numpy-2.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1e25507d85da11ff5066269d0bd25d06e0a0f2e908415534f3e603d2a78e4ffa", size = 21212476 }, + { url = "https://files.pythonhosted.org/packages/2c/64/5577dc71240272749e07fcacb47c0f29e31ba4fbd1613fefbd1aa88efc29/numpy-2.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a62eb442011776e4036af5c8b1a00b706c5bc02dc15eb5344b0c750428c94219", size = 14351441 }, + { url = "https://files.pythonhosted.org/packages/c9/43/850c040481c19c1c2289203a606df1a202eeb3aa81440624bac891024f83/numpy-2.2.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:b606b1aaf802e6468c2608c65ff7ece53eae1a6874b3765f69b8ceb20c5fa78e", size = 5390304 }, + { url = "https://files.pythonhosted.org/packages/73/96/a4c8a86300dbafc7e4f44d8986f8b64950b7f4640a2dc5c91e036afe28c6/numpy-2.2.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:36b2b43146f646642b425dd2027730f99bac962618ec2052932157e213a040e9", size = 6925476 }, + { url = "https://files.pythonhosted.org/packages/0c/0a/22129c3107c4fb237f97876df4399a5c3a83f3d95f86e0353ae6fbbd202f/numpy-2.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fe8f3583e0607ad4e43a954e35c1748b553bfe9fdac8635c02058023277d1b3", size = 14329997 }, + { url = "https://files.pythonhosted.org/packages/4c/49/c2adeccc8a47bcd9335ec000dfcb4de34a7c34aeaa23af57cd504017e8c3/numpy-2.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:122fd2fcfafdefc889c64ad99c228d5a1f9692c3a83f56c292618a59aa60ae83", size = 16378908 }, + { url = "https://files.pythonhosted.org/packages/8d/85/b65f4596748cc5468c0a978a16b3be45f6bcec78339b0fe7bce71d121d89/numpy-2.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3f2f5cddeaa4424a0a118924b988746db6ffa8565e5829b1841a8a3bd73eb59a", size = 15540949 }, + { url = "https://files.pythonhosted.org/packages/ff/b3/3b18321c94a6a6a1d972baf1b39a6de50e65c991002c014ffbcce7e09be8/numpy-2.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fe4bb0695fe986a9e4deec3b6857003b4cfe5c5e4aac0b95f6a658c14635e31", size = 18167677 }, + { url = "https://files.pythonhosted.org/packages/41/f0/fa2a76e893a05764e4474f6011575c4e4ccf32af9c95bfcc8ef4b8a99f69/numpy-2.2.0-cp310-cp310-win32.whl", hash = "sha256:b30042fe92dbd79f1ba7f6898fada10bdaad1847c44f2dff9a16147e00a93661", size = 6570288 }, + { url = "https://files.pythonhosted.org/packages/97/4e/0b7debcd013214db224997b0d3e39bb7b3656d37d06dfc31bb57d42d143b/numpy-2.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dc1d6d66f8d37843ed281773c7174f03bf7ad826523f73435deb88ba60d2d4", size = 12912730 }, + { url = "https://files.pythonhosted.org/packages/80/1b/736023977a96e787c4e7653a1ac2d31d4f6ab6b4048f83c8359f7c0af2e3/numpy-2.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9874bc2ff574c40ab7a5cbb7464bf9b045d617e36754a7bc93f933d52bd9ffc6", size = 21216607 }, + { url = "https://files.pythonhosted.org/packages/85/4f/5f0be4c5c93525e663573bab9e29bd88a71f85de3a0d01413ee05bce0c2f/numpy-2.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0da8495970f6b101ddd0c38ace92edea30e7e12b9a926b57f5fabb1ecc25bb90", size = 14387756 }, + { url = "https://files.pythonhosted.org/packages/36/78/c38af7833c4f29999cdacdf12452b43b660cd25a1990ea9a7edf1fb01f17/numpy-2.2.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0557eebc699c1c34cccdd8c3778c9294e8196df27d713706895edc6f57d29608", size = 5388483 }, + { url = "https://files.pythonhosted.org/packages/e9/b5/306ac6ee3f8f0c51abd3664ee8a9b8e264cbf179a860674827151ecc0a9c/numpy-2.2.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:3579eaeb5e07f3ded59298ce22b65f877a86ba8e9fe701f5576c99bb17c283da", size = 6929721 }, + { url = "https://files.pythonhosted.org/packages/ea/15/e33a7d86d8ce91de82c34ce94a87f2b8df891e603675e83ec7039325ff10/numpy-2.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40deb10198bbaa531509aad0cd2f9fadb26c8b94070831e2208e7df543562b74", size = 14334667 }, + { url = "https://files.pythonhosted.org/packages/52/33/10825f580f42a353f744abc450dcd2a4b1e6f1931abb0ccbd1d63bd3993c/numpy-2.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2aed8fcf8abc3020d6a9ccb31dbc9e7d7819c56a348cc88fd44be269b37427e", size = 16390204 }, + { url = "https://files.pythonhosted.org/packages/b4/24/36cce77559572bdc6c8bcdd2f3e0db03c7079d14b9a1cd342476d7f451e8/numpy-2.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a222d764352c773aa5ebde02dd84dba3279c81c6db2e482d62a3fa54e5ece69b", size = 15556123 }, + { url = "https://files.pythonhosted.org/packages/05/51/2d706d14adee8f5c70c5de3831673d4d57051fc9ac6f3f6bff8811d2f9bd/numpy-2.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4e58666988605e251d42c2818c7d3d8991555381be26399303053b58a5bbf30d", size = 18179898 }, + { url = "https://files.pythonhosted.org/packages/8a/e7/ea8b7652564113f218e75b296e3545a256d88b233021f792fd08591e8f33/numpy-2.2.0-cp311-cp311-win32.whl", hash = "sha256:4723a50e1523e1de4fccd1b9a6dcea750c2102461e9a02b2ac55ffeae09a4410", size = 6568146 }, + { url = "https://files.pythonhosted.org/packages/d0/06/3d1ff6ed377cb0340baf90487a35f15f9dc1db8e0a07de2bf2c54a8e490f/numpy-2.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:16757cf28621e43e252c560d25b15f18a2f11da94fea344bf26c599b9cf54b73", size = 12916677 }, + { url = "https://files.pythonhosted.org/packages/7f/bc/a20dc4e1d051149052762e7647455311865d11c603170c476d1e910a353e/numpy-2.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cff210198bb4cae3f3c100444c5eaa573a823f05c253e7188e1362a5555235b3", size = 20909153 }, + { url = "https://files.pythonhosted.org/packages/60/3d/ac4fb63f36db94f4c7db05b45e3ecb3f88f778ca71850664460c78cfde41/numpy-2.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b92a5828bd4d9aa0952492b7de803135038de47343b2aa3cc23f3b71a3dc4e", size = 14095021 }, + { url = "https://files.pythonhosted.org/packages/41/6d/a654d519d24e4fcc7a83d4a51209cda086f26cf30722b3d8ffc1aa9b775e/numpy-2.2.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ebe5e59545401fbb1b24da76f006ab19734ae71e703cdb4a8b347e84a0cece67", size = 5125491 }, + { url = "https://files.pythonhosted.org/packages/e6/22/fab7e1510a62e5092f4e6507a279020052b89f11d9cfe52af7f52c243b04/numpy-2.2.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e2b8cd48a9942ed3f85b95ca4105c45758438c7ed28fff1e4ce3e57c3b589d8e", size = 6658534 }, + { url = "https://files.pythonhosted.org/packages/fc/29/a3d938ddc5a534cd53df7ab79d20a68db8c67578de1df0ae0118230f5f54/numpy-2.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57fcc997ffc0bef234b8875a54d4058afa92b0b0c4223fc1f62f24b3b5e86038", size = 14046306 }, + { url = "https://files.pythonhosted.org/packages/90/24/d0bbb56abdd8934f30384632e3c2ca1ebfeb5d17e150c6e366ba291de36b/numpy-2.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85ad7d11b309bd132d74397fcf2920933c9d1dc865487128f5c03d580f2c3d03", size = 16095819 }, + { url = "https://files.pythonhosted.org/packages/99/9c/58a673faa9e8a0e77248e782f7a17410cf7259b326265646fd50ed49c4e1/numpy-2.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cb24cca1968b21355cc6f3da1a20cd1cebd8a023e3c5b09b432444617949085a", size = 15243215 }, + { url = "https://files.pythonhosted.org/packages/9c/61/f311693f78cbf635cfb69ce9e1e857ff83937a27d93c96ac5932fd33e330/numpy-2.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0798b138c291d792f8ea40fe3768610f3c7dd2574389e37c3f26573757c8f7ef", size = 17860175 }, + { url = "https://files.pythonhosted.org/packages/11/3e/491c34262cb1fc9dd13a00beb80d755ee0517b17db20e54cac7aa524533e/numpy-2.2.0-cp312-cp312-win32.whl", hash = "sha256:afe8fb968743d40435c3827632fd36c5fbde633b0423da7692e426529b1759b1", size = 6273281 }, + { url = "https://files.pythonhosted.org/packages/89/ea/00537f599eb230771157bc509f6ea5b2dddf05d4b09f9d2f1d7096a18781/numpy-2.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:3a4199f519e57d517ebd48cb76b36c82da0360781c6a0353e64c0cac30ecaad3", size = 12613227 }, + { url = "https://files.pythonhosted.org/packages/bd/4c/0d1eef206545c994289e7a9de21b642880a11e0ed47a2b0c407c688c4f69/numpy-2.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f8c8b141ef9699ae777c6278b52c706b653bf15d135d302754f6b2e90eb30367", size = 20895707 }, + { url = "https://files.pythonhosted.org/packages/16/cb/88f6c1e6df83002c421d5f854ccf134aa088aa997af786a5dac3f32ec99b/numpy-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f0986e917aca18f7a567b812ef7ca9391288e2acb7a4308aa9d265bd724bdae", size = 14110592 }, + { url = "https://files.pythonhosted.org/packages/b4/54/817e6894168a43f33dca74199ba0dd0f1acd99aa6323ed6d323d63d640a2/numpy-2.2.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:1c92113619f7b272838b8d6702a7f8ebe5edea0df48166c47929611d0b4dea69", size = 5110858 }, + { url = "https://files.pythonhosted.org/packages/c7/99/00d8a1a8eb70425bba7880257ed73fed08d3e8d05da4202fb6b9a81d5ee4/numpy-2.2.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5a145e956b374e72ad1dff82779177d4a3c62bc8248f41b80cb5122e68f22d13", size = 6645143 }, + { url = "https://files.pythonhosted.org/packages/34/86/5b9c2b7c56e7a9d9297a0a4be0b8433f498eba52a8f5892d9132b0f64627/numpy-2.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18142b497d70a34b01642b9feabb70156311b326fdddd875a9981f34a369b671", size = 14042812 }, + { url = "https://files.pythonhosted.org/packages/df/54/13535f74391dbe5f479ceed96f1403267be302c840040700d4fd66688089/numpy-2.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7d41d1612c1a82b64697e894b75db6758d4f21c3ec069d841e60ebe54b5b571", size = 16093419 }, + { url = "https://files.pythonhosted.org/packages/dd/37/dfb2056842ac61315f225aa56f455da369f5223e4c5a38b91d20da1b628b/numpy-2.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a98f6f20465e7618c83252c02041517bd2f7ea29be5378f09667a8f654a5918d", size = 15238969 }, + { url = "https://files.pythonhosted.org/packages/5a/3d/d20d24ee313992f0b7e7b9d9eef642d9b545d39d5b91c4a2cc8c98776328/numpy-2.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e09d40edfdb4e260cb1567d8ae770ccf3b8b7e9f0d9b5c2a9992696b30ce2742", size = 17855705 }, + { url = "https://files.pythonhosted.org/packages/5b/40/944c9ee264f875a2db6f79380944fd2b5bb9d712bb4a134d11f45ad5b693/numpy-2.2.0-cp313-cp313-win32.whl", hash = "sha256:3905a5fffcc23e597ee4d9fb3fcd209bd658c352657548db7316e810ca80458e", size = 6270078 }, + { url = "https://files.pythonhosted.org/packages/30/04/e1ee6f8b22034302d4c5c24e15782bdedf76d90b90f3874ed0b48525def0/numpy-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a184288538e6ad699cbe6b24859206e38ce5fba28f3bcfa51c90d0502c1582b2", size = 12605791 }, + { url = "https://files.pythonhosted.org/packages/ef/fb/51d458625cd6134d60ac15180ae50995d7d21b0f2f92a6286ae7b0792d19/numpy-2.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7832f9e8eb00be32f15fdfb9a981d6955ea9adc8574c521d48710171b6c55e95", size = 20920160 }, + { url = "https://files.pythonhosted.org/packages/b4/34/162ae0c5d2536ea4be98c813b5161c980f0443cd5765fde16ddfe3450140/numpy-2.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f0dd071b95bbca244f4cb7f70b77d2ff3aaaba7fa16dc41f58d14854a6204e6c", size = 14119064 }, + { url = "https://files.pythonhosted.org/packages/17/6c/4195dd0e1c41c55f466d516e17e9e28510f32af76d23061ea3da67438e3c/numpy-2.2.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:b0b227dcff8cdc3efbce66d4e50891f04d0a387cce282fe1e66199146a6a8fca", size = 5152778 }, + { url = "https://files.pythonhosted.org/packages/2f/47/ea804ae525832c8d05ed85b560dfd242d34e4bb0962bc269ccaa720fb934/numpy-2.2.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6ab153263a7c5ccaf6dfe7e53447b74f77789f28ecb278c3b5d49db7ece10d6d", size = 6667605 }, + { url = "https://files.pythonhosted.org/packages/76/99/34d20e50b3d894bb16b5374bfbee399ab8ff3a33bf1e1f0b8acfe7bbd70d/numpy-2.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e500aba968a48e9019e42c0c199b7ec0696a97fa69037bea163b55398e390529", size = 14013275 }, + { url = "https://files.pythonhosted.org/packages/69/8f/a1df7bd02d434ab82539517d1b98028985700cfc4300bc5496fb140ca648/numpy-2.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:440cfb3db4c5029775803794f8638fbdbf71ec702caf32735f53b008e1eaece3", size = 16074900 }, + { url = "https://files.pythonhosted.org/packages/04/94/b419e7a76bf21a00fcb03c613583f10e389fdc8dfe420412ff5710c8ad3d/numpy-2.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a55dc7a7f0b6198b07ec0cd445fbb98b05234e8b00c5ac4874a63372ba98d4ab", size = 15219122 }, + { url = "https://files.pythonhosted.org/packages/65/d9/dddf398b2b6c5d750892a207a469c2854a8db0f033edaf72103af8cf05aa/numpy-2.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4bddbaa30d78c86329b26bd6aaaea06b1e47444da99eddac7bf1e2fab717bd72", size = 17851668 }, + { url = "https://files.pythonhosted.org/packages/d4/dc/09a4e5819a9782a213c0eb4eecacdc1cd75ad8dac99279b04cfccb7eeb0a/numpy-2.2.0-cp313-cp313t-win32.whl", hash = "sha256:30bf971c12e4365153afb31fc73f441d4da157153f3400b82db32d04de1e4066", size = 6325288 }, + { url = "https://files.pythonhosted.org/packages/ce/e1/e0d06ec34036c92b43aef206efe99a5f5f04e12c776eab82a36e00c40afc/numpy-2.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d35717333b39d1b6bb8433fa758a55f1081543de527171543a2b710551d40881", size = 12692303 }, + { url = "https://files.pythonhosted.org/packages/f3/18/6d4e1274f221073058b621f4df8050958b7564b24b4fa25be9f1b7639274/numpy-2.2.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e12c6c1ce84628c52d6367863773f7c8c8241be554e8b79686e91a43f1733773", size = 21043901 }, + { url = "https://files.pythonhosted.org/packages/19/3e/2b20599e7ead7ae1b89a77bb34f88c5ec12e43fbb320576ed646388d2cb7/numpy-2.2.0-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:b6207dc8fb3c8cb5668e885cef9ec7f70189bec4e276f0ff70d5aa078d32c88e", size = 6789122 }, + { url = "https://files.pythonhosted.org/packages/c9/5a/378954132c192fafa6c3d5c160092a427c7562e5bda0cc6ad9cc37008a7a/numpy-2.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a50aeff71d0f97b6450d33940c7181b08be1441c6c193e678211bff11aa725e7", size = 16194018 }, + { url = "https://files.pythonhosted.org/packages/67/17/209bda34fc83f3436834392f44643e66dcf3c77465f232102e7f1c7d8eae/numpy-2.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:df12a1f99b99f569a7c2ae59aa2d31724e8d835fc7f33e14f4792e3071d11221", size = 12819486 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, +] + +[[package]] +name = "pdbpp" +version = "0.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fancycompleter" }, + { name = "pygments" }, + { name = "wmctrl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/a3/c4bd048256fd4b7d28767ca669c505e156f24d16355505c62e6fce3314df/pdbpp-0.10.3.tar.gz", hash = "sha256:d9e43f4fda388eeb365f2887f4e7b66ac09dce9b6236b76f63616530e2f669f5", size = 68116 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/ee/491e63a57fffa78b9de1c337b06c97d0cd0753e88c00571c7b011680332a/pdbpp-0.10.3-py2.py3-none-any.whl", hash = "sha256:79580568e33eb3d6f6b462b1187f53e10cd8e4538f7d31495c9181e2cf9665d1", size = 23961 }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, +] + +[[package]] +name = "pint" +version = "0.24.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flexcache" }, + { name = "flexparser" }, + { name = "platformdirs" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/bb/52b15ddf7b7706ed591134a895dbf6e41c8348171fb635e655e0a4bbb0ea/pint-0.24.4.tar.gz", hash = "sha256:35275439b574837a6cd3020a5a4a73645eb125ce4152a73a2f126bf164b91b80", size = 342225 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/16/bd2f5904557265882108dc2e04f18abc05ab0c2b7082ae9430091daf1d5c/Pint-0.24.4-py3-none-any.whl", hash = "sha256:aa54926c8772159fcf65f82cc0d34de6768c151b32ad1deb0331291c38fe7659", size = 302029 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pre-commit" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713 }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/4f/feb5e137aff82f7c7f3248267b97451da3644f6cdc218edfe549fb354127/prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90", size = 424684 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/6a/fd08d94654f7e67c52ca30523a178b3f8ccc4237fce4be90d39c938a831a/prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e", size = 386595 }, +] + +[[package]] +name = "psutil" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/10/2a30b13c61e7cf937f4adf90710776b7918ed0a9c434e2c38224732af310/psutil-6.1.0.tar.gz", hash = "sha256:353815f59a7f64cdaca1c0307ee13558a0512f6db064e92fe833784f08539c7a", size = 508565 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/9e/8be43078a171381953cfee33c07c0d628594b5dbfc5157847b85022c2c1b/psutil-6.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6e2dcd475ce8b80522e51d923d10c7871e45f20918e027ab682f94f1c6351688", size = 247762 }, + { url = "https://files.pythonhosted.org/packages/1d/cb/313e80644ea407f04f6602a9e23096540d9dc1878755f3952ea8d3d104be/psutil-6.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0895b8414afafc526712c498bd9de2b063deaac4021a3b3c34566283464aff8e", size = 248777 }, + { url = "https://files.pythonhosted.org/packages/65/8e/bcbe2025c587b5d703369b6a75b65d41d1367553da6e3f788aff91eaf5bd/psutil-6.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9dcbfce5d89f1d1f2546a2090f4fcf87c7f669d1d90aacb7d7582addece9fb38", size = 284259 }, + { url = "https://files.pythonhosted.org/packages/58/4d/8245e6f76a93c98aab285a43ea71ff1b171bcd90c9d238bf81f7021fb233/psutil-6.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:498c6979f9c6637ebc3a73b3f87f9eb1ec24e1ce53a7c5173b8508981614a90b", size = 287255 }, + { url = "https://files.pythonhosted.org/packages/27/c2/d034856ac47e3b3cdfa9720d0e113902e615f4190d5d1bdb8df4b2015fb2/psutil-6.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d905186d647b16755a800e7263d43df08b790d709d575105d419f8b6ef65423a", size = 288804 }, + { url = "https://files.pythonhosted.org/packages/ea/55/5389ed243c878725feffc0d6a3bc5ef6764312b6fc7c081faaa2cfa7ef37/psutil-6.1.0-cp37-abi3-win32.whl", hash = "sha256:1ad45a1f5d0b608253b11508f80940985d1d0c8f6111b5cb637533a0e6ddc13e", size = 250386 }, + { url = "https://files.pythonhosted.org/packages/11/91/87fa6f060e649b1e1a7b19a4f5869709fbf750b7c8c262ee776ec32f3028/psutil-6.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:a8fb3752b491d246034fa4d279ff076501588ce8cbcdbb62c32fd7a377d996be", size = 254228 }, +] + +[[package]] +name = "psygnal" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/b0/194cfbcb76dbf9c4a1c4271ccc825b38406d20fb7f95fd18320c28708800/psygnal-0.11.1.tar.gz", hash = "sha256:f9b02ca246ab0adb108c4010b4a486e464f940543201074591e50370cd7b0cc0", size = 102103 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/bf/2dee9491518402489909c0613004d3a0f79672f27ce16aae774c5addc506/psygnal-0.11.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:8d9187700fc608abefeb287bf2e0980a26c62471921ffd1a3cd223ccc554181b", size = 433484 }, + { url = "https://files.pythonhosted.org/packages/66/0a/52b7e40f4c7ec82c9809c62e568ee9c117dd911d3f6f562ac3007a4ad969/psygnal-0.11.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:cec87aee468a1fe564094a64bc3c30edc86ce34d7bb37ab69332c7825b873396", size = 462250 }, + { url = "https://files.pythonhosted.org/packages/c3/3f/ae610fd14cdbae8735344abfc7f67c76ff8bcf18e0e3c5f26a1ca590014e/psygnal-0.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7676e89225abc2f37ca7022c300ffd26fefaf21bdc894bc7c41dffbad5e969df", size = 727435 }, + { url = "https://files.pythonhosted.org/packages/a5/93/8d91aef01261123640406d132add52973e16d74b6c6e63b6fb54cc261f1e/psygnal-0.11.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c392f638aac2cdc4f13fffb904455224ae9b4dbb2f26d7f3264e4208fee5334d", size = 706104 }, + { url = "https://files.pythonhosted.org/packages/a6/a8/ed06fe70c8bd03f02ab0c1df020f53f079a6dbae056eba0a91823c0d1242/psygnal-0.11.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:3c04baec10f882cdf784a7312e23892416188417ad85607e6d1de2e8a9e70709", size = 427499 }, + { url = "https://files.pythonhosted.org/packages/25/92/6dcab17c3bb91fa3f250ebdbb66de55332436da836c4c547c26e3942877e/psygnal-0.11.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:8f77317cbd11fbed5bfdd40ea41b4e551ee0cf37881cdbc325b67322af577485", size = 453373 }, + { url = "https://files.pythonhosted.org/packages/84/6f/868f1d7d22c76b96e0c8a75f8eb196deaff83916ad2da7bd78d1d0f6a5df/psygnal-0.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24e69ea57ee39e3677298f38a18828af87cdc0bf0aa64685d44259e608bae3ec", size = 717571 }, + { url = "https://files.pythonhosted.org/packages/da/7d/24ca61d177b26e6ab89e9c520dca9c6341083920ab0ea8ac763a31b2b029/psygnal-0.11.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d77f1a71fe9859c0335c87d92afe1b17c520a4137326810e94351839342d8fc7", size = 695336 }, + { url = "https://files.pythonhosted.org/packages/33/5d/9b2d8f91a9198dda6ad0eaa276f975207b1314ac2d22a2f905f0a6e34524/psygnal-0.11.1-cp312-cp312-macosx_10_16_arm64.whl", hash = "sha256:0b55cb42e468f3a7de75392520778604fef2bc518b7df36c639b35ce4ed92016", size = 425244 }, + { url = "https://files.pythonhosted.org/packages/c4/66/e1bd57a8efef6582141939876d014f86792adbbb8853bd475a1cbf3649ca/psygnal-0.11.1-cp312-cp312-macosx_10_16_x86_64.whl", hash = "sha256:c7dd3cf809c9c1127d90c6b11fbbd1eb2d66d512ccd4d5cab048786f13d11220", size = 444681 }, + { url = "https://files.pythonhosted.org/packages/49/ad/8ee3f8ac1d59cf269ae2d55f7cac7c65fe3b3f41cada5d6a17bc2f4c5d6d/psygnal-0.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:885922a6e65ece9ff8ccf2b6810f435ca8067f410889f7a8fffb6b0d61421a0d", size = 743785 }, + { url = "https://files.pythonhosted.org/packages/14/54/b29b854dff0e27bdaf42a7c1edc65f6d3ea35866e9d9250f1dbabf6381a0/psygnal-0.11.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1c2388360a9ffcd1381e9b36d0f794287a270d58e69bf17658a194bbf86685c1", size = 725134 }, + { url = "https://files.pythonhosted.org/packages/68/76/d5c5bf5a932ec2dcdc4a23565815a1cc5fd96b03b26ff3f647cdff5ea62c/psygnal-0.11.1-py3-none-any.whl", hash = "sha256:04255fe28828060a80320f8fda937c47bc0c21ca14f55a13eb7c494b165ea395", size = 76998 }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, +] + +[[package]] +name = "pyconify" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/21/8b703dd994e4833dcfa611536485ce2096c215aa74cbf610c2ff51a7dea8/pyconify-0.1.6.tar.gz", hash = "sha256:25272f7a29965f32ee698c9da6aab8bbbeb0756ca3bfeadd3d9effab689d239d", size = 22236 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/fc/be68d2acc43d22dbfe6d52d573dc83da14e4fd45821acaf42992e8803682/pyconify-0.1.6-py3-none-any.whl", hash = "sha256:63040836b344ec351c05531231609edd30425c145ff68478b6250f33135b8fc5", size = 18929 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pydantic" +version = "2.10.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/0f/27908242621b14e649a84e62b133de45f84c255eecb350ab02979844a788/pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9", size = 786486 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/51/72c18c55cf2f46ff4f91ebcc8f75aa30f7305f3d726be3f4ebffb4ae972b/pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d", size = 456997 }, +] + +[[package]] +name = "pydantic-core" +version = "2.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/9f/7de1f19b6aea45aeb441838782d68352e71bfa98ee6fa048d5041991b33e/pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", size = 412785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/ce/60fd96895c09738648c83f3f00f595c807cb6735c70d3306b548cc96dd49/pydantic_core-2.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a", size = 1897984 }, + { url = "https://files.pythonhosted.org/packages/fd/b9/84623d6b6be98cc209b06687d9bca5a7b966ffed008d15225dd0d20cce2e/pydantic_core-2.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b", size = 1807491 }, + { url = "https://files.pythonhosted.org/packages/01/72/59a70165eabbc93b1111d42df9ca016a4aa109409db04304829377947028/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278", size = 1831953 }, + { url = "https://files.pythonhosted.org/packages/7c/0c/24841136476adafd26f94b45bb718a78cb0500bd7b4f8d667b67c29d7b0d/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05", size = 1856071 }, + { url = "https://files.pythonhosted.org/packages/53/5e/c32957a09cceb2af10d7642df45d1e3dbd8596061f700eac93b801de53c0/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4", size = 2038439 }, + { url = "https://files.pythonhosted.org/packages/e4/8f/979ab3eccd118b638cd6d8f980fea8794f45018255a36044dea40fe579d4/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f", size = 2787416 }, + { url = "https://files.pythonhosted.org/packages/02/1d/00f2e4626565b3b6d3690dab4d4fe1a26edd6a20e53749eb21ca892ef2df/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08", size = 2134548 }, + { url = "https://files.pythonhosted.org/packages/9d/46/3112621204128b90898adc2e721a3cd6cf5626504178d6f32c33b5a43b79/pydantic_core-2.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6", size = 1989882 }, + { url = "https://files.pythonhosted.org/packages/49/ec/557dd4ff5287ffffdf16a31d08d723de6762bb1b691879dc4423392309bc/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807", size = 1995829 }, + { url = "https://files.pythonhosted.org/packages/6e/b2/610dbeb74d8d43921a7234555e4c091cb050a2bdb8cfea86d07791ce01c5/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c", size = 2091257 }, + { url = "https://files.pythonhosted.org/packages/8c/7f/4bf8e9d26a9118521c80b229291fa9558a07cdd9a968ec2d5c1026f14fbc/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206", size = 2143894 }, + { url = "https://files.pythonhosted.org/packages/1f/1c/875ac7139c958f4390f23656fe696d1acc8edf45fb81e4831960f12cd6e4/pydantic_core-2.27.1-cp310-none-win32.whl", hash = "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c", size = 1816081 }, + { url = "https://files.pythonhosted.org/packages/d7/41/55a117acaeda25ceae51030b518032934f251b1dac3704a53781383e3491/pydantic_core-2.27.1-cp310-none-win_amd64.whl", hash = "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17", size = 1981109 }, + { url = "https://files.pythonhosted.org/packages/27/39/46fe47f2ad4746b478ba89c561cafe4428e02b3573df882334bd2964f9cb/pydantic_core-2.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8", size = 1895553 }, + { url = "https://files.pythonhosted.org/packages/1c/00/0804e84a78b7fdb394fff4c4f429815a10e5e0993e6ae0e0b27dd20379ee/pydantic_core-2.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330", size = 1807220 }, + { url = "https://files.pythonhosted.org/packages/01/de/df51b3bac9820d38371f5a261020f505025df732ce566c2a2e7970b84c8c/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52", size = 1829727 }, + { url = "https://files.pythonhosted.org/packages/5f/d9/c01d19da8f9e9fbdb2bf99f8358d145a312590374d0dc9dd8dbe484a9cde/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4", size = 1854282 }, + { url = "https://files.pythonhosted.org/packages/5f/84/7db66eb12a0dc88c006abd6f3cbbf4232d26adfd827a28638c540d8f871d/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c", size = 2037437 }, + { url = "https://files.pythonhosted.org/packages/34/ac/a2537958db8299fbabed81167d58cc1506049dba4163433524e06a7d9f4c/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de", size = 2780899 }, + { url = "https://files.pythonhosted.org/packages/4a/c1/3e38cd777ef832c4fdce11d204592e135ddeedb6c6f525478a53d1c7d3e5/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025", size = 2135022 }, + { url = "https://files.pythonhosted.org/packages/7a/69/b9952829f80fd555fe04340539d90e000a146f2a003d3fcd1e7077c06c71/pydantic_core-2.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e", size = 1987969 }, + { url = "https://files.pythonhosted.org/packages/05/72/257b5824d7988af43460c4e22b63932ed651fe98804cc2793068de7ec554/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919", size = 1994625 }, + { url = "https://files.pythonhosted.org/packages/73/c3/78ed6b7f3278a36589bcdd01243189ade7fc9b26852844938b4d7693895b/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c", size = 2090089 }, + { url = "https://files.pythonhosted.org/packages/8d/c8/b4139b2f78579960353c4cd987e035108c93a78371bb19ba0dc1ac3b3220/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc", size = 2142496 }, + { url = "https://files.pythonhosted.org/packages/3e/f8/171a03e97eb36c0b51981efe0f78460554a1d8311773d3d30e20c005164e/pydantic_core-2.27.1-cp311-none-win32.whl", hash = "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9", size = 1811758 }, + { url = "https://files.pythonhosted.org/packages/6a/fe/4e0e63c418c1c76e33974a05266e5633e879d4061f9533b1706a86f77d5b/pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5", size = 1980864 }, + { url = "https://files.pythonhosted.org/packages/50/fc/93f7238a514c155a8ec02fc7ac6376177d449848115e4519b853820436c5/pydantic_core-2.27.1-cp311-none-win_arm64.whl", hash = "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89", size = 1864327 }, + { url = "https://files.pythonhosted.org/packages/be/51/2e9b3788feb2aebff2aa9dfbf060ec739b38c05c46847601134cc1fed2ea/pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f", size = 1895239 }, + { url = "https://files.pythonhosted.org/packages/7b/9e/f8063952e4a7d0127f5d1181addef9377505dcce3be224263b25c4f0bfd9/pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02", size = 1805070 }, + { url = "https://files.pythonhosted.org/packages/2c/9d/e1d6c4561d262b52e41b17a7ef8301e2ba80b61e32e94520271029feb5d8/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c", size = 1828096 }, + { url = "https://files.pythonhosted.org/packages/be/65/80ff46de4266560baa4332ae3181fffc4488ea7d37282da1a62d10ab89a4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac", size = 1857708 }, + { url = "https://files.pythonhosted.org/packages/d5/ca/3370074ad758b04d9562b12ecdb088597f4d9d13893a48a583fb47682cdf/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb", size = 2037751 }, + { url = "https://files.pythonhosted.org/packages/b1/e2/4ab72d93367194317b99d051947c071aef6e3eb95f7553eaa4208ecf9ba4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529", size = 2733863 }, + { url = "https://files.pythonhosted.org/packages/8a/c6/8ae0831bf77f356bb73127ce5a95fe115b10f820ea480abbd72d3cc7ccf3/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35", size = 2161161 }, + { url = "https://files.pythonhosted.org/packages/f1/f4/b2fe73241da2429400fc27ddeaa43e35562f96cf5b67499b2de52b528cad/pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089", size = 1993294 }, + { url = "https://files.pythonhosted.org/packages/77/29/4bb008823a7f4cc05828198153f9753b3bd4c104d93b8e0b1bfe4e187540/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381", size = 2001468 }, + { url = "https://files.pythonhosted.org/packages/f2/a9/0eaceeba41b9fad851a4107e0cf999a34ae8f0d0d1f829e2574f3d8897b0/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb", size = 2091413 }, + { url = "https://files.pythonhosted.org/packages/d8/36/eb8697729725bc610fd73940f0d860d791dc2ad557faaefcbb3edbd2b349/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae", size = 2154735 }, + { url = "https://files.pythonhosted.org/packages/52/e5/4f0fbd5c5995cc70d3afed1b5c754055bb67908f55b5cb8000f7112749bf/pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c", size = 1833633 }, + { url = "https://files.pythonhosted.org/packages/ee/f2/c61486eee27cae5ac781305658779b4a6b45f9cc9d02c90cb21b940e82cc/pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16", size = 1986973 }, + { url = "https://files.pythonhosted.org/packages/df/a6/e3f12ff25f250b02f7c51be89a294689d175ac76e1096c32bf278f29ca1e/pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e", size = 1883215 }, + { url = "https://files.pythonhosted.org/packages/0f/d6/91cb99a3c59d7b072bded9959fbeab0a9613d5a4935773c0801f1764c156/pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073", size = 1895033 }, + { url = "https://files.pythonhosted.org/packages/07/42/d35033f81a28b27dedcade9e967e8a40981a765795c9ebae2045bcef05d3/pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08", size = 1807542 }, + { url = "https://files.pythonhosted.org/packages/41/c2/491b59e222ec7e72236e512108ecad532c7f4391a14e971c963f624f7569/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf", size = 1827854 }, + { url = "https://files.pythonhosted.org/packages/e3/f3/363652651779113189cefdbbb619b7b07b7a67ebb6840325117cc8cc3460/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737", size = 1857389 }, + { url = "https://files.pythonhosted.org/packages/5f/97/be804aed6b479af5a945daec7538d8bf358d668bdadde4c7888a2506bdfb/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2", size = 2037934 }, + { url = "https://files.pythonhosted.org/packages/42/01/295f0bd4abf58902917e342ddfe5f76cf66ffabfc57c2e23c7681a1a1197/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107", size = 2735176 }, + { url = "https://files.pythonhosted.org/packages/9d/a0/cd8e9c940ead89cc37812a1a9f310fef59ba2f0b22b4e417d84ab09fa970/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51", size = 2160720 }, + { url = "https://files.pythonhosted.org/packages/73/ae/9d0980e286627e0aeca4c352a60bd760331622c12d576e5ea4441ac7e15e/pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a", size = 1992972 }, + { url = "https://files.pythonhosted.org/packages/bf/ba/ae4480bc0292d54b85cfb954e9d6bd226982949f8316338677d56541b85f/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc", size = 2001477 }, + { url = "https://files.pythonhosted.org/packages/55/b7/e26adf48c2f943092ce54ae14c3c08d0d221ad34ce80b18a50de8ed2cba8/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960", size = 2091186 }, + { url = "https://files.pythonhosted.org/packages/ba/cc/8491fff5b608b3862eb36e7d29d36a1af1c945463ca4c5040bf46cc73f40/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23", size = 2154429 }, + { url = "https://files.pythonhosted.org/packages/78/d8/c080592d80edd3441ab7f88f865f51dae94a157fc64283c680e9f32cf6da/pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05", size = 1833713 }, + { url = "https://files.pythonhosted.org/packages/83/84/5ab82a9ee2538ac95a66e51f6838d6aba6e0a03a42aa185ad2fe404a4e8f/pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337", size = 1987897 }, + { url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983 }, + { url = "https://files.pythonhosted.org/packages/7c/60/e5eb2d462595ba1f622edbe7b1d19531e510c05c405f0b87c80c1e89d5b1/pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6", size = 1894016 }, + { url = "https://files.pythonhosted.org/packages/61/20/da7059855225038c1c4326a840908cc7ca72c7198cb6addb8b92ec81c1d6/pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676", size = 1771648 }, + { url = "https://files.pythonhosted.org/packages/8f/fc/5485cf0b0bb38da31d1d292160a4d123b5977841ddc1122c671a30b76cfd/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d", size = 1826929 }, + { url = "https://files.pythonhosted.org/packages/a1/ff/fb1284a210e13a5f34c639efc54d51da136074ffbe25ec0c279cf9fbb1c4/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c", size = 1980591 }, + { url = "https://files.pythonhosted.org/packages/f1/14/77c1887a182d05af74f6aeac7b740da3a74155d3093ccc7ee10b900cc6b5/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27", size = 1981326 }, + { url = "https://files.pythonhosted.org/packages/06/aa/6f1b2747f811a9c66b5ef39d7f02fbb200479784c75e98290d70004b1253/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f", size = 1989205 }, + { url = "https://files.pythonhosted.org/packages/7a/d2/8ce2b074d6835f3c88d85f6d8a399790043e9fdb3d0e43455e72d19df8cc/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed", size = 2079616 }, + { url = "https://files.pythonhosted.org/packages/65/71/af01033d4e58484c3db1e5d13e751ba5e3d6b87cc3368533df4c50932c8b/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f", size = 2133265 }, + { url = "https://files.pythonhosted.org/packages/33/72/f881b5e18fbb67cf2fb4ab253660de3c6899dbb2dba409d0b757e3559e3d/pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c", size = 2001864 }, +] + +[[package]] +name = "pygments" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, +] + +[[package]] +name = "pymmcore" +version = "11.1.1.71.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/b5/62dd1859d013be8a6e415fe4c8dae6a9d8bc66e7619f1523eb36ff2d197e/pymmcore-11.1.1.71.3.tar.gz", hash = "sha256:e163bc4412d45e361405ecb80cd135be7648ae308b6dc579c0c7e12139227204", size = 190180 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/88/4a46e0366652433c676c9826e484d0dad703a5e64506ca7897a52c39b9a6/pymmcore-11.1.1.71.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99fd7e068a0721fde517ebd0104d297100483f23134d6de62d29c2c96e3f9bc8", size = 945627 }, + { url = "https://files.pythonhosted.org/packages/f6/04/c5dcebaafafed1f8c8bfa1659ed8d08488d1923caa829347276562c4fb6d/pymmcore-11.1.1.71.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3d7b76a59f233c7c2303193456f3cd6fe34eaf90d44082105884381e5746ae81", size = 881854 }, + { url = "https://files.pythonhosted.org/packages/20/05/b549104eea850ce6e58140c5a900ad649ea23d1e17f968006e309d8b95d0/pymmcore-11.1.1.71.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c051ffe4b8e290e0d11f707c983557aaa8e6a7d506f292aebb38f4dd0831472b", size = 1008317 }, + { url = "https://files.pythonhosted.org/packages/55/bf/32d67999065b4840a976fdc5adcba3da62054ce2516c58f1ee33f6e75393/pymmcore-11.1.1.71.3-cp310-cp310-win_amd64.whl", hash = "sha256:bd101428d768259892d6b2924c71418e1a86a35b6be75a8f0104a3e77ee1327b", size = 582477 }, + { url = "https://files.pythonhosted.org/packages/82/db/3768693b215f6e52e8a28e5347fcefe20fd737b0d8f6533254dce42d3453/pymmcore-11.1.1.71.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:07587f497f4e2f3475bbc0d8715554f6118a5b586114a22941e8696687468e2f", size = 945626 }, + { url = "https://files.pythonhosted.org/packages/6b/ec/e1f7f290540c50d745618510b2fdf261e7acd43836334d0c64b56045c90d/pymmcore-11.1.1.71.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:31d49684c8e9934f5ccf59f3e4f0119a21b45d5271cc39cdeaebad4fd1266bef", size = 882309 }, + { url = "https://files.pythonhosted.org/packages/3c/49/e0decfd8495381fcae48b8ed315b69402e2aea2241d3276b745a37cd5077/pymmcore-11.1.1.71.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87287f99b64053940b2f5189470769f91043915e642e7fef8897679b10b27b4b", size = 1008455 }, + { url = "https://files.pythonhosted.org/packages/ee/e4/9e3960a27d3c68d33905c633976ccca99a44e00c80427fca1aaceb378dcb/pymmcore-11.1.1.71.3-cp311-cp311-win_amd64.whl", hash = "sha256:8dfdbe00e9c7d22a48f4e2cc03162bb359189f3934285c655d3351fe10af3506", size = 582547 }, + { url = "https://files.pythonhosted.org/packages/b0/fb/d5248648a641896ecf25124711d6bad1d1e5140f25b50005e115d202657c/pymmcore-11.1.1.71.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:0ca03e6c1a15adb4500d3c2b9f5436f071a2d8f2d59e2bf1113bee8d2f927d48", size = 950912 }, + { url = "https://files.pythonhosted.org/packages/8c/6f/5d2244424ee268bc8f281b58eb07a7f7124da483557af9f91d1f776a5b43/pymmcore-11.1.1.71.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b92eb362f6eb382e9267066f666aba91645b03962579f892efafe6ebd340d8a9", size = 885473 }, + { url = "https://files.pythonhosted.org/packages/43/88/3d36e49c5cb321591025a396e61290fb0615e08b86e92c1f33f34f92baef/pymmcore-11.1.1.71.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c284898eb41af0ecd320bf14c9d3f28de4a06615a4800983d8788b6c7bf2fe4e", size = 1007823 }, + { url = "https://files.pythonhosted.org/packages/8c/cf/1354c7a208fe4cc540b693bbcf0a25b6efa353e9446c8187593046309da0/pymmcore-11.1.1.71.3-cp312-cp312-win_amd64.whl", hash = "sha256:501eb729e16ef8fc9a9a00371086f039790f77d1ee8f909dcef6dadc42e0e426", size = 583073 }, +] + +[[package]] +name = "pymmcore-gui" +version = "0.1.dev222+gc9d09ab.d20241217" +source = { editable = "." } +dependencies = [ + { name = "ndv", extra = ["vispy"] }, + { name = "pymmcore-plus", extra = ["cli"] }, + { name = "pymmcore-widgets" }, + { name = "pyqt6" }, + { name = "pyyaml" }, + { name = "qtconsole" }, + { name = "qtpy" }, + { name = "superqt", extra = ["cmap", "iconify"] }, + { name = "tifffile" }, + { name = "tqdm" }, + { name = "zarr" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ipython" }, + { name = "mypy" }, + { name = "pdbpp", marker = "sys_platform != 'windows'" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-qt" }, + { name = "rich" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "ndv", extras = ["vispy"], specifier = "==0.0.4" }, + { name = "pymmcore-plus", extras = ["cli"], specifier = ">=0.12.0" }, + { name = "pymmcore-widgets", specifier = ">=0.8.0" }, + { name = "pyqt6", specifier = "==6.7.1" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "qtconsole", specifier = ">=5.6.1" }, + { name = "qtpy", specifier = ">=2.4.2" }, + { name = "superqt", extras = ["cmap", "iconify"], specifier = ">=0.7.0" }, + { name = "tifffile", specifier = ">=2024.12.12" }, + { name = "tqdm", specifier = ">=4.67.1" }, + { name = "zarr", specifier = ">=2.18.3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "ipython", specifier = ">=8.30.0" }, + { name = "mypy", specifier = ">=1.13.0" }, + { name = "pdbpp", marker = "sys_platform != 'windows'", specifier = ">=0.10.3" }, + { name = "pre-commit", specifier = ">=4.0.1" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "pytest-qt", specifier = ">=4.4.0" }, + { name = "rich", specifier = ">=13.9.4" }, + { name = "ruff", specifier = ">=0.8.3" }, +] + +[[package]] +name = "pymmcore-plus" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "platformdirs" }, + { name = "psygnal" }, + { name = "pymmcore" }, + { name = "rich" }, + { name = "tensorstore" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "useq-schema" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/40/50023cff76ee31860b361f2bf49095096e3b5776ec192a70d37be8694aba/pymmcore_plus-0.12.0.tar.gz", hash = "sha256:9971d596ffc6357a6f42b1193be3c8abd6ef786ab308ffeb8d13ca35785c5286", size = 139704 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/88/c3a94f65b527aa4631426bed957add0e4e0bcc531a9686ff1cb73fc5efab/pymmcore_plus-0.12.0-py3-none-any.whl", hash = "sha256:17230f98aba055446c7927f53d58156edfa5e9f5b2d23c9c2b52f8e04a7d24d9", size = 142730 }, +] + +[package.optional-dependencies] +cli = [ + { name = "rich" }, + { name = "typer" }, +] + +[[package]] +name = "pymmcore-widgets" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fonticon-materialdesignicons6" }, + { name = "pymmcore-plus", extra = ["cli"] }, + { name = "qtpy" }, + { name = "superqt", extra = ["quantity"] }, + { name = "useq-schema" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/c9/245a255938d901c327dfe88f64e54a18d42576ec187f0023514a72b93bd9/pymmcore_widgets-0.8.0.tar.gz", hash = "sha256:69dd12904ffe4f2c3ab0925c358d31dbb38ba94efc0ffd5c95f6a6e95d11a4fc", size = 167619 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/51/e1081441a1ef088fffeb1c4a53a4a1ccc92fc5fb74057de39bbee1ffacfe/pymmcore_widgets-0.8.0-py3-none-any.whl", hash = "sha256:c7e2ddcce01ff6782303cbe13e2acf7611dce2aa53aa9e1c6aa0506e07803847", size = 193083 }, +] + +[[package]] +name = "pyopengl" +version = "3.1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/b6/970868d44b619292f1f54501923c69c9bd0ab1d2d44cf02590eac2706f4f/PyOpenGL-3.1.7.tar.gz", hash = "sha256:eef31a3888e6984fd4d8e6c9961b184c9813ca82604d37fe3da80eb000a76c86", size = 1896446 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/48/00e31747821d3fc56faddd00a4725454d1e694a8b67d715cf20f531506a5/PyOpenGL-3.1.7-py3-none-any.whl", hash = "sha256:a6ab19cf290df6101aaf7470843a9c46207789855746399d0af92521a0a92b7a", size = 2416834 }, +] + +[[package]] +name = "pyqt6" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyqt6-qt6" }, + { name = "pyqt6-sip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/f9/b0c2ba758b14a7219e076138ea1e738c068bf388e64eee68f3df4fc96f5a/PyQt6-6.7.1.tar.gz", hash = "sha256:3672a82ccd3a62e99ab200a13903421e2928e399fda25ced98d140313ad59cb9", size = 1051212 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/b0/20a05cfe287a1bc5a034cfed002bb1999f71c15e53a6ab7886c010ea0ba3/PyQt6-6.7.1-1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7f397f4b38b23b5588eb2c0933510deb953d96b1f0323a916c4839c2a66ccccc", size = 8020146 }, + { url = "https://files.pythonhosted.org/packages/e4/d3/8789879c05cfe06127c4b59258632bd175fcdd9eaaadaf0c897b458fb91d/PyQt6-6.7.1-1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2f202b7941aa74e5c7e1463a6f27d9131dbc1e6cabe85571d7364f5b3de7397", size = 8227345 }, + { url = "https://files.pythonhosted.org/packages/15/2b/a0c516931697214dcb93b24a62f54b7467194ba1c76f3f7a55cb3a120cc9/PyQt6-6.7.1-cp38-abi3-macosx_11_0_universal2.whl", hash = "sha256:f053378e3aef6248fa612c8afddda17f942fb63f9fe8a9aeb2a6b6b4cbb0eba9", size = 11871174 }, + { url = "https://files.pythonhosted.org/packages/59/8c/3b528f5fa8dfc3d0ba07d8da37ea72dfc59352d80804a12507d7080efb30/PyQt6-6.7.1-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0adb7914c732ad1dee46d9cec838a98cb2b11bc38cc3b7b36fbd8701ae64bf47", size = 7999939 }, + { url = "https://files.pythonhosted.org/packages/d8/58/5082dd3654da2b17de19057f181526df566f38af90f517cb8a541bea0890/PyQt6-6.7.1-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2d771fa0981514cb1ee937633dfa64f14caa902707d9afffab66677f3a73e3da", size = 8177790 }, + { url = "https://files.pythonhosted.org/packages/a3/69/99d22ee685c08a99fcf2048d366fe6173ba6e43ee13b95a3a2ac2911c52c/PyQt6-6.7.1-cp38-abi3-win_amd64.whl", hash = "sha256:fa3954698233fe286a8afc477b84d8517f0788eb46b74da69d3ccc0170d3714c", size = 6596360 }, +] + +[[package]] +name = "pyqt6-qt6" +version = "6.7.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/a2/9ef7c001068da2d3c8c37fe0e1e0451b1073d47c6ef4e44abf5883559963/PyQt6_Qt6-6.7.3-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:f517a93b6b1a814d4aa6587adc312e812ebaf4d70415bb15cfb44268c5ad3f5f", size = 49136114 }, + { url = "https://files.pythonhosted.org/packages/ec/63/a85bdd7c66800208f0af417bb4d07cb1543a75384021e4594e66d919f855/PyQt6_Qt6-6.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8551732984fb36a5f4f3db51eafc4e8e6caf18617365830285306f2db17a94c2", size = 45762813 }, + { url = "https://files.pythonhosted.org/packages/8a/6c/4f329f83a6082a7b4c1dc6046e2c48edb72e0d6d0ca3f8d0701fe134dccf/PyQt6_Qt6-6.7.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:50c7482bcdcf2bb78af257fb10ed8b582f8daf91d829782393bc50ac5a0a900c", size = 63801442 }, + { url = "https://files.pythonhosted.org/packages/88/4d/26ca7239f7223e5b95b58a58537a09b069582ebb4dfa38234113a9f898ab/PyQt6_Qt6-6.7.3-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cb525fdd393332de60887953029276a44de480fce1d785251ae639580f5e7246", size = 74366973 }, + { url = "https://files.pythonhosted.org/packages/7e/57/3b44f6af1020fa543bd564c5bd346ba4aab1f1be0b861c2e8a0ad88cf3ca/PyQt6_Qt6-6.7.3-py3-none-win_amd64.whl", hash = "sha256:36ea0892b8caeb983af3f285f45fb8dfbb93cfd972439f4e01b7efb2868f6230", size = 58467498 }, +] + +[[package]] +name = "pyqt6-sip" +version = "13.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/e4/f39ca1fd6de7d4823d964a3ec33e85b6f51a9c2a7a1e95956b7a92c8acc9/pyqt6_sip-13.9.1.tar.gz", hash = "sha256:15be741d1ae8c82bb7afe9a61f3cf8c50457f7d61229a1c39c24cd6e8f4d86dc", size = 92358 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/11/b662dbc75c575ba8b61a5ee7aa8315ee25803d478a832e4da176d93ee407/PyQt6_sip-13.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e996d320744ca8342cad6f9454345330d4f06bce129812d032bda3bad6967c5c", size = 110729 }, + { url = "https://files.pythonhosted.org/packages/19/56/d86d586b6b47aea373b80e50322cba79b3fb7e962a2e0c892af963f86983/PyQt6_sip-13.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ab85aaf155828331399c59ebdd4d3b0358e42c08250e86b43d56d9873df148a", size = 305452 }, + { url = "https://files.pythonhosted.org/packages/02/74/2ecfb35a0f2a213444e194e42114b2b2d58f227b6d4607d9b2ca52b53256/PyQt6_sip-13.9.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:22d66256b800f552ade51a463510bf905f3cb318aae00ff4288fae4de5d0e600", size = 285032 }, + { url = "https://files.pythonhosted.org/packages/d4/46/242314ecc43920c9d91fe78c6e48ca65c851b7370b93cb4510ed761aba51/PyQt6_sip-13.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:5643c92424fe62cb0b33378fef3d28c1525f91ada79e8a15bd9a05414a09503d", size = 53366 }, + { url = "https://files.pythonhosted.org/packages/73/07/342ea1c95367d714eb86ae4b2159798347b80535dcba6c7f60256fc7c5e5/PyQt6_sip-13.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:57b5312ef13c1766bdf69b317041140b184eb24a51e1e23ce8fc5386ba8dffb2", size = 110717 }, + { url = "https://files.pythonhosted.org/packages/89/f2/13159c98929d2dec84cb98021a8de9e66e9429ebe44be08476779130af25/PyQt6_sip-13.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5004514b08b045ad76425cf3618187091a668d972b017677b1b4b193379ef553", size = 316880 }, + { url = "https://files.pythonhosted.org/packages/76/92/47fc35401d7f192bd690ed3552242909c6e77db5a97d8b2d980a948400fe/PyQt6_sip-13.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:accab6974b2758296400120fdcc9d1f37785b2ea2591f00656e1776f058ded6c", size = 294518 }, + { url = "https://files.pythonhosted.org/packages/39/7c/3c775c219d9c17bda783e1dbab1d16f09f7713b93920f761d2cc61aa3ad0/PyQt6_sip-13.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:1ec52e962f54137a19208b6e95b6bd9f7a403eb25d7237768a99306cd9db26d1", size = 53368 }, + { url = "https://files.pythonhosted.org/packages/81/bf/0700d0f5832e6e54ec56dbbdc912c63662ae54d1571f3241377b5acb37fa/PyQt6_sip-13.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6e6c1e2592187934f4e790c0c099d0033e986dcef7bdd3c06e3895ffa995e9fc", size = 45013 }, + { url = "https://files.pythonhosted.org/packages/34/19/6df209c4f1a3fbae0a90c8f37b0205ce42d61d8d762e4a539fceeabee340/PyQt6_sip-13.9.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1fb405615970e85b622b13b4cad140ff1e4182eb8334a0b27a4698e6217b89b0", size = 112248 }, + { url = "https://files.pythonhosted.org/packages/6b/81/9df86e90a8dd1f6f5faa1199af03e208f5dd3193e462e541e276001d098c/PyQt6_sip-13.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c800db3464481e87b1d2b84523b075df1e8fc7856c6f9623dc243f89be1cb604", size = 322310 }, + { url = "https://files.pythonhosted.org/packages/dd/4d/e263f045c97c2398fbd762d07295140b628a611da5f37744324080a86a1d/PyQt6_sip-13.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c1942e107b0243ced9e510d507e0f27aeea9d6b13e0a1b7c06fd52a62e0d41f7", size = 303638 }, + { url = "https://files.pythonhosted.org/packages/28/9f/047a3b28265b39ff151981da84fde7895b87b94131db4b044a6125df9a4a/PyQt6_sip-13.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:552ff8fdc41f5769d3eccc661f022ed496f55f6e0a214c20aaf56e56385d61b6", size = 53498 }, + { url = "https://files.pythonhosted.org/packages/92/af/17a92b67db31a086bd693bf97d98aa2f45427e7982df53f59cdc26899979/PyQt6_sip-13.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:976c7758f668806d4df7a8853f390ac123d5d1f73591ed368bdb8963574ff589", size = 45335 }, + { url = "https://files.pythonhosted.org/packages/4b/10/43eab7a9cd26f3ba81220f2ec7a7c471c232df738dd5787affcc00d75aa9/PyQt6_sip-13.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:56ce0afb19cd8a8c63ff93ae506dffb74f844b88adaa6673ebc0dec43af48a76", size = 112278 }, + { url = "https://files.pythonhosted.org/packages/31/ba/b0cb5ad4d44265213d50b8e7b24a9c1605baf242019e51104dd87499d68f/PyQt6_sip-13.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d7726556d1ca7a7ed78e19ba53285b64a2a8f6ad7ff4cb18a1832efca1a3102", size = 322531 }, + { url = "https://files.pythonhosted.org/packages/ba/73/556391d6115e87972d8c88b757b4db614af4fb8579ce28030965fe793fe7/PyQt6_sip-13.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:14f95c6352e3b85dc26bf59cfbf77a470ecbd5fcdcf00af4b648f0e1b9eefb9e", size = 303760 }, + { url = "https://files.pythonhosted.org/packages/d8/5b/7321bb2564673bc4facfed338553bc42542f2ae05cbea1c8b57a596ab28d/PyQt6_sip-13.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3c269052c770c09b61fce2f2f9ea934a67dfc65f443d59629b4ccc8f89751890", size = 53509 }, + { url = "https://files.pythonhosted.org/packages/cf/b6/eabe1d0fdcc56c80325c5a2cc361ae3e57b3346cb7700b707481d3977a24/PyQt6_sip-13.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:8b2ac36d6e04db6099614b9c1178a2f87788c7ffc3826571fb63d36ddb4c401d", size = 45358 }, +] + +[[package]] +name = "pyreadline" +version = "2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/7c/d724ef1ec3ab2125f38a1d53285745445ec4a8f19b9bb0761b4064316679/pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1", size = 109189 } + +[[package]] +name = "pyrepl" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/1b/ea40363be0056080454cdbabe880773c3c5bd66d7b13f0c8b8b8c8da1e0c/pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775", size = 48744 } + +[[package]] +name = "pytest" +version = "8.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, +] + +[[package]] +name = "pytest-cov" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, +] + +[[package]] +name = "pytest-qt" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/2c/6a477108342bbe1f5a81a2c54c86c3efadc35f6ad47c76f00c75764a0f7c/pytest-qt-4.4.0.tar.gz", hash = "sha256:76896142a940a4285339008d6928a36d4be74afec7e634577e842c9cc5c56844", size = 125443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/51/6cc5b9c1ecdcd78e6cde97e03d05f5a4ace8f720c5ce0f26f9dce474a0da/pytest_qt-4.4.0-py3-none-any.whl", hash = "sha256:001ed2f8641764b394cf286dc8a4203e40eaf9fff75bf0bfe5103f7f8d0c591d", size = 36286 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "pywin32" +version = "308" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/a6/3e9f2c474895c1bb61b11fa9640be00067b5c5b363c501ee9c3fa53aec01/pywin32-308-cp310-cp310-win32.whl", hash = "sha256:796ff4426437896550d2981b9c2ac0ffd75238ad9ea2d3bfa67a1abd546d262e", size = 5927028 }, + { url = "https://files.pythonhosted.org/packages/d9/b4/84e2463422f869b4b718f79eb7530a4c1693e96b8a4e5e968de38be4d2ba/pywin32-308-cp310-cp310-win_amd64.whl", hash = "sha256:4fc888c59b3c0bef905ce7eb7e2106a07712015ea1c8234b703a088d46110e8e", size = 6558484 }, + { url = "https://files.pythonhosted.org/packages/9f/8f/fb84ab789713f7c6feacaa08dad3ec8105b88ade8d1c4f0f0dfcaaa017d6/pywin32-308-cp310-cp310-win_arm64.whl", hash = "sha256:a5ab5381813b40f264fa3495b98af850098f814a25a63589a8e9eb12560f450c", size = 7971454 }, + { url = "https://files.pythonhosted.org/packages/eb/e2/02652007469263fe1466e98439831d65d4ca80ea1a2df29abecedf7e47b7/pywin32-308-cp311-cp311-win32.whl", hash = "sha256:5d8c8015b24a7d6855b1550d8e660d8daa09983c80e5daf89a273e5c6fb5095a", size = 5928156 }, + { url = "https://files.pythonhosted.org/packages/48/ef/f4fb45e2196bc7ffe09cad0542d9aff66b0e33f6c0954b43e49c33cad7bd/pywin32-308-cp311-cp311-win_amd64.whl", hash = "sha256:575621b90f0dc2695fec346b2d6302faebd4f0f45c05ea29404cefe35d89442b", size = 6559559 }, + { url = "https://files.pythonhosted.org/packages/79/ef/68bb6aa865c5c9b11a35771329e95917b5559845bd75b65549407f9fc6b4/pywin32-308-cp311-cp311-win_arm64.whl", hash = "sha256:100a5442b7332070983c4cd03f2e906a5648a5104b8a7f50175f7906efd16bb6", size = 7972495 }, + { url = "https://files.pythonhosted.org/packages/00/7c/d00d6bdd96de4344e06c4afbf218bc86b54436a94c01c71a8701f613aa56/pywin32-308-cp312-cp312-win32.whl", hash = "sha256:587f3e19696f4bf96fde9d8a57cec74a57021ad5f204c9e627e15c33ff568897", size = 5939729 }, + { url = "https://files.pythonhosted.org/packages/21/27/0c8811fbc3ca188f93b5354e7c286eb91f80a53afa4e11007ef661afa746/pywin32-308-cp312-cp312-win_amd64.whl", hash = "sha256:00b3e11ef09ede56c6a43c71f2d31857cf7c54b0ab6e78ac659497abd2834f47", size = 6543015 }, + { url = "https://files.pythonhosted.org/packages/9d/0f/d40f8373608caed2255781a3ad9a51d03a594a1248cd632d6a298daca693/pywin32-308-cp312-cp312-win_arm64.whl", hash = "sha256:9b4de86c8d909aed15b7011182c8cab38c8850de36e6afb1f0db22b8959e3091", size = 7976033 }, + { url = "https://files.pythonhosted.org/packages/a9/a4/aa562d8935e3df5e49c161b427a3a2efad2ed4e9cf81c3de636f1fdddfd0/pywin32-308-cp313-cp313-win32.whl", hash = "sha256:1c44539a37a5b7b21d02ab34e6a4d314e0788f1690d65b48e9b0b89f31abbbed", size = 5938579 }, + { url = "https://files.pythonhosted.org/packages/c7/50/b0efb8bb66210da67a53ab95fd7a98826a97ee21f1d22949863e6d588b22/pywin32-308-cp313-cp313-win_amd64.whl", hash = "sha256:fd380990e792eaf6827fcb7e187b2b4b1cede0585e3d0c9e84201ec27b9905e4", size = 6542056 }, + { url = "https://files.pythonhosted.org/packages/26/df/2b63e3e4f2df0224f8aaf6d131f54fe4e8c96400eb9df563e2aae2e1a1f9/pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd", size = 7974986 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "pyzmq" +version = "26.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/05/bed626b9f7bb2322cdbbf7b4bd8f54b1b617b0d2ab2d3547d6e39428a48e/pyzmq-26.2.0.tar.gz", hash = "sha256:070672c258581c8e4f640b5159297580a9974b026043bd4ab0470be9ed324f1f", size = 271975 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/a8/9837c39aba390eb7d01924ace49d761c8dbe7bc2d6082346d00c8332e431/pyzmq-26.2.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:ddf33d97d2f52d89f6e6e7ae66ee35a4d9ca6f36eda89c24591b0c40205a3629", size = 1340058 }, + { url = "https://files.pythonhosted.org/packages/a2/1f/a006f2e8e4f7d41d464272012695da17fb95f33b54342612a6890da96ff6/pyzmq-26.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dacd995031a01d16eec825bf30802fceb2c3791ef24bcce48fa98ce40918c27b", size = 1008818 }, + { url = "https://files.pythonhosted.org/packages/b6/09/b51b6683fde5ca04593a57bbe81788b6b43114d8f8ee4e80afc991e14760/pyzmq-26.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89289a5ee32ef6c439086184529ae060c741334b8970a6855ec0b6ad3ff28764", size = 673199 }, + { url = "https://files.pythonhosted.org/packages/c9/78/486f3e2e824f3a645238332bf5a4c4b4477c3063033a27c1e4052358dee2/pyzmq-26.2.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5506f06d7dc6ecf1efacb4a013b1f05071bb24b76350832c96449f4a2d95091c", size = 911762 }, + { url = "https://files.pythonhosted.org/packages/5e/3b/2eb1667c9b866f53e76ee8b0c301b0469745a23bd5a87b7ee3d5dd9eb6e5/pyzmq-26.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ea039387c10202ce304af74def5021e9adc6297067f3441d348d2b633e8166a", size = 868773 }, + { url = "https://files.pythonhosted.org/packages/16/29/ca99b4598a9dc7e468b5417eda91f372b595be1e3eec9b7cbe8e5d3584e8/pyzmq-26.2.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a2224fa4a4c2ee872886ed00a571f5e967c85e078e8e8c2530a2fb01b3309b88", size = 868834 }, + { url = "https://files.pythonhosted.org/packages/ad/e5/9efaeb1d2f4f8c50da04144f639b042bc52869d3a206d6bf672ab3522163/pyzmq-26.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:28ad5233e9c3b52d76196c696e362508959741e1a005fb8fa03b51aea156088f", size = 1202861 }, + { url = "https://files.pythonhosted.org/packages/c3/62/c721b5608a8ac0a69bb83cbb7d07a56f3ff00b3991a138e44198a16f94c7/pyzmq-26.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1c17211bc037c7d88e85ed8b7d8f7e52db6dc8eca5590d162717c654550f7282", size = 1515304 }, + { url = "https://files.pythonhosted.org/packages/87/84/e8bd321aa99b72f48d4606fc5a0a920154125bd0a4608c67eab742dab087/pyzmq-26.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b8f86dd868d41bea9a5f873ee13bf5551c94cf6bc51baebc6f85075971fe6eea", size = 1414712 }, + { url = "https://files.pythonhosted.org/packages/cd/cd/420e3fd1ac6977b008b72e7ad2dae6350cc84d4c5027fc390b024e61738f/pyzmq-26.2.0-cp310-cp310-win32.whl", hash = "sha256:46a446c212e58456b23af260f3d9fb785054f3e3653dbf7279d8f2b5546b21c2", size = 578113 }, + { url = "https://files.pythonhosted.org/packages/5c/57/73930d56ed45ae0cb4946f383f985c855c9b3d4063f26416998f07523c0e/pyzmq-26.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:49d34ab71db5a9c292a7644ce74190b1dd5a3475612eefb1f8be1d6961441971", size = 641631 }, + { url = "https://files.pythonhosted.org/packages/61/d2/ae6ac5c397f1ccad59031c64beaafce7a0d6182e0452cc48f1c9c87d2dd0/pyzmq-26.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:bfa832bfa540e5b5c27dcf5de5d82ebc431b82c453a43d141afb1e5d2de025fa", size = 543528 }, + { url = "https://files.pythonhosted.org/packages/12/20/de7442172f77f7c96299a0ac70e7d4fb78cd51eca67aa2cf552b66c14196/pyzmq-26.2.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:8f7e66c7113c684c2b3f1c83cdd3376103ee0ce4c49ff80a648643e57fb22218", size = 1340639 }, + { url = "https://files.pythonhosted.org/packages/98/4d/5000468bd64c7910190ed0a6c76a1ca59a68189ec1f007c451dc181a22f4/pyzmq-26.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3a495b30fc91db2db25120df5847d9833af237546fd59170701acd816ccc01c4", size = 1008710 }, + { url = "https://files.pythonhosted.org/packages/e1/bf/c67fd638c2f9fbbab8090a3ee779370b97c82b84cc12d0c498b285d7b2c0/pyzmq-26.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77eb0968da535cba0470a5165468b2cac7772cfb569977cff92e240f57e31bef", size = 673129 }, + { url = "https://files.pythonhosted.org/packages/86/94/99085a3f492aa538161cbf27246e8886ff850e113e0c294a5b8245f13b52/pyzmq-26.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ace4f71f1900a548f48407fc9be59c6ba9d9aaf658c2eea6cf2779e72f9f317", size = 910107 }, + { url = "https://files.pythonhosted.org/packages/31/1d/346809e8a9b999646d03f21096428453465b1bca5cd5c64ecd048d9ecb01/pyzmq-26.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92a78853d7280bffb93df0a4a6a2498cba10ee793cc8076ef797ef2f74d107cf", size = 867960 }, + { url = "https://files.pythonhosted.org/packages/ab/68/6fb6ae5551846ad5beca295b7bca32bf0a7ce19f135cb30e55fa2314e6b6/pyzmq-26.2.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:689c5d781014956a4a6de61d74ba97b23547e431e9e7d64f27d4922ba96e9d6e", size = 869204 }, + { url = "https://files.pythonhosted.org/packages/0f/f9/18417771dee223ccf0f48e29adf8b4e25ba6d0e8285e33bcbce078070bc3/pyzmq-26.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0aca98bc423eb7d153214b2df397c6421ba6373d3397b26c057af3c904452e37", size = 1203351 }, + { url = "https://files.pythonhosted.org/packages/e0/46/f13e67fe0d4f8a2315782cbad50493de6203ea0d744610faf4d5f5b16e90/pyzmq-26.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f3496d76b89d9429a656293744ceca4d2ac2a10ae59b84c1da9b5165f429ad3", size = 1514204 }, + { url = "https://files.pythonhosted.org/packages/50/11/ddcf7343b7b7a226e0fc7b68cbf5a5bb56291fac07f5c3023bb4c319ebb4/pyzmq-26.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5c2b3bfd4b9689919db068ac6c9911f3fcb231c39f7dd30e3138be94896d18e6", size = 1414339 }, + { url = "https://files.pythonhosted.org/packages/01/14/1c18d7d5b7be2708f513f37c61bfadfa62161c10624f8733f1c8451b3509/pyzmq-26.2.0-cp311-cp311-win32.whl", hash = "sha256:eac5174677da084abf378739dbf4ad245661635f1600edd1221f150b165343f4", size = 576928 }, + { url = "https://files.pythonhosted.org/packages/3b/1b/0a540edd75a41df14ec416a9a500b9fec66e554aac920d4c58fbd5756776/pyzmq-26.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:5a509df7d0a83a4b178d0f937ef14286659225ef4e8812e05580776c70e155d5", size = 642317 }, + { url = "https://files.pythonhosted.org/packages/98/77/1cbfec0358078a4c5add529d8a70892db1be900980cdb5dd0898b3d6ab9d/pyzmq-26.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:c0e6091b157d48cbe37bd67233318dbb53e1e6327d6fc3bb284afd585d141003", size = 543834 }, + { url = "https://files.pythonhosted.org/packages/28/2f/78a766c8913ad62b28581777ac4ede50c6d9f249d39c2963e279524a1bbe/pyzmq-26.2.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:ded0fc7d90fe93ae0b18059930086c51e640cdd3baebdc783a695c77f123dcd9", size = 1343105 }, + { url = "https://files.pythonhosted.org/packages/b7/9c/4b1e2d3d4065be715e007fe063ec7885978fad285f87eae1436e6c3201f4/pyzmq-26.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:17bf5a931c7f6618023cdacc7081f3f266aecb68ca692adac015c383a134ca52", size = 1008365 }, + { url = "https://files.pythonhosted.org/packages/4f/ef/5a23ec689ff36d7625b38d121ef15abfc3631a9aecb417baf7a4245e4124/pyzmq-26.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55cf66647e49d4621a7e20c8d13511ef1fe1efbbccf670811864452487007e08", size = 665923 }, + { url = "https://files.pythonhosted.org/packages/ae/61/d436461a47437d63c6302c90724cf0981883ec57ceb6073873f32172d676/pyzmq-26.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4661c88db4a9e0f958c8abc2b97472e23061f0bc737f6f6179d7a27024e1faa5", size = 903400 }, + { url = "https://files.pythonhosted.org/packages/47/42/fc6d35ecefe1739a819afaf6f8e686f7f02a4dd241c78972d316f403474c/pyzmq-26.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea7f69de383cb47522c9c208aec6dd17697db7875a4674c4af3f8cfdac0bdeae", size = 860034 }, + { url = "https://files.pythonhosted.org/packages/07/3b/44ea6266a6761e9eefaa37d98fabefa112328808ac41aa87b4bbb668af30/pyzmq-26.2.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7f98f6dfa8b8ccaf39163ce872bddacca38f6a67289116c8937a02e30bbe9711", size = 860579 }, + { url = "https://files.pythonhosted.org/packages/38/6f/4df2014ab553a6052b0e551b37da55166991510f9e1002c89cab7ce3b3f2/pyzmq-26.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e3e0210287329272539eea617830a6a28161fbbd8a3271bf4150ae3e58c5d0e6", size = 1196246 }, + { url = "https://files.pythonhosted.org/packages/38/9d/ee240fc0c9fe9817f0c9127a43238a3e28048795483c403cc10720ddef22/pyzmq-26.2.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6b274e0762c33c7471f1a7471d1a2085b1a35eba5cdc48d2ae319f28b6fc4de3", size = 1507441 }, + { url = "https://files.pythonhosted.org/packages/85/4f/01711edaa58d535eac4a26c294c617c9a01f09857c0ce191fd574d06f359/pyzmq-26.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:29c6a4635eef69d68a00321e12a7d2559fe2dfccfa8efae3ffb8e91cd0b36a8b", size = 1406498 }, + { url = "https://files.pythonhosted.org/packages/07/18/907134c85c7152f679ed744e73e645b365f3ad571f38bdb62e36f347699a/pyzmq-26.2.0-cp312-cp312-win32.whl", hash = "sha256:989d842dc06dc59feea09e58c74ca3e1678c812a4a8a2a419046d711031f69c7", size = 575533 }, + { url = "https://files.pythonhosted.org/packages/ce/2c/a6f4a20202a4d3c582ad93f95ee78d79bbdc26803495aec2912b17dbbb6c/pyzmq-26.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:2a50625acdc7801bc6f74698c5c583a491c61d73c6b7ea4dee3901bb99adb27a", size = 637768 }, + { url = "https://files.pythonhosted.org/packages/5f/0e/eb16ff731632d30554bf5af4dbba3ffcd04518219d82028aea4ae1b02ca5/pyzmq-26.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:4d29ab8592b6ad12ebbf92ac2ed2bedcfd1cec192d8e559e2e099f648570e19b", size = 540675 }, + { url = "https://files.pythonhosted.org/packages/04/a7/0f7e2f6c126fe6e62dbae0bc93b1bd3f1099cf7fea47a5468defebe3f39d/pyzmq-26.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9dd8cd1aeb00775f527ec60022004d030ddc51d783d056e3e23e74e623e33726", size = 1006564 }, + { url = "https://files.pythonhosted.org/packages/31/b6/a187165c852c5d49f826a690857684333a6a4a065af0a6015572d2284f6a/pyzmq-26.2.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:28c812d9757fe8acecc910c9ac9dafd2ce968c00f9e619db09e9f8f54c3a68a3", size = 1340447 }, + { url = "https://files.pythonhosted.org/packages/68/ba/f4280c58ff71f321602a6e24fd19879b7e79793fb8ab14027027c0fb58ef/pyzmq-26.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d80b1dd99c1942f74ed608ddb38b181b87476c6a966a88a950c7dee118fdf50", size = 665485 }, + { url = "https://files.pythonhosted.org/packages/77/b5/c987a5c53c7d8704216f29fc3d810b32f156bcea488a940e330e1bcbb88d/pyzmq-26.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c997098cc65e3208eca09303630e84d42718620e83b733d0fd69543a9cab9cb", size = 903484 }, + { url = "https://files.pythonhosted.org/packages/29/c9/07da157d2db18c72a7eccef8e684cefc155b712a88e3d479d930aa9eceba/pyzmq-26.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad1bc8d1b7a18497dda9600b12dc193c577beb391beae5cd2349184db40f187", size = 859981 }, + { url = "https://files.pythonhosted.org/packages/43/09/e12501bd0b8394b7d02c41efd35c537a1988da67fc9c745cae9c6c776d31/pyzmq-26.2.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:bea2acdd8ea4275e1278350ced63da0b166421928276c7c8e3f9729d7402a57b", size = 860334 }, + { url = "https://files.pythonhosted.org/packages/eb/ff/f5ec1d455f8f7385cc0a8b2acd8c807d7fade875c14c44b85c1bddabae21/pyzmq-26.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:23f4aad749d13698f3f7b64aad34f5fc02d6f20f05999eebc96b89b01262fb18", size = 1196179 }, + { url = "https://files.pythonhosted.org/packages/ec/8a/bb2ac43295b1950fe436a81fc5b298be0b96ac76fb029b514d3ed58f7b27/pyzmq-26.2.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a4f96f0d88accc3dbe4a9025f785ba830f968e21e3e2c6321ccdfc9aef755115", size = 1507668 }, + { url = "https://files.pythonhosted.org/packages/a9/49/dbc284ebcfd2dca23f6349227ff1616a7ee2c4a35fe0a5d6c3deff2b4fed/pyzmq-26.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ced65e5a985398827cc9276b93ef6dfabe0273c23de8c7931339d7e141c2818e", size = 1406539 }, + { url = "https://files.pythonhosted.org/packages/00/68/093cdce3fe31e30a341d8e52a1ad86392e13c57970d722c1f62a1d1a54b6/pyzmq-26.2.0-cp313-cp313-win32.whl", hash = "sha256:31507f7b47cc1ead1f6e86927f8ebb196a0bab043f6345ce070f412a59bf87b5", size = 575567 }, + { url = "https://files.pythonhosted.org/packages/92/ae/6cc4657148143412b5819b05e362ae7dd09fb9fe76e2a539dcff3d0386bc/pyzmq-26.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:70fc7fcf0410d16ebdda9b26cbd8bf8d803d220a7f3522e060a69a9c87bf7bad", size = 637551 }, + { url = "https://files.pythonhosted.org/packages/6c/67/fbff102e201688f97c8092e4c3445d1c1068c2f27bbd45a578df97ed5f94/pyzmq-26.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:c3789bd5768ab5618ebf09cef6ec2b35fed88709b104351748a63045f0ff9797", size = 540378 }, + { url = "https://files.pythonhosted.org/packages/3f/fe/2d998380b6e0122c6c4bdf9b6caf490831e5f5e2d08a203b5adff060c226/pyzmq-26.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:034da5fc55d9f8da09015d368f519478a52675e558c989bfcb5cf6d4e16a7d2a", size = 1007378 }, + { url = "https://files.pythonhosted.org/packages/4a/f4/30d6e7157f12b3a0390bde94d6a8567cdb88846ed068a6e17238a4ccf600/pyzmq-26.2.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:c92d73464b886931308ccc45b2744e5968cbaade0b1d6aeb40d8ab537765f5bc", size = 1329532 }, + { url = "https://files.pythonhosted.org/packages/82/86/3fe917870e15ee1c3ad48229a2a64458e36036e64b4afa9659045d82bfa8/pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:794a4562dcb374f7dbbfb3f51d28fb40123b5a2abadee7b4091f93054909add5", size = 653242 }, + { url = "https://files.pythonhosted.org/packages/50/2d/242e7e6ef6c8c19e6cb52d095834508cd581ffb925699fd3c640cdc758f1/pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aee22939bb6075e7afededabad1a56a905da0b3c4e3e0c45e75810ebe3a52672", size = 888404 }, + { url = "https://files.pythonhosted.org/packages/ac/11/7270566e1f31e4ea73c81ec821a4b1688fd551009a3d2bab11ec66cb1e8f/pyzmq-26.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ae90ff9dad33a1cfe947d2c40cb9cb5e600d759ac4f0fd22616ce6540f72797", size = 845858 }, + { url = "https://files.pythonhosted.org/packages/91/d5/72b38fbc69867795c8711bdd735312f9fef1e3d9204e2f63ab57085434b9/pyzmq-26.2.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:43a47408ac52647dfabbc66a25b05b6a61700b5165807e3fbd40063fcaf46386", size = 847375 }, + { url = "https://files.pythonhosted.org/packages/dd/9a/10ed3c7f72b4c24e719c59359fbadd1a27556a28b36cdf1cd9e4fb7845d5/pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:25bf2374a2a8433633c65ccb9553350d5e17e60c8eb4de4d92cc6bd60f01d306", size = 1183489 }, + { url = "https://files.pythonhosted.org/packages/72/2d/8660892543fabf1fe41861efa222455811adac9f3c0818d6c3170a1153e3/pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:007137c9ac9ad5ea21e6ad97d3489af654381324d5d3ba614c323f60dab8fae6", size = 1492932 }, + { url = "https://files.pythonhosted.org/packages/7b/d6/32fd69744afb53995619bc5effa2a405ae0d343cd3e747d0fbc43fe894ee/pyzmq-26.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:470d4a4f6d48fb34e92d768b4e8a5cc3780db0d69107abf1cd7ff734b9766eb0", size = 1392485 }, + { url = "https://files.pythonhosted.org/packages/53/fb/36b2b2548286e9444e52fcd198760af99fd89102b5be50f0660fcfe902df/pyzmq-26.2.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:706e794564bec25819d21a41c31d4df2d48e1cc4b061e8d345d7fb4dd3e94072", size = 906955 }, + { url = "https://files.pythonhosted.org/packages/77/8f/6ce54f8979a01656e894946db6299e2273fcee21c8e5fa57c6295ef11f57/pyzmq-26.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b435f2753621cd36e7c1762156815e21c985c72b19135dac43a7f4f31d28dd1", size = 565701 }, + { url = "https://files.pythonhosted.org/packages/ee/1c/bf8cd66730a866b16db8483286078892b7f6536f8c389fb46e4beba0a970/pyzmq-26.2.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:160c7e0a5eb178011e72892f99f918c04a131f36056d10d9c1afb223fc952c2d", size = 794312 }, + { url = "https://files.pythonhosted.org/packages/71/43/91fa4ff25bbfdc914ab6bafa0f03241d69370ef31a761d16bb859f346582/pyzmq-26.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c4a71d5d6e7b28a47a394c0471b7e77a0661e2d651e7ae91e0cab0a587859ca", size = 752775 }, + { url = "https://files.pythonhosted.org/packages/ec/d2/3b2ab40f455a256cb6672186bea95cd97b459ce4594050132d71e76f0d6f/pyzmq-26.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:90412f2db8c02a3864cbfc67db0e3dcdbda336acf1c469526d3e869394fe001c", size = 550762 }, +] + +[[package]] +name = "qtconsole" +version = "5.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ipykernel" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "qtpy" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/83/d2b11f2f737c276d8650a04ad3bf478d10fbcd55fe39f129cdb9e6843d31/qtconsole-5.6.1.tar.gz", hash = "sha256:5cad1c7e6c75d3ef8143857fd2ed28062b4b92b933c2cc328252d18a9cfd0be5", size = 435808 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/8a/635610fb6131bc702229e2780d7b042416866ab78f8ed1ff24c4b23a2f4c/qtconsole-5.6.1-py3-none-any.whl", hash = "sha256:3d22490d9589bace566ad4f3455b61fa2209156f40e87e19e2c3cb64e9264950", size = 125035 }, +] + +[[package]] +name = "qtpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/10/51e0e50dd1e4a160c54ac0717b8ff11b2063d441e721c2037f61931cf38d/qtpy-2.4.2.tar.gz", hash = "sha256:9d6ec91a587cc1495eaebd23130f7619afa5cdd34a277acb87735b4ad7c65156", size = 66849 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/0c/58a1e48209b0b1220ca2368435573f39ff1fa3577b7eef913f8960c5d429/QtPy-2.4.2-py3-none-any.whl", hash = "sha256:5a696b1dd7a354cb330657da1d17c20c2190c72d4888ba923f8461da67aa1a1c", size = 95155 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "ruff" +version = "0.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/5e/683c7ef7a696923223e7d95ca06755d6e2acbc5fd8382b2912a28008137c/ruff-0.8.3.tar.gz", hash = "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3", size = 3378522 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/c4/bfdbb8b9c419ff3b52479af8581026eeaac3764946fdb463dec043441b7d/ruff-0.8.3-py3-none-linux_armv6l.whl", hash = "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6", size = 10535860 }, + { url = "https://files.pythonhosted.org/packages/ef/c5/0aabdc9314b4b6f051168ac45227e2aa8e1c6d82718a547455e40c9c9faa/ruff-0.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939", size = 10346327 }, + { url = "https://files.pythonhosted.org/packages/1a/78/4843a59e7e7b398d6019cf91ab06502fd95397b99b2b858798fbab9151f5/ruff-0.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d", size = 9942585 }, + { url = "https://files.pythonhosted.org/packages/91/5a/642ed8f1ba23ffc2dd347697e01eef3c42fad6ac76603be4a8c3a9d6311e/ruff-0.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13", size = 10797597 }, + { url = "https://files.pythonhosted.org/packages/30/25/2e654bc7226da09a49730a1a2ea6e89f843b362db80b4b2a7a4f948ac986/ruff-0.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18", size = 10307244 }, + { url = "https://files.pythonhosted.org/packages/c0/2d/a224d56bcd4383583db53c2b8f410ebf1200866984aa6eb9b5a70f04e71f/ruff-0.8.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502", size = 11362439 }, + { url = "https://files.pythonhosted.org/packages/82/01/03e2857f9c371b8767d3e909f06a33bbdac880df17f17f93d6f6951c3381/ruff-0.8.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d", size = 12078538 }, + { url = "https://files.pythonhosted.org/packages/af/ae/ff7f97b355da16d748ceec50e1604a8215d3659b36b38025a922e0612e9b/ruff-0.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82", size = 11616172 }, + { url = "https://files.pythonhosted.org/packages/6a/d0/6156d4d1e53ebd17747049afe801c5d7e3014d9b2f398b9236fe36ba4320/ruff-0.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452", size = 12919886 }, + { url = "https://files.pythonhosted.org/packages/4e/84/affcb30bacb94f6036a128ad5de0e29f543d3f67ee42b490b17d68e44b8a/ruff-0.8.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd", size = 11212599 }, + { url = "https://files.pythonhosted.org/packages/60/b9/5694716bdefd8f73df7c0104334156c38fb0f77673d2966a5a1345bab94d/ruff-0.8.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20", size = 10784637 }, + { url = "https://files.pythonhosted.org/packages/24/7e/0e8f835103ac7da81c3663eedf79dec8359e9ae9a3b0d704bae50be59176/ruff-0.8.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc", size = 10390591 }, + { url = "https://files.pythonhosted.org/packages/27/da/180ec771fc01c004045962ce017ca419a0281f4bfaf867ed0020f555b56e/ruff-0.8.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060", size = 10894298 }, + { url = "https://files.pythonhosted.org/packages/6d/f8/29f241742ed3954eb2222314b02db29f531a15cab3238d1295e8657c5f18/ruff-0.8.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea", size = 11275965 }, + { url = "https://files.pythonhosted.org/packages/79/e9/5b81dc9afc8a80884405b230b9429efeef76d04caead904bd213f453b973/ruff-0.8.3-py3-none-win32.whl", hash = "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964", size = 8807651 }, + { url = "https://files.pythonhosted.org/packages/ea/67/7291461066007617b59a707887b90e319b6a043c79b4d19979f86b7a20e7/ruff-0.8.3-py3-none-win_amd64.whl", hash = "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9", size = 9625289 }, + { url = "https://files.pythonhosted.org/packages/03/8f/e4fa95288b81233356d9a9dcaed057e5b0adc6399aa8fd0f6d784041c9c3/ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936", size = 9078754 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, +] + +[[package]] +name = "superqt" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "qtpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/7b/10b854842f76b20fc8a13ea5b62f78346c84bf9664b7d54ee82e53d88b6d/superqt-0.7.0.tar.gz", hash = "sha256:fa8014a0ffead7f618a85346ac355ba5e9529b177f50caf5093edeec566c6f5d", size = 97558 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/77/47289ce2c607f72446811230ec96406ace44db7669e443793a13fd7b3486/superqt-0.7.0-py3-none-any.whl", hash = "sha256:395d3f511fe5143882eba6e9ffb8d79bd4c782b6d571c1edb4141cea977612d3", size = 91179 }, +] + +[package.optional-dependencies] +cmap = [ + { name = "cmap" }, +] +iconify = [ + { name = "pyconify" }, +] +quantity = [ + { name = "pint" }, +] + +[[package]] +name = "tensorstore" +version = "0.1.71" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ml-dtypes" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/cf/9ec04b1d24aeecee27a456cc3a59974c9d2e863c34939bbc738a4db04df7/tensorstore-0.1.71.tar.gz", hash = "sha256:5c37c7b385517b568282a7aedded446216335d0cb41187c93c80b53596c92c96", size = 6716705 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/e0/90c48f6cf9d1d20bb63693c81ae7ebb7018377a945bb57ec5cc4f86886ca/tensorstore-0.1.71-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:f3e62aa7b473c0715706a809da3591763906059e8731a38c0b495337a1dc55ea", size = 15128614 }, + { url = "https://files.pythonhosted.org/packages/93/ab/7be1edfb222bd3f8bf207952299657134455b91d54f4c182bee2dfabb78c/tensorstore-0.1.71-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b961bbbb7a1c6a48e4c1406a98caebeb400461e2e75a08b6df0c013294037a15", size = 13109915 }, + { url = "https://files.pythonhosted.org/packages/c8/73/c4355b59a0f2c2f67a8af9cced3b7046ca0d73916daafaa5a10203f1e87d/tensorstore-0.1.71-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95041b55a2ec86d1f6690512d1883581b18f2f4f46c3d97894aeb0ac2db6af7f", size = 16510065 }, + { url = "https://files.pythonhosted.org/packages/ac/fa/718dde1be02677be16021c184b909d22f227a8b2b8086a56624e3ee1b2f0/tensorstore-0.1.71-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a6cdcc52e4b841d23e50a2fa28e016e6d9f61d6ea9188d4555ea189b040a0f6", size = 17844270 }, + { url = "https://files.pythonhosted.org/packages/c6/1c/4c1f3e4843a4fa40838d56f03b721ab79042034a7f7a99d7736fcdc5ed2b/tensorstore-0.1.71-cp310-cp310-win_amd64.whl", hash = "sha256:46ff0f41ef3b1dbd1a925d62e6475523a587bcd37b277bf4f633f46f5b7e22bd", size = 12291319 }, + { url = "https://files.pythonhosted.org/packages/3d/a9/fde383745b1ac3d84f5b274cc7b1bdb296a1e2284b3bca6a6dfc701acac7/tensorstore-0.1.71-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:321d6302e5116b20fda500821240eba7de28477209070728d98edefced97d2b5", size = 15130313 }, + { url = "https://files.pythonhosted.org/packages/f5/2b/1bcc27029b33bd76a086c6caf3e6c59e94ac4c5f2f8f42f2cf861d943374/tensorstore-0.1.71-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f40e73bcdc333dfb3f7fe0fcf023bcbec41533c9856657718ff76ece1a1902e0", size = 13112249 }, + { url = "https://files.pythonhosted.org/packages/7d/d8/7c33792c09e26e1069c8933980720ec6aa04eeb268589ba869aa4055f7f4/tensorstore-0.1.71-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65c3a1a2a35a1b537403f36403d258caab477e564bc0f64109b941cc77b4f203", size = 16512539 }, + { url = "https://files.pythonhosted.org/packages/e7/13/9484ff657f29ecee0612db8f92efffcab4e68215a67a15ed5351461ebb8d/tensorstore-0.1.71-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:373558b803d8c2c57fc613b11007ae58139f19a3cddd443a0de5d7b5321e5961", size = 17845020 }, + { url = "https://files.pythonhosted.org/packages/bd/4a/fc21a3ff24069049674721b6d74a7cc65c0f4d185a551a2193471521fa07/tensorstore-0.1.71-cp311-cp311-win_amd64.whl", hash = "sha256:75a9ff1f7b6759094cc210baa4e8135c4898472e08a7036476374433d03c6a34", size = 12292373 }, + { url = "https://files.pythonhosted.org/packages/47/9d/d7e0e268e20ebbf517ecd8049f075ee599012a5f08fc63ecba7a7118f810/tensorstore-0.1.71-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:0bd87899e1c6049b078e785e8b7871e2579202f5b929e89c3c37340965b922bb", size = 15170281 }, + { url = "https://files.pythonhosted.org/packages/b4/5f/71a0d3404f434f2353979e32f3fa313dd7e783a4e5eaf84b2c9a2ec00433/tensorstore-0.1.71-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3a24feb6195f1c222162965c0107c9ff56d322cca23e19f0e66636f6eb80f14", size = 13139577 }, + { url = "https://files.pythonhosted.org/packages/7a/32/b76a1ab31e63d6c9b70733824dc8939f34e410034fdceb0d26b193174f47/tensorstore-0.1.71-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87a97a34b0475ddc7d2afc40e5dd7f8d12522aa81edfbcccb39628cf591454d5", size = 16500153 }, + { url = "https://files.pythonhosted.org/packages/be/79/94decc1a93a4b53be8ef1fada387bbc1cf89475fe8da5a6dae6c74bf1f01/tensorstore-0.1.71-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ced5430bcdfa7fcb3a6bdc44733176158cb877b35bdd233cac82e25b4cc94e92", size = 17835318 }, + { url = "https://files.pythonhosted.org/packages/5e/0e/7e83a53f04bc284c365e20abd161d24e3f3d19e0949e9f61191dc6434d69/tensorstore-0.1.71-cp312-cp312-win_amd64.whl", hash = "sha256:583f0ec143062176ca21fe8dcc3b3b6f94d7f4ea643443b49942d3d1a2fa29b4", size = 12292999 }, + { url = "https://files.pythonhosted.org/packages/8f/53/f6784957b1a297da54e634e8d0e4e692fc4b6806ab4f98686842b6cc6173/tensorstore-0.1.71-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:31e39ed7d374f43e45bff52611bad99315c577b44c099b2f6837b801b3467645", size = 15171021 }, + { url = "https://files.pythonhosted.org/packages/f5/d5/7083d964be24787b6a68eaa2f2e2923fad7dc93ab09ddb807336f4ca4eea/tensorstore-0.1.71-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:de8843fb3462899de7bcdeeaccb92303a9d61006bc36364deb4a88df46320ba4", size = 13140859 }, + { url = "https://files.pythonhosted.org/packages/7b/bc/21262635b8466a1b752749aa3583bd4ce7041d89ab0922a5ef94da7d59a5/tensorstore-0.1.71-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6276e279b45eb5d9b95c4df3e7956255f414fd4b128d2de16d8aecde86c36357", size = 16501539 }, + { url = "https://files.pythonhosted.org/packages/ff/69/504f487ed7c7fc81a9d6951b829adb94c7affe1d8149db6b309f2d1c0a40/tensorstore-0.1.71-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:52b546f076b2c3bf217c60f05de4124cc1197ce92f8e826e7ec73ae324074a5a", size = 17834921 }, + { url = "https://files.pythonhosted.org/packages/e3/d1/1d7d88510a2e46b258e9788550fb3ac330ab1ac2aa9f56274541b7f5627d/tensorstore-0.1.71-cp313-cp313-win_amd64.whl", hash = "sha256:ecf4feb574051f40e81572ea2ff8e5895b2980c5dd3b29fe81c70d25e42d3b6a", size = 12292950 }, +] + +[[package]] +name = "tifffile" +version = "2024.12.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/c9/fc4e490c5b0ccad68c98ea1d6e0f409bd7d50e2e8fc30a0725594d3104ff/tifffile-2024.12.12.tar.gz", hash = "sha256:c38e929bf74c04b6c8708d87f16b32c85c6d7c2514b99559ea3db8003ba4edda", size = 365416 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/1e/76cbc758f6865a9da18001ac70d1a4154603b71e233f704401fc7d62493e/tifffile-2024.12.12-py3-none-any.whl", hash = "sha256:6ff0f196a46a75c8c0661c70995e06ea4d08a81fe343193e69f1673f4807d508", size = 227538 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "tornado" +version = "6.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/45/a0daf161f7d6f36c3ea5fc0c2de619746cc3dd4c76402e9db545bd920f63/tornado-6.4.2.tar.gz", hash = "sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b", size = 501135 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/7e/71f604d8cea1b58f82ba3590290b66da1e72d840aeb37e0d5f7291bd30db/tornado-6.4.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1", size = 436299 }, + { url = "https://files.pythonhosted.org/packages/96/44/87543a3b99016d0bf54fdaab30d24bf0af2e848f1d13d34a3a5380aabe16/tornado-6.4.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803", size = 434253 }, + { url = "https://files.pythonhosted.org/packages/cb/fb/fdf679b4ce51bcb7210801ef4f11fdac96e9885daa402861751353beea6e/tornado-6.4.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec", size = 437602 }, + { url = "https://files.pythonhosted.org/packages/4f/3b/e31aeffffc22b475a64dbeb273026a21b5b566f74dee48742817626c47dc/tornado-6.4.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946", size = 436972 }, + { url = "https://files.pythonhosted.org/packages/22/55/b78a464de78051a30599ceb6983b01d8f732e6f69bf37b4ed07f642ac0fc/tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf", size = 437173 }, + { url = "https://files.pythonhosted.org/packages/79/5e/be4fb0d1684eb822c9a62fb18a3e44a06188f78aa466b2ad991d2ee31104/tornado-6.4.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634", size = 437892 }, + { url = "https://files.pythonhosted.org/packages/f5/33/4f91fdd94ea36e1d796147003b490fe60a0215ac5737b6f9c65e160d4fe0/tornado-6.4.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73", size = 437334 }, + { url = "https://files.pythonhosted.org/packages/2b/ae/c1b22d4524b0e10da2f29a176fb2890386f7bd1f63aacf186444873a88a0/tornado-6.4.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c", size = 437261 }, + { url = "https://files.pythonhosted.org/packages/b5/25/36dbd49ab6d179bcfc4c6c093a51795a4f3bed380543a8242ac3517a1751/tornado-6.4.2-cp38-abi3-win32.whl", hash = "sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482", size = 438463 }, + { url = "https://files.pythonhosted.org/packages/61/cc/58b1adeb1bb46228442081e746fcdbc4540905c87e8add7c277540934edb/tornado-6.4.2-cp38-abi3-win_amd64.whl", hash = "sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38", size = 438907 }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, +] + +[[package]] +name = "typer" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/dca7b219718afd37a0068f4f2530a727c2b74a8b6e8e0c0080a4c0de4fcd/typer-0.15.1.tar.gz", hash = "sha256:a0588c0a7fa68a1978a069818657778f86abe6ff5ea6abf472f940a08bfe4f0a", size = 99789 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/cc/0a838ba5ca64dc832aa43f727bd586309846b0ffb2ce52422543e6075e8a/typer-0.15.1-py3-none-any.whl", hash = "sha256:7994fb7b8155b64d3402518560648446072864beefd44aa2dc36972a5972e847", size = 44908 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, +] + +[[package]] +name = "useq-schema" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/bf/eaedad3e51033324dd2b4449dbec09ea11ebce879b78615b497149a146ed/useq_schema-0.6.2.tar.gz", hash = "sha256:58a67d88e10a32f8542c958042287baabb74ae285a026b7b390084ea637b3489", size = 66811 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/8f/4aafd9fb46202dc9237426abe6f361fae0acf08ab211ee5fd5f3f6071d8d/useq_schema-0.6.2-py3-none-any.whl", hash = "sha256:1ae55a36b88c2ad5b4054ca0ab7a6ca4757f8677f737392c4bb0126b9171807d", size = 54161 }, +] + +[[package]] +name = "virtualenv" +version = "20.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/75/53316a5a8050069228a2f6d11f32046cfa94fbb6cc3f08703f59b873de2e/virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa", size = 7650368 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/f9/0919cf6f1432a8c4baa62511f8f8da8225432d22e83e3476f5be1a1edc6e/virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0", size = 4276702 }, +] + +[[package]] +name = "vispy" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "freetype-py" }, + { name = "hsluv" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/96/a0bf368c0a8f5c9b599f3f9f4643b425298dfde8a744c9b0b02af9ce8595/vispy-0.14.3.tar.gz", hash = "sha256:efbbb847a908baf7e7169ab9bf296138a39364f367e6cb0a8ec03ad71699d31d", size = 2508703 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/85/aed5441743837003ab8633a92318dc1711c9289e49ceeed40e41e11f7037/vispy-0.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df35d09a5638392875e605008e3efaebc91238d169bda1fadd74851eb0762cbc", size = 1477729 }, + { url = "https://files.pythonhosted.org/packages/7e/a5/966d50940d2cfa2a589abc9e0b13615ca0b1081fcb656392d52968ff8bd5/vispy-0.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:69f32f914bbb42c029e9eead272418a3a29c3d52d413a479c8ba32eab34ccab8", size = 1471488 }, + { url = "https://files.pythonhosted.org/packages/7c/ed/054ae026816444aa7ac31feaf4e3d9453ecadfdcb195e9b4f75abf394e33/vispy-0.14.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b244bdfb70aebf1d8d926cd16408fd32bb204a5e1aa55813368f75f90c09389", size = 1835484 }, + { url = "https://files.pythonhosted.org/packages/1a/9e/54d48be7c0129382f2358ed609da6289a31354e218c1ea8c65f6bf65af23/vispy-0.14.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0bdc2bdf1b7aef27ca1a744ea7de7333e81e5ec4dc6bb532977fef8fed703cf8", size = 1839472 }, + { url = "https://files.pythonhosted.org/packages/ac/33/353ae8a9cc093e07862ebab52beb5d04fb3764014e6bc9e01f7de25861cb/vispy-0.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:f211440444edea428c9d0ffb70447e945015071efb3332408c6335b07c47574e", size = 1468907 }, + { url = "https://files.pythonhosted.org/packages/54/1d/ae51e2114e678418ecc00ea20762d556c0d4913271bfe227b22ef7997c1f/vispy-0.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe3ae49fbc6fd7f53fa34a5bbe693eb7fb6b69316fb7fe60c5e2d352afafe278", size = 1477623 }, + { url = "https://files.pythonhosted.org/packages/d8/7d/e2b6f574f4bac658255961f98d98776efad656d10391bf00982e7afc1485/vispy-0.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f474f415d280e5ed71f5a513c4d42d59049710b11f144fa85c312fd639c08a9b", size = 1471353 }, + { url = "https://files.pythonhosted.org/packages/94/96/f2096351d4519b57a617857144c0ad238595f57ab8604fa6c304992db12c/vispy-0.14.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af4863e7ba8ec4985ab8772d86b11dc71b3ab20f29c7e044fb35a1a009da5f98", size = 1873818 }, + { url = "https://files.pythonhosted.org/packages/f7/44/08653f4a54eba576cd2df95a1779a5bad7e3a4a9a6bfcaf575422beb9edf/vispy-0.14.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66bcb62a004bb97544fd14b9035c8194d8074a8dbc3eea6a6f9a3a9f5fc1ff08", size = 1877699 }, + { url = "https://files.pythonhosted.org/packages/1a/5d/3a522ad57f6aeaff91bc7653854c27207fab9094d3ca2615db539a946227/vispy-0.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:12d8e23ffb865e6d491d71cbf0dc54f53ca41b9167f5de99cdb08921a111f585", size = 1468954 }, + { url = "https://files.pythonhosted.org/packages/ae/8b/991197f3e975de9c7eb03bdd880bd00980ccb0d7be862518514905819c7f/vispy-0.14.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f624b58c9a62e68aeb279678f9ae042cf875c24f650b042e2a7005fde9f2f3e2", size = 1478725 }, + { url = "https://files.pythonhosted.org/packages/3e/fe/ae8018ab01cdea43f3cd2e072df28d257edd8274f1fcde04174ff263bf4a/vispy-0.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:31b1fdd1e1924ca04fce250fb958412fbcefe4f1e4e6fffa12eb4040c00b0963", size = 1472297 }, + { url = "https://files.pythonhosted.org/packages/2c/ff/1cca6b74ec64789bd2b696cabd00276d1e82529d47a4e7db69147208abba/vispy-0.14.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca7aebb4280e3754ae60c673dafb2f5acc26d6182761215281b07e696962e013", size = 1859501 }, + { url = "https://files.pythonhosted.org/packages/5e/08/87a7e2640e5dd444804c69505eef8ae14d50d782c2b2d3b21679cf6a70be/vispy-0.14.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a90896898b10b31760634a955031dc048fda41fd6e21ee4ff3e12ebf16970b09", size = 1866372 }, + { url = "https://files.pythonhosted.org/packages/75/72/e02fb3b3e3ad4458bdc9830e97a980919921752bca1f40d816c1e25d566f/vispy-0.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:2b39304dae410fde21723cdcf50cae71ba611479f01cb8e30116493ce318fcab", size = 1469553 }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +] + +[[package]] +name = "wmctrl" +version = "0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/d9/6625ead93412c5ce86db1f8b4f2a70b8043e0a7c1d30099ba3c6a81641ff/wmctrl-0.5.tar.gz", hash = "sha256:7839a36b6fe9e2d6fd22304e5dc372dbced2116ba41283ea938b2da57f53e962", size = 5202 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/ca/723e3f8185738d7947f14ee7dc663b59415c6dee43bd71575f8c7f5cd6be/wmctrl-0.5-py2.py3-none-any.whl", hash = "sha256:ae695c1863a314c899e7cf113f07c0da02a394b968c4772e1936219d9234ddd7", size = 4268 }, +] + +[[package]] +name = "wrapt" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/a1/fc03dca9b0432725c2e8cdbf91a349d2194cf03d8523c124faebe581de09/wrapt-1.17.0.tar.gz", hash = "sha256:16187aa2317c731170a88ef35e8937ae0f533c402872c1ee5e6d079fcf320801", size = 55542 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/f9/85220321e9bb1a5f72ccce6604395ae75fcb463d87dad0014dc1010bd1f1/wrapt-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a0c23b8319848426f305f9cb0c98a6e32ee68a36264f45948ccf8e7d2b941f8", size = 38766 }, + { url = "https://files.pythonhosted.org/packages/ff/71/ff624ff3bde91ceb65db6952cdf8947bc0111d91bd2359343bc2fa7c57fd/wrapt-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1ca5f060e205f72bec57faae5bd817a1560fcfc4af03f414b08fa29106b7e2d", size = 83262 }, + { url = "https://files.pythonhosted.org/packages/9f/0a/814d4a121a643af99cfe55a43e9e6dd08f4a47cdac8e8f0912c018794715/wrapt-1.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e185ec6060e301a7e5f8461c86fb3640a7beb1a0f0208ffde7a65ec4074931df", size = 74990 }, + { url = "https://files.pythonhosted.org/packages/cd/c7/b8c89bf5ca5c4e6a2d0565d149d549cdb4cffb8916d1d1b546b62fb79281/wrapt-1.17.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb90765dd91aed05b53cd7a87bd7f5c188fcd95960914bae0d32c5e7f899719d", size = 82712 }, + { url = "https://files.pythonhosted.org/packages/19/7c/5977aefa8460906c1ff914fd42b11cf6c09ded5388e46e1cc6cea4ab15e9/wrapt-1.17.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:879591c2b5ab0a7184258274c42a126b74a2c3d5a329df16d69f9cee07bba6ea", size = 81705 }, + { url = "https://files.pythonhosted.org/packages/ae/e7/233402d7bd805096bb4a8ec471f5a141421a01de3c8c957cce569772c056/wrapt-1.17.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fce6fee67c318fdfb7f285c29a82d84782ae2579c0e1b385b7f36c6e8074fffb", size = 74636 }, + { url = "https://files.pythonhosted.org/packages/93/81/b6c32d8387d9cfbc0134f01585dee7583315c3b46dfd3ae64d47693cd078/wrapt-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0698d3a86f68abc894d537887b9bbf84d29bcfbc759e23f4644be27acf6da301", size = 81299 }, + { url = "https://files.pythonhosted.org/packages/d1/c3/1fae15d453468c98f09519076f8d401b476d18d8d94379e839eed14c4c8b/wrapt-1.17.0-cp310-cp310-win32.whl", hash = "sha256:69d093792dc34a9c4c8a70e4973a3361c7a7578e9cd86961b2bbf38ca71e4e22", size = 36425 }, + { url = "https://files.pythonhosted.org/packages/c6/f4/77e0886c95556f2b4caa8908ea8eb85f713fc68296a2113f8c63d50fe0fb/wrapt-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:f28b29dc158ca5d6ac396c8e0a2ef45c4e97bb7e65522bfc04c989e6fe814575", size = 38748 }, + { url = "https://files.pythonhosted.org/packages/0e/40/def56538acddc2f764c157d565b9f989072a1d2f2a8e384324e2e104fc7d/wrapt-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74bf625b1b4caaa7bad51d9003f8b07a468a704e0644a700e936c357c17dd45a", size = 38766 }, + { url = "https://files.pythonhosted.org/packages/89/e2/8c299f384ae4364193724e2adad99f9504599d02a73ec9199bf3f406549d/wrapt-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f2a28eb35cf99d5f5bd12f5dd44a0f41d206db226535b37b0c60e9da162c3ed", size = 83730 }, + { url = "https://files.pythonhosted.org/packages/29/ef/fcdb776b12df5ea7180d065b28fa6bb27ac785dddcd7202a0b6962bbdb47/wrapt-1.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81b1289e99cf4bad07c23393ab447e5e96db0ab50974a280f7954b071d41b489", size = 75470 }, + { url = "https://files.pythonhosted.org/packages/55/b5/698bd0bf9fbb3ddb3a2feefbb7ad0dea1205f5d7d05b9cbab54f5db731aa/wrapt-1.17.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f2939cd4a2a52ca32bc0b359015718472d7f6de870760342e7ba295be9ebaf9", size = 83168 }, + { url = "https://files.pythonhosted.org/packages/ce/07/701a5cee28cb4d5df030d4b2649319e36f3d9fdd8000ef1d84eb06b9860d/wrapt-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a9653131bda68a1f029c52157fd81e11f07d485df55410401f745007bd6d339", size = 82307 }, + { url = "https://files.pythonhosted.org/packages/42/92/c48ba92cda6f74cb914dc3c5bba9650dc80b790e121c4b987f3a46b028f5/wrapt-1.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4e4b4385363de9052dac1a67bfb535c376f3d19c238b5f36bddc95efae15e12d", size = 75101 }, + { url = "https://files.pythonhosted.org/packages/8a/0a/9276d3269334138b88a2947efaaf6335f61d547698e50dff672ade24f2c6/wrapt-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bdf62d25234290db1837875d4dceb2151e4ea7f9fff2ed41c0fde23ed542eb5b", size = 81835 }, + { url = "https://files.pythonhosted.org/packages/b9/4c/39595e692753ef656ea94b51382cc9aea662fef59d7910128f5906486f0e/wrapt-1.17.0-cp311-cp311-win32.whl", hash = "sha256:5d8fd17635b262448ab8f99230fe4dac991af1dabdbb92f7a70a6afac8a7e346", size = 36412 }, + { url = "https://files.pythonhosted.org/packages/63/bb/c293a67fb765a2ada48f48cd0f2bb957da8161439da4c03ea123b9894c02/wrapt-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:92a3d214d5e53cb1db8b015f30d544bc9d3f7179a05feb8f16df713cecc2620a", size = 38744 }, + { url = "https://files.pythonhosted.org/packages/85/82/518605474beafff11f1a34759f6410ab429abff9f7881858a447e0d20712/wrapt-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:89fc28495896097622c3fc238915c79365dd0ede02f9a82ce436b13bd0ab7569", size = 38904 }, + { url = "https://files.pythonhosted.org/packages/80/6c/17c3b2fed28edfd96d8417c865ef0b4c955dc52c4e375d86f459f14340f1/wrapt-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875d240fdbdbe9e11f9831901fb8719da0bd4e6131f83aa9f69b96d18fae7504", size = 88622 }, + { url = "https://files.pythonhosted.org/packages/4a/11/60ecdf3b0fd3dca18978d89acb5d095a05f23299216e925fcd2717c81d93/wrapt-1.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ed16d95fd142e9c72b6c10b06514ad30e846a0d0917ab406186541fe68b451", size = 80920 }, + { url = "https://files.pythonhosted.org/packages/d2/50/dbef1a651578a3520d4534c1e434989e3620380c1ad97e309576b47f0ada/wrapt-1.17.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18b956061b8db634120b58f668592a772e87e2e78bc1f6a906cfcaa0cc7991c1", size = 89170 }, + { url = "https://files.pythonhosted.org/packages/44/a2/78c5956bf39955288c9e0dd62e807b308c3aa15a0f611fbff52aa8d6b5ea/wrapt-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:daba396199399ccabafbfc509037ac635a6bc18510ad1add8fd16d4739cdd106", size = 86748 }, + { url = "https://files.pythonhosted.org/packages/99/49/2ee413c78fc0bdfebe5bee590bf3becdc1fab0096a7a9c3b5c9666b2415f/wrapt-1.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4d63f4d446e10ad19ed01188d6c1e1bb134cde8c18b0aa2acfd973d41fcc5ada", size = 79734 }, + { url = "https://files.pythonhosted.org/packages/c0/8c/4221b7b270e36be90f0930fe15a4755a6ea24093f90b510166e9ed7861ea/wrapt-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a5e7cc39a45fc430af1aefc4d77ee6bad72c5bcdb1322cfde852c15192b8bd4", size = 87552 }, + { url = "https://files.pythonhosted.org/packages/4c/6b/1aaccf3efe58eb95e10ce8e77c8909b7a6b0da93449a92c4e6d6d10b3a3d/wrapt-1.17.0-cp312-cp312-win32.whl", hash = "sha256:0a0a1a1ec28b641f2a3a2c35cbe86c00051c04fffcfcc577ffcdd707df3f8635", size = 36647 }, + { url = "https://files.pythonhosted.org/packages/b3/4f/243f88ac49df005b9129194c6511b3642818b3e6271ddea47a15e2ee4934/wrapt-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:3c34f6896a01b84bab196f7119770fd8466c8ae3dfa73c59c0bb281e7b588ce7", size = 38830 }, + { url = "https://files.pythonhosted.org/packages/67/9c/38294e1bb92b055222d1b8b6591604ca4468b77b1250f59c15256437644f/wrapt-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:714c12485aa52efbc0fc0ade1e9ab3a70343db82627f90f2ecbc898fdf0bb181", size = 38904 }, + { url = "https://files.pythonhosted.org/packages/78/b6/76597fb362cbf8913a481d41b14b049a8813cd402a5d2f84e57957c813ae/wrapt-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da427d311782324a376cacb47c1a4adc43f99fd9d996ffc1b3e8529c4074d393", size = 88608 }, + { url = "https://files.pythonhosted.org/packages/bc/69/b500884e45b3881926b5f69188dc542fb5880019d15c8a0df1ab1dfda1f7/wrapt-1.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba1739fb38441a27a676f4de4123d3e858e494fac05868b7a281c0a383c098f4", size = 80879 }, + { url = "https://files.pythonhosted.org/packages/52/31/f4cc58afe29eab8a50ac5969963010c8b60987e719c478a5024bce39bc42/wrapt-1.17.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e711fc1acc7468463bc084d1b68561e40d1eaa135d8c509a65dd534403d83d7b", size = 89119 }, + { url = "https://files.pythonhosted.org/packages/aa/9c/05ab6bf75dbae7a9d34975fb6ee577e086c1c26cde3b6cf6051726d33c7c/wrapt-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:140ea00c87fafc42739bd74a94a5a9003f8e72c27c47cd4f61d8e05e6dec8721", size = 86778 }, + { url = "https://files.pythonhosted.org/packages/0e/6c/4b8d42e3db355603d35fe5c9db79c28f2472a6fd1ccf4dc25ae46739672a/wrapt-1.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73a96fd11d2b2e77d623a7f26e004cc31f131a365add1ce1ce9a19e55a1eef90", size = 79793 }, + { url = "https://files.pythonhosted.org/packages/69/23/90e3a2ee210c0843b2c2a49b3b97ffcf9cad1387cb18cbeef9218631ed5a/wrapt-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0b48554952f0f387984da81ccfa73b62e52817a4386d070c75e4db7d43a28c4a", size = 87606 }, + { url = "https://files.pythonhosted.org/packages/5f/06/3683126491ca787d8d71d8d340e775d40767c5efedb35039d987203393b7/wrapt-1.17.0-cp313-cp313-win32.whl", hash = "sha256:498fec8da10e3e62edd1e7368f4b24aa362ac0ad931e678332d1b209aec93045", size = 36651 }, + { url = "https://files.pythonhosted.org/packages/f1/bc/3bf6d2ca0d2c030d324ef9272bea0a8fdaff68f3d1fa7be7a61da88e51f7/wrapt-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd136bb85f4568fffca995bd3c8d52080b1e5b225dbf1c2b17b66b4c5fa02838", size = 38835 }, + { url = "https://files.pythonhosted.org/packages/ce/b5/251165c232d87197a81cd362eeb5104d661a2dd3aa1f0b33e4bf61dda8b8/wrapt-1.17.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17fcf043d0b4724858f25b8826c36e08f9fb2e475410bece0ec44a22d533da9b", size = 40146 }, + { url = "https://files.pythonhosted.org/packages/89/33/1e1bdd3e866eeb73d8c4755db1ceb8a80d5bd51ee4648b3f2247adec4e67/wrapt-1.17.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4a557d97f12813dc5e18dad9fa765ae44ddd56a672bb5de4825527c847d6379", size = 113444 }, + { url = "https://files.pythonhosted.org/packages/9f/7c/94f53b065a43f5dc1fbdd8b80fd8f41284315b543805c956619c0b8d92f0/wrapt-1.17.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0229b247b0fc7dee0d36176cbb79dbaf2a9eb7ecc50ec3121f40ef443155fb1d", size = 101246 }, + { url = "https://files.pythonhosted.org/packages/62/5d/640360baac6ea6018ed5e34e6e80e33cfbae2aefde24f117587cd5efd4b7/wrapt-1.17.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8425cfce27b8b20c9b89d77fb50e368d8306a90bf2b6eef2cdf5cd5083adf83f", size = 109320 }, + { url = "https://files.pythonhosted.org/packages/e3/cf/6c7a00ae86a2e9482c91170aefe93f4ccda06c1ac86c4de637c69133da59/wrapt-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c900108df470060174108012de06d45f514aa4ec21a191e7ab42988ff42a86c", size = 110193 }, + { url = "https://files.pythonhosted.org/packages/cd/cc/aa718df0d20287e8f953ce0e2f70c0af0fba1d3c367db7ee8bdc46ea7003/wrapt-1.17.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e547b447073fc0dbfcbff15154c1be8823d10dab4ad401bdb1575e3fdedff1b", size = 100460 }, + { url = "https://files.pythonhosted.org/packages/f7/16/9f3ac99fe1f6caaa789d67b4e3c562898b532c250769f5255fa8b8b93983/wrapt-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:914f66f3b6fc7b915d46c1cc424bc2441841083de01b90f9e81109c9759e43ab", size = 106347 }, + { url = "https://files.pythonhosted.org/packages/64/85/c77a331b2c06af49a687f8b926fc2d111047a51e6f0b0a4baa01ff3a673a/wrapt-1.17.0-cp313-cp313t-win32.whl", hash = "sha256:a4192b45dff127c7d69b3bdfb4d3e47b64179a0b9900b6351859f3001397dabf", size = 37971 }, + { url = "https://files.pythonhosted.org/packages/05/9b/b2469f8be9efed24283fd7b9eeb8e913e9bc0715cf919ea8645e428ab7af/wrapt-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:4f643df3d4419ea3f856c5c3f40fec1d65ea2e89ec812c83f7767c8730f9827a", size = 40755 }, + { url = "https://files.pythonhosted.org/packages/4b/d9/a8ba5e9507a9af1917285d118388c5eb7a81834873f45df213a6fe923774/wrapt-1.17.0-py3-none-any.whl", hash = "sha256:d2c63b93548eda58abf5188e505ffed0229bf675f7c3090f8e36ad55b8cbc371", size = 23592 }, +] + +[[package]] +name = "zarr" +version = "2.18.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asciitree" }, + { name = "fasteners", marker = "sys_platform != 'emscripten'" }, + { name = "numcodecs" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/c4/187a21ce7cf7c8f00c060dd0e04c2a81139bb7b1ab178bba83f2e1134ce2/zarr-2.18.3.tar.gz", hash = "sha256:2580d8cb6dd84621771a10d31c4d777dca8a27706a1a89b29f42d2d37e2df5ce", size = 3603224 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/142095e654c2b97133ff71df60979422717b29738b08bc8a1709a5d5e0d0/zarr-2.18.3-py3-none-any.whl", hash = "sha256:b1f7dfd2496f436745cdd4c7bcf8d3b4bc1dceef5fdd0d589c87130d842496dd", size = 210723 }, +] From 178d888aad4ead35b707fa611bca49225dd5172e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 17 Dec 2024 08:43:53 -0500 Subject: [PATCH 175/226] check in version and lockfile --- .gitignore | 25 ------------------------- .python-version | 1 + uv.lock | 2 +- 3 files changed, 2 insertions(+), 26 deletions(-) create mode 100644 .python-version diff --git a/.gitignore b/.gitignore index f27f8954..f1623a58 100644 --- a/.gitignore +++ b/.gitignore @@ -84,31 +84,6 @@ target/ profile_default/ ipython_config.py -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control .pdm.toml .pdm-python .pdm-build/ diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..fdcfcfdf --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 \ No newline at end of file diff --git a/uv.lock b/uv.lock index 3f31d41d..4ceccb6d 100644 --- a/uv.lock +++ b/uv.lock @@ -1176,7 +1176,7 @@ wheels = [ [[package]] name = "pymmcore-gui" -version = "0.1.dev222+gc9d09ab.d20241217" +version = "0.1.dev223+g9de76ea" source = { editable = "." } dependencies = [ { name = "ndv", extra = ["vispy"] }, From 2ef22a3a7ce0dd5ba7c41f5e49d5f4c0605a374f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 17 Dec 2024 08:48:16 -0500 Subject: [PATCH 176/226] add uv tests --- .github/workflows/ci.yml | 48 ++++++++++++---------------------------- 1 file changed, 14 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ec2b4c4..917d5a32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,14 +6,10 @@ concurrency: on: push: - branches: - - main - tags: - - "v*" + branches: [main] + tags: [v*] pull_request: workflow_dispatch: - schedule: - - cron: "0 0 * * 0" # every week (for --pre release tests) jobs: check-manifest: @@ -23,50 +19,34 @@ jobs: - run: pipx run check-manifest test: - name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{ matrix.backend }} + name: ${{ matrix.platform }} runs-on: ${{ matrix.platform }} strategy: fail-fast: false matrix: - python-version: ['3.10', '3.11'] platform: [macos-13, windows-latest] - backend: [pyqt6] - include: - - platform: windows-latest - python-version: "3.10" - backend: pyqt5 - - platform: windows-latest - python-version: "3.11" - backend: pyqt5 - # NOTE: pyside 2 and pyqt6 fails for some reason. maybe try to fix it at some point steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v4 with: - python-version: ${{ matrix.python-version }} - cache-dependency-path: "pyproject.toml" - cache: "pip" + version: "0.5.9" + enable-cache: true - - name: Install dependencies - run: | - python -m pip install -U pip - python -m pip install -e .[test,${{ matrix.backend }}] + - uses: pyvista/setup-headless-display-action@v3 + with: + qt: true - - name: Install Windows OpenGL - if: runner.os == 'Windows' - run: | - git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git - powershell gl-ci-helpers/appveyor/install_opengl.ps1 - if (Test-Path -Path "C:\Windows\system32\opengl32.dll" -PathType Leaf) {Exit 0} else {Exit 1} + - name: Install the project + run: uv sync --all-extras --dev - name: Install Micro-Manager run: mmcore install - name: Test - run: pytest -v --cov=pymmcore_widgets --cov-report=xml --color=yes + run: uv run pytest -v --cov=pymmcore_widgets --cov-report=xml --color=yes - name: Coverage uses: codecov/codecov-action@v5 @@ -104,4 +84,4 @@ jobs: - uses: softprops/action-gh-release@v2 with: generate_release_notes: true - files: './dist/*' + files: "./dist/*" From a902afbad5ae7cdf99f1a92b0277c6b011f22760 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 17 Dec 2024 09:00:24 -0500 Subject: [PATCH 177/226] remove zarr --- test.ome.zarr/.zgroup | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 test.ome.zarr/.zgroup diff --git a/test.ome.zarr/.zgroup b/test.ome.zarr/.zgroup deleted file mode 100644 index 3b7daf22..00000000 --- a/test.ome.zarr/.zgroup +++ /dev/null @@ -1,3 +0,0 @@ -{ - "zarr_format": 2 -} \ No newline at end of file From a75a97ed2a1e82c1363262eaf78e6749681ac9d4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 17 Dec 2024 09:08:55 -0500 Subject: [PATCH 178/226] uv run --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 917d5a32..5f79a94b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: run: uv sync --all-extras --dev - name: Install Micro-Manager - run: mmcore install + run: uv run mmcore install - name: Test run: uv run pytest -v --cov=pymmcore_widgets --cov-report=xml --color=yes From 8426271507763651c6c5a6f4471310eb8c599b13 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 17 Dec 2024 09:12:01 -0500 Subject: [PATCH 179/226] update pdbpp --- pyproject.toml | 2 +- uv.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 01a096a7..914e16a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ dev = [ "ipython>=8.30.0", "mypy>=1.13.0", - "pdbpp>=0.10.3 ; sys_platform != 'windows'", + "pdbpp>=0.10.3 ; sys_platform != 'Windows'", "pre-commit>=4.0.1", "pytest>=8.3.4", "pytest-cov>=6.0.0", diff --git a/uv.lock b/uv.lock index 4ceccb6d..06fcd6b5 100644 --- a/uv.lock +++ b/uv.lock @@ -1176,7 +1176,7 @@ wheels = [ [[package]] name = "pymmcore-gui" -version = "0.1.dev223+g9de76ea" +version = "0.1.dev184+ga75a97e.d20241217" source = { editable = "." } dependencies = [ { name = "ndv", extra = ["vispy"] }, @@ -1196,7 +1196,7 @@ dependencies = [ dev = [ { name = "ipython" }, { name = "mypy" }, - { name = "pdbpp", marker = "sys_platform != 'windows'" }, + { name = "pdbpp", marker = "sys_platform != 'Windows'" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1224,7 +1224,7 @@ requires-dist = [ dev = [ { name = "ipython", specifier = ">=8.30.0" }, { name = "mypy", specifier = ">=1.13.0" }, - { name = "pdbpp", marker = "sys_platform != 'windows'", specifier = ">=0.10.3" }, + { name = "pdbpp", marker = "sys_platform != 'Windows'", specifier = ">=0.10.3" }, { name = "pre-commit", specifier = ">=4.0.1" }, { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-cov", specifier = ">=6.0.0" }, From bc1d8771c26f81a5392d6c77b4df87aedf68f248 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 17 Dec 2024 09:15:37 -0500 Subject: [PATCH 180/226] fix again --- pyproject.toml | 2 +- uv.lock | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 914e16a6..e5e4068d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ dev = [ "ipython>=8.30.0", "mypy>=1.13.0", - "pdbpp>=0.10.3 ; sys_platform != 'Windows'", + "pdbpp>=0.10.3 ; sys_platform != 'win32'", "pre-commit>=4.0.1", "pytest>=8.3.4", "pytest-cov>=6.0.0", diff --git a/uv.lock b/uv.lock index 06fcd6b5..4b955e8e 100644 --- a/uv.lock +++ b/uv.lock @@ -1176,7 +1176,7 @@ wheels = [ [[package]] name = "pymmcore-gui" -version = "0.1.dev184+ga75a97e.d20241217" +version = "0.1.dev185+g8426271.d20241217" source = { editable = "." } dependencies = [ { name = "ndv", extra = ["vispy"] }, @@ -1196,7 +1196,7 @@ dependencies = [ dev = [ { name = "ipython" }, { name = "mypy" }, - { name = "pdbpp", marker = "sys_platform != 'Windows'" }, + { name = "pdbpp", marker = "sys_platform != 'win32'" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1224,7 +1224,7 @@ requires-dist = [ dev = [ { name = "ipython", specifier = ">=8.30.0" }, { name = "mypy", specifier = ">=1.13.0" }, - { name = "pdbpp", marker = "sys_platform != 'Windows'", specifier = ">=0.10.3" }, + { name = "pdbpp", marker = "sys_platform != 'win32'", specifier = ">=0.10.3" }, { name = "pre-commit", specifier = ">=4.0.1" }, { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-cov", specifier = ">=6.0.0" }, From 73f8ae22a94a4ccc88ea8436513f9f9d65fa09d9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 17 Dec 2024 10:13:31 -0500 Subject: [PATCH 181/226] changes to structure --- README.md | 45 ++++++++ pyproject.toml | 1 + src/pymmcore_gui/__main__.py | 53 +-------- src/pymmcore_gui/_app.py | 109 ++++++++++++++++++ src/pymmcore_gui/_core_link.py | 2 +- src/pymmcore_gui/_main_window.py | 18 ++- .../_widgets/_mda_widget/_mda_widget.py | 8 +- .../_widgets/_mda_widget/_save_widget.py | 2 +- .../{_menubar => _widgets}/_menubar.py | 2 +- .../_shutters_toolbar.py | 10 +- .../{_toolbar => _widgets}/_snap_live.py | 2 +- .../_viewers/_mda_viewer/_data_wrappers.py | 3 +- .../_viewers/_mda_viewer/_mda_save_button.py | 14 +-- .../_viewers/_mda_viewer/_mda_viewer.py | 2 +- .../_preview_viewer/_preview_viewer.py | 4 +- .../_writers/_tensorstore_zarr.py | 3 +- src/pymmcore_gui/readers/_ome_zarr_reader.py | 10 +- .../readers/_tensorstore_zarr_reader.py | 10 +- tests/conftest.py | 18 +-- tests/test_gui.py | 13 ++- tests/test_mda_viewer.py | 20 ++-- tests/test_save_widget.py | 7 +- tests/test_stage_widget.py | 5 +- uv.lock | 2 +- 24 files changed, 238 insertions(+), 125 deletions(-) create mode 100644 src/pymmcore_gui/_app.py rename src/pymmcore_gui/{_menubar => _widgets}/_menubar.py (99%) rename src/pymmcore_gui/{_toolbar => _widgets}/_shutters_toolbar.py (91%) rename src/pymmcore_gui/{_toolbar => _widgets}/_snap_live.py (98%) diff --git a/README.md b/README.md index e69de29b..88eec509 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,45 @@ +# pymmcore-gui + +This is a stub repo for discussing a unified effort towards a GUI application for [`pymmcore-plus`](https://github.com/pymmcore-plus/pymmcore-plus) & [`pymmcore-widgets`](https://github.com/pymmcore-plus/pymmcore-widgets) + +## Goals (and non-goals) of unification: + +**Goals** + +- Provide a napari-independent GUI for controlling micro-manager via pymmcore-plus (i.e. pure python micro-manager control). We'd like to have a primary application that we can point interested parties to (rather than having to describe all the related efforts and explain how to compose pymmcore-widgets directly). +- Avoid duplicate efforts. While independent related projects are excellent in that they allow rapid exploration and experimentation, we'd like to be able to share the results of these efforts. In some ways that is done via pymmcore-widgets, but all of the application level stuff (persistence of settings, complex layouts, coordination of data saving, viewing & processing) is explicitly not part of pymmcore widgets. +- Establish patterns for persistence and application state. + +**Non-Goals** + +- Working on a shared application is *not* meant to discourage independent experimentation and repositories. (One of the real strengths in doing this all in python is the ease of creating custom widgets and GUIs!). One possible pattern would be forks & branches off of a main central repository. + +## Purpose of this repo + +For now, this serves as place to store TODO issues and discussion items. Please open an issue if you are interested, (even just to say hi! 🙂) + +## Existing Efforts + +### napari-micromanager + +napari-micromanager + +An initial effort towards a pure python micro-manager gui based on the pymmcore-plus ecosystem was [napari-micromanager](https://github.com/pymmcore-plus/napari-micromanager). It uses [napari](https://github.com/napari/napari) as the primary viewer, and [pymmcore-widgets](https://github.com/pymmcore-plus/pymmcore-widgets) for most of the UI related to micro-manager functionality. It still works and will continue to be maintained for the foreseable future, but we are also interested in exploring options that do not depend on napari. + +One candidate to replace the viewing functionality provided by napari is [`ndv`](https://github.com/pyapp-kit/ndv), a slim multi-dimensional viewer with minimal dependencies. Two experimental efforts exist to build a micro-manager gui using ndv + +### micromanager-gui + +Screenshot 2024-06-03 at 11 49 45 PM + +[micromanager-gui](https://github.com/fdrgsp/micromanager-gui) is a standalone application written by Federico Gasparoli ([@fdrgsp](https://github.com/fdrgsp)), and currently lives in federico's personal org while we experiment with it. + +### pymmcore-plus-sandbox + +Screenshot 2024-10-13 at 2 50 57 PM + +[`pymmcore-plus-sandbox`](https://github.com/gselzer/pymmcore-plus-sandbox) is another experimental standalone GUI written by Gabe Selzer ([@gselzer](https://github.com/gselzer) with input from [@marktsuchida](https://github.com/marktsuchida). One initial goal here is to create a main window that looks very similar to the java based MMStudio (which would make it familiar to existing users of the java ecosystem). + +### LEB-EPFL + +Willi Stepp ([@wl-stepp](https://github.com/wl-stepp)) has been an active contributor to pymmcore-widgets and uses some of these widgets in his event-driven microscopy controllers. diff --git a/pyproject.toml b/pyproject.toml index e5e4068d..ed831280 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -116,6 +116,7 @@ disallow_any_generics = false disallow_subclassing_any = false show_error_codes = true pretty = true +plugins = ["pydantic.mypy"] # https://docs.pytest.org/ [tool.pytest.ini_options] diff --git a/src/pymmcore_gui/__main__.py b/src/pymmcore_gui/__main__.py index 44712ccf..7748c294 100644 --- a/src/pymmcore_gui/__main__.py +++ b/src/pymmcore_gui/__main__.py @@ -1,52 +1,3 @@ -from __future__ import annotations +from ._app import main -import argparse -import sys -import traceback -from typing import TYPE_CHECKING, Sequence - -from qtpy.QtWidgets import QApplication - -from pymmcore_gui import MicroManagerGUI - -if TYPE_CHECKING: - from types import TracebackType - - -def main(args: Sequence[str] | None = None) -> None: - """Run the Micro-Manager GUI.""" - if args is None: - args = sys.argv[1:] - - parser = argparse.ArgumentParser(description="Enter string") - parser.add_argument( - "-c", - "--config", - type=str, - default=None, - help="Config file to load", - nargs="?", - ) - parsed_args = parser.parse_args(args) - - app = QApplication([]) - win = MicroManagerGUI(config=parsed_args.config) - win.show() - - sys.excepthook = _our_excepthook - app.exec_() - - -def _our_excepthook( - type: type[BaseException], value: BaseException, tb: TracebackType | None -) -> None: - """Excepthook that prints the traceback to the console. - - By default, Qt's excepthook raises sys.exit(), which is not what we want. - """ - # this could be elaborated to do all kinds of things... - traceback.print_exception(type, value, tb) - - -if __name__ == "__main__": - main() +main() diff --git a/src/pymmcore_gui/_app.py b/src/pymmcore_gui/_app.py new file mode 100644 index 00000000..9e0e3268 --- /dev/null +++ b/src/pymmcore_gui/_app.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import argparse +import os +import sys +import traceback +from contextlib import suppress +from typing import TYPE_CHECKING + +from qtpy.QtWidgets import QApplication + +from pymmcore_gui import MicroManagerGUI + +if TYPE_CHECKING: + from collections.abc import Sequence + from types import TracebackType + + +def main(args: Sequence[str] | None = None) -> None: + """Run the Micro-Manager GUI.""" + if args is None: + args = sys.argv[1:] + + parser = argparse.ArgumentParser(description="Enter string") + parser.add_argument( + "-c", "--config", type=str, default=None, help="Config file to load", nargs="?" + ) + parsed_args = parser.parse_args(args) + + app = QApplication([]) + _install_excepthook() + + win = MicroManagerGUI(config=parsed_args.config) + win.show() + + app.exec() + + +# ------------------- Custom excepthook ------------------- + + +def _install_excepthook() -> None: + """Install a custom excepthook that does not raise sys.exit(). + + This is necessary to prevent the application from closing when an exception + is raised. + """ + if hasattr(sys, "_original_excepthook_"): + return + sys._original_excepthook_ = sys.excepthook # type: ignore + sys.excepthook = ndv_excepthook + + +def _print_exception( + exc_type: type[BaseException], + exc_value: BaseException, + exc_traceback: TracebackType | None, +) -> None: + try: + import psygnal + from rich.console import Console + from rich.traceback import Traceback + + tb = Traceback.from_exception( + exc_type, exc_value, exc_traceback, suppress=[psygnal], max_frames=10 + ) + Console(stderr=True).print(tb) + except ImportError: + traceback.print_exception(exc_type, value=exc_value, tb=exc_traceback) + + +def ndv_excepthook( + exc_type: type[BaseException], exc_value: BaseException, tb: TracebackType | None +) -> None: + _print_exception(exc_type, exc_value, tb) + if not tb: + return + + # if we're running in a vscode debugger, let the debugger handle the exception + if ( + (debugpy := sys.modules.get("debugpy")) + and debugpy.is_client_connected() + and ("pydevd" in sys.modules) + ): + with suppress(Exception): + import threading + + import pydevd + + py_db = pydevd.get_global_debugger() + thread = threading.current_thread() + additional_info = py_db.set_additional_thread_info(thread) + additional_info.is_tracing += 1 + + try: + arg = (exc_type, exc_value, tb) + py_db.stop_on_unhandled_exception(py_db, thread, additional_info, arg) + finally: + additional_info.is_tracing -= 1 + # otherwise, if MMGUI_DEBUG_EXCEPTIONS is set, drop into pdb + elif os.getenv("MMGUI_DEBUG_EXCEPTIONS"): + import pdb + + pdb.post_mortem(tb) + + # after handling the exception, exit if MMGUI_EXIT_ON_EXCEPTION is set + if os.getenv("MMGUI_EXIT_ON_EXCEPTION"): + print("\nMMGUI_EXIT_ON_EXCEPTION is set, exiting.") + sys.exit(1) diff --git a/src/pymmcore_gui/_core_link.py b/src/pymmcore_gui/_core_link.py index 70aff2a3..51eb5626 100644 --- a/src/pymmcore_gui/_core_link.py +++ b/src/pymmcore_gui/_core_link.py @@ -9,7 +9,7 @@ from pymmcore_gui._widgets._viewers import MDAViewer -from ._menubar._menubar import PREVIEW, VIEWERS +from ._widgets._menubar import PREVIEW, VIEWERS from ._widgets._viewers import Preview if TYPE_CHECKING: diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 92e6d9f4..8a7d26c5 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -6,18 +6,14 @@ from ndv import NDViewer from pymmcore_plus import CMMCorePlus -from qtpy.QtWidgets import ( - QGridLayout, - QMainWindow, - QWidget, -) +from qtpy.QtWidgets import QGridLayout, QMainWindow, QWidget from pymmcore_gui.readers import TensorstoreZarrReader from ._core_link import CoreViewersLink -from ._menubar._menubar import _MenuBar -from ._toolbar._shutters_toolbar import _ShuttersToolbar -from ._toolbar._snap_live import _SnapLive +from ._widgets._menubar import MenuBar +from ._widgets._shutters_toolbar import ShuttersToolbar +from ._widgets._snap_live import SnapLive if TYPE_CHECKING: from qtpy.QtGui import QCloseEvent, QDragEnterEvent, QDropEvent @@ -47,13 +43,13 @@ def __init__( self.setCentralWidget(central_wdg) # add the menu bar (and the logic to create/show widgets) - self._menu_bar = _MenuBar(parent=self, mmcore=self._mmc) + self._menu_bar = MenuBar(parent=self, mmcore=self._mmc) self.setMenuBar(self._menu_bar) # add toolbar - self._snap_live_toolbar = _SnapLive(parent=self, mmcore=self._mmc) + self._snap_live_toolbar = SnapLive(parent=self, mmcore=self._mmc) self.addToolBar(self._snap_live_toolbar) - self._shutters_toolbar = _ShuttersToolbar(parent=self, mmcore=self._mmc) + self._shutters_toolbar = ShuttersToolbar(parent=self, mmcore=self._mmc) self.addToolBar(self._shutters_toolbar) # link the MDA viewers diff --git a/src/pymmcore_gui/_widgets/_mda_widget/_mda_widget.py b/src/pymmcore_gui/_widgets/_mda_widget/_mda_widget.py index da76eb1c..3812606f 100644 --- a/src/pymmcore_gui/_widgets/_mda_widget/_mda_widget.py +++ b/src/pymmcore_gui/_widgets/_mda_widget/_mda_widget.py @@ -16,13 +16,7 @@ from pymmcore_gui._writers._tensorstore_zarr import _TensorStoreHandler -from ._save_widget import ( - OME_TIFF, - OME_ZARR, - WRITERS, - ZARR_TESNSORSTORE, - SaveGroupBox, -) +from ._save_widget import OME_TIFF, OME_ZARR, WRITERS, ZARR_TESNSORSTORE, SaveGroupBox NUM_SPLIT = re.compile(r"(.*?)(?:_(\d{3,}))?$") OME_TIFFS = tuple(WRITERS[OME_TIFF]) diff --git a/src/pymmcore_gui/_widgets/_mda_widget/_save_widget.py b/src/pymmcore_gui/_widgets/_mda_widget/_save_widget.py index 008f0319..53236139 100644 --- a/src/pymmcore_gui/_widgets/_mda_widget/_save_widget.py +++ b/src/pymmcore_gui/_widgets/_mda_widget/_save_widget.py @@ -153,7 +153,7 @@ def setValue(self, value: dict | str | Path) -> None: - format: str - Set the combo box to the writer with this name. - should_save: bool - Set the checked state of the checkbox. """ - if isinstance(value, (str, Path)): + if isinstance(value, str | Path): self.setCurrentPath(value) self.setChecked(True) return diff --git a/src/pymmcore_gui/_menubar/_menubar.py b/src/pymmcore_gui/_widgets/_menubar.py similarity index 99% rename from src/pymmcore_gui/_menubar/_menubar.py rename to src/pymmcore_gui/_widgets/_menubar.py index 2a5ada54..6bd351d5 100644 --- a/src/pymmcore_gui/_menubar/_menubar.py +++ b/src/pymmcore_gui/_widgets/_menubar.py @@ -82,7 +82,7 @@ def __init__( self.resize(widget.minimumSizeHint()) -class _MenuBar(QMenuBar): +class MenuBar(QMenuBar): """Menu Bar for the Micro-Manager GUI. It contains the actions to create and show widgets and dockwidgets. diff --git a/src/pymmcore_gui/_toolbar/_shutters_toolbar.py b/src/pymmcore_gui/_widgets/_shutters_toolbar.py similarity index 91% rename from src/pymmcore_gui/_toolbar/_shutters_toolbar.py rename to src/pymmcore_gui/_widgets/_shutters_toolbar.py index b59671d2..1fae3c54 100644 --- a/src/pymmcore_gui/_toolbar/_shutters_toolbar.py +++ b/src/pymmcore_gui/_widgets/_shutters_toolbar.py @@ -1,12 +1,18 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from pymmcore_plus import CMMCorePlus, DeviceType from pymmcore_widgets import ShuttersWidget from qtpy.QtCore import Qt -from qtpy.QtWidgets import QToolBar, QWidget + +if TYPE_CHECKING: + from PyQt6.QtWidgets import QToolBar, QWidget +else: + from qtpy.QtWidgets import QToolBar, QWidget -class _ShuttersToolbar(QToolBar): +class ShuttersToolbar(QToolBar): """A QToolBar for the loased Shutters.""" def __init__( diff --git a/src/pymmcore_gui/_toolbar/_snap_live.py b/src/pymmcore_gui/_widgets/_snap_live.py similarity index 98% rename from src/pymmcore_gui/_toolbar/_snap_live.py rename to src/pymmcore_gui/_widgets/_snap_live.py index 3d1ab48d..266f905a 100644 --- a/src/pymmcore_gui/_toolbar/_snap_live.py +++ b/src/pymmcore_gui/_widgets/_snap_live.py @@ -8,7 +8,7 @@ from pymmcore_gui._widgets._snap_live_buttons import Live, Snap -class _SnapLive(QToolBar): +class SnapLive(QToolBar): """A QToolBar for the Snap and Live buttons.""" def __init__( diff --git a/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py b/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py index 65451db0..c607aeab 100644 --- a/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py +++ b/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py @@ -1,7 +1,7 @@ from __future__ import annotations from contextlib import suppress -from typing import TYPE_CHECKING, Any, Hashable, Mapping, TypeGuard +from typing import TYPE_CHECKING, Any, TypeGuard from ndv import DataWrapper from pymmcore_plus.mda.handlers import OMEZarrWriter, TensorStoreHandler @@ -9,6 +9,7 @@ from pymmcore_gui.readers import OMEZarrReader, TensorstoreZarrReader if TYPE_CHECKING: + from collections.abc import Hashable, Mapping from pathlib import Path from pymmcore_plus.mda.handlers._5d_writer_base import _5DWriterBase diff --git a/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py b/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py index cf34b04c..d13cbacc 100644 --- a/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py +++ b/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py @@ -4,11 +4,7 @@ from typing import TYPE_CHECKING from fonticon_mdi6 import MDI6 -from qtpy.QtWidgets import ( - QFileDialog, - QPushButton, - QWidget, -) +from qtpy.QtWidgets import QFileDialog, QPushButton, QWidget from superqt.fonticon import icon from ._data_wrappers import MM5DWriterWrapper, MMTensorstoreWrapper @@ -18,11 +14,7 @@ class MDASaveButton(QPushButton): - def __init__( - self, - data_wrapper: DataWrapper, - parent: QWidget | None = None, - ): + def __init__(self, data_wrapper: DataWrapper, parent: QWidget | None = None): super().__init__(parent=parent) self.setIcon(icon(MDI6.content_save_outline)) self.clicked.connect(self._on_click) @@ -43,7 +35,7 @@ def _on_click(self) -> None: if suffix == ".zarr": self._data_wrapper.save_as_zarr(self._last_loc) elif suffix in {".tif", ".tiff"} and isinstance( - self._data_wrapper, (MMTensorstoreWrapper, MM5DWriterWrapper) + self._data_wrapper, MMTensorstoreWrapper | MM5DWriterWrapper ): self._data_wrapper.save_as_tiff(self._last_loc) else: diff --git a/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py b/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py index 7bebf3d4..51c11d90 100644 --- a/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py +++ b/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py @@ -54,7 +54,7 @@ def __init__( # MMTensorstoreWrapper) or OMEZarrWriter (and thus the MM5DWriterWrapper) # since we didn't yet implement the save_as_zarr and save_as_tiff methods # for OMETiffWriter in the MM5DWriterWrapper. - if isinstance(data, (TensorStoreHandler, OMEZarrWriter)): + if isinstance(data, TensorStoreHandler | OMEZarrWriter): self._save_btn = MDASaveButton(self._data_wrapper) self._btns.insertWidget(3, self._save_btn) diff --git a/src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py b/src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py index b47dfdd4..5d15e092 100644 --- a/src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py +++ b/src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Hashable, Mapping, cast +from typing import TYPE_CHECKING, Any, cast import tensorstore as ts from ndv import DataWrapper, NDViewer @@ -14,6 +14,8 @@ from ._preview_save_button import SaveButton if TYPE_CHECKING: + from collections.abc import Hashable, Mapping + import numpy as np from qtpy.QtGui import QCloseEvent from qtpy.QtWidgets import QWidget diff --git a/src/pymmcore_gui/_writers/_tensorstore_zarr.py b/src/pymmcore_gui/_writers/_tensorstore_zarr.py index 9b28a3cc..d919e496 100644 --- a/src/pymmcore_gui/_writers/_tensorstore_zarr.py +++ b/src/pymmcore_gui/_writers/_tensorstore_zarr.py @@ -1,12 +1,13 @@ from __future__ import annotations import time -from typing import TYPE_CHECKING, Literal, Mapping, TypeAlias +from typing import TYPE_CHECKING, Literal, TypeAlias from pymmcore_plus._logger import logger from pymmcore_plus.mda.handlers import TensorStoreHandler if TYPE_CHECKING: + from collections.abc import Mapping from os import PathLike from pymmcore_plus.metadata.serialize import json_dumps, json_loads diff --git a/src/pymmcore_gui/readers/_ome_zarr_reader.py b/src/pymmcore_gui/readers/_ome_zarr_reader.py index dd2c0a58..9c3d1745 100644 --- a/src/pymmcore_gui/readers/_ome_zarr_reader.py +++ b/src/pymmcore_gui/readers/_ome_zarr_reader.py @@ -2,7 +2,7 @@ import json from pathlib import Path -from typing import Any, Mapping, cast +from typing import TYPE_CHECKING, Any, cast import numpy as np import useq @@ -11,6 +11,9 @@ from tqdm import tqdm from zarr.hierarchy import Group +if TYPE_CHECKING: + from collections.abc import Mapping + EVENT = "Event" FRAME_META = "frame_meta" ARRAY_DIMS = "_ARRAY_DIMENSIONS" @@ -135,10 +138,7 @@ def isel( return data def write_tiff( - self, - path: str | Path, - indexers: Mapping[str, int] | None = None, - **kwargs: Any, + self, path: str | Path, indexers: Mapping[str, int] | None = None, **kwargs: Any ) -> None: """Write the data to a tiff file. diff --git a/src/pymmcore_gui/readers/_tensorstore_zarr_reader.py b/src/pymmcore_gui/readers/_tensorstore_zarr_reader.py index 1ca889e2..3ee53398 100644 --- a/src/pymmcore_gui/readers/_tensorstore_zarr_reader.py +++ b/src/pymmcore_gui/readers/_tensorstore_zarr_reader.py @@ -3,7 +3,7 @@ import json import warnings from pathlib import Path -from typing import Any, Mapping, cast +from typing import TYPE_CHECKING, Any, cast import numpy as np import tensorstore as ts @@ -12,6 +12,9 @@ from tifffile import imwrite from tqdm import tqdm +if TYPE_CHECKING: + from collections.abc import Mapping + class TensorstoreZarrReader: """Read a tensorstore zarr file generated with the 'TensorstoreZarrWriter'. @@ -140,10 +143,7 @@ def isel( return data def write_tiff( - self, - path: str | Path, - indexers: Mapping[str, int] | None = None, - **kwargs: Any, + self, path: str | Path, indexers: Mapping[str, int] | None = None, **kwargs: Any ) -> None: """Write the data to a tiff file. diff --git a/tests/conftest.py b/tests/conftest.py index 2a67c8dc..3963d90d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,19 +15,23 @@ from unittest.mock import patch import pytest -from pymmcore_plus import CMMCorePlus +from pymmcore_plus import CMMCorePlus, configure_logging from pymmcore_plus.core import _mmcore_plus if TYPE_CHECKING: + from collections.abc import Iterator + from pytest import FixtureRequest from qtpy.QtWidgets import QApplication TEST_CONFIG = str(Path(__file__).parent / "test_config.cfg") +configure_logging(stderr_level="CRITICAL") + # to create a new CMMCorePlus() for every test @pytest.fixture(autouse=True) -def global_mmcore(): +def global_mmcore() -> Iterator[CMMCorePlus]: mmc = CMMCorePlus() mmc.loadSystemConfiguration(TEST_CONFIG) with patch.object(_mmcore_plus, "_instance", mmc): @@ -35,7 +39,7 @@ def global_mmcore(): @pytest.fixture() -def _run_after_each_test(request: FixtureRequest, qapp: QApplication): +def check_leaks(request: FixtureRequest, qapp: QApplication) -> Iterator[None]: """Run after each test to ensure no widgets have been left around. When this test fails, it means that a widget being tested has an issue closing @@ -49,12 +53,12 @@ def _run_after_each_test(request: FixtureRequest, qapp: QApplication): # if the test failed, don't worry about checking widgets if request.session.testsfailed - failures_before: return - remaining = qapp.topLevelWidgets() - print() - for r in remaining: - print(r, r.parent()) + remaining = qapp.topLevelWidgets() if len(remaining) > nbefore: + print() + for r in remaining: + print(r, r.parent()) test = f"{request.node.path.name}::{request.node.originalname}" raise AssertionError(f"topLevelWidgets remaining after {test!r}: {remaining}") diff --git a/tests/test_gui.py b/tests/test_gui.py index 344d4600..6be29947 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -2,8 +2,10 @@ from typing import TYPE_CHECKING +import pytest + from pymmcore_gui import MicroManagerGUI -from pymmcore_gui._menubar._menubar import DOCKWIDGETS, WIDGETS +from pymmcore_gui._widgets._menubar import DOCKWIDGETS, WIDGETS from pymmcore_gui._widgets._viewers import MDAViewer if TYPE_CHECKING: @@ -11,7 +13,8 @@ from pytestqt.qtbot import QtBot -def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +@pytest.mark.usefixtures("check_leaks") +def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) assert gui._menu_bar._mda @@ -23,7 +26,8 @@ def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test assert gui._core_link._mda_running is False -def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +@pytest.mark.usefixtures("check_leaks") +def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) menu = gui._menu_bar @@ -34,7 +38,8 @@ def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test assert len(menu._widgets.keys()) == len(WIDGETS) + len(DOCKWIDGETS) -def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +@pytest.mark.usefixtures("check_leaks") +def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) menu = gui._menu_bar diff --git a/tests/test_mda_viewer.py b/tests/test_mda_viewer.py index f6d5ef0e..55802ea5 100644 --- a/tests/test_mda_viewer.py +++ b/tests/test_mda_viewer.py @@ -1,16 +1,11 @@ from __future__ import annotations -from pathlib import Path from typing import TYPE_CHECKING, cast from unittest.mock import patch import pytest import useq -from pymmcore_plus.mda.handlers import ( - OMETiffWriter, - OMEZarrWriter, - TensorStoreHandler, -) +from pymmcore_plus.mda.handlers import OMETiffWriter, OMEZarrWriter, TensorStoreHandler from pymmcore_plus.metadata import SummaryMetaV1 from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY @@ -25,13 +20,16 @@ from pymmcore_gui._widgets._viewers import MDAViewer if TYPE_CHECKING: + from pathlib import Path + from pymmcore_plus import CMMCorePlus from pytestqt.qtbot import QtBot +@pytest.mark.usefixtures("check_leaks") def test_mda_viewer_no_saving( - qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path, _run_after_each_test -): + qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path +) -> None: gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) @@ -56,14 +54,14 @@ def test_mda_viewer_no_saving( ] +@pytest.mark.usefixtures("check_leaks") @pytest.mark.parametrize("writers", writers) def test_mda_viewer_saving( qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path, writers: tuple[str, str, type], - _run_after_each_test, -): +) -> None: gui = MicroManagerGUI(mmcore=global_mmcore) qtbot.addWidget(gui) @@ -115,5 +113,5 @@ def test_mda_writer(qtbot: QtBot, tmp_path: Path, data: tuple) -> None: qtbot.addWidget(wdg) wdg.show() path, save_format, cls = data - writer = wdg._create_writer(save_format, Path(path)) + writer = wdg._create_writer(save_format, tmp_path / path) assert isinstance(writer, cls) if writer is not None else writer is None diff --git a/tests/test_save_widget.py b/tests/test_save_widget.py index a3f87dc8..22bd9417 100644 --- a/tests/test_save_widget.py +++ b/tests/test_save_widget.py @@ -1,7 +1,9 @@ +from __future__ import annotations + from pathlib import Path +from typing import TYPE_CHECKING import pytest -from pytestqt.qtbot import QtBot from pymmcore_gui._widgets._mda_widget._save_widget import ( DIRECTORY_WRITERS, @@ -15,6 +17,9 @@ SaveGroupBox, ) +if TYPE_CHECKING: + from pytestqt.qtbot import QtBot + def test_set_get_value(qtbot: QtBot) -> None: wdg = SaveGroupBox() diff --git a/tests/test_stage_widget.py b/tests/test_stage_widget.py index 8bc3ecd1..dac5aaa2 100644 --- a/tests/test_stage_widget.py +++ b/tests/test_stage_widget.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING +import pytest + from pymmcore_gui._widgets._stage_control import StagesControlWidget if TYPE_CHECKING: @@ -9,7 +11,8 @@ from pytestqt.qtbot import QtBot -def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus, _run_after_each_test): +@pytest.mark.usefixtures("check_leaks") +def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: s = StagesControlWidget(mmcore=global_mmcore) qtbot.addWidget(s) group1 = s._layout.takeAt(0).widget() diff --git a/uv.lock b/uv.lock index 4b955e8e..ce703957 100644 --- a/uv.lock +++ b/uv.lock @@ -1176,7 +1176,7 @@ wheels = [ [[package]] name = "pymmcore-gui" -version = "0.1.dev185+g8426271.d20241217" +version = "0.1.dev186+gbc1d877.d20241217" source = { editable = "." } dependencies = [ { name = "ndv", extra = ["vispy"] }, From 7d12467f720619f47677d83a6687f092aa39c16f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 17 Dec 2024 10:19:25 -0500 Subject: [PATCH 182/226] more move --- pyproject.toml | 1 + src/pymmcore_gui/_core_link.py | 10 +++++----- src/pymmcore_gui/_main_window.py | 8 ++++---- src/pymmcore_gui/_writers/__init__.py | 5 ----- src/pymmcore_gui/io/__init__.py | 4 ++++ src/pymmcore_gui/io/_writers/__init__.py | 5 +++++ .../{ => io}/_writers/_tensorstore_zarr.py | 4 ++-- src/pymmcore_gui/{ => io}/readers/__init__.py | 0 src/pymmcore_gui/{ => io}/readers/_ome_zarr_reader.py | 0 .../{ => io}/readers/_tensorstore_zarr_reader.py | 0 src/pymmcore_gui/widgets/__init__.py | 0 .../{_widgets => widgets}/_install_widget.py | 0 .../{_widgets => widgets}/_mda_widget/__init__.py | 0 .../{_widgets => widgets}/_mda_widget/_mda_widget.py | 6 +++--- .../{_widgets => widgets}/_mda_widget/_save_widget.py | 0 src/pymmcore_gui/{_widgets => widgets}/_menubar.py | 8 ++++---- src/pymmcore_gui/{_widgets => widgets}/_mm_console.py | 0 .../{_widgets => widgets}/_shutters_toolbar.py | 0 src/pymmcore_gui/{_widgets => widgets}/_snap_live.py | 2 +- .../{_widgets => widgets}/_snap_live_buttons.py | 0 .../{_widgets => widgets}/_stage_control.py | 0 .../{_widgets => widgets}/_viewers/__init__.py | 0 .../_viewers/_mda_viewer/__init__.py | 0 .../_viewers/_mda_viewer/_data_wrappers.py | 4 ++-- .../_viewers/_mda_viewer/_mda_save_button.py | 0 .../_viewers/_mda_viewer/_mda_viewer.py | 2 +- .../_viewers/_preview_viewer/__init__.py | 0 .../_viewers/_preview_viewer/_preview_save_button.py | 2 +- .../_viewers/_preview_viewer/_preview_viewer.py | 2 +- tests/test_gui.py | 4 ++-- tests/test_mda_viewer.py | 6 +++--- tests/test_readers_writers.py | 8 +++----- tests/test_save_widget.py | 2 +- tests/test_stage_widget.py | 2 +- 34 files changed, 44 insertions(+), 41 deletions(-) delete mode 100644 src/pymmcore_gui/_writers/__init__.py create mode 100644 src/pymmcore_gui/io/__init__.py create mode 100644 src/pymmcore_gui/io/_writers/__init__.py rename src/pymmcore_gui/{ => io}/_writers/_tensorstore_zarr.py (95%) rename src/pymmcore_gui/{ => io}/readers/__init__.py (100%) rename src/pymmcore_gui/{ => io}/readers/_ome_zarr_reader.py (100%) rename src/pymmcore_gui/{ => io}/readers/_tensorstore_zarr_reader.py (100%) create mode 100644 src/pymmcore_gui/widgets/__init__.py rename src/pymmcore_gui/{_widgets => widgets}/_install_widget.py (100%) rename src/pymmcore_gui/{_widgets => widgets}/_mda_widget/__init__.py (100%) rename src/pymmcore_gui/{_widgets => widgets}/_mda_widget/_mda_widget.py (97%) rename src/pymmcore_gui/{_widgets => widgets}/_mda_widget/_save_widget.py (100%) rename src/pymmcore_gui/{_widgets => widgets}/_menubar.py (97%) rename src/pymmcore_gui/{_widgets => widgets}/_mm_console.py (100%) rename src/pymmcore_gui/{_widgets => widgets}/_shutters_toolbar.py (100%) rename src/pymmcore_gui/{_widgets => widgets}/_snap_live.py (95%) rename src/pymmcore_gui/{_widgets => widgets}/_snap_live_buttons.py (100%) rename src/pymmcore_gui/{_widgets => widgets}/_stage_control.py (100%) rename src/pymmcore_gui/{_widgets => widgets}/_viewers/__init__.py (100%) rename src/pymmcore_gui/{_widgets => widgets}/_viewers/_mda_viewer/__init__.py (100%) rename src/pymmcore_gui/{_widgets => widgets}/_viewers/_mda_viewer/_data_wrappers.py (96%) rename src/pymmcore_gui/{_widgets => widgets}/_viewers/_mda_viewer/_mda_save_button.py (100%) rename src/pymmcore_gui/{_widgets => widgets}/_viewers/_mda_viewer/_mda_viewer.py (97%) rename src/pymmcore_gui/{_widgets => widgets}/_viewers/_preview_viewer/__init__.py (100%) rename src/pymmcore_gui/{_widgets => widgets}/_viewers/_preview_viewer/_preview_save_button.py (97%) rename src/pymmcore_gui/{_widgets => widgets}/_viewers/_preview_viewer/_preview_viewer.py (99%) diff --git a/pyproject.toml b/pyproject.toml index ed831280..18785751 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,7 @@ select = [ ] ignore = [ "D100", # Missing docstring in public module + "D104", # Missing docstring in public package "D401", # First line should be in imperative mood (remove to opt in) ] diff --git a/src/pymmcore_gui/_core_link.py b/src/pymmcore_gui/_core_link.py index 51eb5626..95aa5397 100644 --- a/src/pymmcore_gui/_core_link.py +++ b/src/pymmcore_gui/_core_link.py @@ -7,18 +7,18 @@ from qtpy.QtCore import QObject, Qt from qtpy.QtWidgets import QTabBar, QTabWidget -from pymmcore_gui._widgets._viewers import MDAViewer +from pymmcore_gui.widgets._viewers import MDAViewer -from ._widgets._menubar import PREVIEW, VIEWERS -from ._widgets._viewers import Preview +from .widgets._menubar import PREVIEW, VIEWERS +from .widgets._viewers import Preview if TYPE_CHECKING: import useq from pymmcore_plus.metadata import SummaryMetaV1 from ._main_window import MicroManagerGUI - from ._widgets._mda_widget import MDAWidget - from ._widgets._mm_console import MMConsole + from .widgets._mda_widget import MDAWidget + from .widgets._mm_console import MMConsole DIALOG = Qt.WindowType.Dialog VIEWER_TEMP_DIR = None diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 8a7d26c5..00393055 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -8,12 +8,12 @@ from pymmcore_plus import CMMCorePlus from qtpy.QtWidgets import QGridLayout, QMainWindow, QWidget -from pymmcore_gui.readers import TensorstoreZarrReader +from pymmcore_gui.io import TensorstoreZarrReader from ._core_link import CoreViewersLink -from ._widgets._menubar import MenuBar -from ._widgets._shutters_toolbar import ShuttersToolbar -from ._widgets._snap_live import SnapLive +from .widgets._menubar import MenuBar +from .widgets._shutters_toolbar import ShuttersToolbar +from .widgets._snap_live import SnapLive if TYPE_CHECKING: from qtpy.QtGui import QCloseEvent, QDragEnterEvent, QDropEvent diff --git a/src/pymmcore_gui/_writers/__init__.py b/src/pymmcore_gui/_writers/__init__.py deleted file mode 100644 index 24d3d8d1..00000000 --- a/src/pymmcore_gui/_writers/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Writer classes for different file formats.""" - -from ._tensorstore_zarr import _TensorStoreHandler - -__all__ = ["_TensorStoreHandler"] diff --git a/src/pymmcore_gui/io/__init__.py b/src/pymmcore_gui/io/__init__.py new file mode 100644 index 00000000..5bde985b --- /dev/null +++ b/src/pymmcore_gui/io/__init__.py @@ -0,0 +1,4 @@ +from ._writers._tensorstore_zarr import TensorStoreHandler +from .readers import OMEZarrReader, TensorstoreZarrReader + +__all__ = ["OMEZarrReader", "TensorStoreHandler", "TensorstoreZarrReader"] diff --git a/src/pymmcore_gui/io/_writers/__init__.py b/src/pymmcore_gui/io/_writers/__init__.py new file mode 100644 index 00000000..f79c7bd7 --- /dev/null +++ b/src/pymmcore_gui/io/_writers/__init__.py @@ -0,0 +1,5 @@ +"""Writer classes for different file formats.""" + +from ._tensorstore_zarr import TensorStoreHandler + +__all__ = ["TensorStoreHandler"] diff --git a/src/pymmcore_gui/_writers/_tensorstore_zarr.py b/src/pymmcore_gui/io/_writers/_tensorstore_zarr.py similarity index 95% rename from src/pymmcore_gui/_writers/_tensorstore_zarr.py rename to src/pymmcore_gui/io/_writers/_tensorstore_zarr.py index d919e496..2ad6431b 100644 --- a/src/pymmcore_gui/_writers/_tensorstore_zarr.py +++ b/src/pymmcore_gui/io/_writers/_tensorstore_zarr.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Literal, TypeAlias from pymmcore_plus._logger import logger -from pymmcore_plus.mda.handlers import TensorStoreHandler +from pymmcore_plus.mda import handlers if TYPE_CHECKING: from collections.abc import Mapping @@ -16,7 +16,7 @@ WAIT_TIME = 10 # seconds -class _TensorStoreHandler(TensorStoreHandler): +class TensorStoreHandler(handlers.TensorStoreHandler): def __init__( self, *, diff --git a/src/pymmcore_gui/readers/__init__.py b/src/pymmcore_gui/io/readers/__init__.py similarity index 100% rename from src/pymmcore_gui/readers/__init__.py rename to src/pymmcore_gui/io/readers/__init__.py diff --git a/src/pymmcore_gui/readers/_ome_zarr_reader.py b/src/pymmcore_gui/io/readers/_ome_zarr_reader.py similarity index 100% rename from src/pymmcore_gui/readers/_ome_zarr_reader.py rename to src/pymmcore_gui/io/readers/_ome_zarr_reader.py diff --git a/src/pymmcore_gui/readers/_tensorstore_zarr_reader.py b/src/pymmcore_gui/io/readers/_tensorstore_zarr_reader.py similarity index 100% rename from src/pymmcore_gui/readers/_tensorstore_zarr_reader.py rename to src/pymmcore_gui/io/readers/_tensorstore_zarr_reader.py diff --git a/src/pymmcore_gui/widgets/__init__.py b/src/pymmcore_gui/widgets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/pymmcore_gui/_widgets/_install_widget.py b/src/pymmcore_gui/widgets/_install_widget.py similarity index 100% rename from src/pymmcore_gui/_widgets/_install_widget.py rename to src/pymmcore_gui/widgets/_install_widget.py diff --git a/src/pymmcore_gui/_widgets/_mda_widget/__init__.py b/src/pymmcore_gui/widgets/_mda_widget/__init__.py similarity index 100% rename from src/pymmcore_gui/_widgets/_mda_widget/__init__.py rename to src/pymmcore_gui/widgets/_mda_widget/__init__.py diff --git a/src/pymmcore_gui/_widgets/_mda_widget/_mda_widget.py b/src/pymmcore_gui/widgets/_mda_widget/_mda_widget.py similarity index 97% rename from src/pymmcore_gui/_widgets/_mda_widget/_mda_widget.py rename to src/pymmcore_gui/widgets/_mda_widget/_mda_widget.py index 3812606f..0cfee2f9 100644 --- a/src/pymmcore_gui/_widgets/_mda_widget/_mda_widget.py +++ b/src/pymmcore_gui/widgets/_mda_widget/_mda_widget.py @@ -14,7 +14,7 @@ from qtpy.QtWidgets import QBoxLayout, QWidget from useq import MDASequence -from pymmcore_gui._writers._tensorstore_zarr import _TensorStoreHandler +from pymmcore_gui.io import TensorStoreHandler from ._save_widget import OME_TIFF, OME_ZARR, WRITERS, ZARR_TESNSORSTORE, SaveGroupBox @@ -208,9 +208,9 @@ def _create_writer( # able to handle it. return None - def _create_zarr_tensorstore(self, save_path: Path) -> _TensorStoreHandler: + def _create_zarr_tensorstore(self, save_path: Path) -> TensorStoreHandler: """Create a Zarr TensorStore writer.""" - return _TensorStoreHandler( + return TensorStoreHandler( driver="zarr", path=save_path, delete_existing=True, diff --git a/src/pymmcore_gui/_widgets/_mda_widget/_save_widget.py b/src/pymmcore_gui/widgets/_mda_widget/_save_widget.py similarity index 100% rename from src/pymmcore_gui/_widgets/_mda_widget/_save_widget.py rename to src/pymmcore_gui/widgets/_mda_widget/_save_widget.py diff --git a/src/pymmcore_gui/_widgets/_menubar.py b/src/pymmcore_gui/widgets/_menubar.py similarity index 97% rename from src/pymmcore_gui/_widgets/_menubar.py rename to src/pymmcore_gui/widgets/_menubar.py index 6bd351d5..4612af44 100644 --- a/src/pymmcore_gui/_widgets/_menubar.py +++ b/src/pymmcore_gui/widgets/_menubar.py @@ -23,10 +23,10 @@ QWidget, ) -from pymmcore_gui._widgets._install_widget import _InstallWidget -from pymmcore_gui._widgets._mda_widget import MDAWidget -from pymmcore_gui._widgets._mm_console import MMConsole -from pymmcore_gui._widgets._stage_control import StagesControlWidget +from pymmcore_gui.widgets._install_widget import _InstallWidget +from pymmcore_gui.widgets._mda_widget import MDAWidget +from pymmcore_gui.widgets._mm_console import MMConsole +from pymmcore_gui.widgets._stage_control import StagesControlWidget if TYPE_CHECKING: from pymmcore_gui._main_window import MicroManagerGUI diff --git a/src/pymmcore_gui/_widgets/_mm_console.py b/src/pymmcore_gui/widgets/_mm_console.py similarity index 100% rename from src/pymmcore_gui/_widgets/_mm_console.py rename to src/pymmcore_gui/widgets/_mm_console.py diff --git a/src/pymmcore_gui/_widgets/_shutters_toolbar.py b/src/pymmcore_gui/widgets/_shutters_toolbar.py similarity index 100% rename from src/pymmcore_gui/_widgets/_shutters_toolbar.py rename to src/pymmcore_gui/widgets/_shutters_toolbar.py diff --git a/src/pymmcore_gui/_widgets/_snap_live.py b/src/pymmcore_gui/widgets/_snap_live.py similarity index 95% rename from src/pymmcore_gui/_widgets/_snap_live.py rename to src/pymmcore_gui/widgets/_snap_live.py index 266f905a..46bb0b19 100644 --- a/src/pymmcore_gui/_widgets/_snap_live.py +++ b/src/pymmcore_gui/widgets/_snap_live.py @@ -5,7 +5,7 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import QHBoxLayout, QLabel, QToolBar, QWidget -from pymmcore_gui._widgets._snap_live_buttons import Live, Snap +from pymmcore_gui.widgets._snap_live_buttons import Live, Snap class SnapLive(QToolBar): diff --git a/src/pymmcore_gui/_widgets/_snap_live_buttons.py b/src/pymmcore_gui/widgets/_snap_live_buttons.py similarity index 100% rename from src/pymmcore_gui/_widgets/_snap_live_buttons.py rename to src/pymmcore_gui/widgets/_snap_live_buttons.py diff --git a/src/pymmcore_gui/_widgets/_stage_control.py b/src/pymmcore_gui/widgets/_stage_control.py similarity index 100% rename from src/pymmcore_gui/_widgets/_stage_control.py rename to src/pymmcore_gui/widgets/_stage_control.py diff --git a/src/pymmcore_gui/_widgets/_viewers/__init__.py b/src/pymmcore_gui/widgets/_viewers/__init__.py similarity index 100% rename from src/pymmcore_gui/_widgets/_viewers/__init__.py rename to src/pymmcore_gui/widgets/_viewers/__init__.py diff --git a/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/__init__.py b/src/pymmcore_gui/widgets/_viewers/_mda_viewer/__init__.py similarity index 100% rename from src/pymmcore_gui/_widgets/_viewers/_mda_viewer/__init__.py rename to src/pymmcore_gui/widgets/_viewers/_mda_viewer/__init__.py diff --git a/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py b/src/pymmcore_gui/widgets/_viewers/_mda_viewer/_data_wrappers.py similarity index 96% rename from src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py rename to src/pymmcore_gui/widgets/_viewers/_mda_viewer/_data_wrappers.py index c607aeab..ba15439c 100644 --- a/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_data_wrappers.py +++ b/src/pymmcore_gui/widgets/_viewers/_mda_viewer/_data_wrappers.py @@ -6,7 +6,7 @@ from ndv import DataWrapper from pymmcore_plus.mda.handlers import OMEZarrWriter, TensorStoreHandler -from pymmcore_gui.readers import OMEZarrReader, TensorstoreZarrReader +from pymmcore_gui.io import OMEZarrReader, TensorstoreZarrReader if TYPE_CHECKING: from collections.abc import Hashable, Mapping @@ -40,7 +40,7 @@ def isel(self, indexers: Mapping[str, int]) -> Any: def save_as_zarr(self, save_loc: str | Path) -> None: # to have access to the metadata, the generated zarr file should be opened with - # the micromanager_gui.readers.TensorstoreZarrReader + # the micromanager_gui.io.TensorstoreZarrReader # TODO: find a way to save as ome-zarr diff --git a/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py b/src/pymmcore_gui/widgets/_viewers/_mda_viewer/_mda_save_button.py similarity index 100% rename from src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_save_button.py rename to src/pymmcore_gui/widgets/_viewers/_mda_viewer/_mda_save_button.py diff --git a/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py b/src/pymmcore_gui/widgets/_viewers/_mda_viewer/_mda_viewer.py similarity index 97% rename from src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py rename to src/pymmcore_gui/widgets/_viewers/_mda_viewer/_mda_viewer.py index 51c11d90..aae65efd 100644 --- a/src/pymmcore_gui/_widgets/_viewers/_mda_viewer/_mda_viewer.py +++ b/src/pymmcore_gui/widgets/_viewers/_mda_viewer/_mda_viewer.py @@ -8,7 +8,7 @@ from superqt import ensure_main_thread from useq import MDAEvent -from pymmcore_gui.readers import OMEZarrReader, TensorstoreZarrReader +from pymmcore_gui.io import OMEZarrReader, TensorstoreZarrReader from ._data_wrappers import MM5DWriterWrapper, MMTensorstoreWrapper from ._mda_save_button import MDASaveButton diff --git a/src/pymmcore_gui/_widgets/_viewers/_preview_viewer/__init__.py b/src/pymmcore_gui/widgets/_viewers/_preview_viewer/__init__.py similarity index 100% rename from src/pymmcore_gui/_widgets/_viewers/_preview_viewer/__init__.py rename to src/pymmcore_gui/widgets/_viewers/_preview_viewer/__init__.py diff --git a/src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_save_button.py b/src/pymmcore_gui/widgets/_viewers/_preview_viewer/_preview_save_button.py similarity index 97% rename from src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_save_button.py rename to src/pymmcore_gui/widgets/_viewers/_preview_viewer/_preview_save_button.py index 6e4236b3..c9fe07ee 100644 --- a/src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_save_button.py +++ b/src/pymmcore_gui/widgets/_viewers/_preview_viewer/_preview_save_button.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from qtpy.QtWidgets import QWidget - from pymmcore_gui._widgets._viewers import Preview + from pymmcore_gui.widgets._viewers import Preview class SaveButton(QPushButton): diff --git a/src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py b/src/pymmcore_gui/widgets/_viewers/_preview_viewer/_preview_viewer.py similarity index 99% rename from src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py rename to src/pymmcore_gui/widgets/_viewers/_preview_viewer/_preview_viewer.py index 5d15e092..7f11a3fa 100644 --- a/src/pymmcore_gui/_widgets/_viewers/_preview_viewer/_preview_viewer.py +++ b/src/pymmcore_gui/widgets/_viewers/_preview_viewer/_preview_viewer.py @@ -9,7 +9,7 @@ from qtpy import QtCore from superqt.utils import ensure_main_thread -from pymmcore_gui._widgets._snap_live_buttons import Live, Snap +from pymmcore_gui.widgets._snap_live_buttons import Live, Snap from ._preview_save_button import SaveButton diff --git a/tests/test_gui.py b/tests/test_gui.py index 6be29947..31111704 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -5,8 +5,8 @@ import pytest from pymmcore_gui import MicroManagerGUI -from pymmcore_gui._widgets._menubar import DOCKWIDGETS, WIDGETS -from pymmcore_gui._widgets._viewers import MDAViewer +from pymmcore_gui.widgets._menubar import DOCKWIDGETS, WIDGETS +from pymmcore_gui.widgets._viewers import MDAViewer if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus diff --git a/tests/test_mda_viewer.py b/tests/test_mda_viewer.py index 55802ea5..c6a277e4 100644 --- a/tests/test_mda_viewer.py +++ b/tests/test_mda_viewer.py @@ -10,14 +10,14 @@ from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY from pymmcore_gui import MicroManagerGUI -from pymmcore_gui._widgets._mda_widget import MDAWidget -from pymmcore_gui._widgets._mda_widget._save_widget import ( +from pymmcore_gui.widgets._mda_widget import MDAWidget +from pymmcore_gui.widgets._mda_widget._save_widget import ( OME_TIFF, OME_ZARR, TIFF_SEQ, ZARR_TESNSORSTORE, ) -from pymmcore_gui._widgets._viewers import MDAViewer +from pymmcore_gui.widgets._viewers import MDAViewer if TYPE_CHECKING: from pathlib import Path diff --git a/tests/test_readers_writers.py b/tests/test_readers_writers.py index 2052d254..4e08eb00 100644 --- a/tests/test_readers_writers.py +++ b/tests/test_readers_writers.py @@ -8,9 +8,7 @@ import useq from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY -from pymmcore_gui._writers._tensorstore_zarr import _TensorStoreHandler -from pymmcore_gui.readers._ome_zarr_reader import OMEZarrReader -from pymmcore_gui.readers._tensorstore_zarr_reader import TensorstoreZarrReader +from pymmcore_gui.io import OMEZarrReader, TensorStoreHandler, TensorstoreZarrReader if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus @@ -42,12 +40,12 @@ writers = [ (ZARR_META, "z.ome.zarr", "", OMEZarrReader), - (TENSOR_META, "ts.tensorstore.zarr", _TensorStoreHandler, TensorstoreZarrReader), + (TENSOR_META, "ts.tensorstore.zarr", TensorStoreHandler, TensorstoreZarrReader), ] # fmt: on -# NOTE: the tensorstore reader works only if we use the internal _TensorStoreHandler +# NOTE: the tensorstore reader works only if we use the internal TensorStoreHandler # TODO: fix the main TensorStoreHandler because it does not write the ".zattrs" @pytest.mark.parametrize("writers", writers) @pytest.mark.parametrize("kwargs", [True, False]) diff --git a/tests/test_save_widget.py b/tests/test_save_widget.py index 22bd9417..cabd8324 100644 --- a/tests/test_save_widget.py +++ b/tests/test_save_widget.py @@ -5,7 +5,7 @@ import pytest -from pymmcore_gui._widgets._mda_widget._save_widget import ( +from pymmcore_gui.widgets._mda_widget._save_widget import ( DIRECTORY_WRITERS, FILE_NAME, OME_TIFF, diff --git a/tests/test_stage_widget.py b/tests/test_stage_widget.py index dac5aaa2..00584033 100644 --- a/tests/test_stage_widget.py +++ b/tests/test_stage_widget.py @@ -4,7 +4,7 @@ import pytest -from pymmcore_gui._widgets._stage_control import StagesControlWidget +from pymmcore_gui.widgets._stage_control import StagesControlWidget if TYPE_CHECKING: from pymmcore_plus import CMMCorePlus From 27c63aab2a809827cfdab655e1976848cc4082bb Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 17 Dec 2024 10:41:01 -0500 Subject: [PATCH 183/226] update main --- src/pymmcore_gui/_main_window.py | 36 +++++++++++++++----------------- uv.lock | 2 +- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 00393055..073c768a 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -6,7 +6,7 @@ from ndv import NDViewer from pymmcore_plus import CMMCorePlus -from qtpy.QtWidgets import QGridLayout, QMainWindow, QWidget +from qtpy.QtWidgets import QApplication, QGridLayout, QMainWindow, QWidget from pymmcore_gui.io import TensorstoreZarrReader @@ -23,15 +23,10 @@ class MicroManagerGUI(QMainWindow): """Micro-Manager minimal GUI.""" def __init__( - self, - parent: QWidget | None = None, - *, - mmcore: CMMCorePlus | None = None, - config: str | None = None, + self, *, mmcore: CMMCorePlus | None = None, config: str | None = None ) -> None: - super().__init__(parent) + super().__init__() self.setAcceptDrops(True) - self.setWindowTitle("Micro-Manager") # get global CMMCorePlus instance @@ -66,15 +61,19 @@ def __init__( # don't crash if the user passed an invalid config warn(f"Config file {config} not found. Nothing loaded.", stacklevel=2) - def dragEnterEvent(self, event: QDragEnterEvent) -> None: - if event.mimeData().hasUrls(): + def dragEnterEvent(self, event: QDragEnterEvent | None) -> None: + if event and (data := event.mimeData()) and data.hasUrls(): event.acceptProposedAction() else: super().dragEnterEvent(event) - def dropEvent(self, event: QDropEvent) -> None: + def dropEvent(self, event: QDropEvent | None) -> None: """Open a tensorstore from a directory dropped in the window.""" - for idx, url in enumerate(event.mimeData().urls()): + if not event or not (data := event.mimeData()) or not data.hasUrls(): + super().dropEvent(event) + return + + for idx, url in enumerate(data.urls()): path = Path(url.toLocalFile()) sw = self._open_datastore(idx, path) @@ -105,14 +104,13 @@ def _open_datastore(self, idx: int, path: Path) -> NDViewer | None: warn(f"Not yet supported format: {path.name}!", stacklevel=2) return None - def closeEvent(self, event: QCloseEvent) -> None: + def closeEvent(self, event: QCloseEvent | None) -> None: """Close all widgets before closing.""" + if not event: + return self.deleteLater() # delete any remaining widgets - from qtpy.QtWidgets import QApplication - - if qapp := QApplication.instance(): - if remaining := qapp.topLevelWidgets(): - for w in remaining: - w.deleteLater() + if remaining := QApplication.topLevelWidgets(): + for w in remaining: + w.deleteLater() super().closeEvent(event) diff --git a/uv.lock b/uv.lock index ce703957..3a5319b8 100644 --- a/uv.lock +++ b/uv.lock @@ -1176,7 +1176,7 @@ wheels = [ [[package]] name = "pymmcore-gui" -version = "0.1.dev186+gbc1d877.d20241217" +version = "0.1.dev188+g7d12467.d20241217" source = { editable = "." } dependencies = [ { name = "ndv", extra = ["vispy"] }, From 722311ddda0c582613ffd26ab31570e3aa02b961 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 18 Dec 2024 08:08:19 -0500 Subject: [PATCH 184/226] rip it out --- pyproject.toml | 2 +- src/pymmcore_gui/__init__.py | 6 +- src/pymmcore_gui/__main__.py | 3 +- src/pymmcore_gui/_main_window.py | 100 +----- src/pymmcore_gui/io/__init__.py | 4 - src/pymmcore_gui/io/_writers/__init__.py | 5 - .../io/_writers/_tensorstore_zarr.py | 66 ---- src/pymmcore_gui/io/readers/__init__.py | 6 - .../io/readers/_ome_zarr_reader.py | 234 ------------- .../io/readers/_tensorstore_zarr_reader.py | 228 ------------- src/pymmcore_gui/widgets/__init__.py | 0 src/pymmcore_gui/widgets/_install_widget.py | 10 - .../widgets/_mda_widget/__init__.py | 5 - .../widgets/_mda_widget/_mda_widget.py | 218 ------------ .../widgets/_mda_widget/_save_widget.py | 229 ------------- src/pymmcore_gui/widgets/_menubar.py | 316 ------------------ src/pymmcore_gui/widgets/_mm_console.py | 52 --- src/pymmcore_gui/widgets/_shutters_toolbar.py | 66 ---- src/pymmcore_gui/widgets/_snap_live.py | 45 --- .../widgets/_snap_live_buttons.py | 45 --- src/pymmcore_gui/widgets/_stage_control.py | 98 ------ src/pymmcore_gui/widgets/_viewers/__init__.py | 4 - .../widgets/_viewers/_mda_viewer/__init__.py | 3 - .../_viewers/_mda_viewer/_data_wrappers.py | 115 ------- .../_viewers/_mda_viewer/_mda_save_button.py | 42 --- .../_viewers/_mda_viewer/_mda_viewer.py | 81 ----- .../_viewers/_preview_viewer/__init__.py | 3 - .../_preview_viewer/_preview_save_button.py | 70 ---- .../_preview_viewer/_preview_viewer.py | 188 ----------- tests/test_gui.py | 65 ---- tests/test_mda_viewer.py | 117 ------- tests/test_readers_writers.py | 125 ------- tests/test_save_widget.py | 108 ------ tests/test_stage_widget.py | 23 -- 34 files changed, 5 insertions(+), 2677 deletions(-) delete mode 100644 src/pymmcore_gui/io/__init__.py delete mode 100644 src/pymmcore_gui/io/_writers/__init__.py delete mode 100644 src/pymmcore_gui/io/_writers/_tensorstore_zarr.py delete mode 100644 src/pymmcore_gui/io/readers/__init__.py delete mode 100644 src/pymmcore_gui/io/readers/_ome_zarr_reader.py delete mode 100644 src/pymmcore_gui/io/readers/_tensorstore_zarr_reader.py delete mode 100644 src/pymmcore_gui/widgets/__init__.py delete mode 100644 src/pymmcore_gui/widgets/_install_widget.py delete mode 100644 src/pymmcore_gui/widgets/_mda_widget/__init__.py delete mode 100644 src/pymmcore_gui/widgets/_mda_widget/_mda_widget.py delete mode 100644 src/pymmcore_gui/widgets/_mda_widget/_save_widget.py delete mode 100644 src/pymmcore_gui/widgets/_menubar.py delete mode 100644 src/pymmcore_gui/widgets/_mm_console.py delete mode 100644 src/pymmcore_gui/widgets/_shutters_toolbar.py delete mode 100644 src/pymmcore_gui/widgets/_snap_live.py delete mode 100644 src/pymmcore_gui/widgets/_snap_live_buttons.py delete mode 100644 src/pymmcore_gui/widgets/_stage_control.py delete mode 100644 src/pymmcore_gui/widgets/_viewers/__init__.py delete mode 100644 src/pymmcore_gui/widgets/_viewers/_mda_viewer/__init__.py delete mode 100644 src/pymmcore_gui/widgets/_viewers/_mda_viewer/_data_wrappers.py delete mode 100644 src/pymmcore_gui/widgets/_viewers/_mda_viewer/_mda_save_button.py delete mode 100644 src/pymmcore_gui/widgets/_viewers/_mda_viewer/_mda_viewer.py delete mode 100644 src/pymmcore_gui/widgets/_viewers/_preview_viewer/__init__.py delete mode 100644 src/pymmcore_gui/widgets/_viewers/_preview_viewer/_preview_save_button.py delete mode 100644 src/pymmcore_gui/widgets/_viewers/_preview_viewer/_preview_viewer.py delete mode 100644 tests/test_gui.py delete mode 100644 tests/test_mda_viewer.py delete mode 100644 tests/test_readers_writers.py delete mode 100644 tests/test_save_widget.py delete mode 100644 tests/test_stage_widget.py diff --git a/pyproject.toml b/pyproject.toml index 18785751..4b99345f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,7 +64,7 @@ dev = [ # same as console_scripts entry point [project.scripts] -mmgui = "pymmcore_gui.__main__:main" +mmgui = "pymmcore_gui._app:main" [project.urls] homepage = "https://github.com/tlambert03/pymmcore-gui" diff --git a/src/pymmcore_gui/__init__.py b/src/pymmcore_gui/__init__.py index 2323c913..fc627308 100644 --- a/src/pymmcore_gui/__init__.py +++ b/src/pymmcore_gui/__init__.py @@ -3,14 +3,10 @@ from importlib.metadata import PackageNotFoundError, version try: - __version__ = version("micromanager-gui") + __version__ = version("pymmcore-gui") except PackageNotFoundError: __version__ = "uninstalled" -__author__ = "Federico Gasparoli" -__email__ = "federico.gasparoli@gmail.com" - - from ._main_window import MicroManagerGUI __all__ = ["MicroManagerGUI"] diff --git a/src/pymmcore_gui/__main__.py b/src/pymmcore_gui/__main__.py index 7748c294..1cb1bbdc 100644 --- a/src/pymmcore_gui/__main__.py +++ b/src/pymmcore_gui/__main__.py @@ -1,3 +1,4 @@ from ._app import main -main() +if __name__ == "__main__": + main() diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 073c768a..6bc9e8a0 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -1,22 +1,7 @@ from __future__ import annotations -from pathlib import Path -from typing import TYPE_CHECKING -from warnings import warn - -from ndv import NDViewer from pymmcore_plus import CMMCorePlus -from qtpy.QtWidgets import QApplication, QGridLayout, QMainWindow, QWidget - -from pymmcore_gui.io import TensorstoreZarrReader - -from ._core_link import CoreViewersLink -from .widgets._menubar import MenuBar -from .widgets._shutters_toolbar import ShuttersToolbar -from .widgets._snap_live import SnapLive - -if TYPE_CHECKING: - from qtpy.QtGui import QCloseEvent, QDragEnterEvent, QDropEvent +from qtpy.QtWidgets import QMainWindow class MicroManagerGUI(QMainWindow): @@ -31,86 +16,3 @@ def __init__( # get global CMMCorePlus instance self._mmc = mmcore or CMMCorePlus.instance() - - # central widget - central_wdg = QWidget(self) - self._central_wdg_layout = QGridLayout(central_wdg) - self.setCentralWidget(central_wdg) - - # add the menu bar (and the logic to create/show widgets) - self._menu_bar = MenuBar(parent=self, mmcore=self._mmc) - self.setMenuBar(self._menu_bar) - - # add toolbar - self._snap_live_toolbar = SnapLive(parent=self, mmcore=self._mmc) - self.addToolBar(self._snap_live_toolbar) - self._shutters_toolbar = ShuttersToolbar(parent=self, mmcore=self._mmc) - self.addToolBar(self._shutters_toolbar) - - # link the MDA viewers - self._core_link = CoreViewersLink(self, mmcore=self._mmc) - - # extend size to fill the screen - self.showMaximized() - - if config is not None: - try: - self._mmc.unloadAllDevices() - self._mmc.loadSystemConfiguration(config) - except FileNotFoundError: - # don't crash if the user passed an invalid config - warn(f"Config file {config} not found. Nothing loaded.", stacklevel=2) - - def dragEnterEvent(self, event: QDragEnterEvent | None) -> None: - if event and (data := event.mimeData()) and data.hasUrls(): - event.acceptProposedAction() - else: - super().dragEnterEvent(event) - - def dropEvent(self, event: QDropEvent | None) -> None: - """Open a tensorstore from a directory dropped in the window.""" - if not event or not (data := event.mimeData()) or not data.hasUrls(): - super().dropEvent(event) - return - - for idx, url in enumerate(data.urls()): - path = Path(url.toLocalFile()) - - sw = self._open_datastore(idx, path) - - if sw is not None: - self._core_link._viewer_tab.addTab(sw, f"datastore_{idx}") - self._core_link._viewer_tab.setCurrentWidget(sw) - - super().dropEvent(event) - - def _open_datastore(self, idx: int, path: Path) -> NDViewer | None: - if path.name.endswith(".tensorstore.zarr"): - try: - reader = TensorstoreZarrReader(path) - return NDViewer(reader.store, parent=self) - except Exception as e: - warn(f"Error opening tensorstore-zarr: {e}!", stacklevel=2) - return None - # TODO: implement with OMEZarrReader - # elif path.name.endswith(".ome.zarr"): - # try: - # reader = OMEZarrReader(path) - # return NDViewer(reader.store, parent=self) - # except Exception as e: - # warn(f"Error opening OME-zarr: {e}!", stacklevel=2) - # return None - else: - warn(f"Not yet supported format: {path.name}!", stacklevel=2) - return None - - def closeEvent(self, event: QCloseEvent | None) -> None: - """Close all widgets before closing.""" - if not event: - return - self.deleteLater() - # delete any remaining widgets - if remaining := QApplication.topLevelWidgets(): - for w in remaining: - w.deleteLater() - super().closeEvent(event) diff --git a/src/pymmcore_gui/io/__init__.py b/src/pymmcore_gui/io/__init__.py deleted file mode 100644 index 5bde985b..00000000 --- a/src/pymmcore_gui/io/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from ._writers._tensorstore_zarr import TensorStoreHandler -from .readers import OMEZarrReader, TensorstoreZarrReader - -__all__ = ["OMEZarrReader", "TensorStoreHandler", "TensorstoreZarrReader"] diff --git a/src/pymmcore_gui/io/_writers/__init__.py b/src/pymmcore_gui/io/_writers/__init__.py deleted file mode 100644 index f79c7bd7..00000000 --- a/src/pymmcore_gui/io/_writers/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Writer classes for different file formats.""" - -from ._tensorstore_zarr import TensorStoreHandler - -__all__ = ["TensorStoreHandler"] diff --git a/src/pymmcore_gui/io/_writers/_tensorstore_zarr.py b/src/pymmcore_gui/io/_writers/_tensorstore_zarr.py deleted file mode 100644 index 2ad6431b..00000000 --- a/src/pymmcore_gui/io/_writers/_tensorstore_zarr.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -import time -from typing import TYPE_CHECKING, Literal, TypeAlias - -from pymmcore_plus._logger import logger -from pymmcore_plus.mda import handlers - -if TYPE_CHECKING: - from collections.abc import Mapping - from os import PathLike -from pymmcore_plus.metadata.serialize import json_dumps, json_loads - -TsDriver: TypeAlias = Literal["zarr", "zarr3", "n5", "neuroglancer_precomputed"] - -WAIT_TIME = 10 # seconds - - -class TensorStoreHandler(handlers.TensorStoreHandler): - def __init__( - self, - *, - driver: TsDriver = "zarr", - kvstore: str | dict | None = "memory://", - path: str | PathLike | None = None, - delete_existing: bool = False, - spec: Mapping | None = None, - ) -> None: - super().__init__( - driver=driver, - kvstore=kvstore, - path=path, - delete_existing=delete_existing, - spec=spec, - ) - - # override this method to make sure the ".zattrs" file is written - def finalize_metadata(self) -> None: - """Finalize and flush metadata to storage.""" - if not (store := self._store) or not store.kvstore: - return # pragma: no cover - - metadata = {"frame_metadatas": [m[1] for m in self.frame_metadatas]} - if not self._nd_storage: - metadata["frame_indices"] = [ - (tuple(dict(k).items()), v) # type: ignore - for k, v in self._frame_indices.items() - ] - - if self.ts_driver.startswith("zarr"): - store.kvstore.write(".zattrs", json_dumps(metadata).decode("utf-8")) - attrs = store.kvstore.read(".zattrs").result().value - logger.info("Writing 'tensorstore_zarr' store 'zattrs' to disk.") - start_time = time.time() - # HACK: wait for attrs to be written. If we don't have the while loop, - # most of the time the attrs will not be written. To avoid looping forever, - # we wait for WAIT_TIME seconds. If the attrs are not written by then, - # we continue. - while not attrs and not time.time() - start_time > WAIT_TIME: - store.kvstore.write(".zattrs", json_dumps(metadata).decode("utf-8")) - attrs = store.kvstore.read(".zattrs").result().value - - elif self.ts_driver == "n5": # pragma: no cover - attrs = json_loads(store.kvstore.read("attributes.json").result().value) - attrs.update(metadata) - store.kvstore.write("attributes.json", json_dumps(attrs).decode("utf-8")) diff --git a/src/pymmcore_gui/io/readers/__init__.py b/src/pymmcore_gui/io/readers/__init__.py deleted file mode 100644 index 4b06d3e5..00000000 --- a/src/pymmcore_gui/io/readers/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Readers for different file formats.""" - -from ._ome_zarr_reader import OMEZarrReader -from ._tensorstore_zarr_reader import TensorstoreZarrReader - -__all__ = ["OMEZarrReader", "TensorstoreZarrReader"] diff --git a/src/pymmcore_gui/io/readers/_ome_zarr_reader.py b/src/pymmcore_gui/io/readers/_ome_zarr_reader.py deleted file mode 100644 index 9c3d1745..00000000 --- a/src/pymmcore_gui/io/readers/_ome_zarr_reader.py +++ /dev/null @@ -1,234 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path -from typing import TYPE_CHECKING, Any, cast - -import numpy as np -import useq -import zarr -from tifffile import imwrite -from tqdm import tqdm -from zarr.hierarchy import Group - -if TYPE_CHECKING: - from collections.abc import Mapping - -EVENT = "Event" -FRAME_META = "frame_meta" -ARRAY_DIMS = "_ARRAY_DIMENSIONS" - - -class OMEZarrReader: - """Reads a ome-zarr file generated with the 'OMEZarrWriter'. - - Parameters - ---------- - data : str | Path | Group - The path to the ome-zarr file or the zarr group itself. - - Attributes - ---------- - path : Path - The path to the ome-zarr file. - store : zarr.Group - The zarr file. - sequence : useq.MDASequence | None - The acquired useq.MDASequence. It is loaded from the metadata using the - `useq.MDASequence` key. - - Methods - ------- - metadata() - Return the unstructured full metadata. - - Usage - ----- - reader = OMEZarrReader("path/to/file") - # to get the numpy array for a specific axis, for example, the first time point for - # the first position and the first z-slice: - data = reader.isel({"p": 0, "t": 1, "z": 0}) - # to also get the metadata for the given index: - data, metadata = reader.isel({"p": 0, "t": 1, "z": 0}, metadata=True) - """ - - def __init__(self, data: str | Path | Group) -> None: - self._path = data.path if isinstance(data, Group) else data - self._store: Group = data if isinstance(data, Group) else zarr.open(self._path) - - # the useq.MDASequence if it exists - self._sequence: useq.MDASequence | None = None - - # ___________________________Public Methods___________________________ - - @property - def path(self) -> Path: - """Return the path.""" - return Path(self._path) - - @property - def store(self) -> Group: - """Return the zarr file.""" - return self._store - - @property - def sequence(self) -> useq.MDASequence | None: - """Return the MDASequence if it exists.""" - try: - seq = cast(dict, self._store["p0"].attrs["useq_MDASequence"]) - self._sequence = useq.MDASequence(**seq) if seq is not None else None - except KeyError: - self._sequence = None - return self._sequence - - def metadata(self) -> list[dict]: - """Return the unstructured full metadata.""" - # concatenate the metadata for all the positions - return [ - meta - for key in self.store.keys() - if key.startswith("p") and key[1:].isdigit() - for meta in self.store[key].attrs.get(FRAME_META, []) - ] - - def isel( - self, - indexers: Mapping[str, int] | None = None, - metadata: bool = False, - **kwargs: Any, - ) -> np.ndarray | tuple[np.ndarray, list[dict]]: - """Select data from the array. - - Parameters - ---------- - indexers : Mapping[str, int] - The indexers to select the data. Thy should contain the 'p' axis since the - OMEZarrWriter saves each position as a separate array. If None, it - assume the first position {"p": 0}. - metadata : bool - If True, return the metadata as well as a list of dictionaries. By default, - False. - **kwargs : Any - Additional way to pass the indexers. You can pass the indexers as kwargs - (e.g. p=0, t=1). NOTE: kwargs will overwrite the indexers if already present - in the indexers mapping. - """ - if indexers is None: - indexers = {} - if kwargs: - if all( - isinstance(k, str) and isinstance(v, int) for k, v in kwargs.items() - ): - indexers = {**indexers, **kwargs} - else: - raise TypeError("kwargs must be a mapping from strings to integers") - - if len(self.store.keys()) > 1 and "p" not in indexers: - raise ValueError( - "The indexers should contain the 'p' axis since the zarr store has " - "more than one position." - ) - - pos_key = f"p{indexers.get('p', 0)}" - index = self._get_axis_index(indexers, pos_key) - data = cast(np.ndarray, self.store[pos_key][index].squeeze()) - if metadata: - meta = self._get_metadata_from_index(indexers, pos_key) - return data, meta - return data - - def write_tiff( - self, path: str | Path, indexers: Mapping[str, int] | None = None, **kwargs: Any - ) -> None: - """Write the data to a tiff file. - - Parameters - ---------- - path : str | Path - The path to the tiff file. If `indexers` is a Mapping of axis and index, - the path should be a file path (e.g. 'path/to/file.tif'). Otherwise, it - should be a directory path (e.g. 'path/to/directory'). - indexers : Mapping[str, int] | None - The indexers to select the data. If None, write all the data per position - to a tiff file. If a Mapping of axis and index (e.g. {"p": 0, "t": 1}), - write the data for the given index to a tiff file. - **kwargs : Any - Additional way to pass the indexers. You can pass the indexers as kwargs - (e.g. p=0, t=1). NOTE: kwargs will overwrite the indexers if already present - in the indexers mapping. - """ - if kwargs: - indexers = indexers or {} - if all( - isinstance(k, str) and isinstance(v, int) for k, v in kwargs.items() - ): - indexers = {**indexers, **kwargs} - else: - raise TypeError( - "kwargs must be a mapping from strings to integers (e.g. p=0, t=1)!" - ) - - if indexers: - data, metadata = self.isel(indexers, metadata=True) - imj = len(data.shape) <= 5 - if Path(path).suffix not in {".tif", ".tiff"}: - path = Path(path).with_suffix(".tiff") - imwrite(path, data, imagej=imj) - # save metadata as json - dest = Path(path).with_suffix(".json") - dest.write_text(json.dumps(metadata)) - - else: - keys = [ - key - for key in self.store.keys() - if key.startswith("p") and key[1:].isdigit() - ] - if pos := len(keys): - if not Path(path).exists(): - Path(path).mkdir(parents=True, exist_ok=False) - with tqdm(total=pos) as pbar: - for i in range(pos): - data, metadata = self.isel({"p": i}, metadata=True) - imwrite(Path(path) / f"p{i}.tif", data, imagej=True) - # save metadata as json - dest = Path(path) / f"p{i}.json" - dest.write_text(json.dumps(metadata)) - pbar.update(1) - - # ___________________________Private Methods___________________________ - - def _get_axis_index( - self, indexers: Mapping[str, int], pos_key: str - ) -> tuple[object, ...]: - """Return a tuple to index the data for the given axis.""" - axis_order = self.store[pos_key].attrs.get(ARRAY_DIMS, []) # ['t','c','y','x'] - # remove x and y from the axis order - if "x" in axis_order: - axis_order.remove("x") - if "y" in axis_order: - axis_order.remove("y") - - # if any of the indexers are not in the axis order, raise an error, NOTE: we - # add "p" to the axis order since the ome-zarr is saved per position - if not set(indexers.keys()).issubset({"p", *axis_order}): - raise ValueError( - f"Invalid axis in indexers {indexers}: available {axis_order}" - ) - - # get the correct index for the axis - # e.g. (slice(None), 1, slice(None), slice(None)) - return tuple( - indexers[axis] if axis in indexers else slice(None) for axis in axis_order - ) - - def _get_metadata_from_index( - self, indexers: Mapping[str, int], pos_key: str - ) -> list[dict]: - """Return the metadata for the given indexers.""" - metadata = [] - for meta in self.store[pos_key].attrs.get(FRAME_META, []): - event_index = meta["mda_event"]["index"] # e.g. {"p": 0, "t": 1} - if indexers.items() <= event_index.items(): - metadata.append(meta) - return metadata diff --git a/src/pymmcore_gui/io/readers/_tensorstore_zarr_reader.py b/src/pymmcore_gui/io/readers/_tensorstore_zarr_reader.py deleted file mode 100644 index 3ee53398..00000000 --- a/src/pymmcore_gui/io/readers/_tensorstore_zarr_reader.py +++ /dev/null @@ -1,228 +0,0 @@ -from __future__ import annotations - -import json -import warnings -from pathlib import Path -from typing import TYPE_CHECKING, Any, cast - -import numpy as np -import tensorstore as ts -import useq -from pymmcore_plus.metadata.serialize import json_loads -from tifffile import imwrite -from tqdm import tqdm - -if TYPE_CHECKING: - from collections.abc import Mapping - - -class TensorstoreZarrReader: - """Read a tensorstore zarr file generated with the 'TensorstoreZarrWriter'. - - Parameters - ---------- - data : str | Path | ts.Tensorstore - The path to the tensorstore zarr file or the tensorstore zarr file itself. - - Attributes - ---------- - path : Path - The path to the tensorstore zarr file. - store : ts.TensorStore - The tensorstore. - metadata : list[dict] - The unstructured full metadata. - sequence : useq.MDASequence - The acquired useq.MDASequence. It is loaded from the metadata using the - `useq.MDASequence` key. - - Usage - ----- - reader = TensorZarrReader("path/to/file") - # to get the numpy array for a specific axis, for example, the first time point for - # the first position and the first z-slice: - data = reader.isel({"p": 0, "t": 1, "z": 0}) - # to also get the metadata for the given index: - data, metadata = reader.isel({"p": 0, "t": 1, "z": 0}, metadata=True) - """ - - def __init__(self, data: str | Path | ts.TensorStore): - if isinstance(data, ts.TensorStore): - self._path = data.kvstore.path - _store = data - else: - self._path = data - spec = { - "driver": "zarr", - "kvstore": {"driver": "file", "path": str(self._path)}, - } - _store = ts.open(spec).result() - - self._metadata: list = [] - if metadata_json := _store.kvstore.read(".zattrs").result().value: - metadata_dict = json_loads(metadata_json) - self._metadata = metadata_dict.get("frame_metadatas", []) - - # set the axis labels - if self.sequence is not None: - # not sure if is x, y or y, x - axis_order = (*self.sequence.axis_order, "y", "x") - if len(axis_order) > 2: - try: - _store = _store[ts.d[:].label[axis_order]] - except IndexError as e: - warnings.warn( - f"Error setting the axis labels: {e}." - "`axis_order`: {axis_order}, `shape`: {_store.shape}.", - stacklevel=2, - ) - - self._store = _store - - @property - def path(self) -> Path: - """Return the path.""" - return Path(self._path) - - @property - def store(self) -> ts.TensorStore: - """Return the tensorstore.""" - return self._store - - @property - def metadata(self) -> list[dict]: - """Return the unstructured full metadata.""" - return self._metadata - - @property - def sequence(self) -> useq.MDASequence | None: - # getting the sequence from the first frame metadata within the "mda_event" key - seq = self._metadata[0].get("mda_event", {}).get("sequence") - return useq.MDASequence(**seq) if seq is not None else None - - # ___________________________Public Methods___________________________ - - def isel( - self, - indexers: Mapping[str, int] | None = None, - metadata: bool = False, - **kwargs: Any, - ) -> np.ndarray | tuple[np.ndarray, list[dict]]: - """Select data from the array. - - Parameters - ---------- - indexers : Mapping[str, int] | None - The indexers to select the data (e.g. {"p": 0, "t": 1}). If None, return - the entire data. - metadata : bool - If True, return the metadata as well as a list of dictionaries. By default, - False. - **kwargs : Any - Additional way to pass the indexers. You can pass the indexers as kwargs - (e.g. p=0, t=1). NOTE: kwargs will overwrite the indexers if already present - in the indexers mapping. - """ - if indexers is None: - indexers = {} - if kwargs: - if all( - isinstance(k, str) and isinstance(v, int) for k, v in kwargs.items() - ): - indexers = {**indexers, **kwargs} - else: - raise TypeError( - "kwargs must be a mapping from strings to integers (e.g. p=0, t=1)!" - ) - - index = self._get_axis_index(indexers) - data = cast(np.ndarray, self.store[index].read().result().squeeze()) - if metadata: - meta = self._get_metadata_from_index(indexers) - return data, meta - return data - - def write_tiff( - self, path: str | Path, indexers: Mapping[str, int] | None = None, **kwargs: Any - ) -> None: - """Write the data to a tiff file. - - Parameters - ---------- - path : str | Path - The path to the tiff file. If `indexers` is a Mapping of axis and index, - the path should be a file path (e.g. 'path/to/file.tif'). Otherwise, it - should be a directory path (e.g. 'path/to/directory'). - indexers : Mapping[str, int] | None - The indexers to select the data. If None, write all the data per position - to a tiff file. If a Mapping of axis and index (e.g. {"p": 0, "t": 1}), - write the data for the given index to a tiff file. - **kwargs : Any - Additional way to pass the indexers. You can pass the indexers as kwargs - (e.g. p=0, t=1). NOTE: kwargs will overwrite the indexers if already present - in the indexers mapping. - """ - # TODO: add support for ome-tiff - if kwargs: - indexers = indexers or {} - if all( - isinstance(k, str) and isinstance(v, int) for k, v in kwargs.items() - ): - indexers = {**indexers, **kwargs} - else: - raise TypeError("kwargs must be a mapping from strings to integers") - - if indexers: - data, metadata = self.isel(indexers, metadata=True) - imj = len(data.shape) <= 5 - if Path(path).suffix not in {".tif", ".tiff"}: - path = Path(path).with_suffix(".tiff") - if not Path(path).parent.exists(): - Path(path).parent.mkdir(parents=True, exist_ok=True) - imwrite(path, data, imagej=imj) - # save metadata as json - dest = Path(path).with_suffix(".json") - dest.write_text(json.dumps(metadata)) - - else: - if self.sequence is None: - raise ValueError("No 'useq.MDASequence' found in the metadata!") - if pos := len(self.sequence.stage_positions): - if not Path(path).exists(): - Path(path).mkdir(parents=True, exist_ok=False) - with tqdm(total=pos) as pbar: - for i in range(pos): - data, metadata = self.isel({"p": i}, metadata=True) - imwrite(Path(path) / f"p{i}.tif", data, imagej=True) - # save metadata as json - dest = Path(path) / f"p{i}.json" - dest.write_text(json.dumps(metadata)) - pbar.update(1) - - # ___________________________Private Methods___________________________ - - def _get_axis_index(self, indexers: Mapping[str, int]) -> tuple[object, ...]: - """Return a tuple to index the data for the given axis.""" - if self.sequence is None: - raise ValueError("No 'useq.MDASequence' found in the metadata!") - - axis_order = self.sequence.axis_order - - # if any of the indexers are not in the axis order, raise an error - if not set(indexers.keys()).issubset(set(axis_order)): - raise ValueError("Invalid axis in indexers!") - - # get the correct index for the axis - # e.g. (slice(None), 1, slice(None), slice(None)) - return tuple( - indexers[axis] if axis in indexers else slice(None) for axis in axis_order - ) - - def _get_metadata_from_index(self, indexers: Mapping[str, int]) -> list[dict]: - """Return the metadata for the given indexers.""" - metadata = [] - for meta in self._metadata: - event_index = meta["mda_event"]["index"] # e.g. {"p": 0, "t": 1} - if indexers.items() <= event_index.items(): - metadata.append(meta) - return metadata diff --git a/src/pymmcore_gui/widgets/__init__.py b/src/pymmcore_gui/widgets/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/pymmcore_gui/widgets/_install_widget.py b/src/pymmcore_gui/widgets/_install_widget.py deleted file mode 100644 index 15791489..00000000 --- a/src/pymmcore_gui/widgets/_install_widget.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import Any - -from pymmcore_widgets import InstallWidget -from qtpy.QtWidgets import QWidget - - -class _InstallWidget(InstallWidget): - def __init__(self, parent: QWidget | None = None, **kwargs: Any) -> None: - super().__init__(parent) - self.resize(800, 400) diff --git a/src/pymmcore_gui/widgets/_mda_widget/__init__.py b/src/pymmcore_gui/widgets/_mda_widget/__init__.py deleted file mode 100644 index 4cbeb69c..00000000 --- a/src/pymmcore_gui/widgets/_mda_widget/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""MDA widgets.""" - -from ._mda_widget import MDAWidget_ as MDAWidget - -__all__ = ["MDAWidget"] diff --git a/src/pymmcore_gui/widgets/_mda_widget/_mda_widget.py b/src/pymmcore_gui/widgets/_mda_widget/_mda_widget.py deleted file mode 100644 index 0cfee2f9..00000000 --- a/src/pymmcore_gui/widgets/_mda_widget/_mda_widget.py +++ /dev/null @@ -1,218 +0,0 @@ -import re -from pathlib import Path -from typing import cast - -from pymmcore_plus import CMMCorePlus -from pymmcore_plus.mda.handlers import ( - ImageSequenceWriter, - OMETiffWriter, - OMEZarrWriter, - TensorStoreHandler, -) -from pymmcore_widgets import MDAWidget -from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY -from qtpy.QtWidgets import QBoxLayout, QWidget -from useq import MDASequence - -from pymmcore_gui.io import TensorStoreHandler - -from ._save_widget import OME_TIFF, OME_ZARR, WRITERS, ZARR_TESNSORSTORE, SaveGroupBox - -NUM_SPLIT = re.compile(r"(.*?)(?:_(\d{3,}))?$") -OME_TIFFS = tuple(WRITERS[OME_TIFF]) -GB_CACHE = 2_000_000_000 # 2 GB for tensorstore cache - - -def get_next_available_path(requested_path: Path | str, min_digits: int = 3) -> Path: - """Get the next available paths (filepath or folderpath if extension = ""). - - This method adds a counter of min_digits to the filename or foldername to ensure - that the path is unique. - - Parameters - ---------- - requested_path : Path | str - A path to a file or folder that may or may not exist. - min_digits : int, optional - The min_digits number of digits to be used for the counter. By default, 3. - """ - if isinstance(requested_path, str): # pragma: no cover - requested_path = Path(requested_path) - directory = requested_path.parent - extension = requested_path.suffix - # ome files like .ome.tiff or .ome.zarr are special,treated as a single extension - if (stem := requested_path.stem).endswith(".ome"): - extension = f".ome{extension}" - stem = stem[:-4] - # NOTE: added in micromanager_gui --------------------------------------------- - elif (stem := requested_path.stem).endswith(".tensorstore"): - extension = f".tensorstore{extension}" - stem = stem[:-12] - # ----------------------------------------------------------------------------- - - # look for ANY existing files in the folder that follow the pattern of - # stem_###.extension - current_max = 0 - for existing in directory.glob(f"*{extension}"): - # cannot use existing.stem because of the ome (2-part-extension) special case - base = existing.name.replace(extension, "") - # if base name ends with a number and stem is the same, increase current_max - if ( - (match := NUM_SPLIT.match(base)) - and (num := match.group(2)) - # NOTE: added in micromanager_gui ------------------------------------- - # this breaks pymmcore_widgets test_get_next_available_paths_special_cases - and match.group(1) == stem - # --------------------------------------------------------------------- - ): - current_max = max(int(num), current_max) - # if it has more digits than expected, update the ndigits - if len(num) > min_digits: - min_digits = len(num) - # if the path does not exist and there are no existing files, - # return the requested path - if not requested_path.exists() and current_max == 0: - return requested_path - - current_max += 1 - # otherwise return the next path greater than the current_max - # remove any existing counter from the stem - if match := NUM_SPLIT.match(stem): - stem, num = match.groups() - if num: - # if the requested path has a counter that is greater than any other files - # use it - current_max = max(int(num), current_max) - return directory / f"{stem}_{current_max:0{min_digits}d}{extension}" - - -class MDAWidget_(MDAWidget): - """Multi-dimensional acquisition widget.""" - - def __init__( - self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None - ) -> None: - super().__init__(parent=parent, mmcore=mmcore) - - # writer for saving the MDA sequence. This is used by the MDAViewer to set its - # internal datastore. If _writer is None, the MDAViewer will use its default - # internal datastore. - self.writer: OMETiffWriter | OMEZarrWriter | TensorStoreHandler | None = None - - main_layout = cast(QBoxLayout, self.layout()) - - # remove the existing save_info widget from the layout and replace it with - # the custom SaveGroupBox widget that also handles tensorstore-zarr - if hasattr(self, "save_info"): - self.save_info.valueChanged.disconnect(self.valueChanged) - main_layout.removeWidget(self.save_info) - self.save_info.deleteLater() - self.save_info: SaveGroupBox = SaveGroupBox(parent=self) - self.save_info.valueChanged.connect(self.valueChanged) - main_layout.insertWidget(0, self.save_info) - - def get_next_available_path(self, requested_path: Path) -> Path: - """Get the next available path. - - Overwrites the method in the parent class to use the custom - 'get_next_available_path' function. - """ - return get_next_available_path(requested_path=requested_path) - - def prepare_mda( - self, - ) -> ( - bool - | OMEZarrWriter - | OMETiffWriter - | TensorStoreHandler - | ImageSequenceWriter - | None - ): - """Prepare the MDA sequence experiment. - - This method sets the writer to use for saving the MDA sequence. - """ - # in case the user does not press enter after editing the save name. - self.save_info.save_name.editingFinished.emit() - - # if autofocus has been requested, but the autofocus device is not engaged, - # and position-specific offsets haven't been set, show a warning - pos = self.stage_positions - if ( - self.af_axis.value() - and not self._mmc.isContinuousFocusLocked() - and (not self.tab_wdg.isChecked(pos) or not pos.af_per_position.isChecked()) - and not self._confirm_af_intentions() - ): - return False - - sequence = self.value() - - # technically, this is in the metadata as well, but isChecked is more direct - if self.save_info.isChecked(): - save_path = self._update_save_path_from_metadata( - sequence, update_metadata=True - ) - if isinstance(save_path, Path): - # get save format from metadata - save_meta = sequence.metadata.get(PYMMCW_METADATA_KEY, {}) - save_format = save_meta.get("format") - # set the writer to use for saving the MDA sequence. - # NOTE: 'self._writer' is used by the 'MDAViewer' to set its datastore - self.writer = self._create_writer(save_format, save_path) - # at this point, if self.writer is None, it means that a - # ImageSequenceWriter should be used to save the sequence. - if self.writer is None: - # Since any other type of writer will be handled by the 'MDAViewer', - # we need to pass a writer to the engine only if it is a - # 'ImageSequenceWriter'. - return ImageSequenceWriter(save_path) - return None - - def run_mda(self) -> None: - """Run the MDA experiment.""" - save_path = self.prepare_mda() - if save_path is False: - return - self.execute_mda(save_path) - - def execute_mda(self, output: Path | str | object | None) -> None: - """Execute the MDA experiment corresponding to the current value.""" - sequence = self.value() - # run the MDA experiment asynchronously - self._mmc.run_mda(sequence, output=output) - - # ------------------- private Methods ---------------------- - - def _on_mda_finished(self, sequence: MDASequence) -> None: - self.writer = None - super()._on_mda_finished(sequence) - - def _create_writer( - self, save_format: str, save_path: Path - ) -> OMEZarrWriter | OMETiffWriter | TensorStoreHandler | None: - """Create a writer for the MDAViewer based on the save format.""" - # use internal OME-TIFF writer if selected - if OME_TIFF in save_format: - # if OME-TIFF, save_path should be a directory without extension, so - # we need to add the ".ome.tif" to correctly use the OMETiffWriter - if not save_path.name.endswith(OME_TIFFS): - save_path = save_path.with_suffix(OME_TIFF) - return OMETiffWriter(save_path) - elif OME_ZARR in save_format: - return OMEZarrWriter(save_path) - elif ZARR_TESNSORSTORE in save_format: - return self._create_zarr_tensorstore(save_path) - # cannot use the ImageSequenceWriter here because the MDAViewer will not be - # able to handle it. - return None - - def _create_zarr_tensorstore(self, save_path: Path) -> TensorStoreHandler: - """Create a Zarr TensorStore writer.""" - return TensorStoreHandler( - driver="zarr", - path=save_path, - delete_existing=True, - spec={"context": {"cache_pool": {"total_bytes_limit": GB_CACHE}}}, - ) diff --git a/src/pymmcore_gui/widgets/_mda_widget/_save_widget.py b/src/pymmcore_gui/widgets/_mda_widget/_save_widget.py deleted file mode 100644 index 53236139..00000000 --- a/src/pymmcore_gui/widgets/_mda_widget/_save_widget.py +++ /dev/null @@ -1,229 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING -from warnings import warn - -from qtpy.QtCore import Qt, Signal -from qtpy.QtWidgets import ( - QComboBox, - QFileDialog, - QGridLayout, - QGroupBox, - QLabel, - QLineEdit, - QPushButton, - QWidget, -) - -if TYPE_CHECKING: - from typing import TypedDict - - from qtpy.QtGui import QFocusEvent - - class SaveInfo(TypedDict): - save_dir: str - save_name: str - format: str - should_save: bool - - -ZARR_TESNSORSTORE = "tensorstore-zarr" -OME_ZARR = "ome-zarr" -OME_TIFF = "ome-tiff" -TIFF_SEQ = "tiff-sequence" - -# dict with writer name and extension -WRITERS: dict[str, list[str]] = { - ZARR_TESNSORSTORE: [".tensorstore.zarr"], - OME_ZARR: [".ome.zarr"], - OME_TIFF: [".ome.tif", ".ome.tiff"], - TIFF_SEQ: [""], -} - -EXT_TO_WRITER = {x: w for w, exts in WRITERS.items() for x in exts} -ALL_EXTENSIONS = [x for exts in WRITERS.values() for x in exts if x] -DIRECTORY_WRITERS = {TIFF_SEQ} # technically could be zarr too - -FILE_NAME = "Filename:" -SUBFOLDER = "Subfolder:" - - -def _known_extension(name: str) -> str | None: - """Return a known extension if the name ends with one. - - Note that all non-None return values are guaranteed to be in EXTENSION_TO_WRITER. - """ - return next((ext for ext in ALL_EXTENSIONS if name.endswith(ext)), None) - - -def _strip_known_extension(name: str) -> str: - """Strip a known extension from the name if it ends with one.""" - if ext := _known_extension(name): - name = name[: -len(ext)] - return name.rstrip(".").rstrip() # remove trailing dots and spaces - - -class _FocusOutLineEdit(QLineEdit): - """A QLineEdit that emits an editingFinished signal when it loses focus. - - This is useful in case the user does not press enter after editing the save name. - """ - - def __init__(self, parent: QWidget | None = None): - super().__init__(parent) - - def focusOutEvent(self, event: QFocusEvent | None) -> None: # pragma: no cover - super().focusOutEvent(event) - self.editingFinished.emit() - - -class SaveGroupBox(QGroupBox): - """A Widget to gather information about MDA file saving.""" - - valueChanged = Signal() - - def __init__( - self, title: str = "Save Acquisition", parent: QWidget | None = None - ) -> None: - super().__init__(title, parent) - self.setCheckable(True) - self.setChecked(False) - - self.name_label = QLabel(FILE_NAME) - - self.save_dir = QLineEdit() - self.save_dir.setPlaceholderText("Select Save Directory") - self.save_name = _FocusOutLineEdit() - self.save_name.setPlaceholderText("Enter Experiment Name") - self.save_name.editingFinished.connect(self._update_writer_from_name) - - self._writer_combo = QComboBox() - self._writer_combo.addItems(list(WRITERS)) - self._writer_combo.currentTextChanged.connect(self._on_writer_combo_changed) - - browse_btn = QPushButton(text="...") - browse_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) - browse_btn.clicked.connect(self._on_browse_clicked) - - grid = QGridLayout(self) - grid.addWidget(QLabel("Directory:"), 0, 0) - grid.addWidget(self.save_dir, 0, 1, 1, 2) - grid.addWidget(browse_btn, 0, 3) - grid.addWidget(self.name_label, 1, 0) - grid.addWidget(self.save_name, 1, 1) - grid.addWidget(self._writer_combo, 1, 2, 1, 2) - - # prevent jiggling when toggling the checkbox - width = self.fontMetrics().horizontalAdvance(SUBFOLDER) - grid.setColumnMinimumWidth(0, width) - self.setFixedHeight(self.minimumSizeHint().height()) - - # connect - self.toggled.connect(self.valueChanged) - self.save_dir.textChanged.connect(self.valueChanged) - self.save_name.textChanged.connect(self.valueChanged) - - def currentPath(self) -> Path: - """Return the current save destination as a Path object.""" - return Path(self.save_dir.text(), str(self.save_name.text())) - - def setCurrentPath(self, path: str | Path) -> None: - """Set the save destination from a string or Path object.""" - path = Path(path) - self.save_dir.setText(str(path.parent)) - self.save_name.setText(path.name) - self._update_writer_from_name(allow_name_change=False) - - def value(self) -> SaveInfo: - """Return current state of the save widget.""" - return { - "save_dir": self.save_dir.text(), - "save_name": self.save_name.text(), - "format": self._writer_combo.currentText(), - "should_save": self.isChecked(), - } - - def setValue(self, value: dict | str | Path) -> None: - """Set the current state of the save widget. - - If value is a dict, keys should be: - - save_dir: str - Set the save directory. - - save_name: str - Set the save name. - - format: str - Set the combo box to the writer with this name. - - should_save: bool - Set the checked state of the checkbox. - """ - if isinstance(value, str | Path): - self.setCurrentPath(value) - self.setChecked(True) - return - - if (fmt := value.get("format")) and fmt not in WRITERS: # pragma: no cover - raise ValueError(f"Invalid format {fmt!r}. Must be one of {list(WRITERS)}") - - self.save_dir.setText(value.get("save_dir", "")) - self.save_name.setText(str(value.get("save_name", ""))) - self.setChecked(value.get("should_save", False)) - - if fmt: - self._writer_combo.setCurrentText(str(fmt)) - else: - self._update_writer_from_name() - - def _update_writer_from_name(self, allow_name_change: bool = True) -> None: - """Called when the user finishes editing the save_name widget. - - Updates the combo box to the writer with the same extension as the save name. - - Parameters - ---------- - allow_name_change : bool, optional - If True (default), allow the widget to update the save_name value - if the current name does not end with a known extension. If False, - the name will not be changed - """ - name = self.save_name.text() - if extension := _known_extension(name): - self._writer_combo.setCurrentText(EXT_TO_WRITER[extension]) - - elif not allow_name_change: - if ext := Path(name).suffix: - warn( - f"Invalid format {ext!r}. Defaulting to {TIFF_SEQ} writer.", - stacklevel=2, - ) - self._writer_combo.setCurrentText(TIFF_SEQ) - elif name: - # otherwise, if the name is not empty, add the first extension from the - # current writer - ext = WRITERS[self._writer_combo.currentText()][0] - self.save_name.setText(name + ext) - - def _on_browse_clicked(self) -> None: # pragma: no cover - """Open a dialog to select the save directory.""" - if save_dir := QFileDialog.getExistingDirectory( - self, "Select Save Directory", self.save_dir.text() - ): - self.save_dir.setText(save_dir) - - def _on_writer_combo_changed(self, writer: str) -> None: - """Called when the writer format combo box is changed. - - Updates save name to have the correct extension, and updates the label to - "Subfolder" or "Filename" depending on the writer type - """ - # update the label - self.name_label.setText(SUBFOLDER if writer in DIRECTORY_WRITERS else FILE_NAME) - - # if the name currently end with a known extension from the selected - # writer, then we're done - this_writer_extensions = WRITERS[writer] - current_name = self.save_name.text() - for ext in this_writer_extensions: - if ext and current_name.endswith(ext): - return - - # otherwise strip any known extension and add the first one from the new writer. - if name := _strip_known_extension(current_name): - name += this_writer_extensions[0] - self.save_name.setText(name) diff --git a/src/pymmcore_gui/widgets/_menubar.py b/src/pymmcore_gui/widgets/_menubar.py deleted file mode 100644 index 4612af44..00000000 --- a/src/pymmcore_gui/widgets/_menubar.py +++ /dev/null @@ -1,316 +0,0 @@ -from __future__ import annotations - -import warnings -from typing import TYPE_CHECKING, cast - -from pymmcore_plus import CMMCorePlus -from pymmcore_widgets import ( - CameraRoiWidget, - ConfigWizard, - GroupPresetTableWidget, - PixelConfigurationWidget, - PropertyBrowser, -) -from pymmcore_widgets.hcwizard.intro_page import SRC_CONFIG -from qtpy.QtCore import Qt -from qtpy.QtWidgets import ( - QAction, - QDockWidget, - QFileDialog, - QMenuBar, - QScrollArea, - QTabWidget, - QWidget, -) - -from pymmcore_gui.widgets._install_widget import _InstallWidget -from pymmcore_gui.widgets._mda_widget import MDAWidget -from pymmcore_gui.widgets._mm_console import MMConsole -from pymmcore_gui.widgets._stage_control import StagesControlWidget - -if TYPE_CHECKING: - from pymmcore_gui._main_window import MicroManagerGUI - -FLAGS = Qt.WindowType.Dialog -CONSOLE = "Console" -PROP_BROWSER = "Property Browser" -WIDGETS = { - "Pixel Configuration": PixelConfigurationWidget, - "Install Devices": _InstallWidget, -} -DOCKWIDGETS = { - "MDA Widget": MDAWidget, - "Groups and Presets": GroupPresetTableWidget, - "Stage Control": StagesControlWidget, - "Camera ROI": CameraRoiWidget, - CONSOLE: MMConsole, -} -RIGHT = Qt.DockWidgetArea.RightDockWidgetArea -LEFT = Qt.DockWidgetArea.LeftDockWidgetArea -BOTTOM = Qt.DockWidgetArea.BottomDockWidgetArea - -MMC = "mmc" -MDA = "mda" -WDGS = "wdgs" -VIEWERS = "viewers" -PREVIEW = "preview" - - -class ScrollableDockWidget(QDockWidget): - """A QDockWidget with a QScrollArea.""" - - def __init__( - self, - parent: QWidget | None = None, - *, - title: str, - widget: QWidget, - ): - super().__init__(title, parent) - self.main_widget = widget - # set allowed dock areas - self.setAllowedAreas(LEFT | RIGHT) - - # create the scroll area and set it as the widget of the QDockwidget - self.scroll_area = QScrollArea() - self.scroll_area.setWidgetResizable(True) - super().setWidget(self.scroll_area) - - # set the widget to the scroll area - self.scroll_area.setWidget(widget) - # resize the dock widget to the size hint of the widget - self.resize(widget.minimumSizeHint()) - - -class MenuBar(QMenuBar): - """Menu Bar for the Micro-Manager GUI. - - It contains the actions to create and show widgets and dockwidgets. - """ - - def __init__( - self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None - ) -> None: - super().__init__(parent) - self._main_window = parent - - # set tabbed dockwidgets tabs to the top - self._main_window.setTabPosition( - Qt.DockWidgetArea.AllDockWidgetAreas, QTabWidget.TabPosition.North - ) - - self._mmc = mmcore or CMMCorePlus.instance() - - # to keep track of the widgets - self._widgets: dict[str, QWidget | ScrollableDockWidget] = {} - - # widgets - self._wizard: ConfigWizard | None = None # is in a different menu - self._mda: MDAWidget | None = None - self._mm_console: MMConsole | None = None - - # configurations_menu - self._configurations_menu = self.addMenu("System Configurations") - # hardware cfg wizard - self._act_cfg_wizard = QAction("Hardware Configuration Wizard", self) - self._act_cfg_wizard.triggered.connect(self._show_config_wizard) - self._configurations_menu.addAction(self._act_cfg_wizard) - # property browser - self._act_property_browser = QAction(PROP_BROWSER, self) - self._act_property_browser.triggered.connect(self._show_property_browser) - self._configurations_menu.addAction(self._act_property_browser) - # save cfg - self._act_save_configuration = QAction("Save Configuration", self) - self._act_save_configuration.triggered.connect(self._save_cfg) - self._configurations_menu.addAction(self._act_save_configuration) - # load cfg - self._act_load_configuration = QAction("Load Configuration", self) - self._act_load_configuration.triggered.connect(self._load_cfg) - self._configurations_menu.addAction(self._act_load_configuration) - - # widgets_menu - self._widgets_menu = self.addMenu("Widgets") - - # viewer menu - self._viewer_menu = self.addMenu("Viewers") - self._act_close_all = QAction("Close All Viewers", self) - self._act_close_all.triggered.connect(self._close_all) - self._viewer_menu.addAction(self._act_close_all) - self._act_close_all_but_current = QAction( - "Close All Viewers but the Current", self - ) - self._act_close_all_but_current.triggered.connect(self._close_all_but_current) - self._viewer_menu.addAction(self._act_close_all_but_current) - - # create actions from WIDGETS and DOCKWIDGETS - keys = {*WIDGETS.keys(), *DOCKWIDGETS.keys()} - for action_name in sorted(keys): - action = QAction(action_name, self) - action.triggered.connect(self._show_widget) - self._widgets_menu.addAction(action) - - # create 'Group and Presets' and 'MDA' widgets at the startup - self._create_dock_widget("Groups and Presets", dock_area=LEFT) - mda = self._create_dock_widget("MDA Widget") - self._mda = cast(MDAWidget, mda.main_widget) - - def _enable(self, enable: bool) -> None: - """Enable or disable the actions.""" - self._configurations_menu.setEnabled(enable) - self._widgets_menu.setEnabled(enable) - self._viewer_menu.setEnabled(enable) - - def _save_cfg(self) -> None: - (filename, _) = QFileDialog.getSaveFileName( - self, "Save Micro-Manager Configuration." - ) - if filename: - self._mmc.saveSystemConfiguration( - filename if str(filename).endswith(".cfg") else f"{filename}.cfg" - ) - - def _load_cfg(self) -> None: - """Open file dialog to select a config file.""" - (filename, _) = QFileDialog.getOpenFileName( - self, "Select a Micro-Manager configuration file", "", "cfg(*.cfg)" - ) - if filename: - self._mmc.unloadAllDevices() - self._mmc.loadSystemConfiguration(filename) - - def _show_config_wizard(self) -> None: - """Show the Micro-Manager Hardware Configuration Wizard.""" - if self._wizard is None: - self._wizard = ConfigWizard(parent=self, core=self._mmc) - self._wizard.setWindowFlags(FLAGS) - if self._wizard.isVisible(): - self._wizard.raise_() - else: - current_cfg = self._mmc.systemConfigurationFile() or "" - self._wizard.setField(SRC_CONFIG, current_cfg) - self._wizard.show() - - def _close_all(self, skip: bool | list[int] | None = None) -> None: - """Close all viewers.""" - # the QAction sends a bool when triggered. We don't want to handle a bool - # so we convert it to an empty list. - if isinstance(skip, bool) or skip is None: - skip = [] - viewer_tab = self._main_window._core_link._viewer_tab - for index in reversed(range(viewer_tab.count())): - if index in skip or index == 0: # 0 to skip the prewiew tab - continue - tab_name = viewer_tab.tabText(index) - widget = viewer_tab.widget(index) - viewer_tab.removeTab(index) - widget.deleteLater() - - # update the viewers variable in the console - if self._mm_console is not None: - self._mm_console.shell.user_ns["viewers"].pop(tab_name, None) - - def _close_all_but_current(self) -> None: - """Close all viewers except the current one.""" - # build the list of indexes to skip - viewer_tab = self._main_window._core_link._viewer_tab - current = viewer_tab.currentWidget() - skip = [viewer_tab.indexOf(current)] - # close all but the current one - self._close_all(skip) - - def _show_widget(self) -> None: - """Create or show a widget.""" - # get the action that triggered the signal - sender = cast(QAction, self.sender()) - # get action name - action_name = sender.text() - - if action_name not in {*WIDGETS.keys(), *DOCKWIDGETS.keys()}: - warnings.warn(f"Widget '{action_name}' not found.", stacklevel=2) - return - - # already created - if action_name in self._widgets: - wdg = self._widgets[action_name] - wdg.show() - wdg.raise_() - return - - # create dock widget - if action_name in DOCKWIDGETS: - if action_name == CONSOLE: - self._launch_mm_console() - else: - self._create_dock_widget(action_name) - # create widget - else: - wdg = self._create_widget(action_name) - wdg.show() - - def _create_dock_widget( - self, action_name: str, dock_area: Qt.DockWidgetArea = RIGHT - ) -> ScrollableDockWidget: - """Create a dock widget with a scroll area.""" - wdg = DOCKWIDGETS[action_name](parent=self, mmcore=self._mmc) - dock = ScrollableDockWidget( - self, - title=action_name, - widget=wdg, - ) - self._main_window.addDockWidget(dock_area, dock) - self._widgets[action_name] = dock - return dock - - def _create_widget(self, action_name: str) -> QWidget: - """Create a widget.""" - wdg = WIDGETS[action_name](parent=self, mmcore=self._mmc) - wdg.setWindowFlags(FLAGS) - self._widgets[action_name] = wdg - return wdg - - def _launch_mm_console(self) -> None: - if self._mm_console is not None: - return - - # All values in the dictionary below can be accessed from the console using - # the associated string key - user_vars = { - MMC: self._mmc, # CMMCorePlus instance - WDGS: self._widgets, # dictionary of all the widgets - MDA: self._mda, # quick access to the MDA widget - VIEWERS: self._get_current_mda_viewers(), # dictionary of all the viewers - PREVIEW: self._main_window._core_link._preview, # access to preview widget - } - - self._mm_console = MMConsole(user_vars) - - dock = QDockWidget(CONSOLE, self) - dock.setAllowedAreas(LEFT | RIGHT | BOTTOM) - dock.setWidget(self._mm_console) - self._widgets[CONSOLE] = dock - - self._main_window.addDockWidget(RIGHT, dock) - - def _show_property_browser(self) -> None: - """Show the property browser.""" - if PROP_BROWSER in self._widgets: - pb = self._widgets[PROP_BROWSER] - pb.show() - pb.raise_() - else: - pb = PropertyBrowser(parent=self, mmcore=self._mmc) - pb.setWindowFlags(FLAGS) - self._widgets[PROP_BROWSER] = pb - pb.show() - - def _get_current_mda_viewers(self) -> dict[str, QWidget]: - """Update the viewers variable in the MMConsole.""" - viewers_dict = {} - tab = self._main_window._core_link._viewer_tab - for viewers in range(tab.count()): - if viewers == 0: # skip the preview tab - continue - tab_name = tab.tabText(viewers) - wdg = tab.widget(viewers) - viewers_dict[tab_name] = wdg - return viewers_dict diff --git a/src/pymmcore_gui/widgets/_mm_console.py b/src/pymmcore_gui/widgets/_mm_console.py deleted file mode 100644 index 38f3bba4..00000000 --- a/src/pymmcore_gui/widgets/_mm_console.py +++ /dev/null @@ -1,52 +0,0 @@ -from qtconsole.inprocess import QtInProcessKernelManager -from qtconsole.rich_jupyter_widget import RichJupyterWidget -from qtpy.QtGui import QCloseEvent - - -class MMConsole(RichJupyterWidget): - """A Qt widget for an IPython console, providing access to UI components. - - Copied from pymmcore-plus-sandbox by gselzer: - https://github.com/gselzer/pymmcore-plus-sandbox/blob/53ac7e8ca3b4874816583b8b74024a75432b8fc9/src/pymmcore_plus_sandbox/_console_widget.py#L5 - """ - - def __init__(self, user_variables: dict | None = None) -> None: - if user_variables is None: - user_variables = {} - super().__init__() - - self.setWindowTitle("micromanager-gui console") - self.set_default_style(colors="linux") - - # this makes calling `setFocus()` on a QtConsole give keyboard focus to - # the underlying `QTextEdit` widget - self.setFocusProxy(self._control) - - # Create an in-process kernel - self.kernel_manager = QtInProcessKernelManager() - self.kernel_manager.start_kernel(show_banner=False) - self.kernel_manager.kernel.gui = "qt" - self.kernel_client = self.kernel_manager.client() - self.kernel_client.start_channels() - self.shell = self.kernel_manager.kernel.shell - self.push = self.shell.push - - # Add any user variables - self.push(user_variables) - - def get_user_variables(self) -> dict: - """Return the variables pushed to the console.""" - return {k: v for k, v in self.shell.user_ns.items() if k != "__builtins__"} - - def closeEvent(self, event: QCloseEvent) -> None: - """Clean up the integrated console in napari.""" - if self.kernel_client is not None: - self.kernel_client.stop_channels() - if self.kernel_manager is not None and self.kernel_manager.has_kernel: - self.kernel_manager.shutdown_kernel() - - # RichJupyterWidget doesn't clean these up - self._completion_widget.deleteLater() - self._call_tip_widget.deleteLater() - self.deleteLater() - event.accept() diff --git a/src/pymmcore_gui/widgets/_shutters_toolbar.py b/src/pymmcore_gui/widgets/_shutters_toolbar.py deleted file mode 100644 index 1fae3c54..00000000 --- a/src/pymmcore_gui/widgets/_shutters_toolbar.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from pymmcore_plus import CMMCorePlus, DeviceType -from pymmcore_widgets import ShuttersWidget -from qtpy.QtCore import Qt - -if TYPE_CHECKING: - from PyQt6.QtWidgets import QToolBar, QWidget -else: - from qtpy.QtWidgets import QToolBar, QWidget - - -class ShuttersToolbar(QToolBar): - """A QToolBar for the loased Shutters.""" - - def __init__( - self, parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None - ) -> None: - super().__init__("Shutters ToolBar", parent) - - self.setObjectName("Shutters ToolBar") - - self.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) - - self._mmc = mmcore or CMMCorePlus.instance() - self._mmc.events.systemConfigurationLoaded.connect(self._on_cfg_loaded) - self._on_cfg_loaded() - - def _on_cfg_loaded(self) -> None: - self._clear() - - if not self._mmc.getLoadedDevicesOfType(DeviceType.ShutterDevice): - # FIXME: - # ShuttersWidget has not been tested with an empty device label... - # it raises all sorts of errors. - # if we want to have a "placeholder" widget, it needs more testing. - - # empty_shutter = ShuttersWidget("") - # self.layout().addWidget(empty_shutter) - return - - shutters_devs = list(self._mmc.getLoadedDevicesOfType(DeviceType.ShutterDevice)) - for d in shutters_devs: - props = self._mmc.getDevicePropertyNames(d) - if bool([x for x in props if "Physical Shutter" in x]): - shutters_devs.remove(d) - shutters_devs.insert(0, d) - - for idx, shutter in enumerate(shutters_devs): - if idx == len(shutters_devs) - 1: - s = ShuttersWidget(shutter) - else: - s = ShuttersWidget(shutter, autoshutter=False) - s.button_text_open = shutter - s.button_text_closed = shutter - s.icon_color_open = () - s.icon_color_closed = () - self.addWidget(s) - - def _clear(self) -> None: - """Delete toolbar action.""" - while self.actions(): - action = self.actions()[0] - self.removeAction(action) diff --git a/src/pymmcore_gui/widgets/_snap_live.py b/src/pymmcore_gui/widgets/_snap_live.py deleted file mode 100644 index 46bb0b19..00000000 --- a/src/pymmcore_gui/widgets/_snap_live.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from pymmcore_plus import CMMCorePlus -from pymmcore_widgets import DefaultCameraExposureWidget -from qtpy.QtCore import Qt -from qtpy.QtWidgets import QHBoxLayout, QLabel, QToolBar, QWidget - -from pymmcore_gui.widgets._snap_live_buttons import Live, Snap - - -class SnapLive(QToolBar): - """A QToolBar for the Snap and Live buttons.""" - - def __init__( - self, parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None - ) -> None: - super().__init__("Snap Live", parent) - - self.setObjectName("Snap Live") - - self.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) - - self._mmc = mmcore or CMMCorePlus.instance() - - # snap button - self._snap = Snap(mmcore=self._mmc) - self._snap.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.addWidget(self._snap) - - # live button - self._live = Live(mmcore=self._mmc) - self._live.setFocusPolicy(Qt.FocusPolicy.NoFocus) - self.addWidget(self._live) - - # camera exposure widget - exp_wdg = QWidget() - exp_wdg_layout = QHBoxLayout(exp_wdg) - exp_wdg_layout.setContentsMargins(5, 0, 0, 0) - exp_wdg_layout.setSpacing(3) - exp = QLabel("Exp:") - self._exposure = DefaultCameraExposureWidget(mmcore=self._mmc) - self._exposure.layout().setContentsMargins(0, 0, 0, 0) - exp_wdg_layout.addWidget(exp) - exp_wdg_layout.addWidget(self._exposure) - self.addWidget(exp_wdg) diff --git a/src/pymmcore_gui/widgets/_snap_live_buttons.py b/src/pymmcore_gui/widgets/_snap_live_buttons.py deleted file mode 100644 index 5d59d335..00000000 --- a/src/pymmcore_gui/widgets/_snap_live_buttons.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from fonticon_mdi6 import MDI6 -from pymmcore_widgets import LiveButton, SnapButton -from qtpy.QtCore import QSize -from superqt.fonticon import icon - -if TYPE_CHECKING: - from pymmcore_plus import CMMCorePlus - from qtpy.QtWidgets import QWidget - -BTN_SIZE = 30 -ICON_SIZE = QSize(25, 25) - - -class Snap(SnapButton): - """A SnapButton.""" - - def __init__( - self, parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None - ) -> None: - super().__init__(parent=parent, mmcore=mmcore) - self.setToolTip("Snap Image") - self.setIcon(icon(MDI6.camera_outline)) - self.setText("") - self.setFixedWidth(BTN_SIZE) - self.setIconSize(ICON_SIZE) - - -class Live(LiveButton): - """A LiveButton.""" - - def __init__( - self, parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None - ) -> None: - super().__init__(parent=parent, mmcore=mmcore) - self.setToolTip("Live Mode") - self.button_text_on = "" - self.button_text_off = "" - self.icon_color_on = () - self.icon_color_off = "#C33" - self.setFixedWidth(BTN_SIZE) - self.setIconSize(ICON_SIZE) diff --git a/src/pymmcore_gui/widgets/_stage_control.py b/src/pymmcore_gui/widgets/_stage_control.py deleted file mode 100644 index 5954ad17..00000000 --- a/src/pymmcore_gui/widgets/_stage_control.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -from pymmcore_plus import CMMCorePlus, DeviceType -from pymmcore_widgets import StageWidget -from qtpy.QtWidgets import ( - QGridLayout, - QGroupBox, - QHBoxLayout, - QMenu, - QSizePolicy, - QWidget, -) - -if TYPE_CHECKING: - from qtpy.QtGui import QWheelEvent - -STAGE_DEVICES = {DeviceType.Stage, DeviceType.XYStage} - - -class _Group(QGroupBox): - def __init__(self, name: str, parent: QWidget | None = None) -> None: - super().__init__(name, parent) - - self.setLayout(QHBoxLayout()) - self.layout().setContentsMargins(0, 0, 0, 0) - self.layout().setSpacing(0) - - self.setSizePolicy( - QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - ) - - -class _Stage(StageWidget): - """Stage control widget with wheel event for z-axis control.""" - - def __init__(self, device: str, parent: QWidget | None = None) -> None: - super().__init__(device=device, parent=parent) - - def wheelEvent(self, event: QWheelEvent) -> None: - if self._dtype != DeviceType.Stage: - return - delta = event.angleDelta().y() - increment = self._step.value() - if delta > 0: - self._move_stage(0, increment) - elif delta < 0: - self._move_stage(0, -increment) - - -class StagesControlWidget(QWidget): - """A widget to control all the XY and Z loaded stages.""" - - def __init__( - self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None - ) -> None: - super().__init__(parent=parent) - - self._mmc = mmcore or CMMCorePlus.instance() - - self._context_menu = QMenu(self) - - self._layout = QGridLayout(self) - self._layout.setContentsMargins(5, 5, 5, 5) - self._layout.setSpacing(5) - - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - self._mmc.events.systemConfigurationLoaded.connect(self._on_cfg_loaded) - - self._on_cfg_loaded() - - def _on_cfg_loaded(self) -> None: - self._clear() - sizepolicy = QSizePolicy( - QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding - ) - stage_dev_list = list(self._mmc.getLoadedDevicesOfType(DeviceType.XYStage)) - stage_dev_list.extend(iter(self._mmc.getLoadedDevicesOfType(DeviceType.Stage))) - for idx, stage_dev in enumerate(stage_dev_list): - if ( - self._mmc.getDeviceType(stage_dev) is not DeviceType.XYStage - and self._mmc.getDeviceType(stage_dev) is not DeviceType.Stage - ): - continue - bx = _Group(stage_dev, self) - bx.setSizePolicy(sizepolicy) - bx.layout().addWidget(_Stage(device=stage_dev, parent=bx)) - self._layout.addWidget(bx, idx // 2, idx % 2) - self.resize(self.sizeHint()) - - def _clear(self) -> None: - while self._layout.count(): - item = self._layout.takeAt(0) - if widget := item.widget(): - widget.setParent(self) - widget.deleteLater() diff --git a/src/pymmcore_gui/widgets/_viewers/__init__.py b/src/pymmcore_gui/widgets/_viewers/__init__.py deleted file mode 100644 index 656914ea..00000000 --- a/src/pymmcore_gui/widgets/_viewers/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from ._mda_viewer import MDAViewer -from ._preview_viewer import Preview - -__all__ = ["MDAViewer", "Preview"] diff --git a/src/pymmcore_gui/widgets/_viewers/_mda_viewer/__init__.py b/src/pymmcore_gui/widgets/_viewers/_mda_viewer/__init__.py deleted file mode 100644 index 6cb7e298..00000000 --- a/src/pymmcore_gui/widgets/_viewers/_mda_viewer/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from ._mda_viewer import MDAViewer - -__all__ = ["MDAViewer"] diff --git a/src/pymmcore_gui/widgets/_viewers/_mda_viewer/_data_wrappers.py b/src/pymmcore_gui/widgets/_viewers/_mda_viewer/_data_wrappers.py deleted file mode 100644 index ba15439c..00000000 --- a/src/pymmcore_gui/widgets/_viewers/_mda_viewer/_data_wrappers.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import annotations - -from contextlib import suppress -from typing import TYPE_CHECKING, Any, TypeGuard - -from ndv import DataWrapper -from pymmcore_plus.mda.handlers import OMEZarrWriter, TensorStoreHandler - -from pymmcore_gui.io import OMEZarrReader, TensorstoreZarrReader - -if TYPE_CHECKING: - from collections.abc import Hashable, Mapping - from pathlib import Path - - from pymmcore_plus.mda.handlers._5d_writer_base import _5DWriterBase - - -class MMTensorstoreWrapper(DataWrapper["TensorStoreHandler"]): - """Wrapper for pymmcore_plus.mda.handlers.TensorStoreHandler objects.""" - - def __init__(self, data: Any) -> None: - super().__init__(data) - - self._data: TensorStoreHandler = data - - @classmethod - def supports(cls, obj: Any) -> bool: - return isinstance(obj, TensorStoreHandler) - - def sizes(self) -> Mapping[str, int]: - with suppress(Exception): - return self.data.current_sequence.sizes # type: ignore - return {} - - def guess_channel_axis(self) -> Hashable | None: - return "c" - - def isel(self, indexers: Mapping[str, int]) -> Any: - return self.data.isel(indexers) - - def save_as_zarr(self, save_loc: str | Path) -> None: - # to have access to the metadata, the generated zarr file should be opened with - # the micromanager_gui.io.TensorstoreZarrReader - - # TODO: find a way to save as ome-zarr - - import tensorstore as ts - - if (store := self.data.store) is None: - return - new_spec = store.spec().to_json() - new_spec["kvstore"] = {"driver": "file", "path": str(save_loc)} - new_ts = ts.open(new_spec, create=True).result() - new_ts[:] = store.read().result() - if meta_json := store.kvstore.read(".zattrs").result().value: - new_ts.kvstore.write(".zattrs", meta_json).result() - - def save_as_tiff(self, save_loc: str | Path) -> None: - if (store := self.data.store) is None: - return - reader = TensorstoreZarrReader(store) - reader.write_tiff(save_loc) - - -class MM5DWriterWrapper(DataWrapper["_5DWriterBase"]): - """Wrapper for pymmcore_plus.mda.handlers._5DWriterBase objects.""" - - @classmethod - def supports(cls, obj: Any) -> TypeGuard[_5DWriterBase]: - try: - from pymmcore_plus.mda.handlers._5d_writer_base import _5DWriterBase - except ImportError: - from pymmcore_plus.mda.handlers import OMETiffWriter, OMEZarrWriter - - _5DWriterBase = (OMETiffWriter, OMEZarrWriter) # type: ignore - - return isinstance(obj, _5DWriterBase) - - def sizes(self) -> Mapping[Hashable, int]: - try: - return super().sizes() # type: ignore - except NotImplementedError: - return {} - - def guess_channel_axis(self) -> Hashable | None: - return "c" - - def isel(self, indexers: Mapping[str, int]) -> Any: - return self.data.isel(indexers) - - def save_as_zarr(self, save_loc: str | Path) -> None: - # TODO: implement logic for OMETiffWriter - if isinstance(self.data, OMEZarrWriter): - import zarr - - # save a copy of the ome-zarr file - new_store = zarr.DirectoryStore(str(save_loc)) - new_group = zarr.group(store=new_store, overwrite=True) - # the group property returns a zarr.hierarchy.Group object - zarr.copy_all(self.data.group, new_group) - else: # OMETiffWriter - raise NotImplementedError( - "Saving as Zarr is not yet implemented for OMETiffWriter." - ) - - def save_as_tiff(self, save_loc: str | Path) -> None: - # TODO: implement logic for OMETiffWriter - if isinstance(self.data, OMEZarrWriter): - # the group property returns a zarr.hierarchy.Group object - reader = OMEZarrReader(self.data.group) - reader.write_tiff(save_loc) - else: # OMETiffWriter - raise NotImplementedError( - "Saving as TIFF is not yet implemented for OMETiffWriter." - ) diff --git a/src/pymmcore_gui/widgets/_viewers/_mda_viewer/_mda_save_button.py b/src/pymmcore_gui/widgets/_viewers/_mda_viewer/_mda_save_button.py deleted file mode 100644 index d13cbacc..00000000 --- a/src/pymmcore_gui/widgets/_viewers/_mda_viewer/_mda_save_button.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -from fonticon_mdi6 import MDI6 -from qtpy.QtWidgets import QFileDialog, QPushButton, QWidget -from superqt.fonticon import icon - -from ._data_wrappers import MM5DWriterWrapper, MMTensorstoreWrapper - -if TYPE_CHECKING: - from ndv import DataWrapper - - -class MDASaveButton(QPushButton): - def __init__(self, data_wrapper: DataWrapper, parent: QWidget | None = None): - super().__init__(parent=parent) - self.setIcon(icon(MDI6.content_save_outline)) - self.clicked.connect(self._on_click) - - self._data_wrapper = data_wrapper - self._last_loc = str(Path.home()) - - def _on_click(self) -> None: - self._last_loc, _ = QFileDialog.getSaveFileName( - self, - "Choose destination", - str(self._last_loc), - "TIFF (*.tif *.tiff);;ZARR (*.zarr)", - ) - if not self._last_loc: - return - suffix = Path(self._last_loc).suffix - if suffix == ".zarr": - self._data_wrapper.save_as_zarr(self._last_loc) - elif suffix in {".tif", ".tiff"} and isinstance( - self._data_wrapper, MMTensorstoreWrapper | MM5DWriterWrapper - ): - self._data_wrapper.save_as_tiff(self._last_loc) - else: - raise ValueError(f"File format not yet supported: {self._last_loc}") diff --git a/src/pymmcore_gui/widgets/_viewers/_mda_viewer/_mda_viewer.py b/src/pymmcore_gui/widgets/_viewers/_mda_viewer/_mda_viewer.py deleted file mode 100644 index aae65efd..00000000 --- a/src/pymmcore_gui/widgets/_viewers/_mda_viewer/_mda_viewer.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -import warnings -from typing import TYPE_CHECKING, Any - -from ndv import NDViewer -from pymmcore_plus.mda.handlers import OMEZarrWriter, TensorStoreHandler -from superqt import ensure_main_thread -from useq import MDAEvent - -from pymmcore_gui.io import OMEZarrReader, TensorstoreZarrReader - -from ._data_wrappers import MM5DWriterWrapper, MMTensorstoreWrapper -from ._mda_save_button import MDASaveButton - -if TYPE_CHECKING: - from pymmcore_plus.mda.handlers._5d_writer_base import _5DWriterBase - from qtpy.QtWidgets import QWidget - - -class MDAViewer(NDViewer): - """NDViewer specialized for pymmcore-plus MDA acquisitions.""" - - from ._data_wrappers import MM5DWriterWrapper, MMTensorstoreWrapper - - def __init__( - self, - data: _5DWriterBase | TensorStoreHandler | None = None, - *, - parent: QWidget | None = None, - **kwargs: Any, - ): - if data is None: - data = TensorStoreHandler() - - # patch the frameReady method to call the superframeReady method - # AFTER handling the event - self._superframeReady = getattr(data, "frameReady", None) - if callable(self._superframeReady): - data.frameReady = self._patched_frame_ready # type: ignore - else: # pragma: no cover - warnings.warn( - "MDAViewer: data does not have a frameReady method to patch, " - "are you sure this is a valid data handler?", - stacklevel=2, - ) - - super().__init__(data, parent=parent, channel_axis="c", **kwargs) - - # temporarily hide the ndims button since we don't yet support - self._ndims_btn.hide() - - # add the save button only if using a TensorStoreHandler (and thus the - # MMTensorstoreWrapper) or OMEZarrWriter (and thus the MM5DWriterWrapper) - # since we didn't yet implement the save_as_zarr and save_as_tiff methods - # for OMETiffWriter in the MM5DWriterWrapper. - if isinstance(data, TensorStoreHandler | OMEZarrWriter): - self._save_btn = MDASaveButton(self._data_wrapper) - self._btns.insertWidget(3, self._save_btn) - - self.dims_sliders.set_locks_visible(True) - - def reader(self) -> Any: - """Return the reader for the data or the data if no reader is available.""" - if isinstance(self._data_wrapper, MMTensorstoreWrapper): - return TensorstoreZarrReader(self.data.store) - elif isinstance(self._data_wrapper, MM5DWriterWrapper): - if isinstance(self._data_wrapper.data, OMEZarrWriter): - return OMEZarrReader(self.data.group) - # TODO: implement logic for OMETiffWriter - else: - return self.data - - def _patched_frame_ready(self, *args: Any) -> None: - self._superframeReady(*args) # type: ignore - if len(args) >= 2 and isinstance(e := args[1], MDAEvent): - self._on_frame_ready(e) - - @ensure_main_thread # type: ignore - def _on_frame_ready(self, event: MDAEvent) -> None: - self.set_current_index(event.index) diff --git a/src/pymmcore_gui/widgets/_viewers/_preview_viewer/__init__.py b/src/pymmcore_gui/widgets/_viewers/_preview_viewer/__init__.py deleted file mode 100644 index 4edab24d..00000000 --- a/src/pymmcore_gui/widgets/_viewers/_preview_viewer/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from ._preview_viewer import Preview - -__all__ = ["Preview"] diff --git a/src/pymmcore_gui/widgets/_viewers/_preview_viewer/_preview_save_button.py b/src/pymmcore_gui/widgets/_viewers/_preview_viewer/_preview_save_button.py deleted file mode 100644 index c9fe07ee..00000000 --- a/src/pymmcore_gui/widgets/_viewers/_preview_viewer/_preview_save_button.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import json -from pathlib import Path -from typing import TYPE_CHECKING - -import tifffile -from fonticon_mdi6 import MDI6 -from pymmcore_plus import CMMCorePlus -from qtpy.QtWidgets import QFileDialog, QPushButton, QSizePolicy -from superqt.fonticon import icon - -if TYPE_CHECKING: - from qtpy.QtWidgets import QWidget - - from pymmcore_gui.widgets._viewers import Preview - - -class SaveButton(QPushButton): - """Create a QPushButton to save Viewfinder data. - - TODO - - Parameters - ---------- - viewfinder : Viewfinder | None - The `Viewfinder` displaying the data to save. - parent : QWidget | None - Optional parent widget. - - """ - - def __init__( - self, - viewer: Preview, - *, - parent: QWidget | None = None, - mmcore: CMMCorePlus | None = None, - ) -> None: - super().__init__(parent=parent) - - self.setSizePolicy( - QSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) - ) - self.setIcon(icon(MDI6.content_save_outline)) - - self._viewer = viewer - self._mmc = mmcore if mmcore is not None else CMMCorePlus.instance() - - self.clicked.connect(self._on_click) - - def _on_click(self) -> None: - # TODO: Add support for other file formats - # Stop sequence acquisitions - self._mmc.stopSequenceAcquisition() - - path, _ = QFileDialog.getSaveFileName( - self, "Save Image", "", "TIFF (*.tif *.tiff)" - ) - if not path: - return - tifffile.imwrite( - path, - self._viewer.data_wrapper.isel({}), - imagej=True, - # description=self._image_preview._meta, # TODO: ome-tiff - ) - # save meta as json - dest = Path(path).with_suffix(".json") - dest.write_text(json.dumps(self._viewer._meta)) diff --git a/src/pymmcore_gui/widgets/_viewers/_preview_viewer/_preview_viewer.py b/src/pymmcore_gui/widgets/_viewers/_preview_viewer/_preview_viewer.py deleted file mode 100644 index 7f11a3fa..00000000 --- a/src/pymmcore_gui/widgets/_viewers/_preview_viewer/_preview_viewer.py +++ /dev/null @@ -1,188 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, cast - -import tensorstore as ts -from ndv import DataWrapper, NDViewer -from ndv.viewer._backends._vispy import VispyViewerCanvas -from pymmcore_plus import CMMCorePlus, Metadata -from qtpy import QtCore -from superqt.utils import ensure_main_thread - -from pymmcore_gui.widgets._snap_live_buttons import Live, Snap - -from ._preview_save_button import SaveButton - -if TYPE_CHECKING: - from collections.abc import Hashable, Mapping - - import numpy as np - from qtpy.QtGui import QCloseEvent - from qtpy.QtWidgets import QWidget - - -def _data_type(mmc: CMMCorePlus) -> ts.dtype: - px_type = mmc.getBytesPerPixel() - if px_type == 1: - return ts.uint8 - elif px_type == 2: - return ts.uint16 - elif px_type == 4: - return ts.uint32 - else: - raise Exception(f"Unsupported Pixel Type: {px_type}") - - -class Preview(NDViewer): - """An NDViewer subclass tailored to active data viewing.""" - - # based on: https://github.com/gselzer/pymmcore-plus-sandbox/blob/53ac7e8ca3b4874816583b8b74024a75432b8fc9/src/pymmcore_plus_sandbox/_viewfinder.py#L154-L211 - - def __init__( - self, mmcore: CMMCorePlus | None = None, parent: QWidget | None = None - ): - super().__init__(data=None, parent=parent) - self.setWindowTitle("Preview") - self.live_view: bool = False - self._meta: Metadata | dict = {} - self._mmc = mmcore if mmcore is not None else CMMCorePlus.instance() - - # custom buttons - # hide the channel mode and ndims buttons - self._channel_mode_btn.hide() - self._ndims_btn.hide() - - # snap and live buttons - snap_btn = Snap(mmcore=self._mmc) - live_btn = Live(mmcore=self._mmc) - icon_size = self._set_range_btn.iconSize() - btn_size = self._set_range_btn.sizeHint().width() - snap_btn.setIconSize(icon_size) - snap_btn.setFixedWidth(btn_size) - live_btn.setIconSize(icon_size) - live_btn.setFixedWidth(btn_size) - - # save button - self.save_btn = SaveButton(mmcore=self._mmc, viewer=self) - - self._btns.insertWidget(1, snap_btn) - self._btns.insertWidget(2, live_btn) - self._btns.insertWidget(3, self.save_btn) - - # create initial buffer - self.ts_array = None - self.ts_shape = (0, 0) - self.bytes_per_pixel = 0 - - # connections - ev = self._mmc.events - ev.imageSnapped.connect(self._handle_snap) - ev.continuousSequenceAcquisitionStarted.connect(self._start_live_viewer) - ev.sequenceAcquisitionStopped.connect(self._stop_live_viewer) - - self._mmc.events.exposureChanged.connect(self._restart_live) - self._mmc.events.configSet.connect(self._restart_live) - - def image(self) -> Any: - """Return the current image data.""" - return self.data.read().result() - - def closeEvent(self, event: QCloseEvent | None) -> None: - self._mmc.stopSequenceAcquisition() - super().closeEvent(event) - - # Begin TODO: Remove once https://github.com/pyapp-kit/ndv/issues/39 solved - - def _update_datastore(self) -> Any: - if ( - self.ts_array is None - or self.ts_shape[0] != self._mmc.getImageHeight() - or self.ts_shape[1] != self._mmc.getImageWidth() - or self.bytes_per_pixel != self._mmc.getBytesPerPixel() - ): - self.ts_shape = (self._mmc.getImageHeight(), self._mmc.getImageWidth()) - self.bytes_per_pixel = self._mmc.getBytesPerPixel() - self.ts_array = ts.open( - {"driver": "zarr", "kvstore": {"driver": "memory"}}, - create=True, - shape=self.ts_shape, - dtype=_data_type(self._mmc), - ).result() - - # this is a hack to update the canvas with the new image shape or the - # set_range method will not work properly - self._canvas = cast(VispyViewerCanvas, self._canvas) # type: ignore - if self._canvas._current_shape: - self._canvas._current_shape = self.ts_shape - - super().set_data(self.ts_array) - - self._canvas.set_range() - - return self.ts_array - - def set_data( - self, - data: DataWrapper[Any] | Any, - *, - initial_index: Mapping[Hashable, int | slice] | None = None, - ) -> None: - if initial_index is None: - initial_index = {} - array = self._update_datastore() - array[:] = data - self.set_current_index(initial_index) - - # End TODO: Remove once https://github.com/pyapp-kit/ndv/issues/39 solved - - # Snap ------------------------------------------------------------- - @ensure_main_thread # type: ignore - def _handle_snap(self) -> None: - if self._mmc.mda.is_running(): - # This signal is emitted during MDAs as well - we want to ignore those. - return - img, meta = self._mmc.getTaggedImage() - self.set_data(img) - self._meta = meta - - # Live ------------------------------------------------------- - - @ensure_main_thread # type: ignore - def _start_live_viewer(self) -> None: - self.live_view = True - - # Start timer to update live viewer - interval = int(self._mmc.getExposure()) - self._live_timer_id = self.startTimer( - interval, QtCore.Qt.TimerType.PreciseTimer - ) - - def _stop_live_viewer(self, cameraLabel: str) -> None: - # Pause live viewer, but leave it open. - if self.live_view: - self.live_view = False - self.killTimer(self._live_timer_id) - self._live_timer_id = None - self._meta = self._mmc.getTags() - - def _update_viewer(self, data: np.ndarray | None = None) -> None: - """Update viewer with the latest image from the circular buffer.""" - if data is None: - if self._mmc.getRemainingImageCount() == 0: - return - try: - self.set_data(self._mmc.getLastImage()) - except (RuntimeError, IndexError): - # circular buffer empty - return - - def _restart_live(self, exposure: float) -> None: - if not self.live_view: - return - self._mmc.stopSequenceAcquisition() - self._mmc.startContinuousSequenceAcquisition() - - def timerEvent(self, a0: QtCore.QTimerEvent | None) -> None: - """Handles TimerEvents.""" - # Handle the timer event by updating the viewer (on gui thread) - self._update_viewer() diff --git a/tests/test_gui.py b/tests/test_gui.py deleted file mode 100644 index 31111704..00000000 --- a/tests/test_gui.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest - -from pymmcore_gui import MicroManagerGUI -from pymmcore_gui.widgets._menubar import DOCKWIDGETS, WIDGETS -from pymmcore_gui.widgets._viewers import MDAViewer - -if TYPE_CHECKING: - from pymmcore_plus import CMMCorePlus - from pytestqt.qtbot import QtBot - - -@pytest.mark.usefixtures("check_leaks") -def test_load_gui(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: - gui = MicroManagerGUI(mmcore=global_mmcore) - qtbot.addWidget(gui) - assert gui._menu_bar._mda - assert gui._core_link._preview - assert not gui._core_link._preview.isHidden() - assert gui._core_link._viewer_tab.count() == 1 - assert gui._core_link._viewer_tab.tabText(0) == "Preview" - assert gui._core_link._current_viewer is None - assert gui._core_link._mda_running is False - - -@pytest.mark.usefixtures("check_leaks") -def test_menu_wdg(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: - gui = MicroManagerGUI(mmcore=global_mmcore) - qtbot.addWidget(gui) - menu = gui._menu_bar - - assert len(menu._widgets.keys()) == 2 # MDA and GroupPreset widgets - for action in menu._widgets_menu.actions(): - action.trigger() - assert len(menu._widgets.keys()) == len(WIDGETS) + len(DOCKWIDGETS) - - -@pytest.mark.usefixtures("check_leaks") -def test_menu_viewer(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: - gui = MicroManagerGUI(mmcore=global_mmcore) - qtbot.addWidget(gui) - menu = gui._menu_bar - assert gui._core_link._viewer_tab.tabText(0) == "Preview" - # add a viewer - gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA1") - gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA2") - assert gui._core_link._viewer_tab.count() == 3 - - menu._close_all() - assert gui._core_link._viewer_tab.count() == 1 - - gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA3") - gui._core_link._viewer_tab.addTab(MDAViewer(), "MDA4") - gui._core_link._viewer_tab.setCurrentIndex(2) - assert gui._core_link._viewer_tab.count() == 3 - - menu._close_all_but_current() - assert gui._core_link._viewer_tab.count() == 2 - assert gui._core_link._viewer_tab.tabText(0) == "Preview" - assert gui._core_link._viewer_tab.tabText(1) == "MDA4" - - menu._close_all() diff --git a/tests/test_mda_viewer.py b/tests/test_mda_viewer.py deleted file mode 100644 index c6a277e4..00000000 --- a/tests/test_mda_viewer.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, cast -from unittest.mock import patch - -import pytest -import useq -from pymmcore_plus.mda.handlers import OMETiffWriter, OMEZarrWriter, TensorStoreHandler -from pymmcore_plus.metadata import SummaryMetaV1 -from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY - -from pymmcore_gui import MicroManagerGUI -from pymmcore_gui.widgets._mda_widget import MDAWidget -from pymmcore_gui.widgets._mda_widget._save_widget import ( - OME_TIFF, - OME_ZARR, - TIFF_SEQ, - ZARR_TESNSORSTORE, -) -from pymmcore_gui.widgets._viewers import MDAViewer - -if TYPE_CHECKING: - from pathlib import Path - - from pymmcore_plus import CMMCorePlus - from pytestqt.qtbot import QtBot - - -@pytest.mark.usefixtures("check_leaks") -def test_mda_viewer_no_saving( - qtbot: QtBot, global_mmcore: CMMCorePlus, tmp_path: Path -) -> None: - gui = MicroManagerGUI(mmcore=global_mmcore) - qtbot.addWidget(gui) - - mda = useq.MDASequence(channels=["DAPI", "FITC"]) - - # simulate that the core run_mda method was called - gui._core_link._on_sequence_started(sequence=mda, meta=SummaryMetaV1()) - assert gui._core_link._viewer_tab.count() == 2 - assert gui._core_link._viewer_tab.tabText(1) == "MDA Viewer 1" - assert gui._core_link._viewer_tab.currentIndex() == 1 - # simulate that the core run_mda method was called again - gui._core_link._on_sequence_started(sequence=mda, meta=SummaryMetaV1()) - assert gui._core_link._viewer_tab.count() == 3 - assert gui._core_link._viewer_tab.tabText(2) == "MDA Viewer 2" - assert gui._core_link._viewer_tab.currentIndex() == 2 - - -writers = [ - ("tensorstore-zarr", "ts.tensorstore.zarr", TensorStoreHandler), - ("ome-tiff", "t.ome.tiff", OMETiffWriter), - ("ome-zarr", "z.ome.zarr", OMEZarrWriter), -] - - -@pytest.mark.usefixtures("check_leaks") -@pytest.mark.parametrize("writers", writers) -def test_mda_viewer_saving( - qtbot: QtBot, - global_mmcore: CMMCorePlus, - tmp_path: Path, - writers: tuple[str, str, type], -) -> None: - gui = MicroManagerGUI(mmcore=global_mmcore) - qtbot.addWidget(gui) - - file_format, save_name, writer = writers - - mda = useq.MDASequence( - channels=["FITC", "DAPI"], - metadata={ - PYMMCW_METADATA_KEY: { - "format": file_format, - "save_dir": str(tmp_path), - "save_name": save_name, - "should_save": True, - } - }, - ) - gui._menu_bar._mda.setValue(mda) - - # patch the run_mda method to avoid running the MDA sequence - def _run_mda(seq, output): - return - - # set the writer attribute of the MDAWidget without running the MDA sequence - with patch.object(global_mmcore, "run_mda", _run_mda): - gui._menu_bar._mda.run_mda() - # simulate that the core run_mda method was called - gui._core_link._on_sequence_started(sequence=mda, meta=SummaryMetaV1()) - - assert isinstance(gui._menu_bar._mda.writer, writer) - assert gui._core_link._viewer_tab.count() == 2 - assert gui._core_link._viewer_tab.tabText(1) == save_name - - # saving datastore and MDAViewer datastore should be the same - viewer = cast(MDAViewer, gui._core_link._viewer_tab.widget(1)) - assert viewer.data == gui._core_link._mda.writer - - -data = [ - ("./test.ome.tiff", OME_TIFF, OMETiffWriter), - ("./test.ome.zarr", OME_ZARR, OMEZarrWriter), - ("./test.tensorstore.zarr", ZARR_TESNSORSTORE, TensorStoreHandler), - ("./test", TIFF_SEQ, None), -] - - -@pytest.mark.parametrize("data", data) -def test_mda_writer(qtbot: QtBot, tmp_path: Path, data: tuple) -> None: - wdg = MDAWidget() - qtbot.addWidget(wdg) - wdg.show() - path, save_format, cls = data - writer = wdg._create_writer(save_format, tmp_path / path) - assert isinstance(writer, cls) if writer is not None else writer is None diff --git a/tests/test_readers_writers.py b/tests/test_readers_writers.py deleted file mode 100644 index 4e08eb00..00000000 --- a/tests/test_readers_writers.py +++ /dev/null @@ -1,125 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -import pytest -import tifffile -import useq -from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY - -from pymmcore_gui.io import OMEZarrReader, TensorStoreHandler, TensorstoreZarrReader - -if TYPE_CHECKING: - from pymmcore_plus import CMMCorePlus - from pytestqt.qtbot import QtBot - - -# fmt: off -files = [ - # indexers, expected files, file_to_check, expected shape - ({}, ["p0.tif", "p0.json", "p1.tif", "p1.json"], "p0.tif", (3, 2, 512, 512)), - ({"p": 0}, ["test.tiff", "test.json"], "test.tiff", (3, 2, 512, 512)), - ({"p": 0, "t": 0}, ["test.tiff", "test.json"], "test.tiff", (2, 512, 512)), - # when a tuple is passed, first element is indexers and second is the kwargs - (({"p": 0}, {"p": 0}), ["test.tiff", "test.json"], "test.tiff", (3, 2, 512, 512)), - (({"p": 0}, {"t": 0}), ["test.tiff", "test.json"], "test.tiff", (2, 512, 512)), -] - -MDA = useq.MDASequence( - axis_order=["p", "t", "c"], - channels=["FITC", "DAPI"], - stage_positions=[(0, 0), (0, 1)], - time_plan={"loops": 3, "interval": 0.1}, -) -ZARR_META = {"format": "ome-zarr", "save_name": "z.ome.zarr"} -TENSOR_META = { - "format": "tensorstore-zarr", - "save_name": "ts.tensorstore.zarr", -} - -writers = [ - (ZARR_META, "z.ome.zarr", "", OMEZarrReader), - (TENSOR_META, "ts.tensorstore.zarr", TensorStoreHandler, TensorstoreZarrReader), -] -# fmt: on - - -# NOTE: the tensorstore reader works only if we use the internal TensorStoreHandler -# TODO: fix the main TensorStoreHandler because it does not write the ".zattrs" -@pytest.mark.parametrize("writers", writers) -@pytest.mark.parametrize("kwargs", [True, False]) -@pytest.mark.parametrize("files", files) -def test_readers( - qtbot: QtBot, - global_mmcore: CMMCorePlus, - tmp_path: Path, - writers: tuple, - files: tuple, - kwargs: bool, -): - meta, name, writer, reader = writers - indexers, expected_files, file_to_check, expected_shape = files - - mda = MDA.replace( - metadata={ - PYMMCW_METADATA_KEY: { - **meta, - "save_dir": str(tmp_path), - "should_save": True, - } - } - ) - - dest = tmp_path / name - writer = writer(path=dest) if writer else dest - with qtbot.waitSignal(global_mmcore.mda.events.sequenceFinished): - global_mmcore.mda.run(mda, output=writer) - - assert dest.exists() - - w = reader(data=dest) - assert w.store - assert w.sequence - assert w.path == Path(dest) - assert ( - w.metadata - if isinstance(w, TensorstoreZarrReader) - else w.metadata() - if isinstance(w, OMEZarrReader) - else None - ) - - # test that the reader can accept the actual store as input on top of the path - w1 = reader(data=w.store) - assert isinstance(w1, type(w)) - assert w1.sequence == w.sequence - assert w1.path - - assert w.isel({"p": 0}).shape == (3, 2, 512, 512) - assert w.isel({"p": 0, "t": 0}).shape == (2, 512, 512) - _, metadata = w.isel({"p": 0}, metadata=True) - assert metadata - - # test saving as tiff - dest = tmp_path / "test" - - if not indexers and "ome.zarr" in name: - return # skipping since the no 'p' index error will be raised - - # if indexers is a tuple, use one as indexers and the other as kwargs - if isinstance(indexers, tuple): - # skip if kwargs is False since we don't want to test it twice - if not kwargs: - return - w.write_tiff(dest, indexers[0], **indexers[1]) - # depends om kwargs (once as dict and once as kwargs) - else: - w.write_tiff(dest, **indexers) if kwargs else w.write_tiff(dest, indexers) - # all files in dest - parent = dest.parent if indexers else dest - dir_files = [f.name for f in parent.iterdir()] - assert all(f in dir_files for f in expected_files) - # open with tifffile and check the shape - with tifffile.TiffFile(parent / file_to_check) as tif: - assert tif.asarray().shape == expected_shape diff --git a/tests/test_save_widget.py b/tests/test_save_widget.py deleted file mode 100644 index cabd8324..00000000 --- a/tests/test_save_widget.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -import pytest - -from pymmcore_gui.widgets._mda_widget._save_widget import ( - DIRECTORY_WRITERS, - FILE_NAME, - OME_TIFF, - OME_ZARR, - SUBFOLDER, - TIFF_SEQ, - WRITERS, - ZARR_TESNSORSTORE, - SaveGroupBox, -) - -if TYPE_CHECKING: - from pytestqt.qtbot import QtBot - - -def test_set_get_value(qtbot: QtBot) -> None: - wdg = SaveGroupBox() - qtbot.addWidget(wdg) - - # Can be set with a Path or a string, in which case `should_save` be set to True - path = Path("/some_path/some_file") - wdg.setValue(path) - assert wdg.value() == { - "save_dir": str(path.parent), - "save_name": str(path.name), - "should_save": True, - "format": TIFF_SEQ, - } - - # When setting to a file with an extension, the format is set to the known writer - wdg.setValue("/some_path/some_file.ome.tif") - assert wdg.value()["format"] == OME_TIFF - - # unrecognized extensions warn and default to TIFF_SEQ - with pytest.warns( - UserWarning, match=f"Invalid format '.png'. Defaulting to {TIFF_SEQ}." - ): - wdg.setValue("/some_path/some_file.png") - assert wdg.value() == { - "save_dir": str(path.parent), - "save_name": "some_file.png", # note, we don't change the name - "should_save": True, - "format": TIFF_SEQ, - } - - # Can be set with a dict. - # note that when setting with a dict, should_save must be set explicitly - wdg.setValue({"save_dir": str(path.parent), "save_name": "some_file.ome.zarr"}) - assert wdg.value() == { - "save_dir": str(path.parent), - "save_name": "some_file.ome.zarr", - "should_save": False, - "format": OME_ZARR, - } - - # setting zarr tensorstore format (dict) - wdg.setValue({"save_dir": str(path.parent), "save_name": "ts.tensorstore.zarr"}) - assert wdg.value() == { - "save_dir": str(path.parent), - "save_name": "ts.tensorstore.zarr", - "should_save": False, - "format": ZARR_TESNSORSTORE, - } - - # setting zarr tensorstore format (path / string) - wdg.setValue("/some_path/ts.tensorstore.zarr") - assert wdg.value() == { - "save_dir": str(path.parent), - "save_name": "ts.tensorstore.zarr", - "should_save": True, - "format": ZARR_TESNSORSTORE, - } - - -def test_save_box_autowriter_selection(qtbot: QtBot) -> None: - """Test that setting the name to known extension changes the format""" - wdg = SaveGroupBox() - qtbot.addWidget(wdg) - - wdg.save_name.setText("name.ome.tiff") - wdg.save_name.editingFinished.emit() # this only happens in the GUI - assert wdg._writer_combo.currentText() == OME_TIFF - - # and it goes both ways - wdg._writer_combo.setCurrentText(OME_ZARR) - assert wdg.save_name.text() == "name.ome.zarr" - - -@pytest.mark.parametrize("writer", WRITERS) -def test_writer_combo_text_changed(qtbot: QtBot, writer: str) -> None: - wdg = SaveGroupBox() - qtbot.addWidget(wdg) - wdg._writer_combo.setCurrentText(writer) - wdg.save_name.setText("name") - wdg.save_name.editingFinished.emit() - - assert wdg._writer_combo.currentText() == writer - expected_label = SUBFOLDER if writer in DIRECTORY_WRITERS else FILE_NAME - assert wdg.name_label.text() == expected_label - assert wdg.save_name.text() == f"name{WRITERS[writer][0]}" diff --git a/tests/test_stage_widget.py b/tests/test_stage_widget.py deleted file mode 100644 index 00584033..00000000 --- a/tests/test_stage_widget.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest - -from pymmcore_gui.widgets._stage_control import StagesControlWidget - -if TYPE_CHECKING: - from pymmcore_plus import CMMCorePlus - from pytestqt.qtbot import QtBot - - -@pytest.mark.usefixtures("check_leaks") -def test_stage_widget(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: - s = StagesControlWidget(mmcore=global_mmcore) - qtbot.addWidget(s) - group1 = s._layout.takeAt(0).widget() - group2 = s._layout.takeAt(0).widget() - assert group1.title() == "XY" - assert group2.title() == "Z" - global_mmcore.loadSystemConfiguration() - global_mmcore.loadSystemConfiguration() From b7af929fcd160ed8e6a8f392bbb1e05c449f5076 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 18 Dec 2024 08:30:59 -0500 Subject: [PATCH 185/226] add pyinstaller --- scripts/mmgui.spec | 55 ++++++++++++++++++++++++++++++++ src/pymmcore_gui/__main__.py | 2 +- src/pymmcore_gui/_main_window.py | 2 ++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 scripts/mmgui.spec diff --git a/scripts/mmgui.spec b/scripts/mmgui.spec new file mode 100644 index 00000000..0e916b81 --- /dev/null +++ b/scripts/mmgui.spec @@ -0,0 +1,55 @@ +import sys + +from PyInstaller.building.api import COLLECT, EXE, PYZ +from PyInstaller.building.build_main import Analysis + +a = Analysis( + ["../src/pymmcore_gui/__main__.py"], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name="mmgui", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="mmgui", +) +if sys.platform == "darwin": + from PyInstaller.building.osx import BUNDLE + + app = BUNDLE( + coll, + name="mmgui.app", + icon=None, + bundle_identifier=None, + ) diff --git a/src/pymmcore_gui/__main__.py b/src/pymmcore_gui/__main__.py index 1cb1bbdc..611c6821 100644 --- a/src/pymmcore_gui/__main__.py +++ b/src/pymmcore_gui/__main__.py @@ -1,4 +1,4 @@ -from ._app import main +from pymmcore_gui._app import main if __name__ == "__main__": main() diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 6bc9e8a0..70708df9 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -16,3 +16,5 @@ def __init__( # get global CMMCorePlus instance self._mmc = mmcore or CMMCorePlus.instance() + self._mmc.loadSystemConfiguration() + print(self._mmc.getLoadedDevices()) From daf470d9501dc227632edf0d64ef8c9ec85f4e2d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Fri, 20 Dec 2024 17:25:40 -0500 Subject: [PATCH 186/226] more progress --- .gitignore | 6 - examples/gui.py | 10 - hooks/hook-vispy.py | 9 + scripts/mmgui.spec => mmgui.spec | 25 ++- pyproject.toml | 8 +- src/pymmcore_gui/_app.py | 2 +- src/pymmcore_gui/_core_link.py | 194 -------------------- src/pymmcore_gui/_main_window.py | 65 ++++++- src/pymmcore_gui/actions/__init__.py | 0 src/pymmcore_gui/actions/_action_info.py | 30 +++ src/pymmcore_gui/actions/_core_actions.py | 172 +++++++++++++++++ src/pymmcore_gui/actions/_core_functions.py | 35 ++++ src/pymmcore_gui/actions/_widget_actions.py | 0 src/pymmcore_gui/widgets/__init__.py | 0 src/pymmcore_gui/widgets/_mm_console.py | 57 ++++++ src/pymmcore_gui/widgets/_toolbars.py | 43 +++++ tests/conftest.py | 2 +- 17 files changed, 433 insertions(+), 225 deletions(-) delete mode 100644 examples/gui.py create mode 100644 hooks/hook-vispy.py rename scripts/mmgui.spec => mmgui.spec (68%) delete mode 100644 src/pymmcore_gui/_core_link.py create mode 100644 src/pymmcore_gui/actions/__init__.py create mode 100644 src/pymmcore_gui/actions/_action_info.py create mode 100644 src/pymmcore_gui/actions/_core_actions.py create mode 100644 src/pymmcore_gui/actions/_core_functions.py create mode 100644 src/pymmcore_gui/actions/_widget_actions.py create mode 100644 src/pymmcore_gui/widgets/__init__.py create mode 100644 src/pymmcore_gui/widgets/_mm_console.py create mode 100644 src/pymmcore_gui/widgets/_toolbars.py diff --git a/.gitignore b/.gitignore index f1623a58..b86ac0ad 100644 --- a/.gitignore +++ b/.gitignore @@ -28,12 +28,6 @@ share/python-wheels/ *.egg MANIFEST -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - # Installer logs pip-log.txt pip-delete-this-directory.txt diff --git a/examples/gui.py b/examples/gui.py deleted file mode 100644 index ae1a9d3d..00000000 --- a/examples/gui.py +++ /dev/null @@ -1,10 +0,0 @@ -from pymmcore_plus import CMMCorePlus -from qtpy.QtWidgets import QApplication - -from pymmcore_gui import MicroManagerGUI - -app = QApplication([]) -mmc = CMMCorePlus.instance() -gui = MicroManagerGUI() -gui.show() -app.exec_() diff --git a/hooks/hook-vispy.py b/hooks/hook-vispy.py new file mode 100644 index 00000000..ffdedf4b --- /dev/null +++ b/hooks/hook-vispy.py @@ -0,0 +1,9 @@ +from PyInstaller.utils.hooks import collect_data_files + +datas = collect_data_files("vispy") + +hiddenimports = [ + "vispy.glsl", + "vispy.app.backends._pyqt6", + "vispy.app.backends._test", +] diff --git a/scripts/mmgui.spec b/mmgui.spec similarity index 68% rename from scripts/mmgui.spec rename to mmgui.spec index 0e916b81..bf7f9065 100644 --- a/scripts/mmgui.spec +++ b/mmgui.spec @@ -2,17 +2,33 @@ import sys from PyInstaller.building.api import COLLECT, EXE, PYZ from PyInstaller.building.build_main import Analysis +from PyInstaller.config import CONF + +if "workpath" not in CONF: + raise ValueError("This script must run with `pyinstaller mmgui.spec`") + +CONF["noconfirm"] = True + +sys.modules["FixTk"] = None a = Analysis( - ["../src/pymmcore_gui/__main__.py"], + ["src/pymmcore_gui/__main__.py"], pathex=[], binaries=[], datas=[], - hiddenimports=[], - hookspath=[], + hiddenimports=['pdb'], + hookspath=["hooks"], hooksconfig={}, runtime_hooks=[], - excludes=[], + excludes=[ + "FixTk", + "tcl", + "tk", + "_tkinter", + "tkinter", + "Tkinter", + "matplotlib", + ], noarchive=False, optimize=0, ) @@ -44,6 +60,7 @@ coll = COLLECT( upx_exclude=[], name="mmgui", ) + if sys.platform == "darwin": from PyInstaller.building.osx import BUNDLE diff --git a/pyproject.toml b/pyproject.toml index 4b99345f..fd0c74db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,10 +39,10 @@ dependencies = [ "ndv[vispy]==0.0.4", "pymmcore-plus[cli]>=0.12.0", "pymmcore-widgets>=0.8.0", - "pyqt6==6.7.1", + "PyQt6==6.7.1", "pyyaml>=6.0.2", "qtconsole>=5.6.1", - "qtpy>=2.4.2", + "rich", # also in pymmcore-plus[cli] above "superqt[cmap,iconify]>=0.7.0", "tifffile>=2024.12.12", "tqdm>=4.67.1", @@ -144,5 +144,5 @@ source = ["pymmcore_gui"] [tool.check-manifest] ignore = [".pre-commit-config.yaml", ".ruff_cache/**/*", "tests/**/*"] -[tool.typos.default] -extend-ignore-identifiers-re = ["(?i)nd2?.*", "(?i)ome"] \ No newline at end of file +[tool.typos.default] +extend-ignore-identifiers-re = ["(?i)nd2?.*", "(?i)ome"] diff --git a/src/pymmcore_gui/_app.py b/src/pymmcore_gui/_app.py index 9e0e3268..078a3db0 100644 --- a/src/pymmcore_gui/_app.py +++ b/src/pymmcore_gui/_app.py @@ -7,7 +7,7 @@ from contextlib import suppress from typing import TYPE_CHECKING -from qtpy.QtWidgets import QApplication +from PyQt6.QtWidgets import QApplication from pymmcore_gui import MicroManagerGUI diff --git a/src/pymmcore_gui/_core_link.py b/src/pymmcore_gui/_core_link.py deleted file mode 100644 index 95aa5397..00000000 --- a/src/pymmcore_gui/_core_link.py +++ /dev/null @@ -1,194 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, cast - -from pymmcore_plus import CMMCorePlus -from pymmcore_widgets.useq_widgets._mda_sequence import PYMMCW_METADATA_KEY -from qtpy.QtCore import QObject, Qt -from qtpy.QtWidgets import QTabBar, QTabWidget - -from pymmcore_gui.widgets._viewers import MDAViewer - -from .widgets._menubar import PREVIEW, VIEWERS -from .widgets._viewers import Preview - -if TYPE_CHECKING: - import useq - from pymmcore_plus.metadata import SummaryMetaV1 - - from ._main_window import MicroManagerGUI - from .widgets._mda_widget import MDAWidget - from .widgets._mm_console import MMConsole - -DIALOG = Qt.WindowType.Dialog -VIEWER_TEMP_DIR = None -NO_R_BTN = (0, QTabBar.ButtonPosition.RightSide, None) -NO_L_BTN = (0, QTabBar.ButtonPosition.LeftSide, None) -MDA_VIEWER = "MDA Viewer" - - -class CoreViewersLink(QObject): - def __init__(self, parent: MicroManagerGUI, *, mmcore: CMMCorePlus | None = None): - super().__init__(parent) - - self._main_window = parent - self._mmc = mmcore or CMMCorePlus.instance() - - # Tab widget for the viewers (preview and MDA) - self._viewer_tab = QTabWidget() - # Enable the close button on tabs - self._viewer_tab.setTabsClosable(True) - self._viewer_tab.tabCloseRequested.connect(self._close_tab) - self._main_window._central_wdg_layout.addWidget(self._viewer_tab, 0, 0) - - # preview tab - self._preview: Preview = Preview(parent=self._main_window, mmcore=self._mmc) - self._viewer_tab.addTab(self._preview, PREVIEW.capitalize()) - # remove the preview tab close button - self._viewer_tab.tabBar().setTabButton(*NO_R_BTN) - self._viewer_tab.tabBar().setTabButton(*NO_L_BTN) - - # keep track of the current mda viewer - self._current_viewer: MDAViewer | None = None - - self._mda_running: bool = False - - # the MDAWidget. It should have been set in the _MenuBar at startup - self._mda = cast("MDAWidget", self._main_window._menu_bar._mda) - - ev = self._mmc.events - ev.continuousSequenceAcquisitionStarted.connect(self._set_preview_tab) - ev.imageSnapped.connect(self._set_preview_tab) - - self._mmc.mda.events.sequenceStarted.connect(self._on_sequence_started) - self._mmc.mda.events.sequenceFinished.connect(self._on_sequence_finished) - self._mmc.mda.events.sequencePauseToggled.connect(self._enable_menubar) - - def _close_tab(self, index: int) -> None: - """Close the tab at the given index.""" - if index == 0: - return - widget = self._viewer_tab.widget(index) - self._viewer_tab.removeTab(index) - widget.deleteLater() - - # Delete the current viewer - del self._current_viewer - self._current_viewer = None - - # remove the viewer from the console - if console := self._get_mm_console(): - if VIEWERS not in console.get_user_variables(): - return - # remove the item at pos index from the viewers variable in the console - viewer_name = list(console.shell.user_ns[VIEWERS].keys())[index - 1] - console.shell.user_ns[VIEWERS].pop(viewer_name, None) - - def _on_sequence_started( - self, sequence: useq.MDASequence, meta: SummaryMetaV1 - ) -> None: - """Show the MDAViewer when the MDA sequence starts.""" - self._mda_running = True - - # disable the menu bar - self._main_window._menu_bar._enable(False) - - # pause until the viewer is ready - self._mmc.mda.toggle_pause() - # setup the viewer - self._setup_viewer(sequence, meta) - # resume the sequence - self._mmc.mda.toggle_pause() - - def _setup_viewer(self, sequence: useq.MDASequence, meta: SummaryMetaV1) -> None: - """Setup the MDAViewer.""" - # get the MDAWidget writer - datastore = self._mda.writer if self._mda is not None else None - self._current_viewer = MDAViewer(parent=self._main_window, data=datastore) - - # rename the viewer if there is a save_name' in the metadata or add a digit - pmmcw_meta = cast(dict, sequence.metadata.get(PYMMCW_METADATA_KEY, {})) - viewer_name = self._get_viewer_name(pmmcw_meta.get("save_name")) - self._viewer_tab.addTab(self._current_viewer, viewer_name) - self._viewer_tab.setCurrentWidget(self._current_viewer) - - # call it manually instead in _connect_viewer because this signal has been - # emitted already - self._current_viewer.data.sequenceStarted(sequence, meta) - - self._enable_menubar(False) - - # connect the signals - self._connect_viewer(self._current_viewer) - - # update the viewers variable in the console with the new viewer - self._add_viewer_to_mm_console(viewer_name, self._current_viewer) - - def _get_viewer_name(self, viewer_name: str | None) -> str: - """Get the viewer name from the metadata. - - If viewer_name is None, get the highest index for the viewer name. Otherwise, - return the viewer name. - """ - if viewer_name: - return viewer_name - - # loop through the tabs and get the highest index for the viewer name - index = 0 - for v in range(self._viewer_tab.count()): - tab_name = self._viewer_tab.tabText(v) - if tab_name.startswith(MDA_VIEWER): - idx = tab_name.replace(f"{MDA_VIEWER} ", "") - if idx.isdigit(): - index = max(index, int(idx)) - return f"{MDA_VIEWER} {index + 1}" - - def _on_sequence_finished(self, sequence: useq.MDASequence) -> None: - """Hide the MDAViewer when the MDA sequence finishes.""" - self._main_window._menu_bar._enable(True) - - self._mda_running = False - - if self._current_viewer is None: - return - - self._enable_menubar(True) - - # call it before we disconnect the signals or it will not be called - self._current_viewer.data.sequenceFinished(sequence) - - self._disconnect_viewer(self._current_viewer) - - self._current_viewer = None - - def _connect_viewer(self, viewer: MDAViewer) -> None: - self._mmc.mda.events.sequenceFinished.connect(viewer.data.sequenceFinished) - self._mmc.mda.events.frameReady.connect(viewer.data.frameReady) - - def _disconnect_viewer(self, viewer: MDAViewer) -> None: - """Disconnect the signals.""" - self._mmc.mda.events.frameReady.disconnect(viewer.data.frameReady) - self._mmc.mda.events.sequenceFinished.disconnect(viewer.data.sequenceFinished) - - def _enable_menubar(self, state: bool) -> None: - """Enable or disable the GUI.""" - self._main_window._menu_bar._enable(state) - - def _set_preview_tab(self) -> None: - """Set the preview tab.""" - if self._mda_running: - return - self._viewer_tab.setCurrentWidget(self._preview) - - def _get_mm_console(self) -> MMConsole | None: - """Rertun the MMConsole if it exists.""" - return self._main_window._menu_bar._mm_console - - def _add_viewer_to_mm_console( - self, viewer_name: str, mda_viewer: MDAViewer - ) -> None: - """Update the viewers variable in the MMConsole.""" - if console := self._get_mm_console(): - if VIEWERS not in console.get_user_variables(): - return - console.shell.user_ns[VIEWERS].update({viewer_name: mda_viewer}) diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 70708df9..8c122b18 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -1,7 +1,27 @@ from __future__ import annotations +from typing import cast + from pymmcore_plus import CMMCorePlus -from qtpy.QtWidgets import QMainWindow +from pymmcore_widgets import ImagePreview +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QDockWidget, + QMainWindow, + QMenu, + QMenuBar, + QVBoxLayout, + QWidget, +) + +from .actions._core_actions import CORE_ACTIONS +from .widgets._mm_console import MMConsole +from .widgets._toolbars import OCToolBar + + +class WindowMenu(QMenu): + def __init__(self, parent: QWidget | None = None): + super().__init__("Window", parent) class MicroManagerGUI(QMainWindow): @@ -11,10 +31,45 @@ def __init__( self, *, mmcore: CMMCorePlus | None = None, config: str | None = None ) -> None: super().__init__() - self.setAcceptDrops(True) self.setWindowTitle("Micro-Manager") # get global CMMCorePlus instance - self._mmc = mmcore or CMMCorePlus.instance() - self._mmc.loadSystemConfiguration() - print(self._mmc.getLoadedDevices()) + self._mmc = mmc = mmcore or CMMCorePlus.instance() + self._mmc.loadSystemConfiguration("tests/test_config.cfg") + + self._console = MMConsole(self) + import pymmcore_plus + import useq + + self._console.push( + { + **pymmcore_plus.__dict__, + **useq.__dict__, + "core": mmc, + "mmcore": mmc, + "mmc": mmc, + "window": self, + } + ) + dw = QDockWidget(self) + dw.setWidget(self._console) + self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, dw) + + mb = cast("QMenuBar", self.menuBar()) + self._window_menu = WindowMenu(self) + mb.addMenu(self._window_menu) + + # TOOLBARS ================================= + + if tb := self.addToolBar("File"): + for info in CORE_ACTIONS.values(): + tb.addAction(info.to_qaction(mmc, self)) + + self.addToolBar(OCToolBar(mmc, self)) + + # LAYOUT ====================================== + + central_wdg = QWidget(self) + layout = QVBoxLayout(central_wdg) + self.setCentralWidget(central_wdg) + layout.addWidget(ImagePreview(mmcore=self._mmc)) diff --git a/src/pymmcore_gui/actions/__init__.py b/src/pymmcore_gui/actions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/pymmcore_gui/actions/_action_info.py b/src/pymmcore_gui/actions/_action_info.py new file mode 100644 index 00000000..18645969 --- /dev/null +++ b/src/pymmcore_gui/actions/_action_info.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from PyQt6.QtCore import Qt + from PyQt6.QtGui import QAction, QIcon, QKeySequence + + +@dataclass +class ActionInfo: + key: Any + text: str | None = None + auto_repeat: bool = False + checkable: bool = False + checked: bool = False + enabled: bool = True + icon: QIcon | str | None = None + icon_text: str | None = None + icon_visible_in_menu: bool | None = None + menu_role: QAction.MenuRole | None = None + priority: QAction.Priority | None = None + shortcut: str | QKeySequence | None = None + shortcut_context: Qt.ShortcutContext | None = None + shortcut_visible_in_context_menu: bool | None = None + status_top: str | None = None + tooltip: str | None = None + visible: bool = True + whats_this: str | None = None diff --git a/src/pymmcore_gui/actions/_core_actions.py b/src/pymmcore_gui/actions/_core_actions.py new file mode 100644 index 00000000..6f46b170 --- /dev/null +++ b/src/pymmcore_gui/actions/_core_actions.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from pymmcore_plus import CMMCorePlus +from PyQt6.QtGui import QAction, QIcon +from superqt import QIconifyIcon +from zmq import Enum + +from . import _core_functions as cf +from ._action_info import ActionInfo + +if TYPE_CHECKING: + from collections.abc import Callable + + from PyQt6.QtCore import QObject + + from ._core_functions import CoreFunc + + +# -------------------------- QCoreAction ------------------------------ + + +class QInfoAction(QAction): + def apply_info(self, info: ActionInfo) -> None: + """Apply settings from a `CoreActionInfo` object to the QAction.""" + if info.key: + self.setText(info.key) + + if info.auto_repeat is not None: + self.setAutoRepeat(info.auto_repeat) + if info.checkable is not None: + self.setCheckable(info.checkable) + if info.checked is not None: + self.setChecked(info.checked) + if info.enabled is not None: + self.setEnabled(info.enabled) + if info.icon is not None: + if isinstance(info.icon, str): + icon: QIcon = QIconifyIcon(info.icon) + else: + icon = QIcon(info.icon) + self.setIcon(icon) + if info.icon_text is not None: + self.setIconText(info.icon_text) + if info.icon_visible_in_menu is not None: + self.setIconVisibleInMenu(info.icon_visible_in_menu) + if info.menu_role is not None: + self.setMenuRole(info.menu_role) + if info.priority is not None: + self.setPriority(info.priority) + if info.shortcut is not None: + self.setShortcut(info.shortcut) + if info.shortcut_context is not None: + self.setShortcutContext(info.shortcut_context) + if info.shortcut_visible_in_context_menu is not None: + self.setShortcutVisibleInContextMenu(info.shortcut_visible_in_context_menu) + if info.status_top is not None: + self.setStatusTip(info.status_top) + if info.tooltip is not None: + self.setToolTip(info.tooltip) + if info.visible is not None: + self.setVisible(info.visible) + if info.whats_this is not None: + self.setWhatsThis(info.whats_this) + + +class QCoreAction(QInfoAction): + """QAction that can act on a CMMCorePlus instance.""" + + def __init__( + self, + mmc: CMMCorePlus | None = None, + parent: QObject | None = None, + info: CoreActionInfo | None = None, + ) -> None: + super().__init__(parent) + self.mmc = mmc or CMMCorePlus.instance() + self._triggered_callback: CoreFunc | None = None + self.triggered.connect(self._on_triggered) + if info is not None: + self.apply_info(info) + + def apply_info(self, info: CoreActionInfo) -> None: + """Apply settings from a `CoreActionInfo` object to the QAction.""" + super().apply_info(info) + self._triggered_callback = info.on_triggered + + if info.on_created is not None: + info.on_created(self, self.mmc) + + def _on_triggered(self, checked: bool) -> None: + if self._triggered_callback is not None: + self._triggered_callback(self.mmc) + + +# -------------------------- CoreActionInfo ------------------------------ + + +@dataclass +class CoreActionInfo(ActionInfo): + """Information for creating a QCoreAction.""" + + key: CoreAction + + # called when triggered + on_triggered: CoreFunc | None = None + # called when QAction is created, can be used to connect stuff + on_created: Callable[[QCoreAction, CMMCorePlus], Any] | None = None + + def mark_on_created( + self, f: Callable[[QCoreAction, CMMCorePlus], Any] + ) -> Callable[[QCoreAction, CMMCorePlus], Any]: + """Decorator to mark a function to call when the QAction is created.""" + self.on_created = f + return f + + def to_qaction( + self, mmc: CMMCorePlus | None = None, parent: QObject | None = None + ) -> QCoreAction: + """Create a QCoreAction from this info.""" + return QCoreAction(mmc, parent, info=self) + + +# ------------------------------ Registry of Actions ------------------------ + + +class CoreAction(str, Enum): + """A registry of core actions.""" + + SNAP = "Snap Image" + TOGGLE_LIVE = "Toggle Live" + + def __str__(self) -> str: + """Return value as the string representation.""" + return str(self.value) + + +snap_action = CoreActionInfo( + key=CoreAction.SNAP, + shortcut="Ctrl+K", + auto_repeat=True, + icon="mdi-light:camera", + on_triggered=cf.snap_image, +) + + +live_action = CoreActionInfo( + key=CoreAction.TOGGLE_LIVE, + shortcut="Ctrl+L", + auto_repeat=True, + icon="mdi:video-outline", + on_triggered=cf.toggle_live, + checkable=True, +) + + +@live_action.mark_on_created +def _(action: QCoreAction, mmc: CMMCorePlus) -> None: + def _on_change() -> None: + action.setChecked(mmc.isSequenceRunning()) + + mmc.events.sequenceAcquisitionStarted.connect(_on_change) + mmc.events.continuousSequenceAcquisitionStarted.connect(_on_change) + mmc.events.sequenceAcquisitionStopped.connect(_on_change) + + +# just gather up all the CoreActionInfos we declared in this module +CORE_ACTIONS = { + act.key: act for act in globals().values() if isinstance(act, CoreActionInfo) +} diff --git a/src/pymmcore_gui/actions/_core_functions.py b/src/pymmcore_gui/actions/_core_functions.py new file mode 100644 index 00000000..02a938ca --- /dev/null +++ b/src/pymmcore_gui/actions/_core_functions.py @@ -0,0 +1,35 @@ +"""Functions that accept a single core instance and do something.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pymmcore_plus._pymmcore import CMMCore + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any, TypeAlias + + from pymmcore_plus import CMMCorePlus + + CoreFunc: TypeAlias = Callable[[CMMCorePlus], Any] + + +def snap_image(mmc: CMMCore) -> None: + """Snap an image, stopping sequence if running.""" + if mmc.isSequenceRunning(): + mmc.stopSequenceAcquisition() + mmc.snapImage() + + +def toggle_live(mmc: CMMCore) -> None: + """Start or stop live mode.""" + if mmc.isSequenceRunning(): + mmc.stopSequenceAcquisition() + else: + mmc.startContinuousSequenceAcquisition(0) + + +clear_roi = CMMCore.clearROI +clear_circular_buffer = CMMCore.clearCircularBuffer +full_focus = CMMCore.fullFocus diff --git a/src/pymmcore_gui/actions/_widget_actions.py b/src/pymmcore_gui/actions/_widget_actions.py new file mode 100644 index 00000000..e69de29b diff --git a/src/pymmcore_gui/widgets/__init__.py b/src/pymmcore_gui/widgets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/pymmcore_gui/widgets/_mm_console.py b/src/pymmcore_gui/widgets/_mm_console.py new file mode 100644 index 00000000..12f54054 --- /dev/null +++ b/src/pymmcore_gui/widgets/_mm_console.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING, Any, cast + +os.environ["PYDEVD_DISABLE_FILE_VALIDATION"] = "1" +from qtconsole.inprocess import QtInProcessKernelManager +from qtconsole.rich_jupyter_widget import RichJupyterWidget + +if TYPE_CHECKING: + from PyQt6.QtGui import QCloseEvent + from PyQt6.QtWidgets import QWidget + + +class MMConsole(RichJupyterWidget): + """A Qt widget for an IPython console, providing access to UI components.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent=parent) + + cast("QWidget", self).setWindowTitle("Python kernel") + self.set_default_style(colors="linux") + + # this makes calling `setFocus()` on a QtConsole give keyboard focus to + # the underlying `QTextEdit` widget + cast("QWidget", self).setFocusProxy(self._control) + + # Create an in-process kernel + self.kernel_manager = QtInProcessKernelManager() + self.kernel_manager.start_kernel(show_banner=False) + self.kernel_manager.kernel.gui = "qt" + self.kernel_client = self.kernel_manager.client() + self.kernel_client.start_channels() + self.shell = self.kernel_manager.kernel.shell + + self.shell.run_cell("from rich import pretty; pretty.install()") + self.shell.run_cell("from rich import print") + + def push(self, variables: dict[str, Any]) -> None: + self.shell.push(variables) + + def get_user_variables(self) -> dict: + """Return the variables pushed to the console.""" + return {k: v for k, v in self.shell.user_ns.items() if k != "__builtins__"} + + def closeEvent(self, event: QCloseEvent) -> None: + """Clean up the integrated console.""" + if self.kernel_client is not None: + self.kernel_client.stop_channels() + if self.kernel_manager is not None and self.kernel_manager.has_kernel: + self.kernel_manager.shutdown_kernel() + + # RichJupyterWidget doesn't clean these up + self._completion_widget.deleteLater() + self._call_tip_widget.deleteLater() + cast("QWidget", self).deleteLater() + event.accept() diff --git a/src/pymmcore_gui/widgets/_toolbars.py b/src/pymmcore_gui/widgets/_toolbars.py new file mode 100644 index 00000000..241ba3ac --- /dev/null +++ b/src/pymmcore_gui/widgets/_toolbars.py @@ -0,0 +1,43 @@ +from pymmcore_plus import CMMCorePlus +from PyQt6.QtWidgets import QToolBar, QWidget + + +class OCToolBar(QToolBar): + """A toolbar that allows selection of current channel. + + e.g: + | DAPI | FITC | Cy5 | + """ + + def __init__(self, mmc: CMMCorePlus, parent: QWidget | None = None) -> None: + super().__init__("Optical Configs", parent) + self.mmc = mmc + mmc.events.systemConfigurationLoaded.connect(self._refresh) + mmc.events.configGroupChanged.connect(self._refresh) + mmc.events.channelGroupChanged.connect(self._refresh) + mmc.events.configSet.connect(self._on_config_set) + + self._refresh() + + def _on_config_set(self, group: str, config: str) -> None: + if group == self.mmc.getChannelGroup(): + for action in self.actions(): + action.setChecked(action.text() == config) + + def _refresh(self) -> None: + """Clear and refresh with all settings in current channel group.""" + self.clear() + mmc = self.mmc + if not (ch_group := mmc.getChannelGroup()): + return + + current = mmc.getCurrentConfig(ch_group) + for preset_name in mmc.getAvailableConfigs(ch_group): + if not (action := self.addAction(preset_name)): + continue + action.setCheckable(True) + action.setChecked(preset_name == current) + + @action.triggered.connect + def _(checked: bool, pname: str = preset_name) -> None: + mmc.setConfig(ch_group, pname) diff --git a/tests/conftest.py b/tests/conftest.py index 3963d90d..2992c0f9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,8 +21,8 @@ if TYPE_CHECKING: from collections.abc import Iterator + from PyQt6.QtWidgets import QApplication from pytest import FixtureRequest - from qtpy.QtWidgets import QApplication TEST_CONFIG = str(Path(__file__).parent / "test_config.cfg") From 6ec030f5ec653be13c695eb256e4266a877145b0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 22 Dec 2024 17:35:52 -0500 Subject: [PATCH 187/226] pattern for widgets --- .pre-commit-config.yaml | 1 + pyproject.toml | 3 + src/pymmcore_gui/_main_window.py | 154 ++++++++++++++---- src/pymmcore_gui/actions/__init__.py | 15 ++ src/pymmcore_gui/actions/_action_info.py | 82 +++++++++- src/pymmcore_gui/actions/_core_actions.py | 172 -------------------- src/pymmcore_gui/actions/_core_functions.py | 35 ---- src/pymmcore_gui/actions/_core_qaction.py | 84 ++++++++++ src/pymmcore_gui/actions/_widget_actions.py | 0 src/pymmcore_gui/actions/core_actions.py | 70 ++++++++ src/pymmcore_gui/actions/widget_actions.py | 117 +++++++++++++ src/pymmcore_gui/widgets/_mm_console.py | 56 ++++++- 12 files changed, 539 insertions(+), 250 deletions(-) delete mode 100644 src/pymmcore_gui/actions/_core_actions.py delete mode 100644 src/pymmcore_gui/actions/_core_functions.py create mode 100644 src/pymmcore_gui/actions/_core_qaction.py delete mode 100644 src/pymmcore_gui/actions/_widget_actions.py create mode 100644 src/pymmcore_gui/actions/core_actions.py create mode 100644 src/pymmcore_gui/actions/widget_actions.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6817de2d..70eaaa5f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,3 +29,4 @@ repos: files: "^src/" additional_dependencies: - pymmcore-plus >=0.11.0 + - PyQt6 diff --git a/pyproject.toml b/pyproject.toml index fd0c74db..4521d902 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -146,3 +146,6 @@ ignore = [".pre-commit-config.yaml", ".ruff_cache/**/*", "tests/**/*"] [tool.typos.default] extend-ignore-identifiers-re = ["(?i)nd2?.*", "(?i)ome"] + +[tool.typos.files] +extend-exclude = ["*.spec", "hooks/"] \ No newline at end of file diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 8c122b18..77fd9b1a 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -1,71 +1,96 @@ from __future__ import annotations -from typing import cast +from collections import ChainMap +from typing import TYPE_CHECKING, cast +from weakref import WeakValueDictionary from pymmcore_plus import CMMCorePlus from pymmcore_widgets import ImagePreview -from PyQt6.QtCore import Qt +from PyQt6.QtGui import QAction, QCloseEvent from PyQt6.QtWidgets import ( + QDialog, QDockWidget, QMainWindow, QMenu, QMenuBar, + QToolBar, QVBoxLayout, QWidget, ) -from .actions._core_actions import CORE_ACTIONS -from .widgets._mm_console import MMConsole -from .widgets._toolbars import OCToolBar +from pymmcore_gui.actions._core_qaction import QCoreAction +from .actions import ActionInfo, CoreAction, WidgetAction +from .actions._action_info import ActionKey +from .widgets._toolbars import OCToolBar -class WindowMenu(QMenu): - def __init__(self, parent: QWidget | None = None): - super().__init__("Window", parent) +if TYPE_CHECKING: + from collections.abc import Callable, Mapping class MicroManagerGUI(QMainWindow): """Micro-Manager minimal GUI.""" + TOOLBARS: Mapping[ + str, list[ActionKey] | Callable[[CMMCorePlus, QMainWindow], QToolBar] + ] = { + "Camera Actions": [CoreAction.SNAP, CoreAction.TOGGLE_LIVE], + "Optical Configs": OCToolBar, + "Widgets": [WidgetAction.CONSOLE], + } + MENUS: Mapping[ + str, list[ActionKey] | Callable[[CMMCorePlus, QMainWindow], QMenu] + ] = { + "Window": [WidgetAction.CONSOLE, WidgetAction.PROP_BROWSER], + } + def __init__( self, *, mmcore: CMMCorePlus | None = None, config: str | None = None ) -> None: super().__init__() - self.setWindowTitle("Micro-Manager") + self.setWindowTitle("Mike") + self.setObjectName("MicroManagerGUI") + + # Serves to cache created QAction objects so that they can be re-used + # when the same action is requested multiple times. This is useful to + # synchronize the state of actions that may appear in multiple menus or + # toolbars. + self._qactions = WeakValueDictionary[ActionKey, QAction]() + self._inner_widgets = WeakValueDictionary[ActionKey, QWidget]() + self._dock_widgets = WeakValueDictionary[ActionKey, QDockWidget]() + self._qwidgets = ChainMap[ActionKey, QWidget]( + self._dock_widgets, # type: ignore [arg-type] + self._inner_widgets, + ) # get global CMMCorePlus instance self._mmc = mmc = mmcore or CMMCorePlus.instance() - self._mmc.loadSystemConfiguration("tests/test_config.cfg") - - self._console = MMConsole(self) - import pymmcore_plus - import useq - - self._console.push( - { - **pymmcore_plus.__dict__, - **useq.__dict__, - "core": mmc, - "mmcore": mmc, - "mmc": mmc, - "window": self, - } - ) - dw = QDockWidget(self) - dw.setWidget(self._console) - self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, dw) + self._mmc.loadSystemConfiguration(config or "tests/test_config.cfg") + + # MENUS ==================================== + # To add menus or menu items, add them to the MENUS dict above mb = cast("QMenuBar", self.menuBar()) - self._window_menu = WindowMenu(self) - mb.addMenu(self._window_menu) + for name, entry in self.MENUS.items(): + if callable(entry): + menu = entry(mmc, self) + mb.addMenu(menu) + else: + menu = cast("QMenu", mb.addMenu(name)) + for action in entry: + menu.addAction(self.get_action(action)) # TOOLBARS ================================= + # To add toolbars or toolbar items, add them to the TOOLBARS dict above - if tb := self.addToolBar("File"): - for info in CORE_ACTIONS.values(): - tb.addAction(info.to_qaction(mmc, self)) - - self.addToolBar(OCToolBar(mmc, self)) + for name, tb_entry in self.TOOLBARS.items(): + if callable(tb_entry): + tb = tb_entry(mmc, self) + self.addToolBar(tb) + else: + tb = cast("QToolBar", self.addToolBar(name)) + for action in tb_entry: + tb.addAction(self.get_action(action)) # LAYOUT ====================================== @@ -73,3 +98,62 @@ def __init__( layout = QVBoxLayout(central_wdg) self.setCentralWidget(central_wdg) layout.addWidget(ImagePreview(mmcore=self._mmc)) + + @property + def mmc(self) -> CMMCorePlus: + return self._mmc + + def get_action(self, key: ActionKey) -> QAction: + """Create a QAction from this key.""" + if key not in self._qactions: + # create and cache it + info = ActionInfo.for_key(key) + self._qactions[key] = action = info.to_qaction(self._mmc, self) + # connect WidgetActions to toggle their widgets + if isinstance(action.key, WidgetAction): + action.triggered.connect(self._toggle_action_widget) + + return self._qactions[key] + + def get_widget(self, key: WidgetAction) -> QWidget: + """Create a QWidget from this key.""" + if key not in self._qwidgets: + self._inner_widgets[key] = widget = key.create_widget(self) + + # override closeEvent to uncheck the corresponding QAction + # FIXME: this still doesn't work for some QDialogs + def _closeEvent(a0: QCloseEvent | None = None) -> None: + if action := self._qactions.get(key): + action.setChecked(False) + if isinstance(a0, QCloseEvent): + superCloseEvent(a0) + + superCloseEvent = widget.closeEvent + widget.closeEvent = _closeEvent # type: ignore [method-assign] + + # also hook up QDialog's finished signal to closeEvent + if isinstance(widget, QDialog): + widget.finished.connect(_closeEvent) + + if dock_area := key.dock_area(): + self._dock_widgets[key] = dw = QDockWidget(key.value, self) + dw.setWidget(widget) + self.addDockWidget(dock_area, dw) + + return self._qwidgets[key] + + def _toggle_action_widget(self, checked: bool) -> None: + """Callback that toggles the visibility of a widget. + + This is connected to the triggered signal of WidgetAction QActions above in + `get_action`, so it is assumed that the sender is a QCoreAction with a + WidgetAction key. Calling otherwise will do nothing. + """ + if not ( + isinstance(action := self.sender(), QCoreAction) + and isinstance((key := action.key), WidgetAction) + ): + return + + widget = self.get_widget(key) + widget.setVisible(checked) diff --git a/src/pymmcore_gui/actions/__init__.py b/src/pymmcore_gui/actions/__init__.py index e69de29b..6ab1cde7 100644 --- a/src/pymmcore_gui/actions/__init__.py +++ b/src/pymmcore_gui/actions/__init__.py @@ -0,0 +1,15 @@ +# these MUST be imported here in order to actually instantiate the actions +# and register them with the ActionInfo registry +from . import core_actions, widget_actions +from ._action_info import ActionInfo, ActionKey +from .core_actions import CoreAction +from .widget_actions import WidgetAction + +__all__ = [ + "ActionInfo", + "ActionKey", + "CoreAction", + "WidgetAction", + "core_actions", + "widget_actions", +] diff --git a/src/pymmcore_gui/actions/_action_info.py b/src/pymmcore_gui/actions/_action_info.py index 18645969..59b8aba6 100644 --- a/src/pymmcore_gui/actions/_action_info.py +++ b/src/pymmcore_gui/actions/_action_info.py @@ -1,16 +1,50 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from enum import Enum +from typing import TYPE_CHECKING, ClassVar, Generic, TypeVar + +from pymmcore_plus import CMMCorePlus +from PyQt6.QtCore import QObject +from PyQt6.QtGui import QAction + +from pymmcore_gui.actions._core_qaction import QCoreAction if TYPE_CHECKING: - from PyQt6.QtCore import Qt + from collections.abc import Callable + from typing import Any, TypeAlias + + from pymmcore_plus import CMMCorePlus + from PyQt6.QtCore import QObject, Qt from PyQt6.QtGui import QAction, QIcon, QKeySequence + CoreActionFunc: TypeAlias = Callable[[QCoreAction], Any] + ActionTriggeredFunc: TypeAlias = Callable[[QCoreAction, bool], Any] + +AK = TypeVar("AK", bound="ActionKey") + + +class ActionKey(Enum): + """A Key representing an action in the GUI. + + This is subclassed in core_actions, widget_actions, etc. to provide a unique key + for each action. + """ + + def __str__(self) -> str: + """Return value as the string representation.""" + return str(self.value) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}.{self.name}" + @dataclass -class ActionInfo: - key: Any +class ActionInfo(Generic[AK]): + """Information for creating a QCoreAction.""" + + key: AK + text: str | None = None auto_repeat: bool = False checkable: bool = False @@ -28,3 +62,43 @@ class ActionInfo: tooltip: str | None = None visible: bool = True whats_this: str | None = None + + # called when triggered + on_triggered: ActionTriggeredFunc | None = None + # called when QAction is created, can be used to connect stuff + on_created: CoreActionFunc | None = None + + # global registry of all Action + _registry: ClassVar[dict[ActionKey, ActionInfo]] = {} + _action_cls: ClassVar[type[QCoreAction]] = QCoreAction + + def __post_init__(self) -> None: + ActionInfo._registry[self.key] = self + + def mark_on_created(self, f: CoreActionFunc) -> CoreActionFunc: + """Decorator to mark a function to call when the QAction is created.""" + self.on_created = f + return f + + def to_qaction( + self, mmc: CMMCorePlus, parent: QObject | None = None + ) -> QCoreAction: + """Create a QCoreAction from this info.""" + return self._action_cls(mmc, self, parent) + + @classmethod + def for_key(cls, key: ActionKey) -> ActionInfo: + """Get the ActionInfo for a given key.""" + try: + return ActionInfo._registry[key] + except KeyError as e: # pragma: no cover + key_type = type(key).__name__ + parent_module = __name__.rsplit(".", 1)[0] + if key_type == "WidgetAction": + module = f"{parent_module}.widget_actions" + else: + module = f"{parent_module}.core_actions" + raise KeyError( + f"No 'ActionInfo' has been declared for key '{key_type}.{key.name}'." + f"Please create one in {module}" + ) from e diff --git a/src/pymmcore_gui/actions/_core_actions.py b/src/pymmcore_gui/actions/_core_actions.py deleted file mode 100644 index 6f46b170..00000000 --- a/src/pymmcore_gui/actions/_core_actions.py +++ /dev/null @@ -1,172 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - -from pymmcore_plus import CMMCorePlus -from PyQt6.QtGui import QAction, QIcon -from superqt import QIconifyIcon -from zmq import Enum - -from . import _core_functions as cf -from ._action_info import ActionInfo - -if TYPE_CHECKING: - from collections.abc import Callable - - from PyQt6.QtCore import QObject - - from ._core_functions import CoreFunc - - -# -------------------------- QCoreAction ------------------------------ - - -class QInfoAction(QAction): - def apply_info(self, info: ActionInfo) -> None: - """Apply settings from a `CoreActionInfo` object to the QAction.""" - if info.key: - self.setText(info.key) - - if info.auto_repeat is not None: - self.setAutoRepeat(info.auto_repeat) - if info.checkable is not None: - self.setCheckable(info.checkable) - if info.checked is not None: - self.setChecked(info.checked) - if info.enabled is not None: - self.setEnabled(info.enabled) - if info.icon is not None: - if isinstance(info.icon, str): - icon: QIcon = QIconifyIcon(info.icon) - else: - icon = QIcon(info.icon) - self.setIcon(icon) - if info.icon_text is not None: - self.setIconText(info.icon_text) - if info.icon_visible_in_menu is not None: - self.setIconVisibleInMenu(info.icon_visible_in_menu) - if info.menu_role is not None: - self.setMenuRole(info.menu_role) - if info.priority is not None: - self.setPriority(info.priority) - if info.shortcut is not None: - self.setShortcut(info.shortcut) - if info.shortcut_context is not None: - self.setShortcutContext(info.shortcut_context) - if info.shortcut_visible_in_context_menu is not None: - self.setShortcutVisibleInContextMenu(info.shortcut_visible_in_context_menu) - if info.status_top is not None: - self.setStatusTip(info.status_top) - if info.tooltip is not None: - self.setToolTip(info.tooltip) - if info.visible is not None: - self.setVisible(info.visible) - if info.whats_this is not None: - self.setWhatsThis(info.whats_this) - - -class QCoreAction(QInfoAction): - """QAction that can act on a CMMCorePlus instance.""" - - def __init__( - self, - mmc: CMMCorePlus | None = None, - parent: QObject | None = None, - info: CoreActionInfo | None = None, - ) -> None: - super().__init__(parent) - self.mmc = mmc or CMMCorePlus.instance() - self._triggered_callback: CoreFunc | None = None - self.triggered.connect(self._on_triggered) - if info is not None: - self.apply_info(info) - - def apply_info(self, info: CoreActionInfo) -> None: - """Apply settings from a `CoreActionInfo` object to the QAction.""" - super().apply_info(info) - self._triggered_callback = info.on_triggered - - if info.on_created is not None: - info.on_created(self, self.mmc) - - def _on_triggered(self, checked: bool) -> None: - if self._triggered_callback is not None: - self._triggered_callback(self.mmc) - - -# -------------------------- CoreActionInfo ------------------------------ - - -@dataclass -class CoreActionInfo(ActionInfo): - """Information for creating a QCoreAction.""" - - key: CoreAction - - # called when triggered - on_triggered: CoreFunc | None = None - # called when QAction is created, can be used to connect stuff - on_created: Callable[[QCoreAction, CMMCorePlus], Any] | None = None - - def mark_on_created( - self, f: Callable[[QCoreAction, CMMCorePlus], Any] - ) -> Callable[[QCoreAction, CMMCorePlus], Any]: - """Decorator to mark a function to call when the QAction is created.""" - self.on_created = f - return f - - def to_qaction( - self, mmc: CMMCorePlus | None = None, parent: QObject | None = None - ) -> QCoreAction: - """Create a QCoreAction from this info.""" - return QCoreAction(mmc, parent, info=self) - - -# ------------------------------ Registry of Actions ------------------------ - - -class CoreAction(str, Enum): - """A registry of core actions.""" - - SNAP = "Snap Image" - TOGGLE_LIVE = "Toggle Live" - - def __str__(self) -> str: - """Return value as the string representation.""" - return str(self.value) - - -snap_action = CoreActionInfo( - key=CoreAction.SNAP, - shortcut="Ctrl+K", - auto_repeat=True, - icon="mdi-light:camera", - on_triggered=cf.snap_image, -) - - -live_action = CoreActionInfo( - key=CoreAction.TOGGLE_LIVE, - shortcut="Ctrl+L", - auto_repeat=True, - icon="mdi:video-outline", - on_triggered=cf.toggle_live, - checkable=True, -) - - -@live_action.mark_on_created -def _(action: QCoreAction, mmc: CMMCorePlus) -> None: - def _on_change() -> None: - action.setChecked(mmc.isSequenceRunning()) - - mmc.events.sequenceAcquisitionStarted.connect(_on_change) - mmc.events.continuousSequenceAcquisitionStarted.connect(_on_change) - mmc.events.sequenceAcquisitionStopped.connect(_on_change) - - -# just gather up all the CoreActionInfos we declared in this module -CORE_ACTIONS = { - act.key: act for act in globals().values() if isinstance(act, CoreActionInfo) -} diff --git a/src/pymmcore_gui/actions/_core_functions.py b/src/pymmcore_gui/actions/_core_functions.py deleted file mode 100644 index 02a938ca..00000000 --- a/src/pymmcore_gui/actions/_core_functions.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Functions that accept a single core instance and do something.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from pymmcore_plus._pymmcore import CMMCore - -if TYPE_CHECKING: - from collections.abc import Callable - from typing import Any, TypeAlias - - from pymmcore_plus import CMMCorePlus - - CoreFunc: TypeAlias = Callable[[CMMCorePlus], Any] - - -def snap_image(mmc: CMMCore) -> None: - """Snap an image, stopping sequence if running.""" - if mmc.isSequenceRunning(): - mmc.stopSequenceAcquisition() - mmc.snapImage() - - -def toggle_live(mmc: CMMCore) -> None: - """Start or stop live mode.""" - if mmc.isSequenceRunning(): - mmc.stopSequenceAcquisition() - else: - mmc.startContinuousSequenceAcquisition(0) - - -clear_roi = CMMCore.clearROI -clear_circular_buffer = CMMCore.clearCircularBuffer -full_focus = CMMCore.fullFocus diff --git a/src/pymmcore_gui/actions/_core_qaction.py b/src/pymmcore_gui/actions/_core_qaction.py new file mode 100644 index 00000000..4231e2e8 --- /dev/null +++ b/src/pymmcore_gui/actions/_core_qaction.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from PyQt6.QtGui import QAction, QIcon +from superqt import QIconifyIcon + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from PyQt6.QtCore import QObject + + from ._action_info import ActionInfo, ActionKey, ActionTriggeredFunc + + +class QCoreAction(QAction): + """QAction that can act on a CMMCorePlus instance.""" + + key: ActionKey + + def __init__( + self, + mmc: CMMCorePlus, + info: ActionInfo | None = None, + parent: QObject | None = None, + ) -> None: + super().__init__(parent) + self.mmc = mmc + self._triggered_callback: ActionTriggeredFunc | None = None + self.triggered.connect(self._on_triggered) + if info is not None: + self.apply_info(info) + + def __repr__(self) -> str: + return f"<{type(self).__name__} for {self.key!r} at {id(self):#x}>" + + def apply_info(self, info: ActionInfo) -> None: + """Apply settings from a `CoreActionInfo` object to the QAction.""" + self.key = info.key + + self.setText(info.text or str(info.key)) + if info.auto_repeat is not None: + self.setAutoRepeat(info.auto_repeat) + if info.checkable is not None: + self.setCheckable(info.checkable) + if info.checked is not None: + self.setChecked(info.checked) + if info.enabled is not None: + self.setEnabled(info.enabled) + if info.icon is not None: + if isinstance(info.icon, str): + icon: QIcon = QIconifyIcon(info.icon) + else: + icon = QIcon(info.icon) + self.setIcon(icon) + if info.icon_text is not None: + self.setIconText(info.icon_text) + if info.icon_visible_in_menu is not None: + self.setIconVisibleInMenu(info.icon_visible_in_menu) + if info.menu_role is not None: + self.setMenuRole(info.menu_role) + if info.priority is not None: + self.setPriority(info.priority) + if info.shortcut is not None: + self.setShortcut(info.shortcut) + if info.shortcut_context is not None: + self.setShortcutContext(info.shortcut_context) + if info.shortcut_visible_in_context_menu is not None: + self.setShortcutVisibleInContextMenu(info.shortcut_visible_in_context_menu) + if info.status_top is not None: + self.setStatusTip(info.status_top) + if info.tooltip is not None: + self.setToolTip(info.tooltip) + if info.visible is not None: + self.setVisible(info.visible) + if info.whats_this is not None: + self.setWhatsThis(info.whats_this) + + self._triggered_callback = info.on_triggered + if info.on_created is not None: + info.on_created(self) + + def _on_triggered(self, checked: bool) -> None: + if self._triggered_callback is not None: + self._triggered_callback(self, checked) diff --git a/src/pymmcore_gui/actions/_widget_actions.py b/src/pymmcore_gui/actions/_widget_actions.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/pymmcore_gui/actions/core_actions.py b/src/pymmcore_gui/actions/core_actions.py new file mode 100644 index 00000000..8356db3a --- /dev/null +++ b/src/pymmcore_gui/actions/core_actions.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ._action_info import ActionInfo, ActionKey + +if TYPE_CHECKING: + from ._core_qaction import QCoreAction + + +class CoreAction(ActionKey): + """Actions that act on the global CMMCore instance.""" + + SNAP = "Snap Image" + TOGGLE_LIVE = "Toggle Live" + + +# ######################## Functions acting on the Core ######################### + + +# TODO: perhaps have alternate signatures for these functions that take a +# CMMCorePlus instance, rather than needing to extract it from the QCoreAction. +def snap_image(action: QCoreAction, checked: bool) -> None: + """Snap an image, stopping sequence if running.""" + mmc = action.mmc + if mmc.isSequenceRunning(): + mmc.stopSequenceAcquisition() + mmc.snapImage() + + +def toggle_live(action: QCoreAction, checked: bool) -> None: + """Start or stop live mode.""" + mmc = action.mmc + if mmc.isSequenceRunning(): + mmc.stopSequenceAcquisition() + else: + mmc.startContinuousSequenceAcquisition(0) + + +def _init_toggle_live(action: QCoreAction) -> None: + mmc = action.mmc + + def _on_change() -> None: + action.setChecked(mmc.isSequenceRunning()) + + mmc.events.sequenceAcquisitionStarted.connect(_on_change) + mmc.events.continuousSequenceAcquisitionStarted.connect(_on_change) + mmc.events.sequenceAcquisitionStopped.connect(_on_change) + + +# ########################## Action Info Instances ############################# + +snap_action = ActionInfo( + key=CoreAction.SNAP, + shortcut="Ctrl+K", + auto_repeat=True, + icon="mdi-light:camera", + on_triggered=snap_image, +) + + +toggle_live_action = ActionInfo( + key=CoreAction.TOGGLE_LIVE, + shortcut="Ctrl+L", + auto_repeat=True, + icon="mdi:video-outline", + checkable=True, + on_triggered=toggle_live, + on_created=_init_toggle_live, +) diff --git a/src/pymmcore_gui/actions/widget_actions.py b/src/pymmcore_gui/actions/widget_actions.py new file mode 100644 index 00000000..3beae1ed --- /dev/null +++ b/src/pymmcore_gui/actions/widget_actions.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +from pymmcore_plus import CMMCorePlus +from PyQt6.QtCore import Qt + +from pymmcore_gui.actions._action_info import ActionKey + +from ._action_info import ActionInfo + +if TYPE_CHECKING: + from collections.abc import Callable + + from pymmcore_widgets import PropertyBrowser + from PyQt6.QtCore import QObject + from PyQt6.QtWidgets import QWidget + + from pymmcore_gui._main_window import MicroManagerGUI + from pymmcore_gui.widgets._mm_console import MMConsole + + +# ######################## Functions that create widgets ######################### + + +def _get_mm_main_window(obj: QObject) -> MicroManagerGUI | None: + if obj.objectName() == "MicroManagerGUI": + return cast("MicroManagerGUI", obj) + parent = obj.parent() + while parent is not None: + if parent.objectName() == "MicroManagerGUI": + return cast("MicroManagerGUI", parent) + parent = parent.parent() + return None + + +def _get_core(obj: QObject) -> CMMCorePlus: + if win := _get_mm_main_window(obj): + return win.mmc + return CMMCorePlus.instance() + + +def create_property_browser(parent: QWidget) -> PropertyBrowser: + """Create a Property Browser widget.""" + from pymmcore_widgets import PropertyBrowser + + mmc = _get_core(parent) + wdg = PropertyBrowser(parent=parent, mmcore=mmc) + wdg.show() + return wdg + + +def create_mm_console(parent: QWidget) -> MMConsole: + """Create a console widget.""" + from pymmcore_gui.widgets._mm_console import MMConsole + + return MMConsole(parent=parent) + + +# ######################## WidgetAction Enum ######################### + + +class WidgetAction(ActionKey): + """Widget Actions toggle/create singleton widgets.""" + + PROP_BROWSER = "Property Browser" + PIXEL_CONFIG = "Pixel Configuration" + INSTALL_DEVICES = "Install Devices" + MDA_WIDGET = "MDA Widget" + CONFIG_GROUPS = "Config Groups" + CAMERA_ROI = "Camera ROI" + CONSOLE = "Console" + + def create_widget(self, parent: QWidget) -> QWidget: + """Create the widget associated with this action.""" + if self not in _CREATOR_MAP: # pragma: no cover + raise NotImplementedError(f"No constructor has been provided for {self!r}") + return _CREATOR_MAP[self](parent) + + def dock_area(self) -> Qt.DockWidgetArea | None: + """Return the default dock area for this widget.""" + return _DOCK_AREAS.get(self, Qt.DockWidgetArea.RightDockWidgetArea) + + +_CREATOR_MAP: dict[WidgetAction, Callable[[QWidget], QWidget]] = { + WidgetAction.CONSOLE: create_mm_console, + WidgetAction.PROP_BROWSER: create_property_browser, +} + +# preferred area for each widget. If None, no dock widget is set. +_DOCK_AREAS: dict[WidgetAction, Qt.DockWidgetArea | None] = { + WidgetAction.CONSOLE: Qt.DockWidgetArea.BottomDockWidgetArea, + WidgetAction.PROP_BROWSER: None, +} + +# ######################## WidgetActionInfos ######################### + + +@dataclass +class WidgetActionInfo(ActionInfo): + """Subclass to set default values for WidgetAction.""" + + checkable: bool = True + + +show_console = WidgetActionInfo( + key=WidgetAction.CONSOLE, + shortcut="Ctrl+Shift+C", + icon="iconoir:terminal", +) + +show_property_browser = WidgetActionInfo( + key=WidgetAction.PROP_BROWSER, + shortcut="Ctrl+Shift+P", + icon="mdi-light:format-list-bulleted", +) diff --git a/src/pymmcore_gui/widgets/_mm_console.py b/src/pymmcore_gui/widgets/_mm_console.py index 12f54054..e88a59c5 100644 --- a/src/pymmcore_gui/widgets/_mm_console.py +++ b/src/pymmcore_gui/widgets/_mm_console.py @@ -4,8 +4,16 @@ from typing import TYPE_CHECKING, Any, cast os.environ["PYDEVD_DISABLE_FILE_VALIDATION"] = "1" + +from PyQt6.QtWidgets import QApplication from qtconsole.inprocess import QtInProcessKernelManager from qtconsole.rich_jupyter_widget import RichJupyterWidget +from traitlets import default + +try: + import rich +except ImportError: + rich = None # type: ignore if TYPE_CHECKING: from PyQt6.QtGui import QCloseEvent @@ -27,14 +35,54 @@ def __init__(self, parent: QWidget | None = None) -> None: # Create an in-process kernel self.kernel_manager = QtInProcessKernelManager() - self.kernel_manager.start_kernel(show_banner=False) + self.kernel_manager.start_kernel() self.kernel_manager.kernel.gui = "qt" + self.shell = self.kernel_manager.kernel.shell + self.shell.banner1 = "" self.kernel_client = self.kernel_manager.client() self.kernel_client.start_channels() - self.shell = self.kernel_manager.kernel.shell - self.shell.run_cell("from rich import pretty; pretty.install()") - self.shell.run_cell("from rich import print") + if rich is not None: + self.shell.run_cell("from rich import pretty; pretty.install()") + self.shell.run_cell("from rich import print") + + self._inject_core_vars() + + def _inject_core_vars(self) -> None: + import numpy + import pymmcore_plus + import useq + + default_vars = { + **pymmcore_plus.__dict__, + **useq.__dict__, + "useq": useq, + "np": numpy, + } + mmc = None + for wdg in QApplication.topLevelWidgets(): + if wdg.objectName() == "MicroManagerGUI": + default_vars["window"] = wdg + mmc = getattr(wdg, "mmc", None) + break + + mmc = mmc or pymmcore_plus.CMMCorePlus.instance() + default_vars.update({"mmc": mmc, "core": mmc, "mmcore": mmc, "mda": mmc.mda}) + self.push(default_vars) + + @default("banner") # type: ignore [misc] + def _banner_default(self) -> str: + # Set the banner displayed at the top of the console + lines = [ + "Welcome to the pymmcore-plus console!", + "All top level pymmcore_plus and useq names are available.", + "", + "Use \033[1;33mmmc\033[0m (or \033[1;33mcore\033[0m) to interact with the CMMCorePlus instance.\n" # noqa: E501 + "Use \033[1;33mmda\033[0m to access the pymmcore_plus.MDARunner.", + ] + if "window" in self.shell.user_ns: + lines.append("Use \033[1;33mwindow\033[0m to interact with the MainWindow.") + return "\n".join(lines) def push(self, variables: dict[str, Any]) -> None: self.shell.push(variables) From d0a03e6eaee425b875027867b0acd931e3c7d6d9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sun, 22 Dec 2024 21:08:30 -0500 Subject: [PATCH 188/226] more stuff!! --- hooks/hook-debugpy.py | 4 + hooks/hook-pymmcore_widgets.py | 3 + hooks/hook-superqt.py | 5 + hooks/hook-vispy.py | 1 + mmgui.spec | 17 ++- src/pymmcore_gui/__init__.py | 3 +- src/pymmcore_gui/_app.py | 16 ++- src/pymmcore_gui/_main_window.py | 81 +++++++++++-- src/pymmcore_gui/actions/_action_info.py | 7 +- src/pymmcore_gui/actions/_core_qaction.py | 9 +- src/pymmcore_gui/actions/core_actions.py | 2 + src/pymmcore_gui/actions/widget_actions.py | 132 +++++++++++++++++---- src/pymmcore_gui/widgets/_exception_log.py | 33 ++++++ src/pymmcore_gui/widgets/_mm_console.py | 3 + 14 files changed, 278 insertions(+), 38 deletions(-) create mode 100644 hooks/hook-debugpy.py create mode 100644 hooks/hook-pymmcore_widgets.py create mode 100644 hooks/hook-superqt.py create mode 100644 src/pymmcore_gui/widgets/_exception_log.py diff --git a/hooks/hook-debugpy.py b/hooks/hook-debugpy.py new file mode 100644 index 00000000..8399af9e --- /dev/null +++ b/hooks/hook-debugpy.py @@ -0,0 +1,4 @@ +from PyInstaller.utils.hooks import collect_all, collect_submodules + +datas, binaries, hiddenimports = collect_all("debugpy") +hiddenimports += collect_submodules("xmlrpc") diff --git a/hooks/hook-pymmcore_widgets.py b/hooks/hook-pymmcore_widgets.py new file mode 100644 index 00000000..6a8488ea --- /dev/null +++ b/hooks/hook-pymmcore_widgets.py @@ -0,0 +1,3 @@ +from PyInstaller.utils.hooks import collect_data_files + +datas = collect_data_files("pymmcore_widgets") diff --git a/hooks/hook-superqt.py b/hooks/hook-superqt.py new file mode 100644 index 00000000..e245934c --- /dev/null +++ b/hooks/hook-superqt.py @@ -0,0 +1,5 @@ +from PyInstaller.utils.hooks import collect_data_files, collect_entry_point + +datas, hiddenimports = collect_entry_point("superqt.fonticon") +for hiddenimport in hiddenimports: + datas += collect_data_files(hiddenimport) diff --git a/hooks/hook-vispy.py b/hooks/hook-vispy.py index ffdedf4b..10c520d9 100644 --- a/hooks/hook-vispy.py +++ b/hooks/hook-vispy.py @@ -6,4 +6,5 @@ "vispy.glsl", "vispy.app.backends._pyqt6", "vispy.app.backends._test", + "vispy.scene.visuals", ] diff --git a/mmgui.spec b/mmgui.spec index bf7f9065..25e389a6 100644 --- a/mmgui.spec +++ b/mmgui.spec @@ -1,5 +1,7 @@ import sys +from pathlib import Path +import rich.pretty from PyInstaller.building.api import COLLECT, EXE, PYZ from PyInstaller.building.build_main import Analysis from PyInstaller.config import CONF @@ -9,14 +11,25 @@ if "workpath" not in CONF: CONF["noconfirm"] = True -sys.modules["FixTk"] = None + +# PATCH rich: +# https://github.com/Textualize/rich/pull/3592 + +fpath = Path(rich.pretty.__file__) +src = fpath.read_text().replace( + " return obj.__repr__.__code__.co_filename in (\n", + " return obj.__repr__.__code__.co_filename in ('dataclasses.py',\n", +) +fpath.write_text(src) + +#################################################### a = Analysis( ["src/pymmcore_gui/__main__.py"], pathex=[], binaries=[], datas=[], - hiddenimports=['pdb'], + hiddenimports=["pdb"], hookspath=["hooks"], hooksconfig={}, runtime_hooks=[], diff --git a/src/pymmcore_gui/__init__.py b/src/pymmcore_gui/__init__.py index fc627308..2ba0a3c6 100644 --- a/src/pymmcore_gui/__init__.py +++ b/src/pymmcore_gui/__init__.py @@ -8,5 +8,6 @@ __version__ = "uninstalled" from ._main_window import MicroManagerGUI +from .actions import ActionInfo, CoreAction, WidgetAction -__all__ = ["MicroManagerGUI"] +__all__ = ["ActionInfo", "CoreAction", "MicroManagerGUI", "WidgetAction"] diff --git a/src/pymmcore_gui/_app.py b/src/pymmcore_gui/_app.py index 078a3db0..f231f5b8 100644 --- a/src/pymmcore_gui/_app.py +++ b/src/pymmcore_gui/_app.py @@ -6,6 +6,8 @@ import traceback from contextlib import suppress from typing import TYPE_CHECKING +from weakref import ReferenceType +import weakref from PyQt6.QtWidgets import QApplication @@ -15,6 +17,8 @@ from collections.abc import Sequence from types import TracebackType +IS_FROZEN = getattr(sys, "frozen", False) + def main(args: Sequence[str] | None = None) -> None: """Run the Micro-Manager GUI.""" @@ -62,16 +66,26 @@ def _print_exception( from rich.traceback import Traceback tb = Traceback.from_exception( - exc_type, exc_value, exc_traceback, suppress=[psygnal], max_frames=10 + exc_type, + exc_value, + exc_traceback, + suppress=[psygnal], + max_frames=100 if IS_FROZEN else 10, + show_locals=True, ) Console(stderr=True).print(tb) except ImportError: traceback.print_exception(exc_type, value=exc_value, tb=exc_traceback) +EXCEPTION_LOG: list[ReferenceType[BaseException]] = [] + + def ndv_excepthook( exc_type: type[BaseException], exc_value: BaseException, tb: TracebackType | None ) -> None: + EXCEPTION_LOG.append(weakref.ref(exc_value)) + _print_exception(exc_type, exc_value, tb) if not tb: return diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 77fd9b1a..642e9485 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections import ChainMap +from enum import Enum from typing import TYPE_CHECKING, cast from weakref import WeakValueDictionary @@ -19,8 +20,9 @@ ) from pymmcore_gui.actions._core_qaction import QCoreAction +from pymmcore_gui.actions.widget_actions import WidgetActionInfo -from .actions import ActionInfo, CoreAction, WidgetAction +from .actions import CoreAction, WidgetAction from .actions._action_info import ActionKey from .widgets._toolbars import OCToolBar @@ -28,20 +30,55 @@ from collections.abc import Callable, Mapping +class Menu(str, Enum): + """Menu names.""" + + WINDOW = "Window" + + def __str__(self) -> str: + return str(self.value) + + +class Toolbar(str, Enum): + """Toolbar names.""" + + CAMERA_ACTIONS = "Camera Actions" + OPTICAL_CONFIGS = "Optical Configs" + WIDGETS = "Widgets" + + def __str__(self) -> str: + return str(self.value) + + class MicroManagerGUI(QMainWindow): """Micro-Manager minimal GUI.""" TOOLBARS: Mapping[ str, list[ActionKey] | Callable[[CMMCorePlus, QMainWindow], QToolBar] ] = { - "Camera Actions": [CoreAction.SNAP, CoreAction.TOGGLE_LIVE], - "Optical Configs": OCToolBar, - "Widgets": [WidgetAction.CONSOLE], + Toolbar.CAMERA_ACTIONS: [ + CoreAction.SNAP, + CoreAction.TOGGLE_LIVE, + ], + Toolbar.OPTICAL_CONFIGS: OCToolBar, + Toolbar.WIDGETS: [ + WidgetAction.CONSOLE, + WidgetAction.PROP_BROWSER, + WidgetAction.MDA_WIDGET, + ], } MENUS: Mapping[ str, list[ActionKey] | Callable[[CMMCorePlus, QMainWindow], QMenu] ] = { - "Window": [WidgetAction.CONSOLE, WidgetAction.PROP_BROWSER], + Menu.WINDOW: [ + WidgetAction.CONSOLE, + WidgetAction.PROP_BROWSER, + WidgetAction.INSTALL_DEVICES, + WidgetAction.MDA_WIDGET, + WidgetAction.CAMERA_ROI, + WidgetAction.CONFIG_GROUPS, + WidgetAction.EXCEPTION_LOG, + ], } def __init__( @@ -65,7 +102,7 @@ def __init__( # get global CMMCorePlus instance self._mmc = mmc = mmcore or CMMCorePlus.instance() - self._mmc.loadSystemConfiguration(config or "tests/test_config.cfg") + self._mmc.loadSystemConfiguration() # MENUS ==================================== # To add menus or menu items, add them to the MENUS dict above @@ -98,16 +135,21 @@ def __init__( layout = QVBoxLayout(central_wdg) self.setCentralWidget(central_wdg) layout.addWidget(ImagePreview(mmcore=self._mmc)) + self.resize(1200, 800) @property def mmc(self) -> CMMCorePlus: return self._mmc - def get_action(self, key: ActionKey) -> QAction: + def get_action(self, key: ActionKey, create: bool = True) -> QAction: """Create a QAction from this key.""" if key not in self._qactions: + if not create: + raise KeyError( + f"Action {key} has not been created yet, and 'create' is False" + ) # create and cache it - info = ActionInfo.for_key(key) + info = WidgetActionInfo.for_key(key) self._qactions[key] = action = info.to_qaction(self._mmc, self) # connect WidgetActions to toggle their widgets if isinstance(action.key, WidgetAction): @@ -115,9 +157,26 @@ def get_action(self, key: ActionKey) -> QAction: return self._qactions[key] - def get_widget(self, key: WidgetAction) -> QWidget: - """Create a QWidget from this key.""" + def get_widget(self, key: WidgetAction, create: bool = True) -> QWidget: + """Get (or create) widget for `key`. + + Parameters + ---------- + key : WidgetAction + The widget to get. + create : bool, optional + Whether to create the widget if it doesn't exist yet, by default True. + + Raises + ------ + KeyError + If the widget doesn't exist and `create` is False. + """ if key not in self._qwidgets: + if not create: + raise KeyError( + f"Widget {key} has not been created yet, and 'create' is False" + ) self._inner_widgets[key] = widget = key.create_widget(self) # override closeEvent to uncheck the corresponding QAction @@ -157,3 +216,5 @@ def _toggle_action_widget(self, checked: bool) -> None: widget = self.get_widget(key) widget.setVisible(checked) + if checked: + widget.raise_() diff --git a/src/pymmcore_gui/actions/_action_info.py b/src/pymmcore_gui/actions/_action_info.py index 59b8aba6..61d7db09 100644 --- a/src/pymmcore_gui/actions/_action_info.py +++ b/src/pymmcore_gui/actions/_action_info.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from enum import Enum -from typing import TYPE_CHECKING, ClassVar, Generic, TypeVar +from typing import TYPE_CHECKING, ClassVar, Generic, Self, TypeVar, cast from pymmcore_plus import CMMCorePlus from PyQt6.QtCore import QObject @@ -87,10 +87,11 @@ def to_qaction( return self._action_cls(mmc, self, parent) @classmethod - def for_key(cls, key: ActionKey) -> ActionInfo: + def for_key(cls, key: ActionKey) -> Self: """Get the ActionInfo for a given key.""" try: - return ActionInfo._registry[key] + # TODO: is this cast valid? + return cast("Self", ActionInfo._registry[key]) except KeyError as e: # pragma: no cover key_type = type(key).__name__ parent_module = __name__.rsplit(".", 1)[0] diff --git a/src/pymmcore_gui/actions/_core_qaction.py b/src/pymmcore_gui/actions/_core_qaction.py index 4231e2e8..5ae4e128 100644 --- a/src/pymmcore_gui/actions/_core_qaction.py +++ b/src/pymmcore_gui/actions/_core_qaction.py @@ -26,7 +26,6 @@ def __init__( super().__init__(parent) self.mmc = mmc self._triggered_callback: ActionTriggeredFunc | None = None - self.triggered.connect(self._on_triggered) if info is not None: self.apply_info(info) @@ -76,6 +75,14 @@ def apply_info(self, info: ActionInfo) -> None: self.setWhatsThis(info.whats_this) self._triggered_callback = info.on_triggered + if info.on_triggered is None: + try: + self.triggered.disconnect(self._on_triggered) + except (TypeError, RuntimeError): + pass + else: + self.triggered.connect(self._on_triggered) + if info.on_created is not None: info.on_created(self) diff --git a/src/pymmcore_gui/actions/core_actions.py b/src/pymmcore_gui/actions/core_actions.py index 8356db3a..4a7fae46 100644 --- a/src/pymmcore_gui/actions/core_actions.py +++ b/src/pymmcore_gui/actions/core_actions.py @@ -1,3 +1,5 @@ +"""Define actions that act on the global CMMCore instance.""" + from __future__ import annotations from typing import TYPE_CHECKING diff --git a/src/pymmcore_gui/actions/widget_actions.py b/src/pymmcore_gui/actions/widget_actions.py index 3beae1ed..bbc81654 100644 --- a/src/pymmcore_gui/actions/widget_actions.py +++ b/src/pymmcore_gui/actions/widget_actions.py @@ -1,3 +1,5 @@ +"""Defines actions that toggle/create singleton widgets.""" + from __future__ import annotations from dataclasses import dataclass @@ -13,7 +15,7 @@ if TYPE_CHECKING: from collections.abc import Callable - from pymmcore_widgets import PropertyBrowser + import pymmcore_widgets as pmmw from PyQt6.QtCore import QObject from PyQt6.QtWidgets import QWidget @@ -41,14 +43,11 @@ def _get_core(obj: QObject) -> CMMCorePlus: return CMMCorePlus.instance() -def create_property_browser(parent: QWidget) -> PropertyBrowser: +def create_property_browser(parent: QWidget) -> pmmw.PropertyBrowser: """Create a Property Browser widget.""" from pymmcore_widgets import PropertyBrowser - mmc = _get_core(parent) - wdg = PropertyBrowser(parent=parent, mmcore=mmc) - wdg.show() - return wdg + return PropertyBrowser(parent=parent, mmcore=_get_core(parent)) def create_mm_console(parent: QWidget) -> MMConsole: @@ -58,6 +57,55 @@ def create_mm_console(parent: QWidget) -> MMConsole: return MMConsole(parent=parent) +def create_install_widgets(parent: QWidget) -> pmmw.InstallWidget: + """Create the Install Devices widget.""" + from pymmcore_widgets import InstallWidget + + wdg = InstallWidget(parent=parent) + wdg.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Window) + wdg.resize(800, 400) + return wdg + + +def create_mda_widget(parent: QWidget) -> pmmw.MDAWidget: + """Create the MDA widget.""" + from pymmcore_widgets import MDAWidget + + wdg = MDAWidget(parent=parent, mmcore=_get_core(parent)) + return wdg + + +def create_camera_roi(parent: QWidget) -> pmmw.CameraRoiWidget: + """Create the Camera ROI widget.""" + from pymmcore_widgets import CameraRoiWidget + + return CameraRoiWidget(parent=parent, mmcore=_get_core(parent)) + + +def create_config_groups(parent: QWidget) -> pmmw.GroupPresetTableWidget: + """Create the Config Groups widget.""" + from pymmcore_widgets import GroupPresetTableWidget + + return GroupPresetTableWidget(parent=parent, mmcore=_get_core(parent)) + + +def create_pixel_config(parent: QWidget) -> pmmw.PixelConfigurationWidget: + """Create the Pixel Configuration widget.""" + from pymmcore_widgets import PixelConfigurationWidget + + return PixelConfigurationWidget(parent=parent, mmcore=_get_core(parent)) + + +def create_exception_log(parent: QWidget) -> pmmw.ExceptionLog: + """Create the Exception Log widget.""" + from pymmcore_gui.widgets._exception_log import ExceptionLog + + wdg = ExceptionLog(parent=parent) + wdg.setWindowFlags(Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Window) + wdg.resize(800, 400) + return wdg + + # ######################## WidgetAction Enum ######################### @@ -71,28 +119,19 @@ class WidgetAction(ActionKey): CONFIG_GROUPS = "Config Groups" CAMERA_ROI = "Camera ROI" CONSOLE = "Console" + EXCEPTION_LOG = "Exception Log" def create_widget(self, parent: QWidget) -> QWidget: """Create the widget associated with this action.""" - if self not in _CREATOR_MAP: # pragma: no cover + info = WidgetActionInfo.for_key(self) + if not info.create_widget: raise NotImplementedError(f"No constructor has been provided for {self!r}") - return _CREATOR_MAP[self](parent) + return info.create_widget(parent) def dock_area(self) -> Qt.DockWidgetArea | None: """Return the default dock area for this widget.""" - return _DOCK_AREAS.get(self, Qt.DockWidgetArea.RightDockWidgetArea) - + return WidgetActionInfo.for_key(self).dock_area -_CREATOR_MAP: dict[WidgetAction, Callable[[QWidget], QWidget]] = { - WidgetAction.CONSOLE: create_mm_console, - WidgetAction.PROP_BROWSER: create_property_browser, -} - -# preferred area for each widget. If None, no dock widget is set. -_DOCK_AREAS: dict[WidgetAction, Qt.DockWidgetArea | None] = { - WidgetAction.CONSOLE: Qt.DockWidgetArea.BottomDockWidgetArea, - WidgetAction.PROP_BROWSER: None, -} # ######################## WidgetActionInfos ######################### @@ -101,17 +140,70 @@ def dock_area(self) -> Qt.DockWidgetArea | None: class WidgetActionInfo(ActionInfo): """Subclass to set default values for WidgetAction.""" + # by default, widget actions are checkable, and the check state indicates visibility checkable: bool = True + # function that can be called with (parent: QWidget) -> QWidget + create_widget: Callable[[QWidget], QWidget] | None = None + # Use None to indicate that the widget should not be docked + dock_area: Qt.DockWidgetArea | None = Qt.DockWidgetArea.RightDockWidgetArea show_console = WidgetActionInfo( key=WidgetAction.CONSOLE, shortcut="Ctrl+Shift+C", icon="iconoir:terminal", + create_widget=create_mm_console, + dock_area=Qt.DockWidgetArea.BottomDockWidgetArea, ) show_property_browser = WidgetActionInfo( key=WidgetAction.PROP_BROWSER, shortcut="Ctrl+Shift+P", icon="mdi-light:format-list-bulleted", + create_widget=create_property_browser, + dock_area=None, +) + +show_install_devices = WidgetActionInfo( + key=WidgetAction.INSTALL_DEVICES, + shortcut="Ctrl+Shift+I", + icon="mdi-light:download", + create_widget=create_install_widgets, + dock_area=None, +) + +show_mda_widget = WidgetActionInfo( + key=WidgetAction.MDA_WIDGET, + shortcut="Ctrl+Shift+M", + icon="qlementine-icons:cube-16", + create_widget=create_mda_widget, +) + +show_camera_roi = WidgetActionInfo( + key=WidgetAction.CAMERA_ROI, + shortcut="Ctrl+Shift+R", + icon="mdi-light:camera", + create_widget=create_camera_roi, +) + +show_config_groups = WidgetActionInfo( + key=WidgetAction.CONFIG_GROUPS, + shortcut="Ctrl+Shift+G", + icon="mdi-light:format-list-bulleted", + create_widget=create_config_groups, +) + +show_pixel_config = WidgetActionInfo( + key=WidgetAction.PIXEL_CONFIG, + shortcut="Ctrl+Shift+X", + icon="mdi-light:grid", + create_widget=create_pixel_config, +) + +show_exception_log = WidgetActionInfo( + key=WidgetAction.EXCEPTION_LOG, + shortcut="Ctrl+Shift+E", + icon="mdi-light:alert", + create_widget=create_exception_log, + dock_area=None, ) diff --git a/src/pymmcore_gui/widgets/_exception_log.py b/src/pymmcore_gui/widgets/_exception_log.py new file mode 100644 index 00000000..5b72e917 --- /dev/null +++ b/src/pymmcore_gui/widgets/_exception_log.py @@ -0,0 +1,33 @@ +from PyQt6.QtGui import QTextOption +from PyQt6.QtWidgets import QTextEdit, QVBoxLayout, QWidget + + +class ExceptionLog(QWidget): + """A scrolling text window with all of the exception tracebacks.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._log = QTextEdit(self) + self._log.setReadOnly(True) + self._log.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap) + self._log.setWordWrapMode(QTextOption.WrapMode.NoWrap) + self._log.setAcceptRichText(False) + self._log.setTabChangesFocus(True) + self._log.setTabStopDistance(4) + self._log.setDocumentTitle("Exception Log") + + self._log.setPlaceholderText("No exceptions have been raised.") + + from pymmcore_gui._app import EXCEPTION_LOG + + for exc_ref in EXCEPTION_LOG: + exc = exc_ref() + if exc: + self.append_exception(exc) + + layout = QVBoxLayout(self) + layout.addWidget(self._log) + + def append_exception(self, exc: BaseException) -> None: + """Append an exception to the log.""" + self._log.append(str(exc)) diff --git a/src/pymmcore_gui/widgets/_mm_console.py b/src/pymmcore_gui/widgets/_mm_console.py index e88a59c5..f918ff20 100644 --- a/src/pymmcore_gui/widgets/_mm_console.py +++ b/src/pymmcore_gui/widgets/_mm_console.py @@ -53,9 +53,12 @@ def _inject_core_vars(self) -> None: import pymmcore_plus import useq + import pymmcore_gui + default_vars = { **pymmcore_plus.__dict__, **useq.__dict__, + **pymmcore_gui.__dict__, "useq": useq, "np": numpy, } From 8584a730e2070863123ce2435842a277f630d38e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 12:24:42 -0500 Subject: [PATCH 189/226] better exception logger --- src/pymmcore_gui/_app.py | 54 ++++-- src/pymmcore_gui/_main_window.py | 3 +- src/pymmcore_gui/widgets/_exception_log.py | 206 ++++++++++++++++++--- 3 files changed, 220 insertions(+), 43 deletions(-) diff --git a/src/pymmcore_gui/_app.py b/src/pymmcore_gui/_app.py index f231f5b8..e41af7f1 100644 --- a/src/pymmcore_gui/_app.py +++ b/src/pymmcore_gui/_app.py @@ -5,10 +5,9 @@ import sys import traceback from contextlib import suppress -from typing import TYPE_CHECKING -from weakref import ReferenceType -import weakref +from typing import IO, TYPE_CHECKING +from PyQt6.QtCore import pyqtSignal from PyQt6.QtWidgets import QApplication from pymmcore_gui import MicroManagerGUI @@ -20,6 +19,10 @@ IS_FROZEN = getattr(sys, "frozen", False) +class MMQApplication(QApplication): + exceptionRaised = pyqtSignal(BaseException) + + def main(args: Sequence[str] | None = None) -> None: """Run the Micro-Manager GUI.""" if args is None: @@ -31,7 +34,7 @@ def main(args: Sequence[str] | None = None) -> None: ) parsed_args = parser.parse_args(args) - app = QApplication([]) + app = MMQApplication(sys.argv) _install_excepthook() win = MicroManagerGUI(config=parsed_args.config) @@ -55,38 +58,49 @@ def _install_excepthook() -> None: sys.excepthook = ndv_excepthook +def rich_print_exception( + exc_type: type[BaseException], + exc_value: BaseException, + exc_traceback: TracebackType | None, +) -> None: + import psygnal + from rich.console import Console + from rich.traceback import Traceback + + tb = Traceback.from_exception( + exc_type, + exc_value, + exc_traceback, + suppress=[psygnal], + max_frames=100 if IS_FROZEN else 10, + show_locals=True, + ) + Console(stderr=True).print(tb) + + def _print_exception( exc_type: type[BaseException], exc_value: BaseException, exc_traceback: TracebackType | None, ) -> None: try: - import psygnal - from rich.console import Console - from rich.traceback import Traceback - - tb = Traceback.from_exception( - exc_type, - exc_value, - exc_traceback, - suppress=[psygnal], - max_frames=100 if IS_FROZEN else 10, - show_locals=True, - ) - Console(stderr=True).print(tb) + rich_print_exception(exc_type, exc_value, exc_traceback) except ImportError: traceback.print_exception(exc_type, value=exc_value, tb=exc_traceback) -EXCEPTION_LOG: list[ReferenceType[BaseException]] = [] +EXCEPTION_LOG: list[ + tuple[type[BaseException], BaseException, TracebackType | None] +] = [] def ndv_excepthook( exc_type: type[BaseException], exc_value: BaseException, tb: TracebackType | None ) -> None: - EXCEPTION_LOG.append(weakref.ref(exc_value)) - + EXCEPTION_LOG.append((exc_type, exc_value, tb)) _print_exception(exc_type, exc_value, tb) + if sig := getattr(QApplication.instance(), "exceptionRaised", None): + sig.emit(exc_value) if not tb: return diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 642e9485..905e2e3a 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -132,8 +132,9 @@ def __init__( # LAYOUT ====================================== central_wdg = QWidget(self) - layout = QVBoxLayout(central_wdg) self.setCentralWidget(central_wdg) + + layout = QVBoxLayout(central_wdg) layout.addWidget(ImagePreview(mmcore=self._mmc)) self.resize(1200, 800) diff --git a/src/pymmcore_gui/widgets/_exception_log.py b/src/pymmcore_gui/widgets/_exception_log.py index 5b72e917..002874e4 100644 --- a/src/pymmcore_gui/widgets/_exception_log.py +++ b/src/pymmcore_gui/widgets/_exception_log.py @@ -1,33 +1,195 @@ -from PyQt6.QtGui import QTextOption -from PyQt6.QtWidgets import QTextEdit, QVBoxLayout, QWidget +from __future__ import annotations +import traceback +from functools import cache, cached_property +from typing import TYPE_CHECKING -class ExceptionLog(QWidget): - """A scrolling text window with all of the exception tracebacks.""" +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont, QTextCursor +from PyQt6.QtWidgets import ( + QAbstractItemView, + QApplication, + QComboBox, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QPushButton, + QSplitter, + QTextEdit, + QVBoxLayout, + QWidget, +) +from superqt.utils import CodeSyntaxHighlight + +from pymmcore_gui import _app + +if TYPE_CHECKING: + from types import TracebackType + from typing import TypeAlias + + ExcInfo: TypeAlias = tuple[type[BaseException], BaseException, TracebackType | None] + +MONO = "Menlo, Courier New, Monaco, Consolas, Andale Mono, Source Code Pro, monospace" +DEFAULT_THEME = "default" + + +@cache +def _format_exception(exc_info: ExcInfo) -> str: + """Format the exception details.""" + exc_type, exc_value, exc_traceback = exc_info + details = f"{exc_type.__name__}: {exc_value}\n\n" + if exc_traceback: + details += "".join(traceback.format_tb(exc_traceback)) + return details + +class ExceptionLog(QWidget): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) - self._log = QTextEdit(self) - self._log.setReadOnly(True) - self._log.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap) - self._log.setWordWrapMode(QTextOption.WrapMode.NoWrap) - self._log.setAcceptRichText(False) - self._log.setTabChangesFocus(True) - self._log.setTabStopDistance(4) - self._log.setDocumentTitle("Exception Log") + self.setWindowTitle("Exception Explorer") + + self.exception_log = _app.EXCEPTION_LOG + + # Top: Filter and Copy + self._type_combo = QComboBox() + self._type_combo.addItem("All") + self._type_combo.currentTextChanged.connect(self._refresh_exc_list) + + self._text_search = QLineEdit() + self._text_search.setPlaceholderText("Search") + self._text_search.setClearButtonEnabled(True) + self._text_search.textChanged.connect(self._refresh_exc_list) + + self._copy_btn = QPushButton("Copy to Clipboard") + self._copy_btn.clicked.connect(self.copy_to_clipboard) + + # Middle: Exception List + self._list_wdg = QListWidget() + self._list_wdg.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self._list_wdg.currentRowChanged.connect(self._on_current_row_changed) - self._log.setPlaceholderText("No exceptions have been raised.") + # Bottom: Exception Details + self._traceback_area = txt = QTextEdit() + txt.setReadOnly(True) + txt.setFont(QFont(MONO)) + self._highlight = CodeSyntaxHighlight(txt, "pytb", DEFAULT_THEME) - from pymmcore_gui._app import EXCEPTION_LOG + self._style_combo = QComboBox() + self._style_combo.currentTextChanged.connect(self._update_style) + self._style_combo.addItems( + [ + "colorful", + "default", + "friendly", + "solarized-light", + "dracula", + "gruvbox-dark", + "one-dark", + "solarized-dark", + ] + ) + self._style_combo.insertSeparator(4) + self._style_combo.setCurrentText(DEFAULT_THEME) - for exc_ref in EXCEPTION_LOG: - exc = exc_ref() - if exc: - self.append_exception(exc) + # LAYOUT + + control_layout = QHBoxLayout() + control_layout.addWidget(QLabel("Error Type:"), 0) + control_layout.addWidget(self._type_combo, 1) + control_layout.addWidget(QLabel("Search:"), 0) + control_layout.addWidget(self._text_search, 1) + + style_layout = QHBoxLayout() + style_layout.addStretch() + style_layout.addWidget(QLabel("Style:")) + style_layout.addWidget(self._style_combo) + style_layout.addWidget(self._copy_btn) + + splitter = QSplitter(Qt.Orientation.Vertical) + splitter.addWidget(self._list_wdg) + splitter.addWidget(self._traceback_area) layout = QVBoxLayout(self) - layout.addWidget(self._log) + layout.addLayout(control_layout) + layout.addWidget(splitter) + layout.addLayout(style_layout) + self.resize(800, 600) + + self._refresh() + + if app := QApplication.instance(): + # support for live updates + if hasattr(app, "exceptionRaised"): + app.exceptionRaised.connect(self._refresh) + + def _refresh(self) -> None: + """Add a new exception to the log.""" + self._update_filter_combo() + self._refresh_exc_list() + + def _update_style(self) -> None: + self._highlight.setTheme(self._style_combo.currentText()) + + def _update_filter_combo(self) -> None: + """Update the filter combo with the latest exception types.""" + prev_text = self._type_combo.currentText() + self._type_combo.blockSignals(True) + try: + self._type_combo.clear() + self._type_combo.addItem("All") + items = {x[0].__name__ for x in self.exception_log} + self._type_combo.addItems(sorted(items)) + if prev_text in items: + self._type_combo.setCurrentText(prev_text) + finally: + self._type_combo.blockSignals(False) + + @cached_property + def filtered_exceptions(self) -> list[ExcInfo]: + """Filter exceptions based on the selected type.""" + etype = self._type_combo.currentText() + exceptions = ( + self.exception_log + if etype == "All" + else (exc for exc in self.exception_log if exc[0].__name__ == etype) + ) + out = [] + if text := self._text_search.text(): + splits = text.lower().split() + for exc in exceptions: + formatted = _format_exception(exc).lower() + if all(x in formatted for x in splits): + out.append(exc) + else: + out = list(exceptions) + return out + + def _refresh_exc_list(self) -> None: + """Populate the QListWidget with filtered exceptions.""" + if hasattr(self, "filtered_exceptions"): + del self.filtered_exceptions + + self._list_wdg.clear() + for exc_type, exc_value, _ in self.filtered_exceptions: + self._list_wdg.addItem(f"{exc_type.__name__}: {exc_value}") + self._list_wdg.setCurrentRow(0) + + def _on_current_row_changed(self, index: int) -> None: + """Display details of the selected exception.""" + text_area = self._traceback_area + if index == -1 or index >= len(self.filtered_exceptions): + text_area.clear() + self._copy_btn.setEnabled(False) + return + + details = _format_exception(self.filtered_exceptions[index]) + text_area.setText(details) + text_area.moveCursor(QTextCursor.MoveOperation.Start) + self._copy_btn.setEnabled(True) - def append_exception(self, exc: BaseException) -> None: - """Append an exception to the log.""" - self._log.append(str(exc)) + def copy_to_clipboard(self) -> None: + """Copy the selected exception to the clipboard.""" + if clipboard := QApplication.clipboard(): + details = self._traceback_area.toPlainText() + clipboard.setText(details) From ab15b07ea04d1d1770f65217105a18633bc76ca2 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 12:44:54 -0500 Subject: [PATCH 190/226] add stage widget --- src/pymmcore_gui/_main_window.py | 7 ++ src/pymmcore_gui/actions/widget_actions.py | 23 +++++- src/pymmcore_gui/widgets/_stage_control.py | 82 ++++++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 src/pymmcore_gui/widgets/_stage_control.py diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 905e2e3a..413f4532 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -53,6 +53,8 @@ def __str__(self) -> str: class MicroManagerGUI(QMainWindow): """Micro-Manager minimal GUI.""" + # Toolbars are a mapping of strings to either a list of ActionKeys or a callable + # that takes a CMMCorePlus instance and QMainWindow and returns a QToolBar. TOOLBARS: Mapping[ str, list[ActionKey] | Callable[[CMMCorePlus, QMainWindow], QToolBar] ] = { @@ -65,8 +67,12 @@ class MicroManagerGUI(QMainWindow): WidgetAction.CONSOLE, WidgetAction.PROP_BROWSER, WidgetAction.MDA_WIDGET, + WidgetAction.STAGE_CONTROL, + WidgetAction.CAMERA_ROI, ], } + # Menus are a mapping of strings to either a list of ActionKeys or a callable + # that takes a CMMCorePlus instance and QMainWindow and returns a QMenu. MENUS: Mapping[ str, list[ActionKey] | Callable[[CMMCorePlus, QMainWindow], QMenu] ] = { @@ -75,6 +81,7 @@ class MicroManagerGUI(QMainWindow): WidgetAction.PROP_BROWSER, WidgetAction.INSTALL_DEVICES, WidgetAction.MDA_WIDGET, + WidgetAction.STAGE_CONTROL, WidgetAction.CAMERA_ROI, WidgetAction.CONFIG_GROUPS, WidgetAction.EXCEPTION_LOG, diff --git a/src/pymmcore_gui/actions/widget_actions.py b/src/pymmcore_gui/actions/widget_actions.py index bbc81654..63adf973 100644 --- a/src/pymmcore_gui/actions/widget_actions.py +++ b/src/pymmcore_gui/actions/widget_actions.py @@ -79,7 +79,9 @@ def create_camera_roi(parent: QWidget) -> pmmw.CameraRoiWidget: """Create the Camera ROI widget.""" from pymmcore_widgets import CameraRoiWidget - return CameraRoiWidget(parent=parent, mmcore=_get_core(parent)) + wdg = CameraRoiWidget(parent=parent, mmcore=_get_core(parent)) + wdg.setMaximumHeight(140) + return wdg def create_config_groups(parent: QWidget) -> pmmw.GroupPresetTableWidget: @@ -106,6 +108,13 @@ def create_exception_log(parent: QWidget) -> pmmw.ExceptionLog: return wdg +def create_stage_widget(parent: QWidget) -> pmmw.StageWidget: + """Create the Stage Control widget.""" + from pymmcore_gui.widgets._stage_control import StagesControlWidget + + return StagesControlWidget(parent=parent, mmcore=_get_core(parent)) + + # ######################## WidgetAction Enum ######################### @@ -120,6 +129,7 @@ class WidgetAction(ActionKey): CAMERA_ROI = "Camera ROI" CONSOLE = "Console" EXCEPTION_LOG = "Exception Log" + STAGE_CONTROL = "Stage Control" def create_widget(self, parent: QWidget) -> QWidget: """Create the widget associated with this action.""" @@ -182,8 +192,9 @@ class WidgetActionInfo(ActionInfo): show_camera_roi = WidgetActionInfo( key=WidgetAction.CAMERA_ROI, shortcut="Ctrl+Shift+R", - icon="mdi-light:camera", + icon="material-symbols-light:screenshot-region-rounded", create_widget=create_camera_roi, + dock_area=Qt.DockWidgetArea.LeftDockWidgetArea, ) show_config_groups = WidgetActionInfo( @@ -207,3 +218,11 @@ class WidgetActionInfo(ActionInfo): create_widget=create_exception_log, dock_area=None, ) + +show_stage_control = WidgetActionInfo( + key=WidgetAction.STAGE_CONTROL, + shortcut="Ctrl+Shift+S", + icon="fa:arrows", + create_widget=create_stage_widget, + dock_area=Qt.DockWidgetArea.LeftDockWidgetArea, +) diff --git a/src/pymmcore_gui/widgets/_stage_control.py b/src/pymmcore_gui/widgets/_stage_control.py new file mode 100644 index 00000000..96626087 --- /dev/null +++ b/src/pymmcore_gui/widgets/_stage_control.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from itertools import chain +from typing import TYPE_CHECKING + +from pymmcore_plus import CMMCorePlus, DeviceType +from pymmcore_widgets import StageWidget +from PyQt6.QtWidgets import ( + QGridLayout, + QGroupBox, + QHBoxLayout, + QSizePolicy, + QWidget, +) + +if TYPE_CHECKING: + from qtpy.QtGui import QWheelEvent + +STAGE_DEVICES = {DeviceType.Stage, DeviceType.XYStage} + + +class _Group(QGroupBox): + def __init__(self, name: str, parent: QWidget | None = None) -> None: + super().__init__(name, parent) + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + + +class _Stage(StageWidget): + """Stage control widget with wheel event for z-axis control.""" + + def wheelEvent(self, event: QWheelEvent | None) -> None: + if not event or self._dtype != DeviceType.Stage: + return + delta = event.angleDelta().y() + increment = self._step.value() + if delta > 0: + self._move_stage(0, increment) + elif delta < 0: + self._move_stage(0, -increment) + + +class StagesControlWidget(QWidget): + """A widget to control all the XY and Z loaded stages.""" + + def __init__( + self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent=parent) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + self._mmc = mmcore or CMMCorePlus.instance() + self._mmc.events.systemConfigurationLoaded.connect(self._on_cfg_loaded) + + self._layout = QGridLayout(self) + self._layout.setContentsMargins(5, 5, 5, 5) + self._layout.setSpacing(5) + + self._on_cfg_loaded() + + def _on_cfg_loaded(self) -> None: + self._clear() + + stages = chain( + self._mmc.getLoadedDevicesOfType(DeviceType.XYStage), + self._mmc.getLoadedDevicesOfType(DeviceType.Stage), + ) + for idx, stage_dev in enumerate(stages): + bx = _Group(stage_dev, self) + stage = _Stage(device=stage_dev, parent=bx) + bx.layout().addWidget(stage) + self._layout.addWidget(bx, idx // 2, idx % 2) + self.resize(self.sizeHint()) + + def _clear(self) -> None: + while self._layout.count(): + if (item := self._layout.takeAt(0)) and (widget := item.widget()): + widget.setParent(self) + widget.deleteLater() From b7de87372ddb719af8dcbb04c9728398497ae6de Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 12:53:20 -0500 Subject: [PATCH 191/226] show some defatuls --- src/pymmcore_gui/_main_window.py | 16 +++++++++++++++- src/pymmcore_gui/actions/widget_actions.py | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 413f4532..43fb4f99 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -136,6 +136,15 @@ def __init__( for action in tb_entry: tb.addAction(self.get_action(action)) + # populate with default widgets ... + # eventually this should be configurable and restored from a config file + for key in ( + WidgetAction.CONFIG_GROUPS, + WidgetAction.STAGE_CONTROL, + WidgetAction.MDA_WIDGET, + ): + self.get_widget(key) + # LAYOUT ====================================== central_wdg = QWidget(self) @@ -143,7 +152,7 @@ def __init__( layout = QVBoxLayout(central_wdg) layout.addWidget(ImagePreview(mmcore=self._mmc)) - self.resize(1200, 800) + self.showMaximized() @property def mmc(self) -> CMMCorePlus: @@ -207,6 +216,11 @@ def _closeEvent(a0: QCloseEvent | None = None) -> None: dw.setWidget(widget) self.addDockWidget(dock_area, dw) + # toggle checked state of QAction if it exists + # can this go somewhere else? + if action := self._qactions.get(key): + action.setChecked(True) + return self._qwidgets[key] def _toggle_action_widget(self, checked: bool) -> None: diff --git a/src/pymmcore_gui/actions/widget_actions.py b/src/pymmcore_gui/actions/widget_actions.py index 63adf973..1d9ba923 100644 --- a/src/pymmcore_gui/actions/widget_actions.py +++ b/src/pymmcore_gui/actions/widget_actions.py @@ -202,6 +202,7 @@ class WidgetActionInfo(ActionInfo): shortcut="Ctrl+Shift+G", icon="mdi-light:format-list-bulleted", create_widget=create_config_groups, + dock_area=Qt.DockWidgetArea.LeftDockWidgetArea, ) show_pixel_config = WidgetActionInfo( From 6269cbb3199f4354cef9038731344d8449c76ea1 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 13:03:54 -0500 Subject: [PATCH 192/226] update pre-commit --- .pre-commit-config.yaml | 4 ++-- src/pymmcore_gui/_app.py | 2 +- src/pymmcore_gui/widgets/_stage_control.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 70eaaa5f..462a72d3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,14 +16,14 @@ repos: args: [--force-exclude] # omitting --write-changes - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.3 + rev: v0.8.4 hooks: - id: ruff args: [--fix, --unsafe-fixes] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.13.0 + rev: v1.14.0 hooks: - id: mypy files: "^src/" diff --git a/src/pymmcore_gui/_app.py b/src/pymmcore_gui/_app.py index e41af7f1..435d9a99 100644 --- a/src/pymmcore_gui/_app.py +++ b/src/pymmcore_gui/_app.py @@ -5,7 +5,7 @@ import sys import traceback from contextlib import suppress -from typing import IO, TYPE_CHECKING +from typing import TYPE_CHECKING from PyQt6.QtCore import pyqtSignal from PyQt6.QtWidgets import QApplication diff --git a/src/pymmcore_gui/widgets/_stage_control.py b/src/pymmcore_gui/widgets/_stage_control.py index 96626087..a554b78c 100644 --- a/src/pymmcore_gui/widgets/_stage_control.py +++ b/src/pymmcore_gui/widgets/_stage_control.py @@ -1,7 +1,7 @@ from __future__ import annotations from itertools import chain -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from pymmcore_plus import CMMCorePlus, DeviceType from pymmcore_widgets import StageWidget @@ -71,7 +71,7 @@ def _on_cfg_loaded(self) -> None: for idx, stage_dev in enumerate(stages): bx = _Group(stage_dev, self) stage = _Stage(device=stage_dev, parent=bx) - bx.layout().addWidget(stage) + cast(QHBoxLayout, bx.layout()).addWidget(stage) self._layout.addWidget(bx, idx // 2, idx % 2) self.resize(self.sizeHint()) From b3af201a7128cee66ea8e30e9377bffc069e9110 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 13:10:58 -0500 Subject: [PATCH 193/226] newline --- .python-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.python-version b/.python-version index fdcfcfdf..e4fba218 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.12 \ No newline at end of file +3.12 From 7a7f7c2c6a4df8635deb30a4822983c48155cbf0 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 13:15:49 -0500 Subject: [PATCH 194/226] fix pre-commit on CI --- .pre-commit-config.yaml | 2 +- src/pymmcore_gui/_main_window.py | 4 ++-- src/pymmcore_gui/widgets/_toolbars.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 462a72d3..3e7ad6e6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,4 +29,4 @@ repos: files: "^src/" additional_dependencies: - pymmcore-plus >=0.11.0 - - PyQt6 + # - PyQt6 diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 43fb4f99..23dd86c1 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -103,7 +103,7 @@ def __init__( self._inner_widgets = WeakValueDictionary[ActionKey, QWidget]() self._dock_widgets = WeakValueDictionary[ActionKey, QDockWidget]() self._qwidgets = ChainMap[ActionKey, QWidget]( - self._dock_widgets, # type: ignore [arg-type] + self._dock_widgets, self._inner_widgets, ) @@ -205,7 +205,7 @@ def _closeEvent(a0: QCloseEvent | None = None) -> None: superCloseEvent(a0) superCloseEvent = widget.closeEvent - widget.closeEvent = _closeEvent # type: ignore [method-assign] + widget.closeEvent = _closeEvent # also hook up QDialog's finished signal to closeEvent if isinstance(widget, QDialog): diff --git a/src/pymmcore_gui/widgets/_toolbars.py b/src/pymmcore_gui/widgets/_toolbars.py index 241ba3ac..bd7f8edd 100644 --- a/src/pymmcore_gui/widgets/_toolbars.py +++ b/src/pymmcore_gui/widgets/_toolbars.py @@ -38,6 +38,6 @@ def _refresh(self) -> None: action.setCheckable(True) action.setChecked(preset_name == current) - @action.triggered.connect + @action.triggered.connect # type: ignore [misc] def _(checked: bool, pname: str = preset_name) -> None: mmc.setConfig(ch_group, pname) From 5980b63a9ea1abcd24edfa41b06a6648c5dd7ea4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 13:24:18 -0500 Subject: [PATCH 195/226] add tests --- tests/test_app.py | 17 +++++++++++++++++ tests/test_main_window.py | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/test_app.py create mode 100644 tests/test_main_window.py diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 00000000..3c00f03a --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,17 @@ +import sys +from unittest.mock import patch + +from PyQt6.QtWidgets import QApplication + +from pymmcore_gui import _app + + +def test_main_app() -> None: + with patch.object(QApplication, "exec") as mock_exec: + _app.main() + assert mock_exec.called + assert isinstance(QApplication.instance(), _app.MMQApplication) + assert sys.excepthook == _app.ndv_excepthook + for wdg in QApplication.topLevelWidgets(): + wdg.close() + wdg.deleteLater() diff --git a/tests/test_main_window.py b/tests/test_main_window.py new file mode 100644 index 00000000..6dac3282 --- /dev/null +++ b/tests/test_main_window.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pymmcore_gui import CoreAction, MicroManagerGUI, WidgetAction + +if TYPE_CHECKING: + from pytestqt.qtbot import QtBot + + +def test_main_window(qtbot: QtBot) -> None: + gui = MicroManagerGUI() + qtbot.addWidget(gui) + for w_action in WidgetAction: + gui.get_action(w_action) + gui.get_widget(w_action) + assert w_action in gui._qactions + assert w_action in gui._qwidgets + + for c_action in CoreAction: + gui.get_action(c_action) + assert c_action in gui._qactions From 7264681854e5341aeb86f7e3b68700b5a5476e73 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 17:53:00 -0500 Subject: [PATCH 196/226] move bundle, vendor code syntax --- {hooks => app/hooks}/hook-debugpy.py | 0 {hooks => app/hooks}/hook-pymmcore_widgets.py | 0 {hooks => app/hooks}/hook-superqt.py | 0 {hooks => app/hooks}/hook-vispy.py | 0 app/icon.icns | Bin 0 -> 64895 bytes app/icon.ico | Bin 0 -> 31526 bytes app/mmgui.spec | 150 +++++++ justfile | 8 + mmgui.spec | 85 ---- src/pymmcore_gui/_app.py | 1 + src/pymmcore_gui/_main_window.py | 1 - src/pymmcore_gui/actions/_core_qaction.py | 1 + src/pymmcore_gui/actions/widget_actions.py | 2 +- .../widgets/_code_syntax_highlight.py | 269 +++++++++++ src/pymmcore_gui/widgets/_exception_log.py | 3 +- uv.lock | 424 +++++++++--------- 16 files changed, 656 insertions(+), 288 deletions(-) rename {hooks => app/hooks}/hook-debugpy.py (100%) rename {hooks => app/hooks}/hook-pymmcore_widgets.py (100%) rename {hooks => app/hooks}/hook-superqt.py (100%) rename {hooks => app/hooks}/hook-vispy.py (100%) create mode 100644 app/icon.icns create mode 100644 app/icon.ico create mode 100644 app/mmgui.spec create mode 100644 justfile delete mode 100644 mmgui.spec create mode 100644 src/pymmcore_gui/widgets/_code_syntax_highlight.py diff --git a/hooks/hook-debugpy.py b/app/hooks/hook-debugpy.py similarity index 100% rename from hooks/hook-debugpy.py rename to app/hooks/hook-debugpy.py diff --git a/hooks/hook-pymmcore_widgets.py b/app/hooks/hook-pymmcore_widgets.py similarity index 100% rename from hooks/hook-pymmcore_widgets.py rename to app/hooks/hook-pymmcore_widgets.py diff --git a/hooks/hook-superqt.py b/app/hooks/hook-superqt.py similarity index 100% rename from hooks/hook-superqt.py rename to app/hooks/hook-superqt.py diff --git a/hooks/hook-vispy.py b/app/hooks/hook-vispy.py similarity index 100% rename from hooks/hook-vispy.py rename to app/hooks/hook-vispy.py diff --git a/app/icon.icns b/app/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..f1cd6a20b37b57f08a98461f1ed74e14e09ed490 GIT binary patch literal 64895 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 D%e&mJ literal 0 HcmV?d00001 diff --git a/app/mmgui.spec b/app/mmgui.spec new file mode 100644 index 00000000..9b460ad4 --- /dev/null +++ b/app/mmgui.spec @@ -0,0 +1,150 @@ +import os +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +import rich.pretty +from PyInstaller.building.api import COLLECT, EXE, PYZ +from PyInstaller.building.build_main import Analysis +from PyInstaller.config import CONF + +import pymmcore_gui + +if TYPE_CHECKING: + from PyInstaller.utils.win32 import versioninfo as vi + +if "workpath" not in CONF: + raise ValueError("This script must run with `pyinstaller mmgui.spec`") + + +# PATCH rich: +# https://github.com/Textualize/rich/pull/3592 + +fpath = Path(rich.pretty.__file__) +src = fpath.read_text().replace( + " return obj.__repr__.__code__.co_filename in (\n", + " return obj.__repr__.__code__.co_filename in ('dataclasses.py',\n", +) +fpath.write_text(src) + +#################################################### + + +PACKAGE = Path(pymmcore_gui.__file__).parent +ROOT = PACKAGE.parent.parent +APP_ROOT = ROOT / "app" +ICON = APP_ROOT / ("icon.ico" if sys.platform.startswith("win") else "icon.icns") + +NAME = "pymmgui" +DEBUG = False +UPX = True + +os.environ["QT_API"] = "PyQt6" + + +def _get_win_version() -> "vi.VSVersionInfo": + if sys.platform != "win32": + return None + from PyInstaller.utils.win32 import versioninfo as vi + + ver_str = pymmcore_gui.__version__ + version = [int(x) for x in ver_str.replace("+", ".").split(".") if x.isnumeric()] + version += [0] * (4 - len(version)) + version_t = tuple(version)[:4] + return vi.VSVersionInfo( + ffi=vi.FixedFileInfo(filevers=version_t, prodvers=version_t), + kids=[ + vi.StringFileInfo( + [ + vi.StringTable( + "000004b0", + [ + vi.StringStruct("CompanyName", NAME), + vi.StringStruct("FileDescription", NAME), + vi.StringStruct("FileVersion", ver_str), + vi.StringStruct("LegalCopyright", ""), + vi.StringStruct("OriginalFileName", NAME + ".exe"), + vi.StringStruct("ProductName", NAME), + vi.StringStruct("ProductVersion", ver_str), + ], + ) + ] + ), + vi.VarFileInfo([vi.VarStruct("Translation", [0, 1200])]), + ], + ) + + +a = Analysis( + [PACKAGE / "__main__.py"], + binaries=[], + datas=[], + # An optional list of additional (hidden) modules to include. + hiddenimports=[], + # An optional list of additional paths to search for hooks. + hookspath=[APP_ROOT / "hooks"], + # An optional list of module or package names (their Python names, not path names) that will be + # ignored (as though they were not found). + excludes=[ + "FixTk", + "tcl", + "tk", + "_tkinter", + "tkinter", + "Tkinter", + "matplotlib", + ], + # If True, do not place source files in a archive, but keep them as individual files. + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name=NAME, + debug=DEBUG, + bootloader_ignore_signals=False, + strip=False, + upx=UPX, + # whether to use the console executable or the windowed executable + console=False, + # windows only + # In console-enabled executable, hide or minimize the console window if the program + # owns the console window (i.e., was not launched from existing console window). + hide_console=None, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=ICON, + version=_get_win_version(), +) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="mmgui", +) + +if sys.platform == "darwin": + from PyInstaller.building.osx import BUNDLE + + app = BUNDLE( + coll, + name=f"{NAME}.app", + icon=ICON, + bundle_identifier=None, + info_plist={ + "CFBundleIdentifier": f"com.{NAME}.{NAME}", + "CFBundleShortVersionString": pymmcore_gui.__version__, + "NSHighResolutionCapable": "True", + }, + ) diff --git a/justfile b/justfile new file mode 100644 index 00000000..2eb727ba --- /dev/null +++ b/justfile @@ -0,0 +1,8 @@ +bundle: + uv run pyinstaller app/mmgui.spec --log-level INFO + +lint: + uv run pre-commit run --all-files + +test: + uv run pytest \ No newline at end of file diff --git a/mmgui.spec b/mmgui.spec deleted file mode 100644 index 25e389a6..00000000 --- a/mmgui.spec +++ /dev/null @@ -1,85 +0,0 @@ -import sys -from pathlib import Path - -import rich.pretty -from PyInstaller.building.api import COLLECT, EXE, PYZ -from PyInstaller.building.build_main import Analysis -from PyInstaller.config import CONF - -if "workpath" not in CONF: - raise ValueError("This script must run with `pyinstaller mmgui.spec`") - -CONF["noconfirm"] = True - - -# PATCH rich: -# https://github.com/Textualize/rich/pull/3592 - -fpath = Path(rich.pretty.__file__) -src = fpath.read_text().replace( - " return obj.__repr__.__code__.co_filename in (\n", - " return obj.__repr__.__code__.co_filename in ('dataclasses.py',\n", -) -fpath.write_text(src) - -#################################################### - -a = Analysis( - ["src/pymmcore_gui/__main__.py"], - pathex=[], - binaries=[], - datas=[], - hiddenimports=["pdb"], - hookspath=["hooks"], - hooksconfig={}, - runtime_hooks=[], - excludes=[ - "FixTk", - "tcl", - "tk", - "_tkinter", - "tkinter", - "Tkinter", - "matplotlib", - ], - noarchive=False, - optimize=0, -) -pyz = PYZ(a.pure) - -exe = EXE( - pyz, - a.scripts, - [], - exclude_binaries=True, - name="mmgui", - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - console=False, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) -coll = COLLECT( - exe, - a.binaries, - a.datas, - strip=False, - upx=True, - upx_exclude=[], - name="mmgui", -) - -if sys.platform == "darwin": - from PyInstaller.building.osx import BUNDLE - - app = BUNDLE( - coll, - name="mmgui.app", - icon=None, - bundle_identifier=None, - ) diff --git a/src/pymmcore_gui/_app.py b/src/pymmcore_gui/_app.py index 435d9a99..4da5fa4f 100644 --- a/src/pymmcore_gui/_app.py +++ b/src/pymmcore_gui/_app.py @@ -38,6 +38,7 @@ def main(args: Sequence[str] | None = None) -> None: _install_excepthook() win = MicroManagerGUI(config=parsed_args.config) + win.showMaximized() win.show() app.exec() diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 23dd86c1..3ac846ba 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -152,7 +152,6 @@ def __init__( layout = QVBoxLayout(central_wdg) layout.addWidget(ImagePreview(mmcore=self._mmc)) - self.showMaximized() @property def mmc(self) -> CMMCorePlus: diff --git a/src/pymmcore_gui/actions/_core_qaction.py b/src/pymmcore_gui/actions/_core_qaction.py index 5ae4e128..83c3fc77 100644 --- a/src/pymmcore_gui/actions/_core_qaction.py +++ b/src/pymmcore_gui/actions/_core_qaction.py @@ -26,6 +26,7 @@ def __init__( super().__init__(parent) self.mmc = mmc self._triggered_callback: ActionTriggeredFunc | None = None + self.setMenuRole(QAction.MenuRole.NoRole) # don't guess menu placement if info is not None: self.apply_info(info) diff --git a/src/pymmcore_gui/actions/widget_actions.py b/src/pymmcore_gui/actions/widget_actions.py index 1d9ba923..0d06f788 100644 --- a/src/pymmcore_gui/actions/widget_actions.py +++ b/src/pymmcore_gui/actions/widget_actions.py @@ -125,7 +125,7 @@ class WidgetAction(ActionKey): PIXEL_CONFIG = "Pixel Configuration" INSTALL_DEVICES = "Install Devices" MDA_WIDGET = "MDA Widget" - CONFIG_GROUPS = "Config Groups" + CONFIG_GROUPS = "Configs and Preset" CAMERA_ROI = "Camera ROI" CONSOLE = "Console" EXCEPTION_LOG = "Exception Log" diff --git a/src/pymmcore_gui/widgets/_code_syntax_highlight.py b/src/pymmcore_gui/widgets/_code_syntax_highlight.py new file mode 100644 index 00000000..7533c3a1 --- /dev/null +++ b/src/pymmcore_gui/widgets/_code_syntax_highlight.py @@ -0,0 +1,269 @@ +"""Vendored from superqt PR https://github.com/pyapp-kit/superqt/pull/268.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from pygments import highlight +from pygments.formatter import Formatter +from pygments.lexers import find_lexer_class, get_lexer_by_name +from pygments.util import ClassNotFound +from qtpy.QtGui import ( + QColor, + QFont, + QPalette, + QSyntaxHighlighter, + QTextCharFormat, + QTextDocument, +) + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from typing import Literal, TypeAlias, TypedDict, Unpack + + import pygments.style + from pygments.style import _StyleDict + from pygments.token import _TokenType + from qtpy.QtCore import QObject + + class SupportsDocumentAndPalette(QObject): + def document(self) -> QTextDocument | None: ... + def palette(self) -> QPalette: ... + def setPalette(self, palette: QPalette) -> None: ... + + KnownStyle: TypeAlias = Literal[ + "abap", + "algol", + "algol_nu", + "arduino", + "autumn", + "bw", + "borland", + "coffee", + "colorful", + "default", + "dracula", + "emacs", + "friendly_grayscale", + "friendly", + "fruity", + "github-dark", + "gruvbox-dark", + "gruvbox-light", + "igor", + "inkpot", + "lightbulb", + "lilypond", + "lovelace", + "manni", + "material", + "monokai", + "murphy", + "native", + "nord-darker", + "nord", + "one-dark", + "paraiso-dark", + "paraiso-light", + "pastie", + "perldoc", + "rainbow_dash", + "rrt", + "sas", + "solarized-dark", + "solarized-light", + "staroffice", + "stata-dark", + "stata-light", + "tango", + "trac", + "vim", + "vs", + "xcode", + "zenburn", + ] + + class FormatterKwargs(TypedDict, total=False): + style: KnownStyle | str + full: bool + title: str + encoding: str + outencoding: str + + +MONO_FAMILIES = [ + "Menlo", + "Courier New", + "Courier", + "Monaco", + "Consolas", + "Andale Mono", + "Source Code Pro", + "monospace", +] + + +# inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py +# (MIT license) and +# https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter +def get_text_char_format(style: _StyleDict) -> QTextCharFormat: + """Return a QTextCharFormat object based on the given Pygments `_StyleDict`. + + style will likely have these keys: + - color: str | None + - bold: bool + - italic: bool + - underline: bool + - bgcolor: str | None + - border: str | None + - roman: bool | None + - sans: bool | None + - mono: bool | None + - ansicolor: str | None + - bgansicolor: str | None + """ + text_char_format = QTextCharFormat() + if style.get("mono"): + text_char_format.setFontFamilies(MONO_FAMILIES) + if color := style.get("color"): + text_char_format.setForeground(QColor(f"#{color}")) + if bgcolor := style.get("bgcolor"): + text_char_format.setBackground(QColor(bgcolor)) + if style.get("bold"): + text_char_format.setFontWeight(QFont.Weight.Bold) + if style.get("italic"): + text_char_format.setFontItalic(True) + if style.get("underline"): + text_char_format.setFontUnderline(True) + # if style.get("border"): + # ... + return text_char_format + + +class QFormatter(Formatter): + def __init__(self, **kwargs: Unpack[FormatterKwargs]) -> None: + super().__init__(**kwargs) + self.data: list[QTextCharFormat] = [] + style = cast("pygments.style.StyleMeta", self.style) + self._style: Mapping[_TokenType, QTextCharFormat] + self._style = {token: get_text_char_format(style) for token, style in style} + + def format( + self, tokensource: Sequence[tuple[_TokenType, str]], outfile: Any + ) -> None: + """Format the given token stream. + + When Qt calls the highlightBlock method on a `CodeSyntaxHighlight` object, + `highlight(text, self.lexer, self.formatter)`, which trigger pygments to call + this method. + + Normally, this method puts output into `outfile`, but in Qt we do not produce + string output; instead we collect QTextCharFormat objects in `self.data`, which + can be used to apply formatting in the `highlightBlock` method that triggered + this method. + """ + self.data = [] + null = QTextCharFormat() + for token, value in tokensource: + # using get method to workaround not defined style for plain token + # https://github.com/pygments/pygments/issues/2149 + self.data.extend([self._style.get(token, null)] * len(value)) + + +class CodeSyntaxHighlight(QSyntaxHighlighter): + """A syntax highlighter for code using Pygments. + + Parameters + ---------- + parent : QTextDocument | QObject | None + The parent object. Usually a QTextDocument. To use this class with a + QTextArea, pass in `text_area.document()`. + lang : str + The language of the code to highlight. This should be a string that + Pygments recognizes, e.g. 'python', 'pytb', 'cpp', 'java', etc. + theme : KnownStyle | str + The name of the Pygments style to use. For a complete list of available + styles, use `pygments.styles.get_all_styles()`. + + Examples + -------- + ```python + from qtpy.QtWidgets import QTextEdit + from superqt.utils import CodeSyntaxHighlight + + text_area = QTextEdit() + highlighter = CodeSyntaxHighlight(text_area.document(), "python", "monokai") + + # then manually apply the background color to the text area. + palette = text_area.palette() + bgrd_color = QColor(self._highlight.background_color) + palette.setColor(QPalette.ColorRole.Base, bgrd_color) + text_area.setPalette(palette) + ``` + """ + + def __init__( + self, + parent: SupportsDocumentAndPalette | QTextDocument | QObject | None, + lang: str, + theme: KnownStyle | str = "default", + ) -> None: + self._doc_parent: SupportsDocumentAndPalette | None = None + if ( + parent + and not isinstance(parent, QTextDocument) + and hasattr(parent, "document") + and callable(parent.document) + and isinstance(doc := parent.document(), QTextDocument) + ): + if hasattr(parent, "palette") and hasattr(parent, "setPalette"): + self._doc_parent = cast("SupportsDocumentAndPalette", parent) + parent = doc + + super().__init__(parent) + self.setLanguage(lang) + self.setTheme(theme) + + def setTheme(self, theme: KnownStyle | str) -> None: + """Set the theme for the syntax highlighting. + + This should be a string that Pygments recognizes, e.g. 'monokai', 'solarized'. + Use `pygments.styles.get_all_styles()` to see a list of available styles. + """ + self.formatter = QFormatter(style=theme) + if self._doc_parent is not None: + palette = self._doc_parent.palette() + bgrd = QColor(self.background_color) + palette.setColor(QPalette.ColorRole.Base, bgrd) + self._doc_parent.setPalette(palette) + + self.rehighlight() + + def setLanguage(self, lang: str) -> None: + """Set the language for the syntax highlighting. + + This should be a string that Pygments recognizes, e.g. 'python', 'pytb', 'cpp', + 'java', etc. + """ + try: + self.lexer = get_lexer_by_name(lang) + except ClassNotFound as e: + if cls := find_lexer_class(lang): + self.lexer = cls() + else: + raise ValueError(f"Could not find lexer for language {lang!r}.") from e + + @property + def background_color(self) -> str: + style = cast("pygments.style.StyleMeta", self.formatter.style) + return style.background_color + + def highlightBlock(self, text: str | None) -> None: + # dirty, dirty hack + # The core problem is that pygments by default use string streams, + # that will not handle QTextCharFormat, so we need use `data` property to + # work around this. + if text: + highlight(text, self.lexer, self.formatter) + for i in range(len(text)): + self.setFormat(i, 1, self.formatter.data[i]) diff --git a/src/pymmcore_gui/widgets/_exception_log.py b/src/pymmcore_gui/widgets/_exception_log.py index 002874e4..cbf1ba62 100644 --- a/src/pymmcore_gui/widgets/_exception_log.py +++ b/src/pymmcore_gui/widgets/_exception_log.py @@ -20,10 +20,11 @@ QVBoxLayout, QWidget, ) -from superqt.utils import CodeSyntaxHighlight from pymmcore_gui import _app +from ._code_syntax_highlight import CodeSyntaxHighlight + if TYPE_CHECKING: from types import TracebackType from typing import TypeAlias diff --git a/uv.lock b/uv.lock index 3a5319b8..f5cd64f1 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,10 @@ version = 1 requires-python = ">=3.10" resolution-markers = [ - "python_full_version < '3.11'", "python_full_version == '3.11.*'", "python_full_version == '3.12.*'", "python_full_version >= '3.13'", + "python_full_version < '3.11'", ] [[package]] @@ -195,14 +195,14 @@ wheels = [ [[package]] name = "click" -version = "8.1.7" +version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "platform_system == 'Windows'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, ] [[package]] @@ -503,7 +503,7 @@ wheels = [ [[package]] name = "ipython" -version = "8.30.0" +version = "8.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -518,9 +518,9 @@ dependencies = [ { name = "traitlets" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/8b/710af065ab8ed05649afa5bd1e07401637c9ec9fb7cfda9eac7e91e9fbd4/ipython-8.30.0.tar.gz", hash = "sha256:cb0a405a306d2995a5cbb9901894d240784a9f341394c6ba3f4fe8c6eb89ff6e", size = 5592205 } +sdist = { url = "https://files.pythonhosted.org/packages/01/35/6f90fdddff7a08b7b715fccbd2427b5212c9525cd043d26fdc45bee0708d/ipython-8.31.0.tar.gz", hash = "sha256:b6a2274606bec6166405ff05e54932ed6e5cfecaca1fc05f2cacde7bb074d70b", size = 5501011 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/f3/1332ba2f682b07b304ad34cad2f003adcfeb349486103f4b632335074a7c/ipython-8.30.0-py3-none-any.whl", hash = "sha256:85ec56a7e20f6c38fce7727dcca699ae4ffc85985aa7b23635a8008f918ae321", size = 820765 }, + { url = "https://files.pythonhosted.org/packages/04/60/d0feb6b6d9fe4ab89fe8fe5b47cbf6cd936bfd9f1e7ffa9d0015425aeed6/ipython-8.31.0-py3-none-any.whl", hash = "sha256:46ec58f8d3d076a61d128fe517a51eb730e3aaf0c184ea8c17d16e366660c6a6", size = 821583 }, ] [[package]] @@ -705,36 +705,36 @@ wheels = [ [[package]] name = "mypy" -version = "1.13.0" +version = "1.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/8c/206de95a27722b5b5a8c85ba3100467bd86299d92a4f71c6b9aa448bfa2f/mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a", size = 11020731 }, - { url = "https://files.pythonhosted.org/packages/ab/bb/b31695a29eea76b1569fd28b4ab141a1adc9842edde080d1e8e1776862c7/mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80", size = 10184276 }, - { url = "https://files.pythonhosted.org/packages/a5/2d/4a23849729bb27934a0e079c9c1aad912167d875c7b070382a408d459651/mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7", size = 12587706 }, - { url = "https://files.pythonhosted.org/packages/5c/c3/d318e38ada50255e22e23353a469c791379825240e71b0ad03e76ca07ae6/mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f", size = 13105586 }, - { url = "https://files.pythonhosted.org/packages/4a/25/3918bc64952370c3dbdbd8c82c363804678127815febd2925b7273d9482c/mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372", size = 9632318 }, - { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027 }, - { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699 }, - { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263 }, - { url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688 }, - { url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811 }, - { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, - { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, - { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, - { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, - { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, - { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, - { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, - { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, - { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, - { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, - { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, +sdist = { url = "https://files.pythonhosted.org/packages/8c/7b/08046ef9330735f536a09a2e31b00f42bccdb2795dcd979636ba43bb2d63/mypy-1.14.0.tar.gz", hash = "sha256:822dbd184d4a9804df5a7d5335a68cf7662930e70b8c1bc976645d1509f9a9d6", size = 3215684 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/97/f00ded038482230e0beaaa08f9c5483a54530b362ad1b0d752d5d2b2f211/mypy-1.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e971c1c667007f9f2b397ffa80fa8e1e0adccff336e5e77e74cb5f22868bee87", size = 11207956 }, + { url = "https://files.pythonhosted.org/packages/68/67/8b4db0da19c9e3fa6264e948f1c135ab4dd45bede1809f4fdb613dc119f6/mypy-1.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e86aaeaa3221a278c66d3d673b297232947d873773d61ca3ee0e28b2ff027179", size = 10363681 }, + { url = "https://files.pythonhosted.org/packages/f5/00/56b1619ff1f3fcad2d411eccda60d74d20e73bda39c218d5ad2769980682/mypy-1.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1628c5c3ce823d296e41e2984ff88c5861499041cb416a8809615d0c1f41740e", size = 12832976 }, + { url = "https://files.pythonhosted.org/packages/e7/8b/9247838774b0bd865f190cc221822212091317f16310305ef924d9772532/mypy-1.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fadb29b77fc14a0dd81304ed73c828c3e5cde0016c7e668a86a3e0dfc9f3af3", size = 13013704 }, + { url = "https://files.pythonhosted.org/packages/b2/69/0c0868a6f3d9761d2f704d1fb6ef84d75998c27d342738a8b20f109a411f/mypy-1.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:3fa76988dc760da377c1e5069200a50d9eaaccf34f4ea18428a3337034ab5a44", size = 9782230 }, + { url = "https://files.pythonhosted.org/packages/34/c1/b9dd3e955953aec1c728992545b7877c9f6fa742a623ce4c200da0f62540/mypy-1.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6e73c8a154eed31db3445fe28f63ad2d97b674b911c00191416cf7f6459fd49a", size = 11121032 }, + { url = "https://files.pythonhosted.org/packages/ee/96/c52d5d516819ab95bf41f4a1ada828a3decc302f8c152ff4fc5feb0e4529/mypy-1.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:273e70fcb2e38c5405a188425aa60b984ffdcef65d6c746ea5813024b68c73dc", size = 10286294 }, + { url = "https://files.pythonhosted.org/packages/69/2c/3dbe51877a24daa467f8d8631f9ffd1aabbf0f6d9367a01c44a59df81fe0/mypy-1.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1daca283d732943731a6a9f20fdbcaa927f160bc51602b1d4ef880a6fb252015", size = 12746528 }, + { url = "https://files.pythonhosted.org/packages/a1/a8/eb20cde4ba9c4c3e20d958918a7c5d92210f4d1a0200c27de9a641f70996/mypy-1.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7e68047bedb04c1c25bba9901ea46ff60d5eaac2d71b1f2161f33107e2b368eb", size = 12883489 }, + { url = "https://files.pythonhosted.org/packages/91/17/a1fc6c70f31d52c99299320cf81c3cb2c6b91ec7269414e0718a6d138e34/mypy-1.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:7a52f26b9c9b1664a60d87675f3bae00b5c7f2806e0c2800545a32c325920bcc", size = 9780113 }, + { url = "https://files.pythonhosted.org/packages/fe/d8/0e72175ee0253217f5c44524f5e95251c02e95ba9749fb87b0e2074d203a/mypy-1.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d5326ab70a6db8e856d59ad4cb72741124950cbbf32e7b70e30166ba7bbf61dd", size = 11269011 }, + { url = "https://files.pythonhosted.org/packages/e9/6d/4ea13839dabe5db588dc6a1b766da16f420d33cf118a7b7172cdf6c7fcb2/mypy-1.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf4ec4980bec1e0e24e5075f449d014011527ae0055884c7e3abc6a99cd2c7f1", size = 10253076 }, + { url = "https://files.pythonhosted.org/packages/3e/38/7db2c5d0f4d290e998f7a52b2e2616c7bbad96b8e04278ab09d11978a29e/mypy-1.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:390dfb898239c25289495500f12fa73aa7f24a4c6d90ccdc165762462b998d63", size = 12862786 }, + { url = "https://files.pythonhosted.org/packages/bf/4b/62d59c801b34141040989949c2b5c157d0408b45357335d3ec5b2845b0f6/mypy-1.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e026d55ddcd76e29e87865c08cbe2d0104e2b3153a523c529de584759379d3d", size = 12971568 }, + { url = "https://files.pythonhosted.org/packages/f1/9c/e0f281b32d70c87b9e4d2939e302b1ff77ada4d7b0f2fb32890c144bc1d6/mypy-1.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:585ed36031d0b3ee362e5107ef449a8b5dfd4e9c90ccbe36414ee405ee6b32ba", size = 9879477 }, + { url = "https://files.pythonhosted.org/packages/13/33/8380efd0ebdfdfac7fc0bf065f03a049800ca1e6c296ec1afc634340d992/mypy-1.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9f6f4c0b27401d14c483c622bc5105eff3911634d576bbdf6695b9a7c1ba741", size = 11251509 }, + { url = "https://files.pythonhosted.org/packages/15/6d/4e1c21c60fee11af7d8e4f2902a29886d1387d6a836be16229eb3982a963/mypy-1.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b2280cedcb312c7a79f5001ae5325582d0d339bce684e4a529069d0e7ca1e7", size = 10244282 }, + { url = "https://files.pythonhosted.org/packages/8b/cf/7a8ae5c0161edae15d25c2c67c68ce8b150cbdc45aefc13a8be271ee80b2/mypy-1.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:342de51c48bab326bfc77ce056ba08c076d82ce4f5a86621f972ed39970f94d8", size = 12867676 }, + { url = "https://files.pythonhosted.org/packages/9c/d0/71f7bbdcc7cfd0f2892db5b13b1e8857673f2cc9e0c30e3e4340523dc186/mypy-1.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00df23b42e533e02a6f0055e54de9a6ed491cd8b7ea738647364fd3a39ea7efc", size = 12964189 }, + { url = "https://files.pythonhosted.org/packages/a7/40/fb4ad65d6d5f8c51396ecf6305ec0269b66013a5bf02d0e9528053640b4a/mypy-1.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8c8387e5d9dff80e7daf961df357c80e694e942d9755f3ad77d69b0957b8e3f", size = 9888247 }, + { url = "https://files.pythonhosted.org/packages/39/32/0214608af400cdf8f5102144bb8af10d880675c65ed0b58f7e0e77175d50/mypy-1.14.0-py3-none-any.whl", hash = "sha256:2238d7f93fc4027ed1efc944507683df3ba406445a2b6c96e79666a045aadfab", size = 2752803 }, ] [[package]] @@ -813,64 +813,64 @@ wheels = [ [[package]] name = "numpy" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/1b/1d565e0f6e156e1522ab564176b8b29d71e13d8caf003a08768df3d5cec5/numpy-2.2.0.tar.gz", hash = "sha256:140dd80ff8981a583a60980be1a655068f8adebf7a45a06a6858c873fcdcd4a0", size = 20225497 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/81/3882353e097204fe4d7a5fe026b694b0104b78f930c969faadeed1538e00/numpy-2.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1e25507d85da11ff5066269d0bd25d06e0a0f2e908415534f3e603d2a78e4ffa", size = 21212476 }, - { url = "https://files.pythonhosted.org/packages/2c/64/5577dc71240272749e07fcacb47c0f29e31ba4fbd1613fefbd1aa88efc29/numpy-2.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a62eb442011776e4036af5c8b1a00b706c5bc02dc15eb5344b0c750428c94219", size = 14351441 }, - { url = "https://files.pythonhosted.org/packages/c9/43/850c040481c19c1c2289203a606df1a202eeb3aa81440624bac891024f83/numpy-2.2.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:b606b1aaf802e6468c2608c65ff7ece53eae1a6874b3765f69b8ceb20c5fa78e", size = 5390304 }, - { url = "https://files.pythonhosted.org/packages/73/96/a4c8a86300dbafc7e4f44d8986f8b64950b7f4640a2dc5c91e036afe28c6/numpy-2.2.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:36b2b43146f646642b425dd2027730f99bac962618ec2052932157e213a040e9", size = 6925476 }, - { url = "https://files.pythonhosted.org/packages/0c/0a/22129c3107c4fb237f97876df4399a5c3a83f3d95f86e0353ae6fbbd202f/numpy-2.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fe8f3583e0607ad4e43a954e35c1748b553bfe9fdac8635c02058023277d1b3", size = 14329997 }, - { url = "https://files.pythonhosted.org/packages/4c/49/c2adeccc8a47bcd9335ec000dfcb4de34a7c34aeaa23af57cd504017e8c3/numpy-2.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:122fd2fcfafdefc889c64ad99c228d5a1f9692c3a83f56c292618a59aa60ae83", size = 16378908 }, - { url = "https://files.pythonhosted.org/packages/8d/85/b65f4596748cc5468c0a978a16b3be45f6bcec78339b0fe7bce71d121d89/numpy-2.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3f2f5cddeaa4424a0a118924b988746db6ffa8565e5829b1841a8a3bd73eb59a", size = 15540949 }, - { url = "https://files.pythonhosted.org/packages/ff/b3/3b18321c94a6a6a1d972baf1b39a6de50e65c991002c014ffbcce7e09be8/numpy-2.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fe4bb0695fe986a9e4deec3b6857003b4cfe5c5e4aac0b95f6a658c14635e31", size = 18167677 }, - { url = "https://files.pythonhosted.org/packages/41/f0/fa2a76e893a05764e4474f6011575c4e4ccf32af9c95bfcc8ef4b8a99f69/numpy-2.2.0-cp310-cp310-win32.whl", hash = "sha256:b30042fe92dbd79f1ba7f6898fada10bdaad1847c44f2dff9a16147e00a93661", size = 6570288 }, - { url = "https://files.pythonhosted.org/packages/97/4e/0b7debcd013214db224997b0d3e39bb7b3656d37d06dfc31bb57d42d143b/numpy-2.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:54dc1d6d66f8d37843ed281773c7174f03bf7ad826523f73435deb88ba60d2d4", size = 12912730 }, - { url = "https://files.pythonhosted.org/packages/80/1b/736023977a96e787c4e7653a1ac2d31d4f6ab6b4048f83c8359f7c0af2e3/numpy-2.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9874bc2ff574c40ab7a5cbb7464bf9b045d617e36754a7bc93f933d52bd9ffc6", size = 21216607 }, - { url = "https://files.pythonhosted.org/packages/85/4f/5f0be4c5c93525e663573bab9e29bd88a71f85de3a0d01413ee05bce0c2f/numpy-2.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0da8495970f6b101ddd0c38ace92edea30e7e12b9a926b57f5fabb1ecc25bb90", size = 14387756 }, - { url = "https://files.pythonhosted.org/packages/36/78/c38af7833c4f29999cdacdf12452b43b660cd25a1990ea9a7edf1fb01f17/numpy-2.2.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0557eebc699c1c34cccdd8c3778c9294e8196df27d713706895edc6f57d29608", size = 5388483 }, - { url = "https://files.pythonhosted.org/packages/e9/b5/306ac6ee3f8f0c51abd3664ee8a9b8e264cbf179a860674827151ecc0a9c/numpy-2.2.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:3579eaeb5e07f3ded59298ce22b65f877a86ba8e9fe701f5576c99bb17c283da", size = 6929721 }, - { url = "https://files.pythonhosted.org/packages/ea/15/e33a7d86d8ce91de82c34ce94a87f2b8df891e603675e83ec7039325ff10/numpy-2.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40deb10198bbaa531509aad0cd2f9fadb26c8b94070831e2208e7df543562b74", size = 14334667 }, - { url = "https://files.pythonhosted.org/packages/52/33/10825f580f42a353f744abc450dcd2a4b1e6f1931abb0ccbd1d63bd3993c/numpy-2.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2aed8fcf8abc3020d6a9ccb31dbc9e7d7819c56a348cc88fd44be269b37427e", size = 16390204 }, - { url = "https://files.pythonhosted.org/packages/b4/24/36cce77559572bdc6c8bcdd2f3e0db03c7079d14b9a1cd342476d7f451e8/numpy-2.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a222d764352c773aa5ebde02dd84dba3279c81c6db2e482d62a3fa54e5ece69b", size = 15556123 }, - { url = "https://files.pythonhosted.org/packages/05/51/2d706d14adee8f5c70c5de3831673d4d57051fc9ac6f3f6bff8811d2f9bd/numpy-2.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4e58666988605e251d42c2818c7d3d8991555381be26399303053b58a5bbf30d", size = 18179898 }, - { url = "https://files.pythonhosted.org/packages/8a/e7/ea8b7652564113f218e75b296e3545a256d88b233021f792fd08591e8f33/numpy-2.2.0-cp311-cp311-win32.whl", hash = "sha256:4723a50e1523e1de4fccd1b9a6dcea750c2102461e9a02b2ac55ffeae09a4410", size = 6568146 }, - { url = "https://files.pythonhosted.org/packages/d0/06/3d1ff6ed377cb0340baf90487a35f15f9dc1db8e0a07de2bf2c54a8e490f/numpy-2.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:16757cf28621e43e252c560d25b15f18a2f11da94fea344bf26c599b9cf54b73", size = 12916677 }, - { url = "https://files.pythonhosted.org/packages/7f/bc/a20dc4e1d051149052762e7647455311865d11c603170c476d1e910a353e/numpy-2.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cff210198bb4cae3f3c100444c5eaa573a823f05c253e7188e1362a5555235b3", size = 20909153 }, - { url = "https://files.pythonhosted.org/packages/60/3d/ac4fb63f36db94f4c7db05b45e3ecb3f88f778ca71850664460c78cfde41/numpy-2.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b92a5828bd4d9aa0952492b7de803135038de47343b2aa3cc23f3b71a3dc4e", size = 14095021 }, - { url = "https://files.pythonhosted.org/packages/41/6d/a654d519d24e4fcc7a83d4a51209cda086f26cf30722b3d8ffc1aa9b775e/numpy-2.2.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ebe5e59545401fbb1b24da76f006ab19734ae71e703cdb4a8b347e84a0cece67", size = 5125491 }, - { url = "https://files.pythonhosted.org/packages/e6/22/fab7e1510a62e5092f4e6507a279020052b89f11d9cfe52af7f52c243b04/numpy-2.2.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e2b8cd48a9942ed3f85b95ca4105c45758438c7ed28fff1e4ce3e57c3b589d8e", size = 6658534 }, - { url = "https://files.pythonhosted.org/packages/fc/29/a3d938ddc5a534cd53df7ab79d20a68db8c67578de1df0ae0118230f5f54/numpy-2.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57fcc997ffc0bef234b8875a54d4058afa92b0b0c4223fc1f62f24b3b5e86038", size = 14046306 }, - { url = "https://files.pythonhosted.org/packages/90/24/d0bbb56abdd8934f30384632e3c2ca1ebfeb5d17e150c6e366ba291de36b/numpy-2.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85ad7d11b309bd132d74397fcf2920933c9d1dc865487128f5c03d580f2c3d03", size = 16095819 }, - { url = "https://files.pythonhosted.org/packages/99/9c/58a673faa9e8a0e77248e782f7a17410cf7259b326265646fd50ed49c4e1/numpy-2.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cb24cca1968b21355cc6f3da1a20cd1cebd8a023e3c5b09b432444617949085a", size = 15243215 }, - { url = "https://files.pythonhosted.org/packages/9c/61/f311693f78cbf635cfb69ce9e1e857ff83937a27d93c96ac5932fd33e330/numpy-2.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0798b138c291d792f8ea40fe3768610f3c7dd2574389e37c3f26573757c8f7ef", size = 17860175 }, - { url = "https://files.pythonhosted.org/packages/11/3e/491c34262cb1fc9dd13a00beb80d755ee0517b17db20e54cac7aa524533e/numpy-2.2.0-cp312-cp312-win32.whl", hash = "sha256:afe8fb968743d40435c3827632fd36c5fbde633b0423da7692e426529b1759b1", size = 6273281 }, - { url = "https://files.pythonhosted.org/packages/89/ea/00537f599eb230771157bc509f6ea5b2dddf05d4b09f9d2f1d7096a18781/numpy-2.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:3a4199f519e57d517ebd48cb76b36c82da0360781c6a0353e64c0cac30ecaad3", size = 12613227 }, - { url = "https://files.pythonhosted.org/packages/bd/4c/0d1eef206545c994289e7a9de21b642880a11e0ed47a2b0c407c688c4f69/numpy-2.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f8c8b141ef9699ae777c6278b52c706b653bf15d135d302754f6b2e90eb30367", size = 20895707 }, - { url = "https://files.pythonhosted.org/packages/16/cb/88f6c1e6df83002c421d5f854ccf134aa088aa997af786a5dac3f32ec99b/numpy-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f0986e917aca18f7a567b812ef7ca9391288e2acb7a4308aa9d265bd724bdae", size = 14110592 }, - { url = "https://files.pythonhosted.org/packages/b4/54/817e6894168a43f33dca74199ba0dd0f1acd99aa6323ed6d323d63d640a2/numpy-2.2.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:1c92113619f7b272838b8d6702a7f8ebe5edea0df48166c47929611d0b4dea69", size = 5110858 }, - { url = "https://files.pythonhosted.org/packages/c7/99/00d8a1a8eb70425bba7880257ed73fed08d3e8d05da4202fb6b9a81d5ee4/numpy-2.2.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5a145e956b374e72ad1dff82779177d4a3c62bc8248f41b80cb5122e68f22d13", size = 6645143 }, - { url = "https://files.pythonhosted.org/packages/34/86/5b9c2b7c56e7a9d9297a0a4be0b8433f498eba52a8f5892d9132b0f64627/numpy-2.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18142b497d70a34b01642b9feabb70156311b326fdddd875a9981f34a369b671", size = 14042812 }, - { url = "https://files.pythonhosted.org/packages/df/54/13535f74391dbe5f479ceed96f1403267be302c840040700d4fd66688089/numpy-2.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7d41d1612c1a82b64697e894b75db6758d4f21c3ec069d841e60ebe54b5b571", size = 16093419 }, - { url = "https://files.pythonhosted.org/packages/dd/37/dfb2056842ac61315f225aa56f455da369f5223e4c5a38b91d20da1b628b/numpy-2.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a98f6f20465e7618c83252c02041517bd2f7ea29be5378f09667a8f654a5918d", size = 15238969 }, - { url = "https://files.pythonhosted.org/packages/5a/3d/d20d24ee313992f0b7e7b9d9eef642d9b545d39d5b91c4a2cc8c98776328/numpy-2.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e09d40edfdb4e260cb1567d8ae770ccf3b8b7e9f0d9b5c2a9992696b30ce2742", size = 17855705 }, - { url = "https://files.pythonhosted.org/packages/5b/40/944c9ee264f875a2db6f79380944fd2b5bb9d712bb4a134d11f45ad5b693/numpy-2.2.0-cp313-cp313-win32.whl", hash = "sha256:3905a5fffcc23e597ee4d9fb3fcd209bd658c352657548db7316e810ca80458e", size = 6270078 }, - { url = "https://files.pythonhosted.org/packages/30/04/e1ee6f8b22034302d4c5c24e15782bdedf76d90b90f3874ed0b48525def0/numpy-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a184288538e6ad699cbe6b24859206e38ce5fba28f3bcfa51c90d0502c1582b2", size = 12605791 }, - { url = "https://files.pythonhosted.org/packages/ef/fb/51d458625cd6134d60ac15180ae50995d7d21b0f2f92a6286ae7b0792d19/numpy-2.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7832f9e8eb00be32f15fdfb9a981d6955ea9adc8574c521d48710171b6c55e95", size = 20920160 }, - { url = "https://files.pythonhosted.org/packages/b4/34/162ae0c5d2536ea4be98c813b5161c980f0443cd5765fde16ddfe3450140/numpy-2.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f0dd071b95bbca244f4cb7f70b77d2ff3aaaba7fa16dc41f58d14854a6204e6c", size = 14119064 }, - { url = "https://files.pythonhosted.org/packages/17/6c/4195dd0e1c41c55f466d516e17e9e28510f32af76d23061ea3da67438e3c/numpy-2.2.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:b0b227dcff8cdc3efbce66d4e50891f04d0a387cce282fe1e66199146a6a8fca", size = 5152778 }, - { url = "https://files.pythonhosted.org/packages/2f/47/ea804ae525832c8d05ed85b560dfd242d34e4bb0962bc269ccaa720fb934/numpy-2.2.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:6ab153263a7c5ccaf6dfe7e53447b74f77789f28ecb278c3b5d49db7ece10d6d", size = 6667605 }, - { url = "https://files.pythonhosted.org/packages/76/99/34d20e50b3d894bb16b5374bfbee399ab8ff3a33bf1e1f0b8acfe7bbd70d/numpy-2.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e500aba968a48e9019e42c0c199b7ec0696a97fa69037bea163b55398e390529", size = 14013275 }, - { url = "https://files.pythonhosted.org/packages/69/8f/a1df7bd02d434ab82539517d1b98028985700cfc4300bc5496fb140ca648/numpy-2.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:440cfb3db4c5029775803794f8638fbdbf71ec702caf32735f53b008e1eaece3", size = 16074900 }, - { url = "https://files.pythonhosted.org/packages/04/94/b419e7a76bf21a00fcb03c613583f10e389fdc8dfe420412ff5710c8ad3d/numpy-2.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a55dc7a7f0b6198b07ec0cd445fbb98b05234e8b00c5ac4874a63372ba98d4ab", size = 15219122 }, - { url = "https://files.pythonhosted.org/packages/65/d9/dddf398b2b6c5d750892a207a469c2854a8db0f033edaf72103af8cf05aa/numpy-2.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4bddbaa30d78c86329b26bd6aaaea06b1e47444da99eddac7bf1e2fab717bd72", size = 17851668 }, - { url = "https://files.pythonhosted.org/packages/d4/dc/09a4e5819a9782a213c0eb4eecacdc1cd75ad8dac99279b04cfccb7eeb0a/numpy-2.2.0-cp313-cp313t-win32.whl", hash = "sha256:30bf971c12e4365153afb31fc73f441d4da157153f3400b82db32d04de1e4066", size = 6325288 }, - { url = "https://files.pythonhosted.org/packages/ce/e1/e0d06ec34036c92b43aef206efe99a5f5f04e12c776eab82a36e00c40afc/numpy-2.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d35717333b39d1b6bb8433fa758a55f1081543de527171543a2b710551d40881", size = 12692303 }, - { url = "https://files.pythonhosted.org/packages/f3/18/6d4e1274f221073058b621f4df8050958b7564b24b4fa25be9f1b7639274/numpy-2.2.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e12c6c1ce84628c52d6367863773f7c8c8241be554e8b79686e91a43f1733773", size = 21043901 }, - { url = "https://files.pythonhosted.org/packages/19/3e/2b20599e7ead7ae1b89a77bb34f88c5ec12e43fbb320576ed646388d2cb7/numpy-2.2.0-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:b6207dc8fb3c8cb5668e885cef9ec7f70189bec4e276f0ff70d5aa078d32c88e", size = 6789122 }, - { url = "https://files.pythonhosted.org/packages/c9/5a/378954132c192fafa6c3d5c160092a427c7562e5bda0cc6ad9cc37008a7a/numpy-2.2.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a50aeff71d0f97b6450d33940c7181b08be1441c6c193e678211bff11aa725e7", size = 16194018 }, - { url = "https://files.pythonhosted.org/packages/67/17/209bda34fc83f3436834392f44643e66dcf3c77465f232102e7f1c7d8eae/numpy-2.2.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:df12a1f99b99f569a7c2ae59aa2d31724e8d835fc7f33e14f4792e3071d11221", size = 12819486 }, +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/fdbf6a7871703df6160b5cf3dd774074b086d278172285c52c2758b76305/numpy-2.2.1.tar.gz", hash = "sha256:45681fd7128c8ad1c379f0ca0776a8b0c6583d2f69889ddac01559dfe4390918", size = 20227662 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/c4/5588367dc9f91e1a813beb77de46ea8cab13f778e1b3a0e661ab031aba44/numpy-2.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5edb4e4caf751c1518e6a26a83501fda79bff41cc59dac48d70e6d65d4ec4440", size = 21213214 }, + { url = "https://files.pythonhosted.org/packages/d8/8b/32dd9f08419023a4cf856c5ad0b4eba9b830da85eafdef841a104c4fc05a/numpy-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa3017c40d513ccac9621a2364f939d39e550c542eb2a894b4c8da92b38896ab", size = 14352248 }, + { url = "https://files.pythonhosted.org/packages/84/2d/0e895d02940ba6e12389f0ab5cac5afcf8dc2dc0ade4e8cad33288a721bd/numpy-2.2.1-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:61048b4a49b1c93fe13426e04e04fdf5a03f456616f6e98c7576144677598675", size = 5391007 }, + { url = "https://files.pythonhosted.org/packages/11/b9/7f1e64a0d46d9c2af6d17966f641fb12d5b8ea3003f31b2308f3e3b9a6aa/numpy-2.2.1-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:7671dc19c7019103ca44e8d94917eba8534c76133523ca8406822efdd19c9308", size = 6926174 }, + { url = "https://files.pythonhosted.org/packages/2e/8c/043fa4418bc9364e364ab7aba8ff6ef5f6b9171ade22de8fbcf0e2fa4165/numpy-2.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4250888bcb96617e00bfa28ac24850a83c9f3a16db471eca2ee1f1714df0f957", size = 14330914 }, + { url = "https://files.pythonhosted.org/packages/f7/b6/d8110985501ca8912dfc1c3bbef99d66e62d487f72e46b2337494df77364/numpy-2.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7746f235c47abc72b102d3bce9977714c2444bdfaea7888d241b4c4bb6a78bf", size = 16379607 }, + { url = "https://files.pythonhosted.org/packages/e2/57/bdca9fb8bdaa810c3a4ff2eb3231379b77f618a7c0d24be9f7070db50775/numpy-2.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:059e6a747ae84fce488c3ee397cee7e5f905fd1bda5fb18c66bc41807ff119b2", size = 15541760 }, + { url = "https://files.pythonhosted.org/packages/97/55/3b9147b3cbc3b6b1abc2a411dec5337a46c873deca0dd0bf5bef9d0579cc/numpy-2.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f62aa6ee4eb43b024b0e5a01cf65a0bb078ef8c395e8713c6e8a12a697144528", size = 18168476 }, + { url = "https://files.pythonhosted.org/packages/00/e7/7c2cde16c9b87a8e14fdd262ca7849c4681cf48c8a774505f7e6f5e3b643/numpy-2.2.1-cp310-cp310-win32.whl", hash = "sha256:48fd472630715e1c1c89bf1feab55c29098cb403cc184b4859f9c86d4fcb6a95", size = 6570985 }, + { url = "https://files.pythonhosted.org/packages/a1/a8/554b0e99fc4ac11ec481254781a10da180d0559c2ebf2c324232317349ee/numpy-2.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:b541032178a718c165a49638d28272b771053f628382d5e9d1c93df23ff58dbf", size = 12913384 }, + { url = "https://files.pythonhosted.org/packages/59/14/645887347124e101d983e1daf95b48dc3e136bf8525cb4257bf9eab1b768/numpy-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:40f9e544c1c56ba8f1cf7686a8c9b5bb249e665d40d626a23899ba6d5d9e1484", size = 21217379 }, + { url = "https://files.pythonhosted.org/packages/9f/fd/2279000cf29f58ccfd3778cbf4670dfe3f7ce772df5e198c5abe9e88b7d7/numpy-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9b57eaa3b0cd8db52049ed0330747b0364e899e8a606a624813452b8203d5f7", size = 14388520 }, + { url = "https://files.pythonhosted.org/packages/58/b0/034eb5d5ba12d66ab658ff3455a31f20add0b78df8203c6a7451bd1bee21/numpy-2.2.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:bc8a37ad5b22c08e2dbd27df2b3ef7e5c0864235805b1e718a235bcb200cf1cb", size = 5389286 }, + { url = "https://files.pythonhosted.org/packages/5d/69/6f3cccde92e82e7835fdb475c2bf439761cbf8a1daa7c07338e1e132dfec/numpy-2.2.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:9036d6365d13b6cbe8f27a0eaf73ddcc070cae584e5ff94bb45e3e9d729feab5", size = 6930345 }, + { url = "https://files.pythonhosted.org/packages/d1/72/1cd38e91ab563e67f584293fcc6aca855c9ae46dba42e6b5ff4600022899/numpy-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51faf345324db860b515d3f364eaa93d0e0551a88d6218a7d61286554d190d73", size = 14335748 }, + { url = "https://files.pythonhosted.org/packages/f2/d4/f999444e86986f3533e7151c272bd8186c55dda554284def18557e013a2a/numpy-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38efc1e56b73cc9b182fe55e56e63b044dd26a72128fd2fbd502f75555d92591", size = 16391057 }, + { url = "https://files.pythonhosted.org/packages/99/7b/85cef6a3ae1b19542b7afd97d0b296526b6ef9e3c43ea0c4d9c4404fb2d0/numpy-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:31b89fa67a8042e96715c68e071a1200c4e172f93b0fbe01a14c0ff3ff820fc8", size = 15556943 }, + { url = "https://files.pythonhosted.org/packages/69/7e/b83cc884c3508e91af78760f6b17ab46ad649831b1fa35acb3eb26d9e6d2/numpy-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4c86e2a209199ead7ee0af65e1d9992d1dce7e1f63c4b9a616500f93820658d0", size = 18180785 }, + { url = "https://files.pythonhosted.org/packages/b2/9f/eb4a9a38867de059dcd4b6e18d47c3867fbd3795d4c9557bb49278f94087/numpy-2.2.1-cp311-cp311-win32.whl", hash = "sha256:b34d87e8a3090ea626003f87f9392b3929a7bbf4104a05b6667348b6bd4bf1cd", size = 6568983 }, + { url = "https://files.pythonhosted.org/packages/6d/1e/be3b9f3073da2f8c7fa361fcdc231b548266b0781029fdbaf75eeab997fd/numpy-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:360137f8fb1b753c5cde3ac388597ad680eccbbbb3865ab65efea062c4a1fd16", size = 12917260 }, + { url = "https://files.pythonhosted.org/packages/62/12/b928871c570d4a87ab13d2cc19f8817f17e340d5481621930e76b80ffb7d/numpy-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:694f9e921a0c8f252980e85bce61ebbd07ed2b7d4fa72d0e4246f2f8aa6642ab", size = 20909861 }, + { url = "https://files.pythonhosted.org/packages/3d/c3/59df91ae1d8ad7c5e03efd63fd785dec62d96b0fe56d1f9ab600b55009af/numpy-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3683a8d166f2692664262fd4900f207791d005fb088d7fdb973cc8d663626faa", size = 14095776 }, + { url = "https://files.pythonhosted.org/packages/af/4e/8ed5868efc8e601fb69419644a280e9c482b75691466b73bfaab7d86922c/numpy-2.2.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:780077d95eafc2ccc3ced969db22377b3864e5b9a0ea5eb347cc93b3ea900315", size = 5126239 }, + { url = "https://files.pythonhosted.org/packages/1a/74/dd0bbe650d7bc0014b051f092f2de65e34a8155aabb1287698919d124d7f/numpy-2.2.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:55ba24ebe208344aa7a00e4482f65742969a039c2acfcb910bc6fcd776eb4355", size = 6659296 }, + { url = "https://files.pythonhosted.org/packages/7f/11/4ebd7a3f4a655764dc98481f97bd0a662fb340d1001be6050606be13e162/numpy-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b1d07b53b78bf84a96898c1bc139ad7f10fda7423f5fd158fd0f47ec5e01ac7", size = 14047121 }, + { url = "https://files.pythonhosted.org/packages/7f/a7/c1f1d978166eb6b98ad009503e4d93a8c1962d0eb14a885c352ee0276a54/numpy-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5062dc1a4e32a10dc2b8b13cedd58988261416e811c1dc4dbdea4f57eea61b0d", size = 16096599 }, + { url = "https://files.pythonhosted.org/packages/3d/6d/0e22afd5fcbb4d8d0091f3f46bf4e8906399c458d4293da23292c0ba5022/numpy-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fce4f615f8ca31b2e61aa0eb5865a21e14f5629515c9151850aa936c02a1ee51", size = 15243932 }, + { url = "https://files.pythonhosted.org/packages/03/39/e4e5832820131ba424092b9610d996b37e5557180f8e2d6aebb05c31ae54/numpy-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:67d4cda6fa6ffa073b08c8372aa5fa767ceb10c9a0587c707505a6d426f4e046", size = 17861032 }, + { url = "https://files.pythonhosted.org/packages/5f/8a/3794313acbf5e70df2d5c7d2aba8718676f8d054a05abe59e48417fb2981/numpy-2.2.1-cp312-cp312-win32.whl", hash = "sha256:32cb94448be47c500d2c7a95f93e2f21a01f1fd05dd2beea1ccd049bb6001cd2", size = 6274018 }, + { url = "https://files.pythonhosted.org/packages/17/c1/c31d3637f2641e25c7a19adf2ae822fdaf4ddd198b05d79a92a9ce7cb63e/numpy-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:ba5511d8f31c033a5fcbda22dd5c813630af98c70b2661f2d2c654ae3cdfcfc8", size = 12613843 }, + { url = "https://files.pythonhosted.org/packages/20/d6/91a26e671c396e0c10e327b763485ee295f5a5a7a48c553f18417e5a0ed5/numpy-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f1d09e520217618e76396377c81fba6f290d5f926f50c35f3a5f72b01a0da780", size = 20896464 }, + { url = "https://files.pythonhosted.org/packages/8c/40/5792ccccd91d45e87d9e00033abc4f6ca8a828467b193f711139ff1f1cd9/numpy-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ecc47cd7f6ea0336042be87d9e7da378e5c7e9b3c8ad0f7c966f714fc10d821", size = 14111350 }, + { url = "https://files.pythonhosted.org/packages/c0/2a/fb0a27f846cb857cef0c4c92bef89f133a3a1abb4e16bba1c4dace2e9b49/numpy-2.2.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f419290bc8968a46c4933158c91a0012b7a99bb2e465d5ef5293879742f8797e", size = 5111629 }, + { url = "https://files.pythonhosted.org/packages/eb/e5/8e81bb9d84db88b047baf4e8b681a3e48d6390bc4d4e4453eca428ecbb49/numpy-2.2.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b6c390bfaef8c45a260554888966618328d30e72173697e5cabe6b285fb2348", size = 6645865 }, + { url = "https://files.pythonhosted.org/packages/7a/1a/a90ceb191dd2f9e2897c69dde93ccc2d57dd21ce2acbd7b0333e8eea4e8d/numpy-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:526fc406ab991a340744aad7e25251dd47a6720a685fa3331e5c59fef5282a59", size = 14043508 }, + { url = "https://files.pythonhosted.org/packages/f1/5a/e572284c86a59dec0871a49cd4e5351e20b9c751399d5f1d79628c0542cb/numpy-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f74e6fdeb9a265624ec3a3918430205dff1df7e95a230779746a6af78bc615af", size = 16094100 }, + { url = "https://files.pythonhosted.org/packages/0c/2c/a79d24f364788386d85899dd280a94f30b0950be4b4a545f4fa4ed1d4ca7/numpy-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:53c09385ff0b72ba79d8715683c1168c12e0b6e84fb0372e97553d1ea91efe51", size = 15239691 }, + { url = "https://files.pythonhosted.org/packages/cf/79/1e20fd1c9ce5a932111f964b544facc5bb9bde7865f5b42f00b4a6a9192b/numpy-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f3eac17d9ec51be534685ba877b6ab5edc3ab7ec95c8f163e5d7b39859524716", size = 17856571 }, + { url = "https://files.pythonhosted.org/packages/be/5b/cc155e107f75d694f562bdc84a26cc930569f3dfdfbccb3420b626065777/numpy-2.2.1-cp313-cp313-win32.whl", hash = "sha256:9ad014faa93dbb52c80d8f4d3dcf855865c876c9660cb9bd7553843dd03a4b1e", size = 6270841 }, + { url = "https://files.pythonhosted.org/packages/44/be/0e5cd009d2162e4138d79a5afb3b5d2341f0fe4777ab6e675aa3d4a42e21/numpy-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:164a829b6aacf79ca47ba4814b130c4020b202522a93d7bff2202bfb33b61c60", size = 12606618 }, + { url = "https://files.pythonhosted.org/packages/a8/87/04ddf02dd86fb17c7485a5f87b605c4437966d53de1e3745d450343a6f56/numpy-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4dfda918a13cc4f81e9118dea249e192ab167a0bb1966272d5503e39234d694e", size = 20921004 }, + { url = "https://files.pythonhosted.org/packages/6e/3e/d0e9e32ab14005425d180ef950badf31b862f3839c5b927796648b11f88a/numpy-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:733585f9f4b62e9b3528dd1070ec4f52b8acf64215b60a845fa13ebd73cd0712", size = 14119910 }, + { url = "https://files.pythonhosted.org/packages/b5/5b/aa2d1905b04a8fb681e08742bb79a7bddfc160c7ce8e1ff6d5c821be0236/numpy-2.2.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:89b16a18e7bba224ce5114db863e7029803c179979e1af6ad6a6b11f70545008", size = 5153612 }, + { url = "https://files.pythonhosted.org/packages/ce/35/6831808028df0648d9b43c5df7e1051129aa0d562525bacb70019c5f5030/numpy-2.2.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:676f4eebf6b2d430300f1f4f4c2461685f8269f94c89698d832cdf9277f30b84", size = 6668401 }, + { url = "https://files.pythonhosted.org/packages/b1/38/10ef509ad63a5946cc042f98d838daebfe7eaf45b9daaf13df2086b15ff9/numpy-2.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f5cdf9f493b35f7e41e8368e7d7b4bbafaf9660cba53fb21d2cd174ec09631", size = 14014198 }, + { url = "https://files.pythonhosted.org/packages/df/f8/c80968ae01df23e249ee0a4487fae55a4c0fe2f838dfe9cc907aa8aea0fa/numpy-2.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1ad395cf254c4fbb5b2132fee391f361a6e8c1adbd28f2cd8e79308a615fe9d", size = 16076211 }, + { url = "https://files.pythonhosted.org/packages/09/69/05c169376016a0b614b432967ac46ff14269eaffab80040ec03ae1ae8e2c/numpy-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:08ef779aed40dbc52729d6ffe7dd51df85796a702afbf68a4f4e41fafdc8bda5", size = 15220266 }, + { url = "https://files.pythonhosted.org/packages/f1/ff/94a4ce67ea909f41cf7ea712aebbe832dc67decad22944a1020bb398a5ee/numpy-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:26c9c4382b19fcfbbed3238a14abf7ff223890ea1936b8890f058e7ba35e8d71", size = 17852844 }, + { url = "https://files.pythonhosted.org/packages/46/72/8a5dbce4020dfc595592333ef2fbb0a187d084ca243b67766d29d03e0096/numpy-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:93cf4e045bae74c90ca833cba583c14b62cb4ba2cba0abd2b141ab52548247e2", size = 6326007 }, + { url = "https://files.pythonhosted.org/packages/7b/9c/4fce9cf39dde2562584e4cfd351a0140240f82c0e3569ce25a250f47037d/numpy-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:bff7d8ec20f5f42607599f9994770fa65d76edca264a87b5e4ea5629bce12268", size = 12693107 }, + { url = "https://files.pythonhosted.org/packages/f1/65/d36a76b811ffe0a4515e290cb05cb0e22171b1b0f0db6bee9141cf023545/numpy-2.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7ba9cc93a91d86365a5d270dee221fdc04fb68d7478e6bf6af650de78a8339e3", size = 21044672 }, + { url = "https://files.pythonhosted.org/packages/aa/3f/b644199f165063154df486d95198d814578f13dd4d8c1651e075bf1cb8af/numpy-2.2.1-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:3d03883435a19794e41f147612a77a8f56d4e52822337844fff3d4040a142964", size = 6789873 }, + { url = "https://files.pythonhosted.org/packages/d7/df/2adb0bb98a3cbe8a6c3c6d1019aede1f1d8b83927ced228a46cc56c7a206/numpy-2.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4511d9e6071452b944207c8ce46ad2f897307910b402ea5fa975da32e0102800", size = 16194933 }, + { url = "https://files.pythonhosted.org/packages/13/3e/1959d5219a9e6d200638d924cedda6a606392f7186a4ed56478252e70d55/numpy-2.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5c5cc0cbabe9452038ed984d05ac87910f89370b9242371bd9079cb4af61811e", size = 12820057 }, ] [[package]] @@ -980,17 +980,17 @@ wheels = [ [[package]] name = "psutil" -version = "6.1.0" +version = "6.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/10/2a30b13c61e7cf937f4adf90710776b7918ed0a9c434e2c38224732af310/psutil-6.1.0.tar.gz", hash = "sha256:353815f59a7f64cdaca1c0307ee13558a0512f6db064e92fe833784f08539c7a", size = 508565 } +sdist = { url = "https://files.pythonhosted.org/packages/1f/5a/07871137bb752428aa4b659f910b399ba6f291156bdea939be3e96cae7cb/psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5", size = 508502 } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/9e/8be43078a171381953cfee33c07c0d628594b5dbfc5157847b85022c2c1b/psutil-6.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6e2dcd475ce8b80522e51d923d10c7871e45f20918e027ab682f94f1c6351688", size = 247762 }, - { url = "https://files.pythonhosted.org/packages/1d/cb/313e80644ea407f04f6602a9e23096540d9dc1878755f3952ea8d3d104be/psutil-6.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0895b8414afafc526712c498bd9de2b063deaac4021a3b3c34566283464aff8e", size = 248777 }, - { url = "https://files.pythonhosted.org/packages/65/8e/bcbe2025c587b5d703369b6a75b65d41d1367553da6e3f788aff91eaf5bd/psutil-6.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9dcbfce5d89f1d1f2546a2090f4fcf87c7f669d1d90aacb7d7582addece9fb38", size = 284259 }, - { url = "https://files.pythonhosted.org/packages/58/4d/8245e6f76a93c98aab285a43ea71ff1b171bcd90c9d238bf81f7021fb233/psutil-6.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:498c6979f9c6637ebc3a73b3f87f9eb1ec24e1ce53a7c5173b8508981614a90b", size = 287255 }, - { url = "https://files.pythonhosted.org/packages/27/c2/d034856ac47e3b3cdfa9720d0e113902e615f4190d5d1bdb8df4b2015fb2/psutil-6.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d905186d647b16755a800e7263d43df08b790d709d575105d419f8b6ef65423a", size = 288804 }, - { url = "https://files.pythonhosted.org/packages/ea/55/5389ed243c878725feffc0d6a3bc5ef6764312b6fc7c081faaa2cfa7ef37/psutil-6.1.0-cp37-abi3-win32.whl", hash = "sha256:1ad45a1f5d0b608253b11508f80940985d1d0c8f6111b5cb637533a0e6ddc13e", size = 250386 }, - { url = "https://files.pythonhosted.org/packages/11/91/87fa6f060e649b1e1a7b19a4f5869709fbf750b7c8c262ee776ec32f3028/psutil-6.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:a8fb3752b491d246034fa4d279ff076501588ce8cbcdbb62c32fd7a377d996be", size = 254228 }, + { url = "https://files.pythonhosted.org/packages/61/99/ca79d302be46f7bdd8321089762dd4476ee725fce16fc2b2e1dbba8cac17/psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8", size = 247511 }, + { url = "https://files.pythonhosted.org/packages/0b/6b/73dbde0dd38f3782905d4587049b9be64d76671042fdcaf60e2430c6796d/psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377", size = 248985 }, + { url = "https://files.pythonhosted.org/packages/17/38/c319d31a1d3f88c5b79c68b3116c129e5133f1822157dd6da34043e32ed6/psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003", size = 284488 }, + { url = "https://files.pythonhosted.org/packages/9c/39/0f88a830a1c8a3aba27fededc642da37613c57cbff143412e3536f89784f/psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160", size = 287477 }, + { url = "https://files.pythonhosted.org/packages/47/da/99f4345d4ddf2845cb5b5bd0d93d554e84542d116934fde07a0c50bd4e9f/psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3", size = 289017 }, + { url = "https://files.pythonhosted.org/packages/38/53/bd755c2896f4461fd4f36fa6a6dcb66a88a9e4b9fd4e5b66a77cf9d4a584/psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53", size = 250602 }, + { url = "https://files.pythonhosted.org/packages/7b/d7/7831438e6c3ebbfa6e01a927127a6cb42ad3ab844247f3c5b96bea25d73d/psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649", size = 254444 }, ] [[package]] @@ -1055,91 +1055,91 @@ wheels = [ [[package]] name = "pydantic" -version = "2.10.3" +version = "2.10.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/0f/27908242621b14e649a84e62b133de45f84c255eecb350ab02979844a788/pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9", size = 786486 } +sdist = { url = "https://files.pythonhosted.org/packages/70/7e/fb60e6fee04d0ef8f15e4e01ff187a196fa976eb0f0ab524af4599e5754c/pydantic-2.10.4.tar.gz", hash = "sha256:82f12e9723da6de4fe2ba888b5971157b3be7ad914267dea8f05f82b28254f06", size = 762094 } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/51/72c18c55cf2f46ff4f91ebcc8f75aa30f7305f3d726be3f4ebffb4ae972b/pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d", size = 456997 }, + { url = "https://files.pythonhosted.org/packages/f3/26/3e1bbe954fde7ee22a6e7d31582c642aad9e84ffe4b5fb61e63b87cd326f/pydantic-2.10.4-py3-none-any.whl", hash = "sha256:597e135ea68be3a37552fb524bc7d0d66dcf93d395acd93a00682f1efcb8ee3d", size = 431765 }, ] [[package]] name = "pydantic-core" -version = "2.27.1" +version = "2.27.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/9f/7de1f19b6aea45aeb441838782d68352e71bfa98ee6fa048d5041991b33e/pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235", size = 412785 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/ce/60fd96895c09738648c83f3f00f595c807cb6735c70d3306b548cc96dd49/pydantic_core-2.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a", size = 1897984 }, - { url = "https://files.pythonhosted.org/packages/fd/b9/84623d6b6be98cc209b06687d9bca5a7b966ffed008d15225dd0d20cce2e/pydantic_core-2.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b", size = 1807491 }, - { url = "https://files.pythonhosted.org/packages/01/72/59a70165eabbc93b1111d42df9ca016a4aa109409db04304829377947028/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278", size = 1831953 }, - { url = "https://files.pythonhosted.org/packages/7c/0c/24841136476adafd26f94b45bb718a78cb0500bd7b4f8d667b67c29d7b0d/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05", size = 1856071 }, - { url = "https://files.pythonhosted.org/packages/53/5e/c32957a09cceb2af10d7642df45d1e3dbd8596061f700eac93b801de53c0/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4", size = 2038439 }, - { url = "https://files.pythonhosted.org/packages/e4/8f/979ab3eccd118b638cd6d8f980fea8794f45018255a36044dea40fe579d4/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f", size = 2787416 }, - { url = "https://files.pythonhosted.org/packages/02/1d/00f2e4626565b3b6d3690dab4d4fe1a26edd6a20e53749eb21ca892ef2df/pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08", size = 2134548 }, - { url = "https://files.pythonhosted.org/packages/9d/46/3112621204128b90898adc2e721a3cd6cf5626504178d6f32c33b5a43b79/pydantic_core-2.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6", size = 1989882 }, - { url = "https://files.pythonhosted.org/packages/49/ec/557dd4ff5287ffffdf16a31d08d723de6762bb1b691879dc4423392309bc/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807", size = 1995829 }, - { url = "https://files.pythonhosted.org/packages/6e/b2/610dbeb74d8d43921a7234555e4c091cb050a2bdb8cfea86d07791ce01c5/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c", size = 2091257 }, - { url = "https://files.pythonhosted.org/packages/8c/7f/4bf8e9d26a9118521c80b229291fa9558a07cdd9a968ec2d5c1026f14fbc/pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206", size = 2143894 }, - { url = "https://files.pythonhosted.org/packages/1f/1c/875ac7139c958f4390f23656fe696d1acc8edf45fb81e4831960f12cd6e4/pydantic_core-2.27.1-cp310-none-win32.whl", hash = "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c", size = 1816081 }, - { url = "https://files.pythonhosted.org/packages/d7/41/55a117acaeda25ceae51030b518032934f251b1dac3704a53781383e3491/pydantic_core-2.27.1-cp310-none-win_amd64.whl", hash = "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17", size = 1981109 }, - { url = "https://files.pythonhosted.org/packages/27/39/46fe47f2ad4746b478ba89c561cafe4428e02b3573df882334bd2964f9cb/pydantic_core-2.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8", size = 1895553 }, - { url = "https://files.pythonhosted.org/packages/1c/00/0804e84a78b7fdb394fff4c4f429815a10e5e0993e6ae0e0b27dd20379ee/pydantic_core-2.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330", size = 1807220 }, - { url = "https://files.pythonhosted.org/packages/01/de/df51b3bac9820d38371f5a261020f505025df732ce566c2a2e7970b84c8c/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52", size = 1829727 }, - { url = "https://files.pythonhosted.org/packages/5f/d9/c01d19da8f9e9fbdb2bf99f8358d145a312590374d0dc9dd8dbe484a9cde/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4", size = 1854282 }, - { url = "https://files.pythonhosted.org/packages/5f/84/7db66eb12a0dc88c006abd6f3cbbf4232d26adfd827a28638c540d8f871d/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c", size = 2037437 }, - { url = "https://files.pythonhosted.org/packages/34/ac/a2537958db8299fbabed81167d58cc1506049dba4163433524e06a7d9f4c/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de", size = 2780899 }, - { url = "https://files.pythonhosted.org/packages/4a/c1/3e38cd777ef832c4fdce11d204592e135ddeedb6c6f525478a53d1c7d3e5/pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025", size = 2135022 }, - { url = "https://files.pythonhosted.org/packages/7a/69/b9952829f80fd555fe04340539d90e000a146f2a003d3fcd1e7077c06c71/pydantic_core-2.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e", size = 1987969 }, - { url = "https://files.pythonhosted.org/packages/05/72/257b5824d7988af43460c4e22b63932ed651fe98804cc2793068de7ec554/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919", size = 1994625 }, - { url = "https://files.pythonhosted.org/packages/73/c3/78ed6b7f3278a36589bcdd01243189ade7fc9b26852844938b4d7693895b/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c", size = 2090089 }, - { url = "https://files.pythonhosted.org/packages/8d/c8/b4139b2f78579960353c4cd987e035108c93a78371bb19ba0dc1ac3b3220/pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc", size = 2142496 }, - { url = "https://files.pythonhosted.org/packages/3e/f8/171a03e97eb36c0b51981efe0f78460554a1d8311773d3d30e20c005164e/pydantic_core-2.27.1-cp311-none-win32.whl", hash = "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9", size = 1811758 }, - { url = "https://files.pythonhosted.org/packages/6a/fe/4e0e63c418c1c76e33974a05266e5633e879d4061f9533b1706a86f77d5b/pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5", size = 1980864 }, - { url = "https://files.pythonhosted.org/packages/50/fc/93f7238a514c155a8ec02fc7ac6376177d449848115e4519b853820436c5/pydantic_core-2.27.1-cp311-none-win_arm64.whl", hash = "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89", size = 1864327 }, - { url = "https://files.pythonhosted.org/packages/be/51/2e9b3788feb2aebff2aa9dfbf060ec739b38c05c46847601134cc1fed2ea/pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f", size = 1895239 }, - { url = "https://files.pythonhosted.org/packages/7b/9e/f8063952e4a7d0127f5d1181addef9377505dcce3be224263b25c4f0bfd9/pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02", size = 1805070 }, - { url = "https://files.pythonhosted.org/packages/2c/9d/e1d6c4561d262b52e41b17a7ef8301e2ba80b61e32e94520271029feb5d8/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c", size = 1828096 }, - { url = "https://files.pythonhosted.org/packages/be/65/80ff46de4266560baa4332ae3181fffc4488ea7d37282da1a62d10ab89a4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac", size = 1857708 }, - { url = "https://files.pythonhosted.org/packages/d5/ca/3370074ad758b04d9562b12ecdb088597f4d9d13893a48a583fb47682cdf/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb", size = 2037751 }, - { url = "https://files.pythonhosted.org/packages/b1/e2/4ab72d93367194317b99d051947c071aef6e3eb95f7553eaa4208ecf9ba4/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529", size = 2733863 }, - { url = "https://files.pythonhosted.org/packages/8a/c6/8ae0831bf77f356bb73127ce5a95fe115b10f820ea480abbd72d3cc7ccf3/pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35", size = 2161161 }, - { url = "https://files.pythonhosted.org/packages/f1/f4/b2fe73241da2429400fc27ddeaa43e35562f96cf5b67499b2de52b528cad/pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089", size = 1993294 }, - { url = "https://files.pythonhosted.org/packages/77/29/4bb008823a7f4cc05828198153f9753b3bd4c104d93b8e0b1bfe4e187540/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381", size = 2001468 }, - { url = "https://files.pythonhosted.org/packages/f2/a9/0eaceeba41b9fad851a4107e0cf999a34ae8f0d0d1f829e2574f3d8897b0/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb", size = 2091413 }, - { url = "https://files.pythonhosted.org/packages/d8/36/eb8697729725bc610fd73940f0d860d791dc2ad557faaefcbb3edbd2b349/pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae", size = 2154735 }, - { url = "https://files.pythonhosted.org/packages/52/e5/4f0fbd5c5995cc70d3afed1b5c754055bb67908f55b5cb8000f7112749bf/pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c", size = 1833633 }, - { url = "https://files.pythonhosted.org/packages/ee/f2/c61486eee27cae5ac781305658779b4a6b45f9cc9d02c90cb21b940e82cc/pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16", size = 1986973 }, - { url = "https://files.pythonhosted.org/packages/df/a6/e3f12ff25f250b02f7c51be89a294689d175ac76e1096c32bf278f29ca1e/pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e", size = 1883215 }, - { url = "https://files.pythonhosted.org/packages/0f/d6/91cb99a3c59d7b072bded9959fbeab0a9613d5a4935773c0801f1764c156/pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073", size = 1895033 }, - { url = "https://files.pythonhosted.org/packages/07/42/d35033f81a28b27dedcade9e967e8a40981a765795c9ebae2045bcef05d3/pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08", size = 1807542 }, - { url = "https://files.pythonhosted.org/packages/41/c2/491b59e222ec7e72236e512108ecad532c7f4391a14e971c963f624f7569/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf", size = 1827854 }, - { url = "https://files.pythonhosted.org/packages/e3/f3/363652651779113189cefdbbb619b7b07b7a67ebb6840325117cc8cc3460/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737", size = 1857389 }, - { url = "https://files.pythonhosted.org/packages/5f/97/be804aed6b479af5a945daec7538d8bf358d668bdadde4c7888a2506bdfb/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2", size = 2037934 }, - { url = "https://files.pythonhosted.org/packages/42/01/295f0bd4abf58902917e342ddfe5f76cf66ffabfc57c2e23c7681a1a1197/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107", size = 2735176 }, - { url = "https://files.pythonhosted.org/packages/9d/a0/cd8e9c940ead89cc37812a1a9f310fef59ba2f0b22b4e417d84ab09fa970/pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51", size = 2160720 }, - { url = "https://files.pythonhosted.org/packages/73/ae/9d0980e286627e0aeca4c352a60bd760331622c12d576e5ea4441ac7e15e/pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a", size = 1992972 }, - { url = "https://files.pythonhosted.org/packages/bf/ba/ae4480bc0292d54b85cfb954e9d6bd226982949f8316338677d56541b85f/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc", size = 2001477 }, - { url = "https://files.pythonhosted.org/packages/55/b7/e26adf48c2f943092ce54ae14c3c08d0d221ad34ce80b18a50de8ed2cba8/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960", size = 2091186 }, - { url = "https://files.pythonhosted.org/packages/ba/cc/8491fff5b608b3862eb36e7d29d36a1af1c945463ca4c5040bf46cc73f40/pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23", size = 2154429 }, - { url = "https://files.pythonhosted.org/packages/78/d8/c080592d80edd3441ab7f88f865f51dae94a157fc64283c680e9f32cf6da/pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05", size = 1833713 }, - { url = "https://files.pythonhosted.org/packages/83/84/5ab82a9ee2538ac95a66e51f6838d6aba6e0a03a42aa185ad2fe404a4e8f/pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337", size = 1987897 }, - { url = "https://files.pythonhosted.org/packages/df/c3/b15fb833926d91d982fde29c0624c9f225da743c7af801dace0d4e187e71/pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5", size = 1882983 }, - { url = "https://files.pythonhosted.org/packages/7c/60/e5eb2d462595ba1f622edbe7b1d19531e510c05c405f0b87c80c1e89d5b1/pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6", size = 1894016 }, - { url = "https://files.pythonhosted.org/packages/61/20/da7059855225038c1c4326a840908cc7ca72c7198cb6addb8b92ec81c1d6/pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676", size = 1771648 }, - { url = "https://files.pythonhosted.org/packages/8f/fc/5485cf0b0bb38da31d1d292160a4d123b5977841ddc1122c671a30b76cfd/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d", size = 1826929 }, - { url = "https://files.pythonhosted.org/packages/a1/ff/fb1284a210e13a5f34c639efc54d51da136074ffbe25ec0c279cf9fbb1c4/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c", size = 1980591 }, - { url = "https://files.pythonhosted.org/packages/f1/14/77c1887a182d05af74f6aeac7b740da3a74155d3093ccc7ee10b900cc6b5/pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27", size = 1981326 }, - { url = "https://files.pythonhosted.org/packages/06/aa/6f1b2747f811a9c66b5ef39d7f02fbb200479784c75e98290d70004b1253/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f", size = 1989205 }, - { url = "https://files.pythonhosted.org/packages/7a/d2/8ce2b074d6835f3c88d85f6d8a399790043e9fdb3d0e43455e72d19df8cc/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed", size = 2079616 }, - { url = "https://files.pythonhosted.org/packages/65/71/af01033d4e58484c3db1e5d13e751ba5e3d6b87cc3368533df4c50932c8b/pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f", size = 2133265 }, - { url = "https://files.pythonhosted.org/packages/33/72/f881b5e18fbb67cf2fb4ab253660de3c6899dbb2dba409d0b757e3559e3d/pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c", size = 2001864 }, +sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, + { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, + { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, + { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, + { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, + { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, + { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, + { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, + { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, + { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, + { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, + { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, + { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, + { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, + { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, + { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, + { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, + { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, + { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, + { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, + { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, + { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, + { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, + { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, + { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, + { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, + { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, + { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, + { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, + { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, + { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, + { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, + { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, + { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, + { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, + { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, + { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, + { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, + { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, + { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, + { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, + { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, + { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, + { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, + { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, + { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, + { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, + { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, + { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, + { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, + { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, + { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, + { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, + { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, + { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, + { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, + { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, + { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, + { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, + { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, + { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, + { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, + { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, ] [[package]] @@ -1176,7 +1176,7 @@ wheels = [ [[package]] name = "pymmcore-gui" -version = "0.1.dev188+g7d12467.d20241217" +version = "0.1.dev201+g5980b63.d20241223" source = { editable = "." } dependencies = [ { name = "ndv", extra = ["vispy"] }, @@ -1185,11 +1185,12 @@ dependencies = [ { name = "pyqt6" }, { name = "pyyaml" }, { name = "qtconsole" }, - { name = "qtpy" }, + { name = "rich" }, { name = "superqt", extra = ["cmap", "iconify"] }, { name = "tifffile" }, { name = "tqdm" }, - { name = "zarr" }, + { name = "zarr", version = "2.18.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "zarr", version = "2.18.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] [package.dev-dependencies] @@ -1213,7 +1214,7 @@ requires-dist = [ { name = "pyqt6", specifier = "==6.7.1" }, { name = "pyyaml", specifier = ">=6.0.2" }, { name = "qtconsole", specifier = ">=5.6.1" }, - { name = "qtpy", specifier = ">=2.4.2" }, + { name = "rich" }, { name = "superqt", extras = ["cmap", "iconify"], specifier = ">=0.7.0" }, { name = "tifffile", specifier = ">=2024.12.12" }, { name = "tqdm", specifier = ">=4.67.1" }, @@ -1606,27 +1607,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.8.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/5e/683c7ef7a696923223e7d95ca06755d6e2acbc5fd8382b2912a28008137c/ruff-0.8.3.tar.gz", hash = "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3", size = 3378522 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/c4/bfdbb8b9c419ff3b52479af8581026eeaac3764946fdb463dec043441b7d/ruff-0.8.3-py3-none-linux_armv6l.whl", hash = "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6", size = 10535860 }, - { url = "https://files.pythonhosted.org/packages/ef/c5/0aabdc9314b4b6f051168ac45227e2aa8e1c6d82718a547455e40c9c9faa/ruff-0.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939", size = 10346327 }, - { url = "https://files.pythonhosted.org/packages/1a/78/4843a59e7e7b398d6019cf91ab06502fd95397b99b2b858798fbab9151f5/ruff-0.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d", size = 9942585 }, - { url = "https://files.pythonhosted.org/packages/91/5a/642ed8f1ba23ffc2dd347697e01eef3c42fad6ac76603be4a8c3a9d6311e/ruff-0.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13", size = 10797597 }, - { url = "https://files.pythonhosted.org/packages/30/25/2e654bc7226da09a49730a1a2ea6e89f843b362db80b4b2a7a4f948ac986/ruff-0.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18", size = 10307244 }, - { url = "https://files.pythonhosted.org/packages/c0/2d/a224d56bcd4383583db53c2b8f410ebf1200866984aa6eb9b5a70f04e71f/ruff-0.8.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502", size = 11362439 }, - { url = "https://files.pythonhosted.org/packages/82/01/03e2857f9c371b8767d3e909f06a33bbdac880df17f17f93d6f6951c3381/ruff-0.8.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d", size = 12078538 }, - { url = "https://files.pythonhosted.org/packages/af/ae/ff7f97b355da16d748ceec50e1604a8215d3659b36b38025a922e0612e9b/ruff-0.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82", size = 11616172 }, - { url = "https://files.pythonhosted.org/packages/6a/d0/6156d4d1e53ebd17747049afe801c5d7e3014d9b2f398b9236fe36ba4320/ruff-0.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452", size = 12919886 }, - { url = "https://files.pythonhosted.org/packages/4e/84/affcb30bacb94f6036a128ad5de0e29f543d3f67ee42b490b17d68e44b8a/ruff-0.8.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd", size = 11212599 }, - { url = "https://files.pythonhosted.org/packages/60/b9/5694716bdefd8f73df7c0104334156c38fb0f77673d2966a5a1345bab94d/ruff-0.8.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20", size = 10784637 }, - { url = "https://files.pythonhosted.org/packages/24/7e/0e8f835103ac7da81c3663eedf79dec8359e9ae9a3b0d704bae50be59176/ruff-0.8.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc", size = 10390591 }, - { url = "https://files.pythonhosted.org/packages/27/da/180ec771fc01c004045962ce017ca419a0281f4bfaf867ed0020f555b56e/ruff-0.8.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060", size = 10894298 }, - { url = "https://files.pythonhosted.org/packages/6d/f8/29f241742ed3954eb2222314b02db29f531a15cab3238d1295e8657c5f18/ruff-0.8.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea", size = 11275965 }, - { url = "https://files.pythonhosted.org/packages/79/e9/5b81dc9afc8a80884405b230b9429efeef76d04caead904bd213f453b973/ruff-0.8.3-py3-none-win32.whl", hash = "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964", size = 8807651 }, - { url = "https://files.pythonhosted.org/packages/ea/67/7291461066007617b59a707887b90e319b6a043c79b4d19979f86b7a20e7/ruff-0.8.3-py3-none-win_amd64.whl", hash = "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9", size = 9625289 }, - { url = "https://files.pythonhosted.org/packages/03/8f/e4fa95288b81233356d9a9dcaed057e5b0adc6399aa8fd0f6d784041c9c3/ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936", size = 9078754 }, +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/37/9c02181ef38d55b77d97c68b78e705fd14c0de0e5d085202bb2b52ce5be9/ruff-0.8.4.tar.gz", hash = "sha256:0d5f89f254836799af1615798caa5f80b7f935d7a670fad66c5007928e57ace8", size = 3402103 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/67/f480bf2f2723b2e49af38ed2be75ccdb2798fca7d56279b585c8f553aaab/ruff-0.8.4-py3-none-linux_armv6l.whl", hash = "sha256:58072f0c06080276804c6a4e21a9045a706584a958e644353603d36ca1eb8a60", size = 10546415 }, + { url = "https://files.pythonhosted.org/packages/eb/7a/5aba20312c73f1ce61814e520d1920edf68ca3b9c507bd84d8546a8ecaa8/ruff-0.8.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ffb60904651c00a1e0b8df594591770018a0f04587f7deeb3838344fe3adabac", size = 10346113 }, + { url = "https://files.pythonhosted.org/packages/76/f4/c41de22b3728486f0aa95383a44c42657b2db4062f3234ca36fc8cf52d8b/ruff-0.8.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ddf5d654ac0d44389f6bf05cee4caeefc3132a64b58ea46738111d687352296", size = 9943564 }, + { url = "https://files.pythonhosted.org/packages/0e/f0/afa0d2191af495ac82d4cbbfd7a94e3df6f62a04ca412033e073b871fc6d/ruff-0.8.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e248b1f0fa2749edd3350a2a342b67b43a2627434c059a063418e3d375cfe643", size = 10805522 }, + { url = "https://files.pythonhosted.org/packages/12/57/5d1e9a0fd0c228e663894e8e3a8e7063e5ee90f8e8e60cf2085f362bfa1a/ruff-0.8.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf197b98ed86e417412ee3b6c893f44c8864f816451441483253d5ff22c0e81e", size = 10306763 }, + { url = "https://files.pythonhosted.org/packages/04/df/f069fdb02e408be8aac6853583572a2873f87f866fe8515de65873caf6b8/ruff-0.8.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c41319b85faa3aadd4d30cb1cffdd9ac6b89704ff79f7664b853785b48eccdf3", size = 11359574 }, + { url = "https://files.pythonhosted.org/packages/d3/04/37c27494cd02e4a8315680debfc6dfabcb97e597c07cce0044db1f9dfbe2/ruff-0.8.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:9f8402b7c4f96463f135e936d9ab77b65711fcd5d72e5d67597b543bbb43cf3f", size = 12094851 }, + { url = "https://files.pythonhosted.org/packages/81/b1/c5d7fb68506cab9832d208d03ea4668da9a9887a4a392f4f328b1bf734ad/ruff-0.8.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4e56b3baa9c23d324ead112a4fdf20db9a3f8f29eeabff1355114dd96014604", size = 11655539 }, + { url = "https://files.pythonhosted.org/packages/ef/38/8f8f2c8898dc8a7a49bc340cf6f00226917f0f5cb489e37075bcb2ce3671/ruff-0.8.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:736272574e97157f7edbbb43b1d046125fce9e7d8d583d5d65d0c9bf2c15addf", size = 12912805 }, + { url = "https://files.pythonhosted.org/packages/06/dd/fa6660c279f4eb320788876d0cff4ea18d9af7d9ed7216d7bd66877468d0/ruff-0.8.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5fe710ab6061592521f902fca7ebcb9fabd27bc7c57c764298b1c1f15fff720", size = 11205976 }, + { url = "https://files.pythonhosted.org/packages/a8/d7/de94cc89833b5de455750686c17c9e10f4e1ab7ccdc5521b8fe911d1477e/ruff-0.8.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:13e9ec6d6b55f6da412d59953d65d66e760d583dd3c1c72bf1f26435b5bfdbae", size = 10792039 }, + { url = "https://files.pythonhosted.org/packages/6d/15/3e4906559248bdbb74854af684314608297a05b996062c9d72e0ef7c7097/ruff-0.8.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:97d9aefef725348ad77d6db98b726cfdb075a40b936c7984088804dfd38268a7", size = 10400088 }, + { url = "https://files.pythonhosted.org/packages/a2/21/9ed4c0e8133cb4a87a18d470f534ad1a8a66d7bec493bcb8bda2d1a5d5be/ruff-0.8.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ab78e33325a6f5374e04c2ab924a3367d69a0da36f8c9cb6b894a62017506111", size = 10900814 }, + { url = "https://files.pythonhosted.org/packages/0d/5d/122a65a18955bd9da2616b69bc839351f8baf23b2805b543aa2f0aed72b5/ruff-0.8.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8ef06f66f4a05c3ddbc9121a8b0cecccd92c5bf3dd43b5472ffe40b8ca10f0f8", size = 11268828 }, + { url = "https://files.pythonhosted.org/packages/43/a9/1676ee9106995381e3d34bccac5bb28df70194167337ed4854c20f27c7ba/ruff-0.8.4-py3-none-win32.whl", hash = "sha256:552fb6d861320958ca5e15f28b20a3d071aa83b93caee33a87b471f99a6c0835", size = 8805621 }, + { url = "https://files.pythonhosted.org/packages/10/98/ed6b56a30ee76771c193ff7ceeaf1d2acc98d33a1a27b8479cbdb5c17a23/ruff-0.8.4-py3-none-win_amd64.whl", hash = "sha256:f21a1143776f8656d7f364bd264a9d60f01b7f52243fbe90e7670c0dfe0cf65d", size = 9660086 }, + { url = "https://files.pythonhosted.org/packages/13/9f/026e18ca7d7766783d779dae5e9c656746c6ede36ef73c6d934aaf4a6dec/ruff-0.8.4-py3-none-win_arm64.whl", hash = "sha256:9183dd615d8df50defa8b1d9a074053891ba39025cf5ae88e8bcb52edcc4bf08", size = 9074500 }, ] [[package]] @@ -1834,11 +1835,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.2.3" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, ] [[package]] @@ -1978,13 +1979,36 @@ wheels = [ name = "zarr" version = "2.18.3" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] dependencies = [ - { name = "asciitree" }, - { name = "fasteners", marker = "sys_platform != 'emscripten'" }, - { name = "numcodecs" }, - { name = "numpy" }, + { name = "asciitree", marker = "python_full_version < '3.11'" }, + { name = "fasteners", marker = "python_full_version < '3.11' and sys_platform != 'emscripten'" }, + { name = "numcodecs", marker = "python_full_version < '3.11'" }, + { name = "numpy", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/23/c4/187a21ce7cf7c8f00c060dd0e04c2a81139bb7b1ab178bba83f2e1134ce2/zarr-2.18.3.tar.gz", hash = "sha256:2580d8cb6dd84621771a10d31c4d777dca8a27706a1a89b29f42d2d37e2df5ce", size = 3603224 } wheels = [ { url = "https://files.pythonhosted.org/packages/ed/c9/142095e654c2b97133ff71df60979422717b29738b08bc8a1709a5d5e0d0/zarr-2.18.3-py3-none-any.whl", hash = "sha256:b1f7dfd2496f436745cdd4c7bcf8d3b4bc1dceef5fdd0d589c87130d842496dd", size = 210723 }, ] + +[[package]] +name = "zarr" +version = "2.18.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", + "python_full_version == '3.12.*'", + "python_full_version >= '3.13'", +] +dependencies = [ + { name = "asciitree", marker = "python_full_version >= '3.11'" }, + { name = "fasteners", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten'" }, + { name = "numcodecs", marker = "python_full_version >= '3.11'" }, + { name = "numpy", marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/d1/764ca5b66d91b20dede66aedc6eb9ede3adbe5c61779e7378a7ecb010e87/zarr-2.18.4.tar.gz", hash = "sha256:37790ededd0683ae1abe6ff90aa16c22543b3436810060f53d72c15e910c24bb", size = 3603684 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/d1/c84022a44afc7b7ccc442fba3daee56bdd03593d91ee4bc245a08e4fcc55/zarr-2.18.4-py3-none-any.whl", hash = "sha256:2795e20aff91093ce7e4da36ab1a138aededbd8ab66bf01fd01512e61d31e5d1", size = 210600 }, +] From 94e6052ec2e515f37b7e3c833143249f7e76cad8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 17:53:50 -0500 Subject: [PATCH 197/226] add pyinstaller dev dep --- pyproject.toml | 3 +- uv.lock | 93 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4521d902..97f6b5cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ dev = [ "mypy>=1.13.0", "pdbpp>=0.10.3 ; sys_platform != 'win32'", "pre-commit>=4.0.1", + "pyinstaller>=6.11.1", "pytest>=8.3.4", "pytest-cov>=6.0.0", "pytest-qt>=4.4.0", @@ -148,4 +149,4 @@ ignore = [".pre-commit-config.yaml", ".ruff_cache/**/*", "tests/**/*"] extend-ignore-identifiers-re = ["(?i)nd2?.*", "(?i)ome"] [tool.typos.files] -extend-exclude = ["*.spec", "hooks/"] \ No newline at end of file +extend-exclude = ["*.spec", "hooks/"] diff --git a/uv.lock b/uv.lock index f5cd64f1..31a96032 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,15 @@ resolution-markers = [ "python_full_version < '3.11'", ] +[[package]] +name = "altgraph" +version = "0.17.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/a8/7145824cf0b9e3c28046520480f207df47e927df83aa9555fb47f8505922/altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406", size = 48418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/3f/3bc3f1d83f6e4a7fcb834d3720544ca597590425be5ba9db032b2bf322a2/altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff", size = 21212 }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -643,6 +652,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/8b/d7497df4a1cae9367adf21665dd1f896c2a7aeb8769ad77b662c5e2bcce7/kiwisolver-1.4.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:eb542fe7933aa09d8d8f9d9097ef37532a7df6497819d16efe4359890a2f417a", size = 55715 }, ] +[[package]] +name = "macholib" +version = "1.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/ee/af1a3842bdd5902ce133bd246eb7ffd4375c38642aeb5dc0ae3a0329dfa2/macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30", size = 59309 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/5d/c059c180c84f7962db0aeae7c3b9303ed1d73d76f2bfbc32bc231c8be314/macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c", size = 38094 }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -905,6 +926,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/ee/491e63a57fffa78b9de1c337b06c97d0cd0753e88c00571c7b011680332a/pdbpp-0.10.3-py2.py3-none-any.whl", hash = "sha256:79580568e33eb3d6f6b462b1187f53e10cd8e4538f7d31495c9181e2cf9665d1", size = 23961 }, ] +[[package]] +name = "pefile" +version = "2023.2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/c5/3b3c62223f72e2360737fd2a57c30e5b2adecd85e70276879609a7403334/pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc", size = 74854 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/26/d0ad8b448476d0a1e8d3ea5622dc77b916db84c6aa3cb1e1c0965af948fc/pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6", size = 71791 }, +] + [[package]] name = "pexpect" version = "4.9.0" @@ -1151,6 +1181,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, ] +[[package]] +name = "pyinstaller" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/d4/54f5f5c73b803e6256ea97ffc6ba8a305d9a5f57f85f9b00b282512bf18a/pyinstaller-6.11.1.tar.gz", hash = "sha256:491dfb4d9d5d1d9650d9507daec1ff6829527a254d8e396badd60a0affcb72ef", size = 4249772 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/15/b0f1c0985ee32fcd2f6ad9a486ef94e4db3fef9af025a3655e76cb708009/pyinstaller-6.11.1-py3-none-macosx_10_13_universal2.whl", hash = "sha256:44e36172de326af6d4e7663b12f71dbd34e2e3e02233e181e457394423daaf03", size = 991780 }, + { url = "https://files.pythonhosted.org/packages/fd/0f/9f54cb18abe2b1d89051bc9214c0cb40d7b5f4049c151c315dacc067f4a2/pyinstaller-6.11.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:6d12c45a29add78039066a53fb05967afaa09a672426072b13816fe7676abfc4", size = 711739 }, + { url = "https://files.pythonhosted.org/packages/32/f7/79d10830780eff8339bfa793eece1df4b2459e35a712fc81983e8536cc29/pyinstaller-6.11.1-py3-none-manylinux2014_i686.whl", hash = "sha256:ddc0fddd75f07f7e423da1f0822e389a42af011f9589e0269b87e0d89aa48c1f", size = 714053 }, + { url = "https://files.pythonhosted.org/packages/25/f7/9961ef02cdbd2dbb1b1a215292656bd0ea72a83aafd8fb6373513849711e/pyinstaller-6.11.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:0d6475559c4939f0735122989611d7f739ed3bf02f666ce31022928f7a7e4fda", size = 719133 }, + { url = "https://files.pythonhosted.org/packages/6f/4d/7f854842a1ce798de762a0b0bc5d5a4fc26ad06164a98575dc3c54abed1f/pyinstaller-6.11.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:e21c7806e34f40181e7606926a14579f848bfb1dc52cbca7eea66eccccbfe977", size = 709591 }, + { url = "https://files.pythonhosted.org/packages/7f/e0/00d29fc90c3ba50620c61554e26ebb4d764569507be7cd1c8794aa696f9a/pyinstaller-6.11.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:32c742a24fe65d0702958fadf4040f76de85859c26bec0008766e5dbabc5b68f", size = 710068 }, + { url = "https://files.pythonhosted.org/packages/3e/57/d14b44a69f068d2caaee49d15e45f9fa0f37c6a2d2ad778c953c1722a1ca/pyinstaller-6.11.1-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:208c0ef6dab0837a0a273ea32d1a3619a208e3d1fe3fec3785eea71a77fd00ce", size = 714439 }, + { url = "https://files.pythonhosted.org/packages/88/01/256824bb57ca208099c86c2fb289f888ca7732580e91ced48fa14e5903b2/pyinstaller-6.11.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:ad84abf465bcda363c1d54eafa76745d77b6a8a713778348377dc98d12a452f7", size = 710457 }, + { url = "https://files.pythonhosted.org/packages/7c/f0/98c9138f5f0ff17462f1ad6d712dcfa643b9a283d6238d464d8145bc139d/pyinstaller-6.11.1-py3-none-win32.whl", hash = "sha256:2e8365276c5131c9bef98e358fbc305e4022db8bedc9df479629d6414021956a", size = 1280261 }, + { url = "https://files.pythonhosted.org/packages/7d/08/f43080614b3e8bce481d4dfd580e579497c7dcdaf87656d9d2ad912e5796/pyinstaller-6.11.1-py3-none-win_amd64.whl", hash = "sha256:7ac83c0dc0e04357dab98c487e74ad2adb30e7eb186b58157a8faf46f1fa796f", size = 1340482 }, + { url = "https://files.pythonhosted.org/packages/ed/56/953c6594cb66e249563854c9cc04ac5a055c6c99d1614298feeaeaa9b87e/pyinstaller-6.11.1-py3-none-win_arm64.whl", hash = "sha256:35e6b8077d240600bb309ed68bb0b1453fd2b7ab740b66d000db7abae6244423", size = 1267519 }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2024.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/df/9fa06adfc7ac4fe07fd796a7bd402ec91ada4d360329a90d17e0275beaba/pyinstaller_hooks_contrib-2024.11.tar.gz", hash = "sha256:84399af6b4b902030958063df25f657abbff249d0f329c5344928355c9833ab4", size = 141622 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/af/965f81a65f4d9bcb337dd0e87845fd2e081c4ab5a1c0b3e0cf20abeac423/pyinstaller_hooks_contrib-2024.11-py3-none-any.whl", hash = "sha256:2781d121a1ee961152ba7287a262c65a1078da30c9ef7621cb8c819326884fd5", size = 339452 }, +] + [[package]] name = "pymmcore" version = "11.1.1.71.3" @@ -1176,7 +1247,7 @@ wheels = [ [[package]] name = "pymmcore-gui" -version = "0.1.dev201+g5980b63.d20241223" +version = "0.1.dev202+g7264681.d20241223" source = { editable = "." } dependencies = [ { name = "ndv", extra = ["vispy"] }, @@ -1199,6 +1270,7 @@ dev = [ { name = "mypy" }, { name = "pdbpp", marker = "sys_platform != 'win32'" }, { name = "pre-commit" }, + { name = "pyinstaller" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-qt" }, @@ -1227,6 +1299,7 @@ dev = [ { name = "mypy", specifier = ">=1.13.0" }, { name = "pdbpp", marker = "sys_platform != 'win32'", specifier = ">=0.10.3" }, { name = "pre-commit", specifier = ">=4.0.1" }, + { name = "pyinstaller", specifier = ">=6.11.1" }, { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-cov", specifier = ">=6.0.0" }, { name = "pytest-qt", specifier = ">=4.4.0" }, @@ -1429,6 +1502,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/df/2b63e3e4f2df0224f8aaf6d131f54fe4e8c96400eb9df563e2aae2e1a1f9/pywin32-308-cp313-cp313-win_arm64.whl", hash = "sha256:ef313c46d4c18dfb82a2431e3051ac8f112ccee1a34f29c263c583c568db63cd", size = 7974986 }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, +] + [[package]] name = "pyyaml" version = "6.0.2" @@ -1630,6 +1712,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/9f/026e18ca7d7766783d779dae5e9c656746c6ede36ef73c6d934aaf4a6dec/ruff-0.8.4-py3-none-win_arm64.whl", hash = "sha256:9183dd615d8df50defa8b1d9a074053891ba39025cf5ae88e8bcb52edcc4bf08", size = 9074500 }, ] +[[package]] +name = "setuptools" +version = "75.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/54/292f26c208734e9a7f067aea4a7e282c080750c4546559b58e2e45413ca0/setuptools-75.6.0.tar.gz", hash = "sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6", size = 1337429 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/21/47d163f615df1d30c094f6c8bbb353619274edccf0327b185cc2493c2c33/setuptools-75.6.0-py3-none-any.whl", hash = "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d", size = 1224032 }, +] + [[package]] name = "shellingham" version = "1.5.4" From 99e1c92641680e2895da8e1b02e02f3ebc2845ef Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 18:27:32 -0500 Subject: [PATCH 198/226] more app bundle stuff --- .github/workflows/bundle.yml | 42 +++++++++++++++++++++++++++ .github/workflows/ci.yml | 4 +-- justfile | 9 +++++- src/pymmcore_gui/_app.py | 54 ++++++++++++++++++++++++++++++----- src/pymmcore_gui/logo.png | Bin 0 -> 3886 bytes 5 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/bundle.yml create mode 100644 src/pymmcore_gui/logo.png diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml new file mode 100644 index 00000000..5f7bc516 --- /dev/null +++ b/.github/workflows/bundle.yml @@ -0,0 +1,42 @@ +name: Bundle app + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + push: + branches: [main] + tags: [v*] + pull_request: + workflow_dispatch: + +jobs: + bundle: + name: ${{ matrix.platform }} + runs-on: ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + platform: [macos-13, windows-latest, ubuntu-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "0.5.11" + enable-cache: true + + - name: Install the project + run: uv sync --all-extras --dev + + - name: Bundle + run: uv run pyinstaller app/mmgui.spec --noconfirm + + - name: Upload bundle + uses: actions/upload-artifact@v2 + with: + name: pymmgui + path: ${{ matrix.platform == 'macos-13' && 'dist/pymmgui.app' || matrix.platform == 'windows-latest' && 'dist/pymmgui.exe' || 'dist/pymmgui' }} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f79a94b..0c6e3163 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,9 +30,9 @@ jobs: - uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v4 + uses: astral-sh/setup-uv@v5 with: - version: "0.5.9" + version: "0.5.11" enable-cache: true - uses: pyvista/setup-headless-display-action@v3 diff --git a/justfile b/justfile index 2eb727ba..585210bd 100644 --- a/justfile +++ b/justfile @@ -1,8 +1,15 @@ +# run the main application +run: + uv run python -m pymmcore_gui + +# create application bundle using pyinstaller bundle: - uv run pyinstaller app/mmgui.spec --log-level INFO + uv run pyinstaller app/mmgui.spec --noconfirm --log-level INFO +# lint all files with pre-commit lint: uv run pre-commit run --all-files +# run tests test: uv run pytest \ No newline at end of file diff --git a/src/pymmcore_gui/_app.py b/src/pymmcore_gui/_app.py index 4da5fa4f..d48bef7b 100644 --- a/src/pymmcore_gui/_app.py +++ b/src/pymmcore_gui/_app.py @@ -5,39 +5,79 @@ import sys import traceback from contextlib import suppress +from pathlib import Path from typing import TYPE_CHECKING from PyQt6.QtCore import pyqtSignal +from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import QApplication +from superqt.utils import WorkerBase -from pymmcore_gui import MicroManagerGUI +from pymmcore_gui import MicroManagerGUI, __version__ if TYPE_CHECKING: from collections.abc import Sequence from types import TracebackType +APP_NAME = "Micro-Manager GUI" +APP_VERSION = __version__ +ORG_NAME = "pymmcore-plus" +ORG_DOMAIN = "pymmcore-plus" +APP_ID = f"{ORG_DOMAIN}.{ORG_NAME}.{APP_NAME}.{APP_VERSION}" +ICON = Path(__file__).parent / "logo.png" IS_FROZEN = getattr(sys, "frozen", False) class MMQApplication(QApplication): exceptionRaised = pyqtSignal(BaseException) + def __init__(self, argv: list[str]) -> None: + if sys.platform == "darwin" and not argv[0].endswith("mmgui"): + # Make sure the app name in the Application menu is `mmgui` + # which is taken from the basename of sys.argv[0]; we use + # a copy so the original value is still available at sys.argv + argv[0] = "napari" -def main(args: Sequence[str] | None = None) -> None: - """Run the Micro-Manager GUI.""" - if args is None: + super().__init__(argv) + self.setApplicationName("Micro-Manager GUI") + self.setWindowIcon(QIcon(str(ICON))) + + self.setApplicationName(APP_NAME) + self.setApplicationVersion(APP_VERSION) + self.setOrganizationName(ORG_NAME) + self.setOrganizationDomain(ORG_DOMAIN) + if os.name == "nt" and not IS_FROZEN: + import ctypes + + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_ID) + + self.aboutToQuit.connect(WorkerBase.await_workers) + + +def parse_args(args: Sequence[str] = ()) -> argparse.Namespace: + if not args: args = sys.argv[1:] parser = argparse.ArgumentParser(description="Enter string") parser.add_argument( - "-c", "--config", type=str, default=None, help="Config file to load", nargs="?" + "-c", + "--config", + type=str, + default=None, + help="Config file to load", + nargs="?", ) - parsed_args = parser.parse_args(args) + return parser.parse_args(args) + + +def main() -> None: + """Run the Micro-Manager GUI.""" + args = parse_args() app = MMQApplication(sys.argv) _install_excepthook() - win = MicroManagerGUI(config=parsed_args.config) + win = MicroManagerGUI(config=args.config) win.showMaximized() win.show() diff --git a/src/pymmcore_gui/logo.png b/src/pymmcore_gui/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..0c128415bde07cc02af2367ee4ee6dcebae310ca GIT binary patch literal 3886 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 I={v>zA0&c#VE_OC literal 0 HcmV?d00001 From 0cf57b1325411f68b9bd5db9c89bd5726f410774 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 19:05:40 -0500 Subject: [PATCH 199/226] bump stuff --- .github/workflows/bundle.yml | 2 +- .github/workflows/ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index 5f7bc516..67d30caa 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -36,7 +36,7 @@ jobs: run: uv run pyinstaller app/mmgui.spec --noconfirm - name: Upload bundle - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: pymmgui path: ${{ matrix.platform == 'macos-13' && 'dist/pymmgui.app' || matrix.platform == 'windows-latest' && 'dist/pymmgui.exe' || 'dist/pymmgui' }} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c6e3163..674bc3a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: run: uv run mmcore install - name: Test - run: uv run pytest -v --cov=pymmcore_widgets --cov-report=xml --color=yes + run: uv run pytest -v --cov=pymmcore_gui --cov-report=xml --color=yes - name: Coverage uses: codecov/codecov-action@v5 From 0f9bfb301eb1a645223471a7504340ada5464d67 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 19:07:56 -0500 Subject: [PATCH 200/226] no ubuntu bundle --- .github/workflows/bundle.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index 67d30caa..b65a92da 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - platform: [macos-13, windows-latest, ubuntu-latest] + platform: [macos-13, windows-latest] steps: - uses: actions/checkout@v4 From 2ea192c113a97e1c6dfd0c9df82d85b5bb25ba73 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 19:11:13 -0500 Subject: [PATCH 201/226] change upload name --- .github/workflows/bundle.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index b65a92da..7bd3bb48 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -38,5 +38,5 @@ jobs: - name: Upload bundle uses: actions/upload-artifact@v4 with: - name: pymmgui - path: ${{ matrix.platform == 'macos-13' && 'dist/pymmgui.app' || matrix.platform == 'windows-latest' && 'dist/pymmgui.exe' || 'dist/pymmgui' }} \ No newline at end of file + name: ${{ matrix.platform == 'macos-13' && 'pymmgui.app' || matrix.platform == 'windows-latest' && 'pymmgui.exe' || 'pymmgui' }} + path: ${{ matrix.platform == 'macos-13' && 'dist/pymmgui.app' || matrix.platform == 'windows-latest' && 'dist/pymmgui.exe' || 'dist/pymmgui' }} From dd7599018050745e50080fb3b1ba2eb84b161ee6 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 19:19:48 -0500 Subject: [PATCH 202/226] ls all --- .github/workflows/bundle.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index 7bd3bb48..3c400009 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -33,7 +33,10 @@ jobs: run: uv sync --all-extras --dev - name: Bundle - run: uv run pyinstaller app/mmgui.spec --noconfirm + shell: bash + run: | + uv run pyinstaller app/mmgui.spec --noconfirm + ls -la dist/ - name: Upload bundle uses: actions/upload-artifact@v4 From 26102ff6bdc654d3f01be860d33de114168de79b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 19:27:16 -0500 Subject: [PATCH 203/226] change windows dir --- .github/workflows/bundle.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index 3c400009..c9604205 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -36,10 +36,10 @@ jobs: shell: bash run: | uv run pyinstaller app/mmgui.spec --noconfirm - ls -la dist/ + ls -la dist/mmgui - name: Upload bundle uses: actions/upload-artifact@v4 with: name: ${{ matrix.platform == 'macos-13' && 'pymmgui.app' || matrix.platform == 'windows-latest' && 'pymmgui.exe' || 'pymmgui' }} - path: ${{ matrix.platform == 'macos-13' && 'dist/pymmgui.app' || matrix.platform == 'windows-latest' && 'dist/pymmgui.exe' || 'dist/pymmgui' }} + path: ${{ matrix.platform == 'macos-13' && 'dist/pymmgui.app' || matrix.platform == 'windows-latest' && 'dist/mmgui/pymmgui.exe' || 'dist/pymmgui' }} From 1a39293050ce6e668bc6aec283791a9ffdb49dd7 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 19:33:06 -0500 Subject: [PATCH 204/226] create dmg --- .github/workflows/bundle.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index c9604205..b7c9514a 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -35,11 +35,19 @@ jobs: - name: Bundle shell: bash run: | - uv run pyinstaller app/mmgui.spec --noconfirm + uv run pyinstaller app/mmgui.spec --clean --noconfirm ls -la dist/mmgui + - name: Create DMG + if: matrix.platform == 'macos-13' + run: mkdir dist/dmg + ln -s /Applications dist/dmg + cp -r dist/pymmgui.app dist/dmg + hdiutil create -volname pymmgui -srcfolder dist/dmg -format UDZO dist/pymmgui.dmg + rm -rf dist/dmg + - name: Upload bundle uses: actions/upload-artifact@v4 with: - name: ${{ matrix.platform == 'macos-13' && 'pymmgui.app' || matrix.platform == 'windows-latest' && 'pymmgui.exe' || 'pymmgui' }} - path: ${{ matrix.platform == 'macos-13' && 'dist/pymmgui.app' || matrix.platform == 'windows-latest' && 'dist/mmgui/pymmgui.exe' || 'dist/pymmgui' }} + name: ${{ matrix.platform == 'macos-13' && 'pymmgui.dmg' || matrix.platform == 'windows-latest' && 'pymmgui.exe' }} + path: ${{ matrix.platform == 'macos-13' && 'dist/pymmgui.dmg' || matrix.platform == 'windows-latest' && 'dist/mmgui/pymmgui.exe' }} From 736c51b78b1e38e4bdb43399bbc4953146365ece Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 19:41:19 -0500 Subject: [PATCH 205/226] fix test --- tests/test_app.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index 3c00f03a..1a52f1cc 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,17 +1,20 @@ import sys -from unittest.mock import patch +from unittest.mock import Mock, patch from PyQt6.QtWidgets import QApplication +from pytest import MonkeyPatch from pymmcore_gui import _app -def test_main_app() -> None: - with patch.object(QApplication, "exec") as mock_exec: - _app.main() - assert mock_exec.called - assert isinstance(QApplication.instance(), _app.MMQApplication) - assert sys.excepthook == _app.ndv_excepthook - for wdg in QApplication.topLevelWidgets(): - wdg.close() - wdg.deleteLater() +def test_main_app(monkeypatch: MonkeyPatch) -> None: + mock_exec = Mock() + monkeypatch.setattr(QApplication, "exec", mock_exec) + monkeypatch.setattr(sys, 'argv', ['mmgui']) + _app.main() + mock_exec.assert_called_once() + assert isinstance(QApplication.instance(), _app.MMQApplication) + assert sys.excepthook == _app.ndv_excepthook + for wdg in QApplication.topLevelWidgets(): + wdg.close() + wdg.deleteLater() From 2a0b0a5c8ad815a507fc33c505b92134fb9d9f16 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Dec 2024 00:41:37 +0000 Subject: [PATCH 206/226] style(pre-commit.ci): auto fixes [...] --- tests/test_app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index 1a52f1cc..537715ac 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,16 +1,16 @@ import sys -from unittest.mock import Mock, patch +from unittest.mock import Mock from PyQt6.QtWidgets import QApplication - from pytest import MonkeyPatch + from pymmcore_gui import _app def test_main_app(monkeypatch: MonkeyPatch) -> None: mock_exec = Mock() monkeypatch.setattr(QApplication, "exec", mock_exec) - monkeypatch.setattr(sys, 'argv', ['mmgui']) + monkeypatch.setattr(sys, "argv", ["mmgui"]) _app.main() mock_exec.assert_called_once() assert isinstance(QApplication.instance(), _app.MMQApplication) From ad9832a1f6cd95368c0b884f4f00426e98ec88f4 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 19:42:45 -0500 Subject: [PATCH 207/226] fix ocommand --- .github/workflows/bundle.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index b7c9514a..79431312 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -40,7 +40,8 @@ jobs: - name: Create DMG if: matrix.platform == 'macos-13' - run: mkdir dist/dmg + run: | + mkdir dist/dmg ln -s /Applications dist/dmg cp -r dist/pymmgui.app dist/dmg hdiutil create -volname pymmgui -srcfolder dist/dmg -format UDZO dist/pymmgui.dmg From e63ce68c542896ee5e972bc19ba51afb9fca9286 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 19:47:50 -0500 Subject: [PATCH 208/226] linting --- src/pymmcore_gui/_app.py | 2 +- src/pymmcore_gui/widgets/_code_syntax_highlight.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pymmcore_gui/_app.py b/src/pymmcore_gui/_app.py index d48bef7b..39702871 100644 --- a/src/pymmcore_gui/_app.py +++ b/src/pymmcore_gui/_app.py @@ -49,7 +49,7 @@ def __init__(self, argv: list[str]) -> None: if os.name == "nt" and not IS_FROZEN: import ctypes - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_ID) + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_ID) # type: ignore self.aboutToQuit.connect(WorkerBase.await_workers) diff --git a/src/pymmcore_gui/widgets/_code_syntax_highlight.py b/src/pymmcore_gui/widgets/_code_syntax_highlight.py index 7533c3a1..6c13541e 100644 --- a/src/pymmcore_gui/widgets/_code_syntax_highlight.py +++ b/src/pymmcore_gui/widgets/_code_syntax_highlight.py @@ -256,7 +256,7 @@ def setLanguage(self, lang: str) -> None: @property def background_color(self) -> str: style = cast("pygments.style.StyleMeta", self.formatter.style) - return style.background_color + return style.background_color # type: ignore def highlightBlock(self, text: str | None) -> None: # dirty, dirty hack From 2f78aba6491194656e3470b0bb2fa6c359ec9264 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 20:03:38 -0500 Subject: [PATCH 209/226] misc --- app/mmgui.spec | 4 +--- justfile | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/mmgui.spec b/app/mmgui.spec index 9b460ad4..b43377f0 100644 --- a/app/mmgui.spec +++ b/app/mmgui.spec @@ -80,7 +80,7 @@ a = Analysis( binaries=[], datas=[], # An optional list of additional (hidden) modules to include. - hiddenimports=[], + hiddenimports=['pdb'], # An optional list of additional paths to search for hooks. hookspath=[APP_ROOT / "hooks"], # An optional list of module or package names (their Python names, not path names) that will be @@ -118,9 +118,7 @@ exe = EXE( hide_console=None, disable_windowed_traceback=False, argv_emulation=False, - target_arch=None, codesign_identity=None, - entitlements_file=None, icon=ICON, version=_get_win_version(), ) diff --git a/justfile b/justfile index 585210bd..0cc8048e 100644 --- a/justfile +++ b/justfile @@ -4,7 +4,7 @@ run: # create application bundle using pyinstaller bundle: - uv run pyinstaller app/mmgui.spec --noconfirm --log-level INFO + uv run pyinstaller app/mmgui.spec --clean --noconfirm --log-level INFO # lint all files with pre-commit lint: From 16e8ebc1311a0bf1575874437bf6763d3a4754e8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 23 Dec 2024 20:14:31 -0500 Subject: [PATCH 210/226] remove pdbpp --- app/mmgui.spec | 1 + pyproject.toml | 3 ++- uv.lock | 70 +++----------------------------------------------- 3 files changed, 7 insertions(+), 67 deletions(-) diff --git a/app/mmgui.spec b/app/mmgui.spec index b43377f0..1068afb3 100644 --- a/app/mmgui.spec +++ b/app/mmgui.spec @@ -86,6 +86,7 @@ a = Analysis( # An optional list of module or package names (their Python names, not path names) that will be # ignored (as though they were not found). excludes=[ + "pdbpp", "FixTk", "tcl", "tk", diff --git a/pyproject.toml b/pyproject.toml index 97f6b5cf..b9edc8d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,10 +50,11 @@ dependencies = [ ] [dependency-groups] +# careful ... +# having pdbpp in the environment will break the console in the bundled app dev = [ "ipython>=8.30.0", "mypy>=1.13.0", - "pdbpp>=0.10.3 ; sys_platform != 'win32'", "pre-commit>=4.0.1", "pyinstaller>=6.11.1", "pytest>=8.3.4", diff --git a/uv.lock b/uv.lock index 31a96032..2ff58b0f 100644 --- a/uv.lock +++ b/uv.lock @@ -49,15 +49,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, ] -[[package]] -name = "attrs" -version = "24.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/c8/6260f8ccc11f0917360fc0da435c5c9c7504e3db174d5a12a1494887b045/attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff", size = 805984 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/aa/ab0f7891a01eeb2d2e338ae8fecbe57fcebea1a24dbb64d45801bfab481d/attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308", size = 63397 }, -] - [[package]] name = "certifi" version = "2024.12.14" @@ -207,7 +198,7 @@ name = "click" version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } wheels = [ @@ -372,19 +363,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/fd/afcd0496feca3276f509df3dbd5dae726fcc756f1a08d9e25abe1733f962/executing-2.1.0-py2.py3-none-any.whl", hash = "sha256:8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf", size = 25805 }, ] -[[package]] -name = "fancycompleter" -version = "0.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyreadline", marker = "platform_system == 'Windows'" }, - { name = "pyrepl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/95/649d135442d8ecf8af5c7e235550c628056423c96c4bc6787348bdae9248/fancycompleter-0.9.1.tar.gz", hash = "sha256:09e0feb8ae242abdfd7ef2ba55069a46f011814a80fe5476be48f51b00247272", size = 10866 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/ef/c08926112034d017633f693d3afc8343393a035134a29dfc12dcd71b0375/fancycompleter-0.9.1-py3-none-any.whl", hash = "sha256:dd076bca7d9d524cc7f25ec8f35ef95388ffef9ef46def4d3d25e9b044ad7080", size = 9681 }, -] - [[package]] name = "fasteners" version = "0.19" @@ -491,7 +469,7 @@ name = "ipykernel" version = "6.29.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "appnope", marker = "platform_system == 'Darwin'" }, + { name = "appnope", marker = "sys_platform == 'darwin'" }, { name = "comm" }, { name = "debugpy" }, { name = "ipython" }, @@ -912,20 +890,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, ] -[[package]] -name = "pdbpp" -version = "0.10.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fancycompleter" }, - { name = "pygments" }, - { name = "wmctrl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1f/a3/c4bd048256fd4b7d28767ca669c505e156f24d16355505c62e6fce3314df/pdbpp-0.10.3.tar.gz", hash = "sha256:d9e43f4fda388eeb365f2887f4e7b66ac09dce9b6236b76f63616530e2f669f5", size = 68116 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/ee/491e63a57fffa78b9de1c337b06c97d0cd0753e88c00571c7b011680332a/pdbpp-0.10.3-py2.py3-none-any.whl", hash = "sha256:79580568e33eb3d6f6b462b1187f53e10cd8e4538f7d31495c9181e2cf9665d1", size = 23961 }, -] - [[package]] name = "pefile" version = "2023.2.7" @@ -1247,7 +1211,7 @@ wheels = [ [[package]] name = "pymmcore-gui" -version = "0.1.dev202+g7264681.d20241223" +version = "0.1.dev216+g2f78aba.d20241224" source = { editable = "." } dependencies = [ { name = "ndv", extra = ["vispy"] }, @@ -1268,7 +1232,6 @@ dependencies = [ dev = [ { name = "ipython" }, { name = "mypy" }, - { name = "pdbpp", marker = "sys_platform != 'win32'" }, { name = "pre-commit" }, { name = "pyinstaller" }, { name = "pytest" }, @@ -1297,7 +1260,6 @@ requires-dist = [ dev = [ { name = "ipython", specifier = ">=8.30.0" }, { name = "mypy", specifier = ">=1.13.0" }, - { name = "pdbpp", marker = "sys_platform != 'win32'", specifier = ">=0.10.3" }, { name = "pre-commit", specifier = ">=4.0.1" }, { name = "pyinstaller", specifier = ">=6.11.1" }, { name = "pytest", specifier = ">=8.3.4" }, @@ -1416,18 +1378,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/b6/eabe1d0fdcc56c80325c5a2cc361ae3e57b3346cb7700b707481d3977a24/PyQt6_sip-13.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:8b2ac36d6e04db6099614b9c1178a2f87788c7ffc3826571fb63d36ddb4c401d", size = 45358 }, ] -[[package]] -name = "pyreadline" -version = "2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bc/7c/d724ef1ec3ab2125f38a1d53285745445ec4a8f19b9bb0761b4064316679/pyreadline-2.1.zip", hash = "sha256:4530592fc2e85b25b1a9f79664433da09237c1a270e4d78ea5aa3a2c7229e2d1", size = 109189 } - -[[package]] -name = "pyrepl" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/1b/ea40363be0056080454cdbabe880773c3c5bd66d7b13f0c8b8b8c8da1e0c/pyrepl-0.9.0.tar.gz", hash = "sha256:292570f34b5502e871bbb966d639474f2b57fbfcd3373c2d6a2f3d56e681a775", size = 48744 } - [[package]] name = "pytest" version = "8.3.4" @@ -1884,7 +1834,7 @@ name = "tqdm" version = "4.67.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "platform_system == 'Windows'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } wheels = [ @@ -2000,18 +1950,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, ] -[[package]] -name = "wmctrl" -version = "0.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/d9/6625ead93412c5ce86db1f8b4f2a70b8043e0a7c1d30099ba3c6a81641ff/wmctrl-0.5.tar.gz", hash = "sha256:7839a36b6fe9e2d6fd22304e5dc372dbced2116ba41283ea938b2da57f53e962", size = 5202 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/ca/723e3f8185738d7947f14ee7dc663b59415c6dee43bd71575f8c7f5cd6be/wmctrl-0.5-py2.py3-none-any.whl", hash = "sha256:ae695c1863a314c899e7cf113f07c0da02a394b968c4772e1936219d9234ddd7", size = 4268 }, -] - [[package]] name = "wrapt" version = "1.17.0" From 2454f4008c2ea97f0df25dbdbf772e723e3864e9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 08:51:32 -0500 Subject: [PATCH 211/226] use macos latest --- .github/workflows/bundle.yml | 8 ++++---- uv.lock | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index 79431312..115b5668 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - platform: [macos-13, windows-latest] + platform: [macos-latest, windows-latest] steps: - uses: actions/checkout@v4 @@ -39,7 +39,7 @@ jobs: ls -la dist/mmgui - name: Create DMG - if: matrix.platform == 'macos-13' + if: runner.os == 'macos' run: | mkdir dist/dmg ln -s /Applications dist/dmg @@ -50,5 +50,5 @@ jobs: - name: Upload bundle uses: actions/upload-artifact@v4 with: - name: ${{ matrix.platform == 'macos-13' && 'pymmgui.dmg' || matrix.platform == 'windows-latest' && 'pymmgui.exe' }} - path: ${{ matrix.platform == 'macos-13' && 'dist/pymmgui.dmg' || matrix.platform == 'windows-latest' && 'dist/mmgui/pymmgui.exe' }} + name: ${{ runner.os == 'macos' && 'pymmgui.dmg' || runner.os == 'windows' && 'pymmgui.exe' }} + path: ${{ runner.os == 'macos' && 'dist/pymmgui.dmg' || runner.os == 'windows' && 'dist/mmgui/pymmgui.exe' }} diff --git a/uv.lock b/uv.lock index 2ff58b0f..0842df62 100644 --- a/uv.lock +++ b/uv.lock @@ -1211,7 +1211,7 @@ wheels = [ [[package]] name = "pymmcore-gui" -version = "0.1.dev216+g2f78aba.d20241224" +version = "0.1.dev217+g16e8ebc.d20241224" source = { editable = "." } dependencies = [ { name = "ndv", extra = ["vispy"] }, From e9598145fecc3172381a8b6fea521bf86f2a5fc1 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 09:19:02 -0500 Subject: [PATCH 212/226] maintain symlinks --- .github/workflows/bundle.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index 115b5668..e2c3e990 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -43,7 +43,8 @@ jobs: run: | mkdir dist/dmg ln -s /Applications dist/dmg - cp -r dist/pymmgui.app dist/dmg + # Use cp -P to preserve symlinks + cp -P -r dist/pymmgui.app dist/dmg hdiutil create -volname pymmgui -srcfolder dist/dmg -format UDZO dist/pymmgui.dmg rm -rf dist/dmg From 92e797cf88082c02d1c06bc3cd39697ceb2256bd Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 09:29:05 -0500 Subject: [PATCH 213/226] try rsync --- .github/workflows/bundle.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index e2c3e990..ca83f3f9 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -41,10 +41,12 @@ jobs: - name: Create DMG if: runner.os == 'macos' run: | + ls -la dist/pymmgui.app/Contents/Resources mkdir dist/dmg ln -s /Applications dist/dmg # Use cp -P to preserve symlinks - cp -P -r dist/pymmgui.app dist/dmg + rsync -a --copy-links dist/pymmgui.app dist/dmg/ + ls -la dist/dmg/pymmgui.app/Contents/Resources hdiutil create -volname pymmgui -srcfolder dist/dmg -format UDZO dist/pymmgui.dmg rm -rf dist/dmg From 034be3b7ff8a3d58025875badaab7e1501341929 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 09:34:36 -0500 Subject: [PATCH 214/226] try zip --- .github/workflows/bundle.yml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index ca83f3f9..ba71dcc7 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -41,17 +41,10 @@ jobs: - name: Create DMG if: runner.os == 'macos' run: | - ls -la dist/pymmgui.app/Contents/Resources - mkdir dist/dmg - ln -s /Applications dist/dmg - # Use cp -P to preserve symlinks - rsync -a --copy-links dist/pymmgui.app dist/dmg/ - ls -la dist/dmg/pymmgui.app/Contents/Resources - hdiutil create -volname pymmgui -srcfolder dist/dmg -format UDZO dist/pymmgui.dmg - rm -rf dist/dmg + zip -r dist/pymmgui.zip dist/pymmgui.app - name: Upload bundle uses: actions/upload-artifact@v4 with: - name: ${{ runner.os == 'macos' && 'pymmgui.dmg' || runner.os == 'windows' && 'pymmgui.exe' }} - path: ${{ runner.os == 'macos' && 'dist/pymmgui.dmg' || runner.os == 'windows' && 'dist/mmgui/pymmgui.exe' }} + name: ${{ runner.os == 'macos' && 'pymmgui.zip' || runner.os == 'windows' && 'pymmgui.exe' }} + path: ${{ runner.os == 'macos' && 'dist/pymmgui.zip' || runner.os == 'windows' && 'dist/mmgui/pymmgui.exe' }} From d382dc4fe038bfe2ffad2c89385ea18ebfc38f4e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 09:43:59 -0500 Subject: [PATCH 215/226] try different action --- .github/workflows/bundle.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index ba71dcc7..52780a7c 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -38,13 +38,14 @@ jobs: uv run pyinstaller app/mmgui.spec --clean --noconfirm ls -la dist/mmgui - - name: Create DMG - if: runner.os == 'macos' - run: | - zip -r dist/pymmgui.zip dist/pymmgui.app + # - name: Create DMG + # if: runner.os == 'macos' + # run: | + # zip -r dist/pymmgui.zip dist/pymmgui.app - name: Upload bundle - uses: actions/upload-artifact@v4 + uses: eXhumer/upload-artifact@0b7d5f5684d3f642f978d2faad9ade64f5b4dd57 with: - name: ${{ runner.os == 'macos' && 'pymmgui.zip' || runner.os == 'windows' && 'pymmgui.exe' }} - path: ${{ runner.os == 'macos' && 'dist/pymmgui.zip' || runner.os == 'windows' && 'dist/mmgui/pymmgui.exe' }} + name: pymmgui + path: ${{ runner.os == 'macos' && 'dist/pymmgui.app' || runner.os == 'windows' && 'dist/mmgui/pymmgui.exe' }} + follow-symlinks: false \ No newline at end of file From 81fbe7b46bf547a2392364b34d174af0431ed675 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 09:50:40 -0500 Subject: [PATCH 216/226] name conflict --- .github/workflows/bundle.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index 52780a7c..eb69176a 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -46,6 +46,6 @@ jobs: - name: Upload bundle uses: eXhumer/upload-artifact@0b7d5f5684d3f642f978d2faad9ade64f5b4dd57 with: - name: pymmgui + name: ${{ runner.os == 'macos' && 'pymmgui.app.zip' || runner.os == 'windows' && 'pymmgui.exe' }} path: ${{ runner.os == 'macos' && 'dist/pymmgui.app' || runner.os == 'windows' && 'dist/mmgui/pymmgui.exe' }} follow-symlinks: false \ No newline at end of file From 4eb0ea06c780ae7708df2df38eeca077c7031fd9 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 09:57:42 -0500 Subject: [PATCH 217/226] try ditto --- .github/workflows/bundle.yml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index eb69176a..67563e38 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -38,14 +38,12 @@ jobs: uv run pyinstaller app/mmgui.spec --clean --noconfirm ls -la dist/mmgui - # - name: Create DMG - # if: runner.os == 'macos' - # run: | - # zip -r dist/pymmgui.zip dist/pymmgui.app + - name: Create DMG + if: runner.os == 'macos' + run: ditto -c -k --keepParent dist/pymmgui.app dist/pymmgui.zip - name: Upload bundle - uses: eXhumer/upload-artifact@0b7d5f5684d3f642f978d2faad9ade64f5b4dd57 + uses: actions/upload-artifact@v4 with: - name: ${{ runner.os == 'macos' && 'pymmgui.app.zip' || runner.os == 'windows' && 'pymmgui.exe' }} - path: ${{ runner.os == 'macos' && 'dist/pymmgui.app' || runner.os == 'windows' && 'dist/mmgui/pymmgui.exe' }} - follow-symlinks: false \ No newline at end of file + name: ${{ runner.os == 'macos' && 'pymmgui.zip' || runner.os == 'windows' && 'pymmgui.exe' }} + path: ${{ runner.os == 'macos' && 'dist/pymmgui.zip' || runner.os == 'windows' && 'dist/mmgui/pymmgui.exe' }} From 9ee3e79d1e3e8d6b6842454e3a149e271649272f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 10:04:12 -0500 Subject: [PATCH 218/226] just dist folder --- .github/workflows/bundle.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index 67563e38..b853e63c 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -38,12 +38,12 @@ jobs: uv run pyinstaller app/mmgui.spec --clean --noconfirm ls -la dist/mmgui - - name: Create DMG - if: runner.os == 'macos' - run: ditto -c -k --keepParent dist/pymmgui.app dist/pymmgui.zip + # - name: Create DMG + # if: runner.os == 'macos' + # run: ditto -c -k --keepParent dist/pymmgui.app dist/pymmgui.zip - name: Upload bundle uses: actions/upload-artifact@v4 with: name: ${{ runner.os == 'macos' && 'pymmgui.zip' || runner.os == 'windows' && 'pymmgui.exe' }} - path: ${{ runner.os == 'macos' && 'dist/pymmgui.zip' || runner.os == 'windows' && 'dist/mmgui/pymmgui.exe' }} + path: ${{ runner.os == 'macos' && 'dist' || runner.os == 'windows' && 'dist/mmgui' }} From 6e8c67c4d29ef0363a24807a53766c34e975132d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 10:10:41 -0500 Subject: [PATCH 219/226] back to fork --- .github/workflows/bundle.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index b853e63c..a66a3d18 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -38,12 +38,16 @@ jobs: uv run pyinstaller app/mmgui.spec --clean --noconfirm ls -la dist/mmgui - # - name: Create DMG - # if: runner.os == 'macos' - # run: ditto -c -k --keepParent dist/pymmgui.app dist/pymmgui.zip + - name: Create DMG + if: runner.os == 'macos' + run: | + rm -rf dist/mmgui + rm -rf dist/.DS_Store - name: Upload bundle - uses: actions/upload-artifact@v4 + # https://github.com/actions/upload-artifact/issues/93#issuecomment-2304775030 + uses: eXhumer/upload-artifact@0b7d5f5684d3f642f978d2faad9ade64f5b4dd57 with: name: ${{ runner.os == 'macos' && 'pymmgui.zip' || runner.os == 'windows' && 'pymmgui.exe' }} path: ${{ runner.os == 'macos' && 'dist' || runner.os == 'windows' && 'dist/mmgui' }} + follow-symlinks: false From 77cbf3317b5b311bb839bc1271359b1eb50ffc57 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 10:17:37 -0500 Subject: [PATCH 220/226] add more comments --- .github/workflows/bundle.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index a66a3d18..722d6c5b 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -45,7 +45,9 @@ jobs: rm -rf dist/.DS_Store - name: Upload bundle + # upload-artifact@v2 does not preserve symlinks, this fork does # https://github.com/actions/upload-artifact/issues/93#issuecomment-2304775030 + # https://github.com/actions/upload-artifact/compare/main...eXhumer:0b7d5f5684d3f642f978d2faad9ade64f5b4dd57 uses: eXhumer/upload-artifact@0b7d5f5684d3f642f978d2faad9ade64f5b4dd57 with: name: ${{ runner.os == 'macos' && 'pymmgui.zip' || runner.os == 'windows' && 'pymmgui.exe' }} From 61c152ec4bf2cf65ba22a39b5f8196c45c8cb12b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 11:13:14 -0500 Subject: [PATCH 221/226] fix console on windows --- app/mmgui.spec | 2 +- pyproject.toml | 1 + src/pymmcore_gui/widgets/_mm_console.py | 9 +++++++++ uv.lock | 26 ++++++++++++++++++++++++- 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app/mmgui.spec b/app/mmgui.spec index 1068afb3..e4a0feae 100644 --- a/app/mmgui.spec +++ b/app/mmgui.spec @@ -40,7 +40,7 @@ DEBUG = False UPX = True os.environ["QT_API"] = "PyQt6" - +os.environ["PYDEVD_DISABLE_FILE_VALIDATION"] = "1" def _get_win_version() -> "vi.VSVersionInfo": if sys.platform != "win32": diff --git a/pyproject.toml b/pyproject.toml index b9edc8d1..3d339115 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,7 @@ dev = [ "pytest-qt>=4.4.0", "rich>=13.9.4", "ruff>=0.8.3", + "rust-just>=1.38.0", ] # same as console_scripts entry point diff --git a/src/pymmcore_gui/widgets/_mm_console.py b/src/pymmcore_gui/widgets/_mm_console.py index f918ff20..f4936842 100644 --- a/src/pymmcore_gui/widgets/_mm_console.py +++ b/src/pymmcore_gui/widgets/_mm_console.py @@ -1,10 +1,19 @@ from __future__ import annotations import os +import sys from typing import TYPE_CHECKING, Any, cast os.environ["PYDEVD_DISABLE_FILE_VALIDATION"] = "1" + +# Redirect sys.stdout and sys.stderr to devnull if they are None +# this is necessary for the IPython console to work properly in a windows PyInstaller +if os.name == 'nt' and sys.stdout is None: + sys.stdout = open(os.devnull, 'w') + sys.stderr = open(os.devnull, 'w') + + from PyQt6.QtWidgets import QApplication from qtconsole.inprocess import QtInProcessKernelManager from qtconsole.rich_jupyter_widget import RichJupyterWidget diff --git a/uv.lock b/uv.lock index 0842df62..6dad61a2 100644 --- a/uv.lock +++ b/uv.lock @@ -1211,7 +1211,7 @@ wheels = [ [[package]] name = "pymmcore-gui" -version = "0.1.dev217+g16e8ebc.d20241224" +version = "0.1.dev227+g77cbf33.d20241224" source = { editable = "." } dependencies = [ { name = "ndv", extra = ["vispy"] }, @@ -1239,6 +1239,7 @@ dev = [ { name = "pytest-qt" }, { name = "rich" }, { name = "ruff" }, + { name = "rust-just" }, ] [package.metadata] @@ -1267,6 +1268,7 @@ dev = [ { name = "pytest-qt", specifier = ">=4.4.0" }, { name = "rich", specifier = ">=13.9.4" }, { name = "ruff", specifier = ">=0.8.3" }, + { name = "rust-just", specifier = ">=1.38.0" }, ] [[package]] @@ -1662,6 +1664,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/9f/026e18ca7d7766783d779dae5e9c656746c6ede36ef73c6d934aaf4a6dec/ruff-0.8.4-py3-none-win_arm64.whl", hash = "sha256:9183dd615d8df50defa8b1d9a074053891ba39025cf5ae88e8bcb52edcc4bf08", size = 9074500 }, ] +[[package]] +name = "rust-just" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/5c/b043e498dc10fcbc02cd06e776ca9e3ef9d888afa8ed6963588964147f76/rust_just-1.38.0.tar.gz", hash = "sha256:05d092602075b4ef244dee4a94267f998a66523ca39b8ac8320630c82662dac9", size = 1387350 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/6b/5f416dfdee8ab1c3e73088dca4e10ac14956239f8c289f4f1b2ffda6511a/rust_just-1.38.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cae21f93bd10223b6e5b50be32aab2b449e46ce1cdf2c8e576b2d85a751fee79", size = 1758552 }, + { url = "https://files.pythonhosted.org/packages/53/45/fe3a456cd39e674c27149b907f802c89e21ad5fa8617178f5d765fbc6495/rust_just-1.38.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b64d750ded84f71fcd49271d88e162e0cbb111e9383b1dc9aa8b8a157e92fd06", size = 1628336 }, + { url = "https://files.pythonhosted.org/packages/c2/46/a4fa7b053af1c80942049f2f22b8a91298776292cdb0cbd2d66b2faa9560/rust_just-1.38.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:443548c03f1dcf46b82347c10f2ef3faf75cf6bee1fc382f9b6bea2b92c59516", size = 1754360 }, + { url = "https://files.pythonhosted.org/packages/44/16/b53cc27a514bd60928338fc61437bf5e6b45d80a4ba88321f882f355e155/rust_just-1.38.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:573ba5bcf94f446ef0e269ab99e3d2de201193dc41906339ca346cbc9ef336fd", size = 1770210 }, + { url = "https://files.pythonhosted.org/packages/45/ea/98235bb6d63ae11fba81e6f1d936b3e54e14d4fbf3430d9598b1407942c1/rust_just-1.38.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:744e2fe7a33f5cbf02a04bda7c07d9e0e4e924c7a47e35096cb034a45609b18a", size = 1893101 }, + { url = "https://files.pythonhosted.org/packages/72/a7/5ee28651daf9c0f214cf9d0f1d90e5c78e1b2561c12a8a716431081a52be/rust_just-1.38.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a1687535ce940149b825ca12a6afe485fcbd8d55766e5e18fef95a4c820c189", size = 1947836 }, + { url = "https://files.pythonhosted.org/packages/2a/96/f352440ccb18a2848bd5aacd309f80fc63b42413a13a651885d248500efd/rust_just-1.38.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7bd519d9d03460b6c32b5d2a082af5080a63e836e8e291c875a62ce3fb2c187", size = 2423371 }, + { url = "https://files.pythonhosted.org/packages/9c/bc/df996131791814f476f10441a5122fa28d8849dc3d77ceaea8ede0ea4eda/rust_just-1.38.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d0018f1cf151161307e7c3f340da290819fc2c5cf240c20a0f6b4de71884a14", size = 1877732 }, + { url = "https://files.pythonhosted.org/packages/ca/25/c6290d129faae0c872869a119e7946e4ae8738b2aa073e40734b5001ade5/rust_just-1.38.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b71eae6d358d0d7fb10f9ba7add658da0a3fc00d9e4d1473741901a0c8920450", size = 1769910 }, + { url = "https://files.pythonhosted.org/packages/f8/a3/6640b29f0bb080c3ffcfe10c7a5e9ba05dd244d0d030204079ca650f1443/rust_just-1.38.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5c0dd27a3616745d5d30e7dc59b1f539c95821da0b3a27bae1837a6202ea0bd4", size = 1799174 }, + { url = "https://files.pythonhosted.org/packages/92/8c/df133ec92d583232a3a78030eb538b093d51a8c9b4b8fd4054a1015e2411/rust_just-1.38.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ac4965aefc741faaa3106b6c8d6231353e067ce93582677cebc338ae25637430", size = 1887808 }, + { url = "https://files.pythonhosted.org/packages/3a/e8/4ba3b2405db0864f9ea5cee2aaa7c057d5ee6c122e996428e9c2b749ddf0/rust_just-1.38.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c626588d64b5fd39bbe5072c6ddd0e0f443b1682a4143be45429046f42be66e3", size = 1938453 }, + { url = "https://files.pythonhosted.org/packages/23/17/0f10aca56132d47e085c33a46d4a016a08ec081cd9efb8e55953ddc2067e/rust_just-1.38.0-py3-none-win32.whl", hash = "sha256:bcbaa423988d94218a457f44aa1310e959a2e634a93147d70cb9940b6a687a5d", size = 1559911 }, + { url = "https://files.pythonhosted.org/packages/a1/78/0a8b305c9fcd16a639ad6dd56268c642828769cd736041d554c82dc135ea/rust_just-1.38.0-py3-none-win_amd64.whl", hash = "sha256:2590b0a3eed6090129a618b7cbd94e107407b618eef95baba8c596fcf598f7a7", size = 1679295 }, +] + [[package]] name = "setuptools" version = "75.6.0" From 34609380b1213ae62d8c9aaf3d732dd4061f18e7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 24 Dec 2024 16:13:58 +0000 Subject: [PATCH 222/226] style(pre-commit.ci): auto fixes [...] --- src/pymmcore_gui/widgets/_mm_console.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pymmcore_gui/widgets/_mm_console.py b/src/pymmcore_gui/widgets/_mm_console.py index f4936842..281810ce 100644 --- a/src/pymmcore_gui/widgets/_mm_console.py +++ b/src/pymmcore_gui/widgets/_mm_console.py @@ -9,9 +9,9 @@ # Redirect sys.stdout and sys.stderr to devnull if they are None # this is necessary for the IPython console to work properly in a windows PyInstaller -if os.name == 'nt' and sys.stdout is None: - sys.stdout = open(os.devnull, 'w') - sys.stderr = open(os.devnull, 'w') +if os.name == "nt" and sys.stdout is None: + sys.stdout = open(os.devnull, "w") + sys.stderr = open(os.devnull, "w") from PyQt6.QtWidgets import QApplication From e6aca69725c9c3a9a2d7e304ba2b10f45aafde51 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 11:14:01 -0500 Subject: [PATCH 223/226] change bundle name --- .github/workflows/bundle.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml index 722d6c5b..01c15c30 100644 --- a/.github/workflows/bundle.yml +++ b/.github/workflows/bundle.yml @@ -50,6 +50,6 @@ jobs: # https://github.com/actions/upload-artifact/compare/main...eXhumer:0b7d5f5684d3f642f978d2faad9ade64f5b4dd57 uses: eXhumer/upload-artifact@0b7d5f5684d3f642f978d2faad9ade64f5b4dd57 with: - name: ${{ runner.os == 'macos' && 'pymmgui.zip' || runner.os == 'windows' && 'pymmgui.exe' }} + name: pymmgui-${{ runner.os }} path: ${{ runner.os == 'macos' && 'dist' || runner.os == 'windows' && 'dist/mmgui' }} follow-symlinks: false From b4fe735f4b72cc68a9467c5bcd137256cf9be535 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 11:14:51 -0500 Subject: [PATCH 224/226] stderr each separately --- src/pymmcore_gui/widgets/_mm_console.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pymmcore_gui/widgets/_mm_console.py b/src/pymmcore_gui/widgets/_mm_console.py index 281810ce..787f1ba0 100644 --- a/src/pymmcore_gui/widgets/_mm_console.py +++ b/src/pymmcore_gui/widgets/_mm_console.py @@ -9,9 +9,11 @@ # Redirect sys.stdout and sys.stderr to devnull if they are None # this is necessary for the IPython console to work properly in a windows PyInstaller -if os.name == "nt" and sys.stdout is None: - sys.stdout = open(os.devnull, "w") - sys.stderr = open(os.devnull, "w") +if os.name == "nt": + if sys.stdout is None: + sys.stdout = open(os.devnull, "w") + if sys.stderr is None: + sys.stderr = open(os.devnull, "w") from PyQt6.QtWidgets import QApplication From d47bb8cb1ca6cb1dcc3782d0cad671ff6e28129b Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 12:57:33 -0500 Subject: [PATCH 225/226] more windows changes --- justfile | 2 ++ src/pymmcore_gui/_app.py | 12 +++++++++++- src/pymmcore_gui/_main_window.py | 5 +---- uv.lock | 2 +- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/justfile b/justfile index 0cc8048e..cc182ba0 100644 --- a/justfile +++ b/justfile @@ -1,3 +1,5 @@ +set windows-powershell + # run the main application run: uv run python -m pymmcore_gui diff --git a/src/pymmcore_gui/_app.py b/src/pymmcore_gui/_app.py index 39702871..e435e8f7 100644 --- a/src/pymmcore_gui/_app.py +++ b/src/pymmcore_gui/_app.py @@ -77,7 +77,17 @@ def main() -> None: app = MMQApplication(sys.argv) _install_excepthook() - win = MicroManagerGUI(config=args.config) + win = MicroManagerGUI() + + # FIXME: be better... + try: + if args.config: + win.mmc.loadSystemConfiguration(args.config) + else: + win.mmc.loadSystemConfiguration() + except Exception as e: + print(f"Failed to load system configuration: {e}") + win.showMaximized() win.show() diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index 3ac846ba..a1c8cd95 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -88,9 +88,7 @@ class MicroManagerGUI(QMainWindow): ], } - def __init__( - self, *, mmcore: CMMCorePlus | None = None, config: str | None = None - ) -> None: + def __init__(self, *, mmcore: CMMCorePlus | None = None) -> None: super().__init__() self.setWindowTitle("Mike") self.setObjectName("MicroManagerGUI") @@ -109,7 +107,6 @@ def __init__( # get global CMMCorePlus instance self._mmc = mmc = mmcore or CMMCorePlus.instance() - self._mmc.loadSystemConfiguration() # MENUS ==================================== # To add menus or menu items, add them to the MENUS dict above diff --git a/uv.lock b/uv.lock index 6dad61a2..1a841bce 100644 --- a/uv.lock +++ b/uv.lock @@ -1211,7 +1211,7 @@ wheels = [ [[package]] name = "pymmcore-gui" -version = "0.1.dev227+g77cbf33.d20241224" +version = "0.1.dev232+gb4fe735.d20241224" source = { editable = "." } dependencies = [ { name = "ndv", extra = ["vispy"] }, From c061e7a7a5fc8420dfa9fb5cf28767b8ee8431c3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 24 Dec 2024 20:26:06 -0500 Subject: [PATCH 226/226] add shutters --- src/pymmcore_gui/_main_window.py | 4 ++- src/pymmcore_gui/widgets/_toolbars.py | 46 ++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/pymmcore_gui/_main_window.py b/src/pymmcore_gui/_main_window.py index a1c8cd95..45d24a0c 100644 --- a/src/pymmcore_gui/_main_window.py +++ b/src/pymmcore_gui/_main_window.py @@ -24,7 +24,7 @@ from .actions import CoreAction, WidgetAction from .actions._action_info import ActionKey -from .widgets._toolbars import OCToolBar +from .widgets._toolbars import OCToolBar, ShuttersToolbar if TYPE_CHECKING: from collections.abc import Callable, Mapping @@ -45,6 +45,7 @@ class Toolbar(str, Enum): CAMERA_ACTIONS = "Camera Actions" OPTICAL_CONFIGS = "Optical Configs" WIDGETS = "Widgets" + SHUTTERS = "Shutters" def __str__(self) -> str: return str(self.value) @@ -63,6 +64,7 @@ class MicroManagerGUI(QMainWindow): CoreAction.TOGGLE_LIVE, ], Toolbar.OPTICAL_CONFIGS: OCToolBar, + Toolbar.SHUTTERS: ShuttersToolbar, Toolbar.WIDGETS: [ WidgetAction.CONSOLE, WidgetAction.PROP_BROWSER, diff --git a/src/pymmcore_gui/widgets/_toolbars.py b/src/pymmcore_gui/widgets/_toolbars.py index bd7f8edd..bae47464 100644 --- a/src/pymmcore_gui/widgets/_toolbars.py +++ b/src/pymmcore_gui/widgets/_toolbars.py @@ -1,4 +1,7 @@ -from pymmcore_plus import CMMCorePlus +from __future__ import annotations + +from pymmcore_plus import CMMCorePlus, DeviceType +from pymmcore_widgets import ShuttersWidget from PyQt6.QtWidgets import QToolBar, QWidget @@ -41,3 +44,44 @@ def _refresh(self) -> None: @action.triggered.connect # type: ignore [misc] def _(checked: bool, pname: str = preset_name) -> None: mmc.setConfig(ch_group, pname) + + +class ShuttersToolbar(QToolBar): + """A QToolBar for the loased Shutters.""" + + def __init__( + self, + mmc: CMMCorePlus, + parent: QWidget | None = None, + ) -> None: + super().__init__("Shutters", parent) + self.mmc = mmc + self.mmc.events.systemConfigurationLoaded.connect(self._on_cfg_loaded) + self._on_cfg_loaded() + + def _on_cfg_loaded(self) -> None: + self._clear() + if not (shutters := self.mmc.getLoadedDevicesOfType(DeviceType.ShutterDevice)): + return + + shutters_devs = sorted( + shutters, + key=lambda d: any( + "Physical Shutter" in x for x in self.mmc.getDevicePropertyNames(d) + ), + reverse=True, + ) + + for idx, shutter in enumerate(shutters_devs): + s = ShuttersWidget(shutter, autoshutter=idx == len(shutters_devs) - 1) + s.button_text_open = shutter + s.button_text_closed = shutter + # s.icon_color_open = () + # s.icon_color_closed = () + self.addWidget(s) + + def _clear(self) -> None: + """Delete toolbar action.""" + while self.actions(): + action = self.actions()[0] + self.removeAction(action)