diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5d9dc9f3..04d32ccb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,21 +1,37 @@ stages: + - prepare - analysis - testing - build - package - deploy +meta-build-image: + image: docker:stable + services: + - docker:dind + stage: prepare + script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + - cd .meta-testimage-python + - docker build -t $CI_REGISTRY/openflexure/openflexure-microscope-server/testimage-python:latest . + - docker push $CI_REGISTRY/openflexure/openflexure-microscope-server/testimage-python:latest + only: + refs: # commented out for initial run + - v3 + changes: + - .meta/Dockerfile + + # Re-usable block to install (and cache) Poetry and openflexure-microscope-server .python: - image: balenalib/raspberry-pi:buster + image: registry.gitlab.com/openflexure/openflexure-microscope-server/testimage-python retry: 1 before_script: - - apt-get update - - apt-get install apt-utils curl wget python3 python3-pip python3-venv raspi-gpio libgfortran3 libatlas3-base libffi-dev vim git-lfs libtiff5 libpango-1.0-0 libavcodec58 libgdk-pixbuf2.0-0 libjasper1 libswscale5 libpangocairo-1.0-0 libilmbase23 libatk1.0-0 libgtk-3-0 libwebp6 libcairo2 libavutil56 libcairo-gobject2 libopenexr23 libavformat58 libxslt1.1 - - python3 -m venv .venv # NB this does not overwrite an existing cached venv + - python --version + - python -m venv .venv # NB this does not overwrite an existing cached venv - source .venv/bin/activate - - pip install pipenv # Should be cached after first run - - pipenv install --dev --deploy # Should be cached after first run - will just check packages are present + - pip install -e .[dev] cache: key: "${CI_COMMIT_REF_SLUG}" paths: @@ -42,57 +58,66 @@ stages: - tags - web -# Python static analysis with PyLint -pylint: +# Python formatting and static analysis with ruff +ruff: stage: analysis extends: .python script: - - poe pylint + - ruff check + - ruff format --check # Python type checking with Mypy mypy: stage: analysis extends: .python script: - - poe mypy + - mypy --cobertura-xml-report mypy --no-error-summary src | tee mypy/stdout.txt + after_script: + - cat mypy/stdout.txt | mypy-gitlab-code-quality > codequality.json + allow_failure: true + artifacts: reports: + codequality: codequality.json coverage_report: coverage_format: cobertura - path: openflexure_microscope/cobertura.xml - -# Python style analysis with Black -black: - stage: analysis - allow_failure: true - extends: .python - script: - - poe black_check + path: mypy/cobertura.xml # Python unit tests with PyTest pytest: stage: testing extends: .python script: - - poe test + - > + pytest --cov --cov-report term + --cov-report xml:coverage.xml + --junitxml=report.xml +# --gitlab-code-quality-report=pytest-warnings.json # can restore when it installs ok + coverage: '/(?i)total.*? (100(?:\.0+)?\%|[1-9]?\d(?:\.\d+)?\%)$/' artifacts: when: always - reports: - junit: pytest_report.xml - -# Generate and validate OpenAPI description -openapi: - stage: testing - extends: .python - script: - - mkdir -p docs/build/ - - ofm-generate-openapi -o docs/build/swagger.yaml --validate - artifacts: - name: "openapi" - expose_as: "OpenAPI Description" - expire_in: 1 week paths: - - "./docs/build/swagger.yaml" + - report.xml + reports: + junit: report.xml +# codequality: pytest-warnings.json + coverage_report: + coverage_format: cobertura + path: coverage.xml + +# Generate and validate OpenAPI description - needs updating to work with v3 +# openapi: +# stage: testing +# extends: .python +# script: +# - mkdir -p docs/build/ +# - ofm-generate-openapi -o docs/build/swagger.yaml --validate +# artifacts: +# name: "openapi" +# expose_as: "OpenAPI Description" +# expire_in: 1 week +# paths: +# - "./docs/build/swagger.yaml" # JavaScript linting with ESLint (via Vue CLI) eslint: @@ -115,86 +140,86 @@ build: paths: - "src/openflexure_microscope/static/" -# Package application into distribution tarball -package: - stage: package - dependencies: - - build - - openapi +# # Package application into distribution tarball +# package: +# stage: package +# dependencies: +# - build +# - openapi - image: ubuntu:latest +# image: ubuntu:latest - script: - # Build distribution archive - - mkdir -p dist - - tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz . - - tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz ./openflexure_microscope_server/static/ - - cp docs/build/swagger.yaml dist/openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml - - cd dist/ - - sha256sum openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz.sha256 - - sha256sum openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz.sha256 - - sha256sum openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml > openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml.sha256 +# script: +# # Build distribution archive +# - mkdir -p dist +# - tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz . +# - tar -c --exclude-vcs --exclude-from .tarignore -vzf dist/openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz ./openflexure_microscope_server/static/ +# - cp docs/build/swagger.yaml dist/openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml +# - cd dist/ +# - sha256sum openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-server-${CI_COMMIT_REF_NAME}.tar.gz.sha256 +# - sha256sum openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz > openflexure-microscope-webapp-${CI_COMMIT_REF_NAME}.tar.gz.sha256 +# - sha256sum openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml > openflexure-microscope-api-${CI_COMMIT_REF_NAME}.yaml.sha256 - artifacts: - name: "dist" - expire_in: 1 week - paths: - - "./dist/" +# artifacts: +# name: "dist" +# expire_in: 1 week +# paths: +# - "./dist/" - only: - - master - - merge_requests - - tags - - web +# only: +# - master +# - merge_requests +# - tags +# - web -# Deploy to builds.openflexure.org -deploy: - stage: deploy - dependencies: - - package +# # Deploy to builds.openflexure.org +# deploy: +# stage: deploy +# dependencies: +# - package - image: ubuntu:latest +# image: ubuntu:latest - before_script: - - "which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )" - - eval $(ssh-agent -s) - - ssh-add <(echo "$SSH_PRIVATE_KEY_BATH_OPENFLEXURE_BASE64" | base64 --decode) - - mkdir -p ~/.ssh - - echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config +# before_script: +# - "which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )" +# - eval $(ssh-agent -s) +# - ssh-add <(echo "$SSH_PRIVATE_KEY_BATH_OPENFLEXURE_BASE64" | base64 --decode) +# - mkdir -p ~/.ssh +# - echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config - script: - # Install rsync if not already installed - - "which rsync || ( apt-get update -y && apt-get install rsync -y )" +# script: +# # Install rsync if not already installed +# - "which rsync || ( apt-get update -y && apt-get install rsync -y )" - # Upload the builds folder to openflexure-microscope builds - - rsync -hrvz -e ssh dist/ ci-user@openflexure.bath.ac.uk:/var/www/build/openflexure-microscope-server +# # Upload the builds folder to openflexure-microscope builds +# - rsync -hrvz -e ssh dist/ ci-user@openflexure.bath.ac.uk:/var/www/build/openflexure-microscope-server - # Run update-latest.py on the build server - - ssh -t ci-user@openflexure.bath.ac.uk "/var/www/build/update-latest.py" +# # Run update-latest.py on the build server +# - ssh -t ci-user@openflexure.bath.ac.uk "/var/www/build/update-latest.py" - only: - - tags - - web +# only: +# - tags +# - web -zenodo: - stage: deploy - image: ubuntu:20.04 - before_script: - - apt-get update -qq - - apt-get -y -qq install git python3-pip - - pip3 install -r scripts/zenodo/requirements.txt - variables: - ZENODO_USE_SANDBOX: "false" - script: - - git archive "${CI_COMMIT_REF_NAME}" --format=zip --output="${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-source.zip" - - python3 scripts/zenodo/upload_to_zenodo.py "${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-source.zip" dist/* - artifacts: - # this is only a small html link, let's just keep it forever - # gitlab doesn't understand "expire_in: never" yet though - expire_in: 100000 years - name: zenodo-${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-link.html - paths: - - zenodo-link.html - only: - - tags - - web +# zenodo: +# stage: deploy +# image: ubuntu:20.04 +# before_script: +# - apt-get update -qq +# - apt-get -y -qq install git python3-pip +# - pip3 install -r scripts/zenodo/requirements.txt +# variables: +# ZENODO_USE_SANDBOX: "false" +# script: +# - git archive "${CI_COMMIT_REF_NAME}" --format=zip --output="${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-source.zip" +# - python3 scripts/zenodo/upload_to_zenodo.py "${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-source.zip" dist/* +# artifacts: +# # this is only a small html link, let's just keep it forever +# # gitlab doesn't understand "expire_in: never" yet though +# expire_in: 100000 years +# name: zenodo-${CI_PROJECT_NAME}-${CI_COMMIT_REF_NAME}-link.html +# paths: +# - zenodo-link.html +# only: +# - tags +# - web diff --git a/.meta-testimage-python/Dockerfile b/.meta-testimage-python/Dockerfile new file mode 100644 index 00000000..b45d0da2 --- /dev/null +++ b/.meta-testimage-python/Dockerfile @@ -0,0 +1,4 @@ +# A docker image with system-level dependencies installed already +FROM python:3.11-bookworm +RUN apt-get -yq update +RUN apt-get -yq install python3-opencv python3-numpy python3-scipy diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 5e1b4315..00000000 --- a/Pipfile +++ /dev/null @@ -1,13 +0,0 @@ -[[source]] -url = "https://www.piwheels.org/simple" -verify_ssl = true -name = "piwheels" - -[requires] -python_version = '3.7' - -[packages] -openflexure-microscope-server = {editable = true, path = "."} - -[dev-packages] -openflexure-microscope-server = {editable = true, path = ".", extras = ["dev"]} diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 7ded8cd7..00000000 --- a/Pipfile.lock +++ /dev/null @@ -1,2387 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "742e956ae3ee7147a09b3b6b6c32efbc74e7ff3e6e392f85b851441840ac55a6" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.7" - }, - "sources": [ - { - "name": "piwheels", - "url": "https://www.piwheels.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "apispec": { - "extras": [ - "validation", - "yaml" - ], - "hashes": [ - "sha256:6613dbc39f41cd58942a697f11c8762ba18422bd173fe0bdfc1535b83d3f84f0", - "sha256:79029486d36a0d7f3c659dbf6ae50a91fbed0c22dcd5376f592e076c130bc7f9" - ], - "markers": "python_version >= '3.6'", - "version": "==4.7.1" - }, - "apispec-webframeworks": { - "hashes": [ - "sha256:0db35b267914b3f8c562aca0261957dbcb4176f255eacc22520277010818dcf3", - "sha256:482c563abbcc2a261439476cb3f1a7c7284cc997c322c574d48c111643e9c04e" - ], - "markers": "python_version >= '3.6'", - "version": "==0.5.2" - }, - "attrs": { - "hashes": [ - "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836", - "sha256:52dd1548aaa4d65af91f99015a21718f8bbd3a45577e565cdd28b8a891da97a5", - "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99" - ], - "markers": "python_version >= '3.6'", - "version": "==22.2.0" - }, - "camera-stage-mapping": { - "hashes": [ - "sha256:02a409bd2cb193b20d9eb015ca31400bd3e1721ec5d702e1b96de54608ff5a8f", - "sha256:d39c3afebb59ec865fb1247dfd951d7090c159ab0cf3e4291e0869d3be33776c" - ], - "markers": "python_version >= '3.6' and python_version < '4.0'", - "version": "==0.1.4" - }, - "certifi": { - "hashes": [ - "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3", - "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18", - "sha256:cb52c7519140a4c2b84314220b3f08768882bb58176f3acc039ccac101d51870" - ], - "markers": "python_version >= '3.6'", - "version": "==2022.12.7" - }, - "chardet": { - "hashes": [ - "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", - "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==4.0.0" - }, - "charset-normalizer": { - "hashes": [ - "sha256:1799ad8111efa28db62e1b9773eba32dcc9fd0cd9d7c631bd4858eb15a30f858" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==3.0.1" - }, - "click": { - "hashes": [ - "sha256:a3747c864f8e400a3664f5f4fd6dae11b4605bf6b727dae7b6f22ba9bd0a194a", - "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", - "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==7.1.2" - }, - "colorzero": { - "hashes": [ - "sha256:0e60d743a6b8071498a56465f7719c96a5e92928f858bab1be2a0d606c9aa0f8", - "sha256:e7d5a5c26cd0dc37b164ebefc609f388de24f8593b659191e12d85f8f9d5eb58" - ], - "version": "==2.0" - }, - "coverage": { - "hashes": [ - "sha256:006ed5582e9cbc8115d2e22d6d2144a0725db542f654d9d4fda86793832f873d", - "sha256:046936ab032a2810dcaafd39cc4ef6dd295df1a7cbead08fe996d4765fca9fe4", - "sha256:0484d9dd1e6f481b24070c87561c8d7151bdd8b044c93ac99faafd01f695c78e", - "sha256:0ce383d5f56d0729d2dd40e53fe3afeb8f2237244b0975e1427bfb2cf0d32bab", - "sha256:186e0fc9cf497365036d51d4d2ab76113fb74f729bd25da0975daab2e107fd90", - "sha256:2199988e0bc8325d941b209f4fd1c6fa007024b1442c5576f1a32ca2e48941e6", - "sha256:299bc75cb2a41e6741b5e470b8c9fb78d931edbd0cd009c58e5c84de57c06731", - "sha256:3668291b50b69a0c1ef9f462c7df2c235da3c4073f49543b01e7eb1dee7dd540", - "sha256:36dd42da34fe94ed98c39887b86db9d06777b1c8f860520e21126a75507024f2", - "sha256:38004671848b5745bb05d4d621526fca30cee164db42a1f185615f39dc997292", - "sha256:387fb46cb8e53ba7304d80aadca5dca84a2fbf6fe3faf6951d8cf2d46485d1e5", - "sha256:3eb55b7b26389dd4f8ae911ba9bc8c027411163839dea4c8b8be54c4ee9ae10b", - "sha256:420f94a35e3e00a2b43ad5740f935358e24478354ce41c99407cddd283be00d2", - "sha256:4ac0f522c3b6109c4b764ffec71bf04ebc0523e926ca7cbe6c5ac88f84faced0", - "sha256:4c752d5264053a7cf2fe81c9e14f8a4fb261370a7bb344c2a011836a96fb3f57", - "sha256:4eef796938e3759c6b6814dbd615b1d3597a376b3174d05aed9f75f6fc6a1650", - "sha256:4f01911c010122f49a3e9bdc730eccc66f9b72bd410a3a9d3cb8448bb50d65d3", - "sha256:4f68ee32d7c4164f1e2c8797535a6d0a3733355f5861e0f667e37df2d4b07140", - "sha256:4fa54fb483decc45f94011898727802309a109d89446a3c76387d016057d2c84", - "sha256:507e4720791977934bba016101579b8c500fb21c5fa3cd4cf256477331ddd988", - "sha256:53d0fd4c17175aded9c633e319360d41a1f3c6e352ba94edcb0fa5167e2bad67", - "sha256:55272f33da9a5d7cccd3774aeca7a01e500a614eaea2a77091e9be000ecd401d", - "sha256:5764e1f7471cb8f64b8cda0554f3d4c4085ae4b417bfeab236799863703e5de2", - "sha256:57b77b9099f172804e695a40ebaa374f79e4fb8b92f3e167f66facbf92e8e7f5", - "sha256:5afdad4cc4cc199fdf3e18088812edcf8f4c5a3c8e6cb69127513ad4cb7471a9", - "sha256:5cc0783844c84af2522e3a99b9b761a979a3ef10fb87fc4048d1ee174e18a7d8", - "sha256:5e1df45c23d4230e3d56d04414f9057eba501f78db60d4eeecfcb940501b08fd", - "sha256:6146910231ece63facfc5984234ad1b06a36cecc9fd0c028e59ac7c9b18c38c6", - "sha256:797aad79e7b6182cb49c08cc5d2f7aa7b2128133b0926060d0a8889ac43843be", - "sha256:7c20b731211261dc9739bbe080c579a1835b0c2d9b274e5fcd903c3a7821cf88", - "sha256:817295f06eacdc8623dc4df7d8b49cea65925030d4e1e2a7c7218380c0072c25", - "sha256:81f63e0fb74effd5be736cfe07d710307cc0a3ccb8f4741f7f053c057615a137", - "sha256:872d6ce1f5be73f05bea4df498c140b9e7ee5418bfa2cc8204e7f9b817caa968", - "sha256:8c99cb7c26a3039a8a4ee3ca1efdde471e61b4837108847fb7d5be7789ed8fd9", - "sha256:8dbe2647bf58d2c5a6c5bcc685f23b5f371909a5624e9f5cd51436d6a9f6c6ef", - "sha256:8efb48fa743d1c1a65ee8787b5b552681610f06c40a40b7ef94a5b517d885c54", - "sha256:92ebc1619650409da324d001b3a36f14f63644c7f0a588e331f3b0f67491f512", - "sha256:9d22e94e6dc86de981b1b684b342bec5e331401599ce652900ec59db52940005", - "sha256:ba279aae162b20444881fc3ed4e4f934c1cf8620f3dab3b531480cf602c76b7f", - "sha256:bc4803779f0e4b06a2361f666e76f5c2e3715e8e379889d02251ec911befd149", - "sha256:bfe7085783cda55e53510482fa7b5efc761fad1abe4d653b32710eb548ebdd2d", - "sha256:c448b5c9e3df5448a362208b8d4b9ed85305528313fca1b479f14f9fe0d873b8", - "sha256:c90e73bdecb7b0d1cea65a08cb41e9d672ac6d7995603d6465ed4914b98b9ad7", - "sha256:d2b96123a453a2d7f3995ddb9f28d01fd112319a7a4d5ca99796a7ff43f02af5", - "sha256:d52f0a114b6a58305b11a5cdecd42b2e7f1ec77eb20e2b33969d702feafdd016", - "sha256:d530191aa9c66ab4f190be8ac8cc7cfd8f4f3217da379606f3dd4e3d83feba69", - "sha256:d683d230b5774816e7d784d7ed8444f2a40e7a450e5720d58af593cb0b94a212", - "sha256:db45eec1dfccdadb179b0f9ca616872c6f700d23945ecc8f21bb105d74b1c5fc", - "sha256:db8c2c5ace167fd25ab5dd732714c51d4633f58bac21fb0ff63b0349f62755a8", - "sha256:e2926b8abedf750c2ecf5035c07515770944acf02e1c46ab08f6348d24c5f94d", - "sha256:e627dee428a176ffb13697a2c4318d3f60b2ccdde3acdc9b3f304206ec130ccd", - "sha256:efe1c0adad110bf0ad7fb59f833880e489a61e39d699d37249bdf42f80590169", - "sha256:f47e4fd792793c19b66a09a7c79390490d7707dfc9c8c1a42cbd55110c73468b" - ], - "markers": "python_version >= '3.7'", - "version": "==7.2.2" - }, - "exceptiongroup": { - "hashes": [ - "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e", - "sha256:64c69a1565ab9821c7aaca26c5457827f8b899595b9a1e9ab78abbf10e8b17a5", - "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785" - ], - "markers": "python_version < '3.11'", - "version": "==1.1.1" - }, - "expiringdict": { - "hashes": [ - "sha256:09a5d20bc361163e6432a874edd3179676e935eb81b925eccef48d409a8a45e8", - "sha256:300fb92a7e98f15b05cf9a856c1415b3bc4f2e132be07daa326da6414c23ee09", - "sha256:5ced493f465d46cb39014399f0be41329056629f019226c02accfb4f990b3dea" - ], - "version": "==1.2.2" - }, - "flask": { - "hashes": [ - "sha256:0fbeb6180d383a9186d0d6ed954e0042ad9f18e0e8de088b2b419d526927d196", - "sha256:c34f04500f2cbbea882b1acb02002ad6fe6b7ffa64a6164577995657f50aed22" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.1.4" - }, - "flask-cors": { - "hashes": [ - "sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438", - "sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de" - ], - "version": "==3.0.10" - }, - "future": { - "hashes": [ - "sha256:06cf12592a81bdba6c2f05be3be47da76c8beedce11c5539e5edea9de9b9eac6", - "sha256:34a17436ed1e96697a86f9de3d15a3b0be01d8bc8de9c1dffd59fb8234ed5307" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.18.3" - }, - "idna": { - "hashes": [ - "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", - "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2", - "sha256:bcac1132a4eadea8321490704e2349b752e29c6ec2f1c27d372010e76ebd3fa1" - ], - "markers": "python_version >= '3.5'", - "version": "==3.4" - }, - "ifaddr": { - "hashes": [ - "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", - "sha256:0998123149175f29de1271c4088edff8ee9274873debac057a1b40149568be87", - "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4" - ], - "version": "==0.2.0" - }, - "importlib-metadata": { - "hashes": [ - "sha256:43ce9281e097583d758c2c708c4376371261a02c34682491a8e98352365aad20", - "sha256:ab197eeef247b9bfe3d3d91334921e09626181362991c0479b1340aabc00532f", - "sha256:ff80f3b5394912eb1b108fcfd444dc78b7f1f3e16b16188054bd01cb9cb86f09" - ], - "markers": "python_version < '3.8'", - "version": "==6.1.0" - }, - "importlib-resources": { - "hashes": [ - "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6", - "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a", - "sha256:f36205b58b84e2f35777817eb7ac6506a21982ad9954a72ab0833abf36c44183" - ], - "markers": "python_version < '3.9'", - "version": "==5.12.0" - }, - "iniconfig": { - "hashes": [ - "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", - "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" - ], - "markers": "python_version >= '3.7'", - "version": "==2.0.0" - }, - "itsdangerous": { - "hashes": [ - "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", - "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.1.0" - }, - "jinja2": { - "hashes": [ - "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", - "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.11.3" - }, - "jsonschema": { - "hashes": [ - "sha256:0f864437ab8b6076ba6707453ef8f98a6a0d512a80e93f8abdb676f737ecb60d", - "sha256:a870ad254da1a8ca84b6a2905cac29d265f805acc57af304784962a2aa6508f6" - ], - "markers": "python_version >= '3.7'", - "version": "==4.17.3" - }, - "jsonschema-spec": { - "hashes": [ - "sha256:34471d8b60e1f06d174236c4d3cf9590fbf3cff1cc733b28d15cd83672bcd062", - "sha256:824c743197bbe2104fcc6dce114a4082bf7f7efdebf16683510cb0ec6d8d53d0", - "sha256:8a9534ec1165fd4e5727c758e848a1e0129e704dc87010bf739c592b0b1f4d55" - ], - "markers": "python_full_version >= '3.7.0' and python_full_version < '4.0.0'", - "version": "==0.1.4" - }, - "labthings": { - "hashes": [ - "sha256:1f165e8a78f0ef28576e64e6990c7cf74026c7db6376c5fabc9fc22bf6a8b372", - "sha256:83e5a8849d556b8e6c3138b1284148095f4e3ed983dad15096262c7b31c5947e", - "sha256:e01842fc6e38594c3ea787911d30ebbc70bc67ed6f58544b38ab4572d46eb60e" - ], - "markers": "python_version >= '3.6' and python_version < '4.0'", - "version": "==1.3.2" - }, - "lazy-object-proxy": { - "hashes": [ - "sha256:09763491ce220c0299688940f8dc2c5d05fd1f45af1e42e636b2e8b2303e4382", - "sha256:0a891e4e41b54fd5b8313b96399f8b0e173bbbfc03c7631f01efbe29bb0bcf82", - "sha256:0e988a556683ec90e9f3284c650746cfadc122effd9073d3db065e145403f7c7", - "sha256:189bbd5d41ae7a498397287c408617fe5c48633e7755287b21d741f7db2706a9", - "sha256:18b78ec83edbbeb69efdc0e9c1cb41a3b1b1ed11ddd8ded602464c3fc6020494", - "sha256:1aa3de4088c89a1b69f8ec0dcc169aa725b0ff017899ac568fe44ddc1396df46", - "sha256:212774e4dfa851e74d393a2370871e174d7ff0ebc980907723bb67d25c8a7c30", - "sha256:2d0daa332786cf3bb49e10dc6a17a52f6a8f9601b4cf5c295a4f85854d61de63", - "sha256:5f83ac4d83ef0ab017683d715ed356e30dd48a93746309c8f3517e1287523ef4", - "sha256:659fb5809fa4629b8a1ac5106f669cfc7bef26fbb389dda53b3e010d1ac4ebae", - "sha256:660c94ea760b3ce47d1855a30984c78327500493d396eac4dfd8bd82041b22be", - "sha256:66a3de4a3ec06cd8af3f61b8e1ec67614fbb7c995d02fa224813cb7afefee701", - "sha256:721532711daa7db0d8b779b0bb0318fa87af1c10d7fe5e52ef30f8eff254d0cd", - "sha256:7322c3d6f1766d4ef1e51a465f47955f1e8123caee67dd641e67d539a534d006", - "sha256:79a31b086e7e68b24b99b23d57723ef7e2c6d81ed21007b6281ebcd1688acb0a", - "sha256:81fc4d08b062b535d95c9ea70dbe8a335c45c04029878e62d744bdced5141586", - "sha256:8fa02eaab317b1e9e03f69aab1f91e120e7899b392c4fc19807a8278a07a97e8", - "sha256:9090d8e53235aa280fc9239a86ae3ea8ac58eff66a705fa6aa2ec4968b95c821", - "sha256:946d27deaff6cf8452ed0dba83ba38839a87f4f7a9732e8f9fd4107b21e6ff07", - "sha256:9990d8e71b9f6488e91ad25f322898c136b008d87bf852ff65391b004da5e17b", - "sha256:9cd077f3d04a58e83d04b20e334f678c2b0ff9879b9375ed107d5d07ff160171", - "sha256:9e7551208b2aded9c1447453ee366f1c4070602b3d932ace044715d89666899b", - "sha256:9f5fa4a61ce2438267163891961cfd5e32ec97a2c444e5b842d574251ade27d2", - "sha256:b40387277b0ed2d0602b8293b94d7257e17d1479e257b4de114ea11a8cb7f2d7", - "sha256:bfb38f9ffb53b942f2b5954e0f610f1e721ccebe9cce9025a38c8ccf4a5183a4", - "sha256:cbf9b082426036e19c6924a9ce90c740a9861e2bdc27a4834fd0a910742ac1e8", - "sha256:cd6184026be98242806204c8dc2398d5bbd19ea105ec93826af5f6176b7913e3", - "sha256:d9e25ef10a39e8afe59a5c348a4dbf29b4868ab76269f81ce1674494e2565a6e", - "sha256:db1c1722726f47e10e0b5fdbf15ac3b8adb58c091d12b3ab713965795036985f", - "sha256:e7c21c95cae3c05c14aafffe2865bbd5e377cfc1348c4f7751d9dc9a48ca4bda", - "sha256:e8c6cfb338b133fbdbc5cfaa10fe3c6aeea827db80c978dbd13bc9dd8526b7d4", - "sha256:ea806fd4c37bf7e7ad82537b0757999264d5f70c45468447bb2b91afdbe73a6e", - "sha256:edd20c5a55acb67c7ed471fa2b5fb66cb17f61430b7a6b9c3b4a1e40293b1671", - "sha256:f0117049dd1d5635bbff65444496c90e0baa48ea405125c088e93d9cf4525b11", - "sha256:f0705c376533ed2a9e5e97aacdbfe04cecd71e0aa84c7c0595d02ef93b6e4455", - "sha256:f12ad7126ae0c98d601a7ee504c1122bcef553d1d5e0c3bfa77b16b3968d2734", - "sha256:f2457189d8257dd41ae9b434ba33298aec198e30adf2dcdaaa3a28b9994f6adb", - "sha256:f699ac1c768270c9e384e4cbd268d6e67aebcfae6cd623b4d7c3bfde5a35db59" - ], - "markers": "python_version >= '3.7'", - "version": "==1.9.0" - }, - "markupsafe": { - "hashes": [ - "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", - "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", - "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", - "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194", - "sha256:060b790af48b3d1cb6776a1e8956fb41106c6befaca717181c41a1728147dc73", - "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", - "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", - "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", - "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", - "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", - "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", - "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", - "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a", - "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", - "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", - "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", - "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", - "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", - "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", - "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", - "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047", - "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", - "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", - "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b", - "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", - "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", - "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", - "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", - "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1", - "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", - "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", - "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", - "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee", - "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f", - "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", - "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", - "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", - "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", - "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", - "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", - "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86", - "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6", - "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", - "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", - "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", - "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", - "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e", - "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", - "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", - "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f", - "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", - "sha256:b2b16984860d962a9f7f42c8f84ecdb74993b803a705309bb0acea7f032e37e7", - "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", - "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", - "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", - "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", - "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", - "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", - "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a", - "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207", - "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", - "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", - "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd", - "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", - "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", - "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9", - "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", - "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", - "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", - "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", - "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" - ], - "markers": "python_version >= '3.6'", - "version": "==2.0.1" - }, - "marshmallow": { - "hashes": [ - "sha256:d41d690f5e799ebd9a97e761be71524ca3a728442e01c029dd029654778e0797" - ], - "markers": "python_version >= '3.7'", - "version": "==3.18.0" - }, - "numpy": { - "hashes": [ - "sha256:0b78ecfa070460104934e2caf51694ccd00f37d5e5dbe76f021b1b0b0d221823", - "sha256:1247ef28387b7bb7f21caf2dbe4767f4f4175df44d30604d42ad9bd701ebb31f", - "sha256:1403b4e2181fc72664737d848b60e65150f272fe5a1c1cbc16145ed43884065a", - "sha256:170b2a0805c6891ca78c1d96ee72e4c3ed1ae0a992c75444b6ab20ff038ba2cd", - "sha256:2e4ed57f45f0aa38beca2a03b6532e70e548faf2debbeb3291cfc9b315d9be8f", - "sha256:32fe5b12061f6446adcbb32cf4060a14741f9c21e15aaee59a207b6ce6423469", - "sha256:34f3456f530ae8b44231c63082c8899fe9c983fd9b108c997c4b1c8c2d435333", - "sha256:4c9c23158b87ed0e70d9a50c67e5c0b3f75bcf2581a8e34668d4e9d7474d76c6", - "sha256:5d95668e727c75b3f5088ec7700e260f90ec83f488e4c0aaccb941148b2cd377", - "sha256:615d4e328af7204c13ae3d4df7615a13ff60a49cb0d9106fde07f541207883ca", - "sha256:69077388c5a4b997442b843dbdc3a85b420fb693ec8e33020bb24d647c164fa5", - "sha256:74b85a17528ca60cf98381a5e779fc0264b4a88b46025e6bcbe9621f46bb3e63", - "sha256:81225e58ef5fce7f1d80399575576fc5febec79a8a2742e8ef86d7b03beef49f", - "sha256:826319c22f05d7b50c3637af5563234edbb02f5634a08d314d21ef00271a32fe", - "sha256:8731b4e638adcdefe10ecad3d8c3ae60eec7311cdfd34ecdb72a41df56071be8", - "sha256:8890b3360f345e8360133bc078d2dacc2843b6ee6059b568781b15b97acbe39f", - "sha256:92aafa03da8658609f59f18722b88f0a73a249101169e28415b4fa148caf7e41", - "sha256:9864424631775b0c052f3bd98bc2712d131b3e2cd95d1c0c68b91709170890b0", - "sha256:9e6f5f50d1eff2f2f752b3089a118aee1ea0da63d56c44f3865681009b0af162", - "sha256:a3deb31bc84f2b42584b8c4001c85d1934dbfb4030827110bc36bfd11509b7bf", - "sha256:ad010846cdffe7ec27e3f933397f8a8d6c801a48634f419e3d075db27acf5880", - "sha256:b1e2312f5b8843a3e4e8224b2b48fe16119617b8fc0a54df8f50098721b5bed2", - "sha256:bc988afcea53e6156546e5b2885b7efab089570783d9d82caf1cfd323b0bb3dd", - "sha256:c449eb870616a7b62e097982c622d2577b3dbc800aaf8689254ec6e0197cbf1e", - "sha256:c74c699b122918a6c4611285cc2cad4a3aafdb135c22a16ec483340ef97d573c", - "sha256:c885bfc07f77e8fee3dc879152ba993732601f1f11de248d4f357f0ffea6a6d4", - "sha256:e3c3e990274444031482a31280bf48674441e0a5b55ddb168f3a6db3e0c38ec8", - "sha256:e4799be6a2d7d3c33699a6f77201836ac975b2e1b98c2a07f66a38f499cb50ce", - "sha256:e6c76a87633aa3fa16614b61ccedfae45b91df2767cf097aa9c933932a7ed1e0", - "sha256:e89717274b41ebd568cd7943fc9418eeb49b1785b66031bc8a7f6300463c5898", - "sha256:f5162ec777ba7138906c9c274353ece5603646c6965570d82905546579573f73", - "sha256:fde96af889262e85aa033f8ee1d3241e32bf36228318a61f1ace579df4e8170d" - ], - "markers": "python_version < '3.11' and python_version >= '3.7'", - "version": "==1.21.4" - }, - "openapi-schema-validator": { - "hashes": [ - "sha256:33661cd8a06d5112dec52d758dda55e543e4a7c9f0efc2901dcf83ebd5604055", - "sha256:79f37f38ef9fd5206b924ed7a6f382cea7b649b3b56383c47f1906082b7b9015", - "sha256:c573e2be2c783abae56c5a1486ab716ca96e09d1c3eab56020d1dc680aa57bf8" - ], - "markers": "python_full_version >= '3.7.0' and python_full_version < '4.0.0'", - "version": "==0.4.4" - }, - "openapi-spec-validator": { - "hashes": [ - "sha256:1189d0618ae0678ccf6c883cc1266d381454eece6f21fcf330cc7caea5fc25eb", - "sha256:4145478f26df16059c147406eaaa59b77ff60e3461ba6edb9aa84d481ed89aaf", - "sha256:92721d4a905a1e47a574d67f38dad32b64f7146b3f63d18edf0f3aa97afad207" - ], - "version": "==0.5.6" - }, - "opencv-python-headless": { - "hashes": [ - "sha256:3f330468c29882dbbec5af25695c5e575572c6b855cb0f9fe53e14116fd46bfc", - "sha256:4bdf982574bf2fefc5f82c86df7cb42e56ad627874c7c0f4d94ecf4ae8885304", - "sha256:567a54c1919bcf5b3d20a9830e3c511e57134de8def286ce137c3544a892f98c", - "sha256:62e31878641a8f96e773118d1eea9f34bdda87c9990a0faab04ebaafb5ae015c", - "sha256:672b5657a36a642d7b38b8ae94a266c7abaedfa3bb31276d0b889d2a96615f22", - "sha256:a60e9ff48854ec37be391e19dd634883cc26c2f0f814e5325b3deca33420912c", - "sha256:c3c2dda44d601757a508b07d628537c49f31223ad4edd0f747a70d4c852a7b98", - "sha256:ca4f013fa958f60fb2327fe87e6127c1ac0ab536890b1d4b00847f417b7af1ba", - "sha256:dcf472cf3b31c4e3f7135378aac6f4c5da5b49a07145c825e38bc23af17ce43f" - ], - "markers": "python_version >= '3.6'", - "version": "==4.5.5.64" - }, - "openflexure-microscope-server": { - "editable": true, - "path": "." - }, - "packaging": { - "hashes": [ - "sha256:36bb3108f8e33f88e52e7985b4068e2585b67e5b7b3a0fa2817f9852bc39d2ef", - "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", - "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" - ], - "markers": "python_version >= '3.6'", - "version": "==21.3" - }, - "pathable": { - "hashes": [ - "sha256:5c869d315be50776cc8a993f3af43e0c60dc01506b399643f919034ebf4cdcab", - "sha256:cdd7b1f9d7d5c8b8d3315dbf5a86b2596053ae845f056f57d97c0eefff84da14" - ], - "markers": "python_full_version >= '3.7.0' and python_full_version < '4.0.0'", - "version": "==0.4.3" - }, - "picamerax": { - "hashes": [ - "sha256:8dc45542644ed9c67e3b331e90bcffa04098e8b056a42d0fedb25769b0b1f17e", - "sha256:fccb201ad9e2ab67946111d6bf875289c141ef900fd5875de6d435d219c59440" - ], - "version": "==20.9.1" - }, - "piexif": { - "hashes": [ - "sha256:3bc435d171720150b81b15d27e05e54b8abbde7b4242cddd81ef160d283108b6", - "sha256:83cb35c606bf3a1ea1a8f0a25cb42cf17e24353fd82e87ae3884e74a302a5f1b" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.1.3" - }, - "pillow": { - "hashes": [ - "sha256:0295442429645fa16d05bd567ef5cff178482439c9aad0411d3f0ce9b88b3a6f", - "sha256:06aba4169e78c439d528fdeb34762c3b61a70813527a2c57f0540541e9f433a8", - "sha256:09d7f9e64289cb40c2c8d7ad674b2ed6105f55dc3b09aa8e4918e20a0311e7ad", - "sha256:0a80dd307a5d8440b0a08bd7b81617e04d870e40a3e46a32d9c246e54705e86f", - "sha256:1ca594126d3c4def54babee699c055a913efb01e106c309fa6b04405d474d5ae", - "sha256:25930fadde8019f374400f7986e8404c8b781ce519da27792cbe46eabec00c4d", - "sha256:39b8830992872e00605ec6eb0d83efad488553f10d79066b8b47c8be38e126b3", - "sha256:431b15cffbf949e89df2f7b48528be18b78bfa5177cb3036284a5508159492b5", - "sha256:4a2ef919795ad6fc28c47be3f16ec8add4e8c6daa01871e25788cda1b244b162", - "sha256:52125833b070791fcb5710fabc640fc1df07d087fc0c0f02d3661f76c23c5b8b", - "sha256:5e51ee2b8114def244384eda1c82b10e307ad9778dac5c83fb0943775a653cd8", - "sha256:612cfda94e9c8346f239bf1a4b082fdd5c8143cf82d685ba2dba76e7adeeb233", - "sha256:6d7741e65835716ceea0fd13a7d0192961212fd59e741a46bbed7a473c634ed6", - "sha256:6edb5446f44d901e8683ffb25ebdfc26988ee813da3bf91e12252b57ac163727", - "sha256:725aa6cfc66ce2857d585f06e9519a1cc0ef6d13f186ff3447ab6dff0a09bc7f", - "sha256:8dad18b69f710bf3a001d2bf3afab7c432785d94fcf819c16b5207b1cfd17d38", - "sha256:94cf49723928eb6070a892cb39d6c156f7b5a2db4e8971cb958f7b6b104fb4c4", - "sha256:97f9e7953a77d5a70f49b9a48da7776dc51e9b738151b22dacf101641594a626", - "sha256:9ad7f865eebde135d526bb3163d0b23ffff365cf87e767c649550964ad72785d", - "sha256:9c87ef410a58dd54b92424ffd7e28fd2ec65d2f7fc02b76f5e9b2067e355ebf6", - "sha256:a060cf8aa332052df2158e5a119303965be92c3da6f2d93b6878f0ebca80b2f6", - "sha256:c79f9c5fb846285f943aafeafda3358992d64f0ef58566e23484132ecd8d7d63", - "sha256:c92302a33138409e8f1ad16731568c55c9053eee71bb05b6b744067e1b62380f", - "sha256:d08b23fdb388c0715990cbc06866db554e1822c4bdcf6d4166cf30ac82df8c41", - "sha256:d350f0f2c2421e65fbc62690f26b59b0bcda1b614beb318c81e38647e0f673a1", - "sha256:e6d08a4d39b8a3a503c6e97e53ee2cc1dcd4fd05c4f87e8991f53e979608d0f6", - "sha256:e901964262a56d9ea3c2693df68bc9860b8bdda2b04768821e4c44ae797de117", - "sha256:ec29604081f10f16a7aea809ad42e27764188fc258b02259a03a8ff7ded3808d", - "sha256:edf31f1150778abd4322444c393ab9c7bd2af271dd4dafb4208fb613b1f3cdc9", - "sha256:f7e30c27477dffc3e85c2463b3e649f751789e0f6c8456099eea7ddd53be4a8a", - "sha256:ffe538682dc19cc542ae7c3e504fdf54ca7f86fb8a135e59dd6bc8627eae6cce" - ], - "markers": "python_version >= '3.5'", - "version": "==7.2.0" - }, - "pkgutil-resolve-name": { - "hashes": [ - "sha256:23dcc36937a564cabe07363c490c1b5acc4019fe299788a7ad3e28f1be5fc506", - "sha256:357d6c9e6a755653cfd78893817c0853af365dd51ec97f3d358a819373bbd174", - "sha256:ca27cc078d25c5ad71a9de0a7a330146c4e014c2462d9af19c6b828280649c5e" - ], - "markers": "python_version < '3.9'", - "version": "==1.3.10" - }, - "pluggy": { - "hashes": [ - "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", - "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3", - "sha256:f2d9abbfd1465ce5d1e261e51cc15afc87ede2ae079c7c7c8d894b1cc37ba123" - ], - "markers": "python_version >= '3.6'", - "version": "==1.0.0" - }, - "prance": { - "extras": [ - "osv" - ], - "hashes": [ - "sha256:814a523bc1ff18383c12cb523ce44c90fe8792bf5f48d8cc33c9f658276658ed", - "sha256:a9d0389da9919cf266a6668aeb1dec294f844f99c256ee6c2e36a9451b69a57b", - "sha256:c15e9ca889b56262e4c2aee354f52918ba5e54f46bb3da42b806d8bbd8255ee9" - ], - "version": "==0.22.11.4.0" - }, - "psutil": { - "hashes": [ - "sha256:094f899ac3ef72422b7e00411b4ed174e3c5a2e04c267db6643937ddba67a05b", - "sha256:10b7f75cc8bd676cfc6fa40cd7d5c25b3f45a0e06d43becd7c2d2871cbb5e806", - "sha256:1b1575240ca9a90b437e5a40db662acd87bbf181f6aa02f0204978737b913c6b", - "sha256:21231ef1c1a89728e29b98a885b8e0a8e00d09018f6da5cdc1f43f988471a995", - "sha256:28f771129bfee9fc6b63d83a15d857663bbdcae3828e1cb926e91320a9b5b5cd", - "sha256:70387772f84fa5c3bb6a106915a2445e20ac8f9821c5914d7cbde148f4d7ff73", - "sha256:7d5e1be385287e2334f708ec261b8ee364ac2bcb07eadcbd7a254f9cd476b65f", - "sha256:891e24ec41be15a99c00b0f3a378d3d439876fe18793f874a4f8e796fec77458", - "sha256:a9340f6b9ff887d77aed72a51d101ca0f21b8d82edc6c85105d1d9f027544365", - "sha256:b560f5cd86cf8df7bcd258a851ca1ad98f0d5b8b98748e877a0aec4e9032b465", - "sha256:b74b43fecce384a57094a83d2778cdfc2e2d9a6afaadd1ebecb2e75e0d34e10d", - "sha256:e85f727ffb21539849e6012f47b12f6dd4c44965e56591d8dec6e8bc9ab96f4a", - "sha256:fd2e09bb593ad9bdd7429e779699d2d47c1268cbde4dda95fcd1bd17544a0217", - "sha256:ffad8eb2ac614518bbe3c0b8eb9dffdb3a8d2e3a7d5da51c5b974fb723a5c5aa" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==5.6.7" - }, - "pyparsing": { - "hashes": [ - "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", - "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc", - "sha256:94fac81ddf92f0bdf3bb33c1c3e6d0181caa204a578184dd02778870ccf307c5" - ], - "markers": "python_full_version >= '3.6.8'", - "version": "==3.0.9" - }, - "pyrsistent": { - "hashes": [ - "sha256:016ad1afadf318eb7911baa24b049909f7f3bb2c5b1ed7b6a8f21db21ea3faa8", - "sha256:0a76a451d782433be74a500c6a072d1c6669481bfcbcdd985d12f0965fa6b87e", - "sha256:1a2994773706bbb4995c31a97bc94f1418314923bd1048c6d964837040376440", - "sha256:20460ac0ea439a3e79caa1dbd560344b64ed75e85d8703943e0b66c2a6150e4a", - "sha256:3311cb4237a341aa52ab8448c27e3a9931e2ee09561ad150ba94e4cfd3fc888c", - "sha256:3a8cb235fa6d3fd7aae6a4f1429bbb1fec1577d978098da1252f0489937786f3", - "sha256:3ab2204234c0ecd8b9368dbd6a53e83c3d4f3cab10ecaf6d0e772f456c442393", - "sha256:42ac0b2f44607eb92ae88609eda931a4f0dfa03038c44c772e07f43e738bcac9", - "sha256:49c32f216c17148695ca0e02a5c521e28a4ee6c5089f97e34fe24163113722da", - "sha256:4b774f9288dda8d425adb6544e5903f1fb6c273ab3128a355c6b972b7df39dcf", - "sha256:4c18264cb84b5e68e7085a43723f9e4c1fd1d935ab240ce02c0324a8e01ccb64", - "sha256:55ff9ca4735904a3897f5d03170c56122b724bf2ead32def8999fbb6949c7bb5", - "sha256:5a474fb80f5e0d6c9394d8db0fc19e90fa540b82ee52dba7d246a7791712f74a", - "sha256:64220c429e42a7150f4bfd280f6f4bb2850f95956bde93c6fda1b70507af6ef3", - "sha256:878433581fc23e906d947a6814336eee031a00e6defba224234169ae3d3d6a98", - "sha256:99abb85579e2165bd8522f0c0138864da97847875ecbd45f3e7e2af569bfc6f2", - "sha256:a2471f3f8693101975b1ff85ffd19bb7ca7dd7c38f8a81701f67d6b4f97b87d8", - "sha256:aeda827381f5e5d65cced3024126529ddc4289d944f75e090572c77ceb19adbf", - "sha256:b735e538f74ec31378f5a1e3886a26d2ca6351106b4dfde376a26fc32a044edc", - "sha256:c147257a92374fde8498491f53ffa8f4822cd70c0d85037e09028e478cababb7", - "sha256:c4db1bd596fefd66b296a3d5d943c94f4fac5bcd13e99bffe2ba6a759d959a28", - "sha256:c74bed51f9b41c48366a286395c67f4e894374306b197e62810e0fdaf2364da2", - "sha256:c9bb60a40a0ab9aba40a59f68214eed5a29c6274c83b2cc206a359c4a89fa41b", - "sha256:cc5d149f31706762c1f8bda2e8c4f8fead6e80312e3692619a75301d3dbb819a", - "sha256:ccf0d6bd208f8111179f0c26fdf84ed7c3891982f2edaeae7422575f47e66b64", - "sha256:e42296a09e83028b3476f7073fcb69ffebac0e66dbbfd1bd847d61f74db30f19", - "sha256:e8f2b814a3dc6225964fa03d8582c6e0b6650d68a232df41e3cc1b66a5d2f8d1", - "sha256:f0774bf48631f3a20471dd7c5989657b639fd2d285b861237ea9e82c36a415a9", - "sha256:f0e7c4b2f77593871e918be000b96c8107da48444d57005b6a6bc61fb4331b2c" - ], - "markers": "python_version >= '3.7'", - "version": "==0.19.3" - }, - "pyserial": { - "hashes": [ - "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", - "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0" - ], - "version": "==3.5" - }, - "pytest": { - "hashes": [ - "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e", - "sha256:a6377f55b06d85889e6db60ee185b02e0aa87af517ec2eec93075d1a9bc22648", - "sha256:c99ab0c73aceb050f68929bc93af19ab6db0558791c6a0715723abe9d0ade9d4" - ], - "markers": "python_version >= '3.7'", - "version": "==7.2.2" - }, - "pytest-cov": { - "hashes": [ - "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191", - "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.10.1" - }, - "python-dateutil": { - "hashes": [ - "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", - "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.8.2" - }, - "pyyaml": { - "hashes": [ - "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", - "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696", - "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393", - "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77", - "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922", - "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5", - "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8", - "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10", - "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc", - "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", - "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", - "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", - "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347", - "sha256:7318f36ecd539646767465d194cbbe9c248f243ba0117c3d043c660d69cfeceb", - "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", - "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541", - "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", - "sha256:8fc87fd1a1d30991d9198a4317e90dfaf3d2963c013608a019e8311568fa08b2", - "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", - "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc", - "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", - "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa", - "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", - "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122", - "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", - "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", - "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", - "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc", - "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247", - "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", - "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==5.4.1" - }, - "requests": { - "hashes": [ - "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa", - "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf", - "sha256:e77536e318369f207754c018f33a97b68adec830f33bee3507d296683ba1d416" - ], - "markers": "python_version >= '3.7' and python_version < '4'", - "version": "==2.28.2" - }, - "rfc3339-validator": { - "hashes": [ - "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", - "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==0.1.4" - }, - "rpi.gpio": { - "hashes": [ - "sha256:15311d3b063b71dee738cd26570effc9985a952454d162937c34e08c0fc99902", - "sha256:231f6142c25975a7e39b96faf4905f05f97dba6809e8c5f0d58f284b35b4b821", - "sha256:26b2ade4bb353bbe5417a64e40b45cdf00a82f27c2d320c0dd46c59751959d77", - "sha256:29226823da8b5ccb9001d795a944f2e00924eeae583490f0bc7317581172c624", - "sha256:5073d1972727cfad38a1a42f02da91dc41f137679290aa804ab6c410168347ac", - "sha256:57b6c044ef5375a78c8dda27cdfadf329e76aa6943cd6cffbbbd345a9adf9ca5", - "sha256:77afb817b81331ce3049a4b8f94a85e41b7c404d8e56b61ac0f1eb75c3120868", - "sha256:96ea7e5bf6bf592828487bfa7d3d1d0e432d5d1682e9e33b4dab398914628fcd", - "sha256:b86b66dc02faa5461b443a1e1f0c1d209d64ab5229696f32fb3b0215e0600c8c", - "sha256:cd61c4b03c37b62bba4a5acfea9862749c33c618e0295e7e90aa4713fb373b70", - "sha256:e5dfec2c4ccb7dbe9eef3dfc76a3b04121fc62d77fd8fbe1a8e841468a1b0c4c" - ], - "version": "==0.7.1" - }, - "ruamel.yaml": { - "hashes": [ - "sha256:5a81d994d11f98c7e7288636ea5bef647359641353411cebf8d2cce3a4a12385", - "sha256:742b35d3d665023981bd6d16b3d24248ce5df75fdb4e2924e93a05c1f8b61ca7", - "sha256:8b7ce697a2f212752a35c1ac414471dc16c424c9573be4926b56ff3f5d23b7af" - ], - "markers": "python_version >= '3'", - "version": "==0.17.21" - }, - "ruamel.yaml.clib": { - "hashes": [ - "sha256:045e0626baf1c52e5527bd5db361bc83180faaba2ff586e763d3d5982a876a9e", - "sha256:15910ef4f3e537eea7fe45f8a5d19997479940d9196f357152a09031c5be59f3", - "sha256:184faeaec61dbaa3cace407cffc5819f7b977e75360e8d5ca19461cd851a5fc5", - "sha256:1f08fd5a2bea9c4180db71678e850b995d2a5f4537be0e94557668cf0f5f9497", - "sha256:2aa261c29a5545adfef9296b7e33941f46aa5bbd21164228e833412af4c9c75f", - "sha256:3110a99e0f94a4a3470ff67fc20d3f96c25b13d24c6980ff841e82bafe827cac", - "sha256:3243f48ecd450eddadc2d11b5feb08aca941b5cd98c9b1db14b2fd128be8c697", - "sha256:370445fd795706fd291ab00c9df38a0caed0f17a6fb46b0f607668ecb16ce763", - "sha256:40d030e2329ce5286d6b231b8726959ebbe0404c92f0a578c0e2482182e38282", - "sha256:41d0f1fa4c6830176eef5b276af04c89320ea616655d01327d5ce65e50575c94", - "sha256:4a4d8d417868d68b979076a9be6a38c676eca060785abaa6709c7b31593c35d1", - "sha256:4b3a93bb9bc662fc1f99c5c3ea8e623d8b23ad22f861eb6fce9377ac07ad6072", - "sha256:5bc0667c1eb8f83a3752b71b9c4ba55ef7c7058ae57022dd9b29065186a113d9", - "sha256:721bc4ba4525f53f6a611ec0967bdcee61b31df5a56801281027a3a6d1c2daf5", - "sha256:763d65baa3b952479c4e972669f679fe490eee058d5aa85da483ebae2009d231", - "sha256:7bdb4c06b063f6fd55e472e201317a3bb6cdeeee5d5a38512ea5c01e1acbdd93", - "sha256:8831a2cedcd0f0927f788c5bdf6567d9dc9cc235646a434986a852af1cb54b4b", - "sha256:8f73b665f03f70986e4b5c4708b9e553b931d34b815032452327fa7c2a9099bf", - "sha256:91a789b4aa0097b78c93e3dc4b40040ba55bef518f84a40d4442f713b4094acb", - "sha256:92460ce908546ab69770b2e576e4f99fbb4ce6ab4b245345a3869a0a0410488f", - "sha256:99e77daab5d13a48a4054803d052ff40780278240a902b880dd37a51ba01a307", - "sha256:9c361022618feec80a1d5e874970d8dae234815097638ed104a40593b602eb5c", - "sha256:a234a20ae07e8469da311e182e70ef6b199d0fbeb6c6cc2901204dd87fb867e8", - "sha256:a7b301ff08055d73223058b5c46c55638917f04d21577c95e00e0c4d79201a6b", - "sha256:be2a7ad8fd8f7442b24323d24ba0b56c51219513cfa45b9ada3b87b76c374d4b", - "sha256:bf9a6bc4a0221538b1a7de3ed7bca4c93c02346853f44e1cd764be0023cd3640", - "sha256:c3ca1fbba4ae962521e5eb66d72998b51f0f4d0f608d3c0347a48e1af262efa7", - "sha256:d000f258cf42fec2b1bbf2863c61d7b8918d31ffee905da62dede869254d3b8a", - "sha256:d5859983f26d8cd7bb5c287ef452e8aacc86501487634573d260968f753e1d71", - "sha256:d5e51e2901ec2366b79f16c2299a03e74ba4531ddcfacc1416639c557aef0ad8", - "sha256:da538167284de58a52109a9b89b8f6a53ff8437dd6dc26d33b57bf6699153122", - "sha256:debc87a9516b237d0466a711b18b6ebeb17ba9f391eb7f91c649c5c4ec5006c7", - "sha256:df5828871e6648db72d1c19b4bd24819b80a755c4541d3409f0f7acd0f335c80", - "sha256:ecdf1a604009bd35c674b9225a8fa609e0282d9b896c03dd441a91e5f53b534e", - "sha256:efa08d63ef03d079dcae1dfe334f6c8847ba8b645d08df286358b1f5293d24ab", - "sha256:f01da5790e95815eb5a8a138508c01c758e5f5bc0ce4286c4f7028b8dd7ac3d0", - "sha256:f34019dced51047d6f70cb9383b2ae2853b7fc4dce65129a5acd49f4f9256646", - "sha256:f6d3d39611ac2e4f62c3128a9eed45f19a6608670c5a2f4f07f24e8de3441d38" - ], - "markers": "python_version < '3.11' and platform_python_implementation == 'CPython'", - "version": "==0.2.7" - }, - "sangaboard": { - "hashes": [ - "sha256:4b678ad7a814aa4f3ccccfc8790bb941222e6ee2c93586baf488220165dae304", - "sha256:4bfc58f1b7493c858b6b314d8b57a26b019ea2724737050000fa559251d3e050", - "sha256:4e945e876a094be9fb62f5a7282a06be4a3eb386d2032c615bff4f42dbb74bc8" - ], - "version": "==0.3.3" - }, - "scipy": { - "hashes": [ - "sha256:01b38dec7e9f897d4db04f8de4e20f0f5be3feac98468188a0f47a991b796055", - "sha256:10dbcc7de03b8d635a1031cb18fd3eaa997969b64fdf78f99f19ac163a825445", - "sha256:19aeac1ad3e57338723f4657ac8520f41714804568f2e30bd547d684d72c392e", - "sha256:1b21c6e0dc97b1762590b70dee0daddb291271be0580384d39f02c480b78290a", - "sha256:1caade0ede6967cc675e235c41451f9fb89ae34319ddf4740194094ab736b88d", - "sha256:208fe23655561a95ede6bb3bb15342882f6a8f3867bf5564cafbdbcc401b1a5d", - "sha256:23995dfcf269ec3735e5a8c80cfceaf384369a47699df111a6246b83a55da582", - "sha256:2a799714bf1f791fb2650d73222b248d18d53fd40d6af2df2c898db048189606", - "sha256:3274ce145b5dc416c49c0cf8b6119f787f0965cd35e22058fe1932c09fe15d77", - "sha256:33d1677d46111cfa1c84b87472a0274dde9ef4a7ef2e1f155f012f5f1e995d8f", - "sha256:44d452850f77e65e25b1eb1ac01e25770323a782bfe3a1a3e43847ad4266d93d", - "sha256:9e3302149a369697c6aaea18b430b216e3c88f9a61b62869f6104881e5f9ef85", - "sha256:a75b014d3294fce26852a9d04ea27b5671d86736beb34acdfc05859246260707", - "sha256:ad7269254de06743fb4768f658753de47d8b54e4672c5ebe8612a007a088bd48", - "sha256:b30280fbc1fd8082ac822994a98632111810311a9ece71a0e48f739df3c555a2", - "sha256:b79104878003487e2b4639a20b9092b02e1bad07fc4cf924b495cf413748a777", - "sha256:d1057272927f435496b4ba1fc11bccad4b98c0ab47cc62baf9d2fbdc206c5958", - "sha256:d449d40e830366b4c612692ad19fbebb722b6b847f78a7b701b1e0d6cda3cc13", - "sha256:d647757373985207af3343301d89fe738d5a294435a4f2aafb04c13b4388c896", - "sha256:f68eb46b86b2c246af99fcaa6f6e37c7a7a413e1084a794990b877f2ff71f7b6", - "sha256:fdf606341cd798530b05705c87779606fcdfaf768a8129c348ea94441da15b04" - ], - "markers": "python_version < '3.10' and python_version >= '3.7'", - "version": "==1.6.3" - }, - "semantic-version": { - "hashes": [ - "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", - "sha256:dc5baeac289ab6817f916c9cb73cef9448e973aa6f7b6c42c27ef39d0ba03ac9", - "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177" - ], - "markers": "python_version >= '2.7'", - "version": "==2.10.0" - }, - "semver": { - "hashes": [ - "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4", - "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.13.0" - }, - "setuptools": { - "hashes": [ - "sha256:257de92a9d50a60b8e22abfcbb771571fde0dbf3ec234463212027a4eeecbe9a", - "sha256:ae060e31efa47ebd031bdf444908164a9c54dbfe734c97afd5d8e52afbad723a", - "sha256:e728ca814a823bf7bf60162daf9db95b93d532948c4c0bea762ce62f60189078" - ], - "markers": "python_version >= '3.7'", - "version": "==67.6.1" - }, - "six": { - "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" - }, - "tomli": { - "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" - ], - "markers": "python_version < '3.11'", - "version": "==2.0.1" - }, - "typing-extensions": { - "hashes": [ - "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02", - "sha256:4e110e067718fa35420fa8739d968edfacd2bb6b4988923875ba9406ad40d7c2", - "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6" - ], - "markers": "python_version >= '3.7'", - "version": "==4.3.0" - }, - "urllib3": { - "hashes": [ - "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305", - "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42", - "sha256:c3909b2a8d21bf543d661269ff27ff0d3feefc600d8a2e09cea4c0f46e45301a" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==1.26.15" - }, - "webargs": { - "hashes": [ - "sha256:30af59b4cc9d0f8c766fe0eb2789cf3be95621c0590a899f8262074367d7a140", - "sha256:6746327faf549533bf30be7333f99541b6c60a85f23acf1bb0bea68498e3bcd7", - "sha256:99d68940c452e07726485a15fef43f12f8ae6c0c5b391bcba76065d4527fb85d" - ], - "markers": "python_full_version >= '3.7.2'", - "version": "==8.2.0" - }, - "werkzeug": { - "hashes": [ - "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", - "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.0.1" - }, - "zeroconf": { - "hashes": [ - "sha256:055355de58edb58708471f76e6ad486f4796ec6d2c7cb97c16b295a00856e789", - "sha256:71ebebbc35d4703d72a3635127626d511d5ee7da4b3946430eac2cdadca81b12", - "sha256:95b4fd11e0498ec8e38d0a0ff50e3bd7250be2b02d131ef8501b12cd3a499d66" - ], - "version": "==0.33.4" - }, - "zipp": { - "hashes": [ - "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", - "sha256:3ee3c5a49d3e276dcf8e83f5f11e93dab67f20b843e7ab2dee428c857d11b3b3", - "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556" - ], - "markers": "python_version >= '3.7'", - "version": "==3.15.0" - } - }, - "develop": { - "alabaster": { - "hashes": [ - "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3", - "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2", - "sha256:be75d1de040fc86cb9b6fea8ade4016d5f21714678f5fb209bb71d4752eaa211" - ], - "markers": "python_version >= '3.6'", - "version": "==0.7.13" - }, - "apispec": { - "extras": [ - "validation", - "yaml" - ], - "hashes": [ - "sha256:6613dbc39f41cd58942a697f11c8762ba18422bd173fe0bdfc1535b83d3f84f0", - "sha256:79029486d36a0d7f3c659dbf6ae50a91fbed0c22dcd5376f592e076c130bc7f9" - ], - "markers": "python_version >= '3.6'", - "version": "==4.7.1" - }, - "apispec-webframeworks": { - "hashes": [ - "sha256:0db35b267914b3f8c562aca0261957dbcb4176f255eacc22520277010818dcf3", - "sha256:482c563abbcc2a261439476cb3f1a7c7284cc997c322c574d48c111643e9c04e" - ], - "markers": "python_version >= '3.6'", - "version": "==0.5.2" - }, - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "version": "==1.4.4" - }, - "astroid": { - "hashes": [ - "sha256:3cbd5e615876f2bd3fa351c2333766136ec2251b3312535e59b89bdc99360225", - "sha256:86b0a340a512c65abf4368b80252754cda17c02cdbbd3f587dddf98112233e7b", - "sha256:bb24615c77f4837c707669d16907331374ae8a964650a66999da3f5ca68dc946" - ], - "markers": "python_full_version >= '3.6.2'", - "version": "==2.11.7" - }, - "attrs": { - "hashes": [ - "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836", - "sha256:52dd1548aaa4d65af91f99015a21718f8bbd3a45577e565cdd28b8a891da97a5", - "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99" - ], - "markers": "python_version >= '3.6'", - "version": "==22.2.0" - }, - "babel": { - "hashes": [ - "sha256:7d4b39b8f00d62b71bf643d554db2e99e2845fbdb69da9577dd7cb512e1da053", - "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610", - "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455" - ], - "markers": "python_version >= '3.7'", - "version": "==2.12.1" - }, - "black": { - "hashes": [ - "sha256:817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739", - "sha256:e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5" - ], - "markers": "python_version >= '3.6'", - "version": "==18.9b0" - }, - "camera-stage-mapping": { - "hashes": [ - "sha256:02a409bd2cb193b20d9eb015ca31400bd3e1721ec5d702e1b96de54608ff5a8f", - "sha256:d39c3afebb59ec865fb1247dfd951d7090c159ab0cf3e4291e0869d3be33776c" - ], - "markers": "python_version >= '3.6' and python_version < '4.0'", - "version": "==0.1.4" - }, - "certifi": { - "hashes": [ - "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3", - "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18", - "sha256:cb52c7519140a4c2b84314220b3f08768882bb58176f3acc039ccac101d51870" - ], - "markers": "python_version >= '3.6'", - "version": "==2022.12.7" - }, - "chardet": { - "hashes": [ - "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", - "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==4.0.0" - }, - "charset-normalizer": { - "hashes": [ - "sha256:1799ad8111efa28db62e1b9773eba32dcc9fd0cd9d7c631bd4858eb15a30f858" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==3.0.1" - }, - "click": { - "hashes": [ - "sha256:a3747c864f8e400a3664f5f4fd6dae11b4605bf6b727dae7b6f22ba9bd0a194a", - "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", - "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==7.1.2" - }, - "colorzero": { - "hashes": [ - "sha256:0e60d743a6b8071498a56465f7719c96a5e92928f858bab1be2a0d606c9aa0f8", - "sha256:e7d5a5c26cd0dc37b164ebefc609f388de24f8593b659191e12d85f8f9d5eb58" - ], - "version": "==2.0" - }, - "coverage": { - "hashes": [ - "sha256:006ed5582e9cbc8115d2e22d6d2144a0725db542f654d9d4fda86793832f873d", - "sha256:046936ab032a2810dcaafd39cc4ef6dd295df1a7cbead08fe996d4765fca9fe4", - "sha256:0484d9dd1e6f481b24070c87561c8d7151bdd8b044c93ac99faafd01f695c78e", - "sha256:0ce383d5f56d0729d2dd40e53fe3afeb8f2237244b0975e1427bfb2cf0d32bab", - "sha256:186e0fc9cf497365036d51d4d2ab76113fb74f729bd25da0975daab2e107fd90", - "sha256:2199988e0bc8325d941b209f4fd1c6fa007024b1442c5576f1a32ca2e48941e6", - "sha256:299bc75cb2a41e6741b5e470b8c9fb78d931edbd0cd009c58e5c84de57c06731", - "sha256:3668291b50b69a0c1ef9f462c7df2c235da3c4073f49543b01e7eb1dee7dd540", - "sha256:36dd42da34fe94ed98c39887b86db9d06777b1c8f860520e21126a75507024f2", - "sha256:38004671848b5745bb05d4d621526fca30cee164db42a1f185615f39dc997292", - "sha256:387fb46cb8e53ba7304d80aadca5dca84a2fbf6fe3faf6951d8cf2d46485d1e5", - "sha256:3eb55b7b26389dd4f8ae911ba9bc8c027411163839dea4c8b8be54c4ee9ae10b", - "sha256:420f94a35e3e00a2b43ad5740f935358e24478354ce41c99407cddd283be00d2", - "sha256:4ac0f522c3b6109c4b764ffec71bf04ebc0523e926ca7cbe6c5ac88f84faced0", - "sha256:4c752d5264053a7cf2fe81c9e14f8a4fb261370a7bb344c2a011836a96fb3f57", - "sha256:4eef796938e3759c6b6814dbd615b1d3597a376b3174d05aed9f75f6fc6a1650", - "sha256:4f01911c010122f49a3e9bdc730eccc66f9b72bd410a3a9d3cb8448bb50d65d3", - "sha256:4f68ee32d7c4164f1e2c8797535a6d0a3733355f5861e0f667e37df2d4b07140", - "sha256:4fa54fb483decc45f94011898727802309a109d89446a3c76387d016057d2c84", - "sha256:507e4720791977934bba016101579b8c500fb21c5fa3cd4cf256477331ddd988", - "sha256:53d0fd4c17175aded9c633e319360d41a1f3c6e352ba94edcb0fa5167e2bad67", - "sha256:55272f33da9a5d7cccd3774aeca7a01e500a614eaea2a77091e9be000ecd401d", - "sha256:5764e1f7471cb8f64b8cda0554f3d4c4085ae4b417bfeab236799863703e5de2", - "sha256:57b77b9099f172804e695a40ebaa374f79e4fb8b92f3e167f66facbf92e8e7f5", - "sha256:5afdad4cc4cc199fdf3e18088812edcf8f4c5a3c8e6cb69127513ad4cb7471a9", - "sha256:5cc0783844c84af2522e3a99b9b761a979a3ef10fb87fc4048d1ee174e18a7d8", - "sha256:5e1df45c23d4230e3d56d04414f9057eba501f78db60d4eeecfcb940501b08fd", - "sha256:6146910231ece63facfc5984234ad1b06a36cecc9fd0c028e59ac7c9b18c38c6", - "sha256:797aad79e7b6182cb49c08cc5d2f7aa7b2128133b0926060d0a8889ac43843be", - "sha256:7c20b731211261dc9739bbe080c579a1835b0c2d9b274e5fcd903c3a7821cf88", - "sha256:817295f06eacdc8623dc4df7d8b49cea65925030d4e1e2a7c7218380c0072c25", - "sha256:81f63e0fb74effd5be736cfe07d710307cc0a3ccb8f4741f7f053c057615a137", - "sha256:872d6ce1f5be73f05bea4df498c140b9e7ee5418bfa2cc8204e7f9b817caa968", - "sha256:8c99cb7c26a3039a8a4ee3ca1efdde471e61b4837108847fb7d5be7789ed8fd9", - "sha256:8dbe2647bf58d2c5a6c5bcc685f23b5f371909a5624e9f5cd51436d6a9f6c6ef", - "sha256:8efb48fa743d1c1a65ee8787b5b552681610f06c40a40b7ef94a5b517d885c54", - "sha256:92ebc1619650409da324d001b3a36f14f63644c7f0a588e331f3b0f67491f512", - "sha256:9d22e94e6dc86de981b1b684b342bec5e331401599ce652900ec59db52940005", - "sha256:ba279aae162b20444881fc3ed4e4f934c1cf8620f3dab3b531480cf602c76b7f", - "sha256:bc4803779f0e4b06a2361f666e76f5c2e3715e8e379889d02251ec911befd149", - "sha256:bfe7085783cda55e53510482fa7b5efc761fad1abe4d653b32710eb548ebdd2d", - "sha256:c448b5c9e3df5448a362208b8d4b9ed85305528313fca1b479f14f9fe0d873b8", - "sha256:c90e73bdecb7b0d1cea65a08cb41e9d672ac6d7995603d6465ed4914b98b9ad7", - "sha256:d2b96123a453a2d7f3995ddb9f28d01fd112319a7a4d5ca99796a7ff43f02af5", - "sha256:d52f0a114b6a58305b11a5cdecd42b2e7f1ec77eb20e2b33969d702feafdd016", - "sha256:d530191aa9c66ab4f190be8ac8cc7cfd8f4f3217da379606f3dd4e3d83feba69", - "sha256:d683d230b5774816e7d784d7ed8444f2a40e7a450e5720d58af593cb0b94a212", - "sha256:db45eec1dfccdadb179b0f9ca616872c6f700d23945ecc8f21bb105d74b1c5fc", - "sha256:db8c2c5ace167fd25ab5dd732714c51d4633f58bac21fb0ff63b0349f62755a8", - "sha256:e2926b8abedf750c2ecf5035c07515770944acf02e1c46ab08f6348d24c5f94d", - "sha256:e627dee428a176ffb13697a2c4318d3f60b2ccdde3acdc9b3f304206ec130ccd", - "sha256:efe1c0adad110bf0ad7fb59f833880e489a61e39d699d37249bdf42f80590169", - "sha256:f47e4fd792793c19b66a09a7c79390490d7707dfc9c8c1a42cbd55110c73468b" - ], - "markers": "python_version >= '3.7'", - "version": "==7.2.2" - }, - "deepmerge": { - "hashes": [ - "sha256:331d8c8421dee318d7cae881c752f82a72573ab3740ef1b1f0d258f39ee1b41c", - "sha256:4c27a0db5de285e1a7ceac7dbc1531deaa556b627dea4900c8244581ecdfea2d", - "sha256:59e6ef80b77dc52af3882a1ea78da22bcfc91ae9cdabc0c80729049fe295ff8b" - ], - "version": "==1.1.0" - }, - "dill": { - "hashes": [ - "sha256:918795da4042490399dd7b3f5bdee9d7ae11cfdb3a9fc5e460b82142543fd3ba", - "sha256:a07ffd2351b8c678dfc4a856a3005f8067aea51d6ba6c700796a4d9e280f39f0", - "sha256:e5db55f3687856d8fbdab002ed78544e1c4559a130302693d839dfe8f93f2373" - ], - "markers": "python_version >= '3.7'", - "version": "==0.3.6" - }, - "docutils": { - "hashes": [ - "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", - "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==0.16" - }, - "expiringdict": { - "hashes": [ - "sha256:09a5d20bc361163e6432a874edd3179676e935eb81b925eccef48d409a8a45e8", - "sha256:300fb92a7e98f15b05cf9a856c1415b3bc4f2e132be07daa326da6414c23ee09", - "sha256:5ced493f465d46cb39014399f0be41329056629f019226c02accfb4f990b3dea" - ], - "version": "==1.2.2" - }, - "flask": { - "hashes": [ - "sha256:0fbeb6180d383a9186d0d6ed954e0042ad9f18e0e8de088b2b419d526927d196", - "sha256:c34f04500f2cbbea882b1acb02002ad6fe6b7ffa64a6164577995657f50aed22" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.1.4" - }, - "flask-cors": { - "hashes": [ - "sha256:74efc975af1194fc7891ff5cd85b0f7478be4f7f59fe158102e91abb72bb4438", - "sha256:b60839393f3b84a0f3746f6cdca56c1ad7426aa738b70d6c61375857823181de" - ], - "version": "==3.0.10" - }, - "freezegun": { - "hashes": [ - "sha256:6684d606f2bba9f2032404a3be04fd691a9ee00ca241c3fa638da0d160b59856", - "sha256:cd22d1ba06941384410cd967d8a99d5ae2442f57dfafeff2fda5de8dc5c05446", - "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f" - ], - "markers": "python_version >= '3.6'", - "version": "==1.2.2" - }, - "future": { - "hashes": [ - "sha256:06cf12592a81bdba6c2f05be3be47da76c8beedce11c5539e5edea9de9b9eac6", - "sha256:34a17436ed1e96697a86f9de3d15a3b0be01d8bc8de9c1dffd59fb8234ed5307" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.18.3" - }, - "idna": { - "hashes": [ - "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", - "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2", - "sha256:bcac1132a4eadea8321490704e2349b752e29c6ec2f1c27d372010e76ebd3fa1" - ], - "markers": "python_version >= '3.5'", - "version": "==3.4" - }, - "ifaddr": { - "hashes": [ - "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", - "sha256:0998123149175f29de1271c4088edff8ee9274873debac057a1b40149568be87", - "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4" - ], - "version": "==0.2.0" - }, - "imagesize": { - "hashes": [ - "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", - "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", - "sha256:a3eb72560a02901465a2fcdc9529752ed750891e4336adadba4a1924db7252b2" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.1" - }, - "importlib-metadata": { - "hashes": [ - "sha256:43ce9281e097583d758c2c708c4376371261a02c34682491a8e98352365aad20", - "sha256:ab197eeef247b9bfe3d3d91334921e09626181362991c0479b1340aabc00532f", - "sha256:ff80f3b5394912eb1b108fcfd444dc78b7f1f3e16b16188054bd01cb9cb86f09" - ], - "markers": "python_version < '3.8'", - "version": "==6.1.0" - }, - "importlib-resources": { - "hashes": [ - "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6", - "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a", - "sha256:f36205b58b84e2f35777817eb7ac6506a21982ad9954a72ab0833abf36c44183" - ], - "markers": "python_version < '3.9'", - "version": "==5.12.0" - }, - "iniconfig": { - "hashes": [ - "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", - "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" - ], - "markers": "python_version >= '3.7'", - "version": "==2.0.0" - }, - "isort": { - "hashes": [ - "sha256:01ad8ce890fe8743f0011655d1e538e8495ba0a0fd2f0801a99f0126cc6160f8", - "sha256:6be1f76a507cb2ecf16c7cf14a37e41609ca082330be4e3436a18ef74add55db", - "sha256:ba1d72fb2595a01c7895a5128f9585a5cc4b6d395f1c8d514989b9a7eb2a8746" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==5.11.5" - }, - "itsdangerous": { - "hashes": [ - "sha256:321b033d07f2a4136d3ec762eac9f16a10ccd60f53c0c91af90217ace7ba1f19", - "sha256:b12271b2047cb23eeb98c8b5622e2e5c5e9abd9784a153e9d8ef9cb4dd09d749" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.1.0" - }, - "jinja2": { - "hashes": [ - "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419", - "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.11.3" - }, - "jsonschema": { - "hashes": [ - "sha256:0f864437ab8b6076ba6707453ef8f98a6a0d512a80e93f8abdb676f737ecb60d", - "sha256:a870ad254da1a8ca84b6a2905cac29d265f805acc57af304784962a2aa6508f6" - ], - "markers": "python_version >= '3.7'", - "version": "==4.17.3" - }, - "jsonschema-spec": { - "hashes": [ - "sha256:34471d8b60e1f06d174236c4d3cf9590fbf3cff1cc733b28d15cd83672bcd062", - "sha256:824c743197bbe2104fcc6dce114a4082bf7f7efdebf16683510cb0ec6d8d53d0", - "sha256:8a9534ec1165fd4e5727c758e848a1e0129e704dc87010bf739c592b0b1f4d55" - ], - "markers": "python_full_version >= '3.7.0' and python_full_version < '4.0.0'", - "version": "==0.1.4" - }, - "labthings": { - "hashes": [ - "sha256:1f165e8a78f0ef28576e64e6990c7cf74026c7db6376c5fabc9fc22bf6a8b372", - "sha256:83e5a8849d556b8e6c3138b1284148095f4e3ed983dad15096262c7b31c5947e", - "sha256:e01842fc6e38594c3ea787911d30ebbc70bc67ed6f58544b38ab4572d46eb60e" - ], - "markers": "python_version >= '3.6' and python_version < '4.0'", - "version": "==1.3.2" - }, - "lazy-object-proxy": { - "hashes": [ - "sha256:09763491ce220c0299688940f8dc2c5d05fd1f45af1e42e636b2e8b2303e4382", - "sha256:0a891e4e41b54fd5b8313b96399f8b0e173bbbfc03c7631f01efbe29bb0bcf82", - "sha256:0e988a556683ec90e9f3284c650746cfadc122effd9073d3db065e145403f7c7", - "sha256:189bbd5d41ae7a498397287c408617fe5c48633e7755287b21d741f7db2706a9", - "sha256:18b78ec83edbbeb69efdc0e9c1cb41a3b1b1ed11ddd8ded602464c3fc6020494", - "sha256:1aa3de4088c89a1b69f8ec0dcc169aa725b0ff017899ac568fe44ddc1396df46", - "sha256:212774e4dfa851e74d393a2370871e174d7ff0ebc980907723bb67d25c8a7c30", - "sha256:2d0daa332786cf3bb49e10dc6a17a52f6a8f9601b4cf5c295a4f85854d61de63", - "sha256:5f83ac4d83ef0ab017683d715ed356e30dd48a93746309c8f3517e1287523ef4", - "sha256:659fb5809fa4629b8a1ac5106f669cfc7bef26fbb389dda53b3e010d1ac4ebae", - "sha256:660c94ea760b3ce47d1855a30984c78327500493d396eac4dfd8bd82041b22be", - "sha256:66a3de4a3ec06cd8af3f61b8e1ec67614fbb7c995d02fa224813cb7afefee701", - "sha256:721532711daa7db0d8b779b0bb0318fa87af1c10d7fe5e52ef30f8eff254d0cd", - "sha256:7322c3d6f1766d4ef1e51a465f47955f1e8123caee67dd641e67d539a534d006", - "sha256:79a31b086e7e68b24b99b23d57723ef7e2c6d81ed21007b6281ebcd1688acb0a", - "sha256:81fc4d08b062b535d95c9ea70dbe8a335c45c04029878e62d744bdced5141586", - "sha256:8fa02eaab317b1e9e03f69aab1f91e120e7899b392c4fc19807a8278a07a97e8", - "sha256:9090d8e53235aa280fc9239a86ae3ea8ac58eff66a705fa6aa2ec4968b95c821", - "sha256:946d27deaff6cf8452ed0dba83ba38839a87f4f7a9732e8f9fd4107b21e6ff07", - "sha256:9990d8e71b9f6488e91ad25f322898c136b008d87bf852ff65391b004da5e17b", - "sha256:9cd077f3d04a58e83d04b20e334f678c2b0ff9879b9375ed107d5d07ff160171", - "sha256:9e7551208b2aded9c1447453ee366f1c4070602b3d932ace044715d89666899b", - "sha256:9f5fa4a61ce2438267163891961cfd5e32ec97a2c444e5b842d574251ade27d2", - "sha256:b40387277b0ed2d0602b8293b94d7257e17d1479e257b4de114ea11a8cb7f2d7", - "sha256:bfb38f9ffb53b942f2b5954e0f610f1e721ccebe9cce9025a38c8ccf4a5183a4", - "sha256:cbf9b082426036e19c6924a9ce90c740a9861e2bdc27a4834fd0a910742ac1e8", - "sha256:cd6184026be98242806204c8dc2398d5bbd19ea105ec93826af5f6176b7913e3", - "sha256:d9e25ef10a39e8afe59a5c348a4dbf29b4868ab76269f81ce1674494e2565a6e", - "sha256:db1c1722726f47e10e0b5fdbf15ac3b8adb58c091d12b3ab713965795036985f", - "sha256:e7c21c95cae3c05c14aafffe2865bbd5e377cfc1348c4f7751d9dc9a48ca4bda", - "sha256:e8c6cfb338b133fbdbc5cfaa10fe3c6aeea827db80c978dbd13bc9dd8526b7d4", - "sha256:ea806fd4c37bf7e7ad82537b0757999264d5f70c45468447bb2b91afdbe73a6e", - "sha256:edd20c5a55acb67c7ed471fa2b5fb66cb17f61430b7a6b9c3b4a1e40293b1671", - "sha256:f0117049dd1d5635bbff65444496c90e0baa48ea405125c088e93d9cf4525b11", - "sha256:f0705c376533ed2a9e5e97aacdbfe04cecd71e0aa84c7c0595d02ef93b6e4455", - "sha256:f12ad7126ae0c98d601a7ee504c1122bcef553d1d5e0c3bfa77b16b3968d2734", - "sha256:f2457189d8257dd41ae9b434ba33298aec198e30adf2dcdaaa3a28b9994f6adb", - "sha256:f699ac1c768270c9e384e4cbd268d6e67aebcfae6cd623b4d7c3bfde5a35db59" - ], - "markers": "python_version >= '3.7'", - "version": "==1.9.0" - }, - "lxml": { - "hashes": [ - "sha256:01d36c05f4afb8f7c20fd9ed5badca32a2029b93b1750f571ccc0b142531caf7", - "sha256:04876580c050a8c5341d706dd464ff04fd597095cc8c023252566a8826505726", - "sha256:05ca3f6abf5cf78fe053da9b1166e062ade3fa5d4f92b4ed688127ea7d7b1d03", - "sha256:090c6543d3696cbe15b4ac6e175e576bcc3f1ccfbba970061b7300b0c15a2140", - "sha256:0dc313ef231edf866912e9d8f5a042ddab56c752619e92dfd3a2c277e6a7299a", - "sha256:0f2b1e0d79180f344ff9f321327b005ca043a50ece8713de61d1cb383fb8ac05", - "sha256:13598ecfbd2e86ea7ae45ec28a2a54fb87ee9b9fdb0f6d343297d8e548392c03", - "sha256:16efd54337136e8cd72fb9485c368d91d77a47ee2d42b057564aae201257d419", - "sha256:1ab8f1f932e8f82355e75dda5413a57612c6ea448069d4fb2e217e9a4bed13d4", - "sha256:223f4232855ade399bd409331e6ca70fb5578efef22cf4069a6090acc0f53c0e", - "sha256:2455cfaeb7ac70338b3257f41e21f0724f4b5b0c0e7702da67ee6c3640835b67", - "sha256:2899456259589aa38bfb018c364d6ae7b53c5c22d8e27d0ec7609c2a1ff78b50", - "sha256:2a29ba94d065945944016b6b74e538bdb1751a1db6ffb80c9d3c2e40d6fa9894", - "sha256:2a87fa548561d2f4643c99cd13131acb607ddabb70682dcf1dff5f71f781a4bf", - "sha256:2e430cd2824f05f2d4f687701144556646bae8f249fd60aa1e4c768ba7018947", - "sha256:36c3c175d34652a35475a73762b545f4527aec044910a651d2bf50de9c3352b1", - "sha256:3818b8e2c4b5148567e1b09ce739006acfaa44ce3156f8cbbc11062994b8e8dd", - "sha256:3ab9fa9d6dc2a7f29d7affdf3edebf6ece6fb28a6d80b14c3b2fb9d39b9322c3", - "sha256:3efea981d956a6f7173b4659849f55081867cf897e719f57383698af6f618a92", - "sha256:4c8f293f14abc8fd3e8e01c5bd86e6ed0b6ef71936ded5bf10fe7a5efefbaca3", - "sha256:5344a43228767f53a9df6e5b253f8cdca7dfc7b7aeae52551958192f56d98457", - "sha256:58bfa3aa19ca4c0f28c5dde0ff56c520fbac6f0daf4fac66ed4c8d2fb7f22e74", - "sha256:5b4545b8a40478183ac06c073e81a5ce4cf01bf1734962577cf2bb569a5b3bbf", - "sha256:5f50a1c177e2fa3ee0667a5ab79fdc6b23086bc8b589d90b93b4bd17eb0e64d1", - "sha256:63da2ccc0857c311d764e7d3d90f429c252e83b52d1f8f1d1fe55be26827d1f4", - "sha256:6749649eecd6a9871cae297bffa4ee76f90b4504a2a2ab528d9ebe912b101975", - "sha256:6804daeb7ef69e7b36f76caddb85cccd63d0c56dedb47555d2fc969e2af6a1a5", - "sha256:689bb688a1db722485e4610a503e3e9210dcc20c520b45ac8f7533c837be76fe", - "sha256:699a9af7dffaf67deeae27b2112aa06b41c370d5e7633e0ee0aea2e0b6c211f7", - "sha256:6b418afe5df18233fc6b6093deb82a32895b6bb0b1155c2cdb05203f583053f1", - "sha256:7311648ee07bbc399f64f3605de71b0f8def24bb90eb68796cfabd2ed4f2c9f3", - "sha256:76cf573e5a365e790396a5cc2b909812633409306c6531a6877c59061e42c4f2", - "sha256:7b515674acfdcadb0eb5d00d8a709868173acece5cb0be3dd165950cbfdf5409", - "sha256:7b770ed79542ed52c519119473898198761d78beb24b107acf3ad65deae61f1f", - "sha256:7d2278d59425777cfcb19735018d897ca8303abe67cc735f9f97177ceff8027f", - "sha256:7e91ee82f4199af8c43d8158024cbdff3d931df350252288f0d4ce656df7f3b5", - "sha256:821b7f59b99551c69c85a6039c65b75f5683bdc63270fec660f75da67469ca24", - "sha256:822068f85e12a6e292803e112ab876bc03ed1f03dddb80154c395f891ca6b31e", - "sha256:8340225bd5e7a701c0fa98284c849c9b9fc9238abf53a0ebd90900f25d39a4e4", - "sha256:85cabf64adec449132e55616e7ca3e1000ab449d1d0f9d7f83146ed5bdcb6d8a", - "sha256:880bbbcbe2fca64e2f4d8e04db47bcdf504936fa2b33933efd945e1b429bea8c", - "sha256:8d0b4612b66ff5d62d03bcaa043bb018f74dfea51184e53f067e6fdcba4bd8de", - "sha256:8e20cb5a47247e383cf4ff523205060991021233ebd6f924bca927fcf25cf86f", - "sha256:925073b2fe14ab9b87e73f9a5fde6ce6392da430f3004d8b72cc86f746f5163b", - "sha256:998c7c41910666d2976928c38ea96a70d1aa43be6fe502f21a651e17483a43c5", - "sha256:9b22c5c66f67ae00c0199f6055705bc3eb3fcb08d03d2ec4059a2b1b25ed48d7", - "sha256:9e3e6e15b0ab6e5c47d1170be3978b50b3d2bf61dd01fc1922f803c94bd52d98", - "sha256:9f102706d0ca011de571de32c3247c6476b55bb6bc65a20f682f000b07a4852a", - "sha256:a08cff61517ee26cb56f1e949cca38caabe9ea9fbb4b1e10a805dc39844b7d5c", - "sha256:a0a336d6d3e8b234a3aae3c674873d8f0e720b76bc1d9416866c41cd9500ffb9", - "sha256:a35f8b7fa99f90dd2f5dc5a9fa12332642f087a7641289ca6c40d6e1a2637d8e", - "sha256:a38486985ca49cfa574a507e7a2215c0c780fd1778bb6290c21193b7211702ab", - "sha256:a5da296eb617d18e497bcf0a5c528f5d3b18dadb3619fbdadf4ed2356ef8d941", - "sha256:a6e441a86553c310258aca15d1c05903aaf4965b23f3bc2d55f200804e005ee5", - "sha256:a82d05da00a58b8e4c0008edbc8a4b6ec5a4bc1e2ee0fb6ed157cf634ed7fa45", - "sha256:ab323679b8b3030000f2be63e22cdeea5b47ee0abd2d6a1dc0c8103ddaa56cd7", - "sha256:b1f42b6921d0e81b1bcb5e395bc091a70f41c4d4e55ba99c6da2b31626c44892", - "sha256:b23e19989c355ca854276178a0463951a653309fb8e57ce674497f2d9f208746", - "sha256:b264171e3143d842ded311b7dccd46ff9ef34247129ff5bf5066123c55c2431c", - "sha256:b26a29f0b7fc6f0897f043ca366142d2b609dc60756ee6e4e90b5f762c6adc53", - "sha256:b64d891da92e232c36976c80ed7ebb383e3f148489796d8d31a5b6a677825efe", - "sha256:b9cc34af337a97d470040f99ba4282f6e6bac88407d021688a5d585e44a23184", - "sha256:bc718cd47b765e790eecb74d044cc8d37d58562f6c314ee9484df26276d36a38", - "sha256:be7292c55101e22f2a3d4d8913944cbea71eea90792bf914add27454a13905df", - "sha256:c83203addf554215463b59f6399835201999b5e48019dc17f182ed5ad87205c9", - "sha256:c9ec3eaf616d67db0764b3bb983962b4f385a1f08304fd30c7283954e6a7869b", - "sha256:ca34efc80a29351897e18888c71c6aca4a359247c87e0b1c7ada14f0ab0c0fb2", - "sha256:ca989b91cf3a3ba28930a9fc1e9aeafc2a395448641df1f387a2d394638943b0", - "sha256:d02a5399126a53492415d4906ab0ad0375a5456cc05c3fc0fc4ca11771745cda", - "sha256:d17bc7c2ccf49c478c5bdd447594e82692c74222698cfc9b5daae7ae7e90743b", - "sha256:d5bf6545cd27aaa8a13033ce56354ed9e25ab0e4ac3b5392b763d8d04b08e0c5", - "sha256:d6b430a9938a5a5d85fc107d852262ddcd48602c120e3dbb02137c83d212b380", - "sha256:da248f93f0418a9e9d94b0080d7ebc407a9a5e6d0b57bb30db9b5cc28de1ad33", - "sha256:da4dd7c9c50c059aba52b3524f84d7de956f7fef88f0bafcf4ad7dde94a064e8", - "sha256:df0623dcf9668ad0445e0558a21211d4e9a149ea8f5666917c8eeec515f0a6d1", - "sha256:e5168986b90a8d1f2f9dc1b841467c74221bd752537b99761a93d2d981e04889", - "sha256:efa29c2fe6b4fdd32e8ef81c1528506895eca86e1d8c4657fda04c9b3786ddf9", - "sha256:f1496ea22ca2c830cbcbd473de8f114a320da308438ae65abad6bab7867fe38f", - "sha256:f49e52d174375a7def9915c9f06ec4e569d235ad428f70751765f48d5926678c" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==4.9.2" - }, - "markupsafe": { - "hashes": [ - "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298", - "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64", - "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b", - "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194", - "sha256:060b790af48b3d1cb6776a1e8956fb41106c6befaca717181c41a1728147dc73", - "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567", - "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff", - "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724", - "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74", - "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646", - "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35", - "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6", - "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a", - "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6", - "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad", - "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26", - "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38", - "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac", - "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7", - "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6", - "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047", - "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75", - "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f", - "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b", - "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135", - "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8", - "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a", - "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a", - "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1", - "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9", - "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864", - "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914", - "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee", - "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f", - "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18", - "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8", - "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2", - "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d", - "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b", - "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b", - "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86", - "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6", - "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f", - "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb", - "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833", - "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28", - "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e", - "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415", - "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902", - "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f", - "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d", - "sha256:b2b16984860d962a9f7f42c8f84ecdb74993b803a705309bb0acea7f032e37e7", - "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9", - "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d", - "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145", - "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066", - "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c", - "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1", - "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a", - "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207", - "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f", - "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53", - "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd", - "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134", - "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85", - "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9", - "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5", - "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94", - "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509", - "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51", - "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872" - ], - "markers": "python_version >= '3.6'", - "version": "==2.0.1" - }, - "marshmallow": { - "hashes": [ - "sha256:d41d690f5e799ebd9a97e761be71524ca3a728442e01c029dd029654778e0797" - ], - "markers": "python_version >= '3.7'", - "version": "==3.18.0" - }, - "mccabe": { - "hashes": [ - "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", - "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", - "sha256:afd5dbbfbc7a5c66374ba2c14777063ec6fb2f0218bf1de55c65dbcf8a7f053d" - ], - "markers": "python_version >= '3.6'", - "version": "==0.7.0" - }, - "mistune": { - "hashes": [ - "sha256:0246113cb2492db875c6be56974a7c893333bf26cd92891c85f63151cee09d34", - "sha256:34d10acf296d022df53f686d2792a24447b440bc35ee7a124c334297e14661d3", - "sha256:bad7f5d431886fcbaf5f758118ecff70d31f75231b34024a1341120340a65ce8" - ], - "version": "==2.0.5" - }, - "mypy": { - "hashes": [ - "sha256:0714258640194d75677e86c786e80ccf294972cc76885d3ebbb560f11db0003d", - "sha256:0c8f3be99e8a8bd403caa8c03be619544bc2c77a7093685dcf308c6b109426c6", - "sha256:0cca5adf694af539aeaa6ac633a7afe9bbd760df9d31be55ab780b77ab5ae8bf", - "sha256:1c8cd4fb70e8584ca1ed5805cbc7c017a3d1a29fb450621089ffed3e99d1857f", - "sha256:1f7d1a520373e2272b10796c3ff721ea1a0712288cafaa95931e66aa15798813", - "sha256:209ee89fbb0deed518605edddd234af80506aec932ad28d73c08f1400ef80a33", - "sha256:26efb2fcc6b67e4d5a55561f39176821d2adf88f2745ddc72751b7890f3194ad", - "sha256:37bd02ebf9d10e05b00d71302d2c2e6ca333e6c2a8584a98c00e038db8121f05", - "sha256:3a700330b567114b673cf8ee7388e949f843b356a73b5ab22dd7cff4742a5297", - "sha256:3c0165ba8f354a6d9881809ef29f1a9318a236a6d81c690094c5df32107bde06", - "sha256:3d80e36b7d7a9259b740be6d8d906221789b0d836201af4234093cae89ced0cd", - "sha256:4175593dc25d9da12f7de8de873a33f9b2b8bdb4e827a7cae952e5b1a342e243", - "sha256:4307270436fd7694b41f913eb09210faff27ea4979ecbcd849e57d2da2f65305", - "sha256:58200ea5a4704ecd13e5f9d625d25abdab754f2721cc8181abdfc3fede8e9146", - "sha256:5e80e758243b97b618cdf22004beb09e8a2de1af481382e4d84bc52152d1c476", - "sha256:641411733b127c3e0dab94c45af15fea99e4468f99ac88b39efb1ad677da5711", - "sha256:652b651d42f155033a1967739788c436491b577b6a44e4c39fb340d0ee7f0d70", - "sha256:6d7464bac72a85cb3491c7e92b5b62f3dcccb8af26826257760a552a5e244aa5", - "sha256:74e259b5c19f70d35fcc1ad3d56499065c601dfe94ff67ae48b85596b9ec1461", - "sha256:7d17e0a9707d0772f4a7b878f04b4fd11f6f5bcb9b3813975a9b13c9332153ab", - "sha256:901c2c269c616e6cb0998b33d4adbb4a6af0ac4ce5cd078afd7bc95830e62c1c", - "sha256:98e781cd35c0acf33eb0295e8b9c55cdbef64fcb35f6d3aa2186f289bed6e80d", - "sha256:a12c56bf73cdab116df96e4ff39610b92a348cc99a1307e1da3c3768bbb5b135", - "sha256:ac6e503823143464538efda0e8e356d871557ef60ccd38f8824a4257acc18d93", - "sha256:b8472f736a5bfb159a5e36740847808f6f5b659960115ff29c7cecec1741c648", - "sha256:b86ce2c1866a748c0f6faca5232059f881cda6dda2a893b9a8373353cfe3715a", - "sha256:bc9ec663ed6c8f15f4ae9d3c04c989b744436c16d26580eaa760ae9dd5d662eb", - "sha256:c9166b3f81a10cdf9b49f2d594b21b31adadb3d5e9db9b834866c3258b695be3", - "sha256:d13674f3fb73805ba0c45eb6c0c3053d218aa1f7abead6e446d474529aafc372", - "sha256:de32edc9b0a7e67c2775e574cb061a537660e51210fbf6006b0b36ea695ae9bb", - "sha256:e62ebaad93be3ad1a828a11e90f0e76f15449371ffeecca4a0a0b9adc99abcef" - ], - "markers": "python_version >= '3.7'", - "version": "==0.991" - }, - "mypy-extensions": { - "hashes": [ - "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", - "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", - "sha256:7b58bcc6d3a548c146400c2e5de934bdbf2658503f897adee40f210e19b1f0ac" - ], - "markers": "python_version >= '3.5'", - "version": "==1.0.0" - }, - "numpy": { - "hashes": [ - "sha256:0b78ecfa070460104934e2caf51694ccd00f37d5e5dbe76f021b1b0b0d221823", - "sha256:1247ef28387b7bb7f21caf2dbe4767f4f4175df44d30604d42ad9bd701ebb31f", - "sha256:1403b4e2181fc72664737d848b60e65150f272fe5a1c1cbc16145ed43884065a", - "sha256:170b2a0805c6891ca78c1d96ee72e4c3ed1ae0a992c75444b6ab20ff038ba2cd", - "sha256:2e4ed57f45f0aa38beca2a03b6532e70e548faf2debbeb3291cfc9b315d9be8f", - "sha256:32fe5b12061f6446adcbb32cf4060a14741f9c21e15aaee59a207b6ce6423469", - "sha256:34f3456f530ae8b44231c63082c8899fe9c983fd9b108c997c4b1c8c2d435333", - "sha256:4c9c23158b87ed0e70d9a50c67e5c0b3f75bcf2581a8e34668d4e9d7474d76c6", - "sha256:5d95668e727c75b3f5088ec7700e260f90ec83f488e4c0aaccb941148b2cd377", - "sha256:615d4e328af7204c13ae3d4df7615a13ff60a49cb0d9106fde07f541207883ca", - "sha256:69077388c5a4b997442b843dbdc3a85b420fb693ec8e33020bb24d647c164fa5", - "sha256:74b85a17528ca60cf98381a5e779fc0264b4a88b46025e6bcbe9621f46bb3e63", - "sha256:81225e58ef5fce7f1d80399575576fc5febec79a8a2742e8ef86d7b03beef49f", - "sha256:826319c22f05d7b50c3637af5563234edbb02f5634a08d314d21ef00271a32fe", - "sha256:8731b4e638adcdefe10ecad3d8c3ae60eec7311cdfd34ecdb72a41df56071be8", - "sha256:8890b3360f345e8360133bc078d2dacc2843b6ee6059b568781b15b97acbe39f", - "sha256:92aafa03da8658609f59f18722b88f0a73a249101169e28415b4fa148caf7e41", - "sha256:9864424631775b0c052f3bd98bc2712d131b3e2cd95d1c0c68b91709170890b0", - "sha256:9e6f5f50d1eff2f2f752b3089a118aee1ea0da63d56c44f3865681009b0af162", - "sha256:a3deb31bc84f2b42584b8c4001c85d1934dbfb4030827110bc36bfd11509b7bf", - "sha256:ad010846cdffe7ec27e3f933397f8a8d6c801a48634f419e3d075db27acf5880", - "sha256:b1e2312f5b8843a3e4e8224b2b48fe16119617b8fc0a54df8f50098721b5bed2", - "sha256:bc988afcea53e6156546e5b2885b7efab089570783d9d82caf1cfd323b0bb3dd", - "sha256:c449eb870616a7b62e097982c622d2577b3dbc800aaf8689254ec6e0197cbf1e", - "sha256:c74c699b122918a6c4611285cc2cad4a3aafdb135c22a16ec483340ef97d573c", - "sha256:c885bfc07f77e8fee3dc879152ba993732601f1f11de248d4f357f0ffea6a6d4", - "sha256:e3c3e990274444031482a31280bf48674441e0a5b55ddb168f3a6db3e0c38ec8", - "sha256:e4799be6a2d7d3c33699a6f77201836ac975b2e1b98c2a07f66a38f499cb50ce", - "sha256:e6c76a87633aa3fa16614b61ccedfae45b91df2767cf097aa9c933932a7ed1e0", - "sha256:e89717274b41ebd568cd7943fc9418eeb49b1785b66031bc8a7f6300463c5898", - "sha256:f5162ec777ba7138906c9c274353ece5603646c6965570d82905546579573f73", - "sha256:fde96af889262e85aa033f8ee1d3241e32bf36228318a61f1ace579df4e8170d" - ], - "markers": "python_version < '3.11' and python_version >= '3.7'", - "version": "==1.21.4" - }, - "openapi-schema-validator": { - "hashes": [ - "sha256:33661cd8a06d5112dec52d758dda55e543e4a7c9f0efc2901dcf83ebd5604055", - "sha256:79f37f38ef9fd5206b924ed7a6f382cea7b649b3b56383c47f1906082b7b9015", - "sha256:c573e2be2c783abae56c5a1486ab716ca96e09d1c3eab56020d1dc680aa57bf8" - ], - "markers": "python_full_version >= '3.7.0' and python_full_version < '4.0.0'", - "version": "==0.4.4" - }, - "openapi-spec-validator": { - "hashes": [ - "sha256:1189d0618ae0678ccf6c883cc1266d381454eece6f21fcf330cc7caea5fc25eb", - "sha256:4145478f26df16059c147406eaaa59b77ff60e3461ba6edb9aa84d481ed89aaf", - "sha256:92721d4a905a1e47a574d67f38dad32b64f7146b3f63d18edf0f3aa97afad207" - ], - "version": "==0.5.6" - }, - "opencv-python-headless": { - "hashes": [ - "sha256:3f330468c29882dbbec5af25695c5e575572c6b855cb0f9fe53e14116fd46bfc", - "sha256:4bdf982574bf2fefc5f82c86df7cb42e56ad627874c7c0f4d94ecf4ae8885304", - "sha256:567a54c1919bcf5b3d20a9830e3c511e57134de8def286ce137c3544a892f98c", - "sha256:62e31878641a8f96e773118d1eea9f34bdda87c9990a0faab04ebaafb5ae015c", - "sha256:672b5657a36a642d7b38b8ae94a266c7abaedfa3bb31276d0b889d2a96615f22", - "sha256:a60e9ff48854ec37be391e19dd634883cc26c2f0f814e5325b3deca33420912c", - "sha256:c3c2dda44d601757a508b07d628537c49f31223ad4edd0f747a70d4c852a7b98", - "sha256:ca4f013fa958f60fb2327fe87e6127c1ac0ab536890b1d4b00847f417b7af1ba", - "sha256:dcf472cf3b31c4e3f7135378aac6f4c5da5b49a07145c825e38bc23af17ce43f" - ], - "markers": "python_version >= '3.6'", - "version": "==4.5.5.64" - }, - "openflexure-microscope-server": { - "editable": true, - "path": "." - }, - "packaging": { - "hashes": [ - "sha256:36bb3108f8e33f88e52e7985b4068e2585b67e5b7b3a0fa2817f9852bc39d2ef", - "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb", - "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522" - ], - "markers": "python_version >= '3.6'", - "version": "==21.3" - }, - "pastel": { - "hashes": [ - "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", - "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.2.1" - }, - "pathable": { - "hashes": [ - "sha256:5c869d315be50776cc8a993f3af43e0c60dc01506b399643f919034ebf4cdcab", - "sha256:cdd7b1f9d7d5c8b8d3315dbf5a86b2596053ae845f056f57d97c0eefff84da14" - ], - "markers": "python_full_version >= '3.7.0' and python_full_version < '4.0.0'", - "version": "==0.4.3" - }, - "picamerax": { - "hashes": [ - "sha256:8dc45542644ed9c67e3b331e90bcffa04098e8b056a42d0fedb25769b0b1f17e", - "sha256:fccb201ad9e2ab67946111d6bf875289c141ef900fd5875de6d435d219c59440" - ], - "version": "==20.9.1" - }, - "picobox": { - "hashes": [ - "sha256:37d00ec92694312eb0a1abad3ecb9d46d4d8ae4f4c26f24fb2eb3910f9a8a792", - "sha256:59d665ee82cafe2de80a6bf9dff407d502bb4688125398eaca05dbe02dbfb07a" - ], - "markers": "python_version >= '3.7'", - "version": "==3.0.0" - }, - "piexif": { - "hashes": [ - "sha256:3bc435d171720150b81b15d27e05e54b8abbde7b4242cddd81ef160d283108b6", - "sha256:83cb35c606bf3a1ea1a8f0a25cb42cf17e24353fd82e87ae3884e74a302a5f1b" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.1.3" - }, - "pillow": { - "hashes": [ - "sha256:0295442429645fa16d05bd567ef5cff178482439c9aad0411d3f0ce9b88b3a6f", - "sha256:06aba4169e78c439d528fdeb34762c3b61a70813527a2c57f0540541e9f433a8", - "sha256:09d7f9e64289cb40c2c8d7ad674b2ed6105f55dc3b09aa8e4918e20a0311e7ad", - "sha256:0a80dd307a5d8440b0a08bd7b81617e04d870e40a3e46a32d9c246e54705e86f", - "sha256:1ca594126d3c4def54babee699c055a913efb01e106c309fa6b04405d474d5ae", - "sha256:25930fadde8019f374400f7986e8404c8b781ce519da27792cbe46eabec00c4d", - "sha256:39b8830992872e00605ec6eb0d83efad488553f10d79066b8b47c8be38e126b3", - "sha256:431b15cffbf949e89df2f7b48528be18b78bfa5177cb3036284a5508159492b5", - "sha256:4a2ef919795ad6fc28c47be3f16ec8add4e8c6daa01871e25788cda1b244b162", - "sha256:52125833b070791fcb5710fabc640fc1df07d087fc0c0f02d3661f76c23c5b8b", - "sha256:5e51ee2b8114def244384eda1c82b10e307ad9778dac5c83fb0943775a653cd8", - "sha256:612cfda94e9c8346f239bf1a4b082fdd5c8143cf82d685ba2dba76e7adeeb233", - "sha256:6d7741e65835716ceea0fd13a7d0192961212fd59e741a46bbed7a473c634ed6", - "sha256:6edb5446f44d901e8683ffb25ebdfc26988ee813da3bf91e12252b57ac163727", - "sha256:725aa6cfc66ce2857d585f06e9519a1cc0ef6d13f186ff3447ab6dff0a09bc7f", - "sha256:8dad18b69f710bf3a001d2bf3afab7c432785d94fcf819c16b5207b1cfd17d38", - "sha256:94cf49723928eb6070a892cb39d6c156f7b5a2db4e8971cb958f7b6b104fb4c4", - "sha256:97f9e7953a77d5a70f49b9a48da7776dc51e9b738151b22dacf101641594a626", - "sha256:9ad7f865eebde135d526bb3163d0b23ffff365cf87e767c649550964ad72785d", - "sha256:9c87ef410a58dd54b92424ffd7e28fd2ec65d2f7fc02b76f5e9b2067e355ebf6", - "sha256:a060cf8aa332052df2158e5a119303965be92c3da6f2d93b6878f0ebca80b2f6", - "sha256:c79f9c5fb846285f943aafeafda3358992d64f0ef58566e23484132ecd8d7d63", - "sha256:c92302a33138409e8f1ad16731568c55c9053eee71bb05b6b744067e1b62380f", - "sha256:d08b23fdb388c0715990cbc06866db554e1822c4bdcf6d4166cf30ac82df8c41", - "sha256:d350f0f2c2421e65fbc62690f26b59b0bcda1b614beb318c81e38647e0f673a1", - "sha256:e6d08a4d39b8a3a503c6e97e53ee2cc1dcd4fd05c4f87e8991f53e979608d0f6", - "sha256:e901964262a56d9ea3c2693df68bc9860b8bdda2b04768821e4c44ae797de117", - "sha256:ec29604081f10f16a7aea809ad42e27764188fc258b02259a03a8ff7ded3808d", - "sha256:edf31f1150778abd4322444c393ab9c7bd2af271dd4dafb4208fb613b1f3cdc9", - "sha256:f7e30c27477dffc3e85c2463b3e649f751789e0f6c8456099eea7ddd53be4a8a", - "sha256:ffe538682dc19cc542ae7c3e504fdf54ca7f86fb8a135e59dd6bc8627eae6cce" - ], - "markers": "python_version >= '3.5'", - "version": "==7.2.0" - }, - "pkgutil-resolve-name": { - "hashes": [ - "sha256:23dcc36937a564cabe07363c490c1b5acc4019fe299788a7ad3e28f1be5fc506", - "sha256:357d6c9e6a755653cfd78893817c0853af365dd51ec97f3d358a819373bbd174", - "sha256:ca27cc078d25c5ad71a9de0a7a330146c4e014c2462d9af19c6b828280649c5e" - ], - "markers": "python_version < '3.9'", - "version": "==1.3.10" - }, - "platformdirs": { - "hashes": [ - "sha256:69de5933ec873bd7ddae497f004be17cf200bce048dc987c28fc4e347d5349ff", - "sha256:e13f076e0f725f1beb58e7d26f80eff94099941740d3c664db03efecd6561271" - ], - "markers": "python_version >= '3.7'", - "version": "==2.6.1" - }, - "pluggy": { - "hashes": [ - "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", - "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3", - "sha256:f2d9abbfd1465ce5d1e261e51cc15afc87ede2ae079c7c7c8d894b1cc37ba123" - ], - "markers": "python_version >= '3.6'", - "version": "==1.0.0" - }, - "poethepoet": { - "hashes": [ - "sha256:3c958792ce488661ba09df67ba832a1b3141aa640236505ee60c23f4b1db4dbc", - "sha256:493d5d47b4cb0894dde6a69d14129ba39ef3f124fabda1f83ebb39bbf737a40e", - "sha256:737d91d16e21258d47d8e4121fc5ca1cebf4ac702f37efb05c370d2c08f218f5" - ], - "markers": "python_version >= '3.7'", - "version": "==0.16.5" - }, - "prance": { - "extras": [ - "osv" - ], - "hashes": [ - "sha256:814a523bc1ff18383c12cb523ce44c90fe8792bf5f48d8cc33c9f658276658ed", - "sha256:a9d0389da9919cf266a6668aeb1dec294f844f99c256ee6c2e36a9451b69a57b", - "sha256:c15e9ca889b56262e4c2aee354f52918ba5e54f46bb3da42b806d8bbd8255ee9" - ], - "version": "==0.22.11.4.0" - }, - "psutil": { - "hashes": [ - "sha256:094f899ac3ef72422b7e00411b4ed174e3c5a2e04c267db6643937ddba67a05b", - "sha256:10b7f75cc8bd676cfc6fa40cd7d5c25b3f45a0e06d43becd7c2d2871cbb5e806", - "sha256:1b1575240ca9a90b437e5a40db662acd87bbf181f6aa02f0204978737b913c6b", - "sha256:21231ef1c1a89728e29b98a885b8e0a8e00d09018f6da5cdc1f43f988471a995", - "sha256:28f771129bfee9fc6b63d83a15d857663bbdcae3828e1cb926e91320a9b5b5cd", - "sha256:70387772f84fa5c3bb6a106915a2445e20ac8f9821c5914d7cbde148f4d7ff73", - "sha256:7d5e1be385287e2334f708ec261b8ee364ac2bcb07eadcbd7a254f9cd476b65f", - "sha256:891e24ec41be15a99c00b0f3a378d3d439876fe18793f874a4f8e796fec77458", - "sha256:a9340f6b9ff887d77aed72a51d101ca0f21b8d82edc6c85105d1d9f027544365", - "sha256:b560f5cd86cf8df7bcd258a851ca1ad98f0d5b8b98748e877a0aec4e9032b465", - "sha256:b74b43fecce384a57094a83d2778cdfc2e2d9a6afaadd1ebecb2e75e0d34e10d", - "sha256:e85f727ffb21539849e6012f47b12f6dd4c44965e56591d8dec6e8bc9ab96f4a", - "sha256:fd2e09bb593ad9bdd7429e779699d2d47c1268cbde4dda95fcd1bd17544a0217", - "sha256:ffad8eb2ac614518bbe3c0b8eb9dffdb3a8d2e3a7d5da51c5b974fb723a5c5aa" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==5.6.7" - }, - "py": { - "hashes": [ - "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719", - "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378", - "sha256:ec07ead4a92be3fe6be916f57ee869b9b4cb7ec17a0add2a5366cc7efa0521c8" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.11.0" - }, - "pygments": { - "hashes": [ - "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297", - "sha256:c2fe87a7618b6f4ebdde31913d4e6c1bccf1caf7f0651f659f3acd37a75cba68", - "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717" - ], - "markers": "python_version >= '3.6'", - "version": "==2.14.0" - }, - "pylint": { - "hashes": [ - "sha256:07ad9caa2f6005c494e59aaec3d6cb6b97b0f54dfb84b8f47609edcf972f8f81", - "sha256:487ce2192eee48211269a0e976421f334cf94de1806ca9d0a99449adcdf0285e", - "sha256:fabe30000de7d07636d2e82c9a518ad5ad7908590fe135ace169b44839c15f90" - ], - "markers": "python_full_version >= '3.7.2'", - "version": "==2.14.5" - }, - "pyparsing": { - "hashes": [ - "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb", - "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc", - "sha256:94fac81ddf92f0bdf3bb33c1c3e6d0181caa204a578184dd02778870ccf307c5" - ], - "markers": "python_full_version >= '3.6.8'", - "version": "==3.0.9" - }, - "pyrsistent": { - "hashes": [ - "sha256:016ad1afadf318eb7911baa24b049909f7f3bb2c5b1ed7b6a8f21db21ea3faa8", - "sha256:0a76a451d782433be74a500c6a072d1c6669481bfcbcdd985d12f0965fa6b87e", - "sha256:1a2994773706bbb4995c31a97bc94f1418314923bd1048c6d964837040376440", - "sha256:20460ac0ea439a3e79caa1dbd560344b64ed75e85d8703943e0b66c2a6150e4a", - "sha256:3311cb4237a341aa52ab8448c27e3a9931e2ee09561ad150ba94e4cfd3fc888c", - "sha256:3a8cb235fa6d3fd7aae6a4f1429bbb1fec1577d978098da1252f0489937786f3", - "sha256:3ab2204234c0ecd8b9368dbd6a53e83c3d4f3cab10ecaf6d0e772f456c442393", - "sha256:42ac0b2f44607eb92ae88609eda931a4f0dfa03038c44c772e07f43e738bcac9", - "sha256:49c32f216c17148695ca0e02a5c521e28a4ee6c5089f97e34fe24163113722da", - "sha256:4b774f9288dda8d425adb6544e5903f1fb6c273ab3128a355c6b972b7df39dcf", - "sha256:4c18264cb84b5e68e7085a43723f9e4c1fd1d935ab240ce02c0324a8e01ccb64", - "sha256:55ff9ca4735904a3897f5d03170c56122b724bf2ead32def8999fbb6949c7bb5", - "sha256:5a474fb80f5e0d6c9394d8db0fc19e90fa540b82ee52dba7d246a7791712f74a", - "sha256:64220c429e42a7150f4bfd280f6f4bb2850f95956bde93c6fda1b70507af6ef3", - "sha256:878433581fc23e906d947a6814336eee031a00e6defba224234169ae3d3d6a98", - "sha256:99abb85579e2165bd8522f0c0138864da97847875ecbd45f3e7e2af569bfc6f2", - "sha256:a2471f3f8693101975b1ff85ffd19bb7ca7dd7c38f8a81701f67d6b4f97b87d8", - "sha256:aeda827381f5e5d65cced3024126529ddc4289d944f75e090572c77ceb19adbf", - "sha256:b735e538f74ec31378f5a1e3886a26d2ca6351106b4dfde376a26fc32a044edc", - "sha256:c147257a92374fde8498491f53ffa8f4822cd70c0d85037e09028e478cababb7", - "sha256:c4db1bd596fefd66b296a3d5d943c94f4fac5bcd13e99bffe2ba6a759d959a28", - "sha256:c74bed51f9b41c48366a286395c67f4e894374306b197e62810e0fdaf2364da2", - "sha256:c9bb60a40a0ab9aba40a59f68214eed5a29c6274c83b2cc206a359c4a89fa41b", - "sha256:cc5d149f31706762c1f8bda2e8c4f8fead6e80312e3692619a75301d3dbb819a", - "sha256:ccf0d6bd208f8111179f0c26fdf84ed7c3891982f2edaeae7422575f47e66b64", - "sha256:e42296a09e83028b3476f7073fcb69ffebac0e66dbbfd1bd847d61f74db30f19", - "sha256:e8f2b814a3dc6225964fa03d8582c6e0b6650d68a232df41e3cc1b66a5d2f8d1", - "sha256:f0774bf48631f3a20471dd7c5989657b639fd2d285b861237ea9e82c36a415a9", - "sha256:f0e7c4b2f77593871e918be000b96c8107da48444d57005b6a6bc61fb4331b2c" - ], - "markers": "python_version >= '3.7'", - "version": "==0.19.3" - }, - "pyserial": { - "hashes": [ - "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", - "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0" - ], - "version": "==3.5" - }, - "pytest": { - "hashes": [ - "sha256:130328f552dcfac0b1cec75c12e3f005619dc5f874f0a06e8ff7263f0ee6225e", - "sha256:a6377f55b06d85889e6db60ee185b02e0aa87af517ec2eec93075d1a9bc22648", - "sha256:c99ab0c73aceb050f68929bc93af19ab6db0558791c6a0715723abe9d0ade9d4" - ], - "markers": "python_version >= '3.7'", - "version": "==7.2.2" - }, - "pytest-cov": { - "hashes": [ - "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191", - "sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.10.1" - }, - "python-dateutil": { - "hashes": [ - "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", - "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.8.2" - }, - "pytz": { - "hashes": [ - "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588", - "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb", - "sha256:dcb23b3d3f96b1c61a06ccc55e8b19dbb430b7571dc8a112432909a14dd64063" - ], - "markers": "python_version < '3.9'", - "version": "==2023.3" - }, - "pyyaml": { - "hashes": [ - "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", - "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696", - "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393", - "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77", - "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922", - "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5", - "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8", - "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10", - "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc", - "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", - "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", - "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", - "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347", - "sha256:7318f36ecd539646767465d194cbbe9c248f243ba0117c3d043c660d69cfeceb", - "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", - "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541", - "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", - "sha256:8fc87fd1a1d30991d9198a4317e90dfaf3d2963c013608a019e8311568fa08b2", - "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", - "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc", - "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", - "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa", - "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", - "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122", - "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", - "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", - "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", - "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc", - "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247", - "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6", - "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==5.4.1" - }, - "requests": { - "hashes": [ - "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa", - "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf", - "sha256:e77536e318369f207754c018f33a97b68adec830f33bee3507d296683ba1d416" - ], - "markers": "python_version >= '3.7' and python_version < '4'", - "version": "==2.28.2" - }, - "rfc3339-validator": { - "hashes": [ - "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", - "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==0.1.4" - }, - "rpi.gpio": { - "hashes": [ - "sha256:15311d3b063b71dee738cd26570effc9985a952454d162937c34e08c0fc99902", - "sha256:231f6142c25975a7e39b96faf4905f05f97dba6809e8c5f0d58f284b35b4b821", - "sha256:26b2ade4bb353bbe5417a64e40b45cdf00a82f27c2d320c0dd46c59751959d77", - "sha256:29226823da8b5ccb9001d795a944f2e00924eeae583490f0bc7317581172c624", - "sha256:5073d1972727cfad38a1a42f02da91dc41f137679290aa804ab6c410168347ac", - "sha256:57b6c044ef5375a78c8dda27cdfadf329e76aa6943cd6cffbbbd345a9adf9ca5", - "sha256:77afb817b81331ce3049a4b8f94a85e41b7c404d8e56b61ac0f1eb75c3120868", - "sha256:96ea7e5bf6bf592828487bfa7d3d1d0e432d5d1682e9e33b4dab398914628fcd", - "sha256:b86b66dc02faa5461b443a1e1f0c1d209d64ab5229696f32fb3b0215e0600c8c", - "sha256:cd61c4b03c37b62bba4a5acfea9862749c33c618e0295e7e90aa4713fb373b70", - "sha256:e5dfec2c4ccb7dbe9eef3dfc76a3b04121fc62d77fd8fbe1a8e841468a1b0c4c" - ], - "version": "==0.7.1" - }, - "ruamel.yaml": { - "hashes": [ - "sha256:5a81d994d11f98c7e7288636ea5bef647359641353411cebf8d2cce3a4a12385", - "sha256:742b35d3d665023981bd6d16b3d24248ce5df75fdb4e2924e93a05c1f8b61ca7", - "sha256:8b7ce697a2f212752a35c1ac414471dc16c424c9573be4926b56ff3f5d23b7af" - ], - "markers": "python_version >= '3'", - "version": "==0.17.21" - }, - "ruamel.yaml.clib": { - "hashes": [ - "sha256:045e0626baf1c52e5527bd5db361bc83180faaba2ff586e763d3d5982a876a9e", - "sha256:15910ef4f3e537eea7fe45f8a5d19997479940d9196f357152a09031c5be59f3", - "sha256:184faeaec61dbaa3cace407cffc5819f7b977e75360e8d5ca19461cd851a5fc5", - "sha256:1f08fd5a2bea9c4180db71678e850b995d2a5f4537be0e94557668cf0f5f9497", - "sha256:2aa261c29a5545adfef9296b7e33941f46aa5bbd21164228e833412af4c9c75f", - "sha256:3110a99e0f94a4a3470ff67fc20d3f96c25b13d24c6980ff841e82bafe827cac", - "sha256:3243f48ecd450eddadc2d11b5feb08aca941b5cd98c9b1db14b2fd128be8c697", - "sha256:370445fd795706fd291ab00c9df38a0caed0f17a6fb46b0f607668ecb16ce763", - "sha256:40d030e2329ce5286d6b231b8726959ebbe0404c92f0a578c0e2482182e38282", - "sha256:41d0f1fa4c6830176eef5b276af04c89320ea616655d01327d5ce65e50575c94", - "sha256:4a4d8d417868d68b979076a9be6a38c676eca060785abaa6709c7b31593c35d1", - "sha256:4b3a93bb9bc662fc1f99c5c3ea8e623d8b23ad22f861eb6fce9377ac07ad6072", - "sha256:5bc0667c1eb8f83a3752b71b9c4ba55ef7c7058ae57022dd9b29065186a113d9", - "sha256:721bc4ba4525f53f6a611ec0967bdcee61b31df5a56801281027a3a6d1c2daf5", - "sha256:763d65baa3b952479c4e972669f679fe490eee058d5aa85da483ebae2009d231", - "sha256:7bdb4c06b063f6fd55e472e201317a3bb6cdeeee5d5a38512ea5c01e1acbdd93", - "sha256:8831a2cedcd0f0927f788c5bdf6567d9dc9cc235646a434986a852af1cb54b4b", - "sha256:8f73b665f03f70986e4b5c4708b9e553b931d34b815032452327fa7c2a9099bf", - "sha256:91a789b4aa0097b78c93e3dc4b40040ba55bef518f84a40d4442f713b4094acb", - "sha256:92460ce908546ab69770b2e576e4f99fbb4ce6ab4b245345a3869a0a0410488f", - "sha256:99e77daab5d13a48a4054803d052ff40780278240a902b880dd37a51ba01a307", - "sha256:9c361022618feec80a1d5e874970d8dae234815097638ed104a40593b602eb5c", - "sha256:a234a20ae07e8469da311e182e70ef6b199d0fbeb6c6cc2901204dd87fb867e8", - "sha256:a7b301ff08055d73223058b5c46c55638917f04d21577c95e00e0c4d79201a6b", - "sha256:be2a7ad8fd8f7442b24323d24ba0b56c51219513cfa45b9ada3b87b76c374d4b", - "sha256:bf9a6bc4a0221538b1a7de3ed7bca4c93c02346853f44e1cd764be0023cd3640", - "sha256:c3ca1fbba4ae962521e5eb66d72998b51f0f4d0f608d3c0347a48e1af262efa7", - "sha256:d000f258cf42fec2b1bbf2863c61d7b8918d31ffee905da62dede869254d3b8a", - "sha256:d5859983f26d8cd7bb5c287ef452e8aacc86501487634573d260968f753e1d71", - "sha256:d5e51e2901ec2366b79f16c2299a03e74ba4531ddcfacc1416639c557aef0ad8", - "sha256:da538167284de58a52109a9b89b8f6a53ff8437dd6dc26d33b57bf6699153122", - "sha256:debc87a9516b237d0466a711b18b6ebeb17ba9f391eb7f91c649c5c4ec5006c7", - "sha256:df5828871e6648db72d1c19b4bd24819b80a755c4541d3409f0f7acd0f335c80", - "sha256:ecdf1a604009bd35c674b9225a8fa609e0282d9b896c03dd441a91e5f53b534e", - "sha256:efa08d63ef03d079dcae1dfe334f6c8847ba8b645d08df286358b1f5293d24ab", - "sha256:f01da5790e95815eb5a8a138508c01c758e5f5bc0ce4286c4f7028b8dd7ac3d0", - "sha256:f34019dced51047d6f70cb9383b2ae2853b7fc4dce65129a5acd49f4f9256646", - "sha256:f6d3d39611ac2e4f62c3128a9eed45f19a6608670c5a2f4f07f24e8de3441d38" - ], - "markers": "python_version < '3.11' and platform_python_implementation == 'CPython'", - "version": "==0.2.7" - }, - "sangaboard": { - "hashes": [ - "sha256:4b678ad7a814aa4f3ccccfc8790bb941222e6ee2c93586baf488220165dae304", - "sha256:4bfc58f1b7493c858b6b314d8b57a26b019ea2724737050000fa559251d3e050", - "sha256:4e945e876a094be9fb62f5a7282a06be4a3eb386d2032c615bff4f42dbb74bc8" - ], - "version": "==0.3.3" - }, - "scipy": { - "hashes": [ - "sha256:01b38dec7e9f897d4db04f8de4e20f0f5be3feac98468188a0f47a991b796055", - "sha256:10dbcc7de03b8d635a1031cb18fd3eaa997969b64fdf78f99f19ac163a825445", - "sha256:19aeac1ad3e57338723f4657ac8520f41714804568f2e30bd547d684d72c392e", - "sha256:1b21c6e0dc97b1762590b70dee0daddb291271be0580384d39f02c480b78290a", - "sha256:1caade0ede6967cc675e235c41451f9fb89ae34319ddf4740194094ab736b88d", - "sha256:208fe23655561a95ede6bb3bb15342882f6a8f3867bf5564cafbdbcc401b1a5d", - "sha256:23995dfcf269ec3735e5a8c80cfceaf384369a47699df111a6246b83a55da582", - "sha256:2a799714bf1f791fb2650d73222b248d18d53fd40d6af2df2c898db048189606", - "sha256:3274ce145b5dc416c49c0cf8b6119f787f0965cd35e22058fe1932c09fe15d77", - "sha256:33d1677d46111cfa1c84b87472a0274dde9ef4a7ef2e1f155f012f5f1e995d8f", - "sha256:44d452850f77e65e25b1eb1ac01e25770323a782bfe3a1a3e43847ad4266d93d", - "sha256:9e3302149a369697c6aaea18b430b216e3c88f9a61b62869f6104881e5f9ef85", - "sha256:a75b014d3294fce26852a9d04ea27b5671d86736beb34acdfc05859246260707", - "sha256:ad7269254de06743fb4768f658753de47d8b54e4672c5ebe8612a007a088bd48", - "sha256:b30280fbc1fd8082ac822994a98632111810311a9ece71a0e48f739df3c555a2", - "sha256:b79104878003487e2b4639a20b9092b02e1bad07fc4cf924b495cf413748a777", - "sha256:d1057272927f435496b4ba1fc11bccad4b98c0ab47cc62baf9d2fbdc206c5958", - "sha256:d449d40e830366b4c612692ad19fbebb722b6b847f78a7b701b1e0d6cda3cc13", - "sha256:d647757373985207af3343301d89fe738d5a294435a4f2aafb04c13b4388c896", - "sha256:f68eb46b86b2c246af99fcaa6f6e37c7a7a413e1084a794990b877f2ff71f7b6", - "sha256:fdf606341cd798530b05705c87779606fcdfaf768a8129c348ea94441da15b04" - ], - "markers": "python_version < '3.10' and python_version >= '3.7'", - "version": "==1.6.3" - }, - "semantic-version": { - "hashes": [ - "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", - "sha256:dc5baeac289ab6817f916c9cb73cef9448e973aa6f7b6c42c27ef39d0ba03ac9", - "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177" - ], - "markers": "python_version >= '2.7'", - "version": "==2.10.0" - }, - "semver": { - "hashes": [ - "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4", - "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.13.0" - }, - "setuptools": { - "hashes": [ - "sha256:257de92a9d50a60b8e22abfcbb771571fde0dbf3ec234463212027a4eeecbe9a", - "sha256:ae060e31efa47ebd031bdf444908164a9c54dbfe734c97afd5d8e52afbad723a", - "sha256:e728ca814a823bf7bf60162daf9db95b93d532948c4c0bea762ce62f60189078" - ], - "markers": "python_version >= '3.7'", - "version": "==67.6.1" - }, - "six": { - "hashes": [ - "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", - "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.16.0" - }, - "snowballstemmer": { - "hashes": [ - "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", - "sha256:53823aa64ad78444911007d0f56ee775f1b8c1e34c32bdba744d8be02ce7bbf3", - "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" - ], - "version": "==2.2.0" - }, - "sphinx": { - "hashes": [ - "sha256:19010b7b9fa0dc7756a6e105b2aacd3a80f798af3c25c273be64d7beeb482cb1", - "sha256:2320d4e994a191f4b4be27da514e46b3d6b420f2ff895d064f52415d342461e8" - ], - "markers": "python_version >= '3.5'", - "version": "==3.5.4" - }, - "sphinx-mdinclude": { - "hashes": [ - "sha256:02afadf4597aecf8255a702956eff5b8c5cb9658ea995c3d361722d2ed78cca9", - "sha256:2998e3d18b3022c9983d1b72191fe37e25ffccd54165cbe3acb22cceedd91af4", - "sha256:bdd78064b93df1ddea00b0778bf9c0fc3f83a805c66c79fdd08e7f48fb3d8537" - ], - "markers": "python_version >= '3.6'", - "version": "==0.5.3" - }, - "sphinx-rtd-theme": { - "hashes": [ - "sha256:32bd3b5d13dc8186d7a42fc816a23d32e83a4827d7d9882948e7b837c232da5a", - "sha256:4a05bdbe8b1446d77a01e20a23ebc6777c74f43237035e76be89699308987d6f" - ], - "version": "==0.5.2" - }, - "sphinxcontrib-applehelp": { - "hashes": [ - "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", - "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" - ], - "markers": "python_version >= '3.5'", - "version": "==1.0.2" - }, - "sphinxcontrib-devhelp": { - "hashes": [ - "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", - "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" - ], - "markers": "python_version >= '3.5'", - "version": "==1.0.2" - }, - "sphinxcontrib-htmlhelp": { - "hashes": [ - "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07", - "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2" - ], - "markers": "python_version >= '3.6'", - "version": "==2.0.0" - }, - "sphinxcontrib-httpdomain": { - "hashes": [ - "sha256:21eefe1270e4d9de8d717cc89ee92cc4871b8736774393bafc5e38a6bb77b1d5", - "sha256:6c2dfe6ca282d75f66df333869bb0ce7331c01b475db6809ff9d107b7cdfe04b", - "sha256:bb0c47784bde6354c4f5c3be20f94dcfc5dd4f595a0b0b3dcba96ec725472049" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.8.1" - }, - "sphinxcontrib-jsmath": { - "hashes": [ - "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", - "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" - ], - "markers": "python_version >= '3.5'", - "version": "==1.0.1" - }, - "sphinxcontrib-openapi": { - "hashes": [ - "sha256:04fcf87c259345162a504ce3dfee0f71389f507c3797798dc531c7748995b4eb", - "sha256:45ecad2a98e304d3665c133ea08a7083a2b5d86e75c6b64c60389240b3927ad5", - "sha256:84c1df31a1e66e0a362d5d8cd74363f36cbea9e6ee6d0fcb4dcee778470b1819" - ], - "markers": "python_version >= '3.7'", - "version": "==0.8.1" - }, - "sphinxcontrib-qthelp": { - "hashes": [ - "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", - "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" - ], - "markers": "python_version >= '3.5'", - "version": "==1.0.3" - }, - "sphinxcontrib-serializinghtml": { - "hashes": [ - "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd", - "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952" - ], - "markers": "python_version >= '3.5'", - "version": "==1.1.5" - }, - "toml": { - "hashes": [ - "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", - "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.10.2" - }, - "tomli": { - "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" - ], - "markers": "python_version < '3.11'", - "version": "==2.0.1" - }, - "tomlkit": { - "hashes": [ - "sha256:5325463a7da2ef0c6bbfefb62a3dc883aebe679984709aee32a317907d0a8d3c", - "sha256:f392ef70ad87a672f02519f99967d28a4d3047133e2d1df936511465fbb3791d" - ], - "markers": "python_version >= '3.7'", - "version": "==0.11.7" - }, - "typed-ast": { - "hashes": [ - "sha256:0261195c2062caf107831e92a76764c81227dae162c4f75192c0d489faf751a2", - "sha256:0fdbcf2fef0ca421a3f5912555804296f0b0960f0418c440f5d6d3abb549f3e1", - "sha256:183afdf0ec5b1b211724dfef3d2cad2d767cbefac291f24d69b00546c1837fb6", - "sha256:211260621ab1cd7324e0798d6be953d00b74e0428382991adfddb352252f1d62", - "sha256:267e3f78697a6c00c689c03db4876dd1efdfea2f251a5ad6555e82a26847b4ac", - "sha256:2efae9db7a8c05ad5547d522e7dbe62c83d838d3906a3716d1478b6c1d61388d", - "sha256:370788a63915e82fd6f212865a596a0fefcbb7d408bbbb13dea723d971ed8bdc", - "sha256:39e21ceb7388e4bb37f4c679d72707ed46c2fbf2a5609b8b8ebc4b067d977df2", - "sha256:3e123d878ba170397916557d31c8f589951e353cc95fb7f24f6bb69adc1a8a97", - "sha256:4879da6c9b73443f97e731b617184a596ac1235fe91f98d279a7af36c796da35", - "sha256:4e964b4ff86550a7a7d56345c7864b18f403f5bd7380edf44a3c1fb4ee7ac6c6", - "sha256:5a824176c0beca4492bcb9f1b0f0602723cb582872844d86108cc56898fef4f4", - "sha256:5c2af3ab3ade2e1e6de497ba632264104f2f591c7fdd8a17ae107a0b56126278", - "sha256:639c5f0b21776605dd6c9dbe592d5228f021404dafd377e2b7ac046b0349b1a1", - "sha256:669dd0c4167f6f2cd9f57041e03c3c2ebf9063d0757dc89f79ba1daa2bfca9d4", - "sha256:6778e1b2f81dfc7bc58e4b259363b83d2e509a65198e85d5700dfae4c6c8ff1c", - "sha256:683407d92dc953c8a7347119596f0b0e6c55eb98ebebd9b23437501b28dcbb8e", - "sha256:79b1e0869db7c830ba6a981d58711c88b6677506e648496b1f64ac7d15633aec", - "sha256:7d5d014b7daa8b0bf2eaef684295acae12b036d79f54178b92a2b6a56f92278f", - "sha256:98f80dee3c03455e92796b58b98ff6ca0b2a6f652120c263efdba4d6c5e58f72", - "sha256:a94d55d142c9265f4ea46fab70977a1944ecae359ae867397757d836ea5a3f47", - "sha256:a9916d2bb8865f973824fb47436fa45e1ebf2efd920f2b9f99342cb7fab93f72", - "sha256:c542eeda69212fa10a7ada75e668876fdec5f856cd3d06829e6aa64ad17c8dfe", - "sha256:cf4afcfac006ece570e32d6fa90ab74a17245b83dfd6655a6f68568098345ff6", - "sha256:ebd9d7f80ccf7a82ac5f88c521115cc55d84e35bf8b446fcd7836eb6b98929a3", - "sha256:ed855bbe3eb3715fca349c80174cfcfd699c2f9de574d40527b8429acae23a66" - ], - "markers": "python_version < '3.8'", - "version": "==1.5.4" - }, - "types-python-dateutil": { - "hashes": [ - "sha256:357553f8056cfbb8ce8ea0ca4a6a3480268596748360df73a94c2b8c113a5b06", - "sha256:de66222c54318c2e05ceb4956976d16696240a45fc2c98e54bfe9a56ce5e1eff", - "sha256:f37eada27a398e2dc2a64e171af7820c6db83faa07fd0cf8612a0f0b4060c70f" - ], - "version": "==2.8.19.11" - }, - "types-setuptools": { - "hashes": [ - "sha256:5bff4b5d6d774717a05b35d00cb5a2b8c481b7759a357d8db2837d7f0c607e00", - "sha256:a5efd019b53c5793a112e0112b91bb402df749f75ee7abe681b6240841650ce4", - "sha256:fb46e651c6b5880ec8932a8876aa832071d612b2954fc5156b09cd263927d85b" - ], - "version": "==67.6.0.6" - }, - "typing-extensions": { - "hashes": [ - "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02", - "sha256:4e110e067718fa35420fa8739d968edfacd2bb6b4988923875ba9406ad40d7c2", - "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6" - ], - "markers": "python_version >= '3.7'", - "version": "==4.3.0" - }, - "urllib3": { - "hashes": [ - "sha256:8a388717b9476f934a21484e8c8e61875ab60644d29b9b39e11e4b9dc1c6b305", - "sha256:aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42", - "sha256:c3909b2a8d21bf543d661269ff27ff0d3feefc600d8a2e09cea4c0f46e45301a" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==1.26.15" - }, - "webargs": { - "hashes": [ - "sha256:30af59b4cc9d0f8c766fe0eb2789cf3be95621c0590a899f8262074367d7a140", - "sha256:6746327faf549533bf30be7333f99541b6c60a85f23acf1bb0bea68498e3bcd7", - "sha256:99d68940c452e07726485a15fef43f12f8ae6c0c5b391bcba76065d4527fb85d" - ], - "markers": "python_full_version >= '3.7.2'", - "version": "==8.2.0" - }, - "werkzeug": { - "hashes": [ - "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43", - "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.0.1" - }, - "wrapt": { - "hashes": [ - "sha256:02fce1852f755f44f95af51f69d22e45080102e9d00258053b79367d07af39c0", - "sha256:077ff0d1f9d9e4ce6476c1a924a3332452c1406e59d90a2cf24aeb29eeac9420", - "sha256:078e2a1a86544e644a68422f881c48b84fef6d18f8c7a957ffd3f2e0a74a0d4a", - "sha256:0970ddb69bba00670e58955f8019bec4a42d1785db3faa043c33d81de2bf843c", - "sha256:1286eb30261894e4c70d124d44b7fd07825340869945c79d05bda53a40caa079", - "sha256:21f6d9a0d5b3a207cdf7acf8e58d7d13d463e639f0c7e01d82cdb671e6cb7923", - "sha256:230ae493696a371f1dbffaad3dafbb742a4d27a0afd2b1aecebe52b740167e7f", - "sha256:26458da5653aa5b3d8dc8b24192f574a58984c749401f98fff994d41d3f08da1", - "sha256:2cf56d0e237280baed46f0b5316661da892565ff58309d4d2ed7dba763d984b8", - "sha256:2e51de54d4fb8fb50d6ee8327f9828306a959ae394d3e01a1ba8b2f937747d86", - "sha256:2e95b9f272a61060f36f044c0cd44d37a7c096426e781a0c4eef05b72f4d9d80", - "sha256:2fbfbca668dd15b744418265a9607baa970c347eefd0db6a518aaf0cfbd153c0", - "sha256:38adf7198f8f154502883242f9fe7333ab05a5b02de7d83aa2d88ea621f13364", - "sha256:3a8564f283394634a7a7054b7983e47dbf39c07712d7b177b37e03f2467a024e", - "sha256:3abbe948c3cbde2689370a262a8d04e32ec2dd4f27103669a45c6929bcdbfe7c", - "sha256:3bbe623731d03b186b3d6b0d6f51865bf598587c38d6f7b0be2e27414f7f214e", - "sha256:40737a081d7497efea35ab9304b829b857f21558acfc7b3272f908d33b0d9d4c", - "sha256:41d07d029dd4157ae27beab04d22b8e261eddfc6ecd64ff7000b10dc8b3a5727", - "sha256:46ed616d5fb42f98630ed70c3529541408166c22cdfd4540b88d5f21006b0eff", - "sha256:493d389a2b63c88ad56cdc35d0fa5752daac56ca755805b1b0c530f785767d5e", - "sha256:4ff0d20f2e670800d3ed2b220d40984162089a6e2c9646fdb09b85e6f9a8fc29", - "sha256:54accd4b8bc202966bafafd16e69da9d5640ff92389d33d28555c5fd4f25ccb7", - "sha256:56374914b132c702aa9aa9959c550004b8847148f95e1b824772d453ac204a72", - "sha256:578383d740457fa790fdf85e6d346fda1416a40549fe8db08e5e9bd281c6a475", - "sha256:58d7a75d731e8c63614222bcb21dd992b4ab01a399f1f09dd82af17bbfc2368a", - "sha256:5c5aa28df055697d7c37d2099a7bc09f559d5053c3349b1ad0c39000e611d317", - "sha256:5fc8e02f5984a55d2c653f5fea93531e9836abbd84342c1d1e17abc4a15084c2", - "sha256:63424c681923b9f3bfbc5e3205aafe790904053d42ddcc08542181a30a7a51bd", - "sha256:64b1df0f83706b4ef4cfb4fb0e4c2669100fd7ecacfb59e091fad300d4e04640", - "sha256:74934ebd71950e3db69960a7da29204f89624dde411afbfb3b4858c1409b1e98", - "sha256:75669d77bb2c071333417617a235324a1618dba66f82a750362eccbe5b61d248", - "sha256:75760a47c06b5974aa5e01949bf7e66d2af4d08cb8c1d6516af5e39595397f5e", - "sha256:76407ab327158c510f44ded207e2f76b657303e17cb7a572ffe2f5a8a48aa04d", - "sha256:76e9c727a874b4856d11a32fb0b389afc61ce8aaf281ada613713ddeadd1cfec", - "sha256:77d4c1b881076c3ba173484dfa53d3582c1c8ff1f914c6461ab70c8428b796c1", - "sha256:780c82a41dc493b62fc5884fb1d3a3b81106642c5c5c78d6a0d4cbe96d62ba7e", - "sha256:7dc0713bf81287a00516ef43137273b23ee414fe41a3c14be10dd95ed98a2df9", - "sha256:7eebcdbe3677e58dd4c0e03b4f2cfa346ed4049687d839adad68cc38bb559c92", - "sha256:896689fddba4f23ef7c718279e42f8834041a21342d95e56922e1c10c0cc7afb", - "sha256:96177eb5645b1c6985f5c11d03fc2dbda9ad24ec0f3a46dcce91445747e15094", - "sha256:96e25c8603a155559231c19c0349245eeb4ac0096fe3c1d0be5c47e075bd4f46", - "sha256:9d37ac69edc5614b90516807de32d08cb8e7b12260a285ee330955604ed9dd29", - "sha256:9ed6aa0726b9b60911f4aed8ec5b8dd7bf3491476015819f56473ffaef8959bd", - "sha256:a487f72a25904e2b4bbc0817ce7a8de94363bd7e79890510174da9d901c38705", - "sha256:a4cbb9ff5795cd66f0066bdf5947f170f5d63a9274f99bdbca02fd973adcf2a8", - "sha256:a74d56552ddbde46c246b5b89199cb3fd182f9c346c784e1a93e4dc3f5ec9975", - "sha256:a89ce3fd220ff144bd9d54da333ec0de0399b52c9ac3d2ce34b569cf1a5748fb", - "sha256:aa98b980245a21a8bd6cd507e6dac545245ec1773058f4707d6619b242293f67", - "sha256:abd52a09d03adf9c763d706df707c343293d5d106aea53483e0ec8d9e310ad5e", - "sha256:abd8f36c99512755b8456047b7be10372fca271bf1467a1caa88db991e7c421b", - "sha256:af5bd9ccb188f6a5fdda9f1f09d9f4c86cc8a539bd48a0bfdc97723970348418", - "sha256:b02f21c1e2074943312d03d243ac4388319f2456576b2c6023041c4d57cd7019", - "sha256:b06fa97478a5f478fb05e1980980a7cdf2712015493b44d0c87606c1513ed5b1", - "sha256:b0724f05c396b0a4c36a3226c31648385deb6a65d8992644c12a4963c70326ba", - "sha256:b130fe77361d6771ecf5a219d8e0817d61b236b7d8b37cc045172e574ed219e6", - "sha256:b56d5519e470d3f2fe4aa7585f0632b060d532d0696c5bdfb5e8319e1d0f69a2", - "sha256:b67b819628e3b748fd3c2192c15fb951f549d0f47c0449af0764d7647302fda3", - "sha256:ba1711cda2d30634a7e452fc79eabcadaffedf241ff206db2ee93dd2c89a60e7", - "sha256:bbeccb1aa40ab88cd29e6c7d8585582c99548f55f9b2581dfc5ba68c59a85752", - "sha256:bd84395aab8e4d36263cd1b9308cd504f6cf713b7d6d3ce25ea55670baec5416", - "sha256:c99f4309f5145b93eca6e35ac1a988f0dc0a7ccf9ccdcd78d3c0adf57224e62f", - "sha256:ca1cccf838cd28d5a0883b342474c630ac48cac5df0ee6eacc9c7290f76b11c1", - "sha256:cd525e0e52a5ff16653a3fc9e3dd827981917d34996600bbc34c05d048ca35cc", - "sha256:cdb4f085756c96a3af04e6eca7f08b1345e94b53af8921b25c72f096e704e145", - "sha256:ce42618f67741d4697684e501ef02f29e758a123aa2d669e2d964ff734ee00ee", - "sha256:d06730c6aed78cee4126234cf2d071e01b44b915e725a6cb439a879ec9754a3a", - "sha256:d5fe3e099cf07d0fb5a1e23d399e5d4d1ca3e6dfcbe5c8570ccff3e9208274f7", - "sha256:d6bcbfc99f55655c3d93feb7ef3800bd5bbe963a755687cbf1f490a71fb7794b", - "sha256:d787272ed958a05b2c86311d3a4135d3c2aeea4fc655705f074130aa57d71653", - "sha256:e169e957c33576f47e21864cf3fc9ff47c223a4ebca8960079b8bd36cb014fd0", - "sha256:e20076a211cd6f9b44a6be58f7eeafa7ab5720eb796975d0c03f05b47d89eb90", - "sha256:e826aadda3cae59295b95343db8f3d965fb31059da7de01ee8d1c40a60398b29", - "sha256:eef4d64c650f33347c1f9266fa5ae001440b232ad9b98f1f43dfe7a79435c0a6", - "sha256:f2e69b3ed24544b0d3dbe2c5c0ba5153ce50dcebb576fdc4696d52aa22db6034", - "sha256:f87ec75864c37c4c6cb908d282e1969e79763e0d9becdfe9fe5473b7bb1e5f09", - "sha256:fbec11614dba0424ca72f4e8ba3c420dba07b4a7c206c8c8e4e73f2e98f4c559", - "sha256:fd69666217b62fa5d7c6aa88e507493a34dec4fa20c5bd925e4bc12fce586639" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==1.15.0" - }, - "zeroconf": { - "hashes": [ - "sha256:055355de58edb58708471f76e6ad486f4796ec6d2c7cb97c16b295a00856e789", - "sha256:71ebebbc35d4703d72a3635127626d511d5ee7da4b3946430eac2cdadca81b12", - "sha256:95b4fd11e0498ec8e38d0a0ff50e3bd7250be2b02d131ef8501b12cd3a499d66" - ], - "version": "==0.33.4" - }, - "zipp": { - "hashes": [ - "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", - "sha256:3ee3c5a49d3e276dcf8e83f5f11e93dab67f20b843e7ab2dee428c857d11b3b3", - "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556" - ], - "markers": "python_version >= '3.7'", - "version": "==3.15.0" - } - } -} diff --git a/README.md b/README.md index 234c0c87..b64dbc86 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ To set up a development version of the software (most likely using emulated came ### Run the server manually on a Raspberry Pi ``` cd /var/openflexure -sudo -u openflexure-ws PATH="/var/openflexure/application/openflexure-microscope-server/.venv/bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" /var/openflexure/application/openflexure-microscope-server/.venv/bin/uvicorn --reload --reload-dir=./application --host 0.0.0.0 --port 5000 openflexure_microscope_server.server:app +sudo -u openflexure-ws PATH="/var/openflexure/application/openflexure-microscope-server/.venv/bin/:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" /var/openflexure/application/openflexure-microscope-server/.venv/bin/openflexure-microscope-serve -c /var/openflexure/settings/ofm_config.json --host 0.0.0.0 --port 5000 ``` @@ -107,70 +107,36 @@ sudo -u openflexure-ws PATH="/var/openflexure/application/openflexure-microscope ## Formatting, linting, and tests All of the commands below assume that you are running in the OFM virtual environment, i.e. you have run `ofm activate` on an OpenFlexure SD card, or `source .venv/bin/activate` on Linux, or `.venv/Scripts/activate`on Windows. -**Before committing** you should auto-format your code: +**Before committing** you should lint and auto-format your code: -* To auto-format the Python code run `poe format` +* To lint run `ruff check` +* To auto-format the Python code run `ruff format` * To auto-format the Javascript code, run * `cd webapp` * `npm run lint` -**Before submitting a merge request/merging** please auto-format your code and also run the quality checks (linting, static analysis, and unit tests) +**Before merging** please auto-format your code and also run the quality checks (linting, static analysis, and unit tests) -* To auto-format and type-check the Python code run `poe check` -* To auto-format the Javascript code, run - * `cd webapp` - * `npm run lint` +* All commands above +* `mypy src` (currently fails) +* `pytest` ### Details -We use several code analysis and formatting libraries in this project. **Please run all of these before submitting a merge request.** - -Our CI will check each of these automatically, so ensuring they pass locally will save you time. - -* **Black** - Code formatting with minimal configuration. - * While sometimes it's not perfect, its fine 90% of the time and prevents arguments about formatting. - * Automatically formats your code - * This will rewrite your files in-place, so if you want to be able to revert, make a backup first! - * `poe black` -* **Pylint** - Static code analysis - * Analyses your code, failing if issues are detected. - * We've disabled some less severe warnings, so _if anything fails your merge request will be blocked_ - * `poe pylint` -* **Mypy** - Type checking - * Analyses your type hints and annotations to flag up potential bugs - * Where possible, use type hints in your code. Even if dependencies don't support it, it'll help identify issues. - * `poe mypy` -* **Pytest** - Unit testing - * While unit testing is of limited use due to our dependence on real hardware, some simple isolated functions can (and should) be unit tested. - * `poe test` - -Though not in the CI, our `format` script also runs isort: - -* **Isort** - Import sorting - * Automatically organises your imports to stop things getting out of hand - * `poe isort` +We use several code analysis and formatting libraries in this project. Our CI will check each of these automatically, so ensuring they pass locally will save you time. Currently `ruff` is used for linting/formatting and `pytest` for unit tests. `mypy` will be enabled once the codebase is ready. ## Python environment, build, and dependencies -As of `2.10.0b0` we have switched to using `pipenv` for managing dependencies, and a standard `setuptools` based build system. See "local installation" above for instructions on how to install the project. Earlier versions of the project used `poetry` but we moved away because of [difficulties](https://gitlab.com/openflexure/openflexure-microscope-server/-/merge_requests/124) getting it to work both on the Raspberry Pi and in our CI pipeline. The new arrangement for configuration files is: - -* Dependencies, and dev-dependencies, are specified in `setup.py` in the usual way (using `install_requires` and `extra_requires[dev]`). -* Package metadata is specified in `setup.py` in the usual way. -* `pyproject.toml` is retained, but *no longer includes package metadata or dependencies* and does not have a `[tool.poetry]` table. It does define the build system as per PEP517, which is `setuptools`, and it also contains settings for `black`, `poe` and `isort`. -* `Pipfile` is very minimal, and only declares dependencies on the current module, i.e. the dependencies declared in `setup.py`. It also specifies the Python version. This allows us to single-source dependency information from `setup.py` but use the dependency resolution/locking functionality of `pipenv` -* `Pipfile.lock` locks the dependency versions in the same way as `poetry.lock` used to, i.e. it specifies exact versions of everything, derived from the looser specifications in `setup.py`. -* Dependency resolution is a pain, in part because compiling packages like scipy and numpy from source on a Pi is slow and unreliable. To avoid this, we usually lock the dependencies on a Raspberry Pi, using: - `PIP_ONLY_BINARY="numpy, scipy, matplotlib" pipenv lock --dev` - This forces the use of wheels only, and thus restricts us to things that built OK on Piwheels. The resulting Pipfile.lock should then work nicely on other Raspberry Pis. -* **The Pipfile we use is now Raspberry Pi-specific** so if you attempt to use it on other platforms it will probably fail. This is because we've removed `pypi` to avoid hash conflicts. The CI now runs on a Raspberry Pi image, so that the tests use a similar environment to our deployment. Currently, changing the `[source]` entry back to PyPi will allow you to re-lock the dependencies on other platforms. In the future we may need to maintain two Pipfiles and two Pipfile.lock files. +As of `v3` we specify dependencies in `pyproject.toml`. These are not currently frozen due to difficulties matching versions on different platforms. On Raspberry Pi, it's best to specify `--only-binary=:all:` to ensure libraries like `numpy` and `scipy` are not compiled from source (which takes many hours, and/or fails). Pinning dependencies with `requirements.txt` and/or `requirements.in` may happen in the future. ## Creating releases * Update the application's internal version number - * Edit `setup.py` to update the version number - * Git commit and git push -* Create a new version tag on GitLab (e.g. `v2.6.11`) + * Edit `pyproject.toml` to update the version number +* Update the changelog +* Git commit and git push +* Create a new version tag on GitLab (e.g. `v2.6.11`) that matches the `pyproject.toml` version number. * Make sure you prefix a lower case 'v', otherwise it won't be recognised as a release! * This tagging will trigger a CI pipeline that builds the JS client, tarballs up the server, and deploys it * Note: This also updates the build server's nginx redirect map file @@ -180,10 +146,8 @@ As of `2.10.0b0` we have switched to using `pipenv` for managing dependencies, a * `npm install -g conventional-changelog-cli` * `npx conventional-changelog -r 1 --config ./changelog.config.js -i CHANGELOG.md -s` -## Microscope extensions +## Adding functionality -The Microscope module, and Flask app, both support plugins for extending lower-level functionality not well suited to web API calls. The current documentation can be found [here](https://openflexure-microscope-software.readthedocs.io/en/latest/plugins.html). -If you want to add functions to the microscope software, this is probably the best mechanism to use if it works for you. +The microscope comprises a number of `Thing` instances, which provide actions and properties to implement hardware control and software features. These may be imported from any Python module, using `ofm_config.json`. See the LabThings-FastAPI documentation for how to create a `Thing`. - -``` +Currently, the camera and stage classes are provided by external libraries, `labthings-picamera2` and `labthings-sangaboard`. Replacing these is the easiest way to use alternative hardware: interfaces are defined in `openflexure_microscope_server.things.camera` and `openflexure_microscope_server.things.stage` respectively. diff --git a/pyproject.toml b/pyproject.toml index 05ec3ba5..7e2d85e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,11 @@ dependencies = [ [project.optional-dependencies] dev = [ "labthings-fastapi[dev]", + "ruff", + "mypy[reports]", + "mypy-gitlab-code-quality", +# "pytest-gitlab-code-quality", # pytest version constraint clashes with labthings + "pytest", ] pi = [ "labthings-picamera2 == 0.0.1-dev2", @@ -58,49 +63,13 @@ build-backend = "setuptools.build_meta" # don't believe it's critical at present, because we don't use # the Python package - we distribute a tarball directly. -[tool.black] -exclude = '(\.eggs|\.git|\.venv|\venv|node_modules/)' - -[tool.isort] -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true -ensure_newline_before_comments = true -line_length = 88 - -[tool.pylint.'MESSAGES CONTROL'] -# W1203 warns about using f strings in logging statements. -# I'm really not concerned about the performance implications of this, -# particularly as we often have the log level set quite high. I think -# for our current purposes, using f strings in logging statements is -# more readable and thus a good idea in many places - I've disabled -# this warning code globally for that reason. -disable = "fixme,C,R,W1203" -max-line-length = 88 - [tool.mypy] plugins = ["pydantic.mypy"] [tool.ruff] target-version = "py39" -[tool.poe.executor] -type = "virtualenv" -location = ".venv" - -[tool.poe.tasks] -black = "black ." -black_check = "black --check ." -ruff = "ruff . --fix" -ruff_check = "ruff ." -isort = "isort openflexure_microscope" -pylint = "pylint openflexure_microscope" -mypy = "mypy --cobertura-xml-report openflexure_microscope openflexure_microscope" -test = "pytest . --junitxml=pytest_report.xml" -serve = "python -m openflexure_microscope.api.app" - -format = ["black", "isort", "ruff"] -lint = ["ruff_check", "pylint", "mypy"] - -check = ["format", "lint", "test"] \ No newline at end of file +[tool.pytest.ini_options] +addopts = [ + "--import-mode=importlib", +] diff --git a/scripts/zenodo/zenodo.py b/scripts/zenodo/zenodo.py index 3109ec5f..943a50df 100644 --- a/scripts/zenodo/zenodo.py +++ b/scripts/zenodo/zenodo.py @@ -5,6 +5,7 @@ Copyright (c) 2018 German Aerospace Center (DLR). All rights reserved. SPDX-License-Identifier: MIT-DLR """ + import json import os @@ -21,7 +22,7 @@ class Zenodo: self.zenodo_url = "https://zenodo.org/api/deposit/depositions" def create_new_deposit(self): - """ Creates a new (unpublished) Zenodo deposit and return its deposition ID. """ + """Creates a new (unpublished) Zenodo deposit and return its deposition ID.""" headers = {"Content-Type": "application/json"} r = requests.post( @@ -35,7 +36,7 @@ class Zenodo: return r.json() def set_metadata(self, deposition_id, metadata): - """ Sets the given metadata for the specified deposit. """ + """Sets the given metadata for the specified deposit.""" headers = {"Content-Type": "application/json"} r = requests.put( @@ -48,7 +49,7 @@ class Zenodo: print(r.json()) def upload_file(self, deposition_id, file_path): - """ Uploads a new file for the given deposit. """ + """Uploads a new file for the given deposit.""" file_name = os.path.basename(file_path) data = {"filename": file_name} @@ -63,7 +64,7 @@ class Zenodo: print(r.json()) def publish_deposit(self, deposition_id): - """ Publishes the given deposit. BEWARE: It is now visible to all!!! """ + """Publishes the given deposit. BEWARE: It is now visible to all!!!""" r = requests.post( self.zenodo_url + "/{}/actions/publish".format(deposition_id), @@ -73,7 +74,7 @@ class Zenodo: print(r.json()) def create_new_version(self, deposition_id): - """ Creates a new version of an already published deposit. """ + """Creates a new version of an already published deposit.""" r = requests.post( self.zenodo_url + "/{}/actions/newversion".format(deposition_id), @@ -84,7 +85,7 @@ class Zenodo: return os.path.basename(r.json()["links"]["latest_draft"]) def remove_all_files(self, deposition_id): - """ Removes all uploaded files of a unpublished deposit. """ + """Removes all uploaded files of a unpublished deposit.""" r = requests.get( self.zenodo_url + "/{}/files".format(deposition_id), diff --git a/src/openflexure_microscope_server/logging.py b/src/openflexure_microscope_server/logging.py index 429355e6..84c253c5 100644 --- a/src/openflexure_microscope_server/logging.py +++ b/src/openflexure_microscope_server/logging.py @@ -4,9 +4,10 @@ import os from fastapi.responses import FileResponse, PlainTextResponse -OFM_LOG_FOLDER = "/var/openflexure/logs/" +OFM_LOG_FOLDER = "/var/openflexure/logs/" OFM_LOG_FILE = os.path.join(OFM_LOG_FOLDER, "openflexure_microscope.log") + def configure_logging(): root_logger = logging.getLogger() root_logger.setLevel(logging.INFO) @@ -14,15 +15,13 @@ def configure_logging(): if not os.path.exists(OFM_LOG_FOLDER): os.makedirs(OFM_LOG_FOLDER) handler = RotatingFileHandler( - filename = OFM_LOG_FILE, - mode = "a", - maxBytes = 1000000, - backupCount = 10, + filename=OFM_LOG_FILE, + mode="a", + maxBytes=1000000, + backupCount=10, ) handler.setFormatter( - logging.Formatter( - "[%(asctime)s] [%(levelname)s] %(message)s" - ) + logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s") ) root_logger.addHandler(handler) except PermissionError as e: diff --git a/src/openflexure_microscope_server/server/__init__.py b/src/openflexure_microscope_server/server/__init__.py index e3f427a5..1aa0a4cf 100644 --- a/src/openflexure_microscope_server/server/__init__.py +++ b/src/openflexure_microscope_server/server/__init__.py @@ -43,5 +43,3 @@ def serve_from_cli(argv: Optional[list[str]] = None): uvicorn.run(app, host=args.host, port=args.port) else: raise e - - diff --git a/src/openflexure_microscope_server/server/legacy_api.py b/src/openflexure_microscope_server/server/legacy_api.py index 509d30b3..ee2ebe06 100644 --- a/src/openflexure_microscope_server/server/legacy_api.py +++ b/src/openflexure_microscope_server/server/legacy_api.py @@ -5,6 +5,7 @@ from socket import gethostname def add_v2_endpoints(thing_server: ThingServer): app = thing_server.app + # TODO: update openflexure connect to make this unnecessary!! # The endpoints below fool OpenFlexure Connect into thinking we are a # v2 microscope, so we show up correctly. @@ -15,7 +16,7 @@ def add_v2_endpoints(thing_server: ThingServer): fake_routes = [ "/api/v2/", "/api/v2/streams/snapshot", - "/api/v2/instrument/settings/name" + "/api/v2/instrument/settings/name", ] return {url: {"url": url, "methods": ["GET"]} for url in fake_routes} diff --git a/src/openflexure_microscope_server/server/serve_static_files.py b/src/openflexure_microscope_server/server/serve_static_files.py index 8653576b..5855d295 100644 --- a/src/openflexure_microscope_server/server/serve_static_files.py +++ b/src/openflexure_microscope_server/server/serve_static_files.py @@ -4,22 +4,23 @@ from fastapi import FastAPI import os import pathlib + def add_static_file(app: FastAPI, fname: str, folder: str): print(f"Adding route for /{fname}") - p=os.path.join(folder, fname) - app.get( - f"/{fname}", - response_class=FileResponse, - include_in_schema=False - )(lambda: FileResponse(p)) + p = os.path.join(folder, fname) + app.get(f"/{fname}", response_class=FileResponse, include_in_schema=False)( + lambda: FileResponse(p) + ) + def add_static_files(app: FastAPI): - #with importlib.resources.as_file(openflexure_microscope_server) as p: + # with importlib.resources.as_file(openflexure_microscope_server) as p: # static_path = p.join("/static/") - #TODO: don't hard code this! + # TODO: don't hard code this! search_paths = [ "/var/openflexure/application/openflexure-microscope-server/src/openflexure_microscope_server/static", - pathlib.Path().absolute() / "application/openflexure-microscope-server/src/openflexure_microscope_server/static", + pathlib.Path().absolute() + / "application/openflexure-microscope-server/src/openflexure_microscope_server/static", ] if __file__: search_paths.append(pathlib.Path(__file__).parent.parent / "static") @@ -34,6 +35,7 @@ def add_static_files(app: FastAPI): @app.get("/", response_class=RedirectResponse) async def redirect_fastapi(): return "/index.html" + for fname in os.listdir(static_path): fpath = os.path.join(static_path, fname) if os.path.isfile(fpath): diff --git a/src/openflexure_microscope_server/things/auto_recentre_stage.py b/src/openflexure_microscope_server/things/auto_recentre_stage.py index f0dfcf74..910a44cf 100644 --- a/src/openflexure_microscope_server/things/auto_recentre_stage.py +++ b/src/openflexure_microscope_server/things/auto_recentre_stage.py @@ -11,6 +11,7 @@ from openflexure_microscope_server.things.camera_stage_mapping import CameraStag CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") + class RecentringThing(Thing): @thing_action def recentre( @@ -27,7 +28,7 @@ class RecentringThing(Thing): corresponds to the centre of the stage. This exploits the fact that the OpenFlexure stage moves in an arc, i.e. its height will vary with X and Y. The point where the variation - of Z with X and Y motion is smallest is the centre of its + of Z with X and Y motion is smallest is the centre of its XY travel. This routine moves in X and Y, monitoring the Z value of the focal plane, and attempts to find the point where Z does not vary with X and Y, which is where it stops. diff --git a/src/openflexure_microscope_server/things/autofocus.py b/src/openflexure_microscope_server/things/autofocus.py index 9f2dae2f..23c5560f 100644 --- a/src/openflexure_microscope_server/things/autofocus.py +++ b/src/openflexure_microscope_server/things/autofocus.py @@ -5,6 +5,7 @@ camera together to perform an autofocus routine. See repository root for licensing information. """ + from __future__ import annotations from contextlib import contextmanager import logging @@ -14,8 +15,6 @@ from typing import Annotated, Mapping, Optional, Sequence from fastapi import Depends from labthings_fastapi.thing import Thing -from labthings_fastapi.dependencies.raw_thing import raw_thing_dependency -from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.dependencies.blocking_portal import BlockingPortal from labthings_fastapi.decorators import thing_action from labthings_fastapi.types.numpy import NDArray @@ -28,8 +27,10 @@ from pydantic import BaseModel ### Autofocus utilities + class JPEGSharpnessMonitor: __globals__ = globals() # Required for FastAPI dependency + def __init__(self, stage: Stage, camera: Camera, portal: BlockingPortal): self.camera = camera self.stage = stage @@ -50,7 +51,7 @@ class JPEGSharpnessMonitor: self.jpeg_sizes.append(len(frame)) if not self.running: break - + @contextmanager def run(self): """Context manager, during which we will monitor sharpness from the camera""" @@ -75,7 +76,7 @@ class JPEGSharpnessMonitor: # Index of the data for this movement data_index: int = len(self.stage_positions) - 2 # Final z position after move - final_z_position: int = self.stage_positions[-1]['z'] + final_z_position: int = self.stage_positions[-1]["z"] return data_index, final_z_position def move_data( @@ -86,12 +87,10 @@ class JPEGSharpnessMonitor: istop = istart + 2 jpeg_times: np.ndarray = np.array(self.jpeg_times) jpeg_sizes: np.ndarray = np.array(self.jpeg_sizes) - stage_times: np.ndarray = np.array(self.stage_times)[ - istart:istop - ] + stage_times: np.ndarray = np.array(self.stage_times)[istart:istop] stage_zs: np.ndarray = np.array( - [p['z'] for p in self.stage_positions[istart:istop]] - ) + [p["z"] for p in self.stage_positions[istart:istop]] + ) try: start: int = int(np.argmax(jpeg_times > stage_times[0])) stop: int = int(np.argmax(jpeg_times > stage_times[1])) @@ -130,6 +129,7 @@ class JPEGSharpnessMonitor: SharpnessMonitorDep = Annotated[JPEGSharpnessMonitor, Depends()] + class SharpnessDataArrays(BaseModel): jpeg_times: NDArray jpeg_sizes: NDArray @@ -142,18 +142,18 @@ class AutofocusThing(Thing): def fast_autofocus( self, m: SharpnessMonitorDep, - dz: int=2000, - start: str='centre', + dz: int = 2000, + start: str = "centre", ) -> SharpnessDataArrays: """Sweep the stage up and down, then move to the sharpest point - + This method will will move down by dz/2, sweep up by dz, and then evaluate the position where the image was sharpest. We'll then move back down, and finally up to the sharpest point. """ with m.run(): # Move to (-dz / 2) - if start == 'centre': + if start == "centre": m.focus_rel(-dz / 2) # Move to dz while monitoring sharpness # i: Sharpness monitor index for this move @@ -167,16 +167,16 @@ class AutofocusThing(Thing): m.focus_rel(fz - z) # Return all focus data return m.data_dict() - + @thing_action def move_and_measure( self, m: SharpnessMonitorDep, dz: Sequence[int], - wait: float=0, + wait: float = 0, ) -> SharpnessDataArrays: """Make a move (or a series of moves) and monitor sharpness - + This method will will make a series of relative moves in z, and return the sharpness (JPEG size) vs time, along with timestamps for the moves. This can be used to calibrate autofocus. @@ -195,9 +195,11 @@ class AutofocusThing(Thing): return m.data_dict() @thing_action - def looping_autofocus(self, stage: Stage, m: SharpnessMonitorDep, dz=2000, start='centre'): + def looping_autofocus( + self, stage: Stage, m: SharpnessMonitorDep, dz=2000, start="centre" + ): """Repeatedly autofocus the stage until it looks focused. - + This action will run the `fast_autofocus` action until it settles on a point in the middle 3/5 of its range. Such logic can be helpful if the microscope is close to focus, but not quite within `dz/2`. It will attempt to autofocus @@ -209,14 +211,13 @@ class AutofocusThing(Thing): with m.run(): while repeat and attempts < 10: - - if start == 'centre': - stage.move_relative(x = 0, y = 0, z = -(backlash + dz / 2)) - stage.move_relative(x = 0, y = 0, z = backlash) + if start == "centre": + stage.move_relative(x=0, y=0, z=-(backlash + dz / 2)) + stage.move_relative(x=0, y=0, z=backlash) i, z = m.focus_rel(dz, block_cancellation=True) _, heights, sizes = m.move_data(i) - + peak_height = heights[np.argmax(sizes)] height_min = np.min(heights) height_max = np.max(heights) @@ -226,25 +227,27 @@ class AutofocusThing(Thing): or height_max - peak_height < dz / 5 ): attempts += 1 - start = 'centre' - stage.move_absolute(z = peak_height-backlash) - stage.move_absolute(z = peak_height) + start = "centre" + stage.move_absolute(z=peak_height - backlash) + stage.move_absolute(z=peak_height) else: repeat = False - stage.move_relative(x = 0, y = 0, z = -(dz+backlash)) - stage.move_absolute(z = peak_height) + stage.move_relative(x=0, y=0, z=-(dz + backlash)) + stage.move_absolute(z=peak_height) return heights.tolist(), sizes.tolist() @thing_action - def verify_focus_sharpness(self, sweep_sizes: list, camera: WrappedCamera, threshold: float = 0.95): - '''Take the sharpness curve of the autofocus, and the size of the current frame + def verify_focus_sharpness( + self, sweep_sizes: list, camera: WrappedCamera, threshold: float = 0.95 + ): + """Take the sharpness curve of the autofocus, and the size of the current frame to see if the autofocus completed successfully. Returns True if current sharpness - is within "leniency" number of frames from the peak of the autofocus''' + is within "leniency" number of frames from the peak of the autofocus""" - current_sharpness = camera.grab_jpeg_size(stream_name='lores') + current_sharpness = camera.grab_jpeg_size(stream_name="lores") peak = np.max(sweep_sizes) base = np.min(sweep_sizes) cutoff = threshold * (peak - base) - return current_sharpness >= base + cutoff \ No newline at end of file + return current_sharpness >= base + cutoff diff --git a/src/openflexure_microscope_server/things/camera/__init__.py b/src/openflexure_microscope_server/things/camera/__init__.py index 62dc0709..48a9b21d 100644 --- a/src/openflexure_microscope_server/things/camera/__init__.py +++ b/src/openflexure_microscope_server/things/camera/__init__.py @@ -5,6 +5,7 @@ should enabe the server to work. See repository root for licensing information. """ + from __future__ import annotations import logging from typing import Literal, Protocol, runtime_checkable @@ -23,15 +24,14 @@ from labthings_fastapi.types.numpy import NDArray class JPEGBlob(Blob): media_type: str = "image/jpeg" + @runtime_checkable class CameraProtocol(Protocol): """A Thing representing a camera""" - def __enter__(self) -> None: - ... - - def __exit__(self, _exc_type, _exc_value, _traceback) -> None: - ... + def __enter__(self) -> None: ... + + def __exit__(self, _exc_type, _exc_value, _traceback) -> None: ... @property def stream_active(self) -> bool: @@ -45,9 +45,8 @@ class CameraProtocol(Protocol): def capture_array( self, resolution: Literal["lores", "main", "full"] = "main", - ) -> NDArray: - ... - + ) -> NDArray: ... + def capture_jpeg( self, metadata_getter: GetThingStates, @@ -78,9 +77,10 @@ class CameraProtocol(Protocol): """Acquire one image from the preview stream and return its size""" ... + class BaseCamera(Thing): """A Thing representing a camera - + This is a concrete base class for `Thing`s implementing the `CameraProtocol`. It provides the stream descriptors and actions to grab from the stream. """ @@ -110,12 +110,16 @@ class BaseCamera(Thing): stream (either "main" for the preview stream, or "lores" for the low resolution preview). No metadata is returned. """ - logging.info(f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) starting") + logging.info( + f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) starting" + ) stream = ( self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream ) frame = portal.call(stream.grab_frame) - logging.info(f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) got frame") + logging.info( + f"StreamingPiCamera2.grab_jpeg(stream_name={stream_name}) got frame" + ) return JPEGBlob.from_bytes(frame) @thing_action @@ -129,12 +133,12 @@ class BaseCamera(Thing): self.lores_mjpeg_stream if stream_name == "lores" else self.mjpeg_stream ) return portal.call(stream.next_frame_size) - + class CameraStub(BaseCamera): """A stub for a camera, to allow dependencies - - + + As of LabThings-FastAPI 0.0.7, we can't make a thing client dependency based on a protocol, because the protocol is not a Thing, and its methods/properties are not decorated as Affordances. This stub class @@ -142,9 +146,10 @@ class CameraStub(BaseCamera): This stub class should be used for dependencies on the CameraProtocol. """ + def __enter__(self) -> None: raise NotImplementedError("Cameras must not inherit from CameraStub") - + def __exit__(self, _exc_type, _exc_value, _traceback) -> None: raise NotImplementedError("Cameras must not inherit from CameraStub") @@ -164,7 +169,7 @@ class CameraStub(BaseCamera): resolution: Literal["lores", "main", "full"] = "main", ) -> NDArray: raise NotImplementedError("Cameras must not inherit from CameraStub") - + @thing_action def capture_jpeg( self, diff --git a/src/openflexure_microscope_server/things/camera/opencv.py b/src/openflexure_microscope_server/things/camera/opencv.py index c8d2b282..3b313485 100644 --- a/src/openflexure_microscope_server/things/camera/opencv.py +++ b/src/openflexure_microscope_server/things/camera/opencv.py @@ -1,10 +1,11 @@ """OpenFlexure Microscope OpenCV Camera -This module defines a camera Thing that uses OpenCV's +This module defines a camera Thing that uses OpenCV's `VideoCapture`. See repository root for licensing information. """ + from __future__ import annotations import io import json @@ -26,7 +27,8 @@ from . import BaseCamera, JPEGBlob class OpenCVCamera(BaseCamera): """A Thing representing an OpenCV camera""" - def __init__(self, camera_index: int=0): + + def __init__(self, camera_index: int = 0): self.camera_index = camera_index self._capture_thread: Optional[Thread] = None self._capture_enabled = False @@ -37,7 +39,7 @@ class OpenCVCamera(BaseCamera): self._capture_thread = Thread(target=self._capture_frames) self._capture_thread.start() return self - + def __exit__(self, _exc_type, _exc_value, _traceback): if self.stream_active: self._capture_enabled = False @@ -50,6 +52,7 @@ class OpenCVCamera(BaseCamera): if self._capture_enabled and self._capture_thread: return self._capture_thread.is_alive() return False + mjpeg_stream = MJPEGStreamDescriptor() lores_mjpeg_stream = MJPEGStreamDescriptor() @@ -58,11 +61,15 @@ class OpenCVCamera(BaseCamera): while self._capture_enabled: ret, frame = self.cap.read() if not ret: - logging.error(f"Failed to capture frame from camera {self.camera_index}") + logging.error( + f"Failed to capture frame from camera {self.camera_index}" + ) break jpeg = cv2.imencode(".jpg", frame)[1].tobytes() self.mjpeg_stream.add_frame(jpeg, portal) - jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[1].tobytes() + jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[ + 1 + ].tobytes() self.lores_mjpeg_stream.add_frame(jpeg_lores, portal) @thing_action @@ -78,9 +85,11 @@ class OpenCVCamera(BaseCamera): """ ret, frame = self.cap.read() if not ret: - raise RuntimeError(f"Failed to capture frame from camera {self.camera_index}") + raise RuntimeError( + f"Failed to capture frame from camera {self.camera_index}" + ) return frame - + @thing_action def capture_jpeg( self, @@ -95,7 +104,9 @@ class OpenCVCamera(BaseCamera): jpeg = cv2.imencode(".jpg", frame)[1].tobytes() exif_dict = { "Exif": { - piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode("utf-8") + piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode( + "utf-8" + ) }, "GPS": {}, "Interop": {}, diff --git a/src/openflexure_microscope_server/things/camera/simulation.py b/src/openflexure_microscope_server/things/camera/simulation.py index dd169283..f0003019 100644 --- a/src/openflexure_microscope_server/things/camera/simulation.py +++ b/src/openflexure_microscope_server/things/camera/simulation.py @@ -5,6 +5,7 @@ camera together to perform an autofocus routine. See repository root for licensing information. """ + from __future__ import annotations import io import json @@ -23,34 +24,48 @@ from labthings_fastapi.dependencies.metadata import GetThingStates from labthings_fastapi.outputs.mjpeg_stream import MJPEGStreamDescriptor from labthings_fastapi.types.numpy import NDArray from labthings_fastapi.server import ThingServer +from pydantic import RootModel from . import BaseCamera, JPEGBlob from ..stage import StageProtocol as Stage +class ArrayModel(RootModel): + """A model for an array""" + + root: NDArray + + class SimulatedCamera(BaseCamera): """A Thing representing an OpenCV camera""" - shape = (600, 800, 3) - glyph_shape = (51, 51, 3) - canvas_shape = (3000, 4000, 3) - frame_interval = 0.1 + _stage: Optional[Stage] = None _server: Optional[ThingServer] = None - def __init__(self): + def __init__( + self, + shape: tuple[int, int, int] = (600, 800, 3), + glyph_shape: tuple[int, int, int] = (51, 51, 3), + canvas_shape: tuple[int, int, int] = (3000, 4000, 3), + frame_interval: float = 0.1, + ): + self.shape = shape + self.glyph_shape = glyph_shape + self.canvas_shape = canvas_shape + self.frame_interval = frame_interval self._capture_thread: Optional[Thread] = None self._capture_enabled = False self.generate_sprites() self.generate_blobs() self.generate_canvas() - + def generate_sprites(self): """Generate sprites to populate the image""" self.sprites = [] black = np.zeros(self.glyph_shape, dtype=np.uint8) x = np.arange(black.shape[0]) y = np.arange(black.shape[1]) - rr = np.sqrt((x[:, None] - np.mean(x))**2 + (y[None, :] - np.mean(y))**2) + rr = np.sqrt((x[:, None] - np.mean(x)) ** 2 + (y[None, :] - np.mean(y)) ** 2) for i in [5, 7, 9, 11, 13, 15]: sprite = black.copy() sprite[rr < i] = 255 @@ -58,15 +73,15 @@ class SimulatedCamera(BaseCamera): def generate_blobs(self, N: int = 1000): """Generate coordinates of blobs - + Blobs are characterised by X, Y, sprite We also generate a KD tree to rapidly find blobs in an image """ self.blobs = np.zeros((N, 3)) rng = np.random.default_rng() w = np.max(self.glyph_shape) - self.blobs[:, 0] = rng.uniform(w/2, self.canvas_shape[0]-w/2, N) - self.blobs[:, 1] = rng.uniform(w/2, self.canvas_shape[1]-w/2, N) + self.blobs[:, 0] = rng.uniform(w / 2, self.canvas_shape[0] - w / 2, N) + self.blobs[:, 1] = rng.uniform(w / 2, self.canvas_shape[1] - w / 2, N) self.blobs[:, 2] = rng.choice(len(self.sprites), N) def generate_canvas(self): @@ -76,28 +91,34 @@ class SimulatedCamera(BaseCamera): w, h, _ = self.glyph_shape for x, y, sprite in self.blobs: self.canvas[ - int(x) - w//2:int(x) - w//2 + w, - int(y) - h//2:int(y) - h//2 + h, + int(x) - w // 2 : int(x) - w // 2 + w, + int(y) - h // 2 : int(y) - h // 2 + h, ] -= self.sprites[int(sprite)] - + def generate_image(self, pos: tuple[int, int]): """Generate an image with blobs based on supplied coordinates""" cw, ch, _ = self.canvas_shape w, h, _ = self.shape - tl = (int(pos[0]) - w//2 - cw//2, int(pos[1]) - h//2 - ch//2) - return self.canvas[ - tuple(slice(tl[i],tl[i] + self.shape[i]) for i in range(2)) + (slice(None),) + tl = (int(pos[0]) - w // 2 - cw // 2, int(pos[1]) - h // 2 - ch // 2) + image = self.canvas[ + tuple(slice(tl[i], tl[i] + self.shape[i]) for i in range(2)) + + (slice(None),) ] + if image.shape != self.shape: + raise ValueError( + f"Image shape {image.shape} does not match intended shape {self.shape}" + ) + return image def attach_to_server(self, server: ThingServer, path: str): self._server = server return super().attach_to_server(server, path) - + def get_stage_position(self): if not self._stage and self._server: self._stage = self._server.things["/stage/"] return self._stage.instantaneous_position - + def generate_frame(self): """Generate a frame with blobs based on the stage coordinates""" try: @@ -105,14 +126,14 @@ class SimulatedCamera(BaseCamera): except Exception as e: print(f"Failed to get stage position: {e}") pos = {"x": 0, "y": 0} - return self.generate_image((pos["x"]/10, pos["y"]/10)) + return self.generate_image((pos["x"] / 10, pos["y"] / 10)) def __enter__(self): self._capture_enabled = True self._capture_thread = Thread(target=self._capture_frames) self._capture_thread.start() return self - + def __exit__(self, _exc_type, _exc_value, _traceback): if self.stream_active: self._capture_enabled = False @@ -124,6 +145,7 @@ class SimulatedCamera(BaseCamera): if self._capture_enabled and self._capture_thread: return self._capture_thread.is_alive() return False + mjpeg_stream = MJPEGStreamDescriptor() lores_mjpeg_stream = MJPEGStreamDescriptor() @@ -131,17 +153,22 @@ class SimulatedCamera(BaseCamera): portal = get_blocking_portal(self) while self._capture_enabled: time.sleep(self.frame_interval) - frame = self.generate_frame() - jpeg = cv2.imencode(".jpg", frame)[1].tobytes() - self.mjpeg_stream.add_frame(jpeg, portal) - jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[1].tobytes() - self.lores_mjpeg_stream.add_frame(jpeg_lores, portal) + try: + frame = self.generate_frame() + jpeg = cv2.imencode(".jpg", frame)[1].tobytes() + self.mjpeg_stream.add_frame(jpeg, portal) + jpeg_lores = cv2.imencode(".jpg", cv2.resize(frame, (320, 240)))[ + 1 + ].tobytes() + self.lores_mjpeg_stream.add_frame(jpeg_lores, portal) + except Exception as e: + logging.error(f"Failed to capture frame: {e}, retrying...") @thing_action def capture_array( self, resolution: Literal["main", "full"] = "full", - ) -> NDArray: + ) -> ArrayModel: """Acquire one image from the camera and return as an array This function will produce a nested list containing an uncompressed RGB image. @@ -149,7 +176,7 @@ class SimulatedCamera(BaseCamera): binary image formats will be added in due course. """ return self.generate_frame() - + @thing_action def capture_jpeg( self, @@ -164,7 +191,9 @@ class SimulatedCamera(BaseCamera): jpeg = cv2.imencode(".jpg", frame)[1].tobytes() exif_dict = { "Exif": { - piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode("utf-8") + piexif.ExifIFD.UserComment: json.dumps(metadata_getter()).encode( + "utf-8" + ) }, "GPS": {}, "Interop": {}, diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index 72415336..5efdfb59 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -10,9 +10,20 @@ and return the calibration data. This module is only intended to be called from the OpenFlexure Microscope server, and depends on that server and its underlying LabThings library. """ -import logging + import time -from typing import Annotated, Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Sequence, Tuple +from typing import ( + Annotated, + Any, + Callable, + Dict, + List, + Mapping, + NamedTuple, + Optional, + Sequence, + Tuple, +) from fastapi import Depends, HTTPException import numpy as np @@ -21,7 +32,10 @@ from camera_stage_mapping.camera_stage_calibration_1d import ( calibrate_backlash_1d, image_to_stage_displacement_from_1d, ) -from labthings_fastapi.dependencies.invocation import InvocationCancelledError, InvocationLogger +from labthings_fastapi.dependencies.invocation import ( + InvocationCancelledError, + InvocationLogger, +) from labthings_fastapi.types.numpy import NDArray, denumpify, DenumpifyingDict from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.thing import Thing @@ -32,6 +46,7 @@ from .stage import StageDependency as Stage CoordinateType = Tuple[float, float, float] XYCoordinateType = Tuple[float, float] + class HardwareInterfaceModel(BaseModel): move: Callable[[NDArray], None] get_position: Callable[[], NDArray] @@ -42,7 +57,7 @@ class HardwareInterfaceModel(BaseModel): def downsample(factor: int, image: np.ndarray) -> np.ndarray: """Downsample an image by taking the mean of each nxn region - + This should be very efficient: we calculate the mean of each `factor * factor` square, no interpolation. If the image is not an integer multiple of the resampling factor, we discard @@ -54,43 +69,60 @@ def downsample(factor: int, image: np.ndarray) -> np.ndarray: new_size = [d // factor for d in image.shape[:2]] # First, we ensure we have something that's an integer multiple # of `factor` - cropped = image[:new_size[0] * factor, :new_size[1] * factor, ...] + cropped = image[: new_size[0] * factor, : new_size[1] * factor, ...] reshaped = cropped.reshape( (new_size[0], factor, new_size[1], factor) + image.shape[2:] ) - return reshaped.mean(axis=(1,3)) + return reshaped.mean(axis=(1, 3)) + + +DEFAULT_SETTLING_TIME = 0.2 def make_hardware_interface( - stage: Stage, camera: Camera, downsample_factor: int = 2 - ) -> HardwareInterfaceModel: + stage: Stage, camera: Camera, downsample_factor: int = 2 +) -> HardwareInterfaceModel: """Construct the functions we need to interface with the hardware""" axes = stage.axis_names + def pos2dict(pos: Sequence[float]) -> Mapping[str, float]: return {k: p for k, p in zip(axes, pos)} + def dict2pos(posd: Mapping[str, float]) -> Sequence[float]: return tuple(posd[k] for k in axes if k in posd) + def move(pos: CoordinateType) -> None: current_pos = stage.position new_pos = pos2dict(pos) displacement = {k: new_pos[k] - current_pos[k] for k in new_pos.keys()} stage.move_relative(**displacement) + def get_position() -> CoordinateType: return dict2pos(stage.position) + def grab_image() -> np.ndarray: img = camera.capture_array() return downsample(downsample_factor, img) + def settle() -> None: - time.sleep(0.2) + time.sleep(DEFAULT_SETTLING_TIME) try: camera.capture_metadata # This discards frames on a picamera except AttributeError: pass # Don't raise an error for other cameras (may consider grabbing a frame) + return HardwareInterfaceModel( - move=move, get_position=get_position, grab_image=grab_image, settle=settle, grab_image_downsampling=downsample_factor + move=move, + get_position=get_position, + grab_image=grab_image, + settle=settle, + grab_image_downsampling=downsample_factor, ) -HardwareInterfaceDep = Annotated[HardwareInterfaceModel, Depends(make_hardware_interface)] + +HardwareInterfaceDep = Annotated[ + HardwareInterfaceModel, Depends(make_hardware_interface) +] class MoveHistory(NamedTuple): @@ -100,11 +132,11 @@ class MoveHistory(NamedTuple): class LoggingMoveWrapper: """Wrap a move function, and maintain a log position/time. - + This class is callable, so it doesn't change the signature of the function it wraps - it just makes it possible to get a list of all the moves we've made, and how long they took. - + Said list is intended to be useful for calibrating the stage so we can estimate how long moves will take. """ @@ -141,33 +173,39 @@ class CSMUncalibratedError(HTTPException): ( "The camera_stage_mapping calibration is not yet available. " "This probably means you need to run the calibration routine." - ) + ), ) class CameraStageMapper(Thing): """A Thing to manage mapping between image and stage coordinates""" + def __enter__(self): pass + def __exit__(self, exc_type, exc_value, traceback): self.thing_settings.write_to_file() @thing_action def calibrate_1d( - self, + self, hw: HardwareInterfaceDep, stage: Stage, logger: InvocationLogger, direction: Tuple[float, float, float], ) -> DenumpifyingDict: """Move a microscope's stage in 1D, and figure out the relationship with the camera""" - move = LoggingMoveWrapper(hw.move) # log positions and times for stage calibration + move = LoggingMoveWrapper( + hw.move + ) # log positions and times for stage calibration tracker = Tracker(hw.grab_image, hw.get_position, settle=hw.settle) direction_array: np.ndarray = np.array(direction) starting_position = stage.position try: - result: dict = calibrate_backlash_1d(tracker, move, direction_array, logger=logger) + result: dict = calibrate_backlash_1d( + tracker, move, direction_array, logger=logger + ) except InvocationCancelledError as e: logger.info("Returning to starting position") stage.move_absolute(**starting_position, block_cancellation=True) @@ -181,7 +219,7 @@ class CameraStageMapper(Thing): self, hw: HardwareInterfaceDep, stage: Stage, logger: InvocationLogger ) -> DenumpifyingDict: """Move the microscope's stage in X and Y, to calibrate its relationship to the camera - + This performs two 1d calibrations in x and y, then combines their results. """ logger.info("Calibrating X axis:") @@ -202,7 +240,7 @@ class CameraStageMapper(Thing): self.thing_settings["image_resolution"] = corrected_resolution csm_matrix = cal_xy["image_to_stage_displacement"] - csm_as_string = f'[{round(csm_matrix[0][0], 2)}, {round(csm_matrix[0][1], 2)},],[{round(csm_matrix[1][0], 2)}, {round(csm_matrix[1][1], 2)}]' + csm_as_string = f"[{round(csm_matrix[0][0], 2)}, {round(csm_matrix[0][1], 2)},],[{round(csm_matrix[1][0], 2)}, {round(csm_matrix[1][1], 2)}]" logger.info(f"CSM matrix is {csm_as_string}.") data: Dict[str, dict] = { @@ -219,14 +257,16 @@ class CameraStageMapper(Thing): return data @thing_property - def image_to_stage_displacement_matrix(self) -> Optional[List[List[float]]]: # 2x2 integer array + def image_to_stage_displacement_matrix( + self, + ) -> Optional[List[List[float]]]: # 2x2 integer array """A 2x2 matrix that converts displacement in image coordinates to stage coordinates. - + Note that this matrix is defined using "matrix coordinates", i.e. image coordinates may be (y,x). This is an artifact of the way numpy, opencv, etc. define images. If you are making use of this matrix in your own code, you will need to take care of that conversion. - + It is often helpful to give a concrete example: to make a move in image coordinates (`dy`, `dx`), where `dx` is horizontal, i.e. the longer dimension of the image, you should move the stage by: @@ -241,12 +281,12 @@ class CameraStageMapper(Thing): if not displacement_matrix: return None return np.array(displacement_matrix).tolist() - + @thing_property def image_resolution(self) -> Optional[Tuple[float, float]]: """The image size used to calibrate the image_to_stage_displacement_matrix""" return self.thing_settings.get("image_resolution", None) - + def assert_calibrated(self): """Raise an exception if the image_to_stage_displacement matrix is not set""" if self.image_to_stage_displacement_matrix is None: @@ -254,10 +294,9 @@ class CameraStageMapper(Thing): @thing_property def last_calibration(self) -> Optional[Dict]: - """The results of the last calibration that was run - """ + """The results of the last calibration that was run""" return self.thing_settings.get("last_calibration", None) - + @thing_action def move_in_image_coordinates( self, @@ -266,7 +305,7 @@ class CameraStageMapper(Thing): y: float, ): """Move by a given number of pixels on the camera - + NB x and y here refer to what is usually understood to be the horizontal and vertical axes of the image. In many toolkits, "matrix indices" are used, which swap the order of these coordinates. This includes opencv and PIL. So, don't be @@ -278,8 +317,7 @@ class CameraStageMapper(Thing): """ self.assert_calibrated() relative_move: np.ndarray = np.dot( - np.array([y, x]), - np.array(self.image_to_stage_displacement_matrix) + np.array([y, x]), np.array(self.image_to_stage_displacement_matrix) ) stage.move_relative(x=relative_move[0], y=relative_move[1]) diff --git a/src/openflexure_microscope_server/things/settings_manager.py b/src/openflexure_microscope_server/things/settings_manager.py index b6e3794c..38247b01 100644 --- a/src/openflexure_microscope_server/things/settings_manager.py +++ b/src/openflexure_microscope_server/things/settings_manager.py @@ -5,6 +5,7 @@ This module provides some settings management across the other Things, and for code that currently lives in clients but needs to persist settings on the server. """ + from collections.abc import Mapping from socket import gethostname from typing import Annotated, Any, MutableMapping, Optional, Sequence @@ -29,10 +30,7 @@ def recursive_update(old_dict: MutableMapping, update: Mapping): """Update a dictionary recursively""" for k, v in update.items(): if isinstance(v, Mapping): - if ( - k in old_dict - and isinstance(old_dict[k], MutableMapping) - ): + if k in old_dict and isinstance(old_dict[k], MutableMapping): recursive_update(old_dict[k], v) else: old_dict[k] = dict(**v) @@ -55,11 +53,13 @@ class SettingsManager(Thing): def external_metadata(self) -> Mapping: """External metadata stored in the server's settings""" return self.thing_settings.get("external_metadata", {}) - + @thing_action - def update_external_metadata(self, data: Mapping, key: Optional[str] = None) -> None: + def update_external_metadata( + self, data: Mapping, key: Optional[str] = None + ) -> None: """Add or replace keys in the external metadata. - + The data supplied will be merged into the existing dictionary recursively, i.e. if a key exists, it will be added to rather than replaced. @@ -80,7 +80,7 @@ class SettingsManager(Thing): @thing_action def delete_external_metadata(self, key: str) -> None: """Delete a key from the stored metadata. - + The key may contain forward slashes, which are understood to separate levels of the dictionary - i.e. `'a/c'` will remove the `c` key from a dictionary that looks like: `{'a': {'c': 1}, 'b': {'d': 2}}` @@ -91,11 +91,10 @@ class SettingsManager(Thing): del subdict[key.split("/")[-1]] except KeyError: raise HTTPException( - status_code=404, - detail="The specified key '{key}' was not found" + status_code=404, detail="The specified key '{key}' was not found" ) self.thing_settings["external_metadata"] = metadata - + @thing_property def microscope_id(self) -> UUID: """A unique identifier for this microscope""" @@ -117,6 +116,7 @@ class SettingsManager(Thing): def external_metadata_in_state(self) -> Sequence[str]: """A list of strings that are included in the "state" metadata""" return self.thing_settings.get("external_metadata_in_state", []) + @external_metadata_in_state.setter def external_metadata_in_state(self, keys: Sequence[str]): """Set the keys from external metadata that are returned in state""" @@ -140,7 +140,9 @@ class SettingsManager(Thing): return state @thing_action - def save_all_thing_settings(self, thing_server: ThingServerDep, logger: InvocationLogger) -> None: + def save_all_thing_settings( + self, thing_server: ThingServerDep, logger: InvocationLogger + ) -> None: """Ensure all the Things sync their settings to disk. Normally, each Thing has a `thing_settings` attribute that can be @@ -161,7 +163,4 @@ class SettingsManager(Thing): f"Could not write {name} settings to disk: permission error." ) except FileNotFoundError: - logger.warning( - f"Could not write {name} settings, folder not found" - ) - + logger.warning(f"Could not write {name} settings, folder not found") diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index 51a445eb..946907e1 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -1,3 +1,5 @@ +# ruff: noqa: E722 + import shutil import zipfile import threading @@ -13,19 +15,21 @@ from pydantic import BaseModel from scipy.stats import norm from scipy.ndimage import zoom from scipy.interpolate import interp1d -from copy import deepcopy from datetime import datetime -from subprocess import CompletedProcess, Popen, PIPE, SubprocessError, run, STDOUT +from subprocess import CompletedProcess, Popen, PIPE, SubprocessError, STDOUT from threading import Event, Thread import glob -import zipfile import json import piexif from labthings_fastapi.thing import Thing from labthings_fastapi.dependencies.metadata import GetThingStates from labthings_fastapi.dependencies.thing import direct_thing_client_dependency -from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger, InvocationCancelledError +from labthings_fastapi.dependencies.invocation import ( + CancelHook, + InvocationLogger, + InvocationCancelledError, +) from labthings_fastapi.decorators import thing_action, thing_property, fastapi_endpoint from labthings_fastapi.outputs.blob import blob_type from .camera import CameraDependency as CamDep @@ -109,19 +113,21 @@ def limit_focus_change(prev_pos, prev_z, new_pos, new_z, limit): return "accept" -def distance_to_site(current, next): - current = np.array(current, dtype="float64") - next = np.array(next, dtype="float64") - if (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2 < 0: - print(f"Negative distance between {next} and {current}") - return np.sqrt( - (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2, dtype="float64" - ) +# def distance_to_site(current, next): +# current = np.array(current, dtype="float64") +# next = np.array(next, dtype="float64") +# if (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2 < 0: +# print(f"Negative distance between {next} and {current}") +# return np.sqrt( +# (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2, dtype="float64" +# ) + def steps_from_centre(current_loc, starting_loc, dx, dy): - step_size = np.array([dx,dy]) + step_size = np.array([dx, dy]) return np.max(np.abs(np.divide(np.subtract(current_loc, starting_loc), step_size))) + # def set_template(microscope, pos): # microscope.move(pos) # background = microscope.grab_image_array() @@ -145,41 +151,60 @@ def distance_to_site(current, next): current = np.array(current, dtype="float64") return np.sqrt((next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2) + def scale_csm(csm_matrix, calibration_width, img_width): "Account for a calibration width that may differ from image width" scale = img_width / calibration_width # Usually >1, if we calibrated at low res csm = np.array(csm_matrix) / scale # Decrease the CSM if pixels are smaller] return csm -def generate_config(folder_path: str, positions: list, names: list, camera_to_sample_matrix, csm_calibration_width, img_width, logger): +def generate_config( + folder_path: str, + positions: list, + names: list, + camera_to_sample_matrix, + csm_calibration_width, + img_width, + logger, +): positions = np.array(positions) - mean_loc = np.mean(positions, axis = 0) + mean_loc = np.mean(positions, axis=0) - #TODO: positions from recent scans need to be 2x bigger - change to CSM res? + # TODO: positions from recent scans need to be 2x bigger - change to CSM res? - camera_to_sample_matrix = scale_csm(camera_to_sample_matrix, csm_calibration_width, img_width) + camera_to_sample_matrix = scale_csm( + camera_to_sample_matrix, csm_calibration_width, img_width + ) - with open(os.path.join(folder_path, 'TileConfiguration.txt'), 'w') as fp: - fp.write('# Define the number of dimensions we are working on\ndim = 2\n\n# Define the image coordinates\n') - for i in range(len(names)): - loc = np.dot((positions[i] - mean_loc), np.linalg.inv(camera_to_sample_matrix)) - fp.write(f'{names[i]}; ; {loc[1], loc[0]} \n') + with open(os.path.join(folder_path, "TileConfiguration.txt"), "w") as fp: + fp.write( + "# Define the number of dimensions we are working on\ndim = 2\n\n# Define the image coordinates\n" + ) + for i in range(len(names)): + loc = np.dot( + (positions[i] - mean_loc), np.linalg.inv(camera_to_sample_matrix) + ) + fp.write(f"{names[i]}; ; {loc[1], loc[0]} \n") def raw2rggb(raw): """Convert packed 10 bit raw to RGGB 8 bit""" raw = np.asarray(raw) # ensure it's an array rggb = np.empty((616, 820, 4), dtype=np.uint8) - raw_w = rggb.shape[1]//2*5 - for plane, offset in enumerate([(1,1), (0,1), (1,0), (0,0)]): - rggb[:, ::2, plane] = raw[offset[0]::2, offset[1]:raw_w+offset[1]:5] - rggb[:, 1::2, plane] = raw[offset[0]::2, offset[1]+2:raw_w+offset[1]+2:5] + raw_w = rggb.shape[1] // 2 * 5 + for plane, offset in enumerate([(1, 1), (0, 1), (1, 0), (0, 0)]): + rggb[:, ::2, plane] = raw[offset[0] :: 2, offset[1] : raw_w + offset[1] : 5] + rggb[:, 1::2, plane] = raw[ + offset[0] :: 2, offset[1] + 2 : raw_w + offset[1] + 2 : 5 + ] return rggb def rggb2rgb(rggb): - return np.stack([rggb[..., 0], rggb[..., 1]//2 + rggb[..., 2]//2, rggb[...,3]], axis=2) + return np.stack( + [rggb[..., 0], rggb[..., 1] // 2 + rggb[..., 2] // 2, rggb[..., 3]], axis=2 + ) class ChannelDistributions(BaseModel): @@ -187,6 +212,7 @@ class ChannelDistributions(BaseModel): standard_deviations: list[float] colorspace: str = "LUV" + class BackgroundDetectThing(Thing): @thing_property def background_distributions(self) -> Optional[ChannelDistributions]: @@ -196,7 +222,7 @@ class BackgroundDetectThing(Thing): return ChannelDistributions(**bd) else: return None - + @background_distributions.setter def background_distributions(self, value: Optional[ChannelDistributions]) -> None: try: @@ -208,7 +234,7 @@ class BackgroundDetectThing(Thing): def tolerance(self) -> float: """How many standard deviations to allow for the background""" return self.thing_settings.get("tolerance", 7) - + @tolerance.setter def tolerance(self, value: float) -> None: self.thing_settings["tolerance"] = value @@ -230,17 +256,20 @@ class BackgroundDetectThing(Thing): """ d = self.background_distributions if not d: - raise RuntimeError("Background is not set: you need to calibrate background detection.") + raise RuntimeError( + "Background is not set: you need to calibrate background detection." + ) return np.all( np.abs(image - np.array(d.means)[np.newaxis, np.newaxis, :]) - < np.array(d.standard_deviations)[np.newaxis, np.newaxis, :] * self.tolerance, + < np.array(d.standard_deviations)[np.newaxis, np.newaxis, :] + * self.tolerance, axis=2, ) - + @thing_action def background_fraction(self, cam: CamDep) -> float: """Determine what fraction of the current image is background - + This action will acquire a new image from the preview stream, then evaluate whether it is foreground or background, by comparing it too the saved statistics. This is done on a per-pixel basis, and @@ -262,11 +291,11 @@ class BackgroundDetectThing(Thing): fraction_threshold = self.fraction return (100 - b_fraction) > fraction_threshold - + @thing_action def set_background(self, cam: CamDep): """Grab an image, and use its statistics to set the background - + This should be run when the microscope is looking at an empty region, and will calculate the mean and standard deviation of the pixel values in the LUV colourspace. These values will then be used to compare @@ -289,9 +318,9 @@ class BackgroundDetectThing(Thing): mu, std = np.apply_along_axis(norm.fit, 0, points) self.background_distributions = ChannelDistributions( - means = mu.tolist(), - standard_deviations = std.tolist(), - colorspace = "LUV", + means=mu.tolist(), + standard_deviations=std.tolist(), + colorspace="LUV", ) @property @@ -303,8 +332,10 @@ class BackgroundDetectThing(Thing): "fraction": self.fraction, } - -BackgroundDep = direct_thing_client_dependency(BackgroundDetectThing, "/background_detect/") + +BackgroundDep = direct_thing_client_dependency( + BackgroundDetectThing, "/background_detect/" +) class NotEnoughFreeSpaceError(IOError): @@ -322,27 +353,27 @@ def ensure_free_disk_space(path: str, min_space: int = 500000000) -> None: class ScanInfo(BaseModel): - """"Summary information about a scan folder""" + """ "Summary information about a scan folder""" + name: str created: datetime modified: datetime number_of_images: int -DOWNLOADABLE_SCAN_FILES = ( - "images.zip", -) +DOWNLOADABLE_SCAN_FILES = ("images.zip",) JPEGBlob = blob_type("image/jpeg") ZipBlob = blob_type("application/zip") + class SmartScanThing(Thing): def __init__(self, path_to_openflexure_stitch: str): self._script = path_to_openflexure_stitch self._preview_stitch_popen_lock = threading.Lock() self._correlate_popen_lock = threading.Lock() self._scan_lock = threading.Lock() - + @property def scans_folder_path(self) -> str: """This folder will hold all the scans we do.""" @@ -350,28 +381,29 @@ class SmartScanThing(Thing): # If the working directory is `/var/openflexure` this will result # in scans being saved at `/var/openflexure/scans/` return "scans" - + _latest_scan_name = None + @thing_property def latest_scan_name(self) -> Optional[str]: """The name of the last scan to be started.""" return self._latest_scan_name - def scan_folder_path(self, scan_name: Optional[str]=None): + def scan_folder_path(self, scan_name: Optional[str] = None): """The path to the scan folder with a given name""" if not scan_name: if not self.latest_scan_name: raise IOError("There is no latest scan to return") scan_name = self.latest_scan_name return os.path.join(self.scans_folder_path, scan_name) - - def new_scan_folder(self, scan_name: str="scan") -> str: + + def new_scan_folder(self, scan_name: str = "scan") -> str: """Create a new empty folder, into which we can save scan images - + The folder will be named `{scan_name}_000001/` where the number is zero-padded to be 6 digits long (to allow correct sorting if the scans are ordered alphanumerically). - + Note that if you have discontinuous numbering (e.g. you've got scans numbered 1 through 10, but you deleted scan 5), then the gaps will get filled in - so there's no guarantee, for now, that the numbers @@ -388,17 +420,16 @@ class SmartScanThing(Thing): self._latest_scan_name = os.path.basename(folder_path) return folder_path raise FileExistsError("Could not create a new scan folder: all names in use!") - - + def move_to_next_point( - self, - stage: StageDep, - logger: InvocationLogger, - path: list[list[int]], - focused_path: list[list[int]], - ) -> list[int]: + self, + stage: StageDep, + logger: InvocationLogger, + path: list[list[int]], + focused_path: list[list[int]], + ) -> list[int]: """Remove the first point from the path, and move there. - + This will move to the next XY position in `path`, taking the `z` value either from the current z value of the stage, or from `focused_path`. @@ -412,9 +443,7 @@ class SmartScanThing(Thing): else: z = stage.position["z"] logger.info(f"Moving to {loc}") - stage.move_absolute( - x=int(loc[0]), y=int(loc[1]), z = z - self.autofocus_dz / 2 - ) + stage.move_absolute(x=int(loc[0]), y=int(loc[1]), z=z - self.autofocus_dz / 2) return loc + [z] @thing_action @@ -429,17 +458,17 @@ class SmartScanThing(Thing): csm: CSMDep, background_detect: BackgroundDep, recentre: RecentreStage, - scan_name: str="", + scan_name: str = "", ): """Move the stage to cover an area, taking images that can be tiled together. The stage will move in a pattern that grows outwards from the starting point, - stopping once it is surrounded by "background" (as detected by the + stopping once it is surrounded by "background" (as detected by the background_detect Thing). Input: - * `overlap` is the fraction by which images should overlap, i.e. + * `overlap` is the fraction by which images should overlap, i.e. `0.3` means we will move by 70% of the field of view each time. """ # Define these variables so we can use them in the finally: block @@ -453,16 +482,20 @@ class SmartScanThing(Thing): # Before anything else, check that we've got a background set # It's annoying to have to wait to find out! max_dist = self.max_range - + if self.autofocus_dz == 0: - logger.info(f'Running scan without autofocus') + logger.info("Running scan without autofocus") elif self.autofocus_dz <= 200: - logger.warning(f'Your dz range is {self.autofocus_dz} steps, which is too short to attempt to focus. Running without autofocus') + logger.warning( + f"Your dz range is {self.autofocus_dz} steps, which is too short to attempt to focus. Running without autofocus" + ) if self.skip_background: d = background_detect.background_distributions if not d: - raise RuntimeError("Background is not set: you need to calibrate background detection.") + raise RuntimeError( + "Background is not set: you need to calibrate background detection." + ) else: logger.warning( "This scan will run in a spiral from the starting point " @@ -470,7 +503,7 @@ class SmartScanThing(Thing): "in every direction. Make sure you watch it run to stop it leaving " "the area of interest, or (worse) leading the microscope's range " "of motion." - ) + ) names = [] positions = [] @@ -495,14 +528,20 @@ class SmartScanThing(Thing): # TODO: generalise to have 2D displacements for x and y (as the # camera and stage may not be aligned). CSM = csm.image_to_stage_displacement_matrix - csm_calibration_width = csm.last_calibration["image_resolution"][1] + # csm_calibration_width = csm.last_calibration["image_resolution"][1] overlap = self.overlap - dx = int(np.abs(np.dot(np.array([0, arr.shape[1] * (1 - overlap)]), CSM)[0])) - dy = int(np.abs(np.dot(np.array([arr.shape[0] * (1 - overlap), 0]), CSM)[1])) + dx = int( + np.abs(np.dot(np.array([0, arr.shape[1] * (1 - overlap)]), CSM)[0]) + ) + dy = int( + np.abs(np.dot(np.array([arr.shape[0] * (1 - overlap), 0]), CSM)[1]) + ) - logger.info(f"Based on an overlap of {overlap}, we will make steps of {dx}, {dy}") + logger.info( + f"Based on an overlap of {overlap}, we will make steps of {dx}, {dy}" + ) # construct a 2D scan path path = [[stage.position["x"], stage.position["y"]]] @@ -510,7 +549,6 @@ class SmartScanThing(Thing): focused_path = [] # This holds a list of all points where focus succeeded true_path = [] # This holds a list of all points visited i = 0 - ids = [] start_time = time.strftime("%H_%M_%S-%d_%m_%Y") scan_folder = self.new_scan_folder(scan_name) @@ -521,16 +559,18 @@ class SmartScanThing(Thing): logger.info(f"Saving images to {images_folder}") data = { - 'scan_name' : scan_name, - 'overlap' : overlap, - 'autofocus range' : self.autofocus_dz, - 'dx' : dx, - 'dy' : dy, - 'start time' : start_time, - 'skipping background' : self.skip_background + "scan_name": scan_name, + "overlap": overlap, + "autofocus range": self.autofocus_dz, + "dx": dx, + "dy": dy, + "start time": start_time, + "skipping background": self.skip_background, } - with open(os.path.join(images_folder, 'scan_inputs.json'), 'w', encoding='utf-8') as f: + with open( + os.path.join(images_folder, "scan_inputs.json"), "w", encoding="utf-8" + ) as f: json.dump(data, f, ensure_ascii=False, indent=4) # We will capture images and process them with this function, defined once here. @@ -538,29 +578,41 @@ class SmartScanThing(Thing): # that change each iteration. # We also pre-calculate a normalisation image based on the LST and white balance raw_image = cam.capture_array(stream_name="raw") - #TODO: assert the image is 10-bit packed, or deal with other formats! + # TODO: assert the image is 10-bit packed, or deal with other formats! rgb = rggb2rgb(raw2rggb(raw_image)) lst = dict(cam.lens_shading_tables) lum = np.array(lst["luminance"]) Cr = np.array(lst["Cr"]) Cb = np.array(lst["Cb"]) gr, gb = cam.colour_gains - G = 1/lum - R = G/Cr/gr*np.min(Cr) # The extra /np.max(Cr) emulates the quirky handling of Cr in - B = G/Cb/gb*np.min(Cb) # the picamera2 pipeline + G = 1 / lum + R = ( + G / Cr / gr * np.min(Cr) + ) # The extra /np.max(Cr) emulates the quirky handling of Cr in + B = G / Cb / gb * np.min(Cb) # the picamera2 pipeline white_norm_lores = np.stack([R, G, B], axis=2) - zoom_factors = [i/n for i, n in zip(rgb[...,:3].shape, white_norm_lores.shape)] - white_norm = zoom(white_norm_lores, zoom_factors, order=1)[:rgb.shape[0], :rgb.shape[1], :] # Could use some work - colour_correction_matrix = np.array(cam.colour_correction_matrix).reshape((3,3)) + zoom_factors = [ + i / n for i, n in zip(rgb[..., :3].shape, white_norm_lores.shape) + ] + white_norm = zoom(white_norm_lores, zoom_factors, order=1)[ + : rgb.shape[0], : rgb.shape[1], : + ] # Could use some work + colour_correction_matrix = np.array(cam.colour_correction_matrix).reshape( + (3, 3) + ) contrast_algorithm = cam.tuning["algorithms"][9]["rpi.contrast"] - gamma = np.array(contrast_algorithm["gamma_curve"]).reshape((-1,2)) - gamma_8bit = interp1d(gamma[:, 0]/255, gamma[:, 1]/255) + gamma = np.array(contrast_algorithm["gamma_curve"]).reshape((-1, 2)) + gamma_8bit = interp1d(gamma[:, 0] / 255, gamma[:, 1] / 255) + def process_raw_image(img): - normed = img/white_norm - corrected = np.dot(colour_correction_matrix, normed.reshape((-1, 3)).T).T.reshape(normed.shape) + normed = img / white_norm + corrected = np.dot( + colour_correction_matrix, normed.reshape((-1, 3)).T + ).T.reshape(normed.shape) corrected[corrected < 0] = 0 corrected[corrected > 255] = 255 return gamma_8bit(corrected) + logger.info( f"Generated normalisation image with shape {white_norm.shape}, " f"max {white_norm.max(axis=(0,1))}, min {white_norm.min(axis=(0,1))}" @@ -572,9 +624,10 @@ class SmartScanThing(Thing): "gain_red": gr, "gain_blue": gb, } + def capture_and_save(acquired: Event, name: str) -> None: """Capture an image and save it to disk - + This will set the event `acquired` once the image has been acquired, so that the stage may be moved while it's saved. """ @@ -585,32 +638,42 @@ class SmartScanThing(Thing): acquired.set() acquisition_time = time.time() # Save the raw image - np.savez(os.path.join(raw_images_folder, name + ".npz"), raw_image=raw_image, **norm_inputs) + np.savez( + os.path.join(raw_images_folder, name + ".npz"), + raw_image=raw_image, + **norm_inputs, + ) # Process it into 8 bit RGB processed = process_raw_image(rggb2rgb(raw2rggb(raw_image))) processed[processed > 255] = 255 processed[processed < 0] = 0 img = Image.fromarray(processed.astype(np.uint8), mode="RGB") img.save( - os.path.join(images_folder, name), - quality=95, - subsampling=0 + os.path.join(images_folder, name), quality=95, subsampling=0 ) exif_dict = piexif.load(os.path.join(images_folder, name)) exif_dict["Exif"][piexif.ExifIFD.UserComment] = json.dumps( metadata ).encode("utf-8") - piexif.insert(piexif.dump(exif_dict), os.path.join(images_folder, name)) + piexif.insert( + piexif.dump(exif_dict), os.path.join(images_folder, name) + ) save_time = time.time() - logger.info(f"Acquired {name} in {acquisition_time-capture_start:.1f}s then {save_time-acquisition_time:.1f}s saving to disk") + logger.info( + f"Acquired {name} in {acquisition_time-capture_start:.1f}s then {save_time-acquisition_time:.1f}s saving to disk" + ) except Exception as e: - logger.error(f"An error occurred while saving {name}: {e}", exc_info=e) - + logger.error( + f"An error occurred while saving {name}: {e}", exc_info=e + ) + # At the start of the loop, we simultaneously capture an image and move to the next scan point. # We skip capturing on the first run, because we've not focused yet - and also we skip capturing if # it looks like background. while len(path) > 0: - loc = self.move_to_next_point(stage, logger, path=path, focused_path=focused_path) + loc = self.move_to_next_point( + stage, logger, path=path, focused_path=focused_path + ) if not self.preview_stitch_running(): self.preview_stitch_start(images_folder) if self.stitch_automatically: @@ -627,10 +690,10 @@ class SmartScanThing(Thing): # if more than 92% of the image is background, treat it as background and continue if not image_is_sample: - logger.info(f"Skipping {stage.position} as it is {round(background_detect.background_fraction(),0)}% background.") - capture_image = False + logger.info( + f"Skipping {stage.position} as it is {round(background_detect.background_fraction(),0)}% background." + ) else: - capture_image = True # if not, it's sample. run an autofocus and use the updated height new_pos = [ [stage.position["x"] - dx, stage.position["y"]], @@ -648,17 +711,25 @@ class SmartScanThing(Thing): attempts = 0 if self.autofocus_dz > 200: while True: - jpeg_zs, jpeg_sizes = autofocus.looping_autofocus(dz=self.autofocus_dz, start = 'base') + jpeg_zs, jpeg_sizes = autofocus.looping_autofocus( + dz=self.autofocus_dz, start="base" + ) current_height = stage.position["z"] time.sleep(0.2) - autofocus_success = autofocus.verify_focus_sharpness(sweep_sizes = jpeg_sizes, camera = CamDep, threshold = 0.92) - logger.info(f"We just tested the focus! Result was {autofocus_success}") + autofocus_success = autofocus.verify_focus_sharpness( + sweep_sizes=jpeg_sizes, camera=CamDep, threshold=0.92 + ) + logger.info( + f"We just tested the focus! Result was {autofocus_success}" + ) if autofocus_success: # if there have been successful autofocuses in this scan, find the closest one in x-y # test if the change in z between them exceeds a ratio (indicating a failed autofocus) if len(focused_path) > 0: - nearest_focused_site = focused_path[closest(loc, focused_path)] + nearest_focused_site = focused_path[ + closest(loc, focused_path) + ] result = limit_focus_change( nearest_focused_site[0:2], nearest_focused_site[-1], @@ -694,31 +765,33 @@ class SmartScanThing(Thing): wait_start = time.time() capture_thread.join() wait_time = time.time() - wait_start - logger.info(f"Waited {wait_time:.1f}s for the previous capture to finish saving.") + logger.info( + f"Waited {wait_time:.1f}s for the previous capture to finish saving." + ) acquired = Event() name = f"image_{loc[0]}_{loc[1]}.jpg" time.sleep(0.2) capture_thread = Thread( target=capture_and_save, kwargs={ - # "cam": cam, - # "logger": logger, + # "cam": cam, + # "logger": logger, "acquired": acquired, "name": name, - # "images_folder": images_folder, - # "raw_images_folder": raw_images_folder, - } + # "images_folder": images_folder, + # "raw_images_folder": raw_images_folder, + }, ) capture_thread.start() acquired.wait() # wait until the image is acquired - #time.sleep(0.5) + # time.sleep(0.5) positions.append(loc[:2]) names.append(name) # add the current position to the list of all positions visited true_path.append(loc) - #if len(names) > 1: + # if len(names) > 1: # generate_config(images_folder, positions, names, CSM, csm_calibration_width, img_width, logger) temp_path = [] @@ -727,10 +800,20 @@ class SmartScanThing(Thing): if distance_to_site(i, true_path[0][:2]) < max_dist: temp_path.append(i) else: - logger.info(f'Rejected moving to {i} as it is out of range') + logger.info(f"Rejected moving to {i} as it is out of range") path = temp_path.copy() - path = sorted(path, key=lambda x: (steps_from_centre(x, true_path[0][:2], dx, dy), distance_to_site(loc[:2], x))) - self.create_zip_of_scan(logger = logger, scan_name = scan_folder.split('scans/')[1], download_zip = False) + path = sorted( + path, + key=lambda x: ( + steps_from_centre(x, true_path[0][:2], dx, dy), + distance_to_site(loc[:2], x), + ), + ) + self.create_zip_of_scan( + logger=logger, + scan_name=scan_folder.split("scans/")[1], + download_zip=False, + ) except InvocationCancelledError: logger.error("Stopping scan because it was cancelled.") @@ -743,8 +826,7 @@ class SmartScanThing(Thing): except Exception as e: logger.error( f"The scan stopped because of an error: {e}", - "We will attempt to stitch and archive the images acquired " - "so far.", + "We will attempt to stitch and archive the images acquired " "so far.", exc_info=e, ) raise e @@ -757,14 +839,20 @@ class SmartScanThing(Thing): stage.move_absolute(**starting_position, block_cancellation=True) finally: self._scan_lock.release() - self.create_zip_of_scan(logger = logger, scan_name = scan_folder.split('scans/')[1], download_zip = False) + self.create_zip_of_scan( + logger=logger, + scan_name=scan_folder.split("scans/")[1], + download_zip=False, + ) logger.info("Waiting for background processes to finish...") self.preview_stitch_wait() self.correlate_wait() try: if scan_folder and self.stitch_automatically: logger.info("Stitching final image (may take some time)...") - self.stitch_scan(logger, os.path.basename(scan_folder), overlap=overlap) + self.stitch_scan( + logger, os.path.basename(scan_folder), overlap=overlap + ) except SubprocessError as e: logger.error(f"Stitching failed: {e}", exc_info=e) @@ -789,7 +877,7 @@ class SmartScanThing(Thing): @thing_property def skip_background(self) -> bool: """Whether to detect and skip empty fields of view - + This uses the settings from the `background_detect` Thing. """ return self.thing_settings.get("skip_background", True) @@ -815,12 +903,12 @@ class SmartScanThing(Thing): @overlap.setter def overlap(self, value: float) -> None: self.thing_settings["overlap"] = value - + @thing_property def stitch_automatically(self) -> bool: """Should we attempt to stitch scans as we go?""" return self.thing_settings.get("stitch_automatically", True) - + @stitch_automatically.setter def stitch_automatically(self, value: bool) -> None: self.thing_settings["stitch_automatically"] = value @@ -828,7 +916,7 @@ class SmartScanThing(Thing): @thing_property def scans(self) -> list[ScanInfo]: """All the available scans - + Each scan has a name (which can be used to access it), along with its modified and created times (according to the filesystem) and the number of items in the `images` folder. Note that the number @@ -848,29 +936,29 @@ class SmartScanThing(Thing): number_of_images = 0 scans.append( ScanInfo( - name = f, - created = os.path.getctime(path), - modified = os.path.getmtime(path), - number_of_images = number_of_images, + name=f, + created=os.path.getctime(path), + modified=os.path.getmtime(path), + number_of_images=number_of_images, ) ) return scans - + @fastapi_endpoint( - "get", - "scans/{scan_name}/{file}", - responses = { - 200: { - "description": "Successfully downloading file", - "content": {"*/*": {}} - }, - 403: {"description": "Filename not permitted"}, - 404: {"description": "File not found"} + "get", + "scans/{scan_name}/{file}", + responses={ + 200: { + "description": "Successfully downloading file", + "content": {"*/*": {}}, }, - ) + 403: {"description": "Filename not permitted"}, + 404: {"description": "File not found"}, + }, + ) def get_scan_file(self, scan_name: str, file: str) -> FileResponse: """Retrieve a file from a scan. - + This endpoint allows files to be downloaded from a scan. For security reasons, there is a list of allowable filenames, and paths with additional slashes are not permitted. @@ -883,18 +971,18 @@ class SmartScanThing(Thing): if not os.path.isfile(path): raise HTTPException(404, "File not found") return FileResponse(path) - + @fastapi_endpoint( "delete", "scans/{scan_name}", - responses = { + responses={ 200: {"description": "Successfully deleted scan"}, 404: {"description": "Scan not found"}, }, ) def delete_scan(self, scan_name: str) -> None: """Delete all files from a scan. - + This endpoint allows scans to be deleted from disk. """ path = os.path.join(self.scans_folder_path, scan_name) @@ -902,25 +990,25 @@ class SmartScanThing(Thing): print(f"can't find {path}") raise HTTPException(404, "Scan not found") shutil.rmtree(path) - + @fastapi_endpoint( "delete", "scans", ) def delete_all_scans(self) -> None: """Delete all the scans on the microscope - - **This will irreversibly remove all smart scan data from the + + **This will irreversibly remove all smart scan data from the microscope!** Use with extreme caution. """ for scan in self.scans: self.delete_scan(scan.name) - - def images_folder(self, scan_name: Optional[str]=None) -> str: + + def images_folder(self, scan_name: Optional[str] = None) -> str: scan_folder = self.scan_folder_path(scan_name=scan_name) return os.path.join(scan_folder, "images") - + @property def latest_preview_stitch_path(self): """The path of the latest preview stitched image""" @@ -929,7 +1017,7 @@ class SmartScanThing(Thing): @thing_property def latest_preview_stitch_time(self) -> Optional[datetime]: """The modification time of the latest preview image - + This will return `null` if there is no preview image to return. """ try: @@ -939,27 +1027,27 @@ class SmartScanThing(Thing): except IOError: return None return None - + @fastapi_endpoint( - "get", - "latest_preview_stitch.jpg", - responses = { - 200: { - "description": "A preview-quality stitched image", - "content": {"image/jpeg": {}} - }, - 404: {"description": "File not found"} + "get", + "latest_preview_stitch.jpg", + responses={ + 200: { + "description": "A preview-quality stitched image", + "content": {"image/jpeg": {}}, }, - ) + 404: {"description": "File not found"}, + }, + ) def get_latest_preview(self) -> FileResponse: - """Retrieve the latest preview image. - """ + """Retrieve the latest preview image.""" path = self.latest_preview_stitch_path if not os.path.isfile(path): raise HTTPException(404, "File not found") return FileResponse(path) - + _preview_stitch_popen = None + def preview_stitch_start(self, images_folder: str) -> None: """Start stitching a preview of the scan in a subprocess""" if self.preview_stitch_running(): @@ -977,20 +1065,28 @@ class SmartScanThing(Thing): if self._preview_stitch_popen.poll() is None: return True return False - + def preview_stitch_wait(self): if self.preview_stitch_running(): with self._preview_stitch_popen_lock: self._preview_stitch_popen.wait() - + _correlate_popen = None + def correlate_start(self, images_folder: str, overlap: float = 0.1) -> None: """Start stitching a preview of the scan in a subprocess""" if self.correlate_running(): raise RuntimeError("Only one subprocess is allowed at a time") with self._correlate_popen_lock: self._correlate_popen = Popen( - [self._script, "--stitching_mode", "only_correlate", "--minimum_overlap", f"{round(overlap*0.9, 2)}", images_folder] + [ + self._script, + "--stitching_mode", + "only_correlate", + "--minimum_overlap", + f"{round(overlap*0.9, 2)}", + images_folder, + ] ) def correlate_running(self) -> bool: @@ -1001,73 +1097,100 @@ class SmartScanThing(Thing): if self._correlate_popen.poll() is None: return True return False - + def correlate_wait(self): if self.correlate_running(): with self._correlate_popen_lock: self._correlate_popen.wait() def run_subprocess( - self, logger: InvocationLogger, cmd: list[str], - ) -> CompletedProcess: + self, + logger: InvocationLogger, + cmd: list[str], + ) -> CompletedProcess: """Run a subprocess and log any output""" logger.info(f"Running command in subprocess: `{' '.join(cmd)}`") - - p = Popen(cmd, stdout=PIPE, stderr = STDOUT, bufsize=1, universal_newlines=True) - os.set_blocking(p.stdout.fileno(), False) - logger.info(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))) - while p.poll() == None: + p = Popen(cmd, stdout=PIPE, stderr=STDOUT, bufsize=1, universal_newlines=True) + os.set_blocking(p.stdout.fileno(), False) + logger.info(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))) + while p.poll() is None: try: output = p.stdout.readline() - if output != "" and output != None: - logger.info(output) + if output != "" and output is not None: + logger.info(output) except: pass for line in p.stdout: try: output = p.stdout.readline() - if output != "" and output != None: - logger.info(output) + if output != "" and output is not None: + logger.info(output) except: pass - logger.info('Stitching complete') + logger.info("Stitching complete") return p @thing_action - def stitch_scan(self, logger: InvocationLogger, scan_name: Optional[str]=None, overlap: float = 0.0) -> None: + def stitch_scan( + self, + logger: InvocationLogger, + scan_name: Optional[str] = None, + overlap: float = 0.0, + ) -> None: """Generate a stitched image based on stage position metadata""" images_folder = self.images_folder(scan_name=scan_name) if self.stitch_tiff: - tiff_arg = '--stitch_tiff' + tiff_arg = "--stitch_tiff" else: - tiff_arg = '--no-stitch_tiff' + tiff_arg = "--no-stitch_tiff" if overlap == 0.0: try: - with open(os.path.join(images_folder, 'scan_inputs.json')) as data_file: + with open(os.path.join(images_folder, "scan_inputs.json")) as data_file: data_loaded = json.load(data_file) logger.info(data_loaded) - overlap = data_loaded['overlap'] + overlap = data_loaded["overlap"] except: overlap = 0.1 - self.run_subprocess(logger, [self._script, "--stitching_mode", "all", f"{tiff_arg}", "--minimum_overlap", f"{round(overlap*0.9,2)}", images_folder]) - + self.run_subprocess( + logger, + [ + self._script, + "--stitching_mode", + "all", + f"{tiff_arg}", + "--minimum_overlap", + f"{round(overlap*0.9,2)}", + images_folder, + ], + ) + @thing_action - def create_zip_of_scan(self, logger: InvocationLogger, scan_name: Optional[str]=None, download_zip = True) -> ZipBlob: + def create_zip_of_scan( + self, + logger: InvocationLogger, + scan_name: Optional[str] = None, + download_zip=True, + ) -> ZipBlob: """Generate a zip file that can be downloaded, with all the scan files in it.""" images_folder = self.images_folder(scan_name=scan_name) scan_folder = self.scan_folder_path(scan_name=scan_name) - if scan_folder != os.path.dirname(images_folder) or os.path.basename(images_folder) != "images": + if ( + scan_folder != os.path.dirname(images_folder) + or os.path.basename(images_folder) != "images" + ): logger.error( "There is a problem with filenames, the archive may be incorrect." f"scan_folder: {scan_folder}, images_folder: {images_folder}." ) if not os.path.isdir(images_folder): - raise FileNotFoundError(f"Tried to make a zip archive of {images_folder} but it does not exist.") + raise FileNotFoundError( + f"Tried to make a zip archive of {images_folder} but it does not exist." + ) # logger.info("Creating zip archive of images (may take some time)...") zip_fname = f'{os.path.join(scan_folder, "images")}.zip' @@ -1078,26 +1201,32 @@ class SmartScanThing(Thing): if not os.path.isfile(zip_fname): with zipfile.ZipFile(zip_fname, mode="w") as zip: pass - - # get a list of files in the existing zip + + # get a list of files in the existing zip current_zip = self.get_files_in_zip(zip_fname) # get a list of files in the folder we're zipping folder_path = self.scan_folder_path(scan_name) - files = glob.glob(folder_path + '/**/*', recursive=True) - files = [i.split(f'{folder_path}/')[1] for i in files] + files = glob.glob(folder_path + "/**/*", recursive=True) + files = [i.split(f"{folder_path}/")[1] for i in files] # This is a list of file names that are updated as the scan goes, # and should only be zipped at the end of the scan - otherwise they'll # be appended on every loop as we can't overwrite files in the zip - files_to_delay = ['TileConfiguration', 'tiling_cache', 'stitched.jp', 'stitched_from', 'stitched.om'] + files_to_delay = [ + "TileConfiguration", + "tiling_cache", + "stitched.jp", + "stitched_from", + "stitched.om", + ] tiff_name = "" with zipfile.ZipFile(zip_fname, mode="a") as zip: for file in files: - if 'stitched.jp' in file: + if "stitched.jp" in file: stitch_name = os.path.split(file)[1] - if '.ome.tiff' in file: + if ".ome.tiff" in file: tiff_name = os.path.split(file)[1] if any(banned_name in file for banned_name in files_to_delay): # logger.info(f'we only add {file} into zip at the end of the scan') @@ -1105,15 +1234,14 @@ class SmartScanThing(Thing): elif file in current_zip: # logger.info(f'{file} is already in zip') pass - elif ".zip" in file or 'raw' in file: + elif ".zip" in file or "raw" in file: # logger.info('Not adding the .zip to itself') pass else: - logger.info(f'appending {file} to zip') + logger.info(f"appending {file} to zip") zip.write(os.path.join(folder_path, file), arcname=file) - - images_folder = os.path.join(folder_path, 'images') + images_folder = os.path.join(folder_path, "images") # Promote key files to the top level of the zip only at the end of the scan (when downloading) # and finally zip some of the final files # TODO: if you download multiple times, you get duplicate files - is this a problem? @@ -1122,13 +1250,13 @@ class SmartScanThing(Thing): for fname in ["stitched_from_stage.jpg", stitch_name, tiff_name]: fpath = os.path.join(images_folder, fname) if os.path.exists(fpath): - logger.info(f'copying {fpath} to upper level') + logger.info(f"copying {fpath} to upper level") zip.write(fpath, arcname=fname) for file in files: if any(banned_name in file for banned_name in files_to_delay): - logger.info(f'we are finally adding {file} into zip') + logger.info(f"we are finally adding {file} into zip") zip.write(os.path.join(folder_path, file), arcname=file) - logger.info('about to download zip') + logger.info("about to download zip") return ZipBlob.from_file(zip_fname) @thing_action @@ -1136,6 +1264,5 @@ class SmartScanThing(Thing): """List the relative paths of all files and folders in the zip folder specified""" zip = zipfile.ZipFile(zip_path) zip = [os.path.normpath(i) for i in zip.namelist()] - - return zip + return zip diff --git a/src/openflexure_microscope_server/things/stage/__init__.py b/src/openflexure_microscope_server/things/stage/__init__.py index 36d11a8b..cc7c1bc3 100644 --- a/src/openflexure_microscope_server/things/stage/__init__.py +++ b/src/openflexure_microscope_server/things/stage/__init__.py @@ -7,10 +7,11 @@ from labthings_fastapi.dependencies.invocation import CancelHook from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from collections.abc import Sequence, Mapping + @runtime_checkable class StageProtocol(Protocol): - """A protocol for the OpenFlexure translation stage - """ + """A protocol for the OpenFlexure translation stage""" + _axis_names: Sequence[str] @property @@ -32,18 +33,28 @@ class StageProtocol(Protocol): def thing_state(self) -> Mapping[str, Any]: """Summary metadata describing the current state of the stage""" ... - - def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): + + def move_relative( + self, + cancel: CancelHook, + block_cancellation: bool = False, + **kwargs: Mapping[str, int], + ): """Make a relative move. Keyword arguments should be axis names.""" ... - def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): + def move_absolute( + self, + cancel: CancelHook, + block_cancellation: bool = False, + **kwargs: Mapping[str, int], + ): """Make an absolute move. Keyword arguments should be axis names.""" ... - + def set_zero_position(self): """Make the current position zero in all axes - + This action does not move the stage, but resets the position to zero. It is intended for use after manually or automatically recentring the stage. @@ -53,12 +64,13 @@ class StageProtocol(Protocol): class BaseStage(Thing): """A base stage class for OpenFlexure translation stages - + This can't be used directly but should reduce boilerplate code when implementing new stages. A minimal working stage must implement `move_relative` and `move_absolute` actions, which update the `position` property on completion, and provide `set_zero_position`. """ + _axis_names = ("x", "y", "z") @thing_property @@ -85,9 +97,7 @@ class BaseStage(Thing): @property def thing_state(self): """Summary metadata describing the current state of the stage""" - return { - "position": self.position - } + return {"position": self.position} class StageStub(BaseStage): @@ -98,20 +108,31 @@ class StageStub(BaseStage): methods/properties are not decorated as Affordances. This stub class is a workaround for that limitation, and should not be used directly. """ + @thing_action - def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): + def move_relative( + self, + cancel: CancelHook, + block_cancellation: bool = False, + **kwargs: Mapping[str, int], + ): """Make a relative move. Keyword arguments should be axis names.""" raise NotImplementedError("StageStub should not be used directly") @thing_action - def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): + def move_absolute( + self, + cancel: CancelHook, + block_cancellation: bool = False, + **kwargs: Mapping[str, int], + ): """Make an absolute move. Keyword arguments should be axis names.""" raise NotImplementedError("StageStub should not be used directly") @thing_action def set_zero_position(self): """Make the current position zero in all axes - + This action does not move the stage, but resets the position to zero. It is intended for use after manually or automatically recentring the stage. diff --git a/src/openflexure_microscope_server/things/stage/dummy.py b/src/openflexure_microscope_server/things/stage/dummy.py index 907ebb16..2608da7d 100644 --- a/src/openflexure_microscope_server/things/stage/dummy.py +++ b/src/openflexure_microscope_server/things/stage/dummy.py @@ -1,6 +1,9 @@ from __future__ import annotations from labthings_fastapi.decorators import thing_action -from labthings_fastapi.dependencies.invocation import CancelHook, InvocationCancelledError +from labthings_fastapi.dependencies.invocation import ( + CancelHook, + InvocationCancelledError, +) from collections.abc import Mapping import time @@ -9,11 +12,15 @@ from . import BaseStage class DummyStage(BaseStage): """A dummy stage for testing purposes - + This stage should work similarly to a Sangaboard stage, but without any hardware attached. """ + def __init__(self, step_time: float = 0.001, **kwargs): + super().__init__(**kwargs) + self.step_time = step_time + def __enter__(self): self.instantaneous_position = self.position @@ -21,13 +28,18 @@ class DummyStage(BaseStage): pass @thing_action - def move_relative(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): + def move_relative( + self, + cancel: CancelHook, + block_cancellation: bool = False, + **kwargs: Mapping[str, int], + ): """Make a relative move. Keyword arguments should be axis names.""" displacement = [kwargs.get(k, 0) for k in self.axis_names] self.moving = True try: fraction_complete = 0.0 - dt = 0.001 + dt = self.step_time max_displacement = max(abs(v) for v in displacement) start_time = time.time() while time.time() - start_time < dt * max_displacement: @@ -47,7 +59,7 @@ class DummyStage(BaseStage): # and to mark the invocation as "cancelled" rather than stopped. raise e finally: - self.moving=False + self.moving = False self.position = { k: self.position[k] + int(fraction_complete * v) for k, v in zip(self.axis_names, displacement) @@ -55,19 +67,26 @@ class DummyStage(BaseStage): self.instantaneous_position = self.position @thing_action - def move_absolute(self, cancel: CancelHook, block_cancellation: bool=False, **kwargs: Mapping[str, int]): + def move_absolute( + self, + cancel: CancelHook, + block_cancellation: bool = False, + **kwargs: Mapping[str, int], + ): """Make an absolute move. Keyword arguments should be axis names.""" displacement = { - k: int(v) - self.position[k] + k: int(v) - self.position[k] for k, v in kwargs.items() if k in self.axis_names } - self.move_relative(cancel, block_cancellation=block_cancellation, **displacement) - + self.move_relative( + cancel, block_cancellation=block_cancellation, **displacement + ) + @thing_action def set_zero_position(self): """Make the current position zero in all axes - + This action does not move the stage, but resets the position to zero. It is intended for use after manually or automatically recentring the stage. diff --git a/src/openflexure_microscope_server/things/stitching.py b/src/openflexure_microscope_server/things/stitching.py index 03b421c3..75979457 100644 --- a/src/openflexure_microscope_server/things/stitching.py +++ b/src/openflexure_microscope_server/things/stitching.py @@ -21,8 +21,10 @@ class Stitcher(Thing): self._script = path_to_openflexure_stitch def run_subprocess( - self, logger: InvocationLogger, cmd: list[str], - ) -> CompletedProcess: + self, + logger: InvocationLogger, + cmd: list[str], + ) -> CompletedProcess: """Run a subprocess and log any output""" logger.info(f"Running command in subprocess: `{' '.join(cmd)}") output = run(cmd, stdout=PIPE, stderr=PIPE, universal_newlines=True) @@ -31,19 +33,26 @@ class Stitcher(Thing): logger.info(pipe) output.check_returncode() return output - - def images_folder(self, smart_scan: SmartScanDep, scan_name: Optional[str]=None) -> str: + + def images_folder( + self, smart_scan: SmartScanDep, scan_name: Optional[str] = None + ) -> str: scan_folder = smart_scan.scan_folder_path(scan_name=scan_name) return os.path.join(scan_folder, "images") - + @staticmethod - def output_up_to_date(folder: str, output_filename: str, image_prefix: str="image", image_suffix: str=".jpg") -> bool: + def output_up_to_date( + folder: str, + output_filename: str, + image_prefix: str = "image", + image_suffix: str = ".jpg", + ) -> bool: """Check if any of the images in a folder are newer than a file - - If there are no images (files with the prefix and suffix) newer than the + + If there are no images (files with the prefix and suffix) newer than the `output_filename`, we return `True`, i.e. the output is up to date. If any image in the folder is newer, we return `False`. - + This is not flawless logic - if an update process is slow, images might be saved between starting that process and saving the output. Consequently, a `True` from this function does not guarantee the output is up to date. @@ -61,24 +70,48 @@ class Stitcher(Thing): return True @thing_action - def stitch_scan_from_stage(self, logger: InvocationLogger, smart_scan: SmartScanDep, scan_name: Optional[str]=None, downsample: float=1.0) -> JPEGBlob: + def stitch_scan_from_stage( + self, + logger: InvocationLogger, + smart_scan: SmartScanDep, + scan_name: Optional[str] = None, + downsample: float = 1.0, + ) -> JPEGBlob: """Generate a stitched image based on stage position metadata""" output_fname = "stitched_from_stage.jpg" images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name) if self.output_up_to_date(images_folder, output_fname): logger.info(f"No images are newer than {output_fname}, skipping.") else: - self.run_subprocess(logger, [self._script, "--stitching_mode", "only_stage_stitch", images_folder]) + self.run_subprocess( + logger, + [self._script, "--stitching_mode", "only_stage_stitch", images_folder], + ) return JPEGBlob.from_file(os.path.join(images_folder, output_fname)) - - @thing_action - def update_scan_correlations(self, logger: InvocationLogger, smart_scan: SmartScanDep, scan_name: Optional[str]=None) -> None: - """Generate a stitched image based on stage position metadata""" - images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name) - self.run_subprocess(logger, [self._script, "--stitching_mode", "only_correlate", images_folder]) @thing_action - def stitch_scan(self, logger: InvocationLogger, smart_scan: SmartScanDep, scan_name: Optional[str]=None, downsample: float=1.0) -> None: + def update_scan_correlations( + self, + logger: InvocationLogger, + smart_scan: SmartScanDep, + scan_name: Optional[str] = None, + ) -> None: """Generate a stitched image based on stage position metadata""" images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name) - self.run_subprocess(logger, [self._script, "--stitching_mode", "all", images_folder]) + self.run_subprocess( + logger, [self._script, "--stitching_mode", "only_correlate", images_folder] + ) + + @thing_action + def stitch_scan( + self, + logger: InvocationLogger, + smart_scan: SmartScanDep, + scan_name: Optional[str] = None, + downsample: float = 1.0, + ) -> None: + """Generate a stitched image based on stage position metadata""" + images_folder = self.images_folder(smart_scan=smart_scan, scan_name=scan_name) + self.run_subprocess( + logger, [self._script, "--stitching_mode", "all", images_folder] + ) diff --git a/src/openflexure_microscope_server/things/system_control.py b/src/openflexure_microscope_server/things/system_control.py index e0e157a6..0b169c36 100644 --- a/src/openflexure_microscope_server/things/system_control.py +++ b/src/openflexure_microscope_server/things/system_control.py @@ -3,12 +3,14 @@ OpenFlexure Microscope system control Thing This module defines a Thing that can shut down or restart the host computer. """ + import subprocess import os from labthings_fastapi.thing import Thing from labthings_fastapi.decorators import thing_action, thing_property from pydantic import BaseModel + class CommandOutput(BaseModel): output: str error: str @@ -16,7 +18,7 @@ class CommandOutput(BaseModel): class SystemControlThing(Thing): """ - Attempt to shutdown the device + Attempt to shutdown the device """ @thing_action @@ -39,7 +41,7 @@ class SystemControlThing(Thing): Checks if we are running on a Raspberry Pi. """ return os.path.exists("/usr/bin/raspi-config") - + @thing_action def reboot(self) -> CommandOutput: """Attempt to reboot the device""" diff --git a/src/openflexure_microscope_server/things/test.py b/src/openflexure_microscope_server/things/test.py index c6163681..318003ca 100644 --- a/src/openflexure_microscope_server/things/test.py +++ b/src/openflexure_microscope_server/things/test.py @@ -3,6 +3,7 @@ OpenFlexure Microscope API test Thing This Thing is intended only for use testing out the API and client(s). """ + from labthings_fastapi.thing import Thing from labthings_fastapi.decorators import thing_action, thing_property from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger diff --git a/tests/test_dummy_server.py b/tests/test_dummy_server.py index db7fd89e..23d139e8 100644 --- a/tests/test_dummy_server.py +++ b/tests/test_dummy_server.py @@ -1,22 +1,98 @@ +import json +import os import tempfile -from fastapi import Depends, FastAPI from fastapi.testclient import TestClient - from labthings_fastapi.client import ThingClient +from PIL import Image +import numpy as np +import piexif +import pytest from openflexure_microscope_server.server import ThingServer from openflexure_microscope_server.things.camera.simulation import SimulatedCamera from openflexure_microscope_server.things.stage.dummy import DummyStage from openflexure_microscope_server.things.autofocus import AutofocusThing +from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper +from openflexure_microscope_server.things import camera_stage_mapping -temp_folder = tempfile.TemporaryDirectory() -server = ThingServer(temp_folder.name) -server.add_thing(SimulatedCamera(), "/camera/") -server.add_thing(DummyStage(), "/stage/") -server.add_thing(AutofocusThing(), "/autofocus/") +camera_stage_mapping.DEFAULT_SETTLING_TIME = 0 # skip the settling time for tests -def test_autofocus(): - with TestClient(server.app) as client: - autofocus = ThingClient.from_url("/autofocus/", client) - _ = autofocus.fast_autofocus() + +@pytest.fixture +def thing_server(): + temp_folder = tempfile.TemporaryDirectory() + server = ThingServer(settings_folder=temp_folder.name) + server.add_thing( + SimulatedCamera( + shape=(240, 320, 3), canvas_shape=(960, 1240, 3), frame_interval=0.01 + ), + "/camera/", + ) + server.add_thing(DummyStage(step_time=0.000001), "/stage/") + server.add_thing(AutofocusThing(), "/autofocus/") + server.add_thing(CameraStageMapper(), "/camera_stage_mapping/") + assert os.path.exists(os.path.join(temp_folder.name, "camera/")) + # NB yield is important: otherwise, the temp folder gets deleted before the test runs + yield server + + +@pytest.fixture +def client(thing_server): + with TestClient(thing_server.app) as client: + yield client + + +@pytest.fixture +def slower_client(thing_server): + thing_server.things["/stage/"].step_time = 0.0001 + with TestClient(thing_server.app) as client: + yield client + + +def test_autofocus(slower_client): + client = slower_client + autofocus = ThingClient.from_url("/autofocus/", client) + _ = autofocus.fast_autofocus() + + +def test_grab_jpeg(client): + camera = ThingClient.from_url("/camera/", client) + blob = camera.grab_jpeg() + _image = Image.open(blob.open()) + + +def test_capture_jpeg_metadata(client): + camera = ThingClient.from_url("/camera/", client) + blob = camera.capture_jpeg() + image = Image.open(blob.open()) + exif_dict = piexif.load(image.info["exif"]) + encoded_metadata = exif_dict["Exif"][piexif.ExifIFD.UserComment] + metadata = json.loads(encoded_metadata) + assert "position" in metadata["/stage/"] + + +def test_stage(client): + stage = ThingClient.from_url("/stage/", client) + start = stage.position + move = {"x": 1, "y": 2, "z": 3} + stage.move_relative(**move) + pos = stage.position + for s, m, p in zip(start.values(), move.values(), pos.values()): + assert s + m == p + stage.move_relative(**{k: -v for k, v in move.items()}) + pos = stage.position + for s, p in zip(start.values(), pos.values()): + assert s == p + + +def test_capture_array(client): + camera = ThingClient.from_url("/camera/", client) + array = np.asarray(camera.capture_array()) + assert array.shape == (240, 320, 3) + + +# Currently this fails, not yet sure why. +def test_camera_stage_mapping_calibration(client): + camera_stage_mapping = ThingClient.from_url("/camera_stage_mapping/", client) + camera_stage_mapping.calibrate_xy() diff --git a/webapp/README.md b/webapp/README.md index d6fa9153..84fd69c2 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -11,7 +11,7 @@ ## Creating releases -* JS client is coupled to the API, and so are no longer separately built and deployed. +* JS client is coupled to the API, and is built and distributed with the server. * See [openflexure-microscope-server/README.md](https://gitlab.com/openflexure/openflexure-microscope-server/-/blob/master/README.md) for details on creating new releases ## Installing